@1agh/maude 0.29.0 → 0.30.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/README.md +1 -1
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1708 -1660
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +208 -116
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-shell.tsx +305 -8
- package/apps/studio/client/app.jsx +2812 -238
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/styles/3-shell-maude.css +713 -11
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +16 -2
- package/apps/studio/client/tour/overlay.jsx +105 -17
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +20 -20
- package/apps/studio/dist/client.bundle.js +3322 -602
- package/apps/studio/dist/comment-mount.js +95 -9
- package/apps/studio/dist/styles.css +2490 -167
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/history.ts +20 -3
- package/apps/studio/http.ts +124 -5
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +1 -7
- package/apps/studio/server.ts +5 -2
- package/apps/studio/sync/agent.ts +231 -57
- package/apps/studio/sync/codec.ts +45 -0
- package/apps/studio/sync/cold-start.ts +158 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +52 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-start.test.ts +244 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +12 -2
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-collab.tsx +20 -15
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +135 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +2 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +28 -4
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// annotations-snap — FigJam v3 drag snapping + smart guides (pure, DOM-free).
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
computeSnap,
|
|
7
|
+
GRID_PITCH_PX,
|
|
8
|
+
SNAP_THRESHOLD_PX,
|
|
9
|
+
type SnapBox,
|
|
10
|
+
} from '../annotations-snap.ts';
|
|
11
|
+
|
|
12
|
+
const box = (x: number, y: number, w = 100, h = 100): SnapBox => ({ x, y, w, h });
|
|
13
|
+
|
|
14
|
+
describe('computeSnap', () => {
|
|
15
|
+
test('snaps left edge to a candidate left edge within the threshold', () => {
|
|
16
|
+
const res = computeSnap(box(104, 300), [box(100, 0)], SNAP_THRESHOLD_PX);
|
|
17
|
+
expect(res.dx).toBe(-4);
|
|
18
|
+
expect(res.dy).toBe(0);
|
|
19
|
+
expect(res.guides).toHaveLength(1);
|
|
20
|
+
const guide = res.guides[0];
|
|
21
|
+
expect(guide?.axis).toBe('x');
|
|
22
|
+
expect(guide?.at).toBe(100);
|
|
23
|
+
// The guide spans from the candidate to the (snapped) moving box.
|
|
24
|
+
expect(guide?.from).toBe(0);
|
|
25
|
+
expect(guide?.to).toBe(400);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('centers participate — center-to-center snap', () => {
|
|
29
|
+
// moving center x = 153, candidate center x = 150 → dx -3.
|
|
30
|
+
const res = computeSnap(box(103, 0), [box(100, 300)], SNAP_THRESHOLD_PX);
|
|
31
|
+
expect(res.dx).toBe(-3);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('axes resolve independently against different candidates', () => {
|
|
35
|
+
const res = computeSnap(box(104, 203), [box(100, 500), box(500, 200)], SNAP_THRESHOLD_PX);
|
|
36
|
+
expect(res.dx).toBe(-4); // left edge → first candidate
|
|
37
|
+
expect(res.dy).toBe(-3); // top edge → second candidate
|
|
38
|
+
expect(res.guides).toHaveLength(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('nearest line wins; outside the threshold = no snap', () => {
|
|
42
|
+
const res = computeSnap(box(104, 0), [box(100, 0), box(105, 0)], SNAP_THRESHOLD_PX);
|
|
43
|
+
expect(res.dx).toBe(1); // 105 is 1 away vs 100 being 4 away
|
|
44
|
+
const none = computeSnap(box(120, 0), [box(100, 300)], SNAP_THRESHOLD_PX);
|
|
45
|
+
expect(none.dx).toBe(0);
|
|
46
|
+
expect(none.guides).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('empty candidates / zero threshold → no snap', () => {
|
|
50
|
+
expect(computeSnap(box(0, 0), [], SNAP_THRESHOLD_PX).guides).toHaveLength(0);
|
|
51
|
+
expect(computeSnap(box(101, 0), [box(100, 0)], 0).dx).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('grid fallback — leading edge snaps to the 24px lattice with no candidates', () => {
|
|
55
|
+
// x=98 → nearest grid line 96 (4·24), 2 away; y=50 → nearest 48, 2 away.
|
|
56
|
+
const res = computeSnap(box(98, 50), [], SNAP_THRESHOLD_PX, { grid: GRID_PITCH_PX });
|
|
57
|
+
expect(res.dx).toBe(-2);
|
|
58
|
+
expect(res.dy).toBe(-2);
|
|
59
|
+
// Grid snap is silent — the dots themselves are the visual, no guide lines.
|
|
60
|
+
expect(res.guides).toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('grid fallback is per-axis — a smart-guide axis keeps its candidate snap', () => {
|
|
64
|
+
// x snaps to the candidate edge (dx -4 → x=100, NOT the 96 grid line);
|
|
65
|
+
// y has no candidate within reach → grid line 48 wins (dy -2).
|
|
66
|
+
const res = computeSnap(box(104, 50), [box(100, 500)], SNAP_THRESHOLD_PX, {
|
|
67
|
+
grid: GRID_PITCH_PX,
|
|
68
|
+
});
|
|
69
|
+
expect(res.dx).toBe(-4);
|
|
70
|
+
expect(res.dy).toBe(-2);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('grid outside the threshold → no correction', () => {
|
|
74
|
+
// x=84 sits 12 from both 72 and 96 — beyond the 6px threshold.
|
|
75
|
+
const res = computeSnap(box(84, 84), [], SNAP_THRESHOLD_PX, { grid: GRID_PITCH_PX });
|
|
76
|
+
expect(res.dx).toBe(0);
|
|
77
|
+
expect(res.dy).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
|
|
6
6
|
import { describe, expect, test } from 'bun:test';
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
applyEdit,
|
|
10
|
+
applyRemove,
|
|
11
|
+
applyTextEdit,
|
|
12
|
+
CanvasEditError,
|
|
13
|
+
editAttribute,
|
|
14
|
+
} from '../canvas-edit.ts';
|
|
9
15
|
import { transpileCanvasSource } from '../canvas-pipeline.ts';
|
|
10
16
|
|
|
11
17
|
const CANVAS = '/abs/Canvas.tsx';
|
|
@@ -52,6 +58,42 @@ describe('canvas-edit / applyEdit', () => {
|
|
|
52
58
|
expect(out.source).toBe(`function Demo() { return <button aria-label="new">x</button>; }`);
|
|
53
59
|
});
|
|
54
60
|
|
|
61
|
+
test('JSX-attribute-escapes a " in a replaced literal value (no JSON \\" corruption)', () => {
|
|
62
|
+
// Regression: the replace branch used to emit JSON.stringify(value) → `\"`,
|
|
63
|
+
// which is invalid JS-escaping inside a JSX attribute and corrupts the source.
|
|
64
|
+
// It must land as the HTML entity " instead. See DDR-105.
|
|
65
|
+
const src = `function Demo() { return <button aria-label="old">x</button>; }`;
|
|
66
|
+
const ids = idsOf(src);
|
|
67
|
+
const id = ids.button as string;
|
|
68
|
+
const out = applyEdit(CANVAS, src, id, 'aria-label', 'say "hi" <b>');
|
|
69
|
+
expect(out.source).toBe(
|
|
70
|
+
`function Demo() { return <button aria-label="say "hi" <b>">x</button>; }`
|
|
71
|
+
);
|
|
72
|
+
// Never the JS-escaped form that broke things.
|
|
73
|
+
expect(out.source).not.toContain('\\"');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('JSX-attribute-escapes a " in a newly inserted attribute value', () => {
|
|
77
|
+
const src = 'function Demo() { return <div>x</div>; }';
|
|
78
|
+
const ids = idsOf(src);
|
|
79
|
+
const id = ids.div as string;
|
|
80
|
+
const out = applyEdit(CANVAS, src, id, 'title', 'a "quoted" title');
|
|
81
|
+
expect(out.source).toBe(
|
|
82
|
+
`function Demo() { return <div title="a "quoted" title">x</div>; }`
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('replaces an expression-container attr value with a JSX-escaped literal', () => {
|
|
87
|
+
// <Tag name={expr} /> → <Tag name="value" />, entity-escaped (not JSON-escaped).
|
|
88
|
+
const src = `function Demo() { return <button aria-label={lbl}>x</button>; }`;
|
|
89
|
+
const ids = idsOf(src);
|
|
90
|
+
const id = ids.button as string;
|
|
91
|
+
const out = applyEdit(CANVAS, src, id, 'aria-label', 'with "quote"');
|
|
92
|
+
expect(out.source).toBe(
|
|
93
|
+
`function Demo() { return <button aria-label="with "quote"">x</button>; }`
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
55
97
|
test('swaps a single style.<prop> value inside style={{...}}', () => {
|
|
56
98
|
const src = 'function Demo() { return <div style={{ padding: 8, margin: 4 }}>x</div>; }';
|
|
57
99
|
const ids = idsOf(src);
|
|
@@ -119,6 +161,62 @@ describe('canvas-edit / applyEdit', () => {
|
|
|
119
161
|
});
|
|
120
162
|
});
|
|
121
163
|
|
|
164
|
+
describe('canvas-edit / applyRemove (reset)', () => {
|
|
165
|
+
test('removes one style key, leaving the siblings + object intact', () => {
|
|
166
|
+
const src = 'function Demo() { return <div style={{ padding: 8, margin: 4 }}>x</div>; }';
|
|
167
|
+
const ids = idsOf(src);
|
|
168
|
+
const out = applyRemove(CANVAS, src, ids.div as string, 'style.margin');
|
|
169
|
+
expect(out.source).toBe('function Demo() { return <div style={{ padding: 8 }}>x</div>; }');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('removes the leading style key (consumes the trailing comma)', () => {
|
|
173
|
+
const src = 'function Demo() { return <div style={{ padding: 8, margin: 4 }}>x</div>; }';
|
|
174
|
+
const ids = idsOf(src);
|
|
175
|
+
const out = applyRemove(CANVAS, src, ids.div as string, 'style.padding');
|
|
176
|
+
expect(out.source).toBe('function Demo() { return <div style={{ margin: 4 }}>x</div>; }');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('removing the only style key drops the whole style attribute', () => {
|
|
180
|
+
const src = 'function Demo() { return <div style={{ padding: 8 }}>x</div>; }';
|
|
181
|
+
const ids = idsOf(src);
|
|
182
|
+
const out = applyRemove(CANVAS, src, ids.div as string, 'style.padding');
|
|
183
|
+
expect(out.source).toBe('function Demo() { return <div>x</div>; }');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('accepts a kebab style prop name (border-radius → borderRadius)', () => {
|
|
187
|
+
const src = 'function Demo() { return <div style={{ borderRadius: 8, padding: 4 }}>x</div>; }';
|
|
188
|
+
const ids = idsOf(src);
|
|
189
|
+
const out = applyRemove(CANVAS, src, ids.div as string, 'style.border-radius');
|
|
190
|
+
expect(out.source).toBe('function Demo() { return <div style={{ padding: 4 }}>x</div>; }');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('removes a plain attribute (custom data-*)', () => {
|
|
194
|
+
const src = 'function Demo() { return <button data-state="active">x</button>; }';
|
|
195
|
+
const ids = idsOf(src);
|
|
196
|
+
const out = applyRemove(CANVAS, src, ids.button as string, 'data-state');
|
|
197
|
+
expect(out.source).toBe('function Demo() { return <button>x</button>; }');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('is a no-op (delta 0) when the key / attribute is absent', () => {
|
|
201
|
+
const src = 'function Demo() { return <div style={{ padding: 8 }}>x</div>; }';
|
|
202
|
+
const ids = idsOf(src);
|
|
203
|
+
const a = applyRemove(CANVAS, src, ids.div as string, 'style.margin');
|
|
204
|
+
expect(a.source).toBe(src);
|
|
205
|
+
expect(a.delta).toBe(0);
|
|
206
|
+
const b = applyRemove(CANVAS, src, ids.div as string, 'data-nope');
|
|
207
|
+
expect(b.source).toBe(src);
|
|
208
|
+
expect(b.delta).toBe(0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('refuses to remove data-cd-id (pipeline-owned)', () => {
|
|
212
|
+
const src = 'function Demo() { return <div>x</div>; }';
|
|
213
|
+
const ids = idsOf(src);
|
|
214
|
+
expect(() => applyRemove(CANVAS, src, ids.div as string, 'data-cd-id')).toThrow(
|
|
215
|
+
CanvasEditError
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
122
220
|
describe('canvas-edit / editAttribute (fs)', () => {
|
|
123
221
|
test('writes the edit atomically + is a no-op when source unchanged', async () => {
|
|
124
222
|
const tmp = `/tmp/canvas-edit-${Math.random().toString(36).slice(2)}.tsx`;
|
|
@@ -137,3 +235,85 @@ describe('canvas-edit / editAttribute (fs)', () => {
|
|
|
137
235
|
expect(r2.delta).toBe(0);
|
|
138
236
|
});
|
|
139
237
|
});
|
|
238
|
+
|
|
239
|
+
// Phase 12 (DDR-103) — inline text-content edit. Leaf-text only; JSX-escaped.
|
|
240
|
+
describe('canvas-edit / applyTextEdit', () => {
|
|
241
|
+
test('overwrites a leaf text node', () => {
|
|
242
|
+
const src = `function Demo() { return <button>Save</button>; }`;
|
|
243
|
+
const id = idsOf(src).button as string;
|
|
244
|
+
const out = applyTextEdit(CANVAS, src, id, 'Uložit');
|
|
245
|
+
expect(out.source).toBe(`function Demo() { return <button>Uložit</button>; }`);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('preserves surrounding whitespace / indentation', () => {
|
|
249
|
+
const src = [
|
|
250
|
+
'function Demo() {',
|
|
251
|
+
' return (',
|
|
252
|
+
' <button>',
|
|
253
|
+
' Save',
|
|
254
|
+
' </button>',
|
|
255
|
+
' );',
|
|
256
|
+
'}',
|
|
257
|
+
].join('\n');
|
|
258
|
+
const id = idsOf(src).button as string;
|
|
259
|
+
const out = applyTextEdit(CANVAS, src, id, 'Go');
|
|
260
|
+
expect(out.source).toContain(' <button>\n Go\n </button>');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('JSX-escapes <, >, {, }, & so text can never become markup or an expression', () => {
|
|
264
|
+
const src = `function Demo() { return <code>x</code>; }`;
|
|
265
|
+
const id = idsOf(src).code as string;
|
|
266
|
+
const out = applyTextEdit(CANVAS, src, id, 'a < b && c > {d}');
|
|
267
|
+
expect(out.source).toContain('a < b && c > {d}');
|
|
268
|
+
expect(out.source).not.toContain('{d}');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('refuses an element with mixed (element) children', () => {
|
|
272
|
+
const src = `function Demo() { return <div>hi <b>there</b></div>; }`;
|
|
273
|
+
const id = idsOf(src).div as string;
|
|
274
|
+
expect(() => applyTextEdit(CANVAS, src, id, 'x')).toThrow(CanvasEditError);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('refuses an element whose only child is a JSX expression', () => {
|
|
278
|
+
const src = 'function Demo() { const t = "x"; return <h1>{t}</h1>; }';
|
|
279
|
+
const id = idsOf(src).h1 as string;
|
|
280
|
+
expect(() => applyTextEdit(CANVAS, src, id, 'x')).toThrow(CanvasEditError);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('refuses a self-closing / empty element (no text to edit)', () => {
|
|
284
|
+
const src = `function Demo() { return <div className="x" />; }`;
|
|
285
|
+
const id = idsOf(src).div as string;
|
|
286
|
+
expect(() => applyTextEdit(CANVAS, src, id, 'x')).toThrow(CanvasEditError);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('throws on a missing id', () => {
|
|
290
|
+
const src = `function Demo() { return <button>Save</button>; }`;
|
|
291
|
+
expect(() => applyTextEdit(CANVAS, src, 'deadbeef', 'x')).toThrow(CanvasEditError);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Phase 12 (DDR-103) — the `style.<prop>` write path the CSS knobs ride
|
|
296
|
+
// (api.editCss maps property → camelCase + always quotes the value).
|
|
297
|
+
describe('canvas-edit / applyEdit style.<prop> (CSS-knob path)', () => {
|
|
298
|
+
test('inserts an inline style object when none exists', () => {
|
|
299
|
+
const src = `function Demo() { return <div>x</div>; }`;
|
|
300
|
+
const id = idsOf(src).div as string;
|
|
301
|
+
const out = applyEdit(CANVAS, src, id, 'style.borderRadius', '"8px"');
|
|
302
|
+
expect(out.source).toContain('style={{ borderRadius: "8px" }}');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('overwrites an existing style key, leaving siblings intact', () => {
|
|
306
|
+
const src = `function Demo() { return <div style={{ padding: 4, color: "red" }}>x</div>; }`;
|
|
307
|
+
const id = idsOf(src).div as string;
|
|
308
|
+
const out = applyEdit(CANVAS, src, id, 'style.color', '"blue"');
|
|
309
|
+
expect(out.source).toContain('color: "blue"');
|
|
310
|
+
expect(out.source).toContain('padding: 4');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('accepts a token-valued (var(--…)) string for on-system edits', () => {
|
|
314
|
+
const src = `function Demo() { return <div>x</div>; }`;
|
|
315
|
+
const id = idsOf(src).div as string;
|
|
316
|
+
const out = applyEdit(CANVAS, src, id, 'style.padding', '"var(--space-3)"');
|
|
317
|
+
expect(out.source).toContain('style={{ padding: "var(--space-3)" }}');
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -54,11 +54,19 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
54
54
|
// /_api/canvas (Phase 22 file-write) is deliberately NOT in the canvas
|
|
55
55
|
// origin's route allowlist — an untrusted canvas iframe must never create
|
|
56
56
|
// arbitrary .tsx files. It is reachable ONLY from the main origin.
|
|
57
|
+
// Phase 12 (DDR-103) — the in-canvas direct-edit write routes are
|
|
58
|
+
// MAIN-ORIGIN ONLY: absent from CANVAS_SAFE_API + startCanvasServer's
|
|
59
|
+
// route map, so the untrusted canvas iframe origin must never reach a
|
|
60
|
+
// source-write endpoint. A GET here 403s at the gate (not 405 from the
|
|
61
|
+
// handler), proving the route is unreachable on this origin.
|
|
57
62
|
for (const p of [
|
|
58
63
|
'/_config',
|
|
59
64
|
'/_sync-status',
|
|
60
65
|
'/_api/export',
|
|
61
66
|
'/_api/canvas',
|
|
67
|
+
'/_api/edit-css',
|
|
68
|
+
'/_api/edit-text',
|
|
69
|
+
'/_api/edit-attr',
|
|
62
70
|
'/package.json',
|
|
63
71
|
]) {
|
|
64
72
|
expect(await code(p)).toBe(403);
|
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
// Unit: Room behavior — two in-memory peers see each other, debounced flush
|
|
2
2
|
// fires, flush is idempotent, room teardown is clean.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { describe, expect, test } from 'bun:test';
|
|
5
5
|
|
|
6
6
|
import { Awareness } from 'y-protocols/awareness';
|
|
7
7
|
import * as Y from 'yjs';
|
|
8
8
|
|
|
9
|
-
import {
|
|
10
|
-
encodeAwarenessFrame,
|
|
11
|
-
encodeHandshake,
|
|
12
|
-
encodeSyncUpdate,
|
|
13
|
-
handleMessage,
|
|
14
|
-
} from '../collab/protocol.ts';
|
|
9
|
+
import { encodeAwarenessFrame, handleMessage } from '../collab/protocol.ts';
|
|
15
10
|
import { createRoom, type RoomCallbacks, type RoomConn } from '../collab/room.ts';
|
|
16
11
|
|
|
17
12
|
function makeConn(id: string): RoomConn & { recv: Uint8Array[] } {
|
|
@@ -39,6 +39,11 @@ describe('cspForCanvasShell — A6 hardening', () => {
|
|
|
39
39
|
expect(csp).toContain("frame-ancestors 'self' http://localhost:4399");
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
test('frame-ancestors carries every advertised loopback spelling (localhost + 127.0.0.1)', () => {
|
|
43
|
+
const csp = cspForCanvasShell(SHELL, 'http://localhost:4399 http://127.0.0.1:4399');
|
|
44
|
+
expect(csp).toContain("frame-ancestors 'self' http://localhost:4399 http://127.0.0.1:4399");
|
|
45
|
+
});
|
|
46
|
+
|
|
42
47
|
test('frame-ancestors is OMITTED when the main origin is unknown (would block the legit embed)', () => {
|
|
43
48
|
const csp = cspForCanvasShell(SHELL);
|
|
44
49
|
expect(csp).not.toContain('frame-ancestors');
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// CSRF Origin guard for the main-origin source-write routes (edit-css /
|
|
2
|
+
// edit-text / edit-attr). DDR-105. The DDR-054 origin-split already blocks the
|
|
3
|
+
// untrusted canvas *iframe* (canvas-origin-gate.test.ts proves that). This guard
|
|
4
|
+
// covers the OTHER untrusted origin: a malicious top-level page in another tab
|
|
5
|
+
// forging a `text/plain` CORS simple-request POST to localhost. The browser
|
|
6
|
+
// stamps such a request with an unspoofable cross-origin `Origin` header, so the
|
|
7
|
+
// guard must reject it — while still letting the legit same-origin shell write
|
|
8
|
+
// and not breaking non-browser clients (which send no Origin header).
|
|
9
|
+
//
|
|
10
|
+
// Unit-level on purpose: `sameOriginWrite` is a pure function of the Request, so
|
|
11
|
+
// we exercise the decision directly instead of booting a subprocess server.
|
|
12
|
+
// (A booted-server integration test would add a 124th server-booting file to the
|
|
13
|
+
// suite, which perturbs bun's worker scheduling and surfaces a PRE-EXISTING
|
|
14
|
+
// cross-worker port-collision flake in canvas-meta-api.test.ts — a test-harness
|
|
15
|
+
// race unrelated to this guard. See the Phase 12.2 validate notes.)
|
|
16
|
+
|
|
17
|
+
import { describe, expect, test } from 'bun:test';
|
|
18
|
+
|
|
19
|
+
import { sameOriginWrite } from '../http.ts';
|
|
20
|
+
|
|
21
|
+
const SELF = 'http://localhost:4399';
|
|
22
|
+
const post = (origin?: string): Request =>
|
|
23
|
+
new Request(`${SELF}/_api/edit-css`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'content-type': 'text/plain', ...(origin ? { origin } : {}) },
|
|
26
|
+
body: '{}',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('CSRF Origin guard — sameOriginWrite (DDR-105)', () => {
|
|
30
|
+
test('rejects a forged cross-origin Origin', () => {
|
|
31
|
+
expect(sameOriginWrite(post('http://evil.example'))).toBe(false);
|
|
32
|
+
// A look-alike host (substring / suffix tricks) must not slip through.
|
|
33
|
+
expect(sameOriginWrite(post('http://localhost.evil.example'))).toBe(false);
|
|
34
|
+
expect(sameOriginWrite(post('http://localhost:4399.evil.example'))).toBe(false);
|
|
35
|
+
// Right host, wrong port is still cross-origin.
|
|
36
|
+
expect(sameOriginWrite(post('http://localhost:4400'))).toBe(false);
|
|
37
|
+
// Right host:port, wrong scheme is still cross-origin.
|
|
38
|
+
expect(sameOriginWrite(post('https://localhost:4399'))).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('allows the legit same-origin shell Origin', () => {
|
|
42
|
+
expect(sameOriginWrite(post(SELF))).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('allows a request with no Origin header (curl / programmatic / bun:test)', () => {
|
|
46
|
+
expect(sameOriginWrite(post())).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('rejects an unparseable Origin rather than letting it through', () => {
|
|
50
|
+
expect(sameOriginWrite(post('not a url'))).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|