@1agh/maude 0.23.0 → 0.25.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 +32 -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 +126 -2
- 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 +74 -10
- 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/plugin-cli-reachability.test.mjs +56 -0
- package/cli/lib/preflight.mjs +41 -10
- package/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/ai-banner.tsx +2 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +763 -123
- package/plugins/design/dev-server/annotations-layer.tsx +1624 -208
- package/plugins/design/dev-server/api.ts +123 -4
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
- package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- 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 +208 -23
- package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
- package/plugins/design/dev-server/canvas-cursors.ts +173 -0
- package/plugins/design/dev-server/canvas-icons.tsx +299 -0
- package/plugins/design/dev-server/canvas-lib.tsx +25 -21
- package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
- package/plugins/design/dev-server/canvas-shell.tsx +404 -66
- package/plugins/design/dev-server/client/app.jsx +1231 -416
- package/plugins/design/dev-server/client/styles/3-shell.css +10 -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 +80 -2
- package/plugins/design/dev-server/collab/room.ts +21 -8
- package/plugins/design/dev-server/context-menu.tsx +167 -23
- package/plugins/design/dev-server/context.ts +24 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
- package/plugins/design/dev-server/dist/client.bundle.js +364 -29
- package/plugins/design/dev-server/dist/comment-mount.js +78 -33
- package/plugins/design/dev-server/dist/styles.css +16 -0
- package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
- package/plugins/design/dev-server/export-dialog.tsx +208 -18
- package/plugins/design/dev-server/exporters/html.ts +4 -1
- package/plugins/design/dev-server/exporters/index.ts +4 -1
- package/plugins/design/dev-server/exporters/pdf.ts +7 -1
- package/plugins/design/dev-server/exporters/png.ts +21 -2
- package/plugins/design/dev-server/exporters/pptx.ts +330 -201
- package/plugins/design/dev-server/exporters/scope.ts +107 -11
- package/plugins/design/dev-server/exporters/svg.ts +4 -1
- package/plugins/design/dev-server/fs-watch.ts +1 -0
- package/plugins/design/dev-server/http.ts +299 -28
- package/plugins/design/dev-server/input-router.tsx +35 -5
- package/plugins/design/dev-server/participants-chrome.tsx +10 -10
- package/plugins/design/dev-server/server.ts +123 -1
- package/plugins/design/dev-server/sync/agent.ts +95 -0
- package/plugins/design/dev-server/sync/codec.ts +155 -0
- package/plugins/design/dev-server/sync/connection-state.ts +203 -0
- package/plugins/design/dev-server/sync/index.ts +479 -35
- 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-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +311 -0
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +519 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +162 -0
- package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- 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/csp-canvas-shell.test.ts +46 -0
- package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
- package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +32 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +132 -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-connection-state.test.ts +146 -0
- package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
- 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-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +39 -13
- package/plugins/design/dev-server/tool-palette.tsx +155 -24
- 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 +219 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +68 -19
- package/plugins/design/dev-server/ws.ts +40 -1
- package/plugins/design/templates/_shell.html +85 -10
- package/plugins/design/templates/canvas.tsx.template +13 -0
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -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,76 @@
|
|
|
1
|
+
// Canvas-origin route gate — A1/A2 (DDR-060 F1 re-audit). Boots a real server
|
|
2
|
+
// with MAUDE_CANVAS_ORIGIN_SPLIT=1 and probes the segregated canvas origin to
|
|
3
|
+
// prove: (1) the %2f-encoded path-traversal that previously leaked repo files
|
|
4
|
+
// outside designRoot is now 403'd; (2) privileged routes stay 403; (3) legit
|
|
5
|
+
// designRoot assets + the shell still serve; (4) the shell carries the hardened
|
|
6
|
+
// CSP. See .ai/logs/security-reviews/phase-9.1-t2-f1-cross-origin-reaudit.md.
|
|
7
|
+
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
|
|
13
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
14
|
+
|
|
15
|
+
async function readCanvasOrigin(designRoot: string): Promise<string> {
|
|
16
|
+
// server.ts writes _server.json under the designRoot once both listeners bind.
|
|
17
|
+
for (let i = 0; i < 40; i++) {
|
|
18
|
+
try {
|
|
19
|
+
const info = JSON.parse(readFileSync(join(designRoot, '_server.json'), 'utf8'));
|
|
20
|
+
if (info.canvasOrigin) return info.canvasOrigin as string;
|
|
21
|
+
} catch {
|
|
22
|
+
/* not written yet */
|
|
23
|
+
}
|
|
24
|
+
await Bun.sleep(50);
|
|
25
|
+
}
|
|
26
|
+
throw new Error('canvasOrigin never appeared in _server.json');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('canvas-origin gate — A1/A2 traversal + privilege containment', () => {
|
|
30
|
+
test('%2f traversal out of designRoot is 403; privileged 403; legit 200; CSP hardened', async () => {
|
|
31
|
+
const { root, designRoot } = makeSandbox();
|
|
32
|
+
// A real asset OUTSIDE .design/ but INSIDE repoRoot — the traversal target.
|
|
33
|
+
writeFileSync(join(root, 'secret-outside.css'), '.leak{content:"SECRET"}');
|
|
34
|
+
// A legit canvas asset under designRoot (the allowed case).
|
|
35
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
36
|
+
writeFileSync(
|
|
37
|
+
join(designRoot, 'ui', 'Gate.tsx'),
|
|
38
|
+
'export default function G(){return <main/>}\n'
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const port = nextPort();
|
|
42
|
+
const proc = await bootServer(root, port, { MAUDE_CANVAS_ORIGIN_SPLIT: '1' });
|
|
43
|
+
try {
|
|
44
|
+
const canvas = await readCanvasOrigin(designRoot);
|
|
45
|
+
const code = async (p: string) =>
|
|
46
|
+
(await fetch(canvas + p, { signal: AbortSignal.timeout(2000) })).status;
|
|
47
|
+
|
|
48
|
+
// (1) The confirmed bypass — must now be 403, not 200.
|
|
49
|
+
expect(await code('/.design/..%2fsecret-outside.css')).toBe(403);
|
|
50
|
+
expect(await code('/.design/ui/..%2f..%2fsecret-outside.css')).toBe(403);
|
|
51
|
+
// Double-encoded + mixed-case variants stay closed.
|
|
52
|
+
expect(await code('/.design/%2e%2e%2fsecret-outside.css')).toBe(403);
|
|
53
|
+
|
|
54
|
+
// (2) Privileged / out-of-scope routes stay 403 on the canvas origin.
|
|
55
|
+
for (const p of ['/_config', '/_sync-status', '/_api/export', '/package.json']) {
|
|
56
|
+
expect(await code(p)).toBe(403);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// (3) Legit surfaces still serve.
|
|
60
|
+
expect(await code('/_health')).toBe(200);
|
|
61
|
+
expect(await code('/_canvas-shell.html')).toBe(200);
|
|
62
|
+
// A real .tsx canvas under designRoot transpiles + serves (200).
|
|
63
|
+
expect(await code('/.design/ui/Gate.tsx')).toBe(200);
|
|
64
|
+
|
|
65
|
+
// (4) The shell carries the hardened CSP (A6).
|
|
66
|
+
const shell = await fetch(`${canvas}/_canvas-shell.html`);
|
|
67
|
+
const csp = shell.headers.get('content-security-policy') ?? '';
|
|
68
|
+
expect(csp).toContain("connect-src 'self'");
|
|
69
|
+
expect(csp).toContain("webrtc 'block'");
|
|
70
|
+
expect(csp).toContain('frame-ancestors');
|
|
71
|
+
expect(csp).toContain(`http://localhost:${port}`);
|
|
72
|
+
} finally {
|
|
73
|
+
await killProc(proc);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -80,7 +80,10 @@ describe('TSX canvas route', () => {
|
|
|
80
80
|
expect(r.headers.get('content-type')).toMatch(/javascript/);
|
|
81
81
|
const etag = r.headers.get('etag');
|
|
82
82
|
expect(etag).toBeTruthy();
|
|
83
|
-
|
|
83
|
+
// Phase 24 (DDR-067) — the canvas etag is `<source-hash>-<bootId>-<epoch>`:
|
|
84
|
+
// the boot id + chrome epoch are folded in so a chrome edit (which doesn't
|
|
85
|
+
// change the canvas source hash) still busts the browser's cached transpile.
|
|
86
|
+
expect(etag).toMatch(/^[0-9a-f]+-[a-z0-9]+-\d+$/i);
|
|
84
87
|
const body = await r.text();
|
|
85
88
|
// Body should contain data-cd-id metadata + an export — proves the two
|
|
86
89
|
// passes ran end to end.
|
|
@@ -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,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
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { describe, expect, test } from 'bun:test';
|
|
10
10
|
|
|
11
|
-
import { run } from '../../exporters/png.ts';
|
|
11
|
+
import { clampScale, run } from '../../exporters/png.ts';
|
|
12
12
|
|
|
13
13
|
const CTX = {
|
|
14
14
|
designRoot: '/tmp/.design',
|
|
@@ -30,3 +30,26 @@ describe('png adapter — contract', () => {
|
|
|
30
30
|
);
|
|
31
31
|
});
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
describe('png clampScale — size presets (item 1)', () => {
|
|
35
|
+
test('defaults to 2× when scale is absent / invalid', () => {
|
|
36
|
+
expect(clampScale(undefined)).toBe(2);
|
|
37
|
+
expect(clampScale(null)).toBe(2);
|
|
38
|
+
expect(clampScale('nonsense')).toBe(2);
|
|
39
|
+
expect(clampScale(0)).toBe(2);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('honours the 1×/2×/3× presets (number or string)', () => {
|
|
43
|
+
expect(clampScale(1)).toBe(1);
|
|
44
|
+
expect(clampScale(2)).toBe(2);
|
|
45
|
+
expect(clampScale(3)).toBe(3);
|
|
46
|
+
expect(clampScale('1')).toBe(1);
|
|
47
|
+
expect(clampScale('3')).toBe(3);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('clamps out-of-range / fractional values to the preset set', () => {
|
|
51
|
+
expect(clampScale(4)).toBe(2); // above max → safe default
|
|
52
|
+
expect(clampScale(2.6)).toBe(3); // rounds to nearest preset
|
|
53
|
+
expect(clampScale(1.2)).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// PPTX adapter — native (svg2pptx) pipeline units (export-pipeline-fixes item 6).
|
|
2
|
+
//
|
|
3
|
+
// Covers the two pure pieces that make svg2pptx faithful + the deck merge:
|
|
4
|
+
// - preprocessSvg: lift tspan x/y onto <text> + collapse the font stack.
|
|
5
|
+
// - mergeDecks: combine N single-slide svg2pptx decks into one valid package.
|
|
6
|
+
// The svg2pptx conversion itself (Python) + live render are integration-shape.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import JSZip from 'jszip';
|
|
10
|
+
|
|
11
|
+
import { mergeDecks, preprocessSvg } from '../../exporters/pptx.ts';
|
|
12
|
+
|
|
13
|
+
describe('preprocessSvg', () => {
|
|
14
|
+
test('lifts the first tspan x/y onto a <text> that lacks them', () => {
|
|
15
|
+
const svg = `<svg><text fill="rgb(0,0,0)"><tspan x="33" y="65.5">Hi</tspan></text></svg>`;
|
|
16
|
+
const out = preprocessSvg(svg);
|
|
17
|
+
// svg2pptx reads text@x/y — they must now be present (was the 0,0 pile-up).
|
|
18
|
+
expect(out).toMatch(/<text x="33" y="65\.5"/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('collapses a CSS font-family stack to its first concrete name', () => {
|
|
22
|
+
const svg = `<text font-family=""Berkeley Mono", TX-02, "JetBrains Mono", monospace"><tspan x="1" y="2">x</tspan></text>`;
|
|
23
|
+
const out = preprocessSvg(svg);
|
|
24
|
+
// A PPTX typeface is a single font name, not a fallback list.
|
|
25
|
+
expect(out).toContain('font-family="Berkeley Mono"');
|
|
26
|
+
expect(out).not.toContain('TX-02');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('does not clobber a <text> that already has its own x/y', () => {
|
|
30
|
+
const svg = `<text x="10" y="20"><tspan x="99" y="88">x</tspan></text>`;
|
|
31
|
+
const out = preprocessSvg(svg);
|
|
32
|
+
expect(out).toContain('<text x="10" y="20"');
|
|
33
|
+
expect(out).not.toContain('<text x="99"');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships';
|
|
38
|
+
const OD_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
39
|
+
const CT_NS = 'http://schemas.openxmlformats.org/package/2006/content-types';
|
|
40
|
+
const SLIDE_CT = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml';
|
|
41
|
+
|
|
42
|
+
/** Minimal single-slide deck shaped like svg2pptx output (native, no media). */
|
|
43
|
+
async function singleSlideDeck(): Promise<Uint8Array> {
|
|
44
|
+
const zip = new JSZip();
|
|
45
|
+
zip.file(
|
|
46
|
+
'[Content_Types].xml',
|
|
47
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="${CT_NS}"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/slideMasters/slideMaster1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"/><Override PartName="/ppt/slides/slide1.xml" ContentType="${SLIDE_CT}"/></Types>`
|
|
48
|
+
);
|
|
49
|
+
zip.file(
|
|
50
|
+
'ppt/presentation.xml',
|
|
51
|
+
'<?xml version="1.0"?><p:presentation xmlns:p="x" xmlns:r="y"><p:sldMasterIdLst><p:sldMasterId id="1" r:id="rId1"/></p:sldMasterIdLst><p:sldIdLst><p:sldId id="256" r:id="rId2"/></p:sldIdLst></p:presentation>'
|
|
52
|
+
);
|
|
53
|
+
zip.file(
|
|
54
|
+
'ppt/_rels/presentation.xml.rels',
|
|
55
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OD_REL}/slideMaster" Target="slideMasters/slideMaster1.xml"/><Relationship Id="rId2" Type="${OD_REL}/slide" Target="slides/slide1.xml"/></Relationships>`
|
|
56
|
+
);
|
|
57
|
+
zip.file('ppt/slides/slide1.xml', '<?xml version="1.0"?><p:sld xmlns:p="x"/>');
|
|
58
|
+
zip.file(
|
|
59
|
+
'ppt/slides/_rels/slide1.xml.rels',
|
|
60
|
+
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="${REL_NS}"><Relationship Id="rId1" Type="${OD_REL}/slideLayout" Target="../slideLayouts/slideLayout1.xml"/></Relationships>`
|
|
61
|
+
);
|
|
62
|
+
zip.file('ppt/slideMasters/slideMaster1.xml', '<?xml version="1.0"?><p:sldMaster xmlns:p="x"/>');
|
|
63
|
+
zip.file('ppt/slideLayouts/slideLayout1.xml', '<?xml version="1.0"?><p:sldLayout xmlns:p="x"/>');
|
|
64
|
+
return zip.generateAsync({ type: 'uint8array' });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('mergeDecks — combine svg2pptx single-slide decks', () => {
|
|
68
|
+
test('single deck passes through', async () => {
|
|
69
|
+
const d = await singleSlideDeck();
|
|
70
|
+
const merged = await mergeDecks([d]);
|
|
71
|
+
expect(merged.byteLength).toBe(d.byteLength);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('three decks → one valid package, all refs + Content-Types resolve', async () => {
|
|
75
|
+
const decks = await Promise.all([singleSlideDeck(), singleSlideDeck(), singleSlideDeck()]);
|
|
76
|
+
const merged = await mergeDecks(decks);
|
|
77
|
+
const zip = await JSZip.loadAsync(merged);
|
|
78
|
+
const parts = new Set(Object.keys(zip.files).filter((n) => !n.endsWith('/')));
|
|
79
|
+
|
|
80
|
+
// 3 contiguous slides; no 4th.
|
|
81
|
+
for (const n of [1, 2, 3]) expect(parts.has(`ppt/slides/slide${n}.xml`)).toBe(true);
|
|
82
|
+
expect(parts.has('ppt/slides/slide4.xml')).toBe(false);
|
|
83
|
+
|
|
84
|
+
// presentation.xml lists 3 slides; its rels keep the slideMaster (the
|
|
85
|
+
// "unopenable" regression) + carry 3 slide rels.
|
|
86
|
+
const pres = (await zip.file('ppt/presentation.xml')?.async('string')) ?? '';
|
|
87
|
+
expect((pres.match(/<p:sldId /g) ?? []).length).toBe(3);
|
|
88
|
+
const prels = (await zip.file('ppt/_rels/presentation.xml.rels')?.async('string')) ?? '';
|
|
89
|
+
expect(prels).toContain('Target="slideMasters/slideMaster1.xml"');
|
|
90
|
+
expect((prels.match(/Type="[^"]*\/relationships\/slide"/g) ?? []).length).toBe(3);
|
|
91
|
+
|
|
92
|
+
// Every rels Target resolves.
|
|
93
|
+
for (const rf of [...parts].filter((p) => p.endsWith('.rels'))) {
|
|
94
|
+
const xml = (await zip.file(rf)?.async('string')) ?? '';
|
|
95
|
+
const baseDir = rf.replace(/_rels\/[^/]+$/, '');
|
|
96
|
+
for (const rel of xml.matchAll(/<Relationship\b[^>]*\/>/g)) {
|
|
97
|
+
if (/TargetMode="External"/.test(rel[0])) continue;
|
|
98
|
+
const tgt = /Target="([^"]+)"/.exec(rel[0])?.[1];
|
|
99
|
+
if (!tgt) continue;
|
|
100
|
+
const resolved = new URL(tgt, `file:///${baseDir}`).pathname.replace(/^\//, '');
|
|
101
|
+
expect(parts.has(resolved)).toBe(true);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Every Content-Types Override resolves; exactly 3 slide overrides.
|
|
106
|
+
const ct = (await zip.file('[Content_Types].xml')?.async('string')) ?? '';
|
|
107
|
+
for (const o of ct.matchAll(/<Override\s+PartName="([^"]+)"/g)) {
|
|
108
|
+
expect(parts.has(o[1].replace(/^\//, ''))).toBe(true);
|
|
109
|
+
}
|
|
110
|
+
expect((ct.match(/PartName="\/ppt\/slides\/slide\d+\.xml"/g) ?? []).length).toBe(3);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Unit cover for the shared Chromium launcher's error mapping. The launch path
|
|
2
|
+
// itself is integration-shape (needs a real browser); here we pin the pure
|
|
3
|
+
// classifier + the user-facing contract so a regression can't silently turn the
|
|
4
|
+
// "browser not installed" 500 back into an opaque stack trace.
|
|
5
|
+
//
|
|
6
|
+
// Regression guard for `.ai/logs/rca/issue-nefunguji-exporty.md`.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
|
|
10
|
+
import { INSTALL_HINT, NO_BROWSER_EXIT, isMissingBrowserError } from '../../bin/_pw-launch.mjs';
|
|
11
|
+
|
|
12
|
+
describe('_pw-launch — missing-browser classifier', () => {
|
|
13
|
+
test('matches the real Playwright "Executable doesn\'t exist" message', () => {
|
|
14
|
+
const real =
|
|
15
|
+
"browserType.launch: Executable doesn't exist at " +
|
|
16
|
+
'/Users/x/Library/Caches/ms-playwright/chromium_headless_shell-1223/' +
|
|
17
|
+
'chrome-headless-shell-mac-arm64/chrome-headless-shell\n' +
|
|
18
|
+
'╔════════════════════════════════════════════════════════════╗';
|
|
19
|
+
expect(isMissingBrowserError(real)).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('matches the "please run … to download" + "playwright install" variants', () => {
|
|
23
|
+
expect(isMissingBrowserError('Please run the following command to download new browsers')).toBe(
|
|
24
|
+
true
|
|
25
|
+
);
|
|
26
|
+
expect(
|
|
27
|
+
isMissingBrowserError('Looks like Playwright was just installed... npx playwright install')
|
|
28
|
+
).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('does NOT match unrelated launch failures (preserve real diagnostics)', () => {
|
|
32
|
+
expect(isMissingBrowserError('Target page, context or browser has been closed')).toBe(false);
|
|
33
|
+
expect(isMissingBrowserError('connect ECONNREFUSED 127.0.0.1:9222')).toBe(false);
|
|
34
|
+
expect(isMissingBrowserError('Timeout 30000ms exceeded')).toBe(false);
|
|
35
|
+
expect(isMissingBrowserError('')).toBe(false);
|
|
36
|
+
expect(isMissingBrowserError(null)).toBe(false);
|
|
37
|
+
expect(isMissingBrowserError(undefined)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('_pw-launch — user-facing contract', () => {
|
|
42
|
+
test('INSTALL_HINT is the copy-pasteable remediation', () => {
|
|
43
|
+
expect(INSTALL_HINT).toContain('npx playwright install chromium');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('NO_BROWSER_EXIT is a distinct non-zero code', () => {
|
|
47
|
+
expect(NO_BROWSER_EXIT).toBeGreaterThan(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
Binary file
|
|
@@ -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 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1"><g data-id="st1" data-tool="sticky" data-r="8" data-fs="14" fill="#ffe27a"><rect x="40" y="50" width="200" height="160" rx="8" ry="8"/><text data-sticky-body="1" x="52" y="62" font-size="14" fill="#1a1a1a" dominant-baseline="hanging">ship it</text></g><rect data-id="r1" data-tool="rect" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" x="10" y="20" width="120" height="80" rx="8" ry="8" data-r="8"/><g data-id="a1" data-tool="arrow" stroke="#e5484d" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke" fill="none" data-start-head="triangle" data-dash="1"><line x1="0" y1="0" x2="80" y2="40" stroke-dasharray="6 4"/><polyline points="17.99801569252915,0.2672660312767773 0,0 11.012622240538908,14.238052935257258" fill="#e5484d"/><polyline points="62.00198430747085,39.732733968723224 80,40 68.98737775946108,25.761947064742742" fill="#e5484d"/></g></svg>
|
|
@@ -201,10 +201,10 @@ describe('input-router / keydown — Phase 5 draw tools', () => {
|
|
|
201
201
|
});
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
-
test('R → tool
|
|
204
|
+
test('R → tool shape (Phase 24 — single Shape tool)', () => {
|
|
205
205
|
expect(classify(base({ type: 'keydown', key: 'r' }))).toEqual({
|
|
206
206
|
kind: 'tool',
|
|
207
|
-
tool: '
|
|
207
|
+
tool: 'shape',
|
|
208
208
|
});
|
|
209
209
|
});
|
|
210
210
|
|
|
@@ -222,10 +222,31 @@ describe('input-router / keydown — Phase 5 draw tools', () => {
|
|
|
222
222
|
});
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
-
test('O → tool
|
|
225
|
+
test('O → tool shape (Phase 24 — legacy ellipse key now arms Shape)', () => {
|
|
226
226
|
expect(classify(base({ type: 'keydown', key: 'o' }))).toEqual({
|
|
227
227
|
kind: 'tool',
|
|
228
|
-
tool: '
|
|
228
|
+
tool: 'shape',
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('shape tool owns bare pointer events (SVG overlay claims)', () => {
|
|
233
|
+
expect(classify(base({ type: 'pointerdown', activeTool: 'shape', button: 0 })).kind).toBe(
|
|
234
|
+
'no-op'
|
|
235
|
+
);
|
|
236
|
+
expect(classify(base({ type: 'pointermove', activeTool: 'shape' })).kind).toBe('no-op');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('N → tool sticky (Phase 21)', () => {
|
|
240
|
+
expect(classify(base({ type: 'keydown', key: 'n' }))).toEqual({
|
|
241
|
+
kind: 'tool',
|
|
242
|
+
tool: 'sticky',
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('T → tool text (Phase 21)', () => {
|
|
247
|
+
expect(classify(base({ type: 'keydown', key: 't' }))).toEqual({
|
|
248
|
+
kind: 'tool',
|
|
249
|
+
tool: 'text',
|
|
229
250
|
});
|
|
230
251
|
});
|
|
231
252
|
|
|
@@ -268,6 +289,13 @@ describe('input-router / pointer events — Phase 5 annotation tools', () => {
|
|
|
268
289
|
expect(classify(base({ type: 'pointermove', activeTool: 'ellipse' })).kind).toBe('no-op');
|
|
269
290
|
});
|
|
270
291
|
|
|
292
|
+
test('sticky + text tools own bare pointer events (Phase 21 — SVG overlay claims)', () => {
|
|
293
|
+
for (const activeTool of ['sticky', 'text'] as const) {
|
|
294
|
+
expect(classify(base({ type: 'pointerdown', activeTool, button: 0 })).kind).toBe('no-op');
|
|
295
|
+
expect(classify(base({ type: 'pointermove', activeTool })).kind).toBe('no-op');
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
271
299
|
test('cmd+left-click in arrow tool → select replace (escape hatch to move)', () => {
|
|
272
300
|
expect(
|
|
273
301
|
classify(
|