@lovinka/vitrinka 1.1.0 → 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.
@@ -0,0 +1,111 @@
1
+ // install-name.test.ts — the `install` PATH shim (temp $HOME, idempotence) and the
2
+ // `name` command's arg validation + PATCH request shape against a loopback fixture.
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+ import { makeTmp, runCli, CLI } from './_helpers.ts';
9
+
10
+ test('install writes an executable shim + zshrc PATH block, idempotently', () => {
11
+ const home = makeTmp();
12
+ const env = { HOME: home, PATH: '/usr/bin:/bin' };
13
+
14
+ const first = runCli(['install'], { env });
15
+ assert.equal(first.status, 0, first.stderr);
16
+ const shimPath = join(home, '.local', 'bin', 'vitrinka');
17
+ const shim = readFileSync(shimPath, 'utf8');
18
+ assert.match(shim, /^#!\/bin\/sh\n/);
19
+ assert.ok(shim.includes(`exec node "${CLI}" "$@"`), shim);
20
+ assert.ok(statSync(shimPath).mode & 0o100, 'shim must be executable');
21
+ const zshrc = readFileSync(join(home, '.zshrc'), 'utf8');
22
+ assert.ok(zshrc.includes('# vitrinka-cli'), zshrc);
23
+ assert.ok(zshrc.includes('export PATH="$HOME/.local/bin:$PATH"'), zshrc);
24
+
25
+ // Re-run: no duplicate PATH block, reports already-installed.
26
+ const second = runCli(['install'], { env });
27
+ assert.equal(second.status, 0, second.stderr);
28
+ assert.match(second.stdout, /already/);
29
+ const again = readFileSync(join(home, '.zshrc'), 'utf8');
30
+ assert.equal(again, zshrc, 'zshrc must not grow on re-install');
31
+ });
32
+
33
+ test('install skips zshrc when bin dir is already on PATH', () => {
34
+ const home = makeTmp();
35
+ const env = { HOME: home, PATH: `/usr/bin:${join(home, '.local', 'bin')}` };
36
+ // --no-completion isolates the PATH-block behavior: with completion appended
37
+ // by default (its own suite covers that), install would otherwise create
38
+ // .zshrc for the completion line even when the PATH block is skipped.
39
+ const r = runCli(['install', '--no-completion'], { env });
40
+ assert.equal(r.status, 0, r.stderr);
41
+ assert.ok(!existsSync(join(home, '.zshrc')), 'zshrc must not be created');
42
+ });
43
+
44
+ test('name validates its arguments', () => {
45
+ for (const args of [
46
+ ['name'], // no ref
47
+ ['name', 'fixit/main'], // 2 segments
48
+ ['name', 'fixit/main/2'], // no new name
49
+ ['name', 'fixit/main/2', 'x', '--clear'], // both
50
+ ]) {
51
+ const r = runCli(args, { env: { HOME: makeTmp() } });
52
+ assert.equal(r.status, 2, `${args.join(' ')}: ${r.stdout}${r.stderr}`);
53
+ }
54
+ });
55
+
56
+ // PATCH fixture: records method+url+auth+body, replies 200 JSON.
57
+ const SERVER_JS = `
58
+ const http = require('node:http');
59
+ const fs = require('node:fs');
60
+ const srv = http.createServer((req, res) => {
61
+ let body = '';
62
+ req.setEncoding('utf8');
63
+ req.on('data', (c) => body += c);
64
+ req.on('end', () => {
65
+ fs.appendFileSync(process.argv[1],
66
+ JSON.stringify({ method: req.method, url: req.url, auth: req.headers.authorization || '', body }) + '\\n');
67
+ res.writeHead(200, { 'content-type': 'application/json' });
68
+ res.end(JSON.stringify({ url: 'http://vitrinka.test/fixit/main/2', version: 2, name: 'payouts' }));
69
+ });
70
+ });
71
+ srv.listen(0, '127.0.0.1', () => console.log(srv.address().port));
72
+ `;
73
+
74
+ function startServer(logPath: string): Promise<{ port: number; stop: () => void }> {
75
+ return new Promise((resolvePort, reject) => {
76
+ const child = spawn(process.execPath, ['-e', SERVER_JS, logPath], { stdio: ['ignore', 'pipe', 'inherit'] });
77
+ child.stdout.setEncoding('utf8');
78
+ child.stdout.once('data', (d) => resolvePort({ port: Number(String(d).trim()), stop: () => child.kill() }));
79
+ child.once('error', reject);
80
+ });
81
+ }
82
+
83
+ test('name PATCHes the API with token + lowercased slug; --clear sends empty', async () => {
84
+ const log = join(makeTmp(), 'reqs.log');
85
+ writeFileSync(log, '');
86
+ const srv = await startServer(log);
87
+ try {
88
+ const env = {
89
+ HOME: makeTmp(),
90
+ VITRINKA_URL: `http://127.0.0.1:${srv.port}`,
91
+ VITRINKA_TOKEN: 'tok-123',
92
+ };
93
+ const r = runCli(['name', 'fixit/main/2', 'PayOuts'], { env });
94
+ assert.equal(r.status, 0, r.stderr);
95
+ assert.match(r.stdout, /named fixit\/main\/2 → .*\/fixit\/main\/payouts/);
96
+
97
+ const rc = runCli(['name', 'fixit/main/payouts', '--clear'], { env });
98
+ assert.equal(rc.status, 0, rc.stderr);
99
+
100
+ const reqs = readFileSync(log, 'utf8').trim().split('\n').map((l) => JSON.parse(l));
101
+ assert.equal(reqs.length, 2);
102
+ assert.deepEqual(
103
+ { method: reqs[0].method, url: reqs[0].url, auth: reqs[0].auth, body: JSON.parse(reqs[0].body) },
104
+ { method: 'PATCH', url: '/api/v1/sets/fixit/main/2', auth: 'Bearer tok-123', body: { name: 'payouts' } },
105
+ );
106
+ assert.equal(reqs[1].url, '/api/v1/sets/fixit/main/payouts');
107
+ assert.deepEqual(JSON.parse(reqs[1].body), { name: '' });
108
+ } finally {
109
+ srv.stop();
110
+ }
111
+ });
@@ -0,0 +1,98 @@
1
+ // manifest.test.ts — manifest ops through the real CLI (add / meta incl. chips / build).
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { makeTmp, runCli } from './_helpers.ts';
7
+
8
+ test('add appends a shot, writes manifest.json + index.html', () => {
9
+ const root = join(makeTmp(), '.screenshots');
10
+ const r = runCli(['add', '--root', root,
11
+ '--file', '2026-07-03/01-ios-login.png', '--surface', 'ios',
12
+ '--route', 'Login', '--note', 'first shot',
13
+ '--label', 'VSTUP', '--title', 'Přihlášení']);
14
+ assert.equal(r.status, 0);
15
+ assert.match(r.stdout, /\+ 2026-07-03\/01-ios-login\.png {2}\(1 shots\)/);
16
+
17
+ const m = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
18
+ assert.equal(m.shots.length, 1);
19
+ const s = m.shots[0];
20
+ assert.equal(s.file, '2026-07-03/01-ios-login.png');
21
+ assert.equal(s.surface, 'ios');
22
+ assert.equal(s.route, 'Login');
23
+ assert.equal(s.note, 'first shot');
24
+ assert.equal(s.label, 'VSTUP');
25
+ assert.equal(s.title, 'Přihlášení');
26
+ assert.equal(Number.isNaN(Date.parse(s.ts)), false);
27
+
28
+ const html = readFileSync(join(root, 'index.html'), 'utf8');
29
+ assert.match(html, /01-ios-login\.png/);
30
+
31
+ // second add accumulates
32
+ const r2 = runCli(['add', '--root', root, '--file', '2026-07-03/02-web-home.png', '--surface', 'web',
33
+ '--route', '/home', '--note', 'second']);
34
+ assert.equal(r2.status, 0);
35
+ assert.match(r2.stdout, /\(2 shots\)/);
36
+ const m2 = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
37
+ assert.equal(m2.shots.length, 2);
38
+ assert.equal(m2.shots[1].label, undefined); // optional journey fields stay absent
39
+ });
40
+
41
+ test('add without --file exits 2', () => {
42
+ const root = join(makeTmp(), '.screenshots');
43
+ const r = runCli(['add', '--root', root]);
44
+ assert.equal(r.status, 2);
45
+ assert.match(r.stderr, /--file <rel-path> is required/);
46
+ });
47
+
48
+ test('meta sets journey header + chips, bumps version to 2, rebuilds index.html', () => {
49
+ const root = join(makeTmp(), '.screenshots');
50
+ runCli(['add', '--root', root, '--file', '2026-07-03/01-web-a.png', '--surface', 'web', '--route', '/a', '--note', 'n']);
51
+ const r = runCli(['meta', '--root', root,
52
+ '--kicker', 'MODE · FLOW', '--title', 'Display title', '--accent', 'title',
53
+ '--intro', 'One sentence.', '--chip', 'Persona=Dev', '--chip', 'Jazyk=CZ']);
54
+ assert.equal(r.status, 0);
55
+ assert.match(r.stdout, /journey meta set \(kicker, title, accent, intro, chips\)/);
56
+
57
+ const m = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
58
+ assert.equal(m.version, 2);
59
+ assert.equal(m.journey.kicker, 'MODE · FLOW');
60
+ assert.equal(m.journey.title, 'Display title');
61
+ assert.equal(m.journey.accent, 'title');
62
+ assert.equal(m.journey.intro, 'One sentence.');
63
+ assert.deepEqual(m.journey.chips, [{ k: 'Persona', v: 'Dev' }, { k: 'Jazyk', v: 'CZ' }]);
64
+ assert.equal(existsSync(join(root, 'index.html')), true);
65
+
66
+ // re-running meta updates fields but keeps the rest (sticky journey)
67
+ const r2 = runCli(['meta', '--root', root, '--title', 'Better title']);
68
+ assert.equal(r2.status, 0);
69
+ const m2 = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
70
+ assert.equal(m2.journey.title, 'Better title');
71
+ assert.equal(m2.journey.kicker, 'MODE · FLOW');
72
+ assert.deepEqual(m2.journey.chips, [{ k: 'Persona', v: 'Dev' }, { k: 'Jazyk', v: 'CZ' }]);
73
+ });
74
+
75
+ test('meta rejects a chip without "=" (exit 2)', () => {
76
+ const root = join(makeTmp(), '.screenshots');
77
+ const r = runCli(['meta', '--root', root, '--chip', 'NoEquals']);
78
+ assert.equal(r.status, 2);
79
+ assert.match(r.stderr, /--chip expects "Key=Value"/);
80
+ });
81
+
82
+ test('build rebuilds index.html from the manifest alone', () => {
83
+ const root = join(makeTmp(), '.screenshots');
84
+ runCli(['add', '--root', root, '--file', '2026-07-03/01-web-a.png', '--surface', 'web', '--route', '/a', '--note', 'n']);
85
+ const r = runCli(['build', '--root', root]);
86
+ assert.equal(r.status, 0);
87
+ assert.match(r.stdout, /built .*index\.html \(1 shots\)/);
88
+ });
89
+
90
+ test('corrupt manifest is refused loudly, never overwritten', () => {
91
+ const root = join(makeTmp(), '.screenshots');
92
+ mkdirSync(root, { recursive: true });
93
+ writeFileSync(join(root, 'manifest.json'), 'not json{');
94
+ const r = runCli(['add', '--root', root, '--file', 'x.png']);
95
+ assert.notEqual(r.status, 0);
96
+ assert.match(r.stderr, /Corrupt manifest/);
97
+ assert.equal(readFileSync(join(root, 'manifest.json'), 'utf8'), 'not json{'); // untouched
98
+ });
@@ -0,0 +1,119 @@
1
+ // operator.test.ts — the `operator` persona command: local file write, server
2
+ // PUT with the token, and the read path (local + server), against a loopback
3
+ // fixture. Also pins that board-from-set stamps X-Board-Actor from the
4
+ // configured operator (UTF-8-as-latin1 so Czech names don't crash the write).
5
+ import { test } from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { spawn } from 'node:child_process';
10
+ import { makeTmp, runCli } from './_helpers.ts';
11
+
12
+ // Records method+url+auth+actor+body per request, replies with a small JSON
13
+ // body shaped for each route the operator/board commands hit.
14
+ const SERVER_JS = `
15
+ const http = require('node:http');
16
+ const fs = require('node:fs');
17
+ const srv = http.createServer((req, res) => {
18
+ let body = '';
19
+ req.setEncoding('utf8');
20
+ req.on('data', (c) => body += c);
21
+ req.on('end', () => {
22
+ fs.appendFileSync(process.argv[1], JSON.stringify({
23
+ method: req.method, url: req.url,
24
+ auth: req.headers.authorization || '',
25
+ actor: req.headers['x-board-actor'] || '',
26
+ body,
27
+ }) + '\\n');
28
+ res.writeHead(200, { 'content-type': 'application/json' });
29
+ if (req.method === 'GET' && req.url === '/api/v1/operator') res.end(JSON.stringify({ operator: 'ServerBob' }));
30
+ else if (req.url === '/api/v1/boards') res.end(JSON.stringify({ slug: 'b1' }));
31
+ else if (req.url.includes('/import-set')) res.end(JSON.stringify({ cards: [], edges: [] }));
32
+ else if (req.url.includes('/index')) res.end(JSON.stringify({ hash: 'h' }));
33
+ else res.end(JSON.stringify({ operator: JSON.parse(body || '{}').operator ?? null }));
34
+ });
35
+ });
36
+ srv.listen(0, '127.0.0.1', () => console.log(srv.address().port));
37
+ `;
38
+
39
+ function startServer(logPath: string): Promise<{ port: number; stop: () => void }> {
40
+ return new Promise((resolvePort, reject) => {
41
+ const child = spawn(process.execPath, ['-e', SERVER_JS, logPath], { stdio: ['ignore', 'pipe', 'inherit'] });
42
+ child.stdout.setEncoding('utf8');
43
+ child.stdout.once('data', (d) => resolvePort({ port: Number(String(d).trim()), stop: () => child.kill() }));
44
+ child.once('error', reject);
45
+ });
46
+ }
47
+
48
+ test('operator <name> writes the local file AND PUTs the server default', async () => {
49
+ const log = join(makeTmp(), 'reqs.log');
50
+ writeFileSync(log, '');
51
+ const srv = await startServer(log);
52
+ try {
53
+ const home = makeTmp();
54
+ const env = { HOME: home, VITRINKA_URL: `http://127.0.0.1:${srv.port}`, VITRINKA_TOKEN: 'tok-1' };
55
+ const r = runCli(['operator', 'Lukáš'], { env });
56
+ assert.equal(r.status, 0, r.stderr);
57
+
58
+ // Local file written verbatim.
59
+ assert.equal(readFileSync(join(home, '.config', 'vitrinka', 'operator'), 'utf8').trim(), 'Lukáš');
60
+
61
+ const reqs = readFileSync(log, 'utf8').trim().split('\n').map((l) => JSON.parse(l));
62
+ const put = reqs.find((q) => q.method === 'PUT' && q.url === '/api/v1/operator');
63
+ assert.ok(put, 'expected a PUT /api/v1/operator');
64
+ assert.equal(put.auth, 'Bearer tok-1');
65
+ assert.deepEqual(JSON.parse(put.body), { operator: 'Lukáš' });
66
+ } finally {
67
+ srv.stop();
68
+ }
69
+ });
70
+
71
+ test('operator with no arg prints the local + server values', async () => {
72
+ const log = join(makeTmp(), 'reqs.log');
73
+ writeFileSync(log, '');
74
+ const srv = await startServer(log);
75
+ try {
76
+ const home = makeTmp();
77
+ const env = { HOME: home, VITRINKA_URL: `http://127.0.0.1:${srv.port}`, VITRINKA_OPERATOR: 'LocalAmy' };
78
+ const r = runCli(['operator'], { env });
79
+ assert.equal(r.status, 0, r.stderr);
80
+ assert.match(r.stdout, /operator \(local\): LocalAmy/);
81
+ assert.match(r.stdout, /operator \(server\): ServerBob/);
82
+ } finally {
83
+ srv.stop();
84
+ }
85
+ });
86
+
87
+ test('operator <name> without a token still writes locally, warns about the server', () => {
88
+ const home = makeTmp();
89
+ const r = runCli(['operator', 'Solo'], { env: { HOME: home, VITRINKA_URL: 'http://127.0.0.1:0' } });
90
+ assert.equal(r.status, 0, r.stderr);
91
+ assert.ok(existsSync(join(home, '.config', 'vitrinka', 'operator')));
92
+ assert.match(r.stderr, /no write token/);
93
+ });
94
+
95
+ test('board-from-set stamps X-Board-Actor from the operator (Czech name, latin1-encoded)', async () => {
96
+ const log = join(makeTmp(), 'reqs.log');
97
+ writeFileSync(log, '');
98
+ const srv = await startServer(log);
99
+ try {
100
+ const home = makeTmp();
101
+ const root = join(makeTmp(), '.screenshots');
102
+ // Seed a .vitrinka config so board-from-set has a set to import.
103
+ const base = `http://127.0.0.1:${srv.port}`;
104
+ runCli(['remote-init', '--root', root, '--base', base, '--project', 'fixit', '--branch', 'main', '--key', 's-x'],
105
+ { env: { HOME: home } });
106
+ const env = { HOME: home, VITRINKA_OPERATOR: 'Lukáš' };
107
+ const r = runCli(['board-from-set', '--root', root], { env });
108
+ assert.equal(r.status, 0, r.stderr);
109
+
110
+ const reqs = readFileSync(log, 'utf8').trim().split('\n').map((l) => JSON.parse(l));
111
+ const create = reqs.find((q) => q.url === '/api/v1/boards');
112
+ assert.ok(create, 'expected a create-board POST');
113
+ // The server read the header as latin1 → decoding those bytes back as UTF-8
114
+ // yields the original Czech name.
115
+ assert.equal(Buffer.from(create.actor, 'latin1').toString('utf8'), 'Lukáš');
116
+ } finally {
117
+ srv.stop();
118
+ }
119
+ });
@@ -0,0 +1,115 @@
1
+ // push.test.ts — push query params + .vitrinka persistence (--source/--title) and the
2
+ // offline-marker warn-once lifecycle across repeated failed pushes. Loopback-only: the
3
+ // "server" is a child-process HTTP fixture on 127.0.0.1 (runCli is spawnSync, which would
4
+ // starve an in-process server's event loop); the offline tests use a port that was bound
5
+ // and released, so connections are refused instantly. No external network.
6
+ import { test } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { createServer } from 'node:net';
11
+ import { spawn } from 'node:child_process';
12
+ import { makeTmp, runCli, PNG } from './_helpers.ts';
13
+
14
+ function writeCfg(root: string, base: string): void {
15
+ mkdirSync(root, { recursive: true });
16
+ writeFileSync(join(root, '.vitrinka'), JSON.stringify({
17
+ base, project: 'proj', branch: 'main', slug: 'main', key: 's-test', kind: 'screenshots', issue: '',
18
+ }, null, 2) + '\n');
19
+ }
20
+
21
+ // Appends each request URL to the log file (argv[1]), always answers 200 JSON.
22
+ const SERVER_JS = `
23
+ const http = require('node:http');
24
+ const fs = require('node:fs');
25
+ const srv = http.createServer((req, res) => {
26
+ fs.appendFileSync(process.argv[1], req.url + '\\n');
27
+ req.resume();
28
+ req.on('end', () => {
29
+ res.writeHead(200, { 'content-type': 'application/json' });
30
+ res.end(JSON.stringify({ files: 1, url: 'http://vitrinka.test/canonical' }));
31
+ });
32
+ });
33
+ srv.listen(0, '127.0.0.1', () => console.log(srv.address().port));
34
+ `;
35
+
36
+ function startServer(logPath: string): Promise<{ port: number; stop: () => void }> {
37
+ return new Promise((resolvePort, reject) => {
38
+ const child = spawn(process.execPath, ['-e', SERVER_JS, logPath], { stdio: ['ignore', 'pipe', 'inherit'] });
39
+ child.stdout.setEncoding('utf8');
40
+ child.stdout.once('data', (d) => resolvePort({ port: Number(String(d).trim()), stop: () => child.kill() }));
41
+ child.once('error', reject);
42
+ child.once('exit', (code) => reject(new Error(`server fixture exited early (code ${code})`)));
43
+ });
44
+ }
45
+
46
+ // A port that is guaranteed connection-refused: bind, read, release.
47
+ function freePort(): Promise<number> {
48
+ return new Promise((res, rej) => {
49
+ const s = createServer();
50
+ s.once('error', rej);
51
+ s.listen(0, '127.0.0.1', () => {
52
+ const port = (s.address() as { port: number }).port;
53
+ s.close((e) => (e ? rej(e) : res(port)));
54
+ });
55
+ });
56
+ }
57
+
58
+ test('push persists explicit --source/--title on success; a plain re-push re-sends both', async () => {
59
+ const tmp = makeTmp();
60
+ const log = join(tmp, 'requests.log');
61
+ writeFileSync(log, '');
62
+ const { port, stop } = await startServer(log);
63
+ try {
64
+ const root = join(tmp, '.screenshots');
65
+ writeCfg(root, `http://127.0.0.1:${port}`);
66
+
67
+ const r1 = runCli(['push', '--root', root, '--source', 'fixit/main/s-xyz', '--title', 'Human Title']);
68
+ assert.equal(r1.status, 0);
69
+ assert.match(r1.stdout, /synced 1 files → http:\/\/vitrinka\.test\/canonical/);
70
+ const cfg = JSON.parse(readFileSync(join(root, '.vitrinka'), 'utf8'));
71
+ assert.equal(cfg.source, 'fixit/main/s-xyz');
72
+ assert.equal(cfg.title, 'Human Title');
73
+
74
+ // The SKILL's "iterate = edit + push again" plain push: the server clears omitted
75
+ // ?source=/?title= on re-sync, so the CLI must re-send the persisted values.
76
+ const r2 = runCli(['push', '--root', root]);
77
+ assert.equal(r2.status, 0);
78
+
79
+ const urls = readFileSync(log, 'utf8').trim().split('\n');
80
+ assert.equal(urls.length, 2);
81
+ for (const u of urls) {
82
+ const q = new URL(u, 'http://x').searchParams;
83
+ assert.equal(q.get('source'), 'fixit/main/s-xyz');
84
+ assert.equal(q.get('title'), 'Human Title');
85
+ }
86
+ } finally {
87
+ stop();
88
+ }
89
+ });
90
+
91
+ test('a failed push mid-episode keeps the marker warned flag — snap warns once per offline episode', async () => {
92
+ const proj = makeTmp();
93
+ writeFileSync(join(proj, 'src.png'), PNG);
94
+ const root = join(proj, '.screenshots');
95
+ writeCfg(root, `http://127.0.0.1:${await freePort()}`); // nothing listens → every push fails
96
+
97
+ const p1 = runCli(['push', '--root', root]);
98
+ assert.equal(p1.status, 1);
99
+ assert.match(p1.stderr, /vitrinka unreachable/);
100
+
101
+ const s1 = runCli(['snap', 'web', '--file', 'src.png', '--route', '/a', '--note', 'n1'], { cwd: proj });
102
+ assert.equal(s1.status, 0);
103
+ assert.match(s1.stderr, /vitrinka unreachable on a previous push/);
104
+
105
+ // The episode continues: another failed push (explicit here; snap's detached push does
106
+ // the same) must carry the warned flag forward instead of re-arming the warning.
107
+ const p2 = runCli(['push', '--root', root]);
108
+ assert.equal(p2.status, 1);
109
+ const marker = JSON.parse(readFileSync(join(root, '.vitrinka-offline'), 'utf8'));
110
+ assert.equal(marker.warned, true);
111
+
112
+ const s2 = runCli(['snap', 'web', '--file', 'src.png', '--route', '/b', '--note', 'n2'], { cwd: proj });
113
+ assert.equal(s2.status, 0);
114
+ assert.doesNotMatch(s2.stderr, /vitrinka unreachable/); // warned exactly once for the whole episode
115
+ });
@@ -0,0 +1,173 @@
1
+ // scaffold.test.ts — artifact-init / artifact-from-set: import map + kit usage, data embedding,
2
+ // .vitrinka key/kind/source recording, .gitignore. All offline (remote-init never touches network).
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
6
+ import { join, basename } from 'node:path';
7
+ import { sanitizeSeg } from '../../../pkg/src/cli.ts';
8
+ import { makeTmp, runCli, writeFakeSips, writeShotsFixture } from './_helpers.ts';
9
+
10
+ test('artifact-init scaffolds index.html with the pinned import map + kit, mints the set, git-ignores', () => {
11
+ const proj = makeTmp();
12
+ const r = runCli(['artifact-init', '--slug', 'test-report', '--title', 'My Report'], { cwd: proj });
13
+ assert.equal(r.status, 0);
14
+ assert.match(r.stdout, /vitrinka set → /);
15
+ assert.match(r.stdout, /scaffolded .*\.artifacts\/test-report\/index\.html/);
16
+
17
+ const html = readFileSync(join(proj, '.artifacts', 'test-report', 'index.html'), 'utf8');
18
+ // import map: the exact vendored, versioned modules
19
+ assert.match(html, /"react": "\/vendor\/react\.mjs"/);
20
+ assert.match(html, /"react-dom\/client": "\/vendor\/react-dom-client\.mjs"/);
21
+ assert.match(html, /"htm": "\/vendor\/htm\.mjs"/);
22
+ assert.match(html, /"kit-1": "\/vendor\/kit-1\.mjs"/);
23
+ assert.match(html, /"zipstore-1": "\/vendor\/zipstore-1\.mjs"/);
24
+ // xyflow stays commented out
25
+ assert.match(html, /<!-- Flow diagram\?[\s\S]*"@xyflow\/react": "\/vendor\/xyflow-react\.mjs"[\s\S]*-->/);
26
+ assert.doesNotMatch(html, /"@xyflow\/react":\s*"\/vendor\/xyflow-react\.mjs"\s*[,}]/); // not a live import-map entry
27
+ // runtime shell
28
+ assert.match(html, /<script src="\/vendor\/tailwind\.js"><\/script>/);
29
+ assert.match(html, /const html = htm\.bind\(React\.createElement\);/);
30
+ assert.match(html, /import { Hero, Chips, Section, ShotCard, StatRow, DownloadAllButton } from 'kit-1';/);
31
+ assert.match(html, /<title>My Report<\/title>/);
32
+ assert.match(html, /TODO/);
33
+ assert.doesNotMatch(html, /fetch\('\.\/data\.json'\)/); // no data.json present at scaffold time
34
+
35
+ // vitrinka set config: key = slug, kind = report (default)
36
+ const cfg = JSON.parse(readFileSync(join(proj, '.artifacts', 'test-report', '.vitrinka'), 'utf8'));
37
+ assert.equal(cfg.key, 'test-report');
38
+ assert.equal(cfg.kind, 'report');
39
+
40
+ // .artifacts/ git-ignored
41
+ assert.match(readFileSync(join(proj, '.gitignore'), 'utf8'), /^\.artifacts\/$/m);
42
+ });
43
+
44
+ test('artifact-init picks up a pre-existing data.json (fetch wired in)', () => {
45
+ const proj = makeTmp();
46
+ mkdirSync(join(proj, '.artifacts', 'with-data'), { recursive: true });
47
+ writeFileSync(join(proj, '.artifacts', 'with-data', 'data.json'), '{}\n');
48
+ const r = runCli(['artifact-init', '--slug', 'with-data'], { cwd: proj });
49
+ assert.equal(r.status, 0);
50
+ const html = readFileSync(join(proj, '.artifacts', 'with-data', 'index.html'), 'utf8');
51
+ assert.match(html, /fetch\('\.\/data\.json'\)/);
52
+ });
53
+
54
+ test('artifact-init refuses to clobber an existing index.html (exit 2)', () => {
55
+ const proj = makeTmp();
56
+ assert.equal(runCli(['artifact-init', '--slug', 'once'], { cwd: proj }).status, 0);
57
+ const r = runCli(['artifact-init', '--slug', 'once'], { cwd: proj });
58
+ assert.equal(r.status, 2);
59
+ assert.match(r.stderr, /already exists/);
60
+ });
61
+
62
+ test('artifact-init requires --slug', () => {
63
+ const r = runCli(['artifact-init'], { cwd: makeTmp() });
64
+ assert.equal(r.status, 2);
65
+ assert.match(r.stderr, /--slug <slug> is required/);
66
+ });
67
+
68
+ test('a title with double quotes lands as an htm interpolation, not a broken quoted attribute', () => {
69
+ const proj = makeTmp();
70
+ const title = 'Report "Q3" summary';
71
+ const r = runCli(['artifact-init', '--slug', 'q3', '--title', title], { cwd: proj });
72
+ assert.equal(r.status, 0);
73
+ const html = readFileSync(join(proj, '.artifacts', 'q3', 'index.html'), 'utf8');
74
+ // htm passes the interpolated JS string through verbatim — inner quotes survive
75
+ assert.equal(html.includes('title=${' + JSON.stringify(title) + '}'), true);
76
+ assert.doesNotMatch(html, /<\$\{Hero\}[^\n]*title="Report/); // the attribute form truncates at the inner '"'
77
+ assert.equal(html.includes('<title>Report "Q3" summary</title>'), true); // element text: quotes are fine
78
+ });
79
+
80
+ test('server-invalid set keys (purely numeric / "latest" / *.zip) fail fast, before any writes', () => {
81
+ const proj = makeTmp();
82
+ for (const slug of ['42', 'latest', 'my.zip']) {
83
+ const r = runCli(['artifact-init', '--slug', slug], { cwd: proj });
84
+ assert.equal(r.status, 2);
85
+ assert.match(r.stderr, /invalid set key/);
86
+ assert.equal(existsSync(join(proj, '.artifacts', slug)), false); // nothing scaffolded
87
+ }
88
+ const r2 = runCli(['remote-init', '--root', join(proj, '.screenshots'), '--key', 'latest']);
89
+ assert.equal(r2.status, 2);
90
+ assert.match(r2.stderr, /invalid set key/);
91
+ assert.equal(existsSync(join(proj, '.screenshots', '.vitrinka')), false);
92
+
93
+ const shots = join(proj, '.screenshots');
94
+ writeShotsFixture(shots, 1);
95
+ const env = { PATH: `${writeFakeSips(join(proj, 'fakebin'))}:${process.env.PATH!}` };
96
+ const r3 = runCli(['artifact-from-set', '--slug', '2026', '--from', '.screenshots'], { cwd: proj, env });
97
+ assert.equal(r3.status, 2);
98
+ assert.match(r3.stderr, /invalid set key/);
99
+ assert.equal(existsSync(join(proj, '.artifacts', '2026')), false); // fails before embedding
100
+ });
101
+
102
+ test('artifact-init outside a git repo files the set under the project dir, not "artifacts"', () => {
103
+ const proj = makeTmp(); // temp dir — not a git repo
104
+ const r = runCli(['artifact-init', '--slug', 'data-analysis'], { cwd: proj });
105
+ assert.equal(r.status, 0);
106
+ const cfg = JSON.parse(readFileSync(join(proj, '.artifacts', 'data-analysis', '.vitrinka'), 'utf8'));
107
+ assert.equal(cfg.project, sanitizeSeg(basename(proj), 64)); // the real project dir…
108
+ assert.notEqual(cfg.project, 'artifacts'); // …not the .artifacts container
109
+ });
110
+
111
+ test('artifact-from-set: embeds shots, composes Hero/Section/ShotCard/DownloadAllButton, records source', () => {
112
+ const proj = makeTmp();
113
+ const shots = join(proj, '.screenshots');
114
+ writeShotsFixture(shots, 2);
115
+ writeFileSync(join(shots, '.vitrinka'), JSON.stringify({
116
+ base: 'https://vitrinka.example', project: 'proj', branch: 'main',
117
+ slug: 'main', key: 's-test', kind: 'screenshots', issue: '',
118
+ }, null, 2) + '\n');
119
+ const env = { PATH: `${writeFakeSips(join(proj, 'fakebin'))}:${process.env.PATH!}` };
120
+
121
+ const r = runCli(['artifact-from-set', '--slug', 'demo', '--from', '.screenshots'], { cwd: proj, env });
122
+ assert.equal(r.status, 0);
123
+ assert.match(r.stdout, /scaffolded .*\.artifacts\/demo\/index\.html \(2 shots embedded, source proj\/main\/s-test\)/);
124
+ assert.match(r.stdout, /write the narrative sections, then `push`/);
125
+
126
+ // data.json: journey + embedded shots
127
+ const data = JSON.parse(readFileSync(join(proj, '.artifacts', 'demo', 'data.json'), 'utf8'));
128
+ assert.equal(data.shots.length, 2);
129
+ assert.equal(data.journey.title, 'Test journey');
130
+ assert.match(data.shots[0].src, /^data:image\/jpeg;base64,/);
131
+
132
+ // scaffold: pre-composed kit usage
133
+ const html = readFileSync(join(proj, '.artifacts', 'demo', 'index.html'), 'utf8');
134
+ assert.match(html, /fetch\('\.\/data\.json'\)/);
135
+ assert.match(html, /<\$\{Hero\} kicker=\$\{j\.kicker\} title=\$\{j\.title\} accent=\$\{j\.accent\} intro=\$\{j\.intro\} chips=\$\{j\.chips\} \/>/);
136
+ assert.match(html, /<\$\{Section\} key=\$\{i\} label=\$\{s\.label\} title=\$\{s\.title\} note=\$\{s\.note\}>/);
137
+ assert.match(html, /<\$\{ShotCard\} src=\$\{s\.src\} alt=\$\{s\.title \|\| s\.route \|\| 'screenshot'\} caption=\$\{s\.note\} \/>/);
138
+ assert.match(html, /<\$\{DownloadAllButton\} files=\$\{files\} zipName="demo\.zip" \/>/);
139
+ assert.match(html, /"kit-1": "\/vendor\/kit-1\.mjs"/);
140
+ assert.match(html, /"zipstore-1": "\/vendor\/zipstore-1\.mjs"/);
141
+ assert.match(html, /<title>Test journey<\/title>/); // title from the journey header
142
+ assert.match(html, /TODO/); // narrative slots left for the author
143
+
144
+ // source ref recorded in the artifact's .vitrinka; key = slug, kind = report
145
+ const cfg = JSON.parse(readFileSync(join(proj, '.artifacts', 'demo', '.vitrinka'), 'utf8'));
146
+ assert.equal(cfg.key, 'demo');
147
+ assert.equal(cfg.kind, 'report');
148
+ assert.equal(cfg.source, 'proj/main/s-test');
149
+
150
+ assert.match(readFileSync(join(proj, '.gitignore'), 'utf8'), /^\.artifacts\/$/m);
151
+ });
152
+
153
+ test('artifact-from-set: --select subsets, --source overrides, missing set config only warns', () => {
154
+ const proj = makeTmp();
155
+ const shots = join(proj, '.screenshots');
156
+ writeShotsFixture(shots, 3); // no .vitrinka in the screenshots root
157
+ const env = { PATH: `${writeFakeSips(join(proj, 'fakebin'))}:${process.env.PATH!}` };
158
+
159
+ const r = runCli(['artifact-from-set', '--slug', 'partial', '--from', '.screenshots',
160
+ '--select', '1,3', '--source', 'other/branch/key-1'], { cwd: proj, env });
161
+ assert.equal(r.status, 0);
162
+ const data = JSON.parse(readFileSync(join(proj, '.artifacts', 'partial', 'data.json'), 'utf8'));
163
+ assert.deepEqual(data.shots.map((s: { route: string }) => s.route), ['/home', '/end']);
164
+ const cfg = JSON.parse(readFileSync(join(proj, '.artifacts', 'partial', '.vitrinka'), 'utf8'));
165
+ assert.equal(cfg.source, 'other/branch/key-1');
166
+
167
+ // and without --source: warn, still scaffold, no source recorded
168
+ const r2 = runCli(['artifact-from-set', '--slug', 'nosource', '--from', '.screenshots'], { cwd: proj, env });
169
+ assert.equal(r2.status, 0);
170
+ assert.match(r2.stderr, /source set not recorded/);
171
+ const cfg2 = JSON.parse(readFileSync(join(proj, '.artifacts', 'nosource', '.vitrinka'), 'utf8'));
172
+ assert.equal(cfg2.source, undefined);
173
+ });
@@ -0,0 +1,28 @@
1
+ // shim.test.ts — gallery.mjs is now a passthrough shim: same args reach the CLI, exit codes
2
+ // and output pass through, and a one-line migration hint lands on stderr.
3
+ import { test } from 'node:test';
4
+ import assert from 'node:assert/strict';
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { makeTmp, runShim } from './_helpers.ts';
8
+
9
+ test('shim with no args passes through to the CLI usage screen (exit 2) + migration hint', () => {
10
+ const r = runShim([]);
11
+ assert.equal(r.status, 2);
12
+ assert.match(r.stderr, /\[gallery\.mjs\] moved — use the "vitrinka" bin/);
13
+ assert.match(r.stderr, /Usage:/);
14
+ assert.match(r.stderr, /artifact-from-set/); // the unified CLI's usage, not the old gallery one
15
+ });
16
+
17
+ test('shim passes real commands through with identical behavior', () => {
18
+ const root = join(makeTmp(), '.screenshots');
19
+ const add = runShim(['add', '--root', root, '--file', '2026-07-03/01-web-a.png',
20
+ '--surface', 'web', '--route', '/a', '--note', 'via shim']);
21
+ assert.equal(add.status, 0);
22
+ assert.match(add.stdout, /\+ 2026-07-03\/01-web-a\.png {2}\(1 shots\)/);
23
+
24
+ const build = runShim(['build', '--root', root]);
25
+ assert.equal(build.status, 0);
26
+ assert.match(build.stdout, /built .*index\.html \(1 shots\)/);
27
+ assert.equal(existsSync(join(root, 'index.html')), true);
28
+ });