@1agh/maude 0.39.1 → 0.41.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 +2 -2
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +306 -5
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +1316 -67
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/_html-playwright.mjs +9 -1
- package/apps/studio/bin/_pdf-playwright.mjs +8 -1
- package/apps/studio/bin/_png-playwright.mjs +8 -1
- package/apps/studio/bin/_pw-launch.mjs +54 -0
- package/apps/studio/bin/_svg-playwright.mjs +8 -1
- package/apps/studio/bin/_video-playwright.mjs +452 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +2625 -111
- package/apps/studio/canvas-lib.tsx +238 -2
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +514 -20
- package/apps/studio/client/app.jsx +2480 -52
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +15 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
- package/apps/studio/client/panels/timeline-parse.js +229 -0
- package/apps/studio/client/panels/timeline-snap.js +55 -0
- package/apps/studio/client/styles/3-shell-maude.css +155 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +16 -6
- package/apps/studio/context.ts +113 -1
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +89 -17
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/runtime/.min-sizes.json +11 -1
- package/apps/studio/dist/runtime/@remotion_media.js +491 -0
- package/apps/studio/dist/runtime/@remotion_player.js +56 -0
- package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
- package/apps/studio/dist/runtime/remotion.js +42 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +117 -0
- package/apps/studio/exporters/_runtime.ts +69 -0
- package/apps/studio/exporters/html.ts +4 -3
- package/apps/studio/exporters/index.ts +28 -2
- package/apps/studio/exporters/pdf.ts +4 -3
- package/apps/studio/exporters/png.ts +5 -3
- package/apps/studio/exporters/pptx.ts +6 -4
- package/apps/studio/exporters/svg.ts +9 -5
- package/apps/studio/exporters/video-encode-lib.ts +200 -0
- package/apps/studio/exporters/video-render-lib.ts +108 -0
- package/apps/studio/exporters/video.ts +184 -0
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +758 -27
- package/apps/studio/input-router.tsx +19 -1
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +140 -0
- package/apps/studio/test/canvas-list-watch.test.ts +49 -0
- package/apps/studio/test/canvas-media-drop.test.ts +30 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/config-reload.test.ts +230 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/test/video-asset.test.ts +163 -0
- package/apps/studio/test/video-comp-fixture.test.ts +50 -0
- package/apps/studio/test/video-comp.test.ts +168 -0
- package/apps/studio/test/video-render-bridge.test.ts +149 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +55 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
- package/plugins/design/templates/_shell.html +25 -2
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type ArrowStroke,
|
|
17
17
|
type ImageStroke,
|
|
18
18
|
type LinkStroke,
|
|
19
|
+
type MediaRefStroke,
|
|
19
20
|
type RectStroke,
|
|
20
21
|
type StickyStroke,
|
|
21
22
|
type Stroke,
|
|
@@ -314,6 +315,52 @@ describe('annotations round-trip / link chip (Phase 23)', () => {
|
|
|
314
315
|
});
|
|
315
316
|
});
|
|
316
317
|
|
|
318
|
+
describe('annotations round-trip / media-reference chip (DDR-150 P4)', () => {
|
|
319
|
+
const ref: MediaRefStroke = {
|
|
320
|
+
id: 'mr1',
|
|
321
|
+
tool: 'mediaref',
|
|
322
|
+
x: 120,
|
|
323
|
+
y: 90,
|
|
324
|
+
w: 240,
|
|
325
|
+
h: 72,
|
|
326
|
+
src: 'assets/9f8e7d6c.mp4',
|
|
327
|
+
mediaKind: 'video',
|
|
328
|
+
title: 'b-roll-sunset.mp4',
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
test('mediaref survives serialize → parse with all fields intact', () => {
|
|
332
|
+
const [parsed] = svgToStrokes(strokesToSvg([ref])) as MediaRefStroke[];
|
|
333
|
+
expect(parsed).toEqual(ref);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('mediaref persists data-src (the agent enumerates refs off it), never a <Video>', () => {
|
|
337
|
+
const svg = strokesToSvg([ref]);
|
|
338
|
+
expect(svg).toContain('data-src="assets/9f8e7d6c.mp4"');
|
|
339
|
+
expect(svg).toContain('data-media-kind="video"');
|
|
340
|
+
expect(svg).not.toContain('<Video');
|
|
341
|
+
expect(svg).not.toContain('<video');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('mediaref serialize → parse is idempotent', () => {
|
|
345
|
+
const once = strokesToSvg([{ ...ref, mediaKind: 'audio', src: 'assets/track.mp3' }]);
|
|
346
|
+
expect(reparse(once)).toBe(once);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test('mediaref SVG survives sanitizeAnnotationSvg byte-intact (PUT-path)', () => {
|
|
350
|
+
const svg = strokesToSvg([ref]);
|
|
351
|
+
expect(sanitizeAnnotationSvg(svg)).toBe(svg);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test('a data-src with a traversal/scheme is dropped to an inert empty ref', () => {
|
|
355
|
+
const svg =
|
|
356
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
357
|
+
'<g data-id="mr2" data-tool="mediaref" data-src="../../etc/passwd" data-media-kind="video" data-title="x">' +
|
|
358
|
+
'<rect x="0" y="0" width="240" height="72" rx="8" ry="8"/></g></svg>';
|
|
359
|
+
const [parsed] = svgToStrokes(svg) as MediaRefStroke[];
|
|
360
|
+
expect(parsed?.src).toBe('');
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
317
364
|
describe('annotations round-trip / standalone vs anchored text', () => {
|
|
318
365
|
test('standalone text round-trips its world (x, y) and stays unanchored', () => {
|
|
319
366
|
const t: TextStroke = {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// camera-reveal — Task A3 (feature-element-editing-robustness), Stage A.
|
|
2
|
+
//
|
|
3
|
+
// Regression guard for Bugs A + B: a `select-by-id` reveal must pan the camera
|
|
4
|
+
// (the `.dc-world` transform, via the viewport controller) and MUST NOT scroll
|
|
5
|
+
// the overflow:hidden `.dc-canvas` / `.dc-artboard-body` clip layers — host
|
|
6
|
+
// scroll is what desynced the camera model (phantom right strip + pan reset
|
|
7
|
+
// after an absolute-element move).
|
|
8
|
+
//
|
|
9
|
+
// canvas-shell.tsx pulls in a React/DOM dependency graph, so we register
|
|
10
|
+
// happy-dom for this file (like canvas-hmr-runtime.test.tsx) before importing.
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
13
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
GlobalRegistrator.register();
|
|
17
|
+
});
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
GlobalRegistrator.unregister();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
import type { ViewportControllerHandle } from '../canvas-lib.tsx';
|
|
23
|
+
import { REVEAL_MARGIN_PX, revealAxisDelta, revealElementViaCamera } from '../canvas-shell.tsx';
|
|
24
|
+
|
|
25
|
+
const M = REVEAL_MARGIN_PX;
|
|
26
|
+
|
|
27
|
+
describe('revealAxisDelta — minimal 1-axis camera pan', () => {
|
|
28
|
+
// Viewport spans [0, 1000]; usable band is [M, 1000 - M].
|
|
29
|
+
const V0 = 0;
|
|
30
|
+
const V1 = 1000;
|
|
31
|
+
|
|
32
|
+
test('fully visible → no pan', () => {
|
|
33
|
+
expect(revealAxisDelta(200, 400, V0, V1, M)).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('flush at the inset band edges → no pan', () => {
|
|
37
|
+
expect(revealAxisDelta(M, V1 - M, V0, V1, M)).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('off the start (left/top) edge → positive delta brings the start in', () => {
|
|
41
|
+
// element [-50, 100]; start (-50) is left of the inset band start (M=40).
|
|
42
|
+
const d = revealAxisDelta(-50, 100, V0, V1, M);
|
|
43
|
+
expect(d).toBe(M - -50); // vs - elStart = 40 - (-50) = 90
|
|
44
|
+
expect(d).toBeGreaterThan(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('off the end (right/bottom) edge → negative delta brings the end in', () => {
|
|
48
|
+
// element [900, 1100]; end (1100) is past the inset band end (960).
|
|
49
|
+
const d = revealAxisDelta(900, 1100, V0, V1, M);
|
|
50
|
+
expect(d).toBe(V1 - M - 1100); // ve - elEnd = 960 - 1100 = -140
|
|
51
|
+
expect(d).toBeLessThan(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('element wider than the viewport → no pan (matches scrollIntoView nearest)', () => {
|
|
55
|
+
expect(revealAxisDelta(-200, 1200, V0, V1, M)).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Minimal fake controller — only `panBy` is exercised; the rest satisfy the type.
|
|
60
|
+
function fakeController(): {
|
|
61
|
+
handle: ViewportControllerHandle;
|
|
62
|
+
calls: Array<[number, number]>;
|
|
63
|
+
} {
|
|
64
|
+
const calls: Array<[number, number]> = [];
|
|
65
|
+
const noop = () => {};
|
|
66
|
+
const handle: ViewportControllerHandle = {
|
|
67
|
+
viewport: { x: 0, y: 0, zoom: 1 },
|
|
68
|
+
setViewport: noop,
|
|
69
|
+
panBy: (dx, dy) => calls.push([dx, dy]),
|
|
70
|
+
zoomAt: noop,
|
|
71
|
+
fit: noop,
|
|
72
|
+
reset: noop,
|
|
73
|
+
zoomIn: noop,
|
|
74
|
+
zoomOut: noop,
|
|
75
|
+
jumpTo: noop,
|
|
76
|
+
animateTo: noop,
|
|
77
|
+
isInteracting: false,
|
|
78
|
+
};
|
|
79
|
+
return { handle, calls };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function elWithRect(rect: Partial<DOMRect>): HTMLElement {
|
|
83
|
+
const el = document.createElement('div');
|
|
84
|
+
const r = {
|
|
85
|
+
left: 0,
|
|
86
|
+
top: 0,
|
|
87
|
+
right: 0,
|
|
88
|
+
bottom: 0,
|
|
89
|
+
width: 0,
|
|
90
|
+
height: 0,
|
|
91
|
+
x: 0,
|
|
92
|
+
y: 0,
|
|
93
|
+
...rect,
|
|
94
|
+
} as DOMRect;
|
|
95
|
+
el.getBoundingClientRect = () => r;
|
|
96
|
+
return el;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('revealElementViaCamera — pans the controller, never host scroll', () => {
|
|
100
|
+
test('off-screen element pans via panBy and leaves host scroll at 0', () => {
|
|
101
|
+
const host = elWithRect({
|
|
102
|
+
left: 0,
|
|
103
|
+
top: 0,
|
|
104
|
+
right: 1000,
|
|
105
|
+
bottom: 800,
|
|
106
|
+
width: 1000,
|
|
107
|
+
height: 800,
|
|
108
|
+
});
|
|
109
|
+
// Target sits past the right edge — the exact Bug B repro (overflowing element).
|
|
110
|
+
const target = elWithRect({
|
|
111
|
+
left: 1100,
|
|
112
|
+
top: 100,
|
|
113
|
+
right: 1300,
|
|
114
|
+
bottom: 200,
|
|
115
|
+
width: 200,
|
|
116
|
+
height: 100,
|
|
117
|
+
});
|
|
118
|
+
const { handle, calls } = fakeController();
|
|
119
|
+
|
|
120
|
+
revealElementViaCamera(host, target, handle);
|
|
121
|
+
|
|
122
|
+
expect(calls.length).toBe(1);
|
|
123
|
+
const [dx, dy] = calls[0];
|
|
124
|
+
expect(dx).toBeLessThan(0); // pan world left to reveal the right-side element
|
|
125
|
+
expect(dy).toBe(0); // vertically already in view
|
|
126
|
+
// The invariant: no host scroll was touched.
|
|
127
|
+
expect(host.scrollLeft).toBe(0);
|
|
128
|
+
expect(host.scrollTop).toBe(0);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('already-visible element → no pan at all', () => {
|
|
132
|
+
const host = elWithRect({
|
|
133
|
+
left: 0,
|
|
134
|
+
top: 0,
|
|
135
|
+
right: 1000,
|
|
136
|
+
bottom: 800,
|
|
137
|
+
width: 1000,
|
|
138
|
+
height: 800,
|
|
139
|
+
});
|
|
140
|
+
const target = elWithRect({
|
|
141
|
+
left: 300,
|
|
142
|
+
top: 300,
|
|
143
|
+
right: 500,
|
|
144
|
+
bottom: 400,
|
|
145
|
+
width: 200,
|
|
146
|
+
height: 100,
|
|
147
|
+
});
|
|
148
|
+
const { handle, calls } = fakeController();
|
|
149
|
+
|
|
150
|
+
revealElementViaCamera(host, target, handle);
|
|
151
|
+
|
|
152
|
+
expect(calls.length).toBe(0);
|
|
153
|
+
expect(host.scrollLeft).toBe(0);
|
|
154
|
+
expect(host.scrollTop).toBe(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('detached element (zero box) → no pan', () => {
|
|
158
|
+
const host = elWithRect({
|
|
159
|
+
left: 0,
|
|
160
|
+
top: 0,
|
|
161
|
+
right: 1000,
|
|
162
|
+
bottom: 800,
|
|
163
|
+
width: 1000,
|
|
164
|
+
height: 800,
|
|
165
|
+
});
|
|
166
|
+
const target = elWithRect({});
|
|
167
|
+
const { handle, calls } = fakeController();
|
|
168
|
+
|
|
169
|
+
revealElementViaCamera(host, target, handle);
|
|
170
|
+
|
|
171
|
+
expect(calls.length).toBe(0);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -122,6 +122,82 @@ describe('/_api/canvas — POST round-trip', () => {
|
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test('assembles a video-comp (kind: video-comp) from dropped clips (DDR-150 P4 Task 12)', async () => {
|
|
126
|
+
const { root, designRoot } = makeSandbox();
|
|
127
|
+
const port = nextPort();
|
|
128
|
+
const proc = await bootServer(root, port);
|
|
129
|
+
try {
|
|
130
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas`, {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: { 'Content-Type': 'application/json' },
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
name: 'My Reel',
|
|
135
|
+
kind: 'video-comp',
|
|
136
|
+
fps: 30,
|
|
137
|
+
clips: [
|
|
138
|
+
{ src: 'assets/a.mp4', mediaKind: 'video', durationInFrames: 60 },
|
|
139
|
+
{ src: 'assets/b.mp4', mediaKind: 'video' },
|
|
140
|
+
{ src: 'assets/music.mp3', mediaKind: 'audio' },
|
|
141
|
+
],
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
expect(r.status).toBe(201);
|
|
145
|
+
const j = (await r.json()) as { ok: boolean; rel: string };
|
|
146
|
+
expect(j.ok).toBe(true);
|
|
147
|
+
const tsx = readFileSync(join(designRoot, 'ui', 'My Reel.tsx'), 'utf8');
|
|
148
|
+
expect(tsx).toContain('<VideoComp component={Comp}');
|
|
149
|
+
expect(tsx).toContain('<OffthreadVideo src="assets/a.mp4" />');
|
|
150
|
+
expect(tsx).toContain('<Audio src="assets/music.mp3" />');
|
|
151
|
+
expect(tsx).toContain('name="clip-1"');
|
|
152
|
+
const m = JSON.parse(readFileSync(join(designRoot, 'ui', 'My Reel.meta.json'), 'utf8'));
|
|
153
|
+
expect(m.kind).toBe('video-comp');
|
|
154
|
+
} finally {
|
|
155
|
+
await killProc(proc);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('rejects a video-comp with an empty clips[] (400) and a traversal src (400)', async () => {
|
|
160
|
+
const { root } = makeSandbox();
|
|
161
|
+
const port = nextPort();
|
|
162
|
+
const proc = await bootServer(root, port);
|
|
163
|
+
try {
|
|
164
|
+
const empty = await fetch(`http://localhost:${port}/_api/canvas`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'Content-Type': 'application/json' },
|
|
167
|
+
body: JSON.stringify({ name: 'Empty', kind: 'video-comp', clips: [] }),
|
|
168
|
+
});
|
|
169
|
+
expect(empty.status).toBe(400);
|
|
170
|
+
const bad = await fetch(`http://localhost:${port}/_api/canvas`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'Content-Type': 'application/json' },
|
|
173
|
+
body: JSON.stringify({
|
|
174
|
+
name: 'Bad',
|
|
175
|
+
kind: 'video-comp',
|
|
176
|
+
clips: [{ src: '../../etc/passwd', mediaKind: 'video' }],
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
expect(bad.status).toBe(400);
|
|
180
|
+
} finally {
|
|
181
|
+
await killProc(proc);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('rejects a cross-origin create (CSRF guard on /_api/canvas — DDR-150 P4)', async () => {
|
|
186
|
+
const { root } = makeSandbox();
|
|
187
|
+
const port = nextPort();
|
|
188
|
+
const proc = await bootServer(root, port);
|
|
189
|
+
try {
|
|
190
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas`, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
|
|
193
|
+
body: JSON.stringify({ name: 'Forged', kind: 'brief-board' }),
|
|
194
|
+
});
|
|
195
|
+
expect(r.status).toBe(403);
|
|
196
|
+
} finally {
|
|
197
|
+
await killProc(proc);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
125
201
|
test('create + delete emit canvas-list-update over the inspector WS (Phase 30)', async () => {
|
|
126
202
|
const { root } = makeSandbox();
|
|
127
203
|
const port = nextPort();
|
|
@@ -123,6 +123,59 @@ describe('canvas-edit / applyEdit', () => {
|
|
|
123
123
|
expect(out.source).toContain('<div style={{ padding: 14 }}>x</div>');
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
test('retime: bumps a referenced const so a derived total moves in lock-step (DDR-148)', async () => {
|
|
127
|
+
const { applyRetimeSequence } = await import('../canvas-edit.ts');
|
|
128
|
+
const src = [
|
|
129
|
+
'const A = 66;',
|
|
130
|
+
'const B = 62;',
|
|
131
|
+
'const XF = 16;',
|
|
132
|
+
'const TOTAL = A + B - XF;',
|
|
133
|
+
'function Movie(){ return (<TransitionSeries>',
|
|
134
|
+
' <TransitionSeries.Sequence durationInFrames={A}><Intro/></TransitionSeries.Sequence>',
|
|
135
|
+
' <TransitionSeries.Transition timing={linearTiming({ durationInFrames: XF })} />',
|
|
136
|
+
' <TransitionSeries.Sequence durationInFrames={B}><Payoff/></TransitionSeries.Sequence>',
|
|
137
|
+
'</TransitionSeries>); }',
|
|
138
|
+
].join('\n');
|
|
139
|
+
const r0 = applyRetimeSequence(CANVAS, src, 0, { durationInFrames: 80 });
|
|
140
|
+
expect(r0.source).toContain('const A = 80'); // const edited, not the literal
|
|
141
|
+
expect(r0.source).toContain('const TOTAL = A + B - XF'); // derived total intact → auto-updates
|
|
142
|
+
const r1 = applyRetimeSequence(CANVAS, r0.source, 1, { durationInFrames: 90 });
|
|
143
|
+
expect(r1.source).toContain('const B = 90');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('retime: edits a literal in place + supports from', async () => {
|
|
147
|
+
const { applyRetimeSequence } = await import('../canvas-edit.ts');
|
|
148
|
+
const src =
|
|
149
|
+
'function C(){ return <Sequence from={10} durationInFrames={40}><Foo/></Sequence>; }';
|
|
150
|
+
const r = applyRetimeSequence(CANVAS, src, 0, { from: 20, durationInFrames: 55 });
|
|
151
|
+
expect(r.source).toContain('from={20}');
|
|
152
|
+
expect(r.source).toContain('durationInFrames={55}');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('retime: an out-of-range sequence index throws', async () => {
|
|
156
|
+
const { applyRetimeSequence, CanvasEditError } = await import('../canvas-edit.ts');
|
|
157
|
+
const src = 'function C(){ return <Sequence durationInFrames={40}><Foo/></Sequence>; }';
|
|
158
|
+
expect(() => applyRetimeSequence(CANVAS, src, 5, { durationInFrames: 10 })).toThrow(
|
|
159
|
+
CanvasEditError
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('inserts into a style object with a TRAILING comma without a double comma (DDR-148 #4)', () => {
|
|
164
|
+
// A frame-driven inline style commonly ends the last property with a
|
|
165
|
+
// trailing comma. Appending must NOT produce `opacity: o, , color: …`
|
|
166
|
+
// (a syntax error that made the canvas render the OLD source on replay).
|
|
167
|
+
const src =
|
|
168
|
+
'function Demo() {\n const o = 1;\n return <div style={{ fontSize: 12, opacity: o, }}>x</div>;\n}';
|
|
169
|
+
const ids = idsOf(src);
|
|
170
|
+
const id = ids.div as string;
|
|
171
|
+
const out = applyEdit(CANVAS, src, id, 'style.color', "'#d48917'");
|
|
172
|
+
expect(out.source).toContain("color: '#d48917'");
|
|
173
|
+
expect(out.source).not.toMatch(/,\s*,/); // no double comma
|
|
174
|
+
// And it must still parse.
|
|
175
|
+
const reparse = applyEdit(CANVAS, out.source, id, 'style.fontSize', '13');
|
|
176
|
+
expect(reparse.source).toContain('fontSize: 13');
|
|
177
|
+
});
|
|
178
|
+
|
|
126
179
|
test('throws CanvasEditError when id is not found', () => {
|
|
127
180
|
const src = 'function Demo() { return <div>x</div>; }';
|
|
128
181
|
expect(() => applyEdit(CANVAS, src, 'deadbeef', 'className', 'x')).toThrow(CanvasEditError);
|
|
@@ -161,6 +214,56 @@ describe('canvas-edit / applyEdit', () => {
|
|
|
161
214
|
});
|
|
162
215
|
});
|
|
163
216
|
|
|
217
|
+
describe('canvas-edit / applyEdit — Stage F2 media src-replace', () => {
|
|
218
|
+
test('replaces a literal <img src="…"> — the ordinary "Replace image…" case', () => {
|
|
219
|
+
const src = `function Demo() { return <img src="assets/old.png" alt="x" />; }`;
|
|
220
|
+
const ids = idsOf(src);
|
|
221
|
+
const id = ids.img as string;
|
|
222
|
+
const out = applyEdit(CANVAS, src, id, 'src', 'assets/12345678.png');
|
|
223
|
+
expect(out.source).toBe(
|
|
224
|
+
`function Demo() { return <img src="assets/12345678.png" alt="x" />; }`
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('replaces a trivial {"literal"} src expression', () => {
|
|
229
|
+
const src = `function Demo() { return <img src={"assets/old.png"} alt="x" />; }`;
|
|
230
|
+
const ids = idsOf(src);
|
|
231
|
+
const id = ids.img as string;
|
|
232
|
+
const out = applyEdit(CANVAS, src, id, 'src', 'assets/new.png');
|
|
233
|
+
expect(out.source).toContain('src="assets/new.png"');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('refuses (does not corrupt) a src bound to a JS expression', () => {
|
|
237
|
+
// src={imageUrl} is a BINDING, not a static value — silently overwriting it
|
|
238
|
+
// would discard the binding. This is the exact gotcha the plan's Task F2
|
|
239
|
+
// flags for "Replace image…"; the context-menu comment already assumed
|
|
240
|
+
// this refusal existed (canvas-shell.tsx) — it didn't, until this fix.
|
|
241
|
+
const src = `function Demo({ imageUrl }) { return <img src={imageUrl} alt="x" />; }`;
|
|
242
|
+
const ids = idsOf(src);
|
|
243
|
+
const id = ids.img as string;
|
|
244
|
+
expect(() => applyEdit(CANVAS, src, id, 'src', 'assets/new.png')).toThrow(
|
|
245
|
+
/bound to a JS expression/
|
|
246
|
+
);
|
|
247
|
+
// Also refuses a member-expression / template-literal binding, not just a
|
|
248
|
+
// bare identifier.
|
|
249
|
+
const src2 = `function Demo({ item }) { return <img src={item.url} alt="x" />; }`;
|
|
250
|
+
const ids2 = idsOf(src2);
|
|
251
|
+
expect(() => applyEdit(CANVAS, src2, ids2.img as string, 'src', 'assets/new.png')).toThrow(
|
|
252
|
+
/bound to a JS expression/
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('a non-src attribute expression binding is unaffected (existing overwrite behavior)', () => {
|
|
257
|
+
// The refusal is scoped to `src` (the plan's stated gotcha) — an
|
|
258
|
+
// expression-bound `title` or similar still overwrites as before; widening
|
|
259
|
+
// the refusal to every attribute is a bigger, separate change.
|
|
260
|
+
const src = `function Demo({ label }) { return <button title={label}>x</button>; }`;
|
|
261
|
+
const ids = idsOf(src);
|
|
262
|
+
const out = applyEdit(CANVAS, src, ids.button as string, 'title', 'Fixed label');
|
|
263
|
+
expect(out.source).toContain('title="Fixed label"');
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
164
267
|
describe('canvas-edit / applyRemove (reset)', () => {
|
|
165
268
|
test('removes one style key, leaving the siblings + object intact', () => {
|
|
166
269
|
const src = 'function Demo() { return <div style={{ padding: 8, margin: 4 }}>x</div>; }';
|
|
@@ -290,6 +393,30 @@ describe('canvas-edit / applyTextEdit', () => {
|
|
|
290
393
|
const src = `function Demo() { return <button>Save</button>; }`;
|
|
291
394
|
expect(() => applyTextEdit(CANVAS, src, 'deadbeef', 'x')).toThrow(CanvasEditError);
|
|
292
395
|
});
|
|
396
|
+
|
|
397
|
+
// DDR-150 P1 — widen the editable set to a single `{'string literal'}` child.
|
|
398
|
+
test('edits a single string-literal expression child', () => {
|
|
399
|
+
const src = `function Demo() { return <h1>{'Old Title'}</h1>; }`;
|
|
400
|
+
const id = idsOf(src).h1 as string;
|
|
401
|
+
const out = applyTextEdit(CANVAS, src, id, 'New Title');
|
|
402
|
+
expect(out.source).toBe(`function Demo() { return <h1>{"New Title"}</h1>; }`);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test('string-literal rewrite is inert — quotes / braces / markup cannot escape the JS string', () => {
|
|
406
|
+
const src = `function Demo() { return <h1>{"x"}</h1>; }`;
|
|
407
|
+
const id = idsOf(src).h1 as string;
|
|
408
|
+
const out = applyTextEdit(CANVAS, src, id, 'say "hi" {expr} <b>');
|
|
409
|
+
// JSON.stringify escapes the double-quotes; {expr} / <b> stay INSIDE the JS
|
|
410
|
+
// string literal, so they never become a JSX expression or markup.
|
|
411
|
+
expect(out.source).toContain('say \\"hi\\"');
|
|
412
|
+
expect(() => transpileCanvasSource(CANVAS, out.source)).not.toThrow();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test('still refuses a dynamic {identifier} child (routes to /design:edit)', () => {
|
|
416
|
+
const src = 'function Demo() { const title = "x"; return <h1>{title}</h1>; }';
|
|
417
|
+
const id = idsOf(src).h1 as string;
|
|
418
|
+
expect(() => applyTextEdit(CANVAS, src, id, 'x')).toThrow(CanvasEditError);
|
|
419
|
+
});
|
|
293
420
|
});
|
|
294
421
|
|
|
295
422
|
// Phase 12 (DDR-103) — the `style.<prop>` write path the CSS knobs ride
|
|
@@ -316,4 +443,17 @@ describe('canvas-edit / applyEdit style.<prop> (CSS-knob path)', () => {
|
|
|
316
443
|
const out = applyEdit(CANVAS, src, id, 'style.padding', '"var(--space-3)"');
|
|
317
444
|
expect(out.source).toContain('style={{ padding: "var(--space-3)" }}');
|
|
318
445
|
});
|
|
446
|
+
|
|
447
|
+
// DDR-150 P1 — the exact video-comp shape: a var-valued prop ending in a
|
|
448
|
+
// trailing comma. Unconditionally prepending ", " produced `opacity: o, ,
|
|
449
|
+
// color` — a syntax error that left the Player rendering stale source (the
|
|
450
|
+
// "my CSS edit resets on replay" bug). Guarded by editStyleProp's between-scan.
|
|
451
|
+
test('appends onto a frame-driven inline style ending in a trailing comma (no double comma)', () => {
|
|
452
|
+
const src = `function Demo() { const o = 1; return <div style={{ opacity: o, }}>x</div>; }`;
|
|
453
|
+
const id = idsOf(src).div as string;
|
|
454
|
+
const out = applyEdit(CANVAS, src, id, 'style.color', '"var(--fg-0)"');
|
|
455
|
+
expect(out.source).not.toContain(', ,');
|
|
456
|
+
expect(out.source).toContain('color: "var(--fg-0)"');
|
|
457
|
+
expect(() => transpileCanvasSource(CANVAS, out.source)).not.toThrow();
|
|
458
|
+
});
|
|
319
459
|
});
|
|
@@ -320,3 +320,52 @@ describe('canvas-list-watch / real fs-watch end-to-end', () => {
|
|
|
320
320
|
}
|
|
321
321
|
}, 10_000);
|
|
322
322
|
});
|
|
323
|
+
|
|
324
|
+
describe('canvas-list-watch / config hot-reload (stale-config RCA)', () => {
|
|
325
|
+
// /design:setup-ds adds the `system` group to config.json mid-session;
|
|
326
|
+
// reloadConfig (context.ts) swaps ctx.cfg in place. The watcher must read
|
|
327
|
+
// groups at USE time — a boot-captured groupPaths list would keep gating
|
|
328
|
+
// the new group's files out forever.
|
|
329
|
+
// RCA: .ai/logs/rca/issue-ds-scaffold-files-not-in-filetree-stale-config.md
|
|
330
|
+
test('a group added by an in-place cfg swap uncovers its canvases', async () => {
|
|
331
|
+
const { root, designRoot } = sandbox();
|
|
332
|
+
mkdirSync(join(designRoot, 'system', 'kanban-glass', 'preview'), { recursive: true });
|
|
333
|
+
const ctx = mkCtx(designRoot);
|
|
334
|
+
// Pre-scaffold fixture shape: UI-only group.
|
|
335
|
+
ctx.cfg.canvasGroups = [{ label: 'UI', path: 'ui' }];
|
|
336
|
+
const got = collect(ctx);
|
|
337
|
+
const w = createCanvasListWatch(ctx, { debounceMs: 10 });
|
|
338
|
+
await w.ready;
|
|
339
|
+
try {
|
|
340
|
+
// Scaffold writes specimens while the group is still absent — no emit.
|
|
341
|
+
writeFileSync(join(designRoot, 'system', 'kanban-glass', 'preview', 'colors.tsx'), TSX);
|
|
342
|
+
ctx.bus.emit('fs:any', 'system/kanban-glass/preview/colors.tsx');
|
|
343
|
+
await new Promise((r) => setTimeout(r, 10 + 40));
|
|
344
|
+
await w.refresh();
|
|
345
|
+
expect(got).toHaveLength(0);
|
|
346
|
+
|
|
347
|
+
// Hot reload: new array on the SAME cfg object (what reloadConfig does),
|
|
348
|
+
// then the server.ts subscriber calls refresh() — the diff must surface
|
|
349
|
+
// the pre-existing specimen.
|
|
350
|
+
ctx.cfg.canvasGroups = [
|
|
351
|
+
{ label: 'Design system', path: 'system' },
|
|
352
|
+
{ label: 'UI', path: 'ui' },
|
|
353
|
+
];
|
|
354
|
+
await w.refresh();
|
|
355
|
+
expect(got).toHaveLength(1);
|
|
356
|
+
expect(got[0]?.action).toBe('added');
|
|
357
|
+
expect(got[0]?.rel).toBe('system/kanban-glass/preview/colors.tsx');
|
|
358
|
+
|
|
359
|
+
// And FUTURE writes under the new group pass the live fs:any gate.
|
|
360
|
+
got.length = 0;
|
|
361
|
+
writeFileSync(join(designRoot, 'system', 'kanban-glass', 'preview', 'type.tsx'), TSX);
|
|
362
|
+
ctx.bus.emit('fs:any', 'system/kanban-glass/preview/type.tsx');
|
|
363
|
+
await new Promise((r) => setTimeout(r, 10 + 40));
|
|
364
|
+
await w.refresh();
|
|
365
|
+
expect(got.some((m) => m.action === 'added' && m.rel?.endsWith('type.tsx'))).toBe(true);
|
|
366
|
+
} finally {
|
|
367
|
+
w.stop();
|
|
368
|
+
rmSync(root, { recursive: true, force: true });
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
isHttpUrl,
|
|
14
14
|
linkDomain,
|
|
15
15
|
type MediaPayload,
|
|
16
|
+
mediaSnippet,
|
|
16
17
|
normalizeUrl,
|
|
17
18
|
prettifyUrl,
|
|
18
19
|
} from '../use-canvas-media-drop.tsx';
|
|
@@ -116,10 +117,38 @@ describe('use-canvas-media-drop / classifyMediaPayload (dispatch matrix)', () =>
|
|
|
116
117
|
expect(intent?.kind).toBe('image');
|
|
117
118
|
});
|
|
118
119
|
|
|
119
|
-
test('a non-image file is ignored', () => {
|
|
120
|
+
test('a non-image, non-media file is ignored', () => {
|
|
120
121
|
expect(classifyMediaPayload({ ...empty, files: [fakeFile('application/pdf')] })).toBeNull();
|
|
121
122
|
});
|
|
122
123
|
|
|
124
|
+
test('a video file → a media intent (DDR-148)', () => {
|
|
125
|
+
const intent = classifyMediaPayload({ ...empty, files: [fakeFile('video/mp4')] });
|
|
126
|
+
expect(intent).toEqual({ kind: 'media', file: expect.anything(), mediaKind: 'video' });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('an audio file → a media intent', () => {
|
|
130
|
+
const intent = classifyMediaPayload({ ...empty, files: [fakeFile('audio/mpeg')] });
|
|
131
|
+
expect(intent?.kind).toBe('media');
|
|
132
|
+
expect(intent && intent.kind === 'media' && intent.mediaKind).toBe('audio');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('an image still wins over a co-dropped video', () => {
|
|
136
|
+
const intent = classifyMediaPayload({
|
|
137
|
+
...empty,
|
|
138
|
+
files: [fakeFile('image/png'), fakeFile('video/mp4')],
|
|
139
|
+
});
|
|
140
|
+
expect(intent?.kind).toBe('image');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('mediaSnippet emits the right Remotion element', () => {
|
|
144
|
+
expect(mediaSnippet('video', 'assets/ab12cd34.mp4')).toBe(
|
|
145
|
+
'<Video src="assets/ab12cd34.mp4" />'
|
|
146
|
+
);
|
|
147
|
+
expect(mediaSnippet('audio', 'assets/ff00aa11.mp3')).toBe(
|
|
148
|
+
'<Audio src="assets/ff00aa11.mp3" />'
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
123
152
|
test('a URL drop becomes a link with the anchor text as the title', () => {
|
|
124
153
|
const intent = classifyMediaPayload({
|
|
125
154
|
...empty,
|
|
@@ -67,6 +67,25 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
67
67
|
'/_api/edit-css',
|
|
68
68
|
'/_api/edit-text',
|
|
69
69
|
'/_api/edit-attr',
|
|
70
|
+
// DDR-148 — raw canvas source for the Timeline parser is MAIN-ORIGIN
|
|
71
|
+
// ONLY (the untrusted canvas iframe must never read project source).
|
|
72
|
+
'/_api/canvas-source',
|
|
73
|
+
// DDR-148 — Timeline drag-to-retime is a source-write, MAIN-ORIGIN ONLY.
|
|
74
|
+
'/_api/retime-sequence',
|
|
75
|
+
// DDR-150 P2 — the clip enumerator reads project source structure, so it
|
|
76
|
+
// is MAIN-ORIGIN ONLY (same boundary as /_api/canvas-source): the
|
|
77
|
+
// untrusted canvas iframe must never enumerate the comp it renders.
|
|
78
|
+
'/_api/comp-clips',
|
|
79
|
+
// DDR-150 P3 — clip removal is a source-write, MAIN-ORIGIN ONLY.
|
|
80
|
+
'/_api/remove-sequence',
|
|
81
|
+
// DDR-150 P4 — clip insert is a source-write, MAIN-ORIGIN ONLY.
|
|
82
|
+
'/_api/insert-sequence',
|
|
83
|
+
// DDR-150 P5 — z-order reorder is a source-write, MAIN-ORIGIN ONLY.
|
|
84
|
+
'/_api/reorder-sequence',
|
|
85
|
+
// DDR-150 dogfood — array-src replace is a source-write, MAIN-ORIGIN ONLY.
|
|
86
|
+
'/_api/edit-array-src',
|
|
87
|
+
// DDR-150 dogfood — clip hide/show is a source-write, MAIN-ORIGIN ONLY.
|
|
88
|
+
'/_api/toggle-hide',
|
|
70
89
|
// Phase 12.1 (DDR-138) — node-move reorder is a source-write, MAIN-ORIGIN
|
|
71
90
|
// ONLY. The canvas iframe requests a reorder over the dgn:* bus; the shell
|
|
72
91
|
// performs the write. A GET here 403s at the gate (route unreachable on
|
|
@@ -74,6 +93,24 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
74
93
|
// revert route (whole-file swap — strictly more powerful than a move).
|
|
75
94
|
'/_api/reorder',
|
|
76
95
|
'/_api/reorder-revert',
|
|
96
|
+
// Stage I (feature-element-editing-robustness) — general element
|
|
97
|
+
// structural edits (delete / insert / new-artboard) + free-hand artboard
|
|
98
|
+
// resize (D4) are ALL source-writes, MAIN-ORIGIN ONLY. The untrusted
|
|
99
|
+
// canvas iframe requests them over the dgn:* bus; the shell performs the
|
|
100
|
+
// write. A GET from the canvas origin must 403 at the gate (route
|
|
101
|
+
// unreachable on this origin), never 405 from a reached handler.
|
|
102
|
+
'/_api/delete-element',
|
|
103
|
+
'/_api/insert-element',
|
|
104
|
+
'/_api/duplicate-element',
|
|
105
|
+
'/_api/insert-artboard',
|
|
106
|
+
'/_api/resize-artboard',
|
|
107
|
+
'/_api/delete-artboard',
|
|
108
|
+
// Stage F1 — the AssetPicker's asset-listing GET is a shell (main-origin)
|
|
109
|
+
// concern; the untrusted canvas iframe must not enumerate project media.
|
|
110
|
+
'/_api/assets',
|
|
111
|
+
// Stage H — the edit-scope (local-vs-shared) verdict is a shell badge
|
|
112
|
+
// concern; the untrusted canvas origin must not reach it (dual-allowlist).
|
|
113
|
+
'/_api/edit-scope',
|
|
77
114
|
// Phase 27 (E2) — every /_api/git/* route is MAIN-ORIGIN ONLY: absent from
|
|
78
115
|
// CANVAS_SAFE_API + startCanvasServer's `routes` map. A GET from the
|
|
79
116
|
// canvas origin must 403 at the gate (not 405 from a reached handler),
|
|
@@ -141,9 +178,26 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
141
178
|
const shell = await fetch(`${canvas}/_canvas-shell.html`);
|
|
142
179
|
const csp = shell.headers.get('content-security-policy') ?? '';
|
|
143
180
|
expect(csp).toContain("connect-src 'self'");
|
|
181
|
+
// DDR-148 — media-src must be present so a video-comp's <Video>/<Audio>
|
|
182
|
+
// isn't blocked by default-src 'none' (black video / silent audio).
|
|
183
|
+
expect(csp).toContain("media-src 'self'");
|
|
144
184
|
expect(csp).toContain("webrtc 'block'");
|
|
145
185
|
expect(csp).toContain('frame-ancestors');
|
|
146
186
|
expect(csp).toContain(`http://localhost:${port}`);
|
|
187
|
+
|
|
188
|
+
// (5) DDR-148 attacker F1 — the EXPORT/CAPTURE render (?hide-chrome=1) on
|
|
189
|
+
// the MAIN origin (where the normal shell CSP is off) MUST carry the
|
|
190
|
+
// network-locked capture CSP, else a canvas <Video src="http://internal">
|
|
191
|
+
// or comp fetch() turns ⌘E into read-SSRF + exfil-via-artifact.
|
|
192
|
+
const capMain = await fetch(`http://localhost:${port}/_canvas-shell.html?hide-chrome=1`);
|
|
193
|
+
const capCsp = capMain.headers.get('content-security-policy') ?? '';
|
|
194
|
+
expect(capCsp).toContain("media-src 'self'"); // <Video src=external> blocked
|
|
195
|
+
expect(capCsp).toContain("connect-src 'self'"); // comp fetch(external) blocked
|
|
196
|
+
expect(capCsp).toContain("img-src 'self'");
|
|
197
|
+
expect(capCsp).toContain("object-src 'none'");
|
|
198
|
+
// A normal main-origin shell (no capture flag) stays CSP-off (back-compat).
|
|
199
|
+
const plainMain = await fetch(`http://localhost:${port}/_canvas-shell.html`);
|
|
200
|
+
expect(plainMain.headers.get('content-security-policy')).toBeNull();
|
|
147
201
|
} finally {
|
|
148
202
|
await killProc(proc);
|
|
149
203
|
}
|