1654 lines
47 KiB
Go
1654 lines
47 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"mime"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/text/encoding/simplifiedchinese"
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/checkpoint"
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/control"
|
|
)
|
|
|
|
// --- workspaceStatePath ---
|
|
|
|
func TestWorkspaceStatePath(t *testing.T) {
|
|
// workspaceStatePath depends on config.MemoryUserDir() which needs a
|
|
// config dir. We just verify it returns a consistent path.
|
|
p1 := workspaceStatePath()
|
|
p2 := workspaceStatePath()
|
|
if p1 != p2 {
|
|
t.Errorf("workspaceStatePath not stable: %q vs %q", p1, p2)
|
|
}
|
|
if p1 != "" && filepath.Base(p1) != "desktop-workspace" {
|
|
t.Errorf("workspaceStatePath should end with desktop-workspace, got %q", p1)
|
|
}
|
|
}
|
|
|
|
// --- saveWorkspace / loadWorkspace round-trip ---
|
|
|
|
func TestSaveLoadWorkspaceRoundTrip(t *testing.T) {
|
|
// workspaceStatePath() resolves via os.UserConfigDir() (HOME on unix,
|
|
// %AppData% on Windows); isolate both so the round-trip exercises real
|
|
// persistence instead of no-opping or leaking into the dev config dir.
|
|
isolateDesktopUserDirs(t)
|
|
if workspaceStatePath() == "" {
|
|
t.Fatal("workspaceStatePath() is empty after isolating the user config dir")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
saveWorkspace(dir)
|
|
if got := loadWorkspace(); got != dir {
|
|
t.Errorf("loadWorkspace = %q, want %q", got, dir)
|
|
}
|
|
}
|
|
|
|
func TestSaveWorkspaceOnlyRemembersLastWorkspace(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
first := t.TempDir()
|
|
second := t.TempDir()
|
|
|
|
saveWorkspace(first)
|
|
saveWorkspace(second)
|
|
saveWorkspace(first)
|
|
|
|
if got := loadWorkspace(); got != first {
|
|
t.Fatalf("loadWorkspace = %q, want %q", got, first)
|
|
}
|
|
if got := loadWorkspaces(); len(got) != 0 {
|
|
t.Fatalf("saveWorkspace should not maintain legacy workspace list, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestDesktopMCPMigrationRootsIncludesLegacyWorkspaces(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
active := t.TempDir()
|
|
legacy := t.TempDir()
|
|
tabRoot := t.TempDir()
|
|
projectRoot := t.TempDir()
|
|
|
|
saveWorkspace(active)
|
|
if err := os.MkdirAll(filepath.Dir(workspaceListPath()), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := json.Marshal([]string{legacy, active, legacy})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(workspaceListPath(), b, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: projectRoot}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
roots := desktopMCPMigrationRoots(desktopTabsFile{
|
|
Tabs: []desktopTabEntry{{Scope: "project", WorkspaceRoot: tabRoot}},
|
|
})
|
|
want := []string{
|
|
normalizeProjectRoot(active),
|
|
normalizeProjectRoot(legacy),
|
|
normalizeProjectRoot(tabRoot),
|
|
normalizeProjectRoot(projectRoot),
|
|
}
|
|
if len(roots) != len(want) {
|
|
t.Fatalf("roots len = %d, want %d: %+v", len(roots), len(want), roots)
|
|
}
|
|
for i, root := range want {
|
|
if roots[i] != root {
|
|
t.Fatalf("roots[%d] = %q, want %q; roots=%+v", i, roots[i], root, roots)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRecoverLegacyProjectSidebarRootsPreservesUpgradeProjects(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
existing := t.TempDir()
|
|
active := t.TempDir()
|
|
legacy := t.TempDir()
|
|
tabRoot := t.TempDir()
|
|
missing := filepath.Join(t.TempDir(), "missing")
|
|
|
|
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: existing, Title: "Existing"}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
saveWorkspace(active)
|
|
if err := os.MkdirAll(filepath.Dir(workspaceListPath()), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := json.Marshal([]string{legacy, active, missing, legacy})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(workspaceListPath(), b, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tabs := desktopTabsFile{
|
|
Tabs: []desktopTabEntry{
|
|
{Scope: "project", WorkspaceRoot: tabRoot},
|
|
{Scope: "project", WorkspaceRoot: missing},
|
|
{Scope: "global"},
|
|
},
|
|
}
|
|
changed, err := recoverLegacyProjectSidebarRoots(tabs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !changed {
|
|
t.Fatal("recoverLegacyProjectSidebarRoots should add missing legacy projects")
|
|
}
|
|
|
|
projects := loadProjectsFile().Projects
|
|
want := []string{
|
|
normalizeProjectRoot(existing),
|
|
normalizeProjectRoot(active),
|
|
normalizeProjectRoot(legacy),
|
|
normalizeProjectRoot(tabRoot),
|
|
}
|
|
if len(projects) != len(want) {
|
|
t.Fatalf("project count = %d, want %d: %+v", len(projects), len(want), projects)
|
|
}
|
|
for i, root := range want {
|
|
if projects[i].Root != root {
|
|
t.Fatalf("projects[%d].Root = %q, want %q; projects=%+v", i, projects[i].Root, root, projects)
|
|
}
|
|
}
|
|
if _, err := os.Stat(filepath.Join(desktopConfigDir(), legacyProjectSidebarRecoveryMarker)); err != nil {
|
|
t.Fatalf("recovery marker was not written: %v", err)
|
|
}
|
|
|
|
if err := removeProject(legacy); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
changed, err = recoverLegacyProjectSidebarRoots(tabs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if changed {
|
|
t.Fatal("recovery should be one-shot after the marker is written")
|
|
}
|
|
for _, project := range loadProjectsFile().Projects {
|
|
if project.Root == normalizeProjectRoot(legacy) {
|
|
t.Fatalf("removed legacy project was restored after marker: %+v", loadProjectsFile().Projects)
|
|
}
|
|
if project.Root == normalizeProjectRoot(missing) {
|
|
t.Fatalf("missing legacy project should not be restored: %+v", loadProjectsFile().Projects)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProjectFileUpdatesSerializeReadModifyWrite(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
active := t.TempDir()
|
|
added := t.TempDir()
|
|
|
|
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: active, Title: "Active"}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
entered := make(chan struct{})
|
|
release := make(chan struct{})
|
|
updateErr := make(chan error, 1)
|
|
go func() {
|
|
updateErr <- updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
|
close(entered)
|
|
<-release
|
|
for i, project := range f.Projects {
|
|
if project.Root == normalizeProjectRoot(active) {
|
|
f.Projects[i].Title = "Active edited"
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
})
|
|
}()
|
|
<-entered
|
|
|
|
addErr := make(chan error, 1)
|
|
go func() {
|
|
addErr <- addProject(added, "Added")
|
|
}()
|
|
select {
|
|
case err := <-addErr:
|
|
t.Fatalf("addProject completed while another project update was in progress: %v", err)
|
|
case <-time.After(10 * time.Millisecond):
|
|
}
|
|
close(release)
|
|
if err := <-updateErr; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := <-addErr; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
projects := loadProjectsFile().Projects
|
|
if len(projects) != 2 {
|
|
t.Fatalf("project count = %d, want 2: %+v", len(projects), projects)
|
|
}
|
|
if projects[0].Root != normalizeProjectRoot(active) || projects[0].Title != "Active edited" {
|
|
t.Fatalf("active project was not preserved with edited title: %+v", projects)
|
|
}
|
|
if projects[1].Root != normalizeProjectRoot(added) || projects[1].Title != "Added" {
|
|
t.Fatalf("concurrent project add was lost: %+v", projects)
|
|
}
|
|
|
|
if err := addProject(active, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
projects = loadProjectsFile().Projects
|
|
if len(projects) != 2 || projects[1].Root != normalizeProjectRoot(added) {
|
|
t.Fatalf("no-op addProject overwrote the added project: %+v", projects)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeProjectsFileMergesEquivalentProjectRoots(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
projectRoot := t.TempDir()
|
|
// A textually different spelling of the same folder. filepath.Join would
|
|
// clean the dot segment away and hand back the identical string, so build
|
|
// the spelling by hand.
|
|
equivalentRoot := projectRoot + string(filepath.Separator) + "."
|
|
|
|
f := normalizeProjectsFile(desktopProjectFile{
|
|
Projects: []desktopProject{
|
|
{Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}},
|
|
{Root: equivalentRoot, Color: "blue", Topics: []string{"topic_b"}, PinnedTopics: []string{"topic_b"}},
|
|
},
|
|
PinnedProjects: []string{equivalentRoot},
|
|
SidebarOrder: []string{equivalentRoot, projectRoot},
|
|
})
|
|
|
|
if len(f.Projects) != 1 {
|
|
t.Fatalf("projects = %+v, want one merged project", f.Projects)
|
|
}
|
|
if f.Projects[0].Root != normalizeProjectRoot(projectRoot) {
|
|
t.Fatalf("merged root = %q, want %q", f.Projects[0].Root, normalizeProjectRoot(projectRoot))
|
|
}
|
|
if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" {
|
|
t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0])
|
|
}
|
|
if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" {
|
|
t.Fatalf("merged topics = %v, want [topic_a topic_b]", got)
|
|
}
|
|
if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != f.Projects[0].Root {
|
|
t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, f.Projects[0].Root)
|
|
}
|
|
if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != f.Projects[0].Root {
|
|
t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, f.Projects[0].Root)
|
|
}
|
|
}
|
|
|
|
func TestSwitchWorkspaceReaddsRemovedProject(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
projectRoot := t.TempDir()
|
|
|
|
if err := addProject(projectRoot, "Project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := removeProject(projectRoot); err != nil {
|
|
t.Fatalf("remove project: %v", err)
|
|
}
|
|
if got := loadProjectsFile().Projects; len(got) != 0 {
|
|
t.Fatalf("projects after remove = %+v, want none", got)
|
|
}
|
|
|
|
app := NewApp()
|
|
installNoopRuntimeEvents(app)
|
|
if got, err := app.SwitchWorkspace(projectRoot + string(filepath.Separator) + "."); err != nil {
|
|
t.Fatalf("switch workspace: %v", err)
|
|
} else if got != normalizeProjectRoot(projectRoot) {
|
|
t.Fatalf("SwitchWorkspace root = %q, want %q", got, normalizeProjectRoot(projectRoot))
|
|
}
|
|
|
|
projects := loadProjectsFile().Projects
|
|
if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(projectRoot) {
|
|
t.Fatalf("projects after re-add = %+v, want %q", projects, normalizeProjectRoot(projectRoot))
|
|
}
|
|
if got := loadWorkspace(); got != normalizeProjectRoot(projectRoot) {
|
|
t.Fatalf("active workspace = %q, want %q", got, normalizeProjectRoot(projectRoot))
|
|
}
|
|
}
|
|
|
|
// flipPathASCIICase returns the path with the case of every ASCII letter
|
|
// swapped — on Windows an equivalent spelling of the same folder that
|
|
// normalizeProjectRoot cannot fold away.
|
|
func flipPathASCIICase(t *testing.T, path string) string {
|
|
t.Helper()
|
|
flipped := strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r >= 'a' && r <= 'z':
|
|
return r - 'a' + 'A'
|
|
case r >= 'A' && r <= 'Z':
|
|
return r - 'A' + 'a'
|
|
}
|
|
return r
|
|
}, path)
|
|
if flipped == path {
|
|
t.Skipf("path %q contains no ASCII letters to flip", path)
|
|
}
|
|
return flipped
|
|
}
|
|
|
|
func TestNormalizeProjectsFileFoldsRootCaseOnWindows(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
t.Skip("case-insensitive root matching only applies to Windows paths")
|
|
}
|
|
isolateDesktopUserDirs(t)
|
|
projectRoot := t.TempDir()
|
|
flipped := flipPathASCIICase(t, projectRoot)
|
|
|
|
f := normalizeProjectsFile(desktopProjectFile{
|
|
Projects: []desktopProject{
|
|
{Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}},
|
|
{Root: flipped, Color: "blue", Topics: []string{"topic_b"}},
|
|
},
|
|
PinnedProjects: []string{flipped},
|
|
SidebarOrder: []string{flipped, projectRoot},
|
|
})
|
|
|
|
if len(f.Projects) != 1 {
|
|
t.Fatalf("projects = %+v, want case-equivalent roots merged", f.Projects)
|
|
}
|
|
canonical := f.Projects[0].Root
|
|
if canonical != normalizeProjectRoot(projectRoot) {
|
|
t.Fatalf("merged root = %q, want first spelling %q", canonical, normalizeProjectRoot(projectRoot))
|
|
}
|
|
if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" {
|
|
t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0])
|
|
}
|
|
if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" {
|
|
t.Fatalf("merged topics = %v, want [topic_a topic_b]", got)
|
|
}
|
|
if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != canonical {
|
|
t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, canonical)
|
|
}
|
|
if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != canonical {
|
|
t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, canonical)
|
|
}
|
|
}
|
|
|
|
func TestProjectRootOpsFoldCaseOnWindows(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
t.Skip("case-insensitive root matching only applies to Windows paths")
|
|
}
|
|
isolateDesktopUserDirs(t)
|
|
projectRoot := t.TempDir()
|
|
flipped := flipPathASCIICase(t, projectRoot)
|
|
|
|
if err := addProject(projectRoot, "Project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := addProject(flipped, ""); err != nil {
|
|
t.Fatalf("re-add project under flipped case: %v", err)
|
|
}
|
|
projects := loadProjectsFile().Projects
|
|
if len(projects) != 1 {
|
|
t.Fatalf("projects = %+v, want re-add under equivalent spelling to update in place", projects)
|
|
}
|
|
if projects[0].Root != normalizeProjectRoot(flipped) {
|
|
t.Fatalf("root = %q, want self-healed to latest spelling %q", projects[0].Root, normalizeProjectRoot(flipped))
|
|
}
|
|
if projects[0].Title != "Project" {
|
|
t.Fatalf("title = %q, want preserved across re-add", projects[0].Title)
|
|
}
|
|
|
|
if err := prependTopicInProjectsFile(projectRoot, "topic_a", true); err != nil {
|
|
t.Fatalf("prepend topic: %v", err)
|
|
}
|
|
projects = loadProjectsFile().Projects
|
|
if len(projects) != 1 {
|
|
t.Fatalf("projects = %+v, want topic prepend to reuse the case-equivalent entry", projects)
|
|
}
|
|
if got := projects[0].Topics; len(got) != 1 || got[0] != "topic_a" {
|
|
t.Fatalf("topics = %v, want [topic_a] on the merged entry", got)
|
|
}
|
|
|
|
if err := removeProject(projectRoot); err != nil {
|
|
t.Fatalf("remove project via original spelling: %v", err)
|
|
}
|
|
if got := loadProjectsFile().Projects; len(got) != 0 {
|
|
t.Fatalf("projects after remove = %+v, want none", got)
|
|
}
|
|
}
|
|
|
|
func TestSyncTabWorkspaceRootSpellingsOnWindows(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
t.Skip("case-insensitive root matching only applies to Windows paths")
|
|
}
|
|
isolateDesktopUserDirs(t)
|
|
projectRoot := t.TempDir()
|
|
flipped := flipPathASCIICase(t, projectRoot)
|
|
|
|
if err := addProject(projectRoot, "Project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
|
|
app := NewApp()
|
|
installNoopRuntimeEvents(app)
|
|
app.tabs["tab_case"] = &WorkspaceTab{
|
|
ID: "tab_case",
|
|
Scope: "project",
|
|
WorkspaceRoot: normalizeProjectRoot(projectRoot),
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
app.tabOrder = []string{"tab_case"}
|
|
|
|
// Re-registering under the flipped spelling self-heals the registry root;
|
|
// open tabs must follow so the frontend keeps comparing one string form.
|
|
app.registerProjectRoot(flipped)
|
|
|
|
projects := loadProjectsFile().Projects
|
|
if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(flipped) {
|
|
t.Fatalf("registry projects = %+v, want single root %q", projects, normalizeProjectRoot(flipped))
|
|
}
|
|
if got := app.tabs["tab_case"].WorkspaceRoot; got != projects[0].Root {
|
|
t.Fatalf("tab root = %q, want registry spelling %q", got, projects[0].Root)
|
|
}
|
|
}
|
|
|
|
func TestFindTopicSessionAfterCaseFlippedReaddOnWindows(t *testing.T) {
|
|
if runtime.GOOS != "windows" {
|
|
t.Skip("case-insensitive root matching only applies to Windows paths")
|
|
}
|
|
isolateDesktopUserDirs(t)
|
|
projectRoot := t.TempDir()
|
|
flipped := flipPathASCIICase(t, projectRoot)
|
|
|
|
// Register under original spelling.
|
|
if err := addProject(projectRoot, "Project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
if err := prependTopicInProjectsFile(projectRoot, "topic_case", true); err != nil {
|
|
t.Fatalf("prepend topic: %v", err)
|
|
}
|
|
|
|
// Write a session file with the original root spelling in its meta.
|
|
sessionDir := desktopSessionDir(projectRoot)
|
|
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir session dir: %v", err)
|
|
}
|
|
sessionPath := filepath.Join(sessionDir, "topic-case.jsonl")
|
|
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
|
|
t.Fatalf("write session file: %v", err)
|
|
}
|
|
if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
|
|
TopicID: "topic_case",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}); err != nil {
|
|
t.Fatalf("save branch meta: %v", err)
|
|
}
|
|
|
|
// Re-add under the flipped-case spelling — simulates Windows Explorer
|
|
// or a different shell returning the same folder with different case.
|
|
app := NewApp()
|
|
installNoopRuntimeEvents(app)
|
|
app.registerProjectRoot(flipped)
|
|
|
|
// findTopicSessionForTarget must match the session whose meta carries
|
|
// the original case spelling against the registry's new (flipped) root.
|
|
path, _ := app.findTopicSessionForTarget("project", normalizeProjectRoot(flipped), "topic_case")
|
|
if path == "" {
|
|
t.Fatal("findTopicSessionForTarget returned empty path; session with old-case root should still match")
|
|
}
|
|
if path != sessionPath {
|
|
t.Fatalf("findTopicSessionForTarget = %q, want %q", path, sessionPath)
|
|
}
|
|
}
|
|
|
|
func TestDialogDefaultDirectoryFallsBackFromMissingWorkspace(t *testing.T) {
|
|
parent := t.TempDir()
|
|
missing := filepath.Join(parent, "deleted", "project")
|
|
|
|
if got := dialogDefaultDirectory(missing); got != parent {
|
|
t.Fatalf("dialogDefaultDirectory(%q) = %q, want %q", missing, got, parent)
|
|
}
|
|
}
|
|
|
|
func TestDialogDefaultDirectoryUsesFileParent(t *testing.T) {
|
|
dir := t.TempDir()
|
|
file := filepath.Join(dir, "reasonix.toml")
|
|
if err := os.WriteFile(file, []byte("default_model = \"x\"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if got := dialogDefaultDirectory(file); got != dir {
|
|
t.Fatalf("dialogDefaultDirectory(file) = %q, want %q", got, dir)
|
|
}
|
|
}
|
|
|
|
func TestDesktopSessionDirIsScopedByWorkspace(t *testing.T) {
|
|
t.Setenv("HOME", t.TempDir())
|
|
t.Setenv("USERPROFILE", t.TempDir())
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
|
|
rootA := filepath.Join(t.TempDir(), "project-a")
|
|
rootB := filepath.Join(t.TempDir(), "project-b")
|
|
if err := os.MkdirAll(rootA, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(rootB, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dirA := desktopSessionDir(rootA)
|
|
dirB := desktopSessionDir(rootB)
|
|
if dirA == "" || dirB == "" {
|
|
t.Fatalf("desktop session dirs should resolve: A=%q B=%q", dirA, dirB)
|
|
}
|
|
if dirA == dirB {
|
|
t.Fatalf("different workspaces must not share a desktop session dir: %q", dirA)
|
|
}
|
|
if dirA == config.SessionDir() || dirB == config.SessionDir() {
|
|
t.Fatalf("desktop workspace sessions should not use the global CLI session dir: A=%q B=%q global=%q", dirA, dirB, config.SessionDir())
|
|
}
|
|
wantPrefix := filepath.Join(config.MemoryUserDir(), "projects") + string(filepath.Separator)
|
|
if !strings.HasPrefix(dirA, wantPrefix) || filepath.Base(dirA) != "sessions" {
|
|
t.Fatalf("workspace session dir should live under the project state tree, got %q", dirA)
|
|
}
|
|
}
|
|
|
|
func BenchmarkDesktopSessionDir(b *testing.B) {
|
|
root := filepath.Join(b.TempDir(), "project")
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
b.ReportAllocs()
|
|
for i := 0; i < b.N; i++ {
|
|
if desktopSessionDir(root) == "" {
|
|
b.Fatal("empty session dir")
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- cwdWritable ---
|
|
|
|
func TestCwdWritable(t *testing.T) {
|
|
// In a normal test environment, cwd should be writable.
|
|
if !cwdWritable() {
|
|
t.Error("cwd should be writable in test environment")
|
|
}
|
|
}
|
|
|
|
func TestCwdWritableInTempDir(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
os.Chdir(dir)
|
|
if !cwdWritable() {
|
|
t.Error("temp dir should be writable")
|
|
}
|
|
}
|
|
|
|
func TestReadFileTrimsPartialUTF8RuneAtPreviewBoundary(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
prefix := strings.Repeat("a", filePreviewLimit-1)
|
|
if err := os.WriteFile("large.md", []byte(prefix+"你tail"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
preview := (&App{}).ReadFile("large.md")
|
|
if preview.Err != "" {
|
|
t.Fatalf("ReadFile err = %q", preview.Err)
|
|
}
|
|
if preview.Binary {
|
|
t.Fatal("ReadFile marked valid truncated UTF-8 text as binary")
|
|
}
|
|
if !preview.Truncated {
|
|
t.Fatal("ReadFile did not mark oversized file as truncated")
|
|
}
|
|
if preview.Body != prefix {
|
|
t.Fatalf("ReadFile body len = %d, want %d", len(preview.Body), len(prefix))
|
|
}
|
|
}
|
|
|
|
func TestReadFilePreviewBinaryClassification(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// NUL is the binary signal, matching the CLI read_file tool once GB18030
|
|
// decoding meant invalid UTF-8 alone no longer implies binary.
|
|
if err := os.WriteFile("binary.bin", append([]byte("data"), 0x00, 0x01, 0x02), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p := (&App{}).ReadFile("binary.bin"); !p.Binary {
|
|
t.Errorf("NUL-containing file should be binary, got Body=%q", p.Body)
|
|
}
|
|
|
|
// Invalid UTF-8 without a NUL is decoded leniently and shown as text, with
|
|
// U+FFFD where bytes don't map — not hidden behind a binary classification.
|
|
if err := os.WriteFile("invalid.txt", append([]byte("hello"), 0xff, 'x', 'y'), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := (&App{}).ReadFile("invalid.txt")
|
|
if p.Binary {
|
|
t.Error("invalid-but-NUL-free file should render as lossy text, not binary")
|
|
}
|
|
if !strings.ContainsRune(p.Body, '�') {
|
|
t.Errorf("lossy decode should mark undecodable bytes with U+FFFD, got %q", p.Body)
|
|
}
|
|
}
|
|
|
|
func TestReadFileMediaPreview(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
png := []byte("\x89PNG\r\n\x1a\npreview")
|
|
if err := os.WriteFile("shot.PNG", png, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var app App
|
|
image := app.ReadFile("shot.PNG")
|
|
if image.Err != "" {
|
|
t.Fatalf("ReadFile png err = %q", image.Err)
|
|
}
|
|
if image.Binary || image.Kind != "image" || image.Mime != "image/png" {
|
|
t.Fatalf("ReadFile png = %+v, want image preview", image)
|
|
}
|
|
if image.Body != "" {
|
|
t.Fatalf("media preview should have empty body, got %q", image.Body)
|
|
}
|
|
if !strings.HasPrefix(image.URL, "/__reasonix_workspace_media/") || !strings.HasSuffix(image.URL, "/shot.PNG") {
|
|
t.Fatalf("unexpected media URL: %q", image.URL)
|
|
}
|
|
|
|
if err := os.WriteFile("report.pdf", []byte("%PDF-1.4\npreview"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pdf := app.ReadFile("report.pdf")
|
|
if pdf.Err != "" {
|
|
t.Fatalf("ReadFile pdf err = %q", pdf.Err)
|
|
}
|
|
if pdf.Binary || pdf.Kind != "pdf" || pdf.Mime != "application/pdf" {
|
|
t.Fatalf("ReadFile pdf = %+v, want pdf preview", pdf)
|
|
}
|
|
if pdf.Body != "" {
|
|
t.Fatalf("media preview should have empty body, got %q", pdf.Body)
|
|
}
|
|
if !strings.HasPrefix(pdf.URL, "/__reasonix_workspace_media/") || !strings.HasSuffix(pdf.URL, "/report.pdf") {
|
|
t.Fatalf("unexpected media URL: %q", pdf.URL)
|
|
}
|
|
}
|
|
|
|
func TestMediaTokenHandlerServesFile(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
png := []byte("\x89PNG\r\n\x1a\npreview-data")
|
|
if err := os.WriteFile("shot.PNG", png, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
app := NewApp()
|
|
preview := app.ReadFile("shot.PNG")
|
|
if preview.URL == "" {
|
|
t.Fatal("expected URL in media preview")
|
|
}
|
|
|
|
mw := app.workspaceMediaMiddleware()
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("fallback handler should not be called for media URLs")
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, preview.URL, nil)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
|
|
t.Fatalf("expected Content-Type image/png, got %q", ct)
|
|
}
|
|
if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, "inline") {
|
|
t.Fatalf("expected inline Content-Disposition, got %q", cd)
|
|
}
|
|
if rec.Body.String() != string(png) {
|
|
t.Fatalf("body mismatch, got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestMediaTokenHandlerEscapedFilename(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
name := `weird "file" name.png`
|
|
rawURLChars := []string{" ", `"`}
|
|
if runtime.GOOS == "windows" {
|
|
name = "weird #file name.png"
|
|
rawURLChars = []string{" ", "#"}
|
|
}
|
|
if err := os.WriteFile(name, []byte("png"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
app := NewApp()
|
|
preview := app.ReadFile(name)
|
|
for _, raw := range rawURLChars {
|
|
if strings.Contains(preview.URL, raw) {
|
|
t.Fatalf("media URL should path-escape %q in filename, got %q", raw, preview.URL)
|
|
}
|
|
}
|
|
|
|
handler := app.workspaceMediaMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("fallback handler should not be called")
|
|
}))
|
|
req := httptest.NewRequest(http.MethodGet, preview.URL, nil)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
disposition, params, err := mime.ParseMediaType(rec.Header().Get("Content-Disposition"))
|
|
if err != nil {
|
|
t.Fatalf("Content-Disposition should parse: %v", err)
|
|
}
|
|
if disposition != "inline" || params["filename"] != name {
|
|
t.Fatalf("Content-Disposition = %q %#v, want inline filename %q", disposition, params, name)
|
|
}
|
|
}
|
|
|
|
func TestMediaTokenHandlerHead(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := os.WriteFile("doc.pdf", []byte("%PDF-test"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
app := NewApp()
|
|
preview := app.ReadFile("doc.pdf")
|
|
|
|
mw := app.workspaceMediaMiddleware()
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("fallback handler should not be called")
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodHead, preview.URL, nil)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/pdf" {
|
|
t.Fatalf("expected Content-Type application/pdf, got %q", ct)
|
|
}
|
|
}
|
|
|
|
func TestMediaTokenHandlerRangeRequest(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
data := []byte("0123456789ABCDEF")
|
|
if err := os.WriteFile("data.png", data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
app := NewApp()
|
|
preview := app.ReadFile("data.png")
|
|
|
|
mw := app.workspaceMediaMiddleware()
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("fallback should not be called")
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, preview.URL, nil)
|
|
req.Header.Set("Range", "bytes=0-4")
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusPartialContent {
|
|
t.Fatalf("expected 206, got %d", rec.Code)
|
|
}
|
|
if rec.Body.String() != "01234" {
|
|
t.Fatalf("expected '01234', got %q", rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestMediaTokenHandlerBadToken(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
app := NewApp()
|
|
mw := app.workspaceMediaMiddleware()
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/__reasonix_workspace_media/deadbeef/fake.png", nil)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404 for bad token, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestMediaTokenHandlerNonGetHead(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := os.WriteFile("test.png", []byte("png"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
app := NewApp()
|
|
preview := app.ReadFile("test.png")
|
|
|
|
mw := app.workspaceMediaMiddleware()
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
t.Error("fallback should not be called")
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodPost, preview.URL, nil)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected 405 for POST, got %d", rec.Code)
|
|
}
|
|
}
|
|
|
|
func TestMediaTokenHandlerPassesUnrelatedPaths(t *testing.T) {
|
|
app := NewApp()
|
|
mw := app.workspaceMediaMiddleware()
|
|
|
|
fallbackCalled := false
|
|
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fallbackCalled = true
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/index.html", nil)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
if !fallbackCalled {
|
|
t.Fatal("expected fallback handler for non-media path")
|
|
}
|
|
}
|
|
|
|
func TestMediaTokenMaxEviction(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := os.WriteFile("test.png", []byte("data"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
app := NewApp()
|
|
store := app.mediaTokens
|
|
|
|
// Fill beyond max to trigger eviction of oldest.
|
|
var oldestToken string
|
|
for i := 0; i < mediaTokenMax+1; i++ {
|
|
tok := store.create(dir+"/test.png", "test.png", "image/png", "image", 4, time.Time{})
|
|
if i == 0 {
|
|
oldestToken = tok
|
|
}
|
|
}
|
|
|
|
if store.get(oldestToken) != nil {
|
|
t.Fatal("oldest token should have been evicted")
|
|
}
|
|
if len(store.order) != mediaTokenMax {
|
|
t.Fatalf("expected %d tokens, got %d", mediaTokenMax, len(store.order))
|
|
}
|
|
}
|
|
|
|
func TestMediaTokenExpiry(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := os.WriteFile("test.png", []byte("data"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
app := NewApp()
|
|
store := app.mediaTokens
|
|
tok := store.create(dir+"/test.png", "test.png", "image/png", "image", 4, time.Time{})
|
|
|
|
// Force expiry by rolling back the clock on the entry.
|
|
store.mu.Lock()
|
|
store.byTok[tok].expiresAt = time.Now().Add(-1 * time.Second)
|
|
store.mu.Unlock()
|
|
|
|
if e := store.get(tok); e != nil {
|
|
t.Fatal("expired token should return nil")
|
|
}
|
|
next := store.create(dir+"/test.png", "test.png", "image/png", "image", 4, time.Time{})
|
|
if next == "" || store.get(next) == nil {
|
|
t.Fatal("store should create and read a fresh token after expired get cleanup")
|
|
}
|
|
}
|
|
|
|
func TestReadFileTextUnchanged(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := os.WriteFile("hello.txt", []byte("hello world"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
app := NewApp()
|
|
preview := app.ReadFile("hello.txt")
|
|
if preview.Err != "" {
|
|
t.Fatalf("ReadFile text err = %q", preview.Err)
|
|
}
|
|
if preview.Body != "hello world" {
|
|
t.Fatalf("expected text body, got %q", preview.Body)
|
|
}
|
|
if preview.Kind != "" || preview.Mime != "" || preview.URL != "" {
|
|
t.Fatalf("text preview should not have media fields")
|
|
}
|
|
}
|
|
|
|
func TestReadFileGB18030(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
gb, _ := simplifiedchinese.GB18030.NewEncoder().String("你好世界")
|
|
if err := os.WriteFile("gbk.txt", []byte(gb), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
preview := (&App{}).ReadFile("gbk.txt")
|
|
if preview.Err != "" {
|
|
t.Fatalf("ReadFile err = %q", preview.Err)
|
|
}
|
|
if preview.Binary {
|
|
t.Fatal("ReadFile should decode GB18030, not mark as binary")
|
|
}
|
|
if !strings.Contains(preview.Body, "你好世界") {
|
|
t.Errorf("expected decoded Chinese text, got %q", preview.Body)
|
|
}
|
|
}
|
|
|
|
// --- RemoveWorkspace cleanup of active pointer ---
|
|
|
|
func TestRemoveWorkspaceClearsActivePointerWhenRemovingCurrentWorkspace(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
if workspaceStatePath() == "" {
|
|
t.Fatal("workspaceStatePath() is empty after isolating")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
saveWorkspace(dir)
|
|
if got := loadWorkspace(); got != dir {
|
|
t.Fatalf("precondition: loadWorkspace = %q, want %q", got, dir)
|
|
}
|
|
|
|
// Simulate RemoveWorkspace's cleanup logic:
|
|
// When the removed workspace equals the active one, clearWorkspace should fire.
|
|
if loadWorkspace() == dir {
|
|
clearWorkspace()
|
|
}
|
|
|
|
if got := loadWorkspace(); got != "" {
|
|
t.Errorf("loadWorkspace = %q after clearWorkspace, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestRemoveWorkspaceFallsBackToRemainingProject(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
|
|
// Set up two projects and make the first one active.
|
|
first := t.TempDir()
|
|
second := t.TempDir()
|
|
saveWorkspace(first)
|
|
|
|
// Simulate: remove the active workspace, fall back to the other.
|
|
if loadWorkspace() == first {
|
|
// In the real code, loadProjectsFile() would return remaining projects.
|
|
// Here we simulate falling back to the second project.
|
|
saveWorkspace(second)
|
|
}
|
|
|
|
if got := loadWorkspace(); got != second {
|
|
t.Errorf("loadWorkspace = %q, want fallback to %q", got, second)
|
|
}
|
|
}
|
|
|
|
func TestClearWorkspace(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
if workspaceStatePath() == "" {
|
|
t.Fatal("workspaceStatePath() is empty after isolating")
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
saveWorkspace(dir)
|
|
if got := loadWorkspace(); got != dir {
|
|
t.Fatalf("precondition failed: loadWorkspace = %q, want %q", got, dir)
|
|
}
|
|
|
|
clearWorkspace()
|
|
if got := loadWorkspace(); got != "" {
|
|
t.Errorf("loadWorkspace after clearWorkspace = %q, want empty", got)
|
|
}
|
|
// Also verify the file is actually removed.
|
|
if _, err := os.Stat(workspaceStatePath()); !os.IsNotExist(err) {
|
|
t.Errorf("desktop-workspace file should be removed, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
// --- OpenProjectTab updates active workspace pointer ---
|
|
|
|
func TestOpenProjectTabUpdatesActiveWorkspacePointer(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
if workspaceStatePath() == "" {
|
|
t.Fatal("workspaceStatePath() is empty after isolating")
|
|
}
|
|
|
|
projectRoot := t.TempDir()
|
|
app := NewApp()
|
|
topic, err := app.CreateTopic("project", projectRoot, "")
|
|
if err != nil {
|
|
t.Fatalf("create topic: %v", err)
|
|
}
|
|
|
|
if _, err := app.OpenProjectTab(projectRoot, topic.ID); err != nil {
|
|
t.Fatalf("open project tab: %v", err)
|
|
}
|
|
|
|
if got := loadWorkspace(); got != projectRoot {
|
|
t.Errorf("loadWorkspace = %q after OpenProjectTab, want %q", got, projectRoot)
|
|
}
|
|
}
|
|
|
|
func TestWindowsOpenWorkspacePathAvoidsCmdShell(t *testing.T) {
|
|
src, err := os.ReadFile("open_workspace_windows.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body := string(src)
|
|
if !strings.Contains(body, "ShellExecute") {
|
|
t.Fatal("Windows workspace opener should use ShellExecute")
|
|
}
|
|
if strings.Contains(body, "cmd") || strings.Contains(body, "/c") {
|
|
t.Fatal("Windows workspace opener must not route paths through cmd.exe")
|
|
}
|
|
}
|
|
|
|
func TestParseGitStatusPorcelainZ(t *testing.T) {
|
|
raw := []byte(" M changed.go\x00?? new.txt\x00R renamed.go\x00old.go\x00")
|
|
got := parseGitStatusPorcelainZ(raw)
|
|
if len(got) != 3 {
|
|
t.Fatalf("entries = %d, want 3: %+v", len(got), got)
|
|
}
|
|
if got[0].Path != "changed.go" || got[0].Status != "M" {
|
|
t.Fatalf("modified entry = %+v", got[0])
|
|
}
|
|
if got[1].Path != "new.txt" || got[1].Status != "??" {
|
|
t.Fatalf("untracked entry = %+v", got[1])
|
|
}
|
|
if got[2].Path != "renamed.go" || got[2].OldPath != "old.go" || got[2].Status != "R" {
|
|
t.Fatalf("rename entry = %+v", got[2])
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceChangesNonGitDirectory(t *testing.T) {
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := (&App{}).WorkspaceChanges("")
|
|
if got.GitAvailable {
|
|
t.Fatal("non-git directory should mark git unavailable")
|
|
}
|
|
if len(got.Files) != 0 {
|
|
t.Fatalf("files = %+v, want none", got.Files)
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceChangesUsesRequestedTabCheckpoints(t *testing.T) {
|
|
workspace := t.TempDir()
|
|
sessionDir := t.TempDir()
|
|
sessionA := filepath.Join(sessionDir, "a.jsonl")
|
|
sessionB := filepath.Join(sessionDir, "b.jsonl")
|
|
content := "old"
|
|
now := time.Now()
|
|
|
|
for _, tc := range []struct {
|
|
session string
|
|
path string
|
|
prompt string
|
|
}{
|
|
{sessionA, "a.txt", "edit a"},
|
|
{sessionB, "b.txt", "edit b"},
|
|
} {
|
|
ckptDir := strings.TrimSuffix(tc.session, ".jsonl") + ".ckpt"
|
|
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{
|
|
Turn: 0,
|
|
Time: now,
|
|
Prompt: tc.prompt,
|
|
Files: []checkpoint.FileSnap{{Path: tc.path, Content: &content}},
|
|
})
|
|
}
|
|
|
|
ctrlA := control.New(control.Options{SessionDir: sessionDir, SessionPath: sessionA, Label: "a"})
|
|
ctrlB := control.New(control.Options{SessionDir: sessionDir, SessionPath: sessionB, Label: "b"})
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"a": {ID: "a", Scope: "project", WorkspaceRoot: workspace, Ctrl: ctrlA, Ready: true},
|
|
"b": {ID: "b", Scope: "project", WorkspaceRoot: workspace, Ctrl: ctrlB, Ready: true},
|
|
},
|
|
activeTabID: "a",
|
|
}
|
|
|
|
got := app.WorkspaceChanges("b")
|
|
byPath := map[string]WorkspaceChangeView{}
|
|
for _, file := range got.Files {
|
|
byPath[file.Path] = file
|
|
}
|
|
if _, ok := byPath["a.txt"]; ok {
|
|
t.Fatalf("requested tab b included active tab a changes: %+v", got.Files)
|
|
}
|
|
if byPath["b.txt"].LatestPrompt != "edit b" {
|
|
t.Fatalf("requested tab b changes = %+v, want b.txt from tab b", got.Files)
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceChangesGitStatus(t *testing.T) {
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not installed")
|
|
}
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "init")
|
|
runGit(t, "checkout", "-b", "feature/test")
|
|
if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "add", "tracked.txt")
|
|
if err := os.WriteFile("tracked.txt", []byte("v2\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile("untracked.txt", []byte("new\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := (&App{}).WorkspaceChanges("")
|
|
if !got.GitAvailable {
|
|
t.Fatalf("git unavailable: %s", got.GitErr)
|
|
}
|
|
if got.GitBranch != "feature/test" {
|
|
t.Fatalf("git branch = %q, want feature/test", got.GitBranch)
|
|
}
|
|
byPath := map[string]WorkspaceChangeView{}
|
|
for _, file := range got.Files {
|
|
byPath[file.Path] = file
|
|
}
|
|
if byPath["tracked.txt"].GitStatus == "" {
|
|
t.Fatalf("tracked.txt missing git status: %+v", got.Files)
|
|
}
|
|
if byPath["untracked.txt"].GitStatus != "??" {
|
|
t.Fatalf("untracked.txt = %+v", byPath["untracked.txt"])
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceChangesGitStatusFromRepoSubdirectory(t *testing.T) {
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not installed")
|
|
}
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
repo := t.TempDir()
|
|
if err := os.Chdir(repo); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "init")
|
|
if err := os.MkdirAll("sub", 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("sub", "tracked.txt"), []byte("v1\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "add", filepath.Join("sub", "tracked.txt"))
|
|
if err := os.WriteFile(filepath.Join("sub", "tracked.txt"), []byte("v2\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("sub", "untracked.txt"), []byte("new\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile("outside.txt", []byte("outside\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(filepath.Join(repo, "sub")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := (&App{}).WorkspaceChanges("")
|
|
if !got.GitAvailable {
|
|
t.Fatalf("git unavailable: %s", got.GitErr)
|
|
}
|
|
byPath := map[string]WorkspaceChangeView{}
|
|
for _, file := range got.Files {
|
|
byPath[file.Path] = file
|
|
}
|
|
if byPath["tracked.txt"].GitStatus == "" {
|
|
t.Fatalf("tracked.txt missing git status: %+v", got.Files)
|
|
}
|
|
if byPath["untracked.txt"].GitStatus != "??" {
|
|
t.Fatalf("untracked.txt = %+v", byPath["untracked.txt"])
|
|
}
|
|
if _, ok := byPath["sub/tracked.txt"]; ok {
|
|
t.Fatalf("git status path should be workspace-relative, got %+v", got.Files)
|
|
}
|
|
if _, ok := byPath["outside.txt"]; ok {
|
|
t.Fatalf("changes outside the opened workspace should be hidden: %+v", got.Files)
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceChangesUntrackedDirectoryListsFiles(t *testing.T) {
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not installed")
|
|
}
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "init")
|
|
if err := os.MkdirAll(filepath.Join("newdir", "nested"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join("newdir", "nested", "file.txt"), []byte("new\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := (&App{}).WorkspaceChanges("")
|
|
byPath := map[string]WorkspaceChangeView{}
|
|
for _, file := range got.Files {
|
|
byPath[file.Path] = file
|
|
}
|
|
if byPath["newdir/"].GitStatus != "" {
|
|
t.Fatalf("directory should not be listed as a changed file: %+v", got.Files)
|
|
}
|
|
if byPath["newdir/nested/file.txt"].GitStatus != "??" {
|
|
t.Fatalf("untracked file missing from directory: %+v", got.Files)
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceChangesGitBranchDetachedHead(t *testing.T) {
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not installed")
|
|
}
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "init")
|
|
runGit(t, "config", "user.email", "test@example.com")
|
|
runGit(t, "config", "user.name", "Test User")
|
|
if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "add", "tracked.txt")
|
|
runGit(t, "commit", "-m", "init")
|
|
short := gitOutput(t, "rev-parse", "--short", "HEAD")
|
|
runGit(t, "checkout", "--detach", "HEAD")
|
|
|
|
got := (&App{}).WorkspaceChanges("")
|
|
if !got.GitAvailable {
|
|
t.Fatalf("git unavailable: %s", got.GitErr)
|
|
}
|
|
if got.GitBranch != "@"+short {
|
|
t.Fatalf("git branch = %q, want @%s", got.GitBranch, short)
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceGitHistory(t *testing.T) {
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not installed")
|
|
}
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
runGit(t, "init")
|
|
runGit(t, "config", "user.email", "test@example.com")
|
|
runGit(t, "config", "user.name", "Test User")
|
|
|
|
if err := os.WriteFile("file1.txt", []byte("v1\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "add", "file1.txt")
|
|
runGit(t, "commit", "-m", "init file1")
|
|
|
|
if err := os.WriteFile("file2.txt", []byte("v1\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "add", "file2.txt")
|
|
runGit(t, "commit", "-m", "init file2")
|
|
|
|
app := &App{}
|
|
history, err := app.WorkspaceGitHistory("", "")
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceGitHistory err = %v", err)
|
|
}
|
|
if len(history) != 2 {
|
|
t.Fatalf("expected 2 commits, got %d", len(history))
|
|
}
|
|
if history[0].Message != "init file2" {
|
|
t.Errorf("expected latest commit message 'init file2', got %q", history[0].Message)
|
|
}
|
|
if history[1].Message != "init file1" {
|
|
t.Errorf("expected older commit message 'init file1', got %q", history[1].Message)
|
|
}
|
|
|
|
// Test history for specific file
|
|
history, err = app.WorkspaceGitHistory("", "file1.txt")
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceGitHistory err = %v", err)
|
|
}
|
|
if len(history) != 1 {
|
|
t.Fatalf("expected 1 commit for file1.txt, got %d", len(history))
|
|
}
|
|
if history[0].Message != "init file1" {
|
|
t.Errorf("expected commit message 'init file1', got %q", history[0].Message)
|
|
}
|
|
}
|
|
|
|
func TestEmptyGitArrayResultsAreNonNil(t *testing.T) {
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not installed")
|
|
}
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "init")
|
|
runGit(t, "config", "user.email", "test@example.com")
|
|
runGit(t, "config", "user.name", "Test User")
|
|
|
|
app := &App{}
|
|
branches, err := app.GitBranches()
|
|
if err != nil {
|
|
t.Fatalf("GitBranches err = %v", err)
|
|
}
|
|
if branches == nil {
|
|
t.Fatal("GitBranches returned nil for an unborn repository; frontend expects []")
|
|
}
|
|
|
|
if err := os.WriteFile("tracked.txt", []byte("content\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "add", "tracked.txt")
|
|
runGit(t, "commit", "-m", "initial")
|
|
|
|
history, err := app.WorkspaceGitHistory("", "never-existed.txt")
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceGitHistory empty path err = %v", err)
|
|
}
|
|
if history == nil {
|
|
t.Fatal("WorkspaceGitHistory returned nil for a path without commits; frontend expects []")
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceGitHistoryUsesRequestedTabWorkspace(t *testing.T) {
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not installed")
|
|
}
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
makeRepo := func(name, message string) string {
|
|
t.Helper()
|
|
dir := filepath.Join(t.TempDir(), name)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "init")
|
|
runGit(t, "config", "user.email", "test@example.com")
|
|
runGit(t, "config", "user.name", "Test User")
|
|
if err := os.WriteFile("file.txt", []byte(message+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "add", "file.txt")
|
|
runGit(t, "commit", "-m", message)
|
|
return dir
|
|
}
|
|
|
|
repoA := makeRepo("a", "repo a commit")
|
|
repoB := makeRepo("b", "repo b commit")
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"a": {ID: "a", Scope: "project", WorkspaceRoot: repoA, Ready: true},
|
|
"b": {ID: "b", Scope: "project", WorkspaceRoot: repoB, Ready: true},
|
|
},
|
|
activeTabID: "a",
|
|
}
|
|
|
|
history, err := app.WorkspaceGitHistory("b", "")
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceGitHistory err = %v", err)
|
|
}
|
|
if len(history) != 1 || history[0].Message != "repo b commit" {
|
|
t.Fatalf("history for requested tab = %+v, want repo b commit", history)
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceGitCommitDetail(t *testing.T) {
|
|
if _, err := exec.LookPath("git"); err != nil {
|
|
t.Skip("git not installed")
|
|
}
|
|
orig, _ := os.Getwd()
|
|
defer os.Chdir(orig)
|
|
|
|
dir := t.TempDir()
|
|
if err := os.Chdir(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
runGit(t, "init")
|
|
runGit(t, "config", "user.email", "test@example.com")
|
|
runGit(t, "config", "user.name", "Test User")
|
|
|
|
if err := os.WriteFile("file1.txt", []byte("v1\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "add", "file1.txt")
|
|
runGit(t, "commit", "-m", "init file1")
|
|
|
|
if err := os.WriteFile("file1.txt", []byte("v2\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runGit(t, "add", "file1.txt")
|
|
runGit(t, "commit", "-m", "update file1")
|
|
|
|
hash := gitOutput(t, "rev-parse", "HEAD")
|
|
|
|
app := &App{}
|
|
|
|
// Test project level detail
|
|
detail, err := app.WorkspaceGitCommitDetail("", hash, "")
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceGitCommitDetail err = %v", err)
|
|
}
|
|
if len(detail.Files) != 1 || detail.Files[0] != "file1.txt" {
|
|
t.Fatalf("expected files [file1.txt], got %v", detail.Files)
|
|
}
|
|
if detail.Diff != nil {
|
|
t.Fatal("expected nil diff for project level")
|
|
}
|
|
|
|
// Test file level detail
|
|
detail, err = app.WorkspaceGitCommitDetail("", hash, "file1.txt")
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceGitCommitDetail err = %v", err)
|
|
}
|
|
if len(detail.Files) != 0 {
|
|
t.Fatalf("expected no files for file level, got %v", detail.Files)
|
|
}
|
|
if detail.Diff == nil || !strings.Contains(*detail.Diff, "+v2") {
|
|
t.Fatalf("expected diff to contain '+v2', got %v", detail.Diff)
|
|
}
|
|
}
|
|
|
|
func runGit(t *testing.T, args ...string) {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
}
|
|
}
|
|
|
|
func gitOutput(t *testing.T, args ...string) string {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
}
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
// --- settings_app.go helpers ---
|
|
// These are unexported but in the same package, so we can test them.
|
|
|
|
func TestOrDefault(t *testing.T) {
|
|
if orDefault("", "fallback") != "fallback" {
|
|
t.Error("empty should return default")
|
|
}
|
|
if orDefault("value", "fallback") != "value" {
|
|
t.Error("non-empty should return value")
|
|
}
|
|
}
|
|
|
|
func TestTrimList(t *testing.T) {
|
|
got := trimList([]string{" a ", "", " b ", " "})
|
|
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
|
t.Errorf("trimList = %v", got)
|
|
}
|
|
}
|
|
|
|
func TestTrimListEmpty(t *testing.T) {
|
|
got := trimList(nil)
|
|
if len(got) != 0 {
|
|
t.Errorf("nil = %v, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestNonNil(t *testing.T) {
|
|
if got := nonNil(nil); got == nil || len(got) != 0 {
|
|
t.Errorf("nonNil(nil) = %v, want empty non-nil", got)
|
|
}
|
|
s := []string{"a"}
|
|
if got := nonNil(s); got[0] != "a" {
|
|
t.Errorf("nonNil should pass through")
|
|
}
|
|
}
|