| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/taskcatalog" |
| 11 | ) |
| 12 | |
| 13 | func TestExtensionGenerationBumpsOnMCPMutationSites(t *testing.T) { |
| 14 | // Guard mutation sites that publish shared Host / config changes while |
| 15 | // controller builds may still be running off the lifecycle lock. |
| 16 | entries, err := os.ReadDir(".") |
| 17 | if err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | var sources []string |
| 21 | for _, entry := range entries { |
| 22 | name := entry.Name() |
| 23 | if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { |
| 24 | continue |
| 25 | } |
| 26 | raw, readErr := os.ReadFile(filepath.Join(".", name)) |
| 27 | if readErr != nil { |
| 28 | t.Fatal(readErr) |
| 29 | } |
| 30 | sources = append(sources, string(raw)) |
| 31 | } |
| 32 | src := strings.Join(sources, "\n") |
| 33 | for _, name := range []string{ |
| 34 | "func (a *App) InstallMCPServer", |
| 35 | "func (a *App) UpdateMCPServer", |
| 36 | "func (a *App) RemoveMCPServer", |
| 37 | "func (a *App) ReconnectMCPServer", |
| 38 | "func (a *App) ClearMCPServerAuthentication", |
| 39 | } { |
| 40 | idx := strings.Index(src, name) |
| 41 | if idx < 0 { |
| 42 | t.Fatalf("missing %s", name) |
| 43 | } |
| 44 | rest := src[idx:] |
| 45 | next := strings.Index(rest[len(name):], "\nfunc (a *App) ") |
| 46 | body := rest |
| 47 | if next >= 0 { |
| 48 | body = rest[:len(name)+next] |
| 49 | } |
| 50 | if !strings.Contains(body, "a.bumpExtensionGeneration()") && !strings.Contains(body, "saveDesktopMCPServerAndBump") { |
| 51 | t.Fatalf("%s does not bump extensionGeneration", name) |
| 52 | } |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestControllerPublicationRejectsMutationThatCompletesAfterBoot(t *testing.T) { |
| 57 | app := NewApp() |
| 58 | generation := app.currentExtensionGeneration() |
| 59 | |
| 60 | // Model a build that has finished extension boot while an MCP mutation is |
| 61 | // queued. The writer must finish and bump the generation before publication. |
| 62 | app.extensionBuildMu.RLock() |
| 63 | mutationLocked := make(chan struct{}) |
| 64 | releaseMutation := make(chan struct{}) |
| 65 | go func() { |
| 66 | unlock := app.lockMCPMutation("test-publication-fence") |
| 67 | close(mutationLocked) |
| 68 | <-releaseMutation |
| 69 | unlock() |
| 70 | }() |
| 71 | app.extensionBuildMu.RUnlock() |
| 72 | <-mutationLocked |
| 73 | |
| 74 | publication := make(chan bool, 1) |
| 75 | go func() { |
| 76 | unlock, ok := app.lockTabControllerPublication(generation, "global", "") |
| 77 | if ok { |
| 78 | unlock() |
| 79 | } |
| 80 | publication <- ok |
| 81 | }() |
| 82 | close(releaseMutation) |
| 83 | if <-publication { |
| 84 | t.Fatal("controller published after an MCP mutation changed its extension generation") |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestTaskActionProjectKeepsAllowlistedRoot(t *testing.T) { |
| 89 | root := t.TempDir() |
| 90 | app := &App{ |
| 91 | ctx: context.Background(), |
| 92 | tabs: map[string]*WorkspaceTab{ |
| 93 | "active": {ID: "active", Scope: "project", WorkspaceRoot: root}, |
| 94 | }, |
| 95 | activeTabID: "active", |
| 96 | } |
| 97 | key := taskcatalog.ProjectKey(root) |
| 98 | project, err := app.taskActionProject(key) |
| 99 | if err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | if abs, err := filepath.Abs(root); err == nil { |
| 103 | root = abs |
| 104 | } |
| 105 | if project.Root != root { |
| 106 | t.Fatalf("root = %q, want allowlisted %q", project.Root, root) |
| 107 | } |
| 108 | } |
| 109 |