claudeup 6.2.0 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/bin/claudeup.js +6 -1
- package/package.json +6 -6
- package/src/__tests__/alias-adopt.test.ts +331 -329
- package/src/__tests__/alias-parser.test.ts +384 -376
- package/src/__tests__/alias-shell-writer.test.ts +638 -625
- package/src/__tests__/alias-store.test.ts +143 -143
- package/src/__tests__/catalog-cache-store.test.ts +265 -265
- package/src/__tests__/catalog-notice.test.ts +123 -123
- package/src/__tests__/cli-apply-seams.test.ts +12 -8
- package/src/__tests__/cli-router.test.ts +132 -3
- package/src/__tests__/cli-update-view.test.ts +27 -8
- package/src/__tests__/community-staleness.test.ts +0 -2
- package/src/__tests__/component-badge.test.ts +4 -4
- package/src/__tests__/content-drift.test.ts +5 -1
- package/src/__tests__/conventions-integration.test.ts +774 -689
- package/src/__tests__/conventions-manager.test.ts +1216 -1152
- package/src/__tests__/doctor-bins.test.ts +14 -6
- package/src/__tests__/doctor.test.ts +438 -425
- package/src/__tests__/dual-write-prevention.test.ts +277 -282
- package/src/__tests__/enabled-not-installed.test.ts +71 -63
- package/src/__tests__/file-locking.test.ts +196 -196
- package/src/__tests__/gap-fill-versions.test.ts +318 -311
- package/src/__tests__/github-budget.test.ts +170 -164
- package/src/__tests__/gitignore-detector.test.ts +160 -152
- package/src/__tests__/gitignore-fixer.test.ts +285 -247
- package/src/__tests__/gitignore-prerun.test.ts +57 -57
- package/src/__tests__/gitignore-resolver.test.ts +173 -146
- package/src/__tests__/gitignore-service.test.ts +121 -119
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/install-command.test.ts +3 -3
- package/src/__tests__/install-plan.test.ts +13 -7
- package/src/__tests__/manifest.test.ts +16 -5
- package/src/__tests__/markdown-renderer.test.ts +0 -1
- package/src/__tests__/marketplace-badge.test.ts +111 -90
- package/src/__tests__/marketplace-version.test.ts +64 -61
- package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
- package/src/__tests__/plugin-requires.test.ts +55 -1
- package/src/__tests__/plugin-setup.test.ts +111 -9
- package/src/__tests__/profile-command.test.ts +1 -7
- package/src/__tests__/profile-materializer.test.ts +31 -8
- package/src/__tests__/profile-sync.test.ts +23 -5
- package/src/__tests__/registry-version-resolution.test.ts +61 -47
- package/src/__tests__/resolve-executable.test.ts +1 -1
- package/src/__tests__/resolver.test.ts +38 -13
- package/src/__tests__/scope-action.test.ts +52 -50
- package/src/__tests__/scope-squares.test.tsx +4 -5
- package/src/__tests__/skill-install-files.test.ts +111 -103
- package/src/__tests__/symlink-manager.test.ts +27 -10
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/update-apply.test.ts +1 -3
- package/src/__tests__/useGitignoreModal.test.ts +69 -61
- package/src/__tests__/version-snapshot.test.ts +183 -165
- package/src/cli/ansi.ts +23 -15
- package/src/cli/doctor.ts +10 -9
- package/src/cli/install.ts +3 -3
- package/src/cli/profile.ts +5 -4
- package/src/cli/prompt.ts +0 -1
- package/src/cli/router.ts +69 -2
- package/src/cli/update-view.ts +11 -6
- package/src/cli/update.ts +9 -4
- package/src/cli/upgrade.ts +6 -4
- package/src/data/alias-flags.ts +260 -260
- package/src/data/gitignore-defaults.ts +20 -20
- package/src/data/gitignore-reasons.ts +86 -92
- package/src/data/gitignore-templates.ts +14 -14
- package/src/data/predefined-profiles.ts +1 -2
- package/src/data/skill-repos.ts +43 -35
- package/src/main.tsx +29 -6
- package/src/opentui.d.ts +1 -3
- package/src/prerunner/index.ts +7 -8
- package/src/services/alias-settings.ts +20 -23
- package/src/services/alias-shell-writer.ts +800 -803
- package/src/services/alias-store.ts +77 -77
- package/src/services/claude-settings.ts +27 -18
- package/src/services/content-drift.ts +14 -4
- package/src/services/conventions-manager.ts +692 -617
- package/src/services/doctor.ts +464 -403
- package/src/services/gitignore-detector.ts +113 -103
- package/src/services/gitignore-fixer.ts +204 -189
- package/src/services/gitignore-prerun.ts +26 -26
- package/src/services/gitignore-resolver.ts +162 -144
- package/src/services/gitignore-service.ts +120 -117
- package/src/services/install-plan.ts +4 -4
- package/src/services/local-marketplace.ts +7 -5
- package/src/services/manifest.ts +1 -1
- package/src/services/marketplace-catalog-git.ts +5 -2
- package/src/services/marketplace-fetcher.ts +241 -235
- package/src/services/plugin-manager.ts +17 -20
- package/src/services/plugin-mcp-config.ts +2 -2
- package/src/services/plugin-requires.ts +16 -8
- package/src/services/plugin-setup.ts +32 -15
- package/src/services/plugin-version-check.ts +4 -4
- package/src/services/profile-materializer.ts +9 -5
- package/src/services/profile-sync.ts +12 -4
- package/src/services/profiles.ts +3 -3
- package/src/services/resolver.ts +10 -6
- package/src/services/settings-manager.ts +82 -46
- package/src/services/skills-manager.ts +11 -9
- package/src/services/symlink-manager.ts +2 -5
- package/src/services/toolchain.ts +3 -3
- package/src/services/update-cache.ts +1 -1
- package/src/services/update-engine.ts +4 -4
- package/src/services/update-plan.ts +9 -11
- package/src/services/version-snapshot.ts +88 -88
- package/src/types/gitignore.ts +28 -28
- package/src/ui/App.tsx +36 -36
- package/src/ui/adapters/settingsAdapter.ts +2 -2
- package/src/ui/components/CategoryHeader.tsx +1 -1
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +3 -1
- package/src/ui/components/ScrollableList.tsx +2 -1
- package/src/ui/components/StyledText.tsx +1 -1
- package/src/ui/components/layout/FooterHints.tsx +5 -3
- package/src/ui/components/layout/Panel.tsx +2 -2
- package/src/ui/components/layout/ScreenLayout.tsx +5 -4
- package/src/ui/components/modals/InputModal.tsx +7 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -3
- package/src/ui/components/modals/SelectModal.tsx +1 -1
- package/src/ui/components/primitives/DetailSection.tsx +1 -1
- package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
- package/src/ui/hooks/index.ts +0 -1
- package/src/ui/hooks/useGitignoreModal.ts +1 -5
- package/src/ui/hooks/useMismatchModal.ts +4 -10
- package/src/ui/renderers/cliToolRenderers.tsx +2 -2
- package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +3 -9
- package/src/ui/renderers/profileRenderers.tsx +9 -8
- package/src/ui/renderers/settingsRenderers.tsx +4 -4
- package/src/ui/renderers/skillRenderers.tsx +14 -14
- package/src/ui/screens/AliasScreen.tsx +31 -34
- package/src/ui/screens/CliToolsScreen.tsx +14 -13
- package/src/ui/screens/EnvVarsScreen.tsx +8 -8
- package/src/ui/screens/GitignoreScreen.tsx +15 -14
- package/src/ui/screens/McpRegistryScreen.tsx +10 -7
- package/src/ui/screens/McpScreen.tsx +10 -10
- package/src/ui/screens/PluginsScreen.tsx +17 -21
- package/src/ui/screens/ProfilesScreen.tsx +25 -24
- package/src/ui/screens/SkillsScreen.tsx +16 -14
- package/src/ui/state/AppContext.tsx +4 -8
- package/src/ui/state/DimensionsContext.tsx +3 -2
- package/src/ui/theme-mode.ts +20 -38
- package/src/ui/theme-resolve.ts +148 -0
- package/src/utils/clipboard.ts +5 -5
- package/src/utils/command-utils.ts +2 -2
- package/src/utils/config-dir.ts +1 -3
- package/src/utils/file-locking.ts +88 -86
- package/src/utils/go-module.ts +36 -0
- package/src/ui/hooks/useAsyncData.ts +0 -127
- package/src/ui/state/AnimationContext.tsx +0 -76
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { loadProjectDotenv } from "../services/dotenv.js";
|
|
7
|
+
import { resolveTheme, snapshotThemeEnv } from "../ui/theme-resolve.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `TERM_THEME` is read from the process environment and never from a `.env`.
|
|
11
|
+
*
|
|
12
|
+
* Nothing at runtime enforces that — `loadProjectDotenv` merges into the same
|
|
13
|
+
* `process.env` the resolver would otherwise read — so the isolation is the
|
|
14
|
+
* ORDER of two calls in main.tsx: snapshot first, dotenv second. T-env-1 and
|
|
15
|
+
* T-env-2 prove the mechanism; T-env-3 pins the order at source level, which is
|
|
16
|
+
* the only place it can be pinned without spawning the TUI.
|
|
17
|
+
*
|
|
18
|
+
* Order alone is not enough on the source path: Bun autoloads the cwd's `.env`
|
|
19
|
+
* into `process.env` BEFORE any user code runs, so a snapshot taken as the
|
|
20
|
+
* first statement already holds the file's values. Every source launcher
|
|
21
|
+
* therefore passes `--no-env-file` (the compiled binary is built with
|
|
22
|
+
* `--no-compile-autoload-dotenv`). T-env-4 proves that flag live, with a
|
|
23
|
+
* negative control showing the leak without it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const dirs: string[] = [];
|
|
27
|
+
|
|
28
|
+
const MAIN_TSX = path.join(import.meta.dir, "..", "main.tsx");
|
|
29
|
+
const THEME_RESOLVE = path.join(
|
|
30
|
+
import.meta.dir,
|
|
31
|
+
"..",
|
|
32
|
+
"ui",
|
|
33
|
+
"theme-resolve.ts",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Spawn bun from `cwd` with the three theme variables removed from the parent
|
|
38
|
+
* environment, so the only way `TERM_THEME` can reach the child is Bun's own
|
|
39
|
+
* `.env` autoload. Returns stdout/stderr/status.
|
|
40
|
+
*/
|
|
41
|
+
function spawnBun(
|
|
42
|
+
cwd: string,
|
|
43
|
+
args: string[],
|
|
44
|
+
): { status: number | null; stdout: string; stderr: string } {
|
|
45
|
+
const env: Record<string, string | undefined> = { ...process.env };
|
|
46
|
+
env.TERM_THEME = undefined;
|
|
47
|
+
env.CLAUDEUP_THEME = undefined;
|
|
48
|
+
env.COLORFGBG = undefined;
|
|
49
|
+
const r = spawnSync("bun", args, {
|
|
50
|
+
cwd,
|
|
51
|
+
env: env as NodeJS.ProcessEnv,
|
|
52
|
+
encoding: "utf-8",
|
|
53
|
+
input: "",
|
|
54
|
+
});
|
|
55
|
+
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A one-file harness that prints `snapshotThemeEnv()` of its own process. */
|
|
59
|
+
function writeSnapshotHarness(cwd: string): string {
|
|
60
|
+
const file = path.join(cwd, "print-snapshot.ts");
|
|
61
|
+
writeFileSync(
|
|
62
|
+
file,
|
|
63
|
+
[
|
|
64
|
+
`import { snapshotThemeEnv } from ${JSON.stringify(THEME_RESOLVE)};`,
|
|
65
|
+
"console.log(JSON.stringify(snapshotThemeEnv()));",
|
|
66
|
+
"",
|
|
67
|
+
].join("\n"),
|
|
68
|
+
);
|
|
69
|
+
return file;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
for (const dir of dirs.splice(0))
|
|
74
|
+
rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function tempDir(): string {
|
|
78
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), "claudeup-theme-env-"));
|
|
79
|
+
dirs.push(dir);
|
|
80
|
+
return dir;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe("snapshotThemeEnv", () => {
|
|
84
|
+
test("T-env-1: the snapshot is a copy — later writes to env do not reach it", () => {
|
|
85
|
+
const env: Record<string, string | undefined> = {
|
|
86
|
+
TERM_THEME: "light",
|
|
87
|
+
CLAUDEUP_THEME: "dark",
|
|
88
|
+
COLORFGBG: "0;15",
|
|
89
|
+
UNRELATED: "x",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const snapshot = snapshotThemeEnv(env);
|
|
93
|
+
env.TERM_THEME = "dark";
|
|
94
|
+
env.CLAUDEUP_THEME = undefined;
|
|
95
|
+
env.COLORFGBG = "15;0";
|
|
96
|
+
|
|
97
|
+
expect(snapshot).toEqual({
|
|
98
|
+
TERM_THEME: "light",
|
|
99
|
+
CLAUDEUP_THEME: "dark",
|
|
100
|
+
COLORFGBG: "0;15",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("T-env-1b: an unset variable is absent from the snapshot, not undefined", () => {
|
|
105
|
+
expect(snapshotThemeEnv({})).toEqual({});
|
|
106
|
+
expect(Object.keys(snapshotThemeEnv({ TERM_THEME: "light" }))).toEqual([
|
|
107
|
+
"TERM_THEME",
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("T-env-2: a .env TERM_THEME lands in env but never in a snapshot taken first", () => {
|
|
112
|
+
const cwd = tempDir();
|
|
113
|
+
writeFileSync(path.join(cwd, ".env"), "TERM_THEME=light\n");
|
|
114
|
+
const env: Record<string, string | undefined> = {};
|
|
115
|
+
|
|
116
|
+
const snapshot = snapshotThemeEnv(env);
|
|
117
|
+
const outcome = loadProjectDotenv(cwd, env);
|
|
118
|
+
|
|
119
|
+
expect(outcome.warnings).toEqual([]);
|
|
120
|
+
expect(env.TERM_THEME).toBe("light");
|
|
121
|
+
expect(snapshot.TERM_THEME).toBeUndefined();
|
|
122
|
+
expect(snapshot).toEqual({});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("T-env-2b: a .env TERM_THEME=light never reaches the resolver — the probe still runs and wins", async () => {
|
|
126
|
+
// End to end through the resolver: snapshot, then .env, then resolve
|
|
127
|
+
// with the snapshot. Were the .env value reaching the resolver, step 3
|
|
128
|
+
// would answer light and the probe would never be called.
|
|
129
|
+
const cwd = tempDir();
|
|
130
|
+
writeFileSync(
|
|
131
|
+
path.join(cwd, ".env"),
|
|
132
|
+
"TERM_THEME=light\nCLAUDEUP_THEME=light\n",
|
|
133
|
+
);
|
|
134
|
+
const env: Record<string, string | undefined> = {};
|
|
135
|
+
let probeCalls = 0;
|
|
136
|
+
|
|
137
|
+
const snapshot = snapshotThemeEnv(env);
|
|
138
|
+
loadProjectDotenv(cwd, env);
|
|
139
|
+
const interactive = await resolveTheme({
|
|
140
|
+
env: snapshot,
|
|
141
|
+
isInteractive: true,
|
|
142
|
+
probe: async () => {
|
|
143
|
+
probeCalls += 1;
|
|
144
|
+
return "dark";
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
const headless = await resolveTheme({
|
|
148
|
+
env: snapshot,
|
|
149
|
+
isInteractive: false,
|
|
150
|
+
probe: async () => "light",
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(env.TERM_THEME).toBe("light");
|
|
154
|
+
expect(interactive).toEqual({ mode: "dark", source: "osc11" });
|
|
155
|
+
expect(probeCalls).toBe(1);
|
|
156
|
+
expect(headless).toEqual({ mode: "dark", source: "default" });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("T-env-2c: .env.local is isolated the same way as .env", async () => {
|
|
160
|
+
const cwd = tempDir();
|
|
161
|
+
writeFileSync(path.join(cwd, ".env.local"), "TERM_THEME=light\n");
|
|
162
|
+
const env: Record<string, string | undefined> = { COLORFGBG: "15;0" };
|
|
163
|
+
|
|
164
|
+
const snapshot = snapshotThemeEnv(env);
|
|
165
|
+
loadProjectDotenv(cwd, env);
|
|
166
|
+
const r = await resolveTheme({
|
|
167
|
+
env: snapshot,
|
|
168
|
+
isInteractive: false,
|
|
169
|
+
probe: async () => null,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(env.TERM_THEME).toBe("light");
|
|
173
|
+
expect(r).toEqual({ mode: "dark", source: "COLORFGBG" });
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("Bun .env autoload (live)", () => {
|
|
178
|
+
test("T-env-4: `bun --no-env-file src/main.tsx --theme bogus` from a cwd with .env exits in the router, not from the file", () => {
|
|
179
|
+
// --theme bogus exits 1 before the renderer, so no tty is needed. The
|
|
180
|
+
// point of the run is the exit path: a leaked .env value cannot change
|
|
181
|
+
// it, and the flag must not break the source launch either.
|
|
182
|
+
const cwd = tempDir();
|
|
183
|
+
writeFileSync(
|
|
184
|
+
path.join(cwd, ".env"),
|
|
185
|
+
"TERM_THEME=light\nCLAUDEUP_THEME=light\n",
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const r = spawnBun(cwd, ["--no-env-file", MAIN_TSX, "--theme", "bogus"]);
|
|
189
|
+
|
|
190
|
+
expect(r.status).toBe(1);
|
|
191
|
+
expect(r.stderr).toContain("bogus");
|
|
192
|
+
expect(r.stderr).toContain("--theme");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("T-env-4b: with --no-env-file the process snapshot holds nothing from the cwd's .env", () => {
|
|
196
|
+
const cwd = tempDir();
|
|
197
|
+
writeFileSync(
|
|
198
|
+
path.join(cwd, ".env"),
|
|
199
|
+
"TERM_THEME=light\nCLAUDEUP_THEME=light\n",
|
|
200
|
+
);
|
|
201
|
+
const harness = writeSnapshotHarness(cwd);
|
|
202
|
+
|
|
203
|
+
const r = spawnBun(cwd, ["--no-env-file", harness]);
|
|
204
|
+
|
|
205
|
+
expect(r.status).toBe(0);
|
|
206
|
+
expect(JSON.parse(r.stdout.trim())).toEqual({});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("T-env-4c (negative control): without --no-env-file the same harness sees TERM_THEME=light — the flag is what isolates", () => {
|
|
210
|
+
const cwd = tempDir();
|
|
211
|
+
writeFileSync(
|
|
212
|
+
path.join(cwd, ".env"),
|
|
213
|
+
"TERM_THEME=light\nCLAUDEUP_THEME=light\n",
|
|
214
|
+
);
|
|
215
|
+
const harness = writeSnapshotHarness(cwd);
|
|
216
|
+
|
|
217
|
+
const r = spawnBun(cwd, [harness]);
|
|
218
|
+
|
|
219
|
+
expect(r.status).toBe(0);
|
|
220
|
+
expect(JSON.parse(r.stdout.trim())).toEqual({
|
|
221
|
+
TERM_THEME: "light",
|
|
222
|
+
CLAUDEUP_THEME: "light",
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("T-env-4d: every source launcher passes --no-env-file", () => {
|
|
227
|
+
// package.json scripts and the bin fallback are the launch surfaces a
|
|
228
|
+
// developer actually uses; a `bun src/main.tsx` without the flag is the
|
|
229
|
+
// bug T-env-4c demonstrates.
|
|
230
|
+
const root = path.join(import.meta.dir, "..", "..");
|
|
231
|
+
const pkg = JSON.parse(
|
|
232
|
+
readFileSync(path.join(root, "package.json"), "utf-8"),
|
|
233
|
+
) as { scripts: Record<string, string> };
|
|
234
|
+
const bin = readFileSync(path.join(root, "bin", "claudeup.js"), "utf-8");
|
|
235
|
+
|
|
236
|
+
for (const name of ["start", "dev"]) {
|
|
237
|
+
expect(pkg.scripts[name]).toContain("--no-env-file");
|
|
238
|
+
expect(pkg.scripts[name]).toContain("src/main.tsx");
|
|
239
|
+
}
|
|
240
|
+
expect(bin).toMatch(/spawnSync\(\s*"bun",\s*\[\s*"--no-env-file"/);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Drop `//` line comments and `/* *\/` blocks so a guard below matches code,
|
|
246
|
+
* never a comment that happens to name the call. Strings in main.tsx contain
|
|
247
|
+
* neither `//` nor `/*`, so the crude regex is exact enough for that one file.
|
|
248
|
+
*/
|
|
249
|
+
function stripComments(source: string): string {
|
|
250
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
describe("main.tsx order", () => {
|
|
254
|
+
const code = stripComments(
|
|
255
|
+
readFileSync(path.join(import.meta.dir, "..", "main.tsx"), "utf-8"),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
test("T-env-3: the snapshot STATEMENT runs before the loadProjectDotenv() call", () => {
|
|
259
|
+
// Source-level guard. Honest about what it is: nothing at runtime would
|
|
260
|
+
// notice a .env winning, and the TUI cannot be spawned in a test. It
|
|
261
|
+
// anchors on the concrete statement, in comment-stripped source, so the
|
|
262
|
+
// explanatory comment above the call cannot satisfy it (it once did).
|
|
263
|
+
const snapshotAt = code.indexOf("const themeEnv = snapshotThemeEnv()");
|
|
264
|
+
const dotenvAt = code.indexOf("loadProjectDotenv(");
|
|
265
|
+
|
|
266
|
+
expect(snapshotAt).toBeGreaterThan(-1);
|
|
267
|
+
expect(dotenvAt).toBeGreaterThan(-1);
|
|
268
|
+
expect(snapshotAt).toBeLessThan(dotenvAt);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("T-env-3b: main.tsx reads the three theme variables only through the snapshot", () => {
|
|
272
|
+
// A later `process.env.TERM_THEME` would see the merged .env and bypass
|
|
273
|
+
// the snapshot silently; the resolver must be handed `themeEnv` instead.
|
|
274
|
+
for (const name of ["TERM_THEME", "CLAUDEUP_THEME", "COLORFGBG"]) {
|
|
275
|
+
expect(code).not.toMatch(
|
|
276
|
+
new RegExp(`process\\.env(\\.${name}\\b|\\[["'\`]${name}["'\`]\\])`),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
expect(code).toMatch(/env:\s*themeEnv\b/);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ThemeMode } from "@opentui/core";
|
|
3
|
+
import {
|
|
4
|
+
type ResolveThemeInput,
|
|
5
|
+
parseColorFgBg,
|
|
6
|
+
parseThemeWord,
|
|
7
|
+
resolveTheme,
|
|
8
|
+
} from "../ui/theme-resolve.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The resolver is a six-step, first-answer-wins list. These tests pin every
|
|
12
|
+
* step, every adjacent precedence pair, and the "no opinion" rule: a word that
|
|
13
|
+
* is not exactly `light` or `dark` says nothing and the next step runs.
|
|
14
|
+
*
|
|
15
|
+
* The probe is a hand-rolled recorder rather than `spyOn` — `spyOn` calls
|
|
16
|
+
* through to the original by default, and the property under test is whether
|
|
17
|
+
* the probe was reached at all.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function probeReturning(value: ThemeMode | null) {
|
|
21
|
+
let calls = 0;
|
|
22
|
+
const probe = async () => {
|
|
23
|
+
calls += 1;
|
|
24
|
+
return value;
|
|
25
|
+
};
|
|
26
|
+
return { probe, calls: () => calls };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function input(overrides: Partial<ResolveThemeInput> = {}): ResolveThemeInput {
|
|
30
|
+
return {
|
|
31
|
+
env: {},
|
|
32
|
+
isInteractive: true,
|
|
33
|
+
probe: async () => null,
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("each step answers on its own", () => {
|
|
39
|
+
test("T-step-1: the flag wins and the probe is never called", async () => {
|
|
40
|
+
const p = probeReturning("dark");
|
|
41
|
+
|
|
42
|
+
const r = await resolveTheme(input({ flag: "light", probe: p.probe }));
|
|
43
|
+
|
|
44
|
+
expect(r).toEqual({ mode: "light", source: "flag" });
|
|
45
|
+
expect(p.calls()).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("T-step-2: CLAUDEUP_THEME answers without the probe", async () => {
|
|
49
|
+
const p = probeReturning("light");
|
|
50
|
+
|
|
51
|
+
const r = await resolveTheme(
|
|
52
|
+
input({ env: { CLAUDEUP_THEME: "dark" }, probe: p.probe }),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(r).toEqual({ mode: "dark", source: "variable" });
|
|
56
|
+
expect(p.calls()).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("T-step-3: TERM_THEME answers and the probe is skipped entirely", async () => {
|
|
60
|
+
const p = probeReturning("dark");
|
|
61
|
+
|
|
62
|
+
const r = await resolveTheme(
|
|
63
|
+
input({ env: { TERM_THEME: "light" }, probe: p.probe }),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(r).toEqual({ mode: "light", source: "TERM_THEME" });
|
|
67
|
+
expect(p.calls()).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("T-step-4: an interactive tty probes exactly once", async () => {
|
|
71
|
+
const p = probeReturning("light");
|
|
72
|
+
|
|
73
|
+
const r = await resolveTheme(input({ probe: p.probe }));
|
|
74
|
+
|
|
75
|
+
expect(r).toEqual({ mode: "light", source: "osc11" });
|
|
76
|
+
expect(p.calls()).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("T-step-5: an unanswered probe falls to COLORFGBG", async () => {
|
|
80
|
+
const r = await resolveTheme(
|
|
81
|
+
input({ env: { COLORFGBG: "15;7" }, probe: async () => null }),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(r).toEqual({ mode: "light", source: "COLORFGBG" });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("T-step-6: nothing answers → dark by decision, no throw", async () => {
|
|
88
|
+
const r = await resolveTheme(input());
|
|
89
|
+
|
|
90
|
+
expect(r).toEqual({ mode: "dark", source: "default" });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("precedence between adjacent steps", () => {
|
|
95
|
+
test("T-prec-flag>var: the flag beats CLAUDEUP_THEME", async () => {
|
|
96
|
+
const r = await resolveTheme(
|
|
97
|
+
input({ flag: "light", env: { CLAUDEUP_THEME: "dark" } }),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(r.mode).toBe("light");
|
|
101
|
+
expect(r.source).toBe("flag");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("T-prec-var>TERM: CLAUDEUP_THEME beats TERM_THEME", async () => {
|
|
105
|
+
const r = await resolveTheme(
|
|
106
|
+
input({ env: { CLAUDEUP_THEME: "light", TERM_THEME: "dark" } }),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(r.mode).toBe("light");
|
|
110
|
+
expect(r.source).toBe("variable");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("T-prec-TERM>probe: TERM_THEME beats a probe that would disagree", async () => {
|
|
114
|
+
const p = probeReturning("light");
|
|
115
|
+
|
|
116
|
+
const r = await resolveTheme(
|
|
117
|
+
input({ env: { TERM_THEME: "dark" }, probe: p.probe }),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(r.mode).toBe("dark");
|
|
121
|
+
expect(p.calls()).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("T-prec-probe>CFB: the probe beats COLORFGBG", async () => {
|
|
125
|
+
const r = await resolveTheme(
|
|
126
|
+
input({ env: { COLORFGBG: "15;0" }, probe: async () => "light" }),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(r).toEqual({ mode: "light", source: "osc11" });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("T-prec-CFB>default: a dark COLORFGBG is attributed to COLORFGBG, not default", async () => {
|
|
133
|
+
const r = await resolveTheme(input({ env: { COLORFGBG: "0;0" } }));
|
|
134
|
+
|
|
135
|
+
expect(r).toEqual({ mode: "dark", source: "COLORFGBG" });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("precedence across non-adjacent steps", () => {
|
|
140
|
+
test("T-prec-flag>all: the flag beats every later step, and none of them is consulted", async () => {
|
|
141
|
+
const p = probeReturning("light");
|
|
142
|
+
|
|
143
|
+
const r = await resolveTheme(
|
|
144
|
+
input({
|
|
145
|
+
flag: "dark",
|
|
146
|
+
env: {
|
|
147
|
+
CLAUDEUP_THEME: "light",
|
|
148
|
+
TERM_THEME: "light",
|
|
149
|
+
COLORFGBG: "0;15",
|
|
150
|
+
},
|
|
151
|
+
probe: p.probe,
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(r).toEqual({ mode: "dark", source: "flag" });
|
|
156
|
+
expect(p.calls()).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("T-prec-var>probe: CLAUDEUP_THEME beats a probe that would disagree, and skips it", async () => {
|
|
160
|
+
const p = probeReturning("light");
|
|
161
|
+
|
|
162
|
+
const r = await resolveTheme(
|
|
163
|
+
input({
|
|
164
|
+
env: { CLAUDEUP_THEME: "dark", COLORFGBG: "0;15" },
|
|
165
|
+
probe: p.probe,
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(r).toEqual({ mode: "dark", source: "variable" });
|
|
170
|
+
expect(p.calls()).toBe(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("T-prec-TERM>CFB: TERM_THEME beats COLORFGBG when there is no tty to probe", async () => {
|
|
174
|
+
const r = await resolveTheme(
|
|
175
|
+
input({
|
|
176
|
+
env: { TERM_THEME: "light", COLORFGBG: "15;0" },
|
|
177
|
+
isInteractive: false,
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(r).toEqual({ mode: "light", source: "TERM_THEME" });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("T-step-4b: a probe that answers dark is attributed to osc11, not default", async () => {
|
|
185
|
+
const r = await resolveTheme(
|
|
186
|
+
input({ env: { COLORFGBG: "0;15" }, probe: async () => "dark" }),
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(r).toEqual({ mode: "dark", source: "osc11" });
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("no opinion falls through", () => {
|
|
194
|
+
test("T-noop-auto: TERM_THEME=auto reaches the probe", async () => {
|
|
195
|
+
const p = probeReturning("light");
|
|
196
|
+
|
|
197
|
+
const r = await resolveTheme(
|
|
198
|
+
input({ env: { TERM_THEME: "auto" }, probe: p.probe }),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
expect(r).toEqual({ mode: "light", source: "osc11" });
|
|
202
|
+
expect(p.calls()).toBe(1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("T-noop-case: Light and DARK are not the exact words and are ignored", async () => {
|
|
206
|
+
const r = await resolveTheme(
|
|
207
|
+
input({
|
|
208
|
+
env: { TERM_THEME: "Light", CLAUDEUP_THEME: "DARK" },
|
|
209
|
+
isInteractive: false,
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(r).toEqual({ mode: "dark", source: "default" });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("T-noop-empty: an empty TERM_THEME falls through", async () => {
|
|
217
|
+
const r = await resolveTheme(
|
|
218
|
+
input({ env: { TERM_THEME: "", COLORFGBG: "0;15" } }),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(r).toEqual({ mode: "light", source: "COLORFGBG" });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("T-noop-auto-notty: TERM_THEME=auto never means dark — without a tty it reaches COLORFGBG", async () => {
|
|
225
|
+
const p = probeReturning("dark");
|
|
226
|
+
|
|
227
|
+
const r = await resolveTheme(
|
|
228
|
+
input({
|
|
229
|
+
env: { TERM_THEME: "auto", COLORFGBG: "0;15" },
|
|
230
|
+
isInteractive: false,
|
|
231
|
+
probe: p.probe,
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
expect(r).toEqual({ mode: "light", source: "COLORFGBG" });
|
|
236
|
+
expect(p.calls()).toBe(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("T-noop-auto-default: TERM_THEME=auto with nothing else is the DEFAULT dark, not a TERM_THEME answer", async () => {
|
|
240
|
+
const r = await resolveTheme(input({ env: { TERM_THEME: "auto" } }));
|
|
241
|
+
|
|
242
|
+
expect(r).toEqual({ mode: "dark", source: "default" });
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test.each(["auto", "purple", "", "Dark"])(
|
|
246
|
+
"T-noop-var: CLAUDEUP_THEME=%p is no opinion and TERM_THEME answers instead",
|
|
247
|
+
async (word) => {
|
|
248
|
+
const p = probeReturning("dark");
|
|
249
|
+
|
|
250
|
+
const r = await resolveTheme(
|
|
251
|
+
input({
|
|
252
|
+
env: { CLAUDEUP_THEME: word, TERM_THEME: "light" },
|
|
253
|
+
probe: p.probe,
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
expect(r).toEqual({ mode: "light", source: "TERM_THEME" });
|
|
258
|
+
expect(p.calls()).toBe(0);
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("R8: resolving is silent", () => {
|
|
264
|
+
// The dark fallback is a decision, not a failure: no step, including step 6
|
|
265
|
+
// and a probe that fails, may print a "could not detect" line.
|
|
266
|
+
test("T-quiet: neither the default path nor a failing probe writes to the console", async () => {
|
|
267
|
+
const written: string[] = [];
|
|
268
|
+
const record = (...a: unknown[]) => {
|
|
269
|
+
written.push(a.join(" "));
|
|
270
|
+
};
|
|
271
|
+
const original = {
|
|
272
|
+
log: console.log,
|
|
273
|
+
warn: console.warn,
|
|
274
|
+
error: console.error,
|
|
275
|
+
};
|
|
276
|
+
console.log = record;
|
|
277
|
+
console.warn = record;
|
|
278
|
+
console.error = record;
|
|
279
|
+
try {
|
|
280
|
+
await resolveTheme(input());
|
|
281
|
+
await resolveTheme(
|
|
282
|
+
input({
|
|
283
|
+
probe: async () => {
|
|
284
|
+
throw new Error("no reply");
|
|
285
|
+
},
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
await resolveTheme(
|
|
289
|
+
input({ env: { TERM_THEME: "auto" }, isInteractive: false }),
|
|
290
|
+
);
|
|
291
|
+
} finally {
|
|
292
|
+
console.log = original.log;
|
|
293
|
+
console.warn = original.warn;
|
|
294
|
+
console.error = original.error;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
expect(written).toEqual([]);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("the probe is guarded", () => {
|
|
302
|
+
test("T-tty: a non-interactive process never probes", async () => {
|
|
303
|
+
const p = probeReturning("light");
|
|
304
|
+
|
|
305
|
+
const fromEnv = await resolveTheme(
|
|
306
|
+
input({
|
|
307
|
+
isInteractive: false,
|
|
308
|
+
env: { COLORFGBG: "0;15" },
|
|
309
|
+
probe: p.probe,
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
const fromDefault = await resolveTheme(
|
|
313
|
+
input({ isInteractive: false, probe: p.probe }),
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(fromEnv).toEqual({ mode: "light", source: "COLORFGBG" });
|
|
317
|
+
expect(fromDefault).toEqual({ mode: "dark", source: "default" });
|
|
318
|
+
expect(p.calls()).toBe(0);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("T-probe-throws: a rejecting probe is treated as null", async () => {
|
|
322
|
+
const r = await resolveTheme(
|
|
323
|
+
input({
|
|
324
|
+
env: { COLORFGBG: "0;7" },
|
|
325
|
+
probe: async () => {
|
|
326
|
+
throw new Error("terminal hung up");
|
|
327
|
+
},
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
expect(r).toEqual({ mode: "light", source: "COLORFGBG" });
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
describe("T-cfb-parse: parseColorFgBg reads the LAST field as the background", () => {
|
|
336
|
+
test.each<[string | undefined, ThemeMode | null]>([
|
|
337
|
+
["15;0", "dark"],
|
|
338
|
+
["0;15", "light"],
|
|
339
|
+
["0;7", "light"],
|
|
340
|
+
["12;3;7", "light"],
|
|
341
|
+
["0;default;15", "light"],
|
|
342
|
+
["default;default", null],
|
|
343
|
+
["7", null],
|
|
344
|
+
["15", null],
|
|
345
|
+
["x;16", null],
|
|
346
|
+
["0;255", null],
|
|
347
|
+
// Zero-padded fields are outside the statusline's literal `case` set, so
|
|
348
|
+
// they are no opinion here too — Number("07") would have said light.
|
|
349
|
+
["0;07", null],
|
|
350
|
+
["0;015", null],
|
|
351
|
+
["0;00", null],
|
|
352
|
+
["0;", null],
|
|
353
|
+
["", null],
|
|
354
|
+
[undefined, null],
|
|
355
|
+
])("%p → %p", (value, expected) => {
|
|
356
|
+
expect(parseColorFgBg(value)).toBe(expected);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("every background 0-14 except 7 is dark", () => {
|
|
360
|
+
for (let bg = 0; bg <= 14; bg += 1) {
|
|
361
|
+
expect(parseColorFgBg(`0;${bg}`)).toBe(bg === 7 ? "light" : "dark");
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
describe("T-word-parse: parseThemeWord is exact, lowercase, untrimmed", () => {
|
|
367
|
+
test.each<[string | undefined, ThemeMode | null]>([
|
|
368
|
+
["light", "light"],
|
|
369
|
+
["dark", "dark"],
|
|
370
|
+
["auto", null],
|
|
371
|
+
["Light", null],
|
|
372
|
+
["DARK", null],
|
|
373
|
+
[" dark", null],
|
|
374
|
+
["dark ", null],
|
|
375
|
+
["", null],
|
|
376
|
+
[undefined, null],
|
|
377
|
+
])("%p → %p", (value, expected) => {
|
|
378
|
+
expect(parseThemeWord(value)).toBe(expected);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
10
|
+
import type { PluginScope } from "../services/claude-cli.js";
|
|
10
11
|
import {
|
|
11
12
|
type PluginApplyDeps,
|
|
12
13
|
applyPlugins,
|
|
13
14
|
installPluginInScope,
|
|
14
15
|
updatePluginInScope,
|
|
15
16
|
} from "../services/update-engine.js";
|
|
16
|
-
import type { PluginScope } from "../services/claude-cli.js";
|
|
17
17
|
import type { PluginUpdateItem } from "../services/update-plan.js";
|
|
18
18
|
|
|
19
19
|
const PROJECT = "/tmp/project";
|
|
@@ -67,9 +67,7 @@ function item(over: Partial<PluginUpdateItem>): PluginUpdateItem {
|
|
|
67
67
|
let logs: string[];
|
|
68
68
|
beforeEach(() => {
|
|
69
69
|
logs = [];
|
|
70
|
-
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
71
70
|
console.log = ((...a: unknown[]) => logs.push(a.join(" "))) as any;
|
|
72
|
-
// biome-ignore lint/suspicious/noExplicitAny: test double
|
|
73
71
|
console.warn = ((...a: unknown[]) => logs.push(a.join(" "))) as any;
|
|
74
72
|
});
|
|
75
73
|
|