@1agh/maude 0.59.0 → 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/bin/_import-figma.mjs +314 -30
- package/apps/studio/client/panels/SyncPanel.jsx +93 -2
- package/apps/studio/client/styles/3-shell-maude.css +10 -0
- package/apps/studio/context.ts +4 -0
- package/apps/studio/dist/client.bundle.js +547 -547
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/figma/fig-decode.test.ts +100 -14
- package/apps/studio/figma/fig-decode.ts +247 -25
- package/apps/studio/figma/fig-differential.test.ts +182 -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/sanitize.ts +7 -0
- package/apps/studio/figma/to-artboard.ts +41 -1
- package/apps/studio/http.ts +47 -0
- package/apps/studio/sync/asset-push-worker.ts +84 -0
- package/apps/studio/sync/asset-push.ts +101 -7
- package/apps/studio/sync/asset-sweep.ts +262 -0
- package/apps/studio/sync/index.ts +29 -5
- package/apps/studio/sync/presentation.ts +21 -0
- package/apps/studio/sync/supervisor.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +9 -0
- package/apps/studio/test/sync-asset-push-worker.test.ts +183 -0
- package/apps/studio/test/sync-asset-push.test.ts +157 -8
- package/apps/studio/test/sync-asset-sweep.test.ts +243 -0
- package/apps/studio/test/sync-panel-surface.test.ts +34 -1
- package/apps/studio/test/sync-resync-routes.test.ts +125 -0
- package/apps/studio/test/sync-supervisor.test.ts +46 -0
- package/apps/studio/whats-new.json +16 -0
- package/package.json +8 -8
|
@@ -0,0 +1,243 @@
|
|
|
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
|
+
});
|
|
@@ -58,7 +58,7 @@ describe('the Sync panel speaks the presentation vocabulary, safely', () => {
|
|
|
58
58
|
test('every name that reaches the DOM goes through safeName', () => {
|
|
59
59
|
// Slugs and asset keys are local, but the payload is read back off disk —
|
|
60
60
|
// bounded text-only rendering is the house rule (DDR-054).
|
|
61
|
-
expect(PANEL).toContain('import { safeName, syncPresentation }');
|
|
61
|
+
expect(PANEL).toContain('import { safeDetail, safeName, syncPresentation }');
|
|
62
62
|
expect(PANEL).not.toMatch(/dangerouslySetInnerHTML/);
|
|
63
63
|
});
|
|
64
64
|
|
|
@@ -88,3 +88,36 @@ describe('the panel reads the off-disk payload fail-closed (DDR-102 discipline)'
|
|
|
88
88
|
);
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
|
+
|
|
92
|
+
// feature-sync-resync-and-out-of-process-sweep — the panel gained the one
|
|
93
|
+
// control the whole feature exists for. What is pinned here is not that a
|
|
94
|
+
// button renders, but the three properties that make it safe to press: it
|
|
95
|
+
// re-runs the WHOLE sync (not just assets), it cannot be spammed, and cancel
|
|
96
|
+
// reaches only the sweep.
|
|
97
|
+
describe('the Resync control', () => {
|
|
98
|
+
test('it calls the whole-sync route, not an asset-only one', () => {
|
|
99
|
+
expect(PANEL).toContain("fetch('/_api/sync/resync'");
|
|
100
|
+
expect(PANEL).toContain('data-testid="sync-resync"');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('it disables itself while running AND during the cooldown', () => {
|
|
104
|
+
// A resync re-authenticates every document against the DDR-102 600/min
|
|
105
|
+
// bucket; without the cooldown an impatient person pins their own hub.
|
|
106
|
+
expect(PANEL).toContain('disabled={resyncing || cooling}');
|
|
107
|
+
expect(PANEL).toMatch(/RESYNC_COOLDOWN_MS = [\d_]+/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a 409 reads as "already restarting", never as a failure', () => {
|
|
111
|
+
expect(PANEL).toMatch(/res\.status === 409/);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('cancel is scoped to the sweep and only offered while one runs', () => {
|
|
115
|
+
expect(PANEL).toContain("fetch('/_api/sync/cancel-assets'");
|
|
116
|
+
expect(PANEL).toContain('data-testid="sync-assets-cancel"');
|
|
117
|
+
expect(PANEL).toMatch(/!assets\.finished && \(/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('the note is sanitized too — server detail is still bounded text', () => {
|
|
121
|
+
expect(PANEL).toContain("safeDetail(note, '')");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// `/_api/sync/resync` + `/_api/sync/cancel-assets` — the MAIN-origin half.
|
|
2
|
+
//
|
|
3
|
+
// `canvas-origin-gate.test.ts` already asserts both routes are unreachable from
|
|
4
|
+
// the segregated canvas origin. That alone proves the wrong property: the main
|
|
5
|
+
// origin is reachable from any website the user happens to visit while the dev
|
|
6
|
+
// server is up. A cross-site POST that lands here would re-authenticate every
|
|
7
|
+
// document against the person's own hub and re-run a whole-project upload — an
|
|
8
|
+
// amplification primitive against their own DDR-102 rate-limit budget, with an
|
|
9
|
+
// opaque response and a very real side effect.
|
|
10
|
+
//
|
|
11
|
+
// So this file asserts the complementary half — the same posture the Figma
|
|
12
|
+
// routes carry (DDR-216 D3): cross-origin write → 403, foreign Host → 403,
|
|
13
|
+
// wrong method → 405. Both halves are required; neither is sufficient.
|
|
14
|
+
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import type { Subprocess } from 'bun';
|
|
19
|
+
|
|
20
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
21
|
+
|
|
22
|
+
const HTTP = readFileSync(join(import.meta.dir, '..', 'http.ts'), 'utf8');
|
|
23
|
+
|
|
24
|
+
let proc: Subprocess;
|
|
25
|
+
let base: string;
|
|
26
|
+
|
|
27
|
+
const ROUTES = ['/_api/sync/resync', '/_api/sync/cancel-assets'];
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
const { root } = makeSandbox();
|
|
31
|
+
const port = nextPort();
|
|
32
|
+
base = `http://localhost:${port}`;
|
|
33
|
+
proc = await bootServer(root, port);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
await killProc(proc);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('cross-origin writes are refused (the CSRF guard)', () => {
|
|
41
|
+
for (const route of ROUTES) {
|
|
42
|
+
test(`POST ${route} from another origin is 403`, async () => {
|
|
43
|
+
const res = await fetch(`${base}${route}`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { Origin: 'https://evil.example' },
|
|
46
|
+
});
|
|
47
|
+
expect(res.status).toBe(403);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test(`a CORS-simple POST to ${route} is still refused`, async () => {
|
|
51
|
+
// No preflight, unreadable response — but the side effect would land.
|
|
52
|
+
const res = await fetch(`${base}${route}`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'text/plain', Origin: 'https://evil.example' },
|
|
55
|
+
body: '{}',
|
|
56
|
+
});
|
|
57
|
+
expect(res.status).toBe(403);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('a foreign Host header is refused (the DNS-rebinding guard)', () => {
|
|
63
|
+
for (const route of ROUTES) {
|
|
64
|
+
test(`POST ${route} with a rebinding Host is 403`, async () => {
|
|
65
|
+
const res = await fetch(`${base}${route}`, {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
headers: { Host: 'sync.127.0.0.1.nip.io' },
|
|
68
|
+
});
|
|
69
|
+
expect(res.status).toBe(403);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('method gating', () => {
|
|
75
|
+
for (const route of ROUTES) {
|
|
76
|
+
test(`GET ${route} is 405 — resync is a write`, async () => {
|
|
77
|
+
expect((await fetch(`${base}${route}`)).status).toBe(405);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Source-level, because these two properties only show up on a LINKED project
|
|
83
|
+
// with a live hub — a state a booted sandbox cannot reach — and they are
|
|
84
|
+
// exactly the two that would be silently wrong.
|
|
85
|
+
describe('the route is wired to the whole-sync restart, and refuses rather than queues', () => {
|
|
86
|
+
test('restart() is called with NO argument — this route can never unlink', () => {
|
|
87
|
+
// `restart(null)` is the DETACH lane: it clears the in-memory link and
|
|
88
|
+
// cycles to solo. A resync that passed a falsy value through would
|
|
89
|
+
// disconnect the person's project instead of re-checking it.
|
|
90
|
+
expect(HTTP).toContain('const sync = await control.restart();');
|
|
91
|
+
expect(HTTP).not.toMatch(/sync\/resync[\s\S]{0,900}restart\(null\)/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a cycle in flight is a 409, not a second full re-link', () => {
|
|
95
|
+
expect(HTTP).toMatch(/control\.busy\?\.\(\)/);
|
|
96
|
+
expect(HTTP).toMatch(/status: 409[\s\S]{0,120}'busy'/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('cancel reaches the sweep through the live runtime, not the supervisor', () => {
|
|
100
|
+
expect(HTTP).toContain('ctx.syncControl?.current?.()?.cancelAssetSweep() ?? false');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('same-origin behaviour on an unlinked project', () => {
|
|
105
|
+
test('resync answers honestly rather than pretending it synced', async () => {
|
|
106
|
+
// The sandbox project has no linkedHub, so the supervisor is present but
|
|
107
|
+
// declines. The panel hides the control in this state; the ROUTE still has
|
|
108
|
+
// to give a truthful answer to anything that reaches it.
|
|
109
|
+
const res = await fetch(`${base}/_api/sync/resync`, { method: 'POST' });
|
|
110
|
+
expect(res.status).toBe(200);
|
|
111
|
+
const json = (await res.json()) as {
|
|
112
|
+
ok: boolean;
|
|
113
|
+
sync?: { syncing: boolean; reason?: string };
|
|
114
|
+
};
|
|
115
|
+
expect(json.ok).toBe(true);
|
|
116
|
+
expect(json.sync?.syncing).toBe(false);
|
|
117
|
+
expect(json.sync?.reason).toBe('unlinked');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('cancelling with no sweep in flight says so instead of erroring', async () => {
|
|
121
|
+
const res = await fetch(`${base}/_api/sync/cancel-assets`, { method: 'POST' });
|
|
122
|
+
expect(res.status).toBe(200);
|
|
123
|
+
expect(await res.json()).toEqual({ ok: true, cancelled: false });
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -77,6 +77,7 @@ function fakeRuntime(log: string[], id: string, size = 3): SyncRuntime {
|
|
|
77
77
|
size: () => size,
|
|
78
78
|
agentFor: () => undefined,
|
|
79
79
|
status: () => null,
|
|
80
|
+
cancelAssetSweep: () => false,
|
|
80
81
|
};
|
|
81
82
|
}
|
|
82
83
|
|
|
@@ -188,6 +189,50 @@ describe('createSyncSupervisor', () => {
|
|
|
188
189
|
rmSync(ctx.paths.repoRoot, { recursive: true, force: true });
|
|
189
190
|
});
|
|
190
191
|
|
|
192
|
+
// feature-sync-resync-and-out-of-process-sweep — `busy()` is what lets the
|
|
193
|
+
// Resync route answer 409 instead of quietly queueing a second full re-link
|
|
194
|
+
// of every canvas. It reports the chain; it must never BE the chain.
|
|
195
|
+
test('busy() reports a cycle in flight and clears when it settles', async () => {
|
|
196
|
+
const ctx = makeCtx({ url: 'https://hub.example', linkedAt: 1 });
|
|
197
|
+
let release: () => void = () => {};
|
|
198
|
+
const held = new Promise<void>((r) => {
|
|
199
|
+
release = r;
|
|
200
|
+
});
|
|
201
|
+
const sup = createSyncSupervisor(ctx, {}, () => ({
|
|
202
|
+
start: () => held,
|
|
203
|
+
stop: async () => {},
|
|
204
|
+
size: () => 1,
|
|
205
|
+
agentFor: () => undefined,
|
|
206
|
+
status: () => null,
|
|
207
|
+
cancelAssetSweep: () => false,
|
|
208
|
+
}));
|
|
209
|
+
|
|
210
|
+
expect(sup.busy()).toBe(false);
|
|
211
|
+
const cycle = sup.start();
|
|
212
|
+
expect(sup.busy()).toBe(true);
|
|
213
|
+
release();
|
|
214
|
+
await cycle;
|
|
215
|
+
expect(sup.busy()).toBe(false);
|
|
216
|
+
rmSync(ctx.paths.repoRoot, { recursive: true, force: true });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('busy() clears after a cycle that THREW — Resync never wedges shut', async () => {
|
|
220
|
+
const ctx = makeCtx({ url: 'https://hub.example', linkedAt: 1 });
|
|
221
|
+
const sup = createSyncSupervisor(ctx, {}, () => ({
|
|
222
|
+
start: async () => {
|
|
223
|
+
throw new Error('nope');
|
|
224
|
+
},
|
|
225
|
+
stop: async () => {},
|
|
226
|
+
size: () => 0,
|
|
227
|
+
agentFor: () => undefined,
|
|
228
|
+
status: () => null,
|
|
229
|
+
cancelAssetSweep: () => false,
|
|
230
|
+
}));
|
|
231
|
+
await sup.start();
|
|
232
|
+
expect(sup.busy()).toBe(false);
|
|
233
|
+
rmSync(ctx.paths.repoRoot, { recursive: true, force: true });
|
|
234
|
+
});
|
|
235
|
+
|
|
191
236
|
test('a throwing start leaves the server up and answers with the reason', async () => {
|
|
192
237
|
const ctx = makeCtx({ url: 'https://hub.example', linkedAt: 1 });
|
|
193
238
|
const sup = createSyncSupervisor(ctx, {}, () => ({
|
|
@@ -198,6 +243,7 @@ describe('createSyncSupervisor', () => {
|
|
|
198
243
|
size: () => 0,
|
|
199
244
|
agentFor: () => undefined,
|
|
200
245
|
status: () => null,
|
|
246
|
+
cancelAssetSweep: () => false,
|
|
201
247
|
}));
|
|
202
248
|
const out = await sup.start();
|
|
203
249
|
expect(out.syncing).toBe(false);
|
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "sync-resync-button",
|
|
6
|
+
"version": "0.60.0",
|
|
7
|
+
"date": "2026-08-12",
|
|
8
|
+
"kind": "feature",
|
|
9
|
+
"title": "Resync your project without restarting Maude",
|
|
10
|
+
"summary": "The Sync panel has a Resync button: one press re-checks everything against the cloud — every canvas and every file — instead of quitting and reopening. Uploads now run on their own too, so a failed upload stays a failed upload: Maude keeps working, the panel says what happened, and you can stop one part-way without leaving anything half-sent."
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "figma-offline-fig-import",
|
|
14
|
+
"version": "0.60.0",
|
|
15
|
+
"date": "2026-08-12",
|
|
16
|
+
"kind": "feature",
|
|
17
|
+
"title": "Import a Figma file with no Figma account",
|
|
18
|
+
"summary": "Drag in a .fig or .jam you exported and Maude reads it entirely offline — no token, no network, no seat. Images travel inside the file, so nothing expires and nothing is rate-limited. If the export is from a Figma version Maude does not recognise, it says so rather than guessing at the geometry."
|
|
19
|
+
},
|
|
4
20
|
{
|
|
5
21
|
"id": "cloud-asset-push-completes",
|
|
6
22
|
"version": "0.59.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -54,13 +54,13 @@
|
|
|
54
54
|
"test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
|
|
55
55
|
},
|
|
56
56
|
"optionalDependencies": {
|
|
57
|
-
"@1agh/maude-darwin-arm64": "0.
|
|
58
|
-
"@1agh/maude-darwin-x64": "0.
|
|
59
|
-
"@1agh/maude-linux-arm64": "0.
|
|
60
|
-
"@1agh/maude-linux-arm64-musl": "0.
|
|
61
|
-
"@1agh/maude-linux-x64": "0.
|
|
62
|
-
"@1agh/maude-linux-x64-musl": "0.
|
|
63
|
-
"@1agh/maude-win32-x64": "0.
|
|
57
|
+
"@1agh/maude-darwin-arm64": "0.60.0",
|
|
58
|
+
"@1agh/maude-darwin-x64": "0.60.0",
|
|
59
|
+
"@1agh/maude-linux-arm64": "0.60.0",
|
|
60
|
+
"@1agh/maude-linux-arm64-musl": "0.60.0",
|
|
61
|
+
"@1agh/maude-linux-x64": "0.60.0",
|
|
62
|
+
"@1agh/maude-linux-x64-musl": "0.60.0",
|
|
63
|
+
"@1agh/maude-win32-x64": "0.60.0"
|
|
64
64
|
},
|
|
65
65
|
"files": [
|
|
66
66
|
"cli",
|