@lovinka/vitrinka 1.1.1 → 1.4.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 +20 -0
- package/dist/cli.js +493 -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 +411 -16
- package/dist/mcp.js.map +1 -1
- package/package.json +7 -5
- 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/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,175 @@
|
|
|
1
|
+
// snap.test.ts — snap's adopt path (web --file): dated dir + NN filename + manifest add +
|
|
2
|
+
// verify prompt, plus the offline-marker warn-once behavior. No capture tools, no network
|
|
3
|
+
// (no .vitrinka config in these fixtures → the detached push is skipped by design).
|
|
4
|
+
// The warn-once behavior WITH a .vitrinka config + real failing pushes lives in push.test.ts.
|
|
5
|
+
import { test } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { makeTmp, runCli, writeFakeBin, noWebpPath, PNG } from './_helpers.ts';
|
|
10
|
+
import { chooseEncoding } from '../../../pkg/src/cli.ts';
|
|
11
|
+
|
|
12
|
+
// ---------- chooseEncoding: the pure WebP-normalization decision ----------
|
|
13
|
+
|
|
14
|
+
test('chooseEncoding: no cwebp → keep the original regardless of --hq', () => {
|
|
15
|
+
assert.equal(chooseEncoding({ hq: false, cwebpAvailable: false }), 'original');
|
|
16
|
+
assert.equal(chooseEncoding({ hq: true, cwebpAvailable: false, pngSize: 100, losslessSize: 50 }), 'original');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('chooseEncoding: normal capture → lossy q85 WebP when cwebp is present', () => {
|
|
20
|
+
assert.equal(chooseEncoding({ hq: false, cwebpAvailable: true }), 'lossy');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('chooseEncoding: --hq never goes lossy; lossless only when smaller than the PNG', () => {
|
|
24
|
+
// lossless smaller → take it (pixel-identical, fewer bytes)
|
|
25
|
+
assert.equal(chooseEncoding({ hq: true, cwebpAvailable: true, pngSize: 1000, losslessSize: 800 }), 'lossless');
|
|
26
|
+
// lossless not smaller (or equal) → keep the original PNG, full fidelity
|
|
27
|
+
assert.equal(chooseEncoding({ hq: true, cwebpAvailable: true, pngSize: 1000, losslessSize: 1200 }), 'original');
|
|
28
|
+
assert.equal(chooseEncoding({ hq: true, cwebpAvailable: true, pngSize: 1000, losslessSize: 1000 }), 'original');
|
|
29
|
+
// sizes unknown (stat raced) → degrade to keeping the original
|
|
30
|
+
assert.equal(chooseEncoding({ hq: true, cwebpAvailable: true }), 'original');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Filename assertions below expect the captured/adopted extension to survive —
|
|
34
|
+
// pin PATH with a failing cwebp so the WebP normalization keeps the original.
|
|
35
|
+
const NO_WEBP = { PATH: noWebpPath() };
|
|
36
|
+
|
|
37
|
+
function today(): string {
|
|
38
|
+
const d = new Date();
|
|
39
|
+
const p2 = (n: number) => String(n).padStart(2, '0');
|
|
40
|
+
return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('snap web --file adopts the shot: sequence, kebab filename, manifest entry, verify prompt', () => {
|
|
44
|
+
const proj = makeTmp();
|
|
45
|
+
writeFileSync(join(proj, 'src.png'), PNG);
|
|
46
|
+
|
|
47
|
+
const r = runCli(['snap', 'web', '--file', 'src.png', '--route', '/login',
|
|
48
|
+
'--note', 'error state after submit', '--label', 'VSTUP', '--title', 'Login Error'], { cwd: proj, env: NO_WEBP });
|
|
49
|
+
assert.equal(r.status, 0);
|
|
50
|
+
|
|
51
|
+
const day = today();
|
|
52
|
+
const shotPath = join(proj, '.screenshots', day, '01-web-login-error.png');
|
|
53
|
+
assert.equal(existsSync(shotPath), true);
|
|
54
|
+
assert.deepEqual(readFileSync(shotPath), PNG);
|
|
55
|
+
assert.match(r.stdout, /saved .*01-web-login-error\.png {2}\(shot 1\)/);
|
|
56
|
+
assert.match(r.stdout, /NOW READ THE IMAGE TO VERIFY/);
|
|
57
|
+
assert.match(r.stdout, /remote-init/); // no .vitrinka yet → publish hint instead of share URL
|
|
58
|
+
|
|
59
|
+
const m = JSON.parse(readFileSync(join(proj, '.screenshots', 'manifest.json'), 'utf8'));
|
|
60
|
+
assert.equal(m.shots.length, 1);
|
|
61
|
+
assert.deepEqual(
|
|
62
|
+
{ ...m.shots[0], ts: 'TS' },
|
|
63
|
+
{ file: `${day}/01-web-login-error.png`, surface: 'web', route: '/login',
|
|
64
|
+
note: 'error state after submit', label: 'VSTUP', title: 'Login Error', ts: 'TS' },
|
|
65
|
+
);
|
|
66
|
+
assert.equal(existsSync(join(proj, '.screenshots', 'index.html')), true);
|
|
67
|
+
|
|
68
|
+
// second snap → 02
|
|
69
|
+
const r2 = runCli(['snap', 'web', '--file', 'src.png', '--route', '/login', '--note', 'fixed', '--title', 'Login OK'], { cwd: proj, env: NO_WEBP });
|
|
70
|
+
assert.equal(r2.status, 0);
|
|
71
|
+
assert.equal(existsSync(join(proj, '.screenshots', day, '02-web-login-ok.png')), true);
|
|
72
|
+
assert.match(r2.stdout, /\(shot 2\)/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('snap prints the 1-based manifest position (--select numbering), not the per-day filename NN', () => {
|
|
76
|
+
const proj = makeTmp();
|
|
77
|
+
writeFileSync(join(proj, 'src.png'), PNG);
|
|
78
|
+
const root = join(proj, '.screenshots');
|
|
79
|
+
// A set resumed the next day: 2 shots already in the manifest under an older dated dir.
|
|
80
|
+
mkdirSync(join(root, '2020-01-01'), { recursive: true });
|
|
81
|
+
writeFileSync(join(root, 'manifest.json'), JSON.stringify({
|
|
82
|
+
version: 1,
|
|
83
|
+
shots: [
|
|
84
|
+
{ file: '2020-01-01/01-web-a.png', surface: 'web', route: '/a', note: 'n', ts: '2020-01-01T10:00:00Z' },
|
|
85
|
+
{ file: '2020-01-01/02-web-b.png', surface: 'web', route: '/b', note: 'n', ts: '2020-01-01T10:01:00Z' },
|
|
86
|
+
],
|
|
87
|
+
}, null, 2) + '\n');
|
|
88
|
+
|
|
89
|
+
const r = runCli(['snap', 'web', '--file', 'src.png', '--route', '/c', '--note', 'today'], { cwd: proj, env: NO_WEBP });
|
|
90
|
+
assert.equal(r.status, 0);
|
|
91
|
+
// Filename sequence restarts per dated dir (01), but the position --select addresses is global (3).
|
|
92
|
+
assert.match(r.stdout, /saved .*01-web-c\.png {2}\(shot 3\)/);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('snap web without --file exits 2 (MCP captures web; snap adopts)', () => {
|
|
96
|
+
const proj = makeTmp();
|
|
97
|
+
const r = runCli(['snap', 'web', '--route', '/x', '--note', 'n'], { cwd: proj });
|
|
98
|
+
assert.equal(r.status, 2);
|
|
99
|
+
assert.match(r.stderr, /--file <path> is required/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('snap validates surface / --route / --note', () => {
|
|
103
|
+
const proj = makeTmp();
|
|
104
|
+
writeFileSync(join(proj, 'src.png'), PNG);
|
|
105
|
+
const bad = runCli(['snap', 'tv', '--file', 'src.png', '--route', '/x', '--note', 'n'], { cwd: proj });
|
|
106
|
+
assert.equal(bad.status, 2);
|
|
107
|
+
assert.match(bad.stderr, /surface must be ios\|android\|macos\|web/);
|
|
108
|
+
const noRoute = runCli(['snap', 'web', '--file', 'src.png', '--note', 'n'], { cwd: proj });
|
|
109
|
+
assert.equal(noRoute.status, 2);
|
|
110
|
+
assert.match(noRoute.stderr, /--route is required/);
|
|
111
|
+
const noNote = runCli(['snap', 'web', '--file', 'src.png', '--route', '/x'], { cwd: proj });
|
|
112
|
+
assert.equal(noNote.status, 2);
|
|
113
|
+
assert.match(noNote.stderr, /--note is required/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('snap surfaces a pre-existing offline marker exactly once', () => {
|
|
117
|
+
const proj = makeTmp();
|
|
118
|
+
writeFileSync(join(proj, 'src.png'), PNG);
|
|
119
|
+
const root = join(proj, '.screenshots');
|
|
120
|
+
mkdirSync(root, { recursive: true });
|
|
121
|
+
writeFileSync(join(root, '.vitrinka-offline'), JSON.stringify({ ts: '2026-07-03T10:00:00Z', error: 'fetch failed (boom)' }) + '\n');
|
|
122
|
+
|
|
123
|
+
const r1 = runCli(['snap', 'web', '--file', 'src.png', '--route', '/a', '--note', 'n1'], { cwd: proj });
|
|
124
|
+
assert.equal(r1.status, 0);
|
|
125
|
+
assert.match(r1.stderr, /vitrinka unreachable on a previous push \(fetch failed \(boom\)\)/);
|
|
126
|
+
|
|
127
|
+
const r2 = runCli(['snap', 'web', '--file', 'src.png', '--route', '/b', '--note', 'n2'], { cwd: proj });
|
|
128
|
+
assert.equal(r2.status, 0);
|
|
129
|
+
assert.doesNotMatch(r2.stderr, /vitrinka unreachable/); // marker remembers it was shown
|
|
130
|
+
const marker = JSON.parse(readFileSync(join(root, '.vitrinka-offline'), 'utf8'));
|
|
131
|
+
assert.equal(marker.warned, true);
|
|
132
|
+
assert.equal(marker.error, 'fetch failed (boom)');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// --open + --settle: the navigate-and-wait lives INSIDE snap so a capture is
|
|
136
|
+
// one command (agent harnesses block `sleep …; openurl …; sleep …; snap` chains).
|
|
137
|
+
test('snap ios --open deep-links via simctl, settles, then captures — one command', () => {
|
|
138
|
+
const proj = makeTmp();
|
|
139
|
+
const bin = join(makeTmp(), 'bin');
|
|
140
|
+
const log = join(makeTmp(), 'xcrun.log');
|
|
141
|
+
writeFileSync(log, '');
|
|
142
|
+
// Fake xcrun: logs every invocation; `openurl` no-ops, `screenshot` writes a PNG.
|
|
143
|
+
writeFakeBin(bin, 'xcrun', `
|
|
144
|
+
const fs = require('node:fs');
|
|
145
|
+
const a = process.argv.slice(2);
|
|
146
|
+
fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify(a) + '\\n');
|
|
147
|
+
if (a[1] === 'io' && a[3] === 'screenshot') fs.writeFileSync(a[4], Buffer.from('${PNG.toString('base64')}', 'base64'));
|
|
148
|
+
`);
|
|
149
|
+
const env = { PATH: `${bin}:${noWebpPath()}` };
|
|
150
|
+
|
|
151
|
+
const t0 = Date.now();
|
|
152
|
+
const r = runCli(['snap', 'ios', '--udid', 'FAKE-UDID',
|
|
153
|
+
'--open', 'fixitapp:///dev/journey-preview?kind=COMPLETED&invoice=PAID', '--settle', '1',
|
|
154
|
+
'--route', 'dev/journey-preview', '--note', 'settled capture'], { cwd: proj, env });
|
|
155
|
+
assert.equal(r.status, 0, r.stderr);
|
|
156
|
+
assert.ok(Date.now() - t0 >= 1000, 'must actually settle before capturing');
|
|
157
|
+
|
|
158
|
+
const calls = readFileSync(log, 'utf8').trim().split('\n').map((l) => JSON.parse(l));
|
|
159
|
+
assert.equal(calls.length, 2);
|
|
160
|
+
assert.deepEqual(calls[0], ['simctl', 'openurl', 'FAKE-UDID',
|
|
161
|
+
'fixitapp:///dev/journey-preview?kind=COMPLETED&invoice=PAID']);
|
|
162
|
+
assert.deepEqual(calls[1].slice(0, 4), ['simctl', 'io', 'FAKE-UDID', 'screenshot']);
|
|
163
|
+
assert.match(r.stdout, /\(shot 1\)/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('snap rejects --open with web or --file', () => {
|
|
167
|
+
const proj = makeTmp();
|
|
168
|
+
writeFileSync(join(proj, 'src.png'), PNG);
|
|
169
|
+
const web = runCli(['snap', 'web', '--file', 'src.png', '--open', 'http://x', '--route', '/x', '--note', 'n'], { cwd: proj });
|
|
170
|
+
assert.equal(web.status, 2);
|
|
171
|
+
assert.match(web.stderr, /--open is not supported/);
|
|
172
|
+
const adopt = runCli(['snap', 'ios', '--udid', 'U', '--file', 'src.png', '--open', 'app://x', '--route', '/x', '--note', 'n'], { cwd: proj });
|
|
173
|
+
assert.equal(adopt.status, 2);
|
|
174
|
+
assert.match(adopt.stderr, /--open cannot be combined with --file/);
|
|
175
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// watch.test.ts — the /vitrinka:listen native-Monitor notifier: the pure
|
|
2
|
+
// watermark/diff core (computeAnnouncements + announceLine) and a --once smoke
|
|
3
|
+
// test against a loopback stub. Loopback-only, no external network; the CLI is
|
|
4
|
+
// run async (execFile) so the in-process stub can answer (spawnSync would
|
|
5
|
+
// starve its event loop — see index.test.ts).
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { createServer } from 'node:http';
|
|
10
|
+
import { announceLine, computeAnnouncements, deriveWatchScope } from '../../../pkg/src/cli.ts';
|
|
11
|
+
import { CLI } from './_helpers.ts';
|
|
12
|
+
|
|
13
|
+
test('announceLine: id, intent, board, prompt trimmed to one line ≤100 chars', () => {
|
|
14
|
+
assert.equal(
|
|
15
|
+
announceLine({ id: 7, intent: 'fix', board: 'fixit-audit', prompt: 'hero contrast too low' }),
|
|
16
|
+
'№7 [fix] fixit-audit: hero contrast too low',
|
|
17
|
+
);
|
|
18
|
+
// Multi-line / whitespace collapses to a single line.
|
|
19
|
+
assert.equal(
|
|
20
|
+
announceLine({ id: 8, intent: 'redesign', board: 'b', prompt: 'line one\n line two\t tabbed' }),
|
|
21
|
+
'№8 [redesign] b: line one line two tabbed',
|
|
22
|
+
);
|
|
23
|
+
// >100 chars → truncated with an ellipsis.
|
|
24
|
+
const long = 'x'.repeat(150);
|
|
25
|
+
const line = announceLine({ id: 9, intent: 'other', board: 'b', prompt: long });
|
|
26
|
+
assert.equal(line, `№9 [other] b: ${'x'.repeat(100)}…`);
|
|
27
|
+
// intent "other" with a custom label uses the label; missing intent → "other".
|
|
28
|
+
assert.equal(announceLine({ id: 1, intent: 'other', intentOther: 'a11y', board: 'b', prompt: 'p' }),
|
|
29
|
+
'№1 [a11y] b: p');
|
|
30
|
+
assert.equal(announceLine({ id: 2, board: 'b', prompt: 'p' }), '№2 [other] b: p');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('computeAnnouncements: fresh items announce once; an unchanged re-poll is silent', () => {
|
|
34
|
+
const q = [
|
|
35
|
+
{ id: 1, intent: 'fix', board: 'b', prompt: 'a', status: 'open', updatedAt: 't1' },
|
|
36
|
+
{ id: 2, intent: 'fix', board: 'b', prompt: 'c', status: 'open', updatedAt: 't1' },
|
|
37
|
+
];
|
|
38
|
+
const first = computeAnnouncements({}, q);
|
|
39
|
+
assert.deepEqual(first.lines, ['№1 [fix] b: a', '№2 [fix] b: c']);
|
|
40
|
+
assert.deepEqual(first.nextMap, {
|
|
41
|
+
1: { status: 'open', updatedAt: 't1' },
|
|
42
|
+
2: { status: 'open', updatedAt: 't1' },
|
|
43
|
+
});
|
|
44
|
+
// Same queue, same watermark → nothing new.
|
|
45
|
+
const second = computeAnnouncements(first.nextMap, q);
|
|
46
|
+
assert.deepEqual(second.lines, []);
|
|
47
|
+
assert.deepEqual(second.nextMap, first.nextMap);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('computeAnnouncements: an item that leaves the queue drops, and re-announces when it reappears', () => {
|
|
51
|
+
const item = { id: 1, intent: 'fix', board: 'b', prompt: 'a', status: 'open', updatedAt: 't1' };
|
|
52
|
+
const s1 = computeAnnouncements({}, [item]);
|
|
53
|
+
assert.deepEqual(s1.lines, ['№1 [fix] b: a']);
|
|
54
|
+
// Item claimed → gone from the open queue → dropped from the watermark.
|
|
55
|
+
const s2 = computeAnnouncements(s1.nextMap, []);
|
|
56
|
+
assert.deepEqual(s2.lines, []);
|
|
57
|
+
assert.deepEqual(s2.nextMap, {});
|
|
58
|
+
// Re-queued (reopened) → absent from the map → announced again.
|
|
59
|
+
const s3 = computeAnnouncements(s2.nextMap, [item]);
|
|
60
|
+
assert.deepEqual(s3.lines, ['№1 [fix] b: a']);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('computeAnnouncements: a still-open item re-announces when updatedAt changes (prompt revision)', () => {
|
|
64
|
+
const s1 = computeAnnouncements({}, [
|
|
65
|
+
{ id: 1, intent: 'fix', board: 'b', prompt: 'v1', status: 'open', updatedAt: 't1' },
|
|
66
|
+
]);
|
|
67
|
+
assert.deepEqual(s1.lines, ['№1 [fix] b: v1']);
|
|
68
|
+
const s2 = computeAnnouncements(s1.nextMap, [
|
|
69
|
+
{ id: 1, intent: 'fix', board: 'b', prompt: 'v2', status: 'open', updatedAt: 't2' },
|
|
70
|
+
]);
|
|
71
|
+
assert.deepEqual(s2.lines, ['№1 [fix] b: v2']);
|
|
72
|
+
assert.deepEqual(s2.nextMap, { 1: { status: 'open', updatedAt: 't2' } });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// runWatchOnce runs `cli.ts watch --once --base <stub>` async and resolves with
|
|
76
|
+
// the exit code + captured stdout.
|
|
77
|
+
function runWatchOnce(base: string, extra: string[] = []): Promise<{ status: number; stdout: string; stderr: string }> {
|
|
78
|
+
return new Promise((res) => {
|
|
79
|
+
execFile(process.execPath, [CLI, 'watch', '--once', '--base', base, ...extra],
|
|
80
|
+
{ encoding: 'utf8', env: { ...process.env, HOME: '/nonexistent-home-for-token-isolation' } },
|
|
81
|
+
(err, stdout, stderr) => res({ status: err ? (err as { code?: number }).code ?? 1 : 0, stdout, stderr }));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A minimal fake vitrinka work API: /work/wait answers per `waitBody`, /work
|
|
86
|
+
// answers per `listBody`. Records the request paths.
|
|
87
|
+
function fakeWorkApi(waitBody: unknown, listBody: unknown) {
|
|
88
|
+
const requests: string[] = [];
|
|
89
|
+
const srv = createServer((req, res) => {
|
|
90
|
+
requests.push(req.url || '');
|
|
91
|
+
req.resume();
|
|
92
|
+
req.on('end', () => {
|
|
93
|
+
const body = (req.url || '').startsWith('/api/v1/work/wait') ? waitBody : listBody;
|
|
94
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
95
|
+
res.end(JSON.stringify(body));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
return { srv, requests };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test('watch --once: work in the window → announces the open queue, exit 0', async () => {
|
|
102
|
+
const { srv, requests } = fakeWorkApi(
|
|
103
|
+
{ work: [{ id: 1, board: 'b', capsule: '…' }], pending: 1 },
|
|
104
|
+
{ work: [{ id: 1, intent: 'fix', board: 'b', prompt: 'hero contrast', status: 'open', updatedAt: 't1' }] },
|
|
105
|
+
);
|
|
106
|
+
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', () => r()));
|
|
107
|
+
const port = (srv.address() as { port: number }).port;
|
|
108
|
+
try {
|
|
109
|
+
const r = await runWatchOnce(`http://127.0.0.1:${port}`);
|
|
110
|
+
assert.equal(r.status, 0, r.stderr);
|
|
111
|
+
assert.equal(r.stdout.trim(), '№1 [fix] b: hero contrast');
|
|
112
|
+
assert.ok(requests.some((u) => u.startsWith('/api/v1/work/wait')));
|
|
113
|
+
assert.ok(requests.some((u) => u.startsWith('/api/v1/work?')));
|
|
114
|
+
} finally {
|
|
115
|
+
srv.close();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('watch --once: idle window → prints nothing, exit 3', async () => {
|
|
120
|
+
const { srv } = fakeWorkApi({ idle: true }, { work: [] });
|
|
121
|
+
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', () => r()));
|
|
122
|
+
const port = (srv.address() as { port: number }).port;
|
|
123
|
+
try {
|
|
124
|
+
const r = await runWatchOnce(`http://127.0.0.1:${port}`);
|
|
125
|
+
assert.equal(r.status, 3);
|
|
126
|
+
assert.equal(r.stdout.trim(), '');
|
|
127
|
+
} finally {
|
|
128
|
+
srv.close();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('watch --once: board filter is forwarded to the work API', async () => {
|
|
133
|
+
const { srv, requests } = fakeWorkApi({ idle: true }, { work: [] });
|
|
134
|
+
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', () => r()));
|
|
135
|
+
const port = (srv.address() as { port: number }).port;
|
|
136
|
+
try {
|
|
137
|
+
await runWatchOnce(`http://127.0.0.1:${port}`, ['--board', 'fixit-audit']);
|
|
138
|
+
assert.ok(requests.some((u) => u.startsWith('/api/v1/work/wait') && u.includes('board=fixit-audit')));
|
|
139
|
+
} finally {
|
|
140
|
+
srv.close();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('watch --once: project+branch scope is forwarded to both endpoints', async () => {
|
|
145
|
+
const { srv, requests } = fakeWorkApi({ idle: true }, { work: [] });
|
|
146
|
+
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', () => r()));
|
|
147
|
+
const port = (srv.address() as { port: number }).port;
|
|
148
|
+
try {
|
|
149
|
+
await runWatchOnce(`http://127.0.0.1:${port}`, ['--project', 'fixit', '--branch', 'main']);
|
|
150
|
+
assert.ok(requests.some((u) => u.startsWith('/api/v1/work/wait') && u.includes('project=fixit') && u.includes('branch=main')));
|
|
151
|
+
} finally {
|
|
152
|
+
srv.close();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('deriveWatchScope: --all wins → firehose (leases nothing-conflicting scope)', () => {
|
|
157
|
+
assert.deepEqual(deriveWatchScope({ all: true } as never, { repoProject: 'fixit', repoBranch: 'main' }),
|
|
158
|
+
{ kind: 'all', label: 'all boards' });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('deriveWatchScope: --board wins over inferred project', () => {
|
|
162
|
+
assert.deepEqual(deriveWatchScope({ board: 'fixit-audit' } as never, { repoProject: 'fixit', repoBranch: 'main' }),
|
|
163
|
+
{ kind: 'board', board: 'fixit-audit', label: 'board fixit-audit' });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('deriveWatchScope: default = repo project + current branch (sanitized)', () => {
|
|
167
|
+
assert.deepEqual(deriveWatchScope({} as never, { repoProject: 'FixIt', repoBranch: 'feat/Cool_Thing' }),
|
|
168
|
+
{ kind: 'project', project: 'fixit', branch: 'feat-cool_thing', label: 'fixit/feat-cool_thing' });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('deriveWatchScope: --project / --branch override the inference', () => {
|
|
172
|
+
assert.deepEqual(deriveWatchScope({ project: 'reservine', branch: 'dev' } as never,
|
|
173
|
+
{ repoProject: 'fixit', repoBranch: 'main' }),
|
|
174
|
+
{ kind: 'project', project: 'reservine', branch: 'dev', label: 'reservine/dev' });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// runWatch runs the PERSISTENT `watch` (no --once) and resolves on process exit.
|
|
178
|
+
function runWatch(base: string, extra: string[] = []): Promise<{ status: number; stdout: string; stderr: string }> {
|
|
179
|
+
return new Promise((res) => {
|
|
180
|
+
execFile(process.execPath, [CLI, 'watch', '--base', base, ...extra],
|
|
181
|
+
{ encoding: 'utf8', env: { ...process.env, HOME: '/nonexistent-home-for-token-isolation' } },
|
|
182
|
+
(err, stdout, stderr) => res({ status: err ? (err as { code?: number }).code ?? 1 : 0, stdout, stderr }));
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
test('watch: a 409 (scope already held) prints the holder and exits 2', async () => {
|
|
187
|
+
const holder = { actor: 'lukas', session: 'mac:42', scopeKind: 'board', scope: 'fixit-audit', lastSeen: '2026-07-06T10:00:00Z' };
|
|
188
|
+
const srv = createServer((req, res) => {
|
|
189
|
+
req.resume();
|
|
190
|
+
req.on('end', () => {
|
|
191
|
+
res.writeHead(409, { 'Content-Type': 'application/json' });
|
|
192
|
+
res.end(JSON.stringify({ error: 'listener scope already active', live: true, holder }));
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', () => r()));
|
|
196
|
+
const port = (srv.address() as { port: number }).port;
|
|
197
|
+
try {
|
|
198
|
+
const r = await runWatch(`http://127.0.0.1:${port}`, ['--board', 'fixit-audit']);
|
|
199
|
+
assert.equal(r.status, 2, r.stderr);
|
|
200
|
+
assert.match(r.stdout, /listener already active \(live\): lukas@mac:42 on fixit-audit/);
|
|
201
|
+
} finally {
|
|
202
|
+
srv.close();
|
|
203
|
+
}
|
|
204
|
+
});
|