@lovinka/vitrinka 1.1.1 → 1.5.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 +28 -0
- package/dist/cli.js +524 -10
- package/dist/cli.js.map +1 -1
- package/dist/lib/token.js +26 -0
- package/dist/lib/token.js.map +1 -1
- package/dist/mcp.js +451 -19
- package/dist/mcp.js.map +1 -1
- package/package.json +6 -4
- package/skills/artifact/SKILL.md +82 -0
- package/skills/brainstorming/SKILL.md +107 -0
- package/skills/screenshot/SKILL.md +70 -0
- package/skills/screenshot/gallery.mjs +6 -0
- package/skills/vitrinka/cli.ts +39 -0
- package/skills/vitrinka/commands/listen.md +10 -0
- package/skills/vitrinka/listen/SKILL.md +193 -0
- package/skills/vitrinka/tests/_helpers.ts +108 -0
- package/skills/vitrinka/tests/derive.test.ts +118 -0
- package/skills/vitrinka/tests/dx.test.ts +264 -0
- package/skills/vitrinka/tests/embed.test.ts +96 -0
- package/skills/vitrinka/tests/index.test.ts +250 -0
- package/skills/vitrinka/tests/install-name.test.ts +111 -0
- package/skills/vitrinka/tests/manifest.test.ts +98 -0
- package/skills/vitrinka/tests/operator.test.ts +119 -0
- package/skills/vitrinka/tests/push.test.ts +115 -0
- package/skills/vitrinka/tests/scaffold.test.ts +173 -0
- package/skills/vitrinka/tests/shim.test.ts +28 -0
- package/skills/vitrinka/tests/snap.test.ts +175 -0
- package/skills/vitrinka/tests/watch.test.ts +204 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: listen
|
|
3
|
+
description: "Tune this Claude Code session into a vitrinka annotation board and work its queue continuously — armed by a NATIVE background monitor (`vitrinka watch`) whose every line re-invokes the session, so idle costs zero tokens and a finished turn still wakes on new work. Fix each annotation in the current repo, push proof shots, honor cancels. Invoke as /vitrinka:listen [board-slug] FROM THE APP'S REPO."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /vitrinka:listen — the listening session (native Monitor)
|
|
7
|
+
|
|
8
|
+
You are the **worker half of an audit loop**: the user looks at their app's
|
|
9
|
+
screens on a vitrinka board and annotates what's wrong; you fix each annotation
|
|
10
|
+
in THIS repository, attach visual proof, and go back to listening.
|
|
11
|
+
|
|
12
|
+
**How the listening works (read this — it changed).** You do NOT loop on
|
|
13
|
+
`wait_for_work` in-model. Instead you arm Claude Code's **native background
|
|
14
|
+
Monitor** on `vitrinka watch`: a persistent script that long-polls the work
|
|
15
|
+
queue and prints ONE line per NEW annotation. Each line re-invokes this session;
|
|
16
|
+
while the queue is idle it prints nothing and costs nothing. This fixes the two
|
|
17
|
+
fatal flaws of the old in-model loop: (1) a finished turn can't re-invoke itself
|
|
18
|
+
via MCP, so the session that published the board never heard new work; (2) even
|
|
19
|
+
a looping session burned one context entry per idle heartbeat and eventually
|
|
20
|
+
died by compaction. Silence now means "healthy and idle", forever.
|
|
21
|
+
|
|
22
|
+
Argument: an optional board slug (`/vitrinka:listen fixit-audit`). **Without a
|
|
23
|
+
board it now AUTO-SCOPES to this repo + branch** — it infers the project from the
|
|
24
|
+
repo (the main worktree's name, the same derivation `vitrinka push` uses) and the
|
|
25
|
+
current git branch, and listens for exactly that project+branch's work. This is
|
|
26
|
+
what lets several Claude sessions listen at once without stepping on each other:
|
|
27
|
+
each session's listener scopes to its own work.
|
|
28
|
+
|
|
29
|
+
## One listener per scope (the multi-session model)
|
|
30
|
+
|
|
31
|
+
Multiple Claude sessions can work concurrently on different things, and each arms
|
|
32
|
+
its own scoped listener. The server enforces **at most one live listener per
|
|
33
|
+
scope** — the same board (or the same project+branch) cannot be listened to twice:
|
|
34
|
+
|
|
35
|
+
- The listener *leases* its scope through the `vitrinka watch` long-poll — that
|
|
36
|
+
long-poll IS the heartbeat, so if this session (or its process) dies the lease
|
|
37
|
+
lapses within ~90s and any work it had claimed reverts to the queue for another
|
|
38
|
+
session. A clean stop (the Monitor being torn down) releases the lease
|
|
39
|
+
immediately.
|
|
40
|
+
- If another live session already holds this scope, `vitrinka watch` prints
|
|
41
|
+
`⚠ listener already active (live): <actor>@<host:pid> on <scope>` and exits
|
|
42
|
+
(code 2). **Tell the user which session holds it** and stop — do not re-arm in a
|
|
43
|
+
loop. They can listen from that session, pick a different board, or (if the
|
|
44
|
+
named holder is actually dead) pass `--takeover`.
|
|
45
|
+
- `--takeover` steals only an **expired** lease (a crashed/hard-killed session
|
|
46
|
+
whose lease hasn't been released). It can never steal a live one.
|
|
47
|
+
|
|
48
|
+
Scope flags (all optional): `--board <slug>` (one board), `--project <p>
|
|
49
|
+
--branch <b>` (override the inferred repo scope), `--all` (the firehose — observe
|
|
50
|
+
every board; `--all` conflicts with nothing and claims no scope, so use it only
|
|
51
|
+
for a read-only overview session, never to work items another session owns).
|
|
52
|
+
|
|
53
|
+
## Preconditions — check once, then arm
|
|
54
|
+
|
|
55
|
+
1. You are in the app repository the board's screenshots come from
|
|
56
|
+
(`git rev-parse --show-toplevel` works and the project matches what capsules
|
|
57
|
+
will reference). If the cwd is clearly not an app repo, say so and stop.
|
|
58
|
+
2. The `vitrinka` MCP tools are available (`wait_for_work`, `set_status`,
|
|
59
|
+
`reply`, `attach_after`, `get_capsule`). If not, tell the user to run
|
|
60
|
+
`claude mcp add --scope user vitrinka -- npx -y --package=@lovinka/vitrinka vitrinka-mcp`
|
|
61
|
+
(or, on a repo checkout, `-- node <vitrinka-repo>/mcp/server.ts`).
|
|
62
|
+
3. The **Monitor tool** is available in this harness. If it is NOT, use the
|
|
63
|
+
FALLBACK loop at the bottom of this file instead.
|
|
64
|
+
|
|
65
|
+
## Arm the monitor, then END THE TURN
|
|
66
|
+
|
|
67
|
+
Use the `vitrinka` binary on PATH (installed by `install.sh` / `npm i -g
|
|
68
|
+
@lovinka/vitrinka`). If it is not on PATH (a repo-dev machine without the shim),
|
|
69
|
+
fall back to `node <vitrinka-repo>/pkg/src/cli.ts watch`.
|
|
70
|
+
|
|
71
|
+
Arm the monitor (the schema requires `timeout_ms` and `persistent` even though
|
|
72
|
+
`timeout_ms` is ignored when `persistent` is true — pass both):
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Monitor({
|
|
76
|
+
command: "vitrinka watch", // auto-scopes to this repo+branch; add --board <slug> for one board, --all for the firehose
|
|
77
|
+
persistent: true,
|
|
78
|
+
timeout_ms: 300000,
|
|
79
|
+
description: "vitrinka work queue (<repo/branch|board|all>)"
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**Default is auto-scope** — no `--board` needed. `vitrinka watch` with no scope
|
|
84
|
+
flag infers project+branch from the repo you are in and leases that scope. If the
|
|
85
|
+
monitor's FIRST output line is a `⚠ listener already active …` (exit 2), another
|
|
86
|
+
session already owns this scope: surface which one and stop (see the multi-session
|
|
87
|
+
section above); do not silently re-arm.
|
|
88
|
+
|
|
89
|
+
Then, in the SAME turn:
|
|
90
|
+
|
|
91
|
+
1. Announce exactly one line: `⏳ listening on <board|all boards> — annotate away`.
|
|
92
|
+
2. **END THE TURN.** Do not loop, do not poll, do not sleep. Idle costs nothing;
|
|
93
|
+
the monitor's next line re-invokes you. This is the whole point — a finished
|
|
94
|
+
turn is fine, the monitor is what wakes you.
|
|
95
|
+
|
|
96
|
+
## On a monitor notification — drain the queue
|
|
97
|
+
|
|
98
|
+
A monitor line looks like `№<id> [<intent>] <board>: <prompt…>` — one per new
|
|
99
|
+
annotation. When you're re-invoked by one (or a batch):
|
|
100
|
+
|
|
101
|
+
1. **Drain via MCP, SCOPED THE SAME WAY.** Call `wait_for_work({ …scope,
|
|
102
|
+
timeoutSec: 1 })` and loop it until it returns `{"idle":true}`, working each
|
|
103
|
+
returned capsule serially, oldest first, under the per-item rules below. Pass
|
|
104
|
+
the SAME scope the monitor uses so you only drain your own work: `{ board }`
|
|
105
|
+
for a board listener, or `{ project, branch }` for the auto-scoped repo
|
|
106
|
+
listener. Use `timeoutSec: 1` (NOT 50) — the monitor is what waits; here you're
|
|
107
|
+
just draining what's ready right now, not blocking.
|
|
108
|
+
2. When `wait_for_work` returns idle, the ready queue is empty. **End the turn
|
|
109
|
+
again.** The monitor stays armed (it's `persistent`) and will re-invoke you on
|
|
110
|
+
the next annotation. Never disarm it yourself.
|
|
111
|
+
|
|
112
|
+
Do NOT re-arm the monitor on each notification — it is already running for the
|
|
113
|
+
session's lifetime.
|
|
114
|
+
|
|
115
|
+
## Working one item
|
|
116
|
+
|
|
117
|
+
1. **Claim it**: `set_status {id, status: "working"}` — the user's board pin
|
|
118
|
+
flips amber live. If this returns a 409 (cancelled / already claimed), skip
|
|
119
|
+
the item silently.
|
|
120
|
+
2. **Announce the plan**: `reply` with ONE terse line ("tightening hero contrast
|
|
121
|
+
on the dark theme").
|
|
122
|
+
3. **Understand**: the capsule has the ask, the region, and a crop URL — fetch
|
|
123
|
+
the crop image if seeing the region matters (it usually does). `get_capsule
|
|
124
|
+
{id}` re-fetches the latest brief after user edits.
|
|
125
|
+
4. **Fix it in this repo.** Scope discipline: the annotated ask only — no
|
|
126
|
+
drive-by refactors. Commit with a conventional message referencing №id.
|
|
127
|
+
5. **Prove it**: regenerate the screen(s) and push a new set the way this repo
|
|
128
|
+
does it (its CLAUDE.md / `vitrinka snap` / journey script). Then `attach_after
|
|
129
|
+
{id, project, branch, selector, file, commit}` pointing at the fixed screen
|
|
130
|
+
inside the set you just pushed.
|
|
131
|
+
6. **Report**: `reply` with 1-3 lines (what changed, commit, anything the user
|
|
132
|
+
should eyeball), then `set_status {id, status: "in_review"}`.
|
|
133
|
+
|
|
134
|
+
Progress notes: at most one `reply` per meaningful moment (plan, pushed vN).
|
|
135
|
+
Never spam the thread; never post "still working".
|
|
136
|
+
|
|
137
|
+
## Cancels (the user changed their mind)
|
|
138
|
+
|
|
139
|
+
Any vitrinka tool returning **409 "cancelled"** for an item means the user
|
|
140
|
+
withdrew it mid-flight:
|
|
141
|
+
|
|
142
|
+
1. Revert the working-tree changes you made FOR THAT ITEM (`git checkout` / drop
|
|
143
|
+
the WIP commit — surgical, don't touch unrelated state).
|
|
144
|
+
2. Do not reply to the cancelled annotation (writes bounce); mention the
|
|
145
|
+
cancellation in your session output only.
|
|
146
|
+
3. Move on to the next ready item as if it never existed.
|
|
147
|
+
|
|
148
|
+
## Degraded / down
|
|
149
|
+
|
|
150
|
+
If a monitor line reads `⚠ vitrinka unreachable for <n>s — listener degraded`,
|
|
151
|
+
tell the user vitrinka looks down and that the listener is retrying — **keep the
|
|
152
|
+
monitor armed** (it self-recovers and will print `✓ vitrinka reachable again`).
|
|
153
|
+
Do not disarm or re-arm it.
|
|
154
|
+
|
|
155
|
+
## Rules
|
|
156
|
+
|
|
157
|
+
- **Never `set_status resolved`** — only the user accepts, on the board.
|
|
158
|
+
- Statuses are the UI: claim before touching code, in_review only after the
|
|
159
|
+
after-shot is attached.
|
|
160
|
+
- One item at a time; the queue is FIFO — take the oldest first.
|
|
161
|
+
- If a fix genuinely needs the user's input, `reply` with the question,
|
|
162
|
+
`set_status {status: "open"}` to put it back, and end the turn — any plain
|
|
163
|
+
reply in the thread re-queues the item and the monitor wakes you again
|
|
164
|
+
(annotation threads route to claude by default; only explicit @eve turns go to
|
|
165
|
+
eve).
|
|
166
|
+
- If your context grows unwieldy after many fixes, finish the current item, tell
|
|
167
|
+
the user to restart the listener, and stop.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## FALLBACK — in-model loop (only if the Monitor tool is unavailable)
|
|
172
|
+
|
|
173
|
+
If this harness has **no Monitor tool**, you cannot arm a background watcher —
|
|
174
|
+
fall back to the old in-model loop. It works but is inferior: it burns context
|
|
175
|
+
while idle and the *publishing* session can't self-wake once its turn ends, so
|
|
176
|
+
prefer the Monitor path whenever it exists.
|
|
177
|
+
|
|
178
|
+
1. Announce `⏳ listening on <board|all boards> — annotate away`.
|
|
179
|
+
2. Loop, never exiting on your own:
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
forever:
|
|
183
|
+
r = wait_for_work({ board?, timeoutSec: 50 })
|
|
184
|
+
if r.idle → call wait_for_work again (free; do NOT sleep, do NOT stop)
|
|
185
|
+
for each capsule in r.work (serially, oldest first):
|
|
186
|
+
work the item (per "Working one item" above)
|
|
187
|
+
→ wait_for_work again
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`{"idle":true}` is the normal heartbeat of an empty queue — loop silently. Never
|
|
191
|
+
conclude "no work, I'm done". The loop only ends when the user interrupts you, or
|
|
192
|
+
when your context grows unwieldy (finish the current item, tell the user to
|
|
193
|
+
restart the listener, and stop — the ONLY self-initiated exit).
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// _helpers.ts — shared fixtures for the vitrinka CLI test suites. Not a test file itself.
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
// The CLI moved into the unified @lovinka/vitrinka package; tests run its source
|
|
9
|
+
// directly. The gallery.mjs shim (and skills/vitrinka/cli.ts delegator) still
|
|
10
|
+
// forward here — covered by shim.test.ts.
|
|
11
|
+
export const CLI = fileURLToPath(new URL('../../../pkg/src/cli.ts', import.meta.url));
|
|
12
|
+
export const SHIM = fileURLToPath(new URL('../../screenshot/gallery.mjs', import.meta.url));
|
|
13
|
+
|
|
14
|
+
// 1x1 transparent PNG.
|
|
15
|
+
export const PNG = Buffer.from(
|
|
16
|
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
|
17
|
+
'base64',
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export function makeTmp(): string {
|
|
21
|
+
return mkdtempSync(join(tmpdir(), 'vitrinka-test-'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type RunResult = { status: number | null; stdout: string; stderr: string };
|
|
25
|
+
|
|
26
|
+
export function runCli(cliArgs: string[], opts: { cwd?: string; env?: Record<string, string> } = {}): RunResult {
|
|
27
|
+
const r = spawnSync(process.execPath, [CLI, ...cliArgs], {
|
|
28
|
+
encoding: 'utf8',
|
|
29
|
+
cwd: opts.cwd,
|
|
30
|
+
env: { ...process.env, ...opts.env },
|
|
31
|
+
});
|
|
32
|
+
if (r.error) throw r.error;
|
|
33
|
+
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function runShim(shimArgs: string[], opts: { cwd?: string } = {}): RunResult {
|
|
37
|
+
const r = spawnSync(process.execPath, [SHIM, ...shimArgs], { encoding: 'utf8', cwd: opts.cwd });
|
|
38
|
+
if (r.error) throw r.error;
|
|
39
|
+
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// writeFakeBin drops an arbitrary Node-script executable into binDir (for
|
|
43
|
+
// faking capture tools like xcrun/cwebp on PATH).
|
|
44
|
+
export function writeFakeBin(binDir: string, name: string, script: string): string {
|
|
45
|
+
mkdirSync(binDir, { recursive: true });
|
|
46
|
+
const p = join(binDir, name);
|
|
47
|
+
writeFileSync(p, `#!/usr/bin/env node\n${script}`);
|
|
48
|
+
chmodSync(p, 0o755);
|
|
49
|
+
return p;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// noWebpPath returns a PATH prefixed with a failing `cwebp`, pinning snap's
|
|
53
|
+
// keep-the-original fallback — otherwise assertions on .png filenames break on
|
|
54
|
+
// machines where `brew install webp` happened.
|
|
55
|
+
export function noWebpPath(): string {
|
|
56
|
+
const bin = join(makeTmp(), 'bin');
|
|
57
|
+
writeFakeBin(bin, 'cwebp', 'process.exit(1);');
|
|
58
|
+
return `${bin}:${process.env.PATH}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A fake `sips` on PATH: copies <src> to the --out path (stands in for resize+jpeg).
|
|
62
|
+
export function writeFakeSips(binDir: string): string {
|
|
63
|
+
mkdirSync(binDir, { recursive: true });
|
|
64
|
+
const p = join(binDir, 'sips');
|
|
65
|
+
writeFileSync(p, `#!/usr/bin/env node
|
|
66
|
+
const fs = require('node:fs');
|
|
67
|
+
const a = process.argv.slice(2);
|
|
68
|
+
const dst = a[a.indexOf('--out') + 1];
|
|
69
|
+
const src = a.find((x) => x !== dst && fs.existsSync(x) && fs.statSync(x).isFile());
|
|
70
|
+
if (!src || !dst) { console.error('fake sips: bad args ' + a.join(' ')); process.exit(1); }
|
|
71
|
+
fs.copyFileSync(src, dst);
|
|
72
|
+
`);
|
|
73
|
+
chmodSync(p, 0o755);
|
|
74
|
+
return binDir;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A screenshots root with a journey header + n sequenced shots (real PNG bytes).
|
|
78
|
+
export function writeShotsFixture(root: string, n = 3): void {
|
|
79
|
+
const day = '2026-07-03';
|
|
80
|
+
mkdirSync(join(root, day), { recursive: true });
|
|
81
|
+
const routes = ['/home', '/detail', '/end', '/extra'];
|
|
82
|
+
const shots = [];
|
|
83
|
+
for (let i = 1; i <= n; i++) {
|
|
84
|
+
const file = `${day}/${String(i).padStart(2, '0')}-web-step${i}.png`;
|
|
85
|
+
writeFileSync(join(root, file), PNG);
|
|
86
|
+
shots.push({
|
|
87
|
+
file,
|
|
88
|
+
surface: 'web',
|
|
89
|
+
route: routes[i - 1] || `/r${i}`,
|
|
90
|
+
note: `note ${i}`,
|
|
91
|
+
label: `STEP ${i}`,
|
|
92
|
+
title: `Step ${i}`,
|
|
93
|
+
ts: `2026-07-03T10:0${i}:00Z`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const manifest = {
|
|
97
|
+
version: 2,
|
|
98
|
+
journey: {
|
|
99
|
+
kicker: 'TEST · FLOW',
|
|
100
|
+
title: 'Test journey',
|
|
101
|
+
accent: 'journey',
|
|
102
|
+
intro: 'Intro sentence.',
|
|
103
|
+
chips: [{ k: 'Persona', v: 'Dev' }],
|
|
104
|
+
},
|
|
105
|
+
shots,
|
|
106
|
+
};
|
|
107
|
+
writeFileSync(join(root, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
|
|
108
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// derive.test.ts — pure helpers: slugs, sequence/filename derivation, --select parsing, udid lookup.
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { execSync } from 'node:child_process';
|
|
7
|
+
import { basename } from 'node:path';
|
|
8
|
+
import { kebab, sanitizeSeg, validKey, nextSeq, deriveShotName, parseSelect, findUdid, parseArgs, mainWorktreeTop } from '../../../pkg/src/cli.ts';
|
|
9
|
+
import { makeTmp } from './_helpers.ts';
|
|
10
|
+
|
|
11
|
+
test('kebab: human titles → filename slugs', () => {
|
|
12
|
+
assert.equal(kebab('Payout Gate — Sheet!'), 'payout-gate-sheet');
|
|
13
|
+
assert.equal(kebab('Login Error'), 'login-error');
|
|
14
|
+
assert.equal(kebab('Přihlášení'), 'prihlaseni'); // diacritics folded
|
|
15
|
+
assert.equal(kebab('Domov — dlaždice „Hned teď“'), 'domov-dlazdice-hned-ted');
|
|
16
|
+
assert.equal(kebab('!!!'), 'shot'); // never empty
|
|
17
|
+
assert.equal(kebab('x'.repeat(100)).length <= 60, true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('sanitizeSeg mirrors vitrinka slug rules', () => {
|
|
21
|
+
assert.equal(sanitizeSeg('Feature/Payouts', 64), 'feature-payouts');
|
|
22
|
+
assert.equal(sanitizeSeg('..hidden..', 64), 'hidden');
|
|
23
|
+
assert.equal(sanitizeSeg('a--b___c', 64), 'a-b___c'); // underscores allowed, dashes collapse
|
|
24
|
+
assert.equal(sanitizeSeg('', 5), 'x');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('nextSeq: max sequenced prefix + 1, ignoring non-shot files', () => {
|
|
28
|
+
const tmp = makeTmp();
|
|
29
|
+
const dir = join(tmp, '2026-07-03');
|
|
30
|
+
assert.equal(nextSeq(dir), '01'); // dir does not exist yet
|
|
31
|
+
mkdirSync(dir, { recursive: true });
|
|
32
|
+
assert.equal(nextSeq(dir), '01');
|
|
33
|
+
writeFileSync(join(dir, '01-web-a.png'), 'x');
|
|
34
|
+
writeFileSync(join(dir, '02-ios-b.png'), 'x');
|
|
35
|
+
writeFileSync(join(dir, 'index.html'), 'x'); // non-shot files don't count
|
|
36
|
+
writeFileSync(join(dir, '.DS_Store'), 'x');
|
|
37
|
+
assert.equal(nextSeq(dir), '03');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('nextSeq never reuses a surviving sequence after a manual shot deletion', () => {
|
|
41
|
+
const tmp = makeTmp();
|
|
42
|
+
const dir = join(tmp, '2026-07-03');
|
|
43
|
+
mkdirSync(dir, { recursive: true });
|
|
44
|
+
writeFileSync(join(dir, '01-web-a.png'), 'x');
|
|
45
|
+
writeFileSync(join(dir, '02-web-b.png'), 'x');
|
|
46
|
+
writeFileSync(join(dir, '03-web-c.png'), 'x');
|
|
47
|
+
rmSync(join(dir, '02-web-b.png')); // SKILL-documented curation: delete a bad shot
|
|
48
|
+
// count-based derivation would say '03' and overwrite 03-web-c.png on re-capture
|
|
49
|
+
assert.equal(nextSeq(dir), '04');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('mainWorktreeTop: a linked worktree resolves to the MAIN checkout (project grouping)', () => {
|
|
53
|
+
const tmp = makeTmp();
|
|
54
|
+
const main = join(tmp, 'FixIt');
|
|
55
|
+
const wt = join(tmp, 'cpi-admin-ui-overhaul');
|
|
56
|
+
const sh = (cmd: string, cwd: string) => execSync(cmd, { cwd, stdio: 'pipe' });
|
|
57
|
+
mkdirSync(main, { recursive: true });
|
|
58
|
+
sh('git init -q -b main', main);
|
|
59
|
+
sh('git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init', main);
|
|
60
|
+
sh(`git worktree add -q -b cpi-admin-ui-overhaul ${JSON.stringify(wt)}`, main);
|
|
61
|
+
// From the worktree the project must be the main repo dir, not the branch dir.
|
|
62
|
+
assert.equal(basename(mainWorktreeTop(wt)), 'FixIt');
|
|
63
|
+
assert.equal(basename(mainWorktreeTop(main)), 'FixIt'); // main checkout unchanged
|
|
64
|
+
assert.equal(mainWorktreeTop(join(tmp)), ''); // outside a repo → '' (caller falls back)
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('validKey mirrors the server rules (purely numeric / "latest" / *.zip rejected)', () => {
|
|
68
|
+
assert.equal(validKey('payout-analysis'), true);
|
|
69
|
+
assert.equal(validKey('s-20260703-0930'), true);
|
|
70
|
+
assert.equal(validKey('v2.1'), true); // has non-digits → fine
|
|
71
|
+
assert.equal(validKey('42'), false); // resolves as a version selector
|
|
72
|
+
assert.equal(validKey('latest'), false);
|
|
73
|
+
assert.equal(validKey('my.zip'), false);
|
|
74
|
+
assert.equal(validKey('MY.ZIP'), true); // server suffix check is case-sensitive — mirror exactly
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('deriveShotName: NN-surface-slug.ext', () => {
|
|
78
|
+
assert.equal(deriveShotName('03', 'ios', 'Login Error'), '03-ios-login-error.png');
|
|
79
|
+
assert.equal(deriveShotName('01', 'web', '/settings/alerts', '.jpeg'), '01-web-settings-alerts.jpeg');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('parseSelect: singles, ranges, dedupe, ordering', () => {
|
|
83
|
+
assert.deepEqual(parseSelect('1,3-5', 6), [1, 3, 4, 5]);
|
|
84
|
+
assert.deepEqual(parseSelect('2-2', 3), [2]);
|
|
85
|
+
assert.deepEqual(parseSelect('3,1-2,2', 3), [1, 2, 3]); // dedupe + ascending
|
|
86
|
+
assert.deepEqual(parseSelect(' 1 , 2 ', 2), [1, 2]); // whitespace tolerated
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('parseSelect: rejects junk, reversed ranges, out-of-range, empty', () => {
|
|
90
|
+
assert.throws(() => parseSelect('x', 3), /bad token/);
|
|
91
|
+
assert.throws(() => parseSelect('1;2', 3), /bad token/);
|
|
92
|
+
assert.throws(() => parseSelect('5-3', 9), /bad range/);
|
|
93
|
+
assert.throws(() => parseSelect('0', 3), /bad range/);
|
|
94
|
+
assert.throws(() => parseSelect('9', 3), /out of range/);
|
|
95
|
+
assert.throws(() => parseSelect('', 3), /empty selection/);
|
|
96
|
+
assert.throws(() => parseSelect(',', 3), /empty selection/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('findUdid: prefers udid-ish keys, falls back to any UUID-shaped value', () => {
|
|
100
|
+
const u1 = '11111111-2222-3333-4444-555555555555';
|
|
101
|
+
const u2 = 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE';
|
|
102
|
+
assert.equal(findUdid({ udid: u1 }), u1);
|
|
103
|
+
assert.equal(findUdid({ worktree: { simulatorUdid: u1 } }), u1); // nested + udid-ish key
|
|
104
|
+
assert.equal(findUdid({ device: u2, iosUdid: u1 }), u1); // udid-ish key wins
|
|
105
|
+
assert.equal(findUdid({ something: u2 }), u2); // fallback: any UUID-shaped string
|
|
106
|
+
assert.equal(findUdid({ udid: 'not-a-udid', other: 42 }), '');
|
|
107
|
+
assert.equal(findUdid(null), '');
|
|
108
|
+
assert.equal(findUdid('string'), '');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('parseArgs: gallery.mjs semantics (accumulating flags, bare flags → true)', () => {
|
|
112
|
+
const a = parseArgs(['--chip', 'A=1', '--chip', 'B=2', '--flag', '--key', 'v']);
|
|
113
|
+
assert.deepEqual(a.chip, ['A=1', 'B=2']);
|
|
114
|
+
assert.equal(a.flag, true);
|
|
115
|
+
assert.equal(a.key, 'v');
|
|
116
|
+
const b = parseArgs(['positional', '--x', 'y']);
|
|
117
|
+
assert.equal(b.x, 'y'); // non-flag tokens skipped
|
|
118
|
+
});
|