@1agh/maude 0.60.3 → 0.60.5
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/api.ts +68 -5
- package/apps/studio/build.ts +10 -0
- package/apps/studio/canvas-lib.tsx +16 -3
- package/apps/studio/canvas-shell.tsx +34 -39
- package/apps/studio/client/app.jsx +43 -9
- package/apps/studio/client/panels/SyncPanel.jsx +27 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +707 -707
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dom-selection.ts +67 -0
- package/apps/studio/http.ts +12 -1
- package/apps/studio/input-router.tsx +26 -2
- package/apps/studio/sync/agent.ts +64 -13
- package/apps/studio/sync/asset-pull.ts +210 -0
- package/apps/studio/sync/asset-push.ts +111 -98
- package/apps/studio/sync/codec.ts +47 -0
- package/apps/studio/sync/cold-start.ts +96 -0
- package/apps/studio/sync/file-membership.ts +290 -0
- package/apps/studio/sync/file-pull.ts +330 -0
- package/apps/studio/sync/index.ts +198 -10
- package/apps/studio/sync/migrate-seed.ts +52 -7
- package/apps/studio/sync/projection.ts +10 -1
- package/apps/studio/sync/remote-docs.ts +98 -4
- package/apps/studio/sync/status.ts +25 -0
- package/apps/studio/sync/tombstone-apply.ts +131 -0
- package/apps/studio/test/canvas-meta-api.test.ts +114 -3
- package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
- package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
- package/apps/studio/test/dom-selection.test.ts +69 -1
- package/apps/studio/test/exporters/jobs.test.ts +10 -4
- package/apps/studio/test/git-cloud-posture.test.ts +11 -2
- package/apps/studio/test/input-router.test.ts +69 -0
- package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
- package/apps/studio/test/sync-asset-pull.test.ts +161 -0
- package/apps/studio/test/sync-asset-push.test.ts +52 -2
- package/apps/studio/test/sync-file-membership.test.ts +331 -0
- package/apps/studio/test/sync-file-pull.test.ts +333 -0
- package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
- package/apps/studio/test/sync-remote-docs.test.ts +118 -8
- package/apps/studio/test/sync-status.test.ts +18 -0
- package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
- package/apps/studio/whats-new.json +18 -0
- package/package.json +8 -8
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Acting on a tombstone — the peer-side half of the delete lane.
|
|
2
|
+
//
|
|
3
|
+
// The hub states that a canvas is gone (`tombstones.mjs`); this is what a peer
|
|
4
|
+
// does about it. One rule governs the whole file:
|
|
5
|
+
//
|
|
6
|
+
// QUARANTINE, NEVER DELETE.
|
|
7
|
+
//
|
|
8
|
+
// This is the only code path where a HUB-SUPPLIED signal removes work from a
|
|
9
|
+
// person's disk, and the hub is untrusted to peers (DDR-054). Moving the bundle
|
|
10
|
+
// to `_trash/<slug>-deleted-<ts>/` is what makes that acceptable: a hub that is
|
|
11
|
+
// hostile, buggy, or simply confused about which project it is costs the user a
|
|
12
|
+
// trip to `_trash/`, never their work. The same posture, and the same directory,
|
|
13
|
+
// `migrate-flat-fallback.ts` already established for a peer-driven move.
|
|
14
|
+
//
|
|
15
|
+
// WHY THE SIDECARS TRAVEL AND THE HISTORY DOES NOT. The lanes that ARE the
|
|
16
|
+
// canvas — body, `.meta.json`, `.css`, `.annotations.svg` — move together, so
|
|
17
|
+
// what lands in `_trash/` is a restorable canvas rather than a body stripped of
|
|
18
|
+
// its annotations. `_history/<slug>/`, `_comments/`, `_canvas-state/` stay: they
|
|
19
|
+
// are per-machine runtime state (DDR-115), regenerated or irrelevant, and
|
|
20
|
+
// sweeping them would make a recoverable delete quietly lossy.
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Park ONE file in `_trash/<rel>-conflict-<ts>` — the file plane's LWW
|
|
27
|
+
* loser's parking spot (feature-sync-file-plane), generalized out of
|
|
28
|
+
* `quarantineCanvas` below: same posture (quarantine, never delete; never
|
|
29
|
+
* throw — a failed quarantine costs the overwrite, not the sync runtime),
|
|
30
|
+
* scoped to a single file rather than a canvas's lane bundle.
|
|
31
|
+
*
|
|
32
|
+
* Returns the design-root-relative destination, or null when nothing moved —
|
|
33
|
+
* and the CALLER must treat null as "do not overwrite": a conflict loser
|
|
34
|
+
* that could not be parked is a conflict loser that stays.
|
|
35
|
+
*/
|
|
36
|
+
export function quarantineFile(opts: {
|
|
37
|
+
designRoot: string;
|
|
38
|
+
/** Design-root-relative path of the file to park. */
|
|
39
|
+
rel: string;
|
|
40
|
+
now?: number;
|
|
41
|
+
log?: (line: string) => void;
|
|
42
|
+
}): string | null {
|
|
43
|
+
const { designRoot, rel } = opts;
|
|
44
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
45
|
+
const abs = path.join(designRoot, rel);
|
|
46
|
+
if (!existsSync(abs)) return null;
|
|
47
|
+
const trashedTo = `_trash/${rel}-conflict-${opts.now ?? Date.now()}`;
|
|
48
|
+
const trashAbs = path.join(designRoot, trashedTo);
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(path.dirname(trashAbs), { recursive: true });
|
|
51
|
+
renameSync(abs, trashAbs);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
log(
|
|
54
|
+
`[sync/files] could not park ${rel} in _trash/: ${(err as Error).message} — keeping the local copy`
|
|
55
|
+
);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
log(`[sync/files] conflict on ${rel} — the local copy moved to ${trashedTo}`);
|
|
59
|
+
return trashedTo;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface TombstoneMove {
|
|
63
|
+
slug: string;
|
|
64
|
+
/** Design-root-relative quarantine dir the canvas landed in. */
|
|
65
|
+
trashedTo: string;
|
|
66
|
+
/** Design-root-relative lanes that actually moved. */
|
|
67
|
+
moved: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The absolute paths that make up one canvas on disk. */
|
|
71
|
+
export interface CanvasLanes {
|
|
72
|
+
html: string;
|
|
73
|
+
meta?: string;
|
|
74
|
+
css?: string;
|
|
75
|
+
annotations?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Move one canvas's lanes into `_trash/`, best-effort.
|
|
80
|
+
*
|
|
81
|
+
* Returns the move, or null when there was nothing on disk to move (already
|
|
82
|
+
* gone — the idempotent steady state once both peers have converged) or when the
|
|
83
|
+
* quarantine directory could not be made. NEVER THROWS: a failed quarantine must
|
|
84
|
+
* cost the deletion, not the sync runtime that called it.
|
|
85
|
+
*
|
|
86
|
+
* `now` is injectable so a test does not have to reason about wall-clock.
|
|
87
|
+
*/
|
|
88
|
+
export function quarantineCanvas(opts: {
|
|
89
|
+
designRoot: string;
|
|
90
|
+
slug: string;
|
|
91
|
+
lanes: CanvasLanes;
|
|
92
|
+
now?: number;
|
|
93
|
+
log?: (line: string) => void;
|
|
94
|
+
}): TombstoneMove | null {
|
|
95
|
+
const { designRoot, slug, lanes } = opts;
|
|
96
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
97
|
+
const present = [lanes.html, lanes.meta, lanes.css, lanes.annotations].filter(
|
|
98
|
+
(p): p is string => !!p && existsSync(p)
|
|
99
|
+
);
|
|
100
|
+
if (present.length === 0) return null;
|
|
101
|
+
|
|
102
|
+
const trashedTo = `_trash/${slug}-deleted-${opts.now ?? Date.now()}`;
|
|
103
|
+
const trashAbs = path.join(designRoot, trashedTo);
|
|
104
|
+
try {
|
|
105
|
+
mkdirSync(trashAbs, { recursive: true });
|
|
106
|
+
} catch (err) {
|
|
107
|
+
log(
|
|
108
|
+
`[sync/tombstone] could not quarantine ${slug}: ${(err as Error).message} — leaving it in place`
|
|
109
|
+
);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const moved: string[] = [];
|
|
114
|
+
for (const abs of present) {
|
|
115
|
+
try {
|
|
116
|
+
renameSync(abs, path.join(trashAbs, path.basename(abs)));
|
|
117
|
+
moved.push(path.relative(designRoot, abs));
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// One stuck lane (an open handle on Windows, a permission quirk) must not
|
|
120
|
+
// abandon the lanes that CAN move — a half-moved canvas is still gone from
|
|
121
|
+
// the tree, and the remainder is named here so it can be found by hand.
|
|
122
|
+
log(
|
|
123
|
+
`[sync/tombstone] could not move ${path.basename(abs)} for ${slug}: ${(err as Error).message}`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (moved.length === 0) return null;
|
|
128
|
+
|
|
129
|
+
log(`[sync/tombstone] ${slug} was deleted in the project — moved to ${trashedTo}/`);
|
|
130
|
+
return { slug, trashedTo, moved };
|
|
131
|
+
}
|
|
@@ -115,7 +115,7 @@ describe('/_api/canvas-meta — GET/PATCH (DDR-115 camera split)', () => {
|
|
|
115
115
|
}
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
test('PATCH clamps zoom to [0.
|
|
118
|
+
test('PATCH clamps zoom to [0.02, 4.0]', async () => {
|
|
119
119
|
const { root, designRoot } = makeSandbox();
|
|
120
120
|
const port = nextPort();
|
|
121
121
|
const proc = await bootServer(root, port);
|
|
@@ -137,10 +137,45 @@ describe('/_api/canvas-meta — GET/PATCH (DDR-115 camera split)', () => {
|
|
|
137
137
|
const r2 = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
138
138
|
method: 'PATCH',
|
|
139
139
|
headers: { 'content-type': 'application/json' },
|
|
140
|
-
body: JSON.stringify({ file, patch: { viewport: { x: 0, y: 0, zoom: 0.
|
|
140
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 0, y: 0, zoom: 0.0001 } } }),
|
|
141
141
|
});
|
|
142
142
|
const m2 = (await r2.json()) as MetaShape;
|
|
143
|
-
expect(m2.viewport?.zoom).toBe(0.
|
|
143
|
+
expect(m2.viewport?.zoom).toBe(0.02);
|
|
144
|
+
} finally {
|
|
145
|
+
await killProc(proc);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Issue #91 — a board wider than ~10x the viewport needs a zoom below the
|
|
150
|
+
// OLD 0.1 floor to ever be framed whole ("fit to screen") or dragged across
|
|
151
|
+
// in one motion. Before the fix, this value was silently re-clamped up to
|
|
152
|
+
// 0.1 on save, so even a client that could reach 0.05 lost it on reload.
|
|
153
|
+
test('PATCH preserves a deep zoom-out that the old [0.1, 4.0] floor used to clamp away (issue #91)', async () => {
|
|
154
|
+
const { root, designRoot } = makeSandbox();
|
|
155
|
+
const port = nextPort();
|
|
156
|
+
const proc = await bootServer(root, port);
|
|
157
|
+
try {
|
|
158
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
159
|
+
const tsxAbs = join(designRoot, 'ui', 'DeepZoom.tsx');
|
|
160
|
+
writeFileSync(tsxAbs, 'export default function D(){return <main/>}\n');
|
|
161
|
+
writeFileSync(tsxAbs.replace(/\.tsx$/, '.meta.json'), '{"title":"DeepZoom"}');
|
|
162
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
163
|
+
|
|
164
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
165
|
+
method: 'PATCH',
|
|
166
|
+
headers: { 'content-type': 'application/json' },
|
|
167
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 0, y: 0, zoom: 0.05 } } }),
|
|
168
|
+
});
|
|
169
|
+
const m = (await r.json()) as MetaShape;
|
|
170
|
+
expect(m.viewport?.zoom).toBe(0.05);
|
|
171
|
+
|
|
172
|
+
// Round-trips through a fresh GET too (the persisted view file, not just
|
|
173
|
+
// the PATCH response) — this is what a page reload would read back.
|
|
174
|
+
const g = await fetch(
|
|
175
|
+
`http://localhost:${port}/_api/canvas-meta?file=${encodeURIComponent(file)}`
|
|
176
|
+
);
|
|
177
|
+
const gm = (await g.json()) as MetaShape;
|
|
178
|
+
expect(gm.viewport?.zoom).toBe(0.05);
|
|
144
179
|
} finally {
|
|
145
180
|
await killProc(proc);
|
|
146
181
|
}
|
|
@@ -317,6 +352,82 @@ describe('/_api/canvas-meta — GET/PATCH (DDR-115 camera split)', () => {
|
|
|
317
352
|
}
|
|
318
353
|
});
|
|
319
354
|
|
|
355
|
+
// Security follow-up to issue #91 — `layout.artboards[]` is reachable from
|
|
356
|
+
// the untrusted canvas origin (DDR-054) and previously had no count/magnitude
|
|
357
|
+
// bound. Now that the zoom-floor fix lets `fit()` correctly frame whatever
|
|
358
|
+
// bounding box a synced layout describes, an unbounded artboard count could
|
|
359
|
+
// promote hundreds/thousands of simultaneous GPU layers on the viewer's
|
|
360
|
+
// client (a DoS). Caps mirror the existing overlays/locked lanes.
|
|
361
|
+
test('PATCH layout rejects an oversized artboards array as a silent no-op', async () => {
|
|
362
|
+
const { root, designRoot } = makeSandbox();
|
|
363
|
+
const port = nextPort();
|
|
364
|
+
const proc = await bootServer(root, port);
|
|
365
|
+
try {
|
|
366
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
367
|
+
const tsxAbs = join(designRoot, 'ui', 'Oversized.tsx');
|
|
368
|
+
writeFileSync(tsxAbs, 'export default function O(){return <main/>}\n');
|
|
369
|
+
const metaAbs = tsxAbs.replace(/\.tsx$/, '.meta.json');
|
|
370
|
+
writeFileSync(metaAbs, JSON.stringify({ title: 'Oversized', sections: [] }));
|
|
371
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
372
|
+
|
|
373
|
+
// Seed a valid, small layout first.
|
|
374
|
+
await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
375
|
+
method: 'PATCH',
|
|
376
|
+
headers: { 'content-type': 'application/json' },
|
|
377
|
+
body: JSON.stringify({ file, patch: { layout: { artboards: [{ id: 'a', x: 0, y: 0 }] } } }),
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// A crafted, oversized layout (2001 > the 2000-entry cap) must not land.
|
|
381
|
+
const huge = Array.from({ length: 2001 }, (_, i) => ({ id: `a${i}`, x: i, y: 0 }));
|
|
382
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
383
|
+
method: 'PATCH',
|
|
384
|
+
headers: { 'content-type': 'application/json' },
|
|
385
|
+
body: JSON.stringify({ file, patch: { layout: { artboards: huge } } }),
|
|
386
|
+
});
|
|
387
|
+
expect(r.status).toBe(200);
|
|
388
|
+
const merged = (await r.json()) as MetaShape;
|
|
389
|
+
// Silent no-op — the prior small, valid layout is still what's live.
|
|
390
|
+
const arts = merged.layout?.artboards as Array<Record<string, unknown>> | undefined;
|
|
391
|
+
expect(arts).toEqual([{ id: 'a', x: 0, y: 0 }]);
|
|
392
|
+
|
|
393
|
+
const onDisk = JSON.parse(readFileSync(metaAbs, 'utf8')) as MetaShape;
|
|
394
|
+
const diskArts = onDisk.layout?.artboards as Array<Record<string, unknown>> | undefined;
|
|
395
|
+
expect(diskArts).toEqual([{ id: 'a', x: 0, y: 0 }]);
|
|
396
|
+
} finally {
|
|
397
|
+
await killProc(proc);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test('PATCH layout rejects an out-of-range artboard coordinate as a silent no-op', async () => {
|
|
402
|
+
const { root, designRoot } = makeSandbox();
|
|
403
|
+
const port = nextPort();
|
|
404
|
+
const proc = await bootServer(root, port);
|
|
405
|
+
try {
|
|
406
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
407
|
+
const tsxAbs = join(designRoot, 'ui', 'OutOfRange.tsx');
|
|
408
|
+
writeFileSync(tsxAbs, 'export default function R(){return <main/>}\n');
|
|
409
|
+
writeFileSync(
|
|
410
|
+
tsxAbs.replace(/\.tsx$/, '.meta.json'),
|
|
411
|
+
JSON.stringify({ title: 'OutOfRange' })
|
|
412
|
+
);
|
|
413
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
414
|
+
|
|
415
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
416
|
+
method: 'PATCH',
|
|
417
|
+
headers: { 'content-type': 'application/json' },
|
|
418
|
+
body: JSON.stringify({
|
|
419
|
+
file,
|
|
420
|
+
patch: { layout: { artboards: [{ id: 'a', x: 50_000_000, y: 0 }] } },
|
|
421
|
+
}),
|
|
422
|
+
});
|
|
423
|
+
expect(r.status).toBe(200);
|
|
424
|
+
const merged = (await r.json()) as MetaShape;
|
|
425
|
+
expect(merged.layout).toBeUndefined();
|
|
426
|
+
} finally {
|
|
427
|
+
await killProc(proc);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
320
431
|
test('PATCH viewport on a non-existent canvas is refused (404) and mints no file (DDR-115 F-A2)', async () => {
|
|
321
432
|
const { root, designRoot } = makeSandbox();
|
|
322
433
|
const port = nextPort();
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// canvas-zoom-floor — issue #91 dogfood report: dragging an artboard across a
|
|
2
|
+
// large board, the user can't zoom out far enough to complete the move in one
|
|
3
|
+
// motion. Root cause: `clampZoom` applies a single hard-coded floor to every
|
|
4
|
+
// viewport write, including the programmatic `fit()` path — a board wider
|
|
5
|
+
// than ~10x the viewport could never be framed whole. See
|
|
6
|
+
// .ai/logs/rca/issue-91-canvas-zoom-floor.md.
|
|
7
|
+
//
|
|
8
|
+
// canvas-lib.tsx pulls in a React/DOM dependency graph, so we register
|
|
9
|
+
// happy-dom for this file (like camera-reveal.test.tsx) before importing.
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
12
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
GlobalRegistrator.register();
|
|
16
|
+
});
|
|
17
|
+
afterAll(() => {
|
|
18
|
+
GlobalRegistrator.unregister();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
import { clampZoom, computeFit } from '../canvas-lib.tsx';
|
|
22
|
+
|
|
23
|
+
function fakeHost(clientWidth: number, clientHeight: number): HTMLElement {
|
|
24
|
+
const el = document.createElement('div');
|
|
25
|
+
Object.defineProperty(el, 'clientWidth', { value: clientWidth });
|
|
26
|
+
Object.defineProperty(el, 'clientHeight', { value: clientHeight });
|
|
27
|
+
return el;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('clampZoom — the interactive zoom floor (issue #91)', () => {
|
|
31
|
+
test('a deep zoom-out below the OLD 0.1 floor is no longer clamped', () => {
|
|
32
|
+
expect(clampZoom(0.05)).toBe(0.05);
|
|
33
|
+
expect(clampZoom(0.02)).toBe(0.02);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('still clamps below the new floor', () => {
|
|
37
|
+
expect(clampZoom(0.0001)).toBe(0.02);
|
|
38
|
+
expect(clampZoom(-5)).toBe(0.02);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('still clamps above the max and normalizes non-finite input', () => {
|
|
42
|
+
expect(clampZoom(99)).toBe(4);
|
|
43
|
+
expect(clampZoom(Number.NaN)).toBe(1);
|
|
44
|
+
expect(clampZoom(Number.POSITIVE_INFINITY)).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('computeFit → clampZoom — fit() can now frame a wide board (issue #91)', () => {
|
|
49
|
+
test('a board ~20x the viewport width computes a zoom the old floor would have clamped up', () => {
|
|
50
|
+
// Viewport 1000x800; board 20000 wide — the exact "more than a screenful
|
|
51
|
+
// of artboards" shape from the report. Ideal fit zoom is well under 0.1.
|
|
52
|
+
const host = fakeHost(1000, 800);
|
|
53
|
+
const fit = computeFit([{ x: 0, y: 0, w: 20000, h: 800 }], host, 24);
|
|
54
|
+
|
|
55
|
+
expect(fit.zoom).toBeLessThan(0.1); // the bug: old floor would have clamped this
|
|
56
|
+
expect(fit.zoom).toBeGreaterThanOrEqual(0.02); // still within the new interactive floor
|
|
57
|
+
|
|
58
|
+
// The regression proper: applying the viewport funnel's clamp must be a
|
|
59
|
+
// no-op for this value now — before the fix (ZOOM_MIN = 0.1) this would
|
|
60
|
+
// have raised fit.zoom back up to 0.1, cropping the board.
|
|
61
|
+
expect(clampZoom(fit.zoom)).toBe(fit.zoom);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('an extremely wide board (>50x viewport) still bottoms out at the interactive floor', () => {
|
|
65
|
+
const host = fakeHost(1000, 800);
|
|
66
|
+
const fit = computeFit([{ x: 0, y: 0, w: 100000, h: 800 }], host, 24);
|
|
67
|
+
|
|
68
|
+
// The ideal fit zoom is far below even the new floor — clampZoom must
|
|
69
|
+
// still protect the interactive floor for pathological board sizes.
|
|
70
|
+
expect(fit.zoom).toBeLessThan(0.02);
|
|
71
|
+
expect(clampZoom(fit.zoom)).toBe(0.02);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// DDR-218's withdrawal, applied to EVERY surface that reports a save state.
|
|
2
|
+
//
|
|
3
|
+
// The decision withdrew the working-tree half of the Changes surface whenever
|
|
4
|
+
// somebody else commits this project — a cloud cell (`historyOnly`, `cfg.cloud`)
|
|
5
|
+
// or the cell behind a linked+credentialed desktop repo (`cloudManaged`). It
|
|
6
|
+
// landed in GitPanel only. `app.jsx` went on computing the dirty count a second
|
|
7
|
+
// time, ungated, and handing it to the toolbar menu and the status-bar chip, so
|
|
8
|
+
// both shells kept rendering "N unsaved" — the exact claim the withdrawal exists
|
|
9
|
+
// to delete. Reported again 2026-08-14 ("89 unsaved changes on the cloud, 36 on
|
|
10
|
+
// the desktop"), which is what these assertions are here to stop.
|
|
11
|
+
//
|
|
12
|
+
// Source-level (sync-panel-surface style): what they pin is what would regress —
|
|
13
|
+
// someone re-deriving the posture at a call site, or adding a THIRD surface that
|
|
14
|
+
// reads `gitStatus.files.length` directly.
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const STUDIO = join(import.meta.dir, '..');
|
|
21
|
+
const APP = readFileSync(join(STUDIO, 'client', 'app.jsx'), 'utf8');
|
|
22
|
+
const PANEL = readFileSync(join(STUDIO, 'client', 'panels', 'GitPanel.jsx'), 'utf8');
|
|
23
|
+
|
|
24
|
+
describe('one expression owns "who is saving this project"', () => {
|
|
25
|
+
test('both postures are named once, at the top level', () => {
|
|
26
|
+
expect(APP).toContain('const cellManaged = !!cfg.cloud;');
|
|
27
|
+
expect(APP).toContain('const cloudManaged = !cfg.cloud && !!cloudLinkedHub?.credentialed;');
|
|
28
|
+
expect(APP).toContain('const savingIsManaged = cellManaged || cloudManaged;');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('the GitPanel call site consumes those names rather than re-deriving them', () => {
|
|
32
|
+
// Re-deriving is how the two halves drift apart in the first place.
|
|
33
|
+
expect(APP).toContain('historyOnly={cellManaged}');
|
|
34
|
+
expect(APP).toContain('cloudManaged={cloudManaged}');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('the dirty COUNT is withheld wherever saving is managed', () => {
|
|
39
|
+
test('unsavedCount is gated, not raw', () => {
|
|
40
|
+
expect(APP).toContain(
|
|
41
|
+
'const unsavedCount = savingIsManaged ? 0 : gitStatus?.files?.length || 0;'
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('no surface reads the raw file count for display', () => {
|
|
46
|
+
// Exactly one `gitStatus?.files?.length` may exist, and it is the gated one
|
|
47
|
+
// above. A second occurrence is a new leak by construction.
|
|
48
|
+
const raw = APP.match(/gitStatus\?\.files\?\.length/g) ?? [];
|
|
49
|
+
expect(raw.length).toBe(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('every changesCount consumer is fed the gated value', () => {
|
|
53
|
+
const feeds = APP.match(/changesCount=\{[^}]+\}/g) ?? [];
|
|
54
|
+
expect(feeds.length).toBeGreaterThan(0);
|
|
55
|
+
for (const feed of feeds) expect(feed).toBe('changesCount={unsavedCount}');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('the status-bar chip names the mechanism instead of a count', () => {
|
|
60
|
+
test('it receives the posture', () => {
|
|
61
|
+
expect(APP).toContain('savingIsManaged={savingIsManaged}');
|
|
62
|
+
expect(APP).toMatch(/savingIsManaged = false,/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('managed saving short-circuits the whole unsaved / to-publish ladder', () => {
|
|
66
|
+
expect(APP).toMatch(/\{savingIsManaged\s*\?/);
|
|
67
|
+
expect(APP).toContain("'cloud saving'");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('"N to publish" is withheld too — Publish is withdrawn in that posture', () => {
|
|
71
|
+
// A gated count alone would fall through to the unpushed branch, which is
|
|
72
|
+
// the same local-git offer wearing a different label.
|
|
73
|
+
expect(APP).toContain('unpushed={savingIsManaged ? 0 : gitStatus?.unpushed || 0}');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('the panel half of DDR-218 is still intact', () => {
|
|
78
|
+
test('withdrawal covers both postures', () => {
|
|
79
|
+
expect(PANEL).toContain('const withdrawn = historyOnly || cloudManaged;');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the panel badge stays gated on it', () => {
|
|
83
|
+
expect(PANEL).toMatch(/!withdrawn && count > 0/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the cloud-managed note keeps its testid and names the live mechanism', () => {
|
|
87
|
+
expect(PANEL).toContain('data-testid="git-cloud-managed"');
|
|
88
|
+
expect(PANEL).toMatch(/Cloud is saving/);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -19,7 +19,7 @@ afterAll(() => {
|
|
|
19
19
|
GlobalRegistrator.unregister();
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
import { resolveByDomPath } from '../dom-selection.ts';
|
|
22
|
+
import { buildComposeSelection, openCommentComposer, resolveByDomPath } from '../dom-selection.ts';
|
|
23
23
|
|
|
24
24
|
function setArtboard(html: string): void {
|
|
25
25
|
document.body.innerHTML = `<div data-dc-screen="art1">${html}</div>`;
|
|
@@ -128,3 +128,71 @@ describe('resolveByDomPath', () => {
|
|
|
128
128
|
expect(found?.getAttribute('data-cd-id')).toBe('aaa11111');
|
|
129
129
|
});
|
|
130
130
|
});
|
|
131
|
+
|
|
132
|
+
// issue-90 — right-click "Add comment" on empty canvas background or an
|
|
133
|
+
// artboard's chrome/border (neither carries a `data-cd-id`) used to have no
|
|
134
|
+
// menu entry at all, and even where the entry DID exist (on an element) it
|
|
135
|
+
// never actually opened the composer (see `openCommentComposer` below).
|
|
136
|
+
// `buildComposeSelection` is the shared builder canvas-shell.tsx's
|
|
137
|
+
// context-menu registry now calls for all three target kinds.
|
|
138
|
+
describe('buildComposeSelection', () => {
|
|
139
|
+
test('with an element under the cursor — delegates to hoverTargetToSelection (same anchor a comment-tool click gets)', () => {
|
|
140
|
+
setArtboard(`<button class="cta" data-cd-id="aaa11111">Buy now</button>`);
|
|
141
|
+
const el = document.querySelector('[data-cd-id="aaa11111"]');
|
|
142
|
+
const sel = buildComposeSelection({ el, cdId: 'aaa11111', artboardId: 'art1' }, 100, 200);
|
|
143
|
+
expect(sel.selector).toBe('[data-dc-screen="art1"] [data-cd-id="aaa11111"]');
|
|
144
|
+
expect(sel.id).toBe('aaa11111');
|
|
145
|
+
expect(sel.tag).toBe('button');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('with no element (empty canvas background) — a FLOATING selection anchored to the click point', () => {
|
|
149
|
+
document.body.innerHTML = '';
|
|
150
|
+
const sel = buildComposeSelection({ el: null, cdId: null, artboardId: null }, 100, 200);
|
|
151
|
+
expect(sel.selector).toBe('');
|
|
152
|
+
expect(sel.tag).toBe('');
|
|
153
|
+
expect(sel.artboardId).toBeNull();
|
|
154
|
+
expect(sel.bounds).toEqual({ x: 88, y: 188, w: 24, h: 24 });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('with no element but INSIDE an artboard (right-click on its chrome/border) — floating, but keeps the artboardId', () => {
|
|
158
|
+
document.body.innerHTML = '';
|
|
159
|
+
const sel = buildComposeSelection({ el: null, cdId: null, artboardId: 'art1' }, 50, 60);
|
|
160
|
+
expect(sel.selector).toBe('');
|
|
161
|
+
expect(sel.artboardId).toBe('art1');
|
|
162
|
+
expect(sel.bounds).toEqual({ x: 38, y: 48, w: 24, h: 24 });
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('openCommentComposer', () => {
|
|
167
|
+
// The regression: canvas-shell.tsx's context-menu "Add comment" used to
|
|
168
|
+
// ONLY postMessage the parent shell (which merely mirrors the target for
|
|
169
|
+
// the StatusBar/sidebar) and never dispatched `cm:open-composer` — the one
|
|
170
|
+
// event `comments-overlay.tsx` listens on to actually render the composer.
|
|
171
|
+
// Selecting the menu item silently did nothing.
|
|
172
|
+
test('dispatches cm:open-composer carrying the selection + click point', () => {
|
|
173
|
+
let detail: { selection?: unknown; clientX?: number; clientY?: number } | null = null;
|
|
174
|
+
const onOpen = (e: Event) => {
|
|
175
|
+
detail = (e as CustomEvent).detail;
|
|
176
|
+
};
|
|
177
|
+
document.addEventListener('cm:open-composer', onOpen);
|
|
178
|
+
const sel = {
|
|
179
|
+
selector: '',
|
|
180
|
+
artboardId: null,
|
|
181
|
+
tag: '',
|
|
182
|
+
classes: '',
|
|
183
|
+
text: '',
|
|
184
|
+
dom_path: [],
|
|
185
|
+
bounds: { x: 30, y: 40, w: 24, h: 24 },
|
|
186
|
+
html: '',
|
|
187
|
+
};
|
|
188
|
+
try {
|
|
189
|
+
openCommentComposer(sel, 42, 52);
|
|
190
|
+
} finally {
|
|
191
|
+
document.removeEventListener('cm:open-composer', onOpen);
|
|
192
|
+
}
|
|
193
|
+
expect(detail).not.toBeNull();
|
|
194
|
+
expect(detail?.selection).toBe(sel);
|
|
195
|
+
expect(detail?.clientX).toBe(42);
|
|
196
|
+
expect(detail?.clientY).toBe(52);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -53,7 +53,11 @@ describe('exporters/jobs — concurrency gate', () => {
|
|
|
53
53
|
let sawThirdQueued = false;
|
|
54
54
|
let maxRunningObserved = 0;
|
|
55
55
|
let allDone = false;
|
|
56
|
-
|
|
56
|
+
// Generous: the assertion below is "the queue drains at all", not "it
|
|
57
|
+
// drains fast". The budget only has to outlast DEFLATE over the seeded
|
|
58
|
+
// sandbox on the slowest machine we run on — a budget tuned to one
|
|
59
|
+
// machine's disk speed fails there while the queue is working fine.
|
|
60
|
+
const deadline = Date.now() + 20_000;
|
|
57
61
|
while (Date.now() < deadline && !allDone) {
|
|
58
62
|
const jobs = await listJobs(port);
|
|
59
63
|
const byId = new Map(jobs.map((j) => [j.id, j]));
|
|
@@ -74,7 +78,7 @@ describe('exporters/jobs — concurrency gate', () => {
|
|
|
74
78
|
} finally {
|
|
75
79
|
await killProc(proc);
|
|
76
80
|
}
|
|
77
|
-
});
|
|
81
|
+
}, 30_000);
|
|
78
82
|
});
|
|
79
83
|
|
|
80
84
|
describe('exporters/jobs — pending-queue cap (security fan-out, /flow:done)', () => {
|
|
@@ -109,7 +113,9 @@ describe('exporters/jobs — pending-queue cap (security fan-out, /flow:done)',
|
|
|
109
113
|
// Once jobs drain below the cap, enqueue works again — this is a
|
|
110
114
|
// capacity limit, not a permanent lockout.
|
|
111
115
|
let drained = false;
|
|
112
|
-
|
|
116
|
+
// Same reasoning as the concurrency gate above — budget for the slowest
|
|
117
|
+
// machine, since what's under test is that the cap lifts, not latency.
|
|
118
|
+
const deadline = Date.now() + 20_000;
|
|
113
119
|
while (Date.now() < deadline && !drained) {
|
|
114
120
|
const jobs = await listJobs(port);
|
|
115
121
|
drained = jobs.every((j) => j.status === 'done' || j.status === 'failed');
|
|
@@ -121,7 +127,7 @@ describe('exporters/jobs — pending-queue cap (security fan-out, /flow:done)',
|
|
|
121
127
|
} finally {
|
|
122
128
|
await killProc(proc);
|
|
123
129
|
}
|
|
124
|
-
});
|
|
130
|
+
}, 30_000);
|
|
125
131
|
});
|
|
126
132
|
|
|
127
133
|
describe('exporters/jobs — list() + history under concurrency', () => {
|
|
@@ -37,8 +37,17 @@ describe('a cloud-linked repo shows ONE save mechanism (DDR-218)', () => {
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
test('the app gate is the CORROBORATED link, and the cell posture is untouched', () => {
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// Hoisted to a single top-level definition (2026-08-14) so the toolbar and
|
|
41
|
+
// status bar read the SAME posture the panel does — see
|
|
42
|
+
// cloud-managed-save-surfaces.test.ts. The gate itself is unchanged, and
|
|
43
|
+
// `credentialed` is the load-bearing half: config.json alone is
|
|
44
|
+
// attacker-authorable (B2), so a link claim without corroboration must
|
|
45
|
+
// never reach this posture.
|
|
46
|
+
expect(APP).toContain('const cloudManaged = !cfg.cloud && !!cloudLinkedHub?.credentialed;');
|
|
47
|
+
expect(APP).toContain('const cellManaged = !!cfg.cloud;');
|
|
48
|
+
// …and the panel is fed from those names, not a re-derivation.
|
|
49
|
+
expect(APP).toContain('cloudManaged={cloudManaged}');
|
|
50
|
+
expect(APP).toContain('historyOnly={cellManaged}');
|
|
42
51
|
});
|
|
43
52
|
|
|
44
53
|
test('CloudBar lifts every link change the posture depends on', () => {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DRAG_THRESHOLD_PX,
|
|
10
10
|
isArtboardDragChrome,
|
|
11
11
|
isEditableTarget,
|
|
12
|
+
isOverlayTarget,
|
|
12
13
|
type Tool,
|
|
13
14
|
yieldsClickToArtboardChrome,
|
|
14
15
|
yieldsToArtboardDrag,
|
|
@@ -496,6 +497,74 @@ describe('input-router / isArtboardDragChrome', () => {
|
|
|
496
497
|
});
|
|
497
498
|
});
|
|
498
499
|
|
|
500
|
+
// issue-90 — once comment mode is armed, a bare click anywhere on the shell's
|
|
501
|
+
// floating chrome (tool palette, zoom pill, minimap, right-click context
|
|
502
|
+
// menu) was claimed as `drop-comment` and dropped a stray pin at the
|
|
503
|
+
// chrome's own coordinates instead of reaching the chrome's own React
|
|
504
|
+
// onClick — the palette became unusable the moment the user picked the
|
|
505
|
+
// Comment tool. `classifyContextKind` (canvas-shell.tsx) already treats
|
|
506
|
+
// these same selectors as non-canvas `'overlay'` chrome; `isOverlayTarget`
|
|
507
|
+
// was the one claim gate that didn't.
|
|
508
|
+
describe('input-router / isOverlayTarget — shell chrome exemptions (issue-90)', () => {
|
|
509
|
+
// isOverlayTarget makes ONE `closest(a, b, c, ...)` call against the full
|
|
510
|
+
// comma-joined selector list, unlike isArtboardDragChrome's fakeTarget
|
|
511
|
+
// above (which mimics one call per bare selector) — so the stub instead
|
|
512
|
+
// checks whether any of the target's own classes appears among the
|
|
513
|
+
// selector's comma-separated parts, same as a real `Element.closest` would
|
|
514
|
+
// resolve for a node carrying that class.
|
|
515
|
+
const fakeTarget = (memberOf: string[]): EventTarget =>
|
|
516
|
+
({
|
|
517
|
+
closest: (sel: string) => (memberOf.some((cls) => sel.includes(cls)) ? {} : null),
|
|
518
|
+
}) as unknown as EventTarget;
|
|
519
|
+
|
|
520
|
+
test('null target → false', () => {
|
|
521
|
+
expect(isOverlayTarget(null)).toBe(false);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test('a click on the tool palette is exempt', () => {
|
|
525
|
+
expect(isOverlayTarget(fakeTarget(['.dc-tool-palette']))).toBe(true);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('a click on the zoom toolbar is exempt', () => {
|
|
529
|
+
expect(isOverlayTarget(fakeTarget(['.dc-zoom-tb']))).toBe(true);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test('a click on the minimap is exempt', () => {
|
|
533
|
+
expect(isOverlayTarget(fakeTarget(['.dc-mm']))).toBe(true);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test('a click on the right-click context menu is exempt', () => {
|
|
537
|
+
expect(isOverlayTarget(fakeTarget(['.dc-context-menu']))).toBe(true);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test('a click on ordinary canvas content is NOT exempt', () => {
|
|
541
|
+
expect(isOverlayTarget(fakeTarget([]))).toBe(false);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('the pre-existing comment-overlay exemptions still hold', () => {
|
|
545
|
+
expect(isOverlayTarget(fakeTarget(['.cm-composer']))).toBe(true);
|
|
546
|
+
expect(isOverlayTarget(fakeTarget(['.cm-thread']))).toBe(true);
|
|
547
|
+
expect(isOverlayTarget(fakeTarget(['.cm-mention-popup']))).toBe(true);
|
|
548
|
+
expect(isOverlayTarget(fakeTarget(['.cm-pin']))).toBe(true);
|
|
549
|
+
expect(isOverlayTarget(fakeTarget(['[data-mediaref-player]']))).toBe(true);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// Security review finding (issue-90) — `.dc-world` is where the ACTIVE
|
|
553
|
+
// CANVAS's own untrusted, AI/user-authored JSX renders. None of the real
|
|
554
|
+
// chrome ever lives there, so an element inside `.dc-world` that ALSO
|
|
555
|
+
// carries one of these class names is necessarily a same-name imposter —
|
|
556
|
+
// untrusted canvas content spoofing the shell's own chrome to make the
|
|
557
|
+
// router yield (skip preventDefault) on it instead of claiming the
|
|
558
|
+
// gesture. Must be rejected regardless of which chrome class it copies.
|
|
559
|
+
test('an element inside .dc-world carrying a spoofed chrome class is NOT exempt', () => {
|
|
560
|
+
expect(isOverlayTarget(fakeTarget(['.dc-world', '.dc-tool-palette']))).toBe(false);
|
|
561
|
+
expect(isOverlayTarget(fakeTarget(['.dc-world', '.dc-zoom-tb']))).toBe(false);
|
|
562
|
+
expect(isOverlayTarget(fakeTarget(['.dc-world', '.dc-mm']))).toBe(false);
|
|
563
|
+
expect(isOverlayTarget(fakeTarget(['.dc-world', '.dc-context-menu']))).toBe(false);
|
|
564
|
+
expect(isOverlayTarget(fakeTarget(['.dc-world', '.cm-composer']))).toBe(false);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
499
568
|
// issue-71 — the actual regression: before this fix, useInputRouter's
|
|
500
569
|
// pointerdown/mousedown handlers called `stopImmediatePropagation()` on EVERY
|
|
501
570
|
// claimed action, including a bare click on an artboard's own drag chrome —
|