@lovinka/vitrinka 1.7.1 → 1.9.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/CHANGELOG.md +45 -0
- package/README.md +22 -5
- package/dist/bin-mcp.js +6 -2
- package/dist/bin-mcp.js.map +1 -1
- package/dist/cli.js +302 -145
- package/dist/cli.js.map +1 -1
- package/dist/mcp.js +18 -6
- package/dist/mcp.js.map +1 -1
- package/package.json +4 -6
- package/skills/artifact/SKILL.md +0 -82
- package/skills/brainstorming/SKILL.md +0 -107
- package/skills/screenshot/SKILL.md +0 -108
- package/skills/screenshot/gallery.mjs +0 -6
- package/skills/vitrinka/cli.ts +0 -39
- package/skills/vitrinka/commands/listen.md +0 -10
- package/skills/vitrinka/listen/SKILL.md +0 -197
- package/skills/vitrinka/tests/_helpers.ts +0 -108
- package/skills/vitrinka/tests/derive.test.ts +0 -118
- package/skills/vitrinka/tests/dx.test.ts +0 -264
- package/skills/vitrinka/tests/embed.test.ts +0 -96
- package/skills/vitrinka/tests/index.test.ts +0 -250
- package/skills/vitrinka/tests/install-name.test.ts +0 -117
- package/skills/vitrinka/tests/manifest.test.ts +0 -98
- package/skills/vitrinka/tests/operator.test.ts +0 -119
- package/skills/vitrinka/tests/push.test.ts +0 -115
- package/skills/vitrinka/tests/scaffold.test.ts +0 -173
- package/skills/vitrinka/tests/shim.test.ts +0 -28
- package/skills/vitrinka/tests/snap.test.ts +0 -175
- package/skills/vitrinka/tests/watch.test.ts +0 -204
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
// dx.test.ts — the CLI developer-experience layer: the command registry (help +
|
|
2
|
-
// completion single source of truth), the generated help/completion, the hidden
|
|
3
|
-
// __complete dynamic candidates, and the AI assistance (proposal parse/validate,
|
|
4
|
-
// askClaude degradation, fuzzy resolution) — all against stub claude bins and
|
|
5
|
-
// loopback HTTP fixtures so nothing reaches the real network or the real claude.
|
|
6
|
-
import { test } from 'node:test';
|
|
7
|
-
import assert from 'node:assert/strict';
|
|
8
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
9
|
-
import { join } from 'node:path';
|
|
10
|
-
import { execFile } from 'node:child_process';
|
|
11
|
-
import { createServer } from 'node:http';
|
|
12
|
-
import { fileURLToPath } from 'node:url';
|
|
13
|
-
import {
|
|
14
|
-
COMMANDS, COMMAND_NAMES, renderFullHelp, renderCmdHelp, renderCompactUsage,
|
|
15
|
-
completionScript, nearestCommand, levenshtein, extractCommandLine, tokenizeArgv,
|
|
16
|
-
validateProposal, askClaude, fetchCandidates, resolveFuzzy,
|
|
17
|
-
} from '../../../pkg/src/cli.ts';
|
|
18
|
-
import { CLI, makeTmp, writeFakeBin } from './_helpers.ts';
|
|
19
|
-
|
|
20
|
-
const CLI_SRC = fileURLToPath(new URL('../../../pkg/src/cli.ts', import.meta.url));
|
|
21
|
-
|
|
22
|
-
// ---------- registry completeness (enforced so a new command can't skip help) ----------
|
|
23
|
-
|
|
24
|
-
test('registry: every dispatch case has a COMMANDS entry, and vice versa', () => {
|
|
25
|
-
const src = readFileSync(CLI_SRC, 'utf8');
|
|
26
|
-
const body = src.slice(src.indexOf('async function runSub'), src.indexOf('const IS_MAIN'));
|
|
27
|
-
const cases = [...body.matchAll(/cmd === '([a-z-]+)'/g)].map((m) => m[1]);
|
|
28
|
-
// Meta commands (help/completion/ai) are handled in the entrypoint, not runSub.
|
|
29
|
-
const dispatchable = COMMANDS.filter((c) => !['help', 'completion', 'ai'].includes(c.name)).map((c) => c.name);
|
|
30
|
-
assert.deepEqual([...new Set(cases)].sort(), [...dispatchable].sort(),
|
|
31
|
-
'runSub cases and the registry must match exactly');
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test('registry: names are unique and every command documents a usage', () => {
|
|
35
|
-
assert.equal(new Set(COMMAND_NAMES).size, COMMAND_NAMES.length);
|
|
36
|
-
for (const c of COMMANDS) assert.ok(c.usage !== undefined && c.summary, `${c.name} needs summary+usage`);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
// ---------- help rendering ----------
|
|
40
|
-
|
|
41
|
-
test('renderFullHelp lists every non-meta command with its summary', () => {
|
|
42
|
-
const help = renderFullHelp();
|
|
43
|
-
for (const c of COMMANDS.filter((c) => !c.hidden)) {
|
|
44
|
-
assert.ok(help.includes(c.name), `full help missing ${c.name}`);
|
|
45
|
-
assert.ok(help.includes(c.summary), `full help missing summary for ${c.name}`);
|
|
46
|
-
}
|
|
47
|
-
assert.ok(/USAGE/.test(help) && /COMMANDS/.test(help));
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('renderCmdHelp renders flags + examples for a command', () => {
|
|
51
|
-
const h = renderCmdHelp('snap');
|
|
52
|
-
assert.ok(h.includes('vitrinka snap'));
|
|
53
|
-
assert.ok(h.includes('--route') && h.includes('--udid'), 'snap flags must appear');
|
|
54
|
-
assert.ok(h.includes('EXAMPLES'));
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test('renderCompactUsage is Usage:-headed and names every command (shim + fallback contract)', () => {
|
|
58
|
-
const u = renderCompactUsage();
|
|
59
|
-
assert.match(u, /^Usage:/);
|
|
60
|
-
assert.ok(u.includes('artifact-from-set'), 'the compact usage must name the last command');
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test('nearestCommand suggests a close miss and stays silent on a far one', () => {
|
|
64
|
-
assert.equal(nearestCommand('snpa'), 'snap');
|
|
65
|
-
assert.equal(nearestCommand('instal'), 'install');
|
|
66
|
-
assert.equal(levenshtein('abc', 'abc'), 0);
|
|
67
|
-
assert.equal(nearestCommand('zzzzzzzzzzzzz'), '');
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// ---------- completion generation ----------
|
|
71
|
-
|
|
72
|
-
for (const shell of ['bash', 'zsh'] as const) {
|
|
73
|
-
test(`completionScript(${shell}): command names + per-command flags + dynamic hook`, () => {
|
|
74
|
-
const s = completionScript(shell);
|
|
75
|
-
for (const c of COMMANDS.filter((c) => !c.hidden)) assert.ok(s.includes(c.name), `missing ${c.name}`);
|
|
76
|
-
assert.ok(s.includes('--route') && s.includes('--slug'), 'per-command flags embedded');
|
|
77
|
-
assert.ok(s.includes('__complete'), 'shells out to the dynamic endpoint');
|
|
78
|
-
assert.ok(s.includes(shell === 'bash' ? 'complete -F _vitrinka' : 'compdef _vitrinka'));
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ---------- __complete dynamic candidates (against a stub server) ----------
|
|
83
|
-
|
|
84
|
-
function runCliAsync(cliArgs: string[], env: Record<string, string> = {}): Promise<{ status: number; stdout: string; stderr: string }> {
|
|
85
|
-
return new Promise((res) => {
|
|
86
|
-
execFile(process.execPath, [CLI, ...cliArgs], { encoding: 'utf8', env: { ...process.env, ...env } },
|
|
87
|
-
(err, stdout, stderr) => res({ status: err ? (err as { code?: number }).code ?? 1 : 0, stdout, stderr }));
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function stubApi(projects: string[], boards: string[]) {
|
|
92
|
-
return createServer((req, res) => {
|
|
93
|
-
res.setHeader('content-type', 'application/json');
|
|
94
|
-
if (req.url === '/api/v1/projects') return res.end(JSON.stringify({ projects: projects.map((p) => ({ project: p })) }));
|
|
95
|
-
if (req.url === '/api/v1/boards') return res.end(JSON.stringify({ boards: boards.map((b) => ({ slug: b })) }));
|
|
96
|
-
res.statusCode = 404; res.end('{}');
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
test('__complete: prefix-filters server projects for a project-taking command', async () => {
|
|
101
|
-
const srv = stubApi(['fixit', 'flowik', 'reservine'], []);
|
|
102
|
-
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', () => r()));
|
|
103
|
-
const base = `http://127.0.0.1:${(srv.address() as { port: number }).port}`;
|
|
104
|
-
try {
|
|
105
|
-
const r = await runCliAsync(['__complete', 'index', 'fi'], { VITRINKA_URL: base });
|
|
106
|
-
assert.equal(r.status, 0, r.stderr);
|
|
107
|
-
assert.equal(r.stdout.trim(), 'fixit');
|
|
108
|
-
// A command with no dynamic source prints nothing (silent).
|
|
109
|
-
const none = await runCliAsync(['__complete', 'build', 'x'], { VITRINKA_URL: base });
|
|
110
|
-
assert.equal(none.stdout.trim(), '');
|
|
111
|
-
} finally { srv.close(); }
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test('fetchCandidates + __complete stay silent when the server is unreachable', async () => {
|
|
115
|
-
// Reserved-doc port .0 → immediate failure; must yield [] not throw/hang.
|
|
116
|
-
const cands = await fetchCandidates('http://127.0.0.1:0', 'boards', 300);
|
|
117
|
-
assert.deepEqual(cands, []);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
// ---------- AI: proposal parsing + validation (claude output is untrusted) ----------
|
|
121
|
-
|
|
122
|
-
test('extractCommandLine: fenced block wins, else the first vitrinka line', () => {
|
|
123
|
-
assert.equal(extractCommandLine('sure!\n```sh\nvitrinka build --root .screenshots\n```\n'), 'vitrinka build --root .screenshots');
|
|
124
|
-
assert.equal(extractCommandLine('Run this:\n$ vitrinka push --root .screenshots'), 'vitrinka push --root .screenshots');
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test('tokenizeArgv honors quotes, rejects unbalanced', () => {
|
|
128
|
-
assert.deepEqual(tokenizeArgv('vitrinka meta --title "Payout flow"'), ['vitrinka', 'meta', '--title', 'Payout flow']);
|
|
129
|
-
assert.equal(tokenizeArgv('vitrinka name "oops'), null);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test('validateProposal accepts a clean vitrinka command', () => {
|
|
133
|
-
const p = validateProposal('vitrinka build --root .screenshots');
|
|
134
|
-
assert.ok(p.ok && p.sub === 'build');
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test('validateProposal REJECTS malicious / malformed suggestions', () => {
|
|
138
|
-
for (const [line, why] of [
|
|
139
|
-
['rm -rf /', 'non-vitrinka argv0'],
|
|
140
|
-
['vitrinka build; rm -rf /', 'shell metachar'],
|
|
141
|
-
['vitrinka push --root . | sh', 'pipe'],
|
|
142
|
-
['vitrinka push && curl evil', 'chain'],
|
|
143
|
-
['vitrinka $(whoami)', 'command substitution'],
|
|
144
|
-
['vitrinka frobnicate', 'unknown subcommand'],
|
|
145
|
-
['vitrinka ai "loop"', 'no ai recursion'],
|
|
146
|
-
['vitrinka build --exfiltrate http://evil', 'unknown flag for the subcommand'],
|
|
147
|
-
['vitrinka name --clear=1 --frobnicate', 'unknown flag (with = form present)'],
|
|
148
|
-
['', 'empty'],
|
|
149
|
-
] as const) {
|
|
150
|
-
const p = validateProposal(line);
|
|
151
|
-
assert.equal(p.ok, false, `should reject (${why}): ${line}`);
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
// ---------- AI: askClaude degradation (stub bins) ----------
|
|
156
|
-
|
|
157
|
-
function withEnv<T>(patch: Record<string, string | undefined>, fn: () => Promise<T>): Promise<T> {
|
|
158
|
-
const saved: Record<string, string | undefined> = {};
|
|
159
|
-
for (const k of Object.keys(patch)) { saved[k] = process.env[k]; if (patch[k] === undefined) delete process.env[k]; else process.env[k] = patch[k]!; }
|
|
160
|
-
return fn().finally(() => { for (const k of Object.keys(saved)) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]!; } });
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
test('askClaude: clean stub → trimmed stdout', async () => {
|
|
164
|
-
const bin = join(makeTmp(), 'bin');
|
|
165
|
-
const stub = writeFakeBin(bin, 'claude-stub', `let s='';process.stdin.on('data',d=>s+=d);process.stdin.on('end',()=>{process.stdout.write(' ok line ');process.exit(0);});`);
|
|
166
|
-
const out = await withEnv({ CLAUDE_BIN: stub, VITRINKA_NO_AI: undefined }, () => askClaude('hi', { timeoutMs: 4000 }));
|
|
167
|
-
assert.equal(out, 'ok line');
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
test('askClaude: non-zero exit → null (degrade)', async () => {
|
|
171
|
-
const bin = join(makeTmp(), 'bin');
|
|
172
|
-
const stub = writeFakeBin(bin, 'claude-stub', `process.stdin.resume();process.stdin.on('end',()=>process.exit(3));`);
|
|
173
|
-
const out = await withEnv({ CLAUDE_BIN: stub, VITRINKA_NO_AI: undefined }, () => askClaude('hi', { timeoutMs: 4000 }));
|
|
174
|
-
assert.equal(out, null);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test('askClaude: TIMEOUT → null (slow stub, small cap)', async () => {
|
|
178
|
-
const bin = join(makeTmp(), 'bin');
|
|
179
|
-
const stub = writeFakeBin(bin, 'claude-stub', `setTimeout(()=>process.exit(0), 3000);`);
|
|
180
|
-
const out = await withEnv({ CLAUDE_BIN: stub, VITRINKA_NO_AI: undefined }, () => askClaude('hi', { timeoutMs: 150 }));
|
|
181
|
-
assert.equal(out, null);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
test('askClaude: VITRINKA_NO_AI=1 → null without spawning', async () => {
|
|
185
|
-
const out = await withEnv({ VITRINKA_NO_AI: '1', CLAUDE_BIN: '/nonexistent/claude' }, () => askClaude('hi'));
|
|
186
|
-
assert.equal(out, null);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
test('askClaude: missing binary → null (no crash)', async () => {
|
|
190
|
-
const out = await withEnv({ CLAUDE_BIN: '/nonexistent/definitely/claude', VITRINKA_NO_AI: undefined }, () => askClaude('hi', { timeoutMs: 2000 }));
|
|
191
|
-
assert.equal(out, null);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// ---------- AI: `ai` end-to-end — happy path dispatches internally, malicious rejected ----------
|
|
195
|
-
|
|
196
|
-
test('ai --yes: stub proposes `vitrinka build` → dispatched INTERNALLY (index.html built)', async () => {
|
|
197
|
-
const root = join(makeTmp(), '.screenshots');
|
|
198
|
-
mkdirSync(root, { recursive: true });
|
|
199
|
-
writeFileSync(join(root, 'manifest.json'), JSON.stringify({ version: 1, shots: [] }) + '\n');
|
|
200
|
-
const bin = join(makeTmp(), 'bin');
|
|
201
|
-
const reply = '```sh\nvitrinka build --root ' + root + '\n```';
|
|
202
|
-
const stub = writeFakeBin(bin, 'claude-stub', `let s='';process.stdin.on('data',d=>s+=d);process.stdin.on('end',()=>{process.stdout.write(process.env.STUB_REPLY||'');process.exit(0);});`);
|
|
203
|
-
const r = await runCliAsync(['ai', 'build the gallery', '--yes'], { CLAUDE_BIN: stub, STUB_REPLY: reply, VITRINKA_NO_AI: '' });
|
|
204
|
-
assert.equal(r.status, 0, r.stderr);
|
|
205
|
-
assert.match(r.stdout, /proposed: vitrinka build --root/);
|
|
206
|
-
assert.ok(existsSync(join(root, 'index.html')), 'the internal dispatch must have run `build`');
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
test('ai: a malicious `rm -rf` proposal is rejected (exit 1) and never runs a shell', async () => {
|
|
210
|
-
const sentinelDir = makeTmp();
|
|
211
|
-
const sentinel = join(sentinelDir, 'keep.txt');
|
|
212
|
-
writeFileSync(sentinel, 'do not delete');
|
|
213
|
-
const bin = join(makeTmp(), 'bin');
|
|
214
|
-
const stub = writeFakeBin(bin, 'claude-stub', `let s='';process.stdin.on('data',d=>s+=d);process.stdin.on('end',()=>{process.stdout.write(process.env.STUB_REPLY||'');process.exit(0);});`);
|
|
215
|
-
const r = await runCliAsync(['ai', 'delete everything', '--yes'],
|
|
216
|
-
{ CLAUDE_BIN: stub, STUB_REPLY: '```sh\nrm -rf ' + sentinelDir + '\n```', VITRINKA_NO_AI: '' });
|
|
217
|
-
assert.equal(r.status, 1);
|
|
218
|
-
assert.match(r.stderr, /rejected/);
|
|
219
|
-
assert.ok(existsSync(sentinel), 'the sentinel must survive — claude output is never shell-eval\'d');
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
test('ai: VITRINKA_NO_AI=1 disables the command', async () => {
|
|
223
|
-
const r = await runCliAsync(['ai', 'anything'], { VITRINKA_NO_AI: '1' });
|
|
224
|
-
assert.equal(r.status, 1);
|
|
225
|
-
assert.match(r.stderr, /disabled/);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
// ---------- AI: fuzzy slug resolution (read auto-applies; write confirms) ----------
|
|
229
|
-
|
|
230
|
-
test('resolveFuzzy: exact match returns as-is (no claude call)', async () => {
|
|
231
|
-
const srv = stubApi(['fixit', 'reservine'], []);
|
|
232
|
-
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', () => r()));
|
|
233
|
-
const base = `http://127.0.0.1:${(srv.address() as { port: number }).port}`;
|
|
234
|
-
try {
|
|
235
|
-
const got = await resolveFuzzy('fixit', 'projects', { base, write: false, yes: false });
|
|
236
|
-
assert.equal(got, 'fixit');
|
|
237
|
-
} finally { srv.close(); }
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
test('resolveFuzzy: READ command auto-applies the claude-picked slug', async () => {
|
|
241
|
-
const srv = stubApi(['fixit', 'reservine'], []);
|
|
242
|
-
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', () => r()));
|
|
243
|
-
const base = `http://127.0.0.1:${(srv.address() as { port: number }).port}`;
|
|
244
|
-
const bin = join(makeTmp(), 'bin');
|
|
245
|
-
const stub = writeFakeBin(bin, 'claude-stub', `let s='';process.stdin.on('data',d=>s+=d);process.stdin.on('end',()=>{process.stdout.write('reservine');process.exit(0);});`);
|
|
246
|
-
try {
|
|
247
|
-
const got = await withEnv({ CLAUDE_BIN: stub, VITRINKA_NO_AI: undefined },
|
|
248
|
-
() => resolveFuzzy('reservin', 'projects', { base, write: false, yes: false }));
|
|
249
|
-
assert.equal(got, 'reservine');
|
|
250
|
-
} finally { srv.close(); }
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
test('resolveFuzzy: WRITE with --yes bypasses the confirm gate (returns the pick)', async () => {
|
|
254
|
-
const srv = stubApi([], ['payout-flow', 'onboarding']);
|
|
255
|
-
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', () => r()));
|
|
256
|
-
const base = `http://127.0.0.1:${(srv.address() as { port: number }).port}`;
|
|
257
|
-
const bin = join(makeTmp(), 'bin');
|
|
258
|
-
const stub = writeFakeBin(bin, 'claude-stub', `let s='';process.stdin.on('data',d=>s+=d);process.stdin.on('end',()=>{process.stdout.write('payout-flow');process.exit(0);});`);
|
|
259
|
-
try {
|
|
260
|
-
const got = await withEnv({ CLAUDE_BIN: stub, VITRINKA_NO_AI: undefined },
|
|
261
|
-
() => resolveFuzzy('payout', 'boards', { base, write: true, yes: true }));
|
|
262
|
-
assert.equal(got, 'payout-flow');
|
|
263
|
-
} finally { srv.close(); }
|
|
264
|
-
});
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
// embed.test.ts — embed-shots output shape, --select subsetting, missing-sips fallback.
|
|
2
|
-
// sips is mocked via a PATH-prefixed fake (copies input → --out) so no real resize runs.
|
|
3
|
-
import { test } from 'node:test';
|
|
4
|
-
import assert from 'node:assert/strict';
|
|
5
|
-
import { readFileSync, rmSync } from 'node:fs';
|
|
6
|
-
import { join } from 'node:path';
|
|
7
|
-
import { makeTmp, runCli, writeFakeSips, writeShotsFixture, PNG } from './_helpers.ts';
|
|
8
|
-
|
|
9
|
-
function envWithFakeSips(tmp: string): Record<string, string> {
|
|
10
|
-
const bin = writeFakeSips(join(tmp, 'fakebin'));
|
|
11
|
-
return { PATH: `${bin}:${process.env.PATH}` };
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
test('embed-shots: all shots → data.json with journey header + jpeg data URIs', () => {
|
|
15
|
-
const tmp = makeTmp();
|
|
16
|
-
const root = join(tmp, '.screenshots');
|
|
17
|
-
writeShotsFixture(root, 3);
|
|
18
|
-
const out = join(tmp, 'artifact', 'data.json');
|
|
19
|
-
const r = runCli(['embed-shots', '--root', root, '--out', out], { env: envWithFakeSips(tmp) });
|
|
20
|
-
assert.equal(r.status, 0);
|
|
21
|
-
assert.match(r.stdout, /embedded 3 shots → /);
|
|
22
|
-
|
|
23
|
-
const data = JSON.parse(readFileSync(out, 'utf8'));
|
|
24
|
-
assert.deepEqual(data.journey, {
|
|
25
|
-
kicker: 'TEST · FLOW', title: 'Test journey', accent: 'journey',
|
|
26
|
-
intro: 'Intro sentence.', chips: [{ k: 'Persona', v: 'Dev' }],
|
|
27
|
-
});
|
|
28
|
-
assert.equal(data.shots.length, 3);
|
|
29
|
-
for (const [i, s] of data.shots.entries()) {
|
|
30
|
-
assert.equal(s.label, `STEP ${i + 1}`);
|
|
31
|
-
assert.equal(s.title, `Step ${i + 1}`);
|
|
32
|
-
assert.equal(s.note, `note ${i + 1}`);
|
|
33
|
-
assert.equal(typeof s.ts, 'string');
|
|
34
|
-
assert.match(s.src, /^data:image\/jpeg;base64,/);
|
|
35
|
-
// fake sips copies the PNG bytes through — payload must round-trip
|
|
36
|
-
assert.deepEqual(Buffer.from(s.src.split(',')[1], 'base64'), PNG);
|
|
37
|
-
}
|
|
38
|
-
assert.equal(data.shots[0].route, '/home');
|
|
39
|
-
assert.equal(data.shots[2].route, '/end');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('embed-shots: --select picks a 1-based subset in manifest order', () => {
|
|
43
|
-
const tmp = makeTmp();
|
|
44
|
-
const root = join(tmp, '.screenshots');
|
|
45
|
-
writeShotsFixture(root, 3);
|
|
46
|
-
const out = join(tmp, 'data.json');
|
|
47
|
-
const r = runCli(['embed-shots', '--root', root, '--select', '1,3', '--out', out], { env: envWithFakeSips(tmp) });
|
|
48
|
-
assert.equal(r.status, 0);
|
|
49
|
-
const data = JSON.parse(readFileSync(out, 'utf8'));
|
|
50
|
-
assert.deepEqual(data.shots.map((s: { route: string }) => s.route), ['/home', '/end']);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test('embed-shots: bad --select exits 2 with a clear error', () => {
|
|
54
|
-
const tmp = makeTmp();
|
|
55
|
-
const root = join(tmp, '.screenshots');
|
|
56
|
-
writeShotsFixture(root, 2);
|
|
57
|
-
const r = runCli(['embed-shots', '--root', root, '--select', '9', '--out', join(tmp, 'd.json')], { env: envWithFakeSips(tmp) });
|
|
58
|
-
assert.equal(r.status, 2);
|
|
59
|
-
assert.match(r.stderr, /out of range/);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test('embed-shots: --out is required', () => {
|
|
63
|
-
const tmp = makeTmp();
|
|
64
|
-
const root = join(tmp, '.screenshots');
|
|
65
|
-
writeShotsFixture(root, 1);
|
|
66
|
-
const r = runCli(['embed-shots', '--root', root]);
|
|
67
|
-
assert.equal(r.status, 2);
|
|
68
|
-
assert.match(r.stderr, /--out .*is required/);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test('embed-shots: missing sips → embeds originals + warns once', () => {
|
|
72
|
-
const tmp = makeTmp();
|
|
73
|
-
const root = join(tmp, '.screenshots');
|
|
74
|
-
writeShotsFixture(root, 2);
|
|
75
|
-
const out = join(tmp, 'data.json');
|
|
76
|
-
// PATH without sips (the CLI itself is spawned via absolute node path)
|
|
77
|
-
const r = runCli(['embed-shots', '--root', root, '--out', out], { env: { PATH: '/var/empty' } });
|
|
78
|
-
assert.equal(r.status, 0);
|
|
79
|
-
assert.equal(r.stderr.match(/sips not found/g)?.length, 1); // warned exactly once
|
|
80
|
-
const data = JSON.parse(readFileSync(out, 'utf8'));
|
|
81
|
-
assert.equal(data.shots.length, 2);
|
|
82
|
-
for (const s of data.shots) {
|
|
83
|
-
assert.match(s.src, /^data:image\/png;base64,/); // original bytes, original mime
|
|
84
|
-
assert.deepEqual(Buffer.from(s.src.split(',')[1], 'base64'), PNG);
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('embed-shots: manifest entry pointing at a deleted file fails loudly', () => {
|
|
89
|
-
const tmp = makeTmp();
|
|
90
|
-
const root = join(tmp, '.screenshots');
|
|
91
|
-
writeShotsFixture(root, 2);
|
|
92
|
-
rmSync(join(root, '2026-07-03', '02-web-step2.png'));
|
|
93
|
-
const r = runCli(['embed-shots', '--root', root, '--out', join(tmp, 'd.json')], { env: { PATH: '/var/empty' } });
|
|
94
|
-
assert.equal(r.status, 1);
|
|
95
|
-
assert.match(r.stderr, /file missing/);
|
|
96
|
-
});
|
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
// index.test.ts — project index builder (project-index design 2026-07-05):
|
|
2
|
-
// route extraction per convention, file exclusion, hash-gated push.
|
|
3
|
-
import { test } from 'node:test';
|
|
4
|
-
import assert from 'node:assert/strict';
|
|
5
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
6
|
-
import { join } from 'node:path';
|
|
7
|
-
import { execSync, execFile } from 'node:child_process';
|
|
8
|
-
import { createServer } from 'node:http';
|
|
9
|
-
import { extractRoutes, buildProjectIndex } from '../../../pkg/src/cli.ts';
|
|
10
|
-
import { CLI, makeTmp } from './_helpers.ts';
|
|
11
|
-
|
|
12
|
-
test('extractRoutes: Expo Router — groups stripped, index collapsed, _layout/+not-found skipped', () => {
|
|
13
|
-
const routes = extractRoutes([
|
|
14
|
-
'app/_layout.tsx',
|
|
15
|
-
'app/+not-found.tsx',
|
|
16
|
-
'app/(tabs)/index.tsx',
|
|
17
|
-
'app/(tabs)/jobs/[id].tsx',
|
|
18
|
-
'app/payouts/index.tsx',
|
|
19
|
-
'src/components/JobCard.tsx',
|
|
20
|
-
'assets/logo.svg',
|
|
21
|
-
]);
|
|
22
|
-
assert.deepEqual(routes, [
|
|
23
|
-
{ p: '/', f: 'app/(tabs)/index.tsx' },
|
|
24
|
-
{ p: '/jobs/[id]', f: 'app/(tabs)/jobs/[id].tsx' },
|
|
25
|
-
{ p: '/payouts', f: 'app/payouts/index.tsx' },
|
|
26
|
-
]);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test('extractRoutes: Next.js app router — only page.* files are routes', () => {
|
|
30
|
-
const routes = extractRoutes([
|
|
31
|
-
'app/layout.tsx',
|
|
32
|
-
'app/page.tsx',
|
|
33
|
-
'app/jobs/[id]/page.tsx',
|
|
34
|
-
'app/jobs/[id]/loading.tsx',
|
|
35
|
-
'app/api/route.ts',
|
|
36
|
-
]);
|
|
37
|
-
assert.deepEqual(routes, [
|
|
38
|
-
{ p: '/', f: 'app/page.tsx' },
|
|
39
|
-
{ p: '/jobs/[id]', f: 'app/jobs/[id]/page.tsx' },
|
|
40
|
-
]);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('extractRoutes: Next.js pages router — _app and api/ skipped, src/ prefix ok', () => {
|
|
44
|
-
const routes = extractRoutes([
|
|
45
|
-
'src/pages/_app.tsx',
|
|
46
|
-
'src/pages/api/webhook.ts',
|
|
47
|
-
'src/pages/index.tsx',
|
|
48
|
-
'src/pages/jobs/[id].tsx',
|
|
49
|
-
]);
|
|
50
|
-
assert.deepEqual(routes, [
|
|
51
|
-
{ p: '/', f: 'src/pages/index.tsx' },
|
|
52
|
-
{ p: '/jobs/[id]', f: 'src/pages/jobs/[id].tsx' },
|
|
53
|
-
]);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('extractRoutes: unrecognized layout yields no routes, never throws', () => {
|
|
57
|
-
assert.deepEqual(extractRoutes(['cmd/main.go', 'internal/web/server.go']), []);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
function seedRepo(): string {
|
|
61
|
-
const repo = join(makeTmp(), 'FixIt');
|
|
62
|
-
mkdirSync(join(repo, 'app'), { recursive: true });
|
|
63
|
-
execSync('git init -q -b main', { cwd: repo });
|
|
64
|
-
writeFileSync(join(repo, 'app/index.tsx'), 'export default 1;');
|
|
65
|
-
writeFileSync(join(repo, 'logo.png'), 'x'); // binary-excluded
|
|
66
|
-
writeFileSync(join(repo, '.gitignore'), 'secret.ts\n');
|
|
67
|
-
writeFileSync(join(repo, 'secret.ts'), 'nope'); // gitignored
|
|
68
|
-
execSync('git add -A && git -c user.email=t@t -c user.name=t commit -qm init', { cwd: repo });
|
|
69
|
-
writeFileSync(join(repo, 'app/fresh.tsx'), 'export default 2;'); // untracked, unignored
|
|
70
|
-
return repo;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
test('buildProjectIndex: tracked + untracked-unignored files, binaries and ignored files out', () => {
|
|
74
|
-
const repo = seedRepo();
|
|
75
|
-
const built = buildProjectIndex(repo);
|
|
76
|
-
assert.ok(built);
|
|
77
|
-
const body = JSON.parse(built.body);
|
|
78
|
-
assert.equal(body.v, 1);
|
|
79
|
-
assert.deepEqual(body.files, ['.gitignore', 'app/fresh.tsx', 'app/index.tsx']);
|
|
80
|
-
assert.deepEqual(body.routes, [{ p: '/', f: 'app/index.tsx' }, { p: '/fresh', f: 'app/fresh.tsx' }]);
|
|
81
|
-
assert.equal(built.hash.length, 64);
|
|
82
|
-
// Outside a repo → null, no throw.
|
|
83
|
-
assert.equal(buildProjectIndex(makeTmp()), null);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
// runCliAsync: the fake vitrinka server lives in THIS process, so the CLI
|
|
87
|
-
// must run async — spawnSync would block the event loop and the server could
|
|
88
|
-
// never answer (the fetch would only ever time out).
|
|
89
|
-
function runCliAsync(cliArgs: string[], cwd: string, env: Record<string, string> = {}): Promise<{ status: number; stdout: string; stderr: string }> {
|
|
90
|
-
return new Promise((res) => {
|
|
91
|
-
execFile(process.execPath, [CLI, ...cliArgs], { cwd, encoding: 'utf8', env: { ...process.env, ...env } },
|
|
92
|
-
(err, stdout, stderr) => res({ status: err ? (err as { code?: number }).code ?? 1 : 0, stdout, stderr }));
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// A stateful fake vitrinka: remembers the pushed index like the real server
|
|
97
|
-
// (hash per PUT), serves conditional GETs (304 on ETag match, 404 when the
|
|
98
|
-
// row is gone), and can be wiped mid-test to simulate a server reset.
|
|
99
|
-
function fakeVitrinka() {
|
|
100
|
-
let stored: string | null = null;
|
|
101
|
-
let seq = 0;
|
|
102
|
-
const requests: string[] = [];
|
|
103
|
-
const srv = createServer((req, res) => {
|
|
104
|
-
let body = '';
|
|
105
|
-
req.on('data', (c) => { body += c; });
|
|
106
|
-
req.on('end', () => {
|
|
107
|
-
requests.push(`${req.method} ${req.url}`);
|
|
108
|
-
if (req.url === '/wipe') { stored = null; res.writeHead(200); return res.end(); }
|
|
109
|
-
if (req.method === 'PUT') {
|
|
110
|
-
stored = `srv-hash-${++seq}`;
|
|
111
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
112
|
-
return res.end(JSON.stringify({ hash: stored }));
|
|
113
|
-
}
|
|
114
|
-
if (!stored) { res.writeHead(404); return res.end('{"error":"no index"}'); }
|
|
115
|
-
if (req.headers['if-none-match'] === `"${stored}"`) { res.writeHead(304); return res.end(); }
|
|
116
|
-
res.writeHead(200, { ETag: `"${stored}"`, 'Content-Type': 'application/json' });
|
|
117
|
-
res.end('{}');
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
return { srv, requests };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
test('index command: pushes once, hash-gates re-runs, and re-pushes after a server wipe', async () => {
|
|
124
|
-
const repo = seedRepo();
|
|
125
|
-
const { srv, requests } = fakeVitrinka();
|
|
126
|
-
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', () => r()));
|
|
127
|
-
const port = (srv.address() as { port: number }).port;
|
|
128
|
-
const base = `http://127.0.0.1:${port}`;
|
|
129
|
-
// Hermetic HOME: the real ~/.claude palette must not leak into the index hash.
|
|
130
|
-
const home = makeTmp();
|
|
131
|
-
const run = () => runCliAsync(['index', '--root', join(repo, '.screenshots'), '--base', base], repo, { HOME: home });
|
|
132
|
-
try {
|
|
133
|
-
const r1 = await run();
|
|
134
|
-
assert.equal(r1.status, 0, r1.stderr);
|
|
135
|
-
assert.match(r1.stdout, /index ↑ fixit: 2 routes, 3 files, 0 commands/);
|
|
136
|
-
assert.deepEqual(requests, ['PUT /api/v1/projects/fixit/index']);
|
|
137
|
-
assert.ok(existsSync(join(repo, '.git', 'vitrinka-index.cache')));
|
|
138
|
-
|
|
139
|
-
// Unchanged re-run: the explicit command VERIFIES the server still holds
|
|
140
|
-
// the index — one conditional GET (304, zero body), never a re-upload.
|
|
141
|
-
const r2 = await run();
|
|
142
|
-
assert.equal(r2.status, 0, r2.stderr);
|
|
143
|
-
assert.deepEqual(requests.slice(1), ['GET /api/v1/projects/fixit/index']);
|
|
144
|
-
|
|
145
|
-
// Server-side loss (DB reset): the local stamp alone must NOT report
|
|
146
|
-
// success — the 404 invalidates it and the index is re-pushed.
|
|
147
|
-
await fetch(`${base}/wipe`);
|
|
148
|
-
const r3 = await run();
|
|
149
|
-
assert.equal(r3.status, 0, r3.stderr);
|
|
150
|
-
assert.deepEqual(requests.slice(3), ['GET /api/v1/projects/fixit/index', 'PUT /api/v1/projects/fixit/index']);
|
|
151
|
-
|
|
152
|
-
// A new file invalidates the content gate directly (straight to PUT).
|
|
153
|
-
writeFileSync(join(repo, 'app/more.tsx'), 'export default 3;');
|
|
154
|
-
const r4 = await run();
|
|
155
|
-
assert.equal(r4.status, 0, r4.stderr);
|
|
156
|
-
assert.deepEqual(requests.slice(5), ['PUT /api/v1/projects/fixit/index']);
|
|
157
|
-
} finally {
|
|
158
|
-
srv.close();
|
|
159
|
-
}
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test('shotContext: src repeat/comma forms, state, device + viewport', async () => {
|
|
163
|
-
const { shotContext, parseArgs } = await import('../../../pkg/src/cli.ts');
|
|
164
|
-
const args = parseArgs(['--src', 'app/payouts/index.tsx', '--src', 'a.ts,b.ts',
|
|
165
|
-
'--state', 'courier · cs', '--device', 'iPhone 15', '--viewport', '393x852@3']);
|
|
166
|
-
assert.deepEqual(shotContext(args), {
|
|
167
|
-
src: ['app/payouts/index.tsx', 'a.ts', 'b.ts'],
|
|
168
|
-
state: 'courier · cs',
|
|
169
|
-
device: { device: 'iPhone 15', w: 393, h: 852, scale: 3 },
|
|
170
|
-
});
|
|
171
|
-
assert.deepEqual(shotContext(parseArgs([])), {}); // all optional
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
test('add: v2 manifest carries src/state and the session commit/branch', async () => {
|
|
175
|
-
const repo = seedRepo();
|
|
176
|
-
const root = join(repo, '.screenshots');
|
|
177
|
-
mkdirSync(root, { recursive: true });
|
|
178
|
-
writeFileSync(join(root, 'shot.png'), 'x');
|
|
179
|
-
const { execFileSync } = await import('node:child_process');
|
|
180
|
-
execFileSync(process.execPath, [CLI, 'add', '--root', root, '--file', 'shot.png',
|
|
181
|
-
'--surface', 'ios', '--route', '/payouts', '--note', 'n',
|
|
182
|
-
'--src', 'app/index.tsx', '--state', 'seed user A'], { cwd: repo });
|
|
183
|
-
const m = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
|
|
184
|
-
assert.equal(m.version, 2);
|
|
185
|
-
assert.match(m.commit, /^[0-9a-f]{7,}$/);
|
|
186
|
-
assert.equal(m.branch, 'main');
|
|
187
|
-
assert.deepEqual(m.shots[0].src, ['app/index.tsx']);
|
|
188
|
-
assert.equal(m.shots[0].state, 'seed user A');
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test('collectCommands: project + home palettes, ns from dirs, _internals skipped, project wins', async () => {
|
|
192
|
-
const { collectCommands } = await import('../../../pkg/src/cli.ts');
|
|
193
|
-
const proj = join(makeTmp(), 'app');
|
|
194
|
-
const home = join(makeTmp(), '.claude');
|
|
195
|
-
const md = (p: string, desc?: string) => {
|
|
196
|
-
mkdirSync(join(p, '..'), { recursive: true });
|
|
197
|
-
writeFileSync(p, desc ? `---\nname: x\ndescription: "${desc}"\n---\nbody\n` : 'no frontmatter\n');
|
|
198
|
-
};
|
|
199
|
-
md(join(proj, '.claude/commands/deploy.md'), 'Deploy the app');
|
|
200
|
-
md(join(proj, '.claude/commands/db/reset.md'));
|
|
201
|
-
md(join(proj, '.claude/skills/review/SKILL.md'), 'Project review');
|
|
202
|
-
md(join(proj, '.claude/skills/git/_shared/SKILL.md'), 'internal'); // _dir → skipped
|
|
203
|
-
md(join(home, 'commands/prc.md'), 'Ensure an open PR exists');
|
|
204
|
-
md(join(home, 'skills/git/merge/SKILL.md'), 'Merge the current PR');
|
|
205
|
-
md(join(home, 'commands/deploy.md'), 'HOME deploy (must lose to project'); // collision → project wins
|
|
206
|
-
const cmds = collectCommands(proj, home);
|
|
207
|
-
assert.deepEqual(cmds, [
|
|
208
|
-
{ c: '/db:reset' },
|
|
209
|
-
{ c: '/deploy', d: 'Deploy the app' },
|
|
210
|
-
{ c: '/git:merge', d: 'Merge the current PR', s: 'home' },
|
|
211
|
-
{ c: '/prc', d: 'Ensure an open PR exists', s: 'home' },
|
|
212
|
-
{ c: '/review', d: 'Project review' },
|
|
213
|
-
]);
|
|
214
|
-
// Absent roots are fine (no .claude anywhere).
|
|
215
|
-
assert.deepEqual(collectCommands(makeTmp(), join(makeTmp(), 'nope')), []);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test('extractRoutes: monorepo workspace roots (apps/<name>/app, packages/<name>/src/app)', () => {
|
|
219
|
-
const routes = extractRoutes([
|
|
220
|
-
'apps/client/app/(tabs)/home.tsx',
|
|
221
|
-
'apps/client/app/payouts/index.tsx',
|
|
222
|
-
'apps/client/app/_layout.tsx',
|
|
223
|
-
'apps/client/lib/app/util.ts', // NOT a router root (app/ not at container root)
|
|
224
|
-
'packages/kiosk/src/app/scan.tsx',
|
|
225
|
-
]);
|
|
226
|
-
const paths = routes.map((r) => r.p);
|
|
227
|
-
assert.deepEqual(paths, ['/home', '/payouts', '/scan']);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
test('extractRoutes: per-root next mode — Next app beside an Expo app in one monorepo', () => {
|
|
231
|
-
const routes = extractRoutes([
|
|
232
|
-
'apps/web/app/pricing/page.tsx', // Next app-router: only page.* are routes
|
|
233
|
-
'apps/web/app/pricing/layout.tsx', // not a route in next mode
|
|
234
|
-
'apps/client/app/jobs/[id].tsx', // Expo root must NOT inherit next mode
|
|
235
|
-
]);
|
|
236
|
-
const paths = routes.map((r) => r.p).sort();
|
|
237
|
-
assert.deepEqual(paths, ['/jobs/[id]', '/pricing']);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
test('extractRoutes: cross-root route collision — first file in input order wins, duplicate dropped', () => {
|
|
241
|
-
const routes = extractRoutes([
|
|
242
|
-
'apps/web/app/index.tsx',
|
|
243
|
-
'apps/client/app/index.tsx', // same "/" — dropped
|
|
244
|
-
'apps/client/app/jobs.tsx',
|
|
245
|
-
]);
|
|
246
|
-
assert.deepEqual(routes.map((r) => ({ p: r.p, f: r.f })), [
|
|
247
|
-
{ p: '/', f: 'apps/web/app/index.tsx' },
|
|
248
|
-
{ p: '/jobs', f: 'apps/client/app/jobs.tsx' },
|
|
249
|
-
]);
|
|
250
|
-
});
|