@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.
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/bin/_import-asset.mjs +18 -0
- package/apps/studio/bin/_import-figma.mjs +1180 -242
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +17 -4
- package/apps/studio/bin/import-figma.sh +10 -1
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +152 -37
- package/apps/studio/client/panels/SyncPanel.jsx +320 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +29 -1
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/styles/3-shell-maude.css +40 -0
- package/apps/studio/client/styles/4-components.css +4 -4
- package/apps/studio/context.ts +4 -0
- package/apps/studio/dist/client.bundle.js +772 -772
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video-encode-lib.ts +8 -5
- package/apps/studio/exporters/video.ts +10 -0
- package/apps/studio/figma/assets.test.ts +92 -0
- package/apps/studio/figma/assets.ts +63 -9
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/endpoints.ts +73 -0
- package/apps/studio/figma/fig-decode.test.ts +788 -0
- package/apps/studio/figma/fig-decode.ts +839 -0
- package/apps/studio/figma/fig-differential.test.ts +182 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-translator.test.ts +192 -0
- package/apps/studio/figma/fig-vector.test.ts +113 -0
- package/apps/studio/figma/fig-vector.ts +145 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +69 -0
- package/apps/studio/figma/sanitize.ts +146 -47
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.ts +41 -1
- package/apps/studio/figma/to-render.ts +25 -3
- package/apps/studio/figma/types.ts +6 -1
- package/apps/studio/http.ts +94 -0
- package/apps/studio/sync/asset-push-worker.ts +84 -0
- package/apps/studio/sync/asset-push.ts +441 -39
- package/apps/studio/sync/asset-sweep.ts +262 -0
- package/apps/studio/sync/connection-state.ts +71 -3
- package/apps/studio/sync/index.ts +39 -6
- package/apps/studio/sync/presentation.ts +21 -0
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/import-figma.test.ts +192 -4
- package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
- package/apps/studio/test/sync-asset-push.test.ts +639 -47
- package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
- package/apps/studio/test/sync-connection-state.test.ts +66 -0
- package/apps/studio/test/sync-panel-surface.test.ts +123 -0
- package/apps/studio/test/sync-resync-routes.test.ts +125 -0
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/sync-supervisor.test.ts +46 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +81 -1
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +51 -0
- package/apps/studio/whats-new.json +87 -0
- package/cli/commands/design.mjs +7 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- 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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
+
});
|