@1agh/maude 0.58.3 → 0.60.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.
Files changed (81) hide show
  1. package/apps/studio/annotations-layer.tsx +49 -15
  2. package/apps/studio/bin/_import-asset.mjs +18 -0
  3. package/apps/studio/bin/_import-figma.mjs +1180 -242
  4. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  5. package/apps/studio/bin/_perf-probe.mjs +228 -0
  6. package/apps/studio/bin/_perf-shared.mjs +345 -0
  7. package/apps/studio/bin/_video-playwright.mjs +17 -4
  8. package/apps/studio/bin/import-figma.sh +10 -1
  9. package/apps/studio/bin/perf.sh +228 -0
  10. package/apps/studio/bin/smoke.sh +49 -5
  11. package/apps/studio/canvas-lib.tsx +148 -6
  12. package/apps/studio/client/app.jsx +152 -37
  13. package/apps/studio/client/panels/SyncPanel.jsx +320 -0
  14. package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
  15. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  16. package/apps/studio/client/styles/3-shell-maude.css +40 -0
  17. package/apps/studio/client/styles/4-components.css +4 -4
  18. package/apps/studio/context.ts +4 -0
  19. package/apps/studio/dist/client.bundle.js +772 -772
  20. package/apps/studio/dist/styles.css +1 -1
  21. package/apps/studio/exporters/video-encode-lib.ts +8 -5
  22. package/apps/studio/exporters/video.ts +10 -0
  23. package/apps/studio/figma/assets.test.ts +92 -0
  24. package/apps/studio/figma/assets.ts +63 -9
  25. package/apps/studio/figma/codegen-client.test.ts +276 -0
  26. package/apps/studio/figma/codegen-client.ts +509 -0
  27. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  28. package/apps/studio/figma/codegen-fonts.ts +195 -0
  29. package/apps/studio/figma/codegen-values.test.ts +179 -0
  30. package/apps/studio/figma/codegen-values.ts +270 -0
  31. package/apps/studio/figma/endpoints.ts +73 -0
  32. package/apps/studio/figma/fig-decode.test.ts +788 -0
  33. package/apps/studio/figma/fig-decode.ts +839 -0
  34. package/apps/studio/figma/fig-differential.test.ts +182 -0
  35. package/apps/studio/figma/fig-kiwi.ts +410 -0
  36. package/apps/studio/figma/fig-translator.test.ts +192 -0
  37. package/apps/studio/figma/fig-vector.test.ts +113 -0
  38. package/apps/studio/figma/fig-vector.ts +145 -0
  39. package/apps/studio/figma/fig-zip.ts +270 -0
  40. package/apps/studio/figma/from-codegen.test.ts +408 -0
  41. package/apps/studio/figma/from-codegen.ts +1103 -0
  42. package/apps/studio/figma/sanitize.test.ts +69 -0
  43. package/apps/studio/figma/sanitize.ts +146 -47
  44. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  45. package/apps/studio/figma/tailwind-map.ts +545 -0
  46. package/apps/studio/figma/to-artboard.ts +41 -1
  47. package/apps/studio/figma/to-render.ts +25 -3
  48. package/apps/studio/figma/types.ts +6 -1
  49. package/apps/studio/http.ts +94 -0
  50. package/apps/studio/sync/asset-push-worker.ts +84 -0
  51. package/apps/studio/sync/asset-push.ts +441 -39
  52. package/apps/studio/sync/asset-sweep.ts +262 -0
  53. package/apps/studio/sync/connection-state.ts +71 -3
  54. package/apps/studio/sync/index.ts +39 -6
  55. package/apps/studio/sync/presentation.ts +21 -0
  56. package/apps/studio/sync/status.ts +18 -0
  57. package/apps/studio/sync/supervisor.ts +20 -0
  58. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  59. package/apps/studio/test/figma-explode.test.ts +438 -0
  60. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  61. package/apps/studio/test/import-figma.test.ts +192 -4
  62. package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
  63. package/apps/studio/test/sync-asset-push.test.ts +639 -47
  64. package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
  65. package/apps/studio/test/sync-connection-state.test.ts +66 -0
  66. package/apps/studio/test/sync-panel-surface.test.ts +123 -0
  67. package/apps/studio/test/sync-resync-routes.test.ts +125 -0
  68. package/apps/studio/test/sync-status.test.ts +28 -0
  69. package/apps/studio/test/sync-supervisor.test.ts +46 -0
  70. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  71. package/apps/studio/test/video-comp.test.ts +81 -1
  72. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  73. package/apps/studio/use-artboard-drag.tsx +37 -3
  74. package/apps/studio/video-comp.tsx +51 -0
  75. package/apps/studio/whats-new.json +87 -0
  76. package/cli/commands/design.mjs +7 -0
  77. package/cli/commands/kg.mjs +8 -1
  78. package/cli/commands/kg.test.mjs +24 -0
  79. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  80. package/cli/lib/figma-import-controls.test.mjs +70 -0
  81. package/package.json +8 -8
@@ -30,7 +30,9 @@ import {
30
30
  ImportFigmaError,
31
31
  importBoard,
32
32
  importFrames,
33
+ importPages,
33
34
  importTokens,
35
+ withSansFallback,
34
36
  } from '../bin/_import-figma.mjs';
35
37
 
36
38
  const TOKEN = 'figd_VERBCANARY_0123456789abcdef';
@@ -107,6 +109,140 @@ afterEach(() => {
107
109
  delete process.env.MAUDE_GEN_KEYS_PATH;
108
110
  });
109
111
 
112
+ describe('a rendered SVG never falls back to a SERIF (live-migration report)', () => {
113
+ // Measured on the StudyFi cover page: Figma renders text as
114
+ // `font-family="Inter"` and nothing else. An SVG behind `<img src>` renders
115
+ // in an isolated document — the page CSS, its @font-face rules and the DS
116
+ // webfonts do not reach inside — so an uninstalled family lands on the
117
+ // browser default, which is a serif. A sans-serif product design arrived in
118
+ // Times, and every count-based check called it a success.
119
+ test('a bare family gains a generic sans fallback', () => {
120
+ expect(withSansFallback('<text font-family="Inter">x</text>')).toContain(
121
+ 'font-family="Inter, sans-serif"'
122
+ );
123
+ expect(withSansFallback('<text style="font-family: SF Pro Text">x</text>')).toContain(
124
+ 'font-family:SF Pro Text, sans-serif'
125
+ );
126
+ });
127
+
128
+ test('a stack that already ends in a generic is left alone', () => {
129
+ for (const already of [
130
+ '<text font-family="Inter, sans-serif">x</text>',
131
+ '<text font-family="Georgia, serif">x</text>',
132
+ '<text font-family="ui-monospace, monospace">x</text>',
133
+ '<text font-family="system-ui">x</text>',
134
+ ]) {
135
+ expect(withSansFallback(already)).toBe(already);
136
+ }
137
+ });
138
+
139
+ test('it is a FALLBACK, not a substitution — the requested family still wins', () => {
140
+ const out = withSansFallback('<text font-family="Hanken Grotesk">x</text>');
141
+ expect(out.indexOf('Hanken Grotesk')).toBeLessThan(out.indexOf('sans-serif'));
142
+ });
143
+
144
+ test('it rewrites nothing but font-family', () => {
145
+ const svg = '<svg><rect fill="#fff"/><text font-size="12" font-family="Inter">hi</text></svg>';
146
+ const out = withSansFallback(svg);
147
+ expect(out).toContain('fill="#fff"');
148
+ expect(out).toContain('font-size="12"');
149
+ expect(out).toContain('>hi<');
150
+ });
151
+
152
+ test('an unterminated / oversized attribute cannot run away', () => {
153
+ const huge = `<text font-family="${'A'.repeat(500)}">x</text>`;
154
+ expect(withSansFallback(huge)).toBe(huge);
155
+ });
156
+ });
157
+
158
+ describe('--pages contains a page failure instead of losing the rest of the file', () => {
159
+ // Measured on the first live migration: a fault entering page 4 of 6 cost
160
+ // pages 4, 5 and 6 — twice in a row — because the loop caught `too_large`,
161
+ // empty pages and comment failures but NOT a page fetch failure. There is no
162
+ // resume, so the retry re-fetched and re-rendered the three that had already
163
+ // succeeded.
164
+ const PAGES = {
165
+ document: {
166
+ id: '0:0',
167
+ type: 'DOCUMENT',
168
+ children: [
169
+ { id: '1:1', name: 'One', type: 'CANVAS' },
170
+ { id: '2:2', name: 'Two', type: 'CANVAS' },
171
+ { id: '3:3', name: 'Three', type: 'CANVAS' },
172
+ ],
173
+ },
174
+ };
175
+ const PAGE_BODY = (id: string) => ({
176
+ nodes: {
177
+ [id]: {
178
+ document: {
179
+ id,
180
+ name: 'P',
181
+ type: 'CANVAS',
182
+ children: [
183
+ {
184
+ id: `${id}0`,
185
+ name: 'Frame',
186
+ type: 'FRAME',
187
+ visible: true,
188
+ absoluteBoundingBox: box(0, 0, 100, 100),
189
+ },
190
+ ],
191
+ },
192
+ },
193
+ },
194
+ });
195
+
196
+ /** Page two's document fetch drops the connection; one and three are fine. */
197
+ function stubWithPageTwoDown() {
198
+ globalThis.fetch = mock(async (input: unknown) => {
199
+ const url = String(input);
200
+ if (url.includes('/images'))
201
+ return new Response(JSON.stringify({ images: {} }), { status: 200 });
202
+ if (url.includes('/comments'))
203
+ return new Response(JSON.stringify({ comments: [] }), { status: 200 });
204
+ if (
205
+ url.includes('depth=1') ||
206
+ /\/files\/[^/]+\?/.test(url) ||
207
+ url.endsWith('/files/2H6a9YUgPAu0AGdEiwP895')
208
+ ) {
209
+ return new Response(JSON.stringify(PAGES), { status: 200 });
210
+ }
211
+ const m = /ids=([^&]+)/.exec(url);
212
+ const id = m ? decodeURIComponent(m[1]) : '';
213
+ if (id.startsWith('2:2')) throw new TypeError('fetch failed');
214
+ if (id) return new Response(JSON.stringify(PAGE_BODY(id)), { status: 200 });
215
+ return new Response(JSON.stringify(PAGES), { status: 200 });
216
+ }) as unknown as typeof fetch;
217
+ }
218
+
219
+ test('the pages either side of the fault still import, and the fault is REPORTED', async () => {
220
+ stubWithPageTwoDown();
221
+ const r = await importPages({
222
+ url: 'https://www.figma.com/design/2H6a9YUgPAu0AGdEiwP895/x',
223
+ root: sandbox,
224
+ folder: 'f',
225
+ });
226
+ // Page three is the one the old behaviour lost: it comes AFTER the fault.
227
+ expect(r.written.length).toBeGreaterThanOrEqual(2);
228
+ const failed = r.skipped.find((x: { page?: string }) => x.page === '2:2');
229
+ expect(failed).toBeDefined();
230
+ // Reported by node id + a code-owned reason, never document text (D10).
231
+ expect(String(failed?.why)).toMatch(/^(network|bad_response|failed \(\w+\))$/);
232
+ });
233
+
234
+ test('a MISCONFIGURED request stays fatal — a partial folder must not look complete', async () => {
235
+ stubWithPageTwoDown();
236
+ await expect(
237
+ importPages({
238
+ url: 'https://www.figma.com/design/2H6a9YUgPAu0AGdEiwP895/x',
239
+ root: sandbox,
240
+ folder: 'NOT A SLUG',
241
+ })
242
+ ).rejects.toBeInstanceOf(ImportFigmaError);
243
+ });
244
+ });
245
+
110
246
  describe('--board writes a sanitized annotation layer', () => {
111
247
  test('lands at a code-computed slug under the design root', async () => {
112
248
  const result = await importBoard({ url: BOARD_URL, root: sandbox });
@@ -139,6 +275,46 @@ describe('--board writes a sanitized annotation layer', () => {
139
275
  expect(svg).not.toContain('you cannot see me');
140
276
  });
141
277
 
278
+ // A FigJam board is not a screen, and framing it as one was wrong twice: the
279
+ // artboard drew screen chrome around whiteboard content, and it took the DS
280
+ // surface colour — which paints a white board near-black on a dark-default
281
+ // design system. Reported from a live migration into `studyfi-design`.
282
+ test('the host canvas has NO artboard — the board is not a screen', async () => {
283
+ const result = await importBoard({ url: BOARD_URL, root: sandbox });
284
+ const tsx = readFileSync(join(sandbox, '.design', result.canvas as string), 'utf8');
285
+ expect(tsx).not.toContain('<DCArtboard');
286
+ expect(tsx).not.toContain('DCArtboard,');
287
+ expect(tsx).toContain('<DesignCanvas />');
288
+ });
289
+
290
+ test('the board gets an OPAQUE light paper — a section is only a 6% tint', async () => {
291
+ // `annotations-model.ts` paints a section at a hardcoded fill-opacity of
292
+ // 0.06, so it cannot be the ground: white-at-6% over a dark-default DS is
293
+ // still dark. Measured on the first migration into `studyfi-design`.
294
+ const result = await importBoard({ url: BOARD_URL, root: sandbox });
295
+ const svg = readFileSync(result.path as string, 'utf8');
296
+ expect(svg).toContain('data-id="figma-board-paper"');
297
+ expect(svg.toLowerCase()).toContain('fill="#ffffff"');
298
+ });
299
+
300
+ test('the region is a labelled SECTION, on the annotation layer', async () => {
301
+ const result = await importBoard({ url: BOARD_URL, root: sandbox });
302
+ const svg = readFileSync(result.path as string, 'utf8');
303
+ expect(svg).toContain('data-tool="section"');
304
+ expect(svg).toContain('data-id="figma-board-region"');
305
+ });
306
+
307
+ test('paint order is paper -> region -> content; either one later would veil the board', async () => {
308
+ const result = await importBoard({ url: BOARD_URL, root: sandbox });
309
+ const svg = readFileSync(result.path as string, 'utf8');
310
+ const paper = svg.indexOf('data-id="figma-board-paper"');
311
+ const region = svg.indexOf('data-id="figma-board-region"');
312
+ const firstContent = svg.search(/data-tool="(sticky|image|text|ellipse|arrow)"/);
313
+ expect(paper).toBeGreaterThan(-1);
314
+ expect(paper).toBeLessThan(region);
315
+ if (firstContent > -1) expect(region).toBeLessThan(firstContent);
316
+ });
317
+
142
318
  test('an explicit --slug is honoured when it is a valid slug', async () => {
143
319
  const result = await importBoard({ url: BOARD_URL, root: sandbox, slug: 'retro-q3' });
144
320
  // `--slug` names the CANVAS; the annotation layer follows from it.
@@ -450,11 +626,23 @@ describe('the asset pipeline is actually WIRED into --frames', () => {
450
626
 
451
627
  const r = await importFrames({ url: URL_D, root: sandbox });
452
628
  const imageCalls = seen.filter((u) => u.includes('/v1/images/'));
629
+ const byFormat = (f: string) =>
630
+ imageCalls.filter((u) => new URL(u).searchParams.get('format') === f);
631
+
453
632
  // ONE call for the whole cluster — the collapse is what keeps this at 1
454
- // instead of 2 (or, on a real logo, 14).
455
- expect(imageCalls.length).toBe(1);
456
- expect(new URL(imageCalls[0]).searchParams.get('ids')).toBe('2:1');
457
- expect(new URL(imageCalls[0]).searchParams.get('format')).toBe('svg');
633
+ // instead of 2 (or, on a real logo, 14). Asserted PER FORMAT, because this
634
+ // stub returns `{ '2:1': null }`, i.e. Figma DECLINING to render, which the
635
+ // later SVG→PNG degradation path answers with a second, legitimate request.
636
+ // The original `imageCalls.length === 1` conflated "the collapse batched the
637
+ // cluster" with "nothing retried", and went red in main when the degradation
638
+ // landed — the same incomplete change that left `asset-degraded` outside the
639
+ // Disposition union (DDR-219 D9). The collapse claim is what this test owns.
640
+ expect(byFormat('svg')).toHaveLength(1);
641
+ expect(new URL(byFormat('svg')[0]).searchParams.get('ids')).toBe('2:1');
642
+ // The degradation is intended behaviour, so it is asserted rather than
643
+ // tolerated: one retry, for the same collapsed id, never one per leaf.
644
+ expect(byFormat('png')).toHaveLength(1);
645
+ expect(new URL(byFormat('png')[0]).searchParams.get('ids')).toBe('2:1');
458
646
  expect(r.pendingExports).toBe(1);
459
647
  });
460
648
 
@@ -0,0 +1,183 @@
1
+ // The out-of-process asset sweep — the CHILD's half of the contract.
2
+ //
3
+ // These spawn the real worker against a real (local) hub, because the two
4
+ // properties that matter here cannot be observed from inside the process that
5
+ // would otherwise mock them: what actually reaches stdout, and what actually
6
+ // reaches the wire's Authorization header. The parent's half (line parsing,
7
+ // crash translation, cancel) is `sync-asset-sweep.test.ts`.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ const WORKER = join(import.meta.dir, '..', 'sync', 'asset-push-worker.ts');
15
+
16
+ /** A design root holding `n` DS assets — the class that pushes to `/_asset-file/`. */
17
+ function projectWith(n: number): string {
18
+ const root = mkdtempSync(join(tmpdir(), 'sweep-worker-'));
19
+ mkdirSync(join(root, 'system/ds/assets/logos'), { recursive: true });
20
+ for (let i = 0; i < n; i++) {
21
+ writeFileSync(join(root, `system/ds/assets/logos/mark-${i}.svg`), `<svg>${i}</svg>`);
22
+ }
23
+ return root;
24
+ }
25
+
26
+ /**
27
+ * A hub that accepts everything, records every Authorization it saw, and lets a
28
+ * test react to a request (that is how the renewal case rewrites the token file
29
+ * mid-sweep).
30
+ */
31
+ function hub(onRequest?: (req: Request, seen: number) => void) {
32
+ const auths: string[] = [];
33
+ let seen = 0;
34
+ const server = Bun.serve({
35
+ port: 0,
36
+ async fetch(req) {
37
+ const url = new URL(req.url);
38
+ auths.push(req.headers.get('authorization') ?? '');
39
+ onRequest?.(req, seen++);
40
+ // No batch probe here — answering 404 sends the sweep down the
41
+ // per-file HEAD path, which is the wider of the two code paths.
42
+ if (url.pathname === '/_asset-probe') return new Response('nope', { status: 404 });
43
+ if (req.method === 'HEAD') return new Response(null, { status: 404 });
44
+ await req.arrayBuffer(); // drain, or the socket desynchronizes
45
+ return new Response('ok', { status: 200 });
46
+ },
47
+ });
48
+ return {
49
+ url: `http://127.0.0.1:${server.port}`,
50
+ auths,
51
+ stop: () => server.stop(true),
52
+ };
53
+ }
54
+
55
+ /** Run the worker to completion; return its stdout lines, stderr and exit code. */
56
+ async function run(args: string[]): Promise<{
57
+ lines: { t: string; [k: string]: unknown }[];
58
+ raw: string;
59
+ stderr: string;
60
+ code: number | null;
61
+ }> {
62
+ const child = Bun.spawn([process.execPath, WORKER, ...args], {
63
+ stdin: 'ignore',
64
+ stdout: 'pipe',
65
+ stderr: 'pipe',
66
+ });
67
+ const [raw, stderr] = await Promise.all([
68
+ new Response(child.stdout).text(),
69
+ new Response(child.stderr).text(),
70
+ ]);
71
+ await child.exited;
72
+ const lines = raw
73
+ .split('\n')
74
+ .filter((l) => l.trim())
75
+ .map((l) => JSON.parse(l));
76
+ return { lines, raw, stderr, code: child.exitCode };
77
+ }
78
+
79
+ describe('the worker speaks NDJSON and nothing else', () => {
80
+ test('progress lines, then exactly one result line, then a clean exit', async () => {
81
+ const root = projectWith(3);
82
+ const h = hub();
83
+ const tokenFile = join(root, 'tok');
84
+ writeFileSync(tokenFile, 'secret-a', { mode: 0o600 });
85
+ try {
86
+ const { lines, raw, code } = await run([root, h.url, tokenFile]);
87
+
88
+ expect(code).toBe(0);
89
+ // Every line parsed — nothing unstructured reached stdout.
90
+ expect(raw.trimEnd().split('\n').length).toBe(lines.length);
91
+ const results = lines.filter((l) => l.t === 'result');
92
+ expect(results.length).toBe(1);
93
+ expect(lines.filter((l) => l.t === 'progress').length).toBeGreaterThan(0);
94
+ // …and the result is the LAST thing said.
95
+ expect(lines[lines.length - 1].t).toBe('result');
96
+ expect(results[0].pushed).toEqual([
97
+ 'system/ds/assets/logos/mark-0.svg',
98
+ 'system/ds/assets/logos/mark-1.svg',
99
+ 'system/ds/assets/logos/mark-2.svg',
100
+ ]);
101
+ expect(results[0].failed).toEqual([]);
102
+ } finally {
103
+ h.stop();
104
+ rmSync(root, { recursive: true, force: true });
105
+ }
106
+ });
107
+
108
+ test('the final progress line is `finished`, so a parent never waits forever', async () => {
109
+ const root = projectWith(1);
110
+ const h = hub();
111
+ const tokenFile = join(root, 'tok');
112
+ writeFileSync(tokenFile, 'secret-a', { mode: 0o600 });
113
+ try {
114
+ const { lines } = await run([root, h.url, tokenFile]);
115
+ const progress = lines.filter((l) => l.t === 'progress');
116
+ expect(progress[progress.length - 1].finished).toBe(true);
117
+ } finally {
118
+ h.stop();
119
+ rmSync(root, { recursive: true, force: true });
120
+ }
121
+ });
122
+ });
123
+
124
+ describe('the credential travels in a file, not on the command line', () => {
125
+ test('the token reaching the hub is the FILE contents', async () => {
126
+ const root = projectWith(1);
127
+ const h = hub();
128
+ const tokenFile = join(root, 'tok');
129
+ writeFileSync(tokenFile, 'secret-from-file\n', { mode: 0o600 });
130
+ try {
131
+ await run([root, h.url, tokenFile]);
132
+ // Trimmed — a file written with a trailing newline must not send one.
133
+ expect(h.auths).toContain('Bearer secret-from-file');
134
+ expect(h.auths.every((a) => a === 'Bearer secret-from-file')).toBe(true);
135
+ } finally {
136
+ h.stop();
137
+ rmSync(root, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ test('a token rewritten mid-sweep is picked up (silent renewal keeps working)', async () => {
142
+ const root = projectWith(4);
143
+ const tokenFile = join(root, 'tok');
144
+ writeFileSync(tokenFile, 'old', { mode: 0o600 });
145
+ // Rotate after the hub has answered a couple of requests — exactly what
146
+ // `scheduleRenewal` does to the parent's in-memory token today.
147
+ const h = hub((_req, seen) => {
148
+ if (seen === 2) writeFileSync(tokenFile, 'new', { mode: 0o600 });
149
+ });
150
+ try {
151
+ await run([root, h.url, tokenFile]);
152
+ expect(h.auths).toContain('Bearer old');
153
+ expect(h.auths).toContain('Bearer new');
154
+ } finally {
155
+ h.stop();
156
+ rmSync(root, { recursive: true, force: true });
157
+ }
158
+ });
159
+ });
160
+
161
+ describe('the worker refuses what it cannot do', () => {
162
+ test('missing arguments is exit 2 and a structured error, not a stack trace', async () => {
163
+ const { lines, code } = await run([]);
164
+ expect(code).toBe(2);
165
+ expect(lines[0].t).toBe('error');
166
+ expect(String(lines[0].message)).toContain('usage:');
167
+ });
168
+
169
+ test('an unreachable hub is a finished sweep with failures — never a hang', async () => {
170
+ const root = projectWith(1);
171
+ const tokenFile = join(root, 'tok');
172
+ writeFileSync(tokenFile, 'secret-a', { mode: 0o600 });
173
+ try {
174
+ // Port 1 is not listening; the sweep must still terminate and report.
175
+ const { lines, code } = await run([root, 'http://127.0.0.1:1', tokenFile]);
176
+ expect(code).toBe(0);
177
+ const result = lines.find((l) => l.t === 'result') as { failed: unknown[] } | undefined;
178
+ expect(result?.failed.length).toBe(1);
179
+ } finally {
180
+ rmSync(root, { recursive: true, force: true });
181
+ }
182
+ });
183
+ });