@1agh/maude 0.22.2 → 0.24.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 +34 -1
- package/cli/bin/maude.mjs +3 -0
- package/cli/commands/cache.mjs +181 -0
- package/cli/commands/cache.test.mjs +166 -0
- package/cli/commands/design-link.test.mjs +84 -2
- package/cli/commands/design.mjs +95 -4
- package/cli/commands/design.test.mjs +56 -0
- package/cli/commands/help.mjs +34 -0
- package/cli/commands/hub.mjs +255 -30
- package/cli/commands/hub.test.mjs +135 -10
- package/cli/commands/init.mjs +3 -0
- package/cli/commands/preflight.mjs +11 -0
- package/cli/commands/preflight.test.mjs +46 -0
- package/cli/commands/scenario-report.mjs +45 -0
- package/cli/lib/cache.mjs +407 -0
- package/cli/lib/cache.test.mjs +303 -0
- package/cli/lib/design-link.mjs +222 -11
- package/cli/lib/flow-design-integration.test.mjs +165 -0
- package/cli/lib/gitignore-block.mjs +90 -0
- package/cli/lib/gitignore-block.test.mjs +123 -0
- package/cli/lib/hubs-config.mjs +42 -4
- package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
- package/plugins/design/dev-server/annotations-layer.tsx +906 -137
- package/plugins/design/dev-server/api.ts +109 -4
- package/plugins/design/dev-server/bin/preflight.sh +21 -10
- package/plugins/design/dev-server/bin/prep.sh +211 -0
- package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
- package/plugins/design/dev-server/bin/screenshot.sh +18 -1
- package/plugins/design/dev-server/bin/smoke.sh +94 -11
- package/plugins/design/dev-server/build.ts +69 -3
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-cursors.ts +125 -0
- package/plugins/design/dev-server/canvas-icons.tsx +130 -0
- package/plugins/design/dev-server/canvas-lib.tsx +47 -27
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +358 -245
- package/plugins/design/dev-server/client/app.jsx +191 -21
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/index.ts +87 -9
- package/plugins/design/dev-server/collab/persistence.ts +34 -3
- package/plugins/design/dev-server/collab/registry.ts +131 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +31 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +144 -22
- package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +19 -17
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/http.ts +260 -20
- package/plugins/design/dev-server/input-router.tsx +125 -64
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +141 -1
- package/plugins/design/dev-server/sync/agent.ts +418 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +324 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +918 -0
- package/plugins/design/dev-server/sync/materialize.ts +62 -0
- package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
- package/plugins/design/dev-server/sync/origins.ts +57 -0
- package/plugins/design/dev-server/sync/projection.ts +368 -0
- package/plugins/design/dev-server/sync/status.ts +115 -0
- package/plugins/design/dev-server/sync/untrusted.ts +153 -0
- package/plugins/design/dev-server/test/_helpers.ts +6 -2
- package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/input-router.test.ts +21 -0
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
- package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
- package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
- package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
- package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
- package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
- package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
- package/plugins/design/dev-server/tool-palette.tsx +18 -16
- package/plugins/design/dev-server/undo-hud.tsx +4 -4
- package/plugins/design/dev-server/undo-stack.ts +20 -4
- package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +63 -5
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
- package/plugins/flow/.claude-plugin/config.schema.json +5 -0
- package/plugins/flow/templates/ai-skeleton/README.md +1 -1
- package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Gap 1 (file-sync clobber fix) — the syncRoomFrom* no-op guards.
|
|
2
|
+
//
|
|
3
|
+
// createCollab now re-seeds a live room from disk whenever a synced file
|
|
4
|
+
// changes externally (sync-agent hub-push or `design:edit` write), so the
|
|
5
|
+
// room stops clobbering the external change back. That re-seed reuses
|
|
6
|
+
// syncRoomFromComments/syncRoomFromAnnotations, which ALSO run on the room's
|
|
7
|
+
// own persist → file → fs-event path. Without an equality short-circuit that
|
|
8
|
+
// would spin an 800ms persist storm. These tests pin the guard: an identical
|
|
9
|
+
// re-seed emits NO doc update (→ no persist → no loop); a real change emits one.
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
|
|
13
|
+
import { Y_TYPES } from '../collab/persistence.ts';
|
|
14
|
+
import { createRegistry } from '../collab/registry.ts';
|
|
15
|
+
import type { RoomCallbacks } from '../collab/room.ts';
|
|
16
|
+
|
|
17
|
+
function noopCallbacks(): RoomCallbacks {
|
|
18
|
+
return {
|
|
19
|
+
async seed() {},
|
|
20
|
+
async persistJson() {},
|
|
21
|
+
async persistBinary() {},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('Gap 1 — syncRoomFromComments no-op guard', () => {
|
|
26
|
+
test('re-seeding identical comments emits NO doc update (loop-safe)', () => {
|
|
27
|
+
const r = createRegistry(noopCallbacks());
|
|
28
|
+
const room = r.get('s');
|
|
29
|
+
const list = [{ id: 'c1', text: 'hi' }];
|
|
30
|
+
r.syncRoomFromComments('s', list);
|
|
31
|
+
|
|
32
|
+
let updates = 0;
|
|
33
|
+
room.doc.on('update', () => {
|
|
34
|
+
updates++;
|
|
35
|
+
});
|
|
36
|
+
// Identical content (the room's-own-persist echo, or a redundant hub push)
|
|
37
|
+
// → must not emit, or the reseed↔persist loop never settles.
|
|
38
|
+
r.syncRoomFromComments('s', [{ id: 'c1', text: 'hi' }]);
|
|
39
|
+
expect(updates).toBe(0);
|
|
40
|
+
|
|
41
|
+
// A genuine change still propagates — exactly one update.
|
|
42
|
+
r.syncRoomFromComments('s', [
|
|
43
|
+
{ id: 'c1', text: 'hi' },
|
|
44
|
+
{ id: 'c2', text: 'yo' },
|
|
45
|
+
]);
|
|
46
|
+
expect(updates).toBe(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('re-seed brings a STALE room up to the on-disk list (the clobber fix)', () => {
|
|
50
|
+
const r = createRegistry(noopCallbacks());
|
|
51
|
+
const room = r.get('s');
|
|
52
|
+
const arr = room.doc.getArray(Y_TYPES.comments);
|
|
53
|
+
expect(arr.length).toBe(0); // room opened empty (the "B shows []" state)
|
|
54
|
+
|
|
55
|
+
// Sync agent wrote a hub-pushed comment to disk → createCollab re-seeds:
|
|
56
|
+
r.syncRoomFromComments('s', [{ id: 'c1', text: 'from peer A' }]);
|
|
57
|
+
expect(arr.toArray()).toEqual([{ id: 'c1', text: 'from peer A' }]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('Gap 1 — syncRoomFromAnnotations no-op guard', () => {
|
|
62
|
+
test('re-seeding the identical svg emits NO doc update', () => {
|
|
63
|
+
const r = createRegistry(noopCallbacks());
|
|
64
|
+
const room = r.get('s');
|
|
65
|
+
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><g/></svg>';
|
|
66
|
+
r.syncRoomFromAnnotations('s', svg);
|
|
67
|
+
|
|
68
|
+
let updates = 0;
|
|
69
|
+
room.doc.on('update', () => {
|
|
70
|
+
updates++;
|
|
71
|
+
});
|
|
72
|
+
r.syncRoomFromAnnotations('s', svg); // identical → no-op
|
|
73
|
+
expect(updates).toBe(0);
|
|
74
|
+
|
|
75
|
+
r.syncRoomFromAnnotations('s', '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>');
|
|
76
|
+
expect(updates).toBe(1);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -3,9 +3,15 @@
|
|
|
3
3
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
5
5
|
|
|
6
|
+
import { Awareness } from 'y-protocols/awareness';
|
|
6
7
|
import * as Y from 'yjs';
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
encodeAwarenessFrame,
|
|
11
|
+
encodeHandshake,
|
|
12
|
+
encodeSyncUpdate,
|
|
13
|
+
handleMessage,
|
|
14
|
+
} from '../collab/protocol.ts';
|
|
9
15
|
import { type RoomCallbacks, type RoomConn, createRoom } from '../collab/room.ts';
|
|
10
16
|
|
|
11
17
|
function makeConn(id: string): RoomConn & { recv: Uint8Array[] } {
|
|
@@ -140,24 +146,29 @@ describe('Room', () => {
|
|
|
140
146
|
await room.destroy();
|
|
141
147
|
});
|
|
142
148
|
|
|
143
|
-
test('disconnect
|
|
149
|
+
test('disconnect removes the awareness states a conn published', async () => {
|
|
144
150
|
const cb = makeCallbacks();
|
|
145
151
|
const room = createRoom('aw-cleanup-slug', cb);
|
|
146
152
|
const A = makeConn('a');
|
|
147
153
|
await room.connect(A);
|
|
148
154
|
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
155
|
+
// Alice (conn A) publishes her awareness over the wire — the server learns
|
|
156
|
+
// her clientID belongs to conn 'a' from the update origin (server-authoritative;
|
|
157
|
+
// it does NOT trust a client-supplied __connId). Mirrors the real receive path.
|
|
158
|
+
const clientDoc = new Y.Doc();
|
|
159
|
+
const clientAw = new Awareness(clientDoc);
|
|
160
|
+
clientAw.setLocalState({ name: 'Alice' });
|
|
161
|
+
room.receive(A, encodeAwarenessFrame(clientAw, [clientAw.clientID]));
|
|
162
|
+
expect(room.awareness.getStates().has(clientAw.clientID)).toBe(true);
|
|
152
163
|
|
|
153
164
|
room.disconnect(A);
|
|
154
165
|
expect(room.size()).toBe(0);
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
expect((s as { __connId?: string }).__connId).not.toBe('a');
|
|
159
|
-
}
|
|
166
|
+
// The disconnected conn's awareness state is dropped immediately — no waiting
|
|
167
|
+
// out the ~30s awareness timeout (the phantom-accumulation bug).
|
|
168
|
+
expect(room.awareness.getStates().has(clientAw.clientID)).toBe(false);
|
|
160
169
|
|
|
170
|
+
clientAw.destroy();
|
|
171
|
+
clientDoc.destroy();
|
|
161
172
|
await room.destroy();
|
|
162
173
|
});
|
|
163
174
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// comment-mount — shell-owned comment layer. Covers provider dedup (the
|
|
2
|
+
// Maybe* wrappers consume an outer instance instead of double-mounting) and
|
|
3
|
+
// the bundle scope-guard (react/react-dom externalized to the importmap so the
|
|
4
|
+
// comment layer shares the canvas's single React singleton).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { createElement } from 'react';
|
|
10
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
MaybeSelectionSetProvider,
|
|
14
|
+
SelectionSetProvider,
|
|
15
|
+
useSelectionSetOptional,
|
|
16
|
+
} from '../use-selection-set.tsx';
|
|
17
|
+
import { MaybeToolProvider, ToolProvider, useToolMode } from '../use-tool-mode.tsx';
|
|
18
|
+
|
|
19
|
+
describe('comment-mount / MaybeToolProvider dedup', () => {
|
|
20
|
+
function ToolReader() {
|
|
21
|
+
const { tool } = useToolMode();
|
|
22
|
+
return createElement('span', { 'data-tool': tool });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test('consumes an OUTER ToolProvider instead of double-mounting', () => {
|
|
26
|
+
// Outer provider seeds tool='comment'. If MaybeToolProvider mounted a
|
|
27
|
+
// fresh provider, the reader would see the default 'move' instead.
|
|
28
|
+
const html = renderToStaticMarkup(
|
|
29
|
+
createElement(
|
|
30
|
+
ToolProvider,
|
|
31
|
+
{ initial: 'comment' },
|
|
32
|
+
createElement(MaybeToolProvider, null, createElement(ToolReader))
|
|
33
|
+
)
|
|
34
|
+
);
|
|
35
|
+
expect(html).toContain('data-tool="comment"');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('mounts its OWN ToolProvider when none exists above', () => {
|
|
39
|
+
const html = renderToStaticMarkup(
|
|
40
|
+
createElement(MaybeToolProvider, null, createElement(ToolReader))
|
|
41
|
+
);
|
|
42
|
+
expect(html).toContain('data-tool="move"');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('comment-mount / MaybeSelectionSetProvider dedup', () => {
|
|
47
|
+
function SelReader() {
|
|
48
|
+
const ctx = useSelectionSetOptional();
|
|
49
|
+
// Tag identity so we can assert a single context instance is shared.
|
|
50
|
+
return createElement('span', { 'data-has-ctx': ctx ? 'yes' : 'no' });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
test('provides a SelectionSet when none exists above', () => {
|
|
54
|
+
const html = renderToStaticMarkup(
|
|
55
|
+
createElement(MaybeSelectionSetProvider, null, createElement(SelReader))
|
|
56
|
+
);
|
|
57
|
+
expect(html).toContain('data-has-ctx="yes"');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('does not throw when nested inside an existing SelectionSetProvider', () => {
|
|
61
|
+
expect(() =>
|
|
62
|
+
renderToStaticMarkup(
|
|
63
|
+
createElement(
|
|
64
|
+
SelectionSetProvider,
|
|
65
|
+
{ postTarget: null },
|
|
66
|
+
createElement(MaybeSelectionSetProvider, null, createElement(SelReader))
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
).not.toThrow();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('comment-mount / bundle scope-guard', () => {
|
|
74
|
+
test('dist/comment-mount.js externalizes react + exports mountCanvas', async () => {
|
|
75
|
+
const here = join(import.meta.dir, '..');
|
|
76
|
+
const bundle = Bun.file(join(here, 'dist', 'comment-mount.js'));
|
|
77
|
+
expect(await bundle.exists()).toBe(true);
|
|
78
|
+
const src = await bundle.text();
|
|
79
|
+
// mountCanvas is the public entry the shell calls.
|
|
80
|
+
expect(src.includes('mountCanvas')).toBe(true);
|
|
81
|
+
// React must be a BARE import (resolved by _shell.html's importmap), not
|
|
82
|
+
// inlined — a second React instance would break hooks ("invalid hook
|
|
83
|
+
// call") across the canvas module and the comment layer.
|
|
84
|
+
expect(/import[^;]*["']react-dom\/client["']/.test(src)).toBe(true);
|
|
85
|
+
expect(/import[^;]*["']react["']/.test(src)).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// cspForCanvasShell — A6 (DDR-060 F1 re-audit) CSP hardening for the segregated
|
|
2
|
+
// canvas origin. Asserts the exfil-relevant directives are present and that
|
|
3
|
+
// `frame-ancestors` is only emitted when the main origin is known (so the legit
|
|
4
|
+
// cross-origin embed is never accidentally blocked).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import { cspForCanvasShell } from '../http.ts';
|
|
9
|
+
|
|
10
|
+
const SHELL = '<!doctype html><html><head><script>const x=1;</script></head><body></body></html>';
|
|
11
|
+
|
|
12
|
+
describe('cspForCanvasShell — A6 hardening', () => {
|
|
13
|
+
test('locks down the dangerous lanes', () => {
|
|
14
|
+
const csp = cspForCanvasShell(SHELL);
|
|
15
|
+
expect(csp).toContain("default-src 'none'");
|
|
16
|
+
expect(csp).toContain("connect-src 'self'");
|
|
17
|
+
expect(csp).toContain("object-src 'none'");
|
|
18
|
+
expect(csp).toContain("base-uri 'none'");
|
|
19
|
+
// No eval / no inline-script escape hatch.
|
|
20
|
+
expect(csp).not.toContain('unsafe-eval');
|
|
21
|
+
expect(csp).not.toMatch(/script-src[^;]*unsafe-inline/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("adds webrtc 'block' (connect-src does not cover WebRTC exfil)", () => {
|
|
25
|
+
expect(cspForCanvasShell(SHELL)).toContain("webrtc 'block'");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("adds form-action 'none'", () => {
|
|
29
|
+
expect(cspForCanvasShell(SHELL)).toContain("form-action 'none'");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('hashes inline scripts (no unsafe-inline for script-src)', () => {
|
|
33
|
+
const csp = cspForCanvasShell(SHELL);
|
|
34
|
+
expect(csp).toMatch(/script-src 'self' 'sha256-[A-Za-z0-9+/=]+'/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('frame-ancestors allowlists the main origin when known', () => {
|
|
38
|
+
const csp = cspForCanvasShell(SHELL, 'http://localhost:4399');
|
|
39
|
+
expect(csp).toContain("frame-ancestors 'self' http://localhost:4399");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('frame-ancestors is OMITTED when the main origin is unknown (would block the legit embed)', () => {
|
|
43
|
+
const csp = cspForCanvasShell(SHELL);
|
|
44
|
+
expect(csp).not.toContain('frame-ancestors');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><path data-id="p1" data-tool="pen" stroke="#d63b1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" d="M0 0 L10 10 L20 5" pointer-events="stroke"/><rect data-id="r1" data-tool="rect" stroke="#222" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="#fff4d6" x="10" y="20" width="30" height="40"/><ellipse data-id="e1" data-tool="ellipse" stroke="#222" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="#e6f4ea" cx="100" cy="100" rx="40" ry="25"/><g data-id="a1" data-tool="arrow" stroke="#1a8f3e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none"><line x1="0" y1="0" x2="100" y2="0"/><polyline points="85.5844981135613,6.94213982588093 100,0 85.5844981135613,-6.94213982588093" fill="#1a8f3e"/></g><text data-id="t1" data-tool="text" data-anchor-id="r1" data-font-size="14" fill="#1a1a1a" text-anchor="middle" dominant-baseline="middle">needs work</text></svg>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Unit test for hmr-broadcast.ts classifyChange().
|
|
2
|
+
//
|
|
3
|
+
// The load-bearing case: a canvas/specimen sibling stylesheet (e.g. motion.css
|
|
4
|
+
// next to motion.tsx) is INLINED into the built module — there is no <link> for
|
|
5
|
+
// the iframe css-swap to target, so it must route to a module reload keyed on
|
|
6
|
+
// the sibling .tsx. Link-mounted CSS (tokens / _components / _layout — no
|
|
7
|
+
// sibling .tsx) keeps the fast css swap. Regression guard for the silent-drop
|
|
8
|
+
// bug (see .ai/logs/rca/hmr-inlined-css-dropped.md).
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
|
|
12
|
+
import { classifyChange } from '../hmr-broadcast.ts';
|
|
13
|
+
|
|
14
|
+
describe('classifyChange', () => {
|
|
15
|
+
const noSibling = () => false;
|
|
16
|
+
const hasSibling = () => true;
|
|
17
|
+
|
|
18
|
+
test('sibling-tsx CSS → module reload keyed on the .tsx', () => {
|
|
19
|
+
const msg = classifyChange(
|
|
20
|
+
'system/x/preview/motion.css',
|
|
21
|
+
(cssRel) => cssRel === 'system/x/preview/motion.css'
|
|
22
|
+
);
|
|
23
|
+
expect(msg?.mode).toBe('module');
|
|
24
|
+
expect(msg?.file).toBe('system/x/preview/motion.tsx');
|
|
25
|
+
expect(msg?.scope).toBe('canvas');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('link-mounted partial CSS (no sibling .tsx) → css swap', () => {
|
|
29
|
+
expect(classifyChange('system/x/preview/_components.css', noSibling)?.mode).toBe('css');
|
|
30
|
+
expect(classifyChange('system/x/preview/_layout.css', noSibling)?.mode).toBe('css');
|
|
31
|
+
expect(classifyChange('system/x/colors_and_type.css', noSibling)?.mode).toBe('css');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('css mode echoes the changed file path', () => {
|
|
35
|
+
const msg = classifyChange('system/x/colors_and_type.css', noSibling);
|
|
36
|
+
expect(msg?.mode).toBe('css');
|
|
37
|
+
expect(msg?.file).toBe('system/x/colors_and_type.css');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('_lib/** → hard reload (no file)', () => {
|
|
41
|
+
const msg = classifyChange('_lib/canvas-lib.tsx', hasSibling);
|
|
42
|
+
expect(msg?.mode).toBe('hard');
|
|
43
|
+
expect(msg?.scope).toBe('lib');
|
|
44
|
+
expect(msg?.file).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('.tsx / .jsx / .ts / .js → module reload', () => {
|
|
48
|
+
for (const f of ['ui/Foo.tsx', 'ui/Foo.jsx', 'ui/Foo.ts', 'ui/Foo.js']) {
|
|
49
|
+
expect(classifyChange(f, noSibling)?.mode).toBe('module');
|
|
50
|
+
expect(classifyChange(f, noSibling)?.file).toBe(f);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('.meta.json → meta (not treated as a .json no-op)', () => {
|
|
55
|
+
const msg = classifyChange('ui/Foo.meta.json', noSibling);
|
|
56
|
+
expect(msg?.mode).toBe('meta');
|
|
57
|
+
expect(msg?.file).toBe('ui/Foo.meta.json');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('unrelated extensions → null', () => {
|
|
61
|
+
expect(classifyChange('ui/notes.md', noSibling)).toBeNull();
|
|
62
|
+
expect(classifyChange('assets/logo.svg', noSibling)).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('backslash paths are normalised before classification', () => {
|
|
66
|
+
const msg = classifyChange('system\\x\\preview\\motion.css', () => true);
|
|
67
|
+
expect(msg?.mode).toBe('module');
|
|
68
|
+
expect(msg?.file).toBe('system/x/preview/motion.tsx');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -229,6 +229,20 @@ describe('input-router / keydown — Phase 5 draw tools', () => {
|
|
|
229
229
|
});
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
test('N → tool sticky (Phase 21)', () => {
|
|
233
|
+
expect(classify(base({ type: 'keydown', key: 'n' }))).toEqual({
|
|
234
|
+
kind: 'tool',
|
|
235
|
+
tool: 'sticky',
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('T → tool text (Phase 21)', () => {
|
|
240
|
+
expect(classify(base({ type: 'keydown', key: 't' }))).toEqual({
|
|
241
|
+
kind: 'tool',
|
|
242
|
+
tool: 'text',
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
232
246
|
test('uppercase B (shift held) — still maps to pen (lowercased)', () => {
|
|
233
247
|
expect(classify(base({ type: 'keydown', key: 'B', shiftKey: true }))).toEqual({
|
|
234
248
|
kind: 'tool',
|
|
@@ -268,6 +282,13 @@ describe('input-router / pointer events — Phase 5 annotation tools', () => {
|
|
|
268
282
|
expect(classify(base({ type: 'pointermove', activeTool: 'ellipse' })).kind).toBe('no-op');
|
|
269
283
|
});
|
|
270
284
|
|
|
285
|
+
test('sticky + text tools own bare pointer events (Phase 21 — SVG overlay claims)', () => {
|
|
286
|
+
for (const activeTool of ['sticky', 'text'] as const) {
|
|
287
|
+
expect(classify(base({ type: 'pointerdown', activeTool, button: 0 })).kind).toBe('no-op');
|
|
288
|
+
expect(classify(base({ type: 'pointermove', activeTool })).kind).toBe('no-op');
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
271
292
|
test('cmd+left-click in arrow tool → select replace (escape hatch to move)', () => {
|
|
272
293
|
expect(
|
|
273
294
|
classify(
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// sanitizeAnnotationSvg — A3 (DDR-060 F1 re-audit). Strips executable constructs
|
|
2
|
+
// from a persisted annotation SVG while leaving the legit presentational
|
|
3
|
+
// vocabulary (strokesToSvg: path/rect/ellipse/g/line/polyline/text) byte-intact.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
|
|
7
|
+
import { sanitizeAnnotationSvg } from '../api.ts';
|
|
8
|
+
|
|
9
|
+
// The exact shape strokesToSvg() emits — must survive sanitization unchanged.
|
|
10
|
+
const LEGIT =
|
|
11
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
12
|
+
'<path data-id="p1" data-tool="pen" stroke="#d63b1f" stroke-width="2" fill="none" d="M0 0 L10 10" pointer-events="stroke"/>' +
|
|
13
|
+
'<rect data-id="r1" data-tool="rect" stroke="#222" stroke-width="2" fill="none" x="5" y="5" width="20" height="20"/>' +
|
|
14
|
+
'<ellipse data-id="e1" data-tool="ellipse" stroke="#222" stroke-width="2" fill="none" cx="10" cy="10" rx="4" ry="4"/>' +
|
|
15
|
+
'<g data-id="a1" data-tool="arrow" stroke="#222" stroke-width="2" fill="none"><line x1="0" y1="0" x2="9" y2="9"/><polyline points="3,3 9,9 5,7" fill="#222"/></g>' +
|
|
16
|
+
'<text data-id="t1" data-tool="text" data-anchor-id="x" data-font-size="14" fill="#222" text-anchor="middle" dominant-baseline="middle">label</text>' +
|
|
17
|
+
'</svg>';
|
|
18
|
+
|
|
19
|
+
describe('sanitizeAnnotationSvg — A3', () => {
|
|
20
|
+
test('leaves the legit annotation vocabulary byte-identical (zero regression)', () => {
|
|
21
|
+
expect(sanitizeAnnotationSvg(LEGIT)).toBe(LEGIT);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('strips <script> blocks', () => {
|
|
25
|
+
const dirty = LEGIT.replace('</svg>', '<script>fetch("/_api/export")</script></svg>');
|
|
26
|
+
const out = sanitizeAnnotationSvg(dirty);
|
|
27
|
+
expect(out).not.toContain('<script');
|
|
28
|
+
expect(out).not.toContain('fetch("/_api/export")');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('strips <foreignObject> (the SVG-XSS smuggle)', () => {
|
|
32
|
+
const dirty = LEGIT.replace(
|
|
33
|
+
'</svg>',
|
|
34
|
+
'<foreignObject><div onload="x"></div></foreignObject></svg>'
|
|
35
|
+
);
|
|
36
|
+
const out = sanitizeAnnotationSvg(dirty);
|
|
37
|
+
expect(out.toLowerCase()).not.toContain('foreignobject');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('strips inline event-handler attributes (onload/onclick/…)', () => {
|
|
41
|
+
const dirty =
|
|
42
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
43
|
+
'<rect x="0" y="0" width="9" height="9" onload="alert(1)" onclick=\'evil()\'/>' +
|
|
44
|
+
'</svg>';
|
|
45
|
+
const out = sanitizeAnnotationSvg(dirty);
|
|
46
|
+
expect(out).not.toMatch(/\son\w+\s*=/i);
|
|
47
|
+
expect(out).toContain('<rect');
|
|
48
|
+
expect(out).toContain('width="9"');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('strips <image>/<use> and javascript: URLs', () => {
|
|
52
|
+
const dirty =
|
|
53
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
54
|
+
'<image href="javascript:alert(1)"/><use href="#x"/>' +
|
|
55
|
+
'</svg>';
|
|
56
|
+
const out = sanitizeAnnotationSvg(dirty);
|
|
57
|
+
expect(out.toLowerCase()).not.toContain('<image');
|
|
58
|
+
expect(out.toLowerCase()).not.toContain('<use');
|
|
59
|
+
expect(out.toLowerCase()).not.toContain('javascript:');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Bypasses the F1 confirming pass found against the prior denylist — the
|
|
63
|
+
// allowlist must close all of them.
|
|
64
|
+
test('drops namespaced <svg:script> (denylist missed this)', () => {
|
|
65
|
+
const out = sanitizeAnnotationSvg(
|
|
66
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><svg:script>alert(1)</svg:script></svg>'
|
|
67
|
+
);
|
|
68
|
+
expect(out.toLowerCase()).not.toContain('<svg:script');
|
|
69
|
+
expect(out.toLowerCase()).not.toContain('script>');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('drops <style> (CSS @import / url(javascript:) exfil lane)', () => {
|
|
73
|
+
const out = sanitizeAnnotationSvg(
|
|
74
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><style>@import url("//evil")</style></svg>'
|
|
75
|
+
);
|
|
76
|
+
expect(out.toLowerCase()).not.toContain('<style');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('strips style= (CSS url(javascript:)) and entity-encoded xlink:href on survivors', () => {
|
|
80
|
+
const out = sanitizeAnnotationSvg(
|
|
81
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
82
|
+
'<rect x="0" y="0" width="9" height="9" style="fill:url(javascript:alert(1))"/>' +
|
|
83
|
+
'<text xlink:href="javascript:alert(1)">hi</text></svg>'
|
|
84
|
+
);
|
|
85
|
+
expect(out).not.toMatch(/\sstyle\s*=/i);
|
|
86
|
+
expect(out).not.toMatch(/href\s*=/i);
|
|
87
|
+
// The legit elements + safe attrs survive.
|
|
88
|
+
expect(out).toContain('<rect');
|
|
89
|
+
expect(out).toContain('width="9"');
|
|
90
|
+
expect(out).toContain('<text');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('drops <set>/<animate> animation elements (attribute-name vector)', () => {
|
|
94
|
+
const out = sanitizeAnnotationSvg(
|
|
95
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><rect><set attributeName="onload" to="alert(1)"/></rect></svg>'
|
|
96
|
+
);
|
|
97
|
+
expect(out.toLowerCase()).not.toContain('<set');
|
|
98
|
+
expect(out.toLowerCase()).not.toContain('<animate');
|
|
99
|
+
});
|
|
100
|
+
});
|