@1agh/maude 0.60.7 → 1.0.3

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 (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +201 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -71
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +915 -308
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +71 -108
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +90 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -1,161 +0,0 @@
1
- // The downward asset lane — see sync/asset-pull.ts.
2
- //
3
- // Regression cover for the reported "obrázky v annotations se nevykreslí":
4
- // `<slug>.annotations.svg` synced, its `assets/<sha8>.png` did not, and the
5
- // canvas rendered a broken-image glyph on every peer but the one that made it.
6
-
7
- import { describe, expect, test } from 'bun:test';
8
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
9
- import { tmpdir } from 'node:os';
10
- import path from 'node:path';
11
-
12
- import { pullAssets, referencedAssets } from '../sync/asset-pull.ts';
13
-
14
- function project(files: Record<string, string>): string {
15
- const root = mkdtempSync(path.join(tmpdir(), 'maude-asset-pull-'));
16
- for (const [rel, body] of Object.entries(files)) {
17
- const abs = path.join(root, rel);
18
- mkdirSync(path.dirname(abs), { recursive: true });
19
- writeFileSync(abs, body);
20
- }
21
- return root;
22
- }
23
-
24
- const annotation = (name: string) =>
25
- `<svg xmlns="http://www.w3.org/2000/svg"><image data-tool="image" href="assets/${name}"/></svg>`;
26
-
27
- /** A hub that serves every asset as three bytes. */
28
- const servingFetch = (seen: string[] = []) =>
29
- (async (url: string) => {
30
- seen.push(String(url));
31
- return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
32
- }) as unknown as typeof fetch;
33
-
34
- describe('referencedAssets', () => {
35
- test('finds the image an annotation references', () => {
36
- const root = project({ 'ui-board.annotations.svg': annotation('c0fa9c7f.png') });
37
- expect(referencedAssets(root)).toEqual(['c0fa9c7f.png']);
38
- });
39
-
40
- test('reads canvas bodies and sidecars too, deduplicating', () => {
41
- const root = project({
42
- 'ui-board.annotations.svg': annotation('a.png'),
43
- 'ui/card.tsx': `<img src="assets/b.jpg" /><img src="assets/a.png" />`,
44
- 'ui/card.css': `.x { background: url(assets/c.webp) }`,
45
- });
46
- expect(referencedAssets(root)).toEqual(['a.png', 'b.jpg', 'c.webp']);
47
- });
48
-
49
- test('refuses a reference that could place a file outside assets/', () => {
50
- // An annotation can itself have arrived from the hub, so a name found in
51
- // one is untrusted input.
52
- const root = project({
53
- 'ui-board.annotations.svg': [
54
- '<svg>',
55
- '<image href="assets/../../etc/passwd"/>',
56
- '<image href="assets/ok.png"/>',
57
- '</svg>',
58
- ].join(''),
59
- });
60
- expect(referencedAssets(root)).toEqual(['ok.png']);
61
- });
62
-
63
- test('refuses an extension outside the asset allowlist', () => {
64
- const root = project({
65
- 'ui/card.tsx': `"assets/payload.sh" "assets/x.tsx" "assets/real.png"`,
66
- });
67
- expect(referencedAssets(root)).toEqual(['real.png']);
68
- });
69
-
70
- test('ignores runtime directories — a trashed canvas wants nothing', () => {
71
- const root = project({
72
- '_trash/old-deleted-1/old.annotations.svg': annotation('ghost.png'),
73
- '_history/ui-board/1.tsx': `"assets/older.png"`,
74
- 'ui-board.annotations.svg': annotation('live.png'),
75
- });
76
- expect(referencedAssets(root)).toEqual(['live.png']);
77
- });
78
- });
79
-
80
- describe('pullAssets', () => {
81
- const opts = (root: string, fetchImpl: typeof fetch) => ({
82
- designRoot: root,
83
- hubUrl: 'https://hub.example/',
84
- token: () => 'tok',
85
- fetchImpl,
86
- log: { log: () => {}, warn: () => {} },
87
- });
88
-
89
- test('fetches a referenced asset this machine does not have', async () => {
90
- const root = project({ 'ui-board.annotations.svg': annotation('c0fa9c7f.png') });
91
- const seen: string[] = [];
92
- const res = await pullAssets(opts(root, servingFetch(seen)));
93
-
94
- expect(res.pulled).toEqual(['c0fa9c7f.png']);
95
- expect(seen).toEqual(['https://hub.example/assets/c0fa9c7f.png']);
96
- expect(readFileSync(path.join(root, 'assets/c0fa9c7f.png'))).toEqual(Buffer.from([1, 2, 3]));
97
- });
98
-
99
- test('asks for nothing when every reference is already on disk', async () => {
100
- const root = project({
101
- 'ui-board.annotations.svg': annotation('have.png'),
102
- 'assets/have.png': 'bytes',
103
- });
104
- let called = 0;
105
- const counting = (async () => {
106
- called += 1;
107
- return new Response('x', { status: 200 });
108
- }) as unknown as typeof fetch;
109
-
110
- const res = await pullAssets(opts(root, counting));
111
- expect(called).toBe(0);
112
- expect(res.present).toBe(1);
113
- expect(res.pulled).toEqual([]);
114
- });
115
-
116
- test('is idempotent — a second pass costs no requests', async () => {
117
- const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
118
- await pullAssets(opts(root, servingFetch()));
119
- const seen: string[] = [];
120
- const second = await pullAssets(opts(root, servingFetch(seen)));
121
- expect(seen).toEqual([]);
122
- expect(second.pulled).toEqual([]);
123
- });
124
-
125
- test('a 404 is recorded and retried, never fatal', async () => {
126
- // Ordinary: the peer that owns the bytes has not pushed them yet.
127
- const root = project({ 'ui-board.annotations.svg': annotation('later.png') });
128
- const missing = (async () => new Response('nope', { status: 404 })) as unknown as typeof fetch;
129
- const res = await pullAssets(opts(root, missing));
130
- expect(res.pulled).toEqual([]);
131
- expect(res.failed).toEqual([{ name: 'later.png', reason: 'HTTP 404' }]);
132
- expect(existsSync(path.join(root, 'assets/later.png'))).toBe(false);
133
- });
134
-
135
- test('a throwing hub is not fatal either', async () => {
136
- const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
137
- const boom = (async () => {
138
- throw new Error('offline');
139
- }) as unknown as typeof fetch;
140
- const res = await pullAssets(opts(root, boom));
141
- expect(res.pulled).toEqual([]);
142
- expect(res.failed[0]?.reason).toBe('offline');
143
- });
144
-
145
- test('refuses an empty body rather than writing a zero-byte image', async () => {
146
- const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
147
- const empty = (async () =>
148
- new Response(new Uint8Array([]), { status: 200 })) as unknown as typeof fetch;
149
- const res = await pullAssets(opts(root, empty));
150
- expect(res.pulled).toEqual([]);
151
- expect(existsSync(path.join(root, 'assets/x.png'))).toBe(false);
152
- });
153
-
154
- test('leaves no .part file behind on success', async () => {
155
- // The write is temp-then-rename so the dev server serving this very path
156
- // never reads a half-written image.
157
- const root = project({ 'ui-board.annotations.svg': annotation('x.png') });
158
- await pullAssets(opts(root, servingFetch()));
159
- expect(existsSync(path.join(root, 'assets/x.png.part'))).toBe(false);
160
- });
161
- });
@@ -1,183 +0,0 @@
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
- });
@@ -1,243 +0,0 @@
1
- // The PARENT half of the out-of-process asset sweep.
2
- //
3
- // The child's own contract is `sync-asset-push-worker.test.ts`; here the child
4
- // is a stand-in script, because what needs pinning is the parent's behaviour
5
- // when that child misbehaves — and a real sweep cannot be made to segfault on
6
- // demand. Every case below is a way the sweep can end, and the property under
7
- // test is the same one each time: the parent always produces a FINAL emit, so
8
- // the panel can never sit at "92 of 182" forever.
9
-
10
- import { describe, expect, test } from 'bun:test';
11
- import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
12
- import { tmpdir } from 'node:os';
13
- import { join } from 'node:path';
14
-
15
- import type { AssetPushProgress } from '../sync/asset-push.ts';
16
- import { assetWorkerScript, runAssetSweep } from '../sync/asset-sweep.ts';
17
-
18
- const dirs: string[] = [];
19
-
20
- /** Write a stand-in child and return its path. */
21
- function fakeChild(body: string): string {
22
- const dir = mkdtempSync(join(tmpdir(), 'sweep-parent-'));
23
- dirs.push(dir);
24
- const p = join(dir, 'child.ts');
25
- writeFileSync(p, body);
26
- return p;
27
- }
28
-
29
- function progressLine(over: Partial<AssetPushProgress> = {}): string {
30
- const base = {
31
- t: 'progress',
32
- total: 3,
33
- done: 1,
34
- pushed: 1,
35
- skipped: 0,
36
- failedCount: 0,
37
- failures: [],
38
- active: 'system/ds/assets/logos/a.svg',
39
- finished: false,
40
- };
41
- return JSON.stringify({ ...base, ...over });
42
- }
43
-
44
- function sweep(script: string, onProgress: (p: AssetPushProgress) => void) {
45
- const designRoot = mkdtempSync(join(tmpdir(), 'sweep-root-'));
46
- dirs.push(designRoot);
47
- return runAssetSweep({
48
- designRoot,
49
- hubUrl: 'https://hub.example',
50
- token: () => 'secret-token',
51
- onProgress,
52
- script,
53
- log: { log: () => {}, warn: () => {} },
54
- });
55
- }
56
-
57
- describe('a sweep that finishes', () => {
58
- test('progress reaches the panel and the result comes back', async () => {
59
- const seen: AssetPushProgress[] = [];
60
- const child = fakeChild(`
61
- process.stdout.write(${JSON.stringify(`${progressLine()}\n`)});
62
- process.stdout.write(${JSON.stringify(`${progressLine({ done: 3, pushed: 3, active: null, finished: true })}\n`)});
63
- process.stdout.write(JSON.stringify({ t: 'result', pushed: ['a', 'b', 'c'], skipped: 0, failed: [] }) + '\\n');
64
- `);
65
- const result = await sweep(child, (p) => seen.push(p)).done;
66
-
67
- expect(result).toEqual({ pushed: ['a', 'b', 'c'], skipped: 0, failed: [] });
68
- expect(seen.length).toBe(2);
69
- expect(seen[1].finished).toBe(true);
70
- });
71
-
72
- test('unparseable output is dropped, not fatal', async () => {
73
- const seen: AssetPushProgress[] = [];
74
- const child = fakeChild(`
75
- process.stdout.write('a stray console.log from an import\\n');
76
- process.stdout.write(${JSON.stringify(`${progressLine()}\n`)});
77
- process.stdout.write('{ half a li');
78
- process.stdout.write('ne }\\n');
79
- process.stdout.write(JSON.stringify({ t: 'result', pushed: [], skipped: 1, failed: [] }) + '\\n');
80
- `);
81
- const result = await sweep(child, (p) => seen.push(p)).done;
82
-
83
- expect(result).toEqual({ pushed: [], skipped: 1, failed: [] });
84
- expect(seen.length).toBe(1);
85
- });
86
- });
87
-
88
- describe('a sweep that dies is a REPORTED failure, never a stall', () => {
89
- test('a signalled child names the signal in the final emit', async () => {
90
- const seen: AssetPushProgress[] = [];
91
- const child = fakeChild(`
92
- process.stdout.write(${JSON.stringify(`${progressLine({ done: 92, total: 182, pushed: 92 })}\n`)});
93
- await Bun.sleep(20);
94
- process.kill(process.pid, 'SIGKILL');
95
- await new Promise(() => {});
96
- `);
97
- const result = await sweep(child, (p) => seen.push(p)).done;
98
-
99
- expect(result).toBeNull();
100
- const final = seen[seen.length - 1];
101
- expect(final.finished).toBe(true);
102
- // The counts it got to are KEPT — the person is told where it stopped.
103
- expect(final.done).toBe(92);
104
- expect(final.total).toBe(182);
105
- expect(final.failedCount).toBe(1);
106
- expect(final.failures[0].reason).toContain('SIGKILL');
107
- expect(final.active).toBeNull();
108
- });
109
-
110
- test('a child that reports an error carries the message through', async () => {
111
- const seen: AssetPushProgress[] = [];
112
- const child = fakeChild(`
113
- process.stdout.write(JSON.stringify({ t: 'error', message: 'the design root vanished' }) + '\\n');
114
- process.exit(1);
115
- `);
116
- const result = await sweep(child, (p) => seen.push(p)).done;
117
-
118
- expect(result).toBeNull();
119
- expect(seen[seen.length - 1].failures[0].reason).toContain('the design root vanished');
120
- });
121
-
122
- test('a missing worker script is reported — never a silent in-process fallback', async () => {
123
- const seen: AssetPushProgress[] = [];
124
- const result = await sweep(join(tmpdir(), 'no-such-worker-xyz.ts'), (p) => seen.push(p)).done;
125
-
126
- expect(result).toBeNull();
127
- expect(seen.length).toBe(1);
128
- expect(seen[0].finished).toBe(true);
129
- expect(seen[0].failedCount).toBe(1);
130
- });
131
-
132
- test('a result line with a non-zero exit is NOT trusted as success', async () => {
133
- const seen: AssetPushProgress[] = [];
134
- const child = fakeChild(`
135
- process.stdout.write(JSON.stringify({ t: 'result', pushed: ['a'], skipped: 0, failed: [] }) + '\\n');
136
- process.exit(3);
137
- `);
138
- const result = await sweep(child, (p) => seen.push(p)).done;
139
-
140
- expect(result).toBeNull();
141
- expect(seen[seen.length - 1].failures[0].reason).toContain('3');
142
- });
143
- });
144
-
145
- describe('cancel ends the sweep, and says that is what happened', () => {
146
- test('a wedged child is killed and the lane closes as cancelled', async () => {
147
- const seen: AssetPushProgress[] = [];
148
- const child = fakeChild(`
149
- process.stdout.write(${JSON.stringify(`${progressLine()}\n`)});
150
- await new Promise(() => {});
151
- `);
152
- const handle = sweep(child, (p) => seen.push(p));
153
- // Wait for the first emit so the child is definitely alive — BOUNDED, so a
154
- // machine that cannot spawn fails this test instead of hanging the suite.
155
- for (let i = 0; seen.length === 0 && i < 500; i++) await Bun.sleep(10);
156
- expect(seen.length).toBeGreaterThan(0);
157
- handle.cancel();
158
- const result = await handle.done;
159
-
160
- expect(result).toBeNull();
161
- const final = seen[seen.length - 1];
162
- expect(final.finished).toBe(true);
163
- expect(final.failures[0].reason).toBe('cancelled');
164
- });
165
-
166
- test('cancel is idempotent and safe after the sweep already ended', async () => {
167
- const child = fakeChild(
168
- `process.stdout.write(JSON.stringify({ t: 'result', pushed: [], skipped: 0, failed: [] }) + '\\n');`
169
- );
170
- const handle = sweep(child, () => {});
171
- await handle.done;
172
- expect(() => {
173
- handle.cancel();
174
- handle.cancel();
175
- }).not.toThrow();
176
- });
177
- });
178
-
179
- describe('the credential never touches argv', () => {
180
- test('the token goes to a 0600 file that is gone when the sweep ends', async () => {
181
- let sawArgv = '';
182
- let sawToken = '';
183
- let sawMode = '';
184
- const child = fakeChild(`
185
- const { readFileSync, statSync } = await import('node:fs');
186
- const tokenFile = process.argv[4];
187
- process.stdout.write(JSON.stringify({
188
- t: 'result', pushed: [], skipped: 0,
189
- failed: [
190
- { key: 'argv', reason: process.argv.slice(2).join(' ') },
191
- { key: 'token', reason: readFileSync(tokenFile, 'utf8') },
192
- { key: 'mode', reason: (statSync(tokenFile).mode & 0o777).toString(8) },
193
- { key: 'file', reason: tokenFile },
194
- ],
195
- }) + '\\n');
196
- `);
197
- const result = await sweep(child, () => {});
198
- const failed = (await result.done)?.failed ?? [];
199
- sawArgv = failed.find((f) => f.key === 'argv')?.reason ?? '';
200
- sawToken = failed.find((f) => f.key === 'token')?.reason ?? '';
201
- sawMode = failed.find((f) => f.key === 'mode')?.reason ?? '';
202
- const tokenFile = failed.find((f) => f.key === 'file')?.reason ?? '';
203
-
204
- expect(sawToken).toBe('secret-token');
205
- // `ps` shows argv to every user on the machine.
206
- expect(sawArgv).not.toContain('secret-token');
207
- expect(sawMode).toBe('600');
208
- expect(existsSync(tokenFile)).toBe(false);
209
- });
210
- });
211
-
212
- describe('the credential stays current across the boundary', () => {
213
- test('the token file is written atomically — the child can never read half of it', async () => {
214
- // The child re-reads this file per request; a plain overwrite is observable
215
- // half-done, and half a bearer token is a 401 on every remaining upload.
216
- const src = readFileSync(join(import.meta.dir, '..', 'sync', 'asset-sweep.ts'), 'utf8');
217
- expect(src).toContain('renameSync(tmp, tokenFile)');
218
- // And the parent keeps it current: a renewal mid-sweep must reach the child,
219
- // which is why `token` is a GETTER and not the string it resolved to.
220
- expect(src).toMatch(/token: \(\) => string/);
221
- expect(src).toMatch(/setInterval\(/);
222
- });
223
- });
224
-
225
- describe('the worker ships where the server can find it', () => {
226
- test('the resolved script is a real file on disk (DDR-045, not a bunfs path)', () => {
227
- const script = assetWorkerScript();
228
- expect(script.endsWith(join('sync', 'asset-push-worker.ts'))).toBe(true);
229
- expect(existsSync(script)).toBe(true);
230
- });
231
- });
232
-
233
- // Temp roots outlive individual tests (the token-file case reads one back), so
234
- // they are swept once at the end rather than per-test.
235
- process.on('exit', () => {
236
- for (const d of dirs) {
237
- try {
238
- if (existsSync(d) && readdirSync(d)) rmSync(d, { recursive: true, force: true });
239
- } catch {
240
- /* best-effort */
241
- }
242
- }
243
- });