428 lines
13 KiB
Go
428 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/event"
|
|
)
|
|
|
|
const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
|
|
|
func TestWorkspaceRelativeIn(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
if rel, ok := workspaceRelativeIn(filepath.Join(root, "sub", "file.go"), root); !ok || rel != "sub/file.go" {
|
|
t.Fatalf("in-tree = (%q, %v), want (sub/file.go, true)", rel, ok)
|
|
}
|
|
if _, ok := workspaceRelativeIn(filepath.Join(filepath.Dir(root), "sibling.txt"), root); ok {
|
|
t.Fatal("a path above the workspace must not resolve as in-tree")
|
|
}
|
|
}
|
|
|
|
func TestIsImageExt(t *testing.T) {
|
|
for _, p := range []string{"a.png", "A.PNG", "b.jpeg", "c.webp"} {
|
|
if !isImageExt(p) {
|
|
t.Errorf("%q should be an image extension", p)
|
|
}
|
|
}
|
|
for _, p := range []string{"notes.pdf", "main.go", "noext"} {
|
|
if isImageExt(p) {
|
|
t.Errorf("%q should not be an image extension", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
launchRoot := t.TempDir()
|
|
projectRoot := t.TempDir()
|
|
if err := os.Chdir(projectRoot); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
projectRoot, _ = os.Getwd()
|
|
if err := os.Chdir(launchRoot); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"project": {ID: "project", WorkspaceRoot: projectRoot},
|
|
},
|
|
activeTabID: "project",
|
|
}
|
|
|
|
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
|
|
if err != nil {
|
|
t.Fatalf("SavePastedImage: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got))); err != nil {
|
|
t.Fatalf("pasted image should be saved under active workspace: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got))); !os.IsNotExist(err) {
|
|
t.Fatalf("pasted image should not be saved under launch root, stat err=%v", err)
|
|
}
|
|
preview, err := app.AttachmentDataURL(got)
|
|
if err != nil {
|
|
t.Fatalf("AttachmentDataURL: %v", err)
|
|
}
|
|
if !strings.HasPrefix(preview, "data:image/png;base64,") {
|
|
t.Fatalf("preview = %q, want png data URL", preview)
|
|
}
|
|
}
|
|
|
|
func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
launchRoot := t.TempDir()
|
|
projectA := t.TempDir()
|
|
projectB := t.TempDir()
|
|
if err := addProject(projectA, "Project A"); err != nil {
|
|
t.Fatalf("add project A: %v", err)
|
|
}
|
|
if err := addProject(projectB, "Project B"); err != nil {
|
|
t.Fatalf("add project B: %v", err)
|
|
}
|
|
sessionDirA := desktopSessionDir(projectA)
|
|
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
|
|
t.Fatalf("mkdir project A sessions: %v", err)
|
|
}
|
|
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_attach_owner", "Attach owner", projectA, "project A prompt", time.Now())
|
|
if err := os.Chdir(launchRoot); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectB, SessionPath: sessionPathA},
|
|
},
|
|
activeTabID: "project",
|
|
}
|
|
|
|
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
|
|
if err != nil {
|
|
t.Fatalf("SavePastedImage: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
|
|
t.Fatalf("pasted image should be saved under pinned session owner project A: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
|
|
t.Fatalf("pasted image should not be saved under stale project B, stat err=%v", err)
|
|
}
|
|
if gotRoot := normalizeProjectRoot(app.tabs["project"].WorkspaceRoot); gotRoot != normalizeProjectRoot(projectA) {
|
|
t.Fatalf("tab workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
|
|
}
|
|
}
|
|
|
|
func TestAttachDroppedUsesActiveWorkspaceRoot(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
launchRoot := t.TempDir()
|
|
projectRoot := t.TempDir()
|
|
if err := os.Chdir(projectRoot); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
projectRoot, _ = os.Getwd()
|
|
if err := os.Chdir(launchRoot); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"project": {ID: "project", WorkspaceRoot: projectRoot},
|
|
},
|
|
activeTabID: "project",
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(projectRoot, "sub"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target := filepath.Join(projectRoot, "sub", "notes.txt")
|
|
if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := app.AttachDropped(target)
|
|
if err != nil {
|
|
t.Fatalf("AttachDropped: %v", err)
|
|
}
|
|
if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
|
|
t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
|
|
}
|
|
}
|
|
|
|
func TestAttachDroppedImageUsesActiveWorkspaceRoot(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
launchRoot := t.TempDir()
|
|
projectRoot := t.TempDir()
|
|
if err := os.Chdir(launchRoot); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"project": {ID: "project", WorkspaceRoot: projectRoot},
|
|
},
|
|
activeTabID: "project",
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(desktopTinyPNG)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
outside := filepath.Join(t.TempDir(), "shot.png")
|
|
if err := os.WriteFile(outside, raw, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := app.AttachDropped(outside)
|
|
if err != nil {
|
|
t.Fatalf("AttachDropped: %v", err)
|
|
}
|
|
if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
|
|
t.Fatalf("got %+v, want png attachment", got)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got.Path))); err != nil {
|
|
t.Fatalf("dropped image should be saved under active workspace: %v", err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got.Path))); !os.IsNotExist(err) {
|
|
t.Fatalf("dropped image should not be saved under launch root, stat err=%v", err)
|
|
}
|
|
if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
|
|
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
|
|
}
|
|
}
|
|
|
|
func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
root := t.TempDir()
|
|
if err := os.Chdir(root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cwd, _ := os.Getwd()
|
|
if err := os.MkdirAll(filepath.Join(cwd, "sub"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target := filepath.Join(cwd, "sub", "notes.txt")
|
|
if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := (&App{}).AttachDropped(target)
|
|
if err != nil {
|
|
t.Fatalf("AttachDropped: %v", err)
|
|
}
|
|
if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
|
|
t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
|
|
}
|
|
}
|
|
|
|
func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
outside := filepath.Join(t.TempDir(), "report.pdf")
|
|
if err := os.WriteFile(outside, []byte("%PDF body"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
root := t.TempDir()
|
|
if err := os.Chdir(root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := (&App{}).AttachDropped(outside)
|
|
if err != nil {
|
|
t.Fatalf("AttachDropped: %v", err)
|
|
}
|
|
if got.Kind != "attachment" || !strings.HasPrefix(got.Path, ".reasonix/attachments/") || !strings.HasSuffix(got.Path, ".pdf") {
|
|
t.Fatalf("got %+v, want copied pdf attachment", got)
|
|
}
|
|
}
|
|
|
|
func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
root := t.TempDir()
|
|
if err := os.Chdir(root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cwd, _ := os.Getwd()
|
|
png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
|
|
if err := os.WriteFile(filepath.Join(cwd, "shot.png"), png, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := (&App{}).AttachDropped(filepath.Join(cwd, "shot.png"))
|
|
if err != nil {
|
|
t.Fatalf("AttachDropped: %v", err)
|
|
}
|
|
if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
|
|
t.Fatalf("got %+v, want png attachment", got)
|
|
}
|
|
if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
|
|
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
|
|
}
|
|
}
|
|
|
|
func TestAttachDroppedOutsideWorkspaceDirRegistersWorkspaceRef(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
workspace := t.TempDir()
|
|
outside := filepath.Join(t.TempDir(), "Folder With Spaces")
|
|
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expectedOutside := outside
|
|
if resolved, err := filepath.EvalSymlinks(outside); err == nil {
|
|
expectedOutside = resolved
|
|
}
|
|
expectedDisplayPath := filepath.ToSlash(expectedOutside)
|
|
if err := os.Chdir(workspace); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctrl := control.New(control.Options{WorkspaceRoot: workspace})
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
|
|
},
|
|
activeTabID: "project",
|
|
}
|
|
|
|
got, err := app.AttachDropped(outside)
|
|
if err != nil {
|
|
t.Fatalf("AttachDropped: %v", err)
|
|
}
|
|
if got.Kind != "workspace" || !got.IsDir {
|
|
t.Fatalf("got %+v, want workspace directory ref", got)
|
|
}
|
|
if !strings.HasPrefix(got.Path, "__reasonix_external_folder/") || strings.ContainsAny(got.Path, " \t\r\n") {
|
|
t.Fatalf("external folder path token = %q, want whitespace-free external token", got.Path)
|
|
}
|
|
if got.DisplayPath != expectedDisplayPath {
|
|
t.Fatalf("display path = %q, want %q", got.DisplayPath, expectedDisplayPath)
|
|
}
|
|
|
|
block, errs := ctrl.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
|
|
if len(errs) != 0 {
|
|
t.Fatalf("ResolveScopedRefs errors = %v", errs)
|
|
}
|
|
if !strings.Contains(block, `<dir path="`+expectedDisplayPath+`">`) ||
|
|
!strings.Contains(block, "sub/") ||
|
|
!strings.Contains(block, "sub/notes.txt") {
|
|
t.Fatalf("external dropped folder should resolve as dir context:\n%s", block)
|
|
}
|
|
}
|
|
|
|
func TestAttachDroppedOutsideWorkspaceDirRegistersAfterPinnedOwnerRebuild(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
|
|
cfg := config.Default()
|
|
cfg.DefaultModel = "test/test-model"
|
|
cfg.Desktop.ProviderAccess = []string{"test"}
|
|
cfg.Providers = []config.ProviderEntry{
|
|
{Name: "test", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
|
|
}
|
|
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
|
t.Fatalf("save config: %v", err)
|
|
}
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
projectA := t.TempDir()
|
|
projectB := t.TempDir()
|
|
outside := filepath.Join(t.TempDir(), "External")
|
|
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := addProject(projectA, "Project A"); err != nil {
|
|
t.Fatalf("add project A: %v", err)
|
|
}
|
|
if err := addProject(projectB, "Project B"); err != nil {
|
|
t.Fatalf("add project B: %v", err)
|
|
}
|
|
sessionDirA := desktopSessionDir(projectA)
|
|
sessionDirB := desktopSessionDir(projectB)
|
|
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
|
|
t.Fatalf("mkdir project A sessions: %v", err)
|
|
}
|
|
if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
|
|
t.Fatalf("mkdir project B sessions: %v", err)
|
|
}
|
|
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_external_ref", "External ref", projectA, "project A prompt", time.Now())
|
|
sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
|
|
oldCtrl := control.New(control.Options{
|
|
SessionDir: sessionDirB,
|
|
SessionPath: sessionPathB,
|
|
WorkspaceRoot: projectB,
|
|
Sink: event.Discard,
|
|
})
|
|
app := NewApp()
|
|
app.readyHook = func() {}
|
|
tab := &WorkspaceTab{
|
|
ID: "project",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectB,
|
|
TopicID: "topic_external_ref",
|
|
TopicTitle: "External ref",
|
|
SessionPath: sessionPathA,
|
|
Ready: true,
|
|
model: "test/test-model",
|
|
Ctrl: oldCtrl,
|
|
sink: &tabEventSink{tabID: "project", app: app},
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
t.Cleanup(func() {
|
|
if tab.Ctrl != nil {
|
|
tab.Ctrl.Close()
|
|
}
|
|
})
|
|
|
|
got, err := app.AttachDropped(outside)
|
|
if err != nil {
|
|
t.Fatalf("AttachDropped: %v", err)
|
|
}
|
|
if tab.Ctrl == oldCtrl {
|
|
t.Fatal("stale controller was reused for external folder ref")
|
|
}
|
|
if gotRoot := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); gotRoot != normalizeProjectRoot(projectA) {
|
|
t.Fatalf("controller workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
|
|
}
|
|
resolver, ok := tab.Ctrl.(interface {
|
|
ResolveScopedRefs(context.Context, string) (string, []string)
|
|
})
|
|
if !ok {
|
|
t.Fatalf("rebuilt controller does not resolve scoped refs: %T", tab.Ctrl)
|
|
}
|
|
block, errs := resolver.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
|
|
if len(errs) != 0 {
|
|
t.Fatalf("ResolveScopedRefs errors = %v", errs)
|
|
}
|
|
if !strings.Contains(block, "sub/notes.txt") {
|
|
t.Fatalf("external dropped folder should resolve on rebuilt controller:\n%s", block)
|
|
}
|
|
}
|