| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/pluginpkg" |
| 10 | ) |
| 11 | |
| 12 | // installV1RuntimePlugin writes a Manifest v1 package with a runtime into |
| 13 | // the test Reasonix home and registers it in plugin state. |
| 14 | func installV1RuntimePlugin(t *testing.T, home string) string { |
| 15 | t.Helper() |
| 16 | root := filepath.Join(home, "plugins", "example") |
| 17 | writePluginTestFile(t, filepath.Join(root, pluginpkg.NativeManifest), `{ |
| 18 | "apiVersion": "reasonix.io/plugin/v1", |
| 19 | "name": "example", |
| 20 | "version": "1.0.0", |
| 21 | "contributes": { |
| 22 | "prompts": ["prompts"], |
| 23 | "themes": ["themes/*.reasonix-theme"] |
| 24 | }, |
| 25 | "runtime": { |
| 26 | "command": "${REASONIX_PLUGIN_ROOT}/bin/example", |
| 27 | "args": ["--serve"], |
| 28 | "required": true, |
| 29 | "intercepts": ["input.receive", "tool.before"], |
| 30 | "replaces": ["system_prompt"], |
| 31 | "capabilities": ["interceptors", "ui"] |
| 32 | } |
| 33 | }`) |
| 34 | writePluginTestFile(t, filepath.Join(root, "prompts", "plan.md"), "---\ndescription: plan\n---\nPlan $ARGUMENTS") |
| 35 | writePluginTestFile(t, filepath.Join(root, "themes", "neon.reasonix-theme"), "theme bytes") |
| 36 | writePluginTestFile(t, filepath.Join(root, "bin", "example"), "#!/bin/sh\n") |
| 37 | if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{ |
| 38 | Name: "example", Root: "plugins/example", Version: "1.0.0", ManifestKind: "reasonix", Enabled: true, |
| 39 | }); err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | return root |
| 43 | } |
| 44 | |
| 45 | func TestPluginShowRendersRuntimeFullTrust(t *testing.T) { |
| 46 | home := t.TempDir() |
| 47 | t.Setenv("REASONIX_HOME", home) |
| 48 | installV1RuntimePlugin(t, home) |
| 49 | |
| 50 | out := captureStdout(t, func() { |
| 51 | if rc := pluginCommand([]string{"show", "example"}); rc != 0 { |
| 52 | t.Fatalf("plugin show rc = %d, want 0", rc) |
| 53 | } |
| 54 | }) |
| 55 | for _, want := range []string{ |
| 56 | "prompts: 1", |
| 57 | "themes: 1", |
| 58 | "runtime: FULL TRUST", |
| 59 | "command: ${REASONIX_PLUGIN_ROOT}/bin/example --serve", |
| 60 | "intercepts: input.receive, tool.before", |
| 61 | "replaces: system_prompt", |
| 62 | "capabilities: interceptors, ui", |
| 63 | "bypass permissions", |
| 64 | "prompts:\n plan", |
| 65 | "themes:\n neon", |
| 66 | } { |
| 67 | if !strings.Contains(out, want) { |
| 68 | t.Fatalf("plugin show output missing %q:\n%s", want, out) |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | func TestPluginDoctorValidatesV1Runtime(t *testing.T) { |
| 74 | home := t.TempDir() |
| 75 | t.Setenv("REASONIX_HOME", home) |
| 76 | installV1RuntimePlugin(t, home) |
| 77 | |
| 78 | out := captureStdout(t, func() { |
| 79 | if rc := pluginCommand([]string{"doctor", "example"}); rc != 0 { |
| 80 | t.Fatalf("plugin doctor rc = %d, want 0", rc) |
| 81 | } |
| 82 | }) |
| 83 | if !strings.Contains(out, "runtime: FULL TRUST") || !strings.Contains(out, "ok: example") { |
| 84 | t.Fatalf("doctor output missing runtime block or ok line:\n%s", out) |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | func TestPluginDoctorFailsForMissingRuntimeCommand(t *testing.T) { |
| 89 | home := t.TempDir() |
| 90 | t.Setenv("REASONIX_HOME", home) |
| 91 | root := installV1RuntimePlugin(t, home) |
| 92 | // Remove the runtime binary so the command no longer resolves. |
| 93 | if err := os.Remove(filepath.Join(root, "bin", "example")); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | |
| 97 | stderr := captureStderr(t, func() { |
| 98 | if rc := pluginCommand([]string{"doctor", "example"}); rc != 1 { |
| 99 | t.Fatalf("plugin doctor rc = %d, want 1 for a missing runtime command", rc) |
| 100 | } |
| 101 | }) |
| 102 | if !strings.Contains(stderr, "runtime command not found:") { |
| 103 | t.Fatalf("doctor stderr missing the runtime command diagnostic:\n%s", stderr) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | func TestPluginDoctorReportsMissingV1PathsAsWarnings(t *testing.T) { |
| 108 | home := t.TempDir() |
| 109 | t.Setenv("REASONIX_HOME", home) |
| 110 | root := filepath.Join(home, "plugins", "gap") |
| 111 | writePluginTestFile(t, filepath.Join(root, pluginpkg.NativeManifest), `{ |
| 112 | "apiVersion": "reasonix.io/plugin/v1", |
| 113 | "name": "gap", |
| 114 | "contributes": { |
| 115 | "themes": ["themes/*.reasonix-theme"] |
| 116 | } |
| 117 | }`) |
| 118 | if err := pluginpkg.Upsert(home, pluginpkg.InstalledPlugin{ |
| 119 | Name: "gap", Root: "plugins/gap", ManifestKind: "reasonix", Enabled: true, |
| 120 | }); err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | |
| 124 | out := captureStdout(t, func() { |
| 125 | if rc := pluginCommand([]string{"doctor", "gap"}); rc != 0 { |
| 126 | t.Fatalf("plugin doctor rc = %d, want 0 (missing themes are warnings, not failures)", rc) |
| 127 | } |
| 128 | }) |
| 129 | if !strings.Contains(out, `theme glob "themes/*.reasonix-theme" matched no files`) { |
| 130 | t.Fatalf("doctor output missing the glob warning:\n%s", out) |
| 131 | } |
| 132 | } |
| 133 |