@1agh/maude 0.24.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/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
- package/plugins/design/dev-server/annotations-layer.tsx +817 -170
- package/plugins/design/dev-server/api.ts +15 -1
- 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/smoke.sh +114 -12
- 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 +130 -82
- package/plugins/design/dev-server/canvas-icons.tsx +169 -0
- package/plugins/design/dev-server/canvas-shell.tsx +113 -89
- package/plugins/design/dev-server/client/app.jsx +1084 -417
- package/plugins/design/dev-server/dist/client.bundle.js +242 -20
- package/plugins/design/dev-server/dist/comment-mount.js +40 -62
- package/plugins/design/dev-server/export-dialog.tsx +189 -1
- 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/http.ts +40 -9
- package/plugins/design/dev-server/input-router.tsx +9 -2
- package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- 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-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +11 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -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 +9 -11
- package/plugins/design/dev-server/tool-palette.tsx +140 -11
- package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
- package/plugins/design/templates/_shell.html +36 -9
|
@@ -67,6 +67,39 @@ describe('annotations round-trip / back-compat canary (Task 10)', () => {
|
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
describe('annotations round-trip / Phase-21 back-compat canary (Phase 24, Task 2)', () => {
|
|
71
|
+
// A frozen Phase-21-era canvas — sticky + rounded rect + arrow-both-heads-
|
|
72
|
+
// dashed. Phase 24 must round-trip it BYTE-IDENTICAL (the new polygon /
|
|
73
|
+
// 6-head / line-type / bold-strike-align fields all serialize only for
|
|
74
|
+
// non-default values, so a Phase-21 scene gains zero bytes).
|
|
75
|
+
test('a frozen Phase-21 SVG round-trips BYTE-IDENTICAL', async () => {
|
|
76
|
+
const fixture = await Bun.file(
|
|
77
|
+
new URL('./fixtures/phase-21-annotations.svg', import.meta.url)
|
|
78
|
+
).text();
|
|
79
|
+
expect(fixture.endsWith('</svg>')).toBe(true);
|
|
80
|
+
expect(reparse(fixture)).toBe(fixture);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('parsing the Phase-21 fixture keeps each shape free of phantom Phase-24 fields', async () => {
|
|
84
|
+
const fixture = await Bun.file(
|
|
85
|
+
new URL('./fixtures/phase-21-annotations.svg', import.meta.url)
|
|
86
|
+
).text();
|
|
87
|
+
const strokes = svgToStrokes(fixture);
|
|
88
|
+
expect(strokes.map((s) => s.tool)).toEqual(['sticky', 'rect', 'arrow']);
|
|
89
|
+
const sticky = strokes.find((s) => s.tool === 'sticky') as StickyStroke;
|
|
90
|
+
// No phantom bold/strike/align on a plain sticky.
|
|
91
|
+
expect(sticky.bold).toBeUndefined();
|
|
92
|
+
expect(sticky.strike).toBeUndefined();
|
|
93
|
+
expect(sticky.align).toBeUndefined();
|
|
94
|
+
expect(sticky.cornerRadius).toBe(8);
|
|
95
|
+
const arrow = strokes.find((s) => s.tool === 'arrow') as ArrowStroke;
|
|
96
|
+
expect(arrow.startHead).toBe('triangle');
|
|
97
|
+
expect(arrow.endHead).toBeUndefined(); // triangle is the default → unset
|
|
98
|
+
expect(arrow.dashed).toBe(true);
|
|
99
|
+
expect(arrow.lineType).toBeUndefined(); // straight default → no phantom
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
70
103
|
describe('annotations round-trip / sticky', () => {
|
|
71
104
|
const sticky: StickyStroke = {
|
|
72
105
|
id: 'st1',
|
|
@@ -274,3 +307,213 @@ describe('annotations round-trip / mixed scene idempotency', () => {
|
|
|
274
307
|
expect(reparse(svg)).toBe(svg);
|
|
275
308
|
});
|
|
276
309
|
});
|
|
310
|
+
|
|
311
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
312
|
+
// Phase 24 — polygon, full arrowhead set + line-type, text/sticky bold/strike/
|
|
313
|
+
// align. Parse needs a DOMParser (registered via happy-dom above).
|
|
314
|
+
|
|
315
|
+
describe('annotations round-trip / Phase 24 polygon', () => {
|
|
316
|
+
for (const shape of ['diamond', 'triangle', 'triangle-down'] as const) {
|
|
317
|
+
test(`${shape} round-trips its bbox + shape + fill`, () => {
|
|
318
|
+
const poly = {
|
|
319
|
+
id: `pg-${shape}`,
|
|
320
|
+
tool: 'polygon' as const,
|
|
321
|
+
shape,
|
|
322
|
+
color: '#e5484d',
|
|
323
|
+
width: 3,
|
|
324
|
+
x: 10,
|
|
325
|
+
y: 20,
|
|
326
|
+
w: 80,
|
|
327
|
+
h: 60,
|
|
328
|
+
fill: '#fbe0e1',
|
|
329
|
+
};
|
|
330
|
+
const svg = strokesToSvg([poly]);
|
|
331
|
+
expect(svg).toContain(`data-shape="${shape}"`);
|
|
332
|
+
const [parsed] = svgToStrokes(svg) as Array<typeof poly>;
|
|
333
|
+
expect(parsed?.tool).toBe('polygon');
|
|
334
|
+
expect(parsed?.shape).toBe(shape);
|
|
335
|
+
expect(parsed?.x).toBeCloseTo(10, 4);
|
|
336
|
+
expect(parsed?.y).toBeCloseTo(20, 4);
|
|
337
|
+
expect(parsed?.w).toBeCloseTo(80, 4);
|
|
338
|
+
expect(parsed?.h).toBeCloseTo(60, 4);
|
|
339
|
+
expect(parsed?.fill).toBe('#fbe0e1');
|
|
340
|
+
expect(reparse(svg)).toBe(svg); // idempotent
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
test('dashed polygon round-trips the dash flag', () => {
|
|
345
|
+
const svg = strokesToSvg([
|
|
346
|
+
{
|
|
347
|
+
id: 'pg',
|
|
348
|
+
tool: 'polygon',
|
|
349
|
+
shape: 'diamond',
|
|
350
|
+
color: '#222',
|
|
351
|
+
width: 2,
|
|
352
|
+
x: 0,
|
|
353
|
+
y: 0,
|
|
354
|
+
w: 40,
|
|
355
|
+
h: 40,
|
|
356
|
+
dashed: true,
|
|
357
|
+
},
|
|
358
|
+
]);
|
|
359
|
+
expect(svg).toContain('data-dash="1"');
|
|
360
|
+
const [parsed] = svgToStrokes(svg) as Array<{ dashed?: boolean }>;
|
|
361
|
+
expect(parsed?.dashed).toBe(true);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe('annotations round-trip / Phase 24 full arrowhead set + line-type', () => {
|
|
366
|
+
const heads: ArrowStroke['startHead'][] = [
|
|
367
|
+
'none',
|
|
368
|
+
'line',
|
|
369
|
+
'triangle',
|
|
370
|
+
'triangle-outline',
|
|
371
|
+
'circle',
|
|
372
|
+
'diamond',
|
|
373
|
+
];
|
|
374
|
+
for (const start of heads) {
|
|
375
|
+
for (const end of heads) {
|
|
376
|
+
test(`heads start=${start} end=${end} round-trip`, () => {
|
|
377
|
+
const arrow: ArrowStroke = {
|
|
378
|
+
id: 'a',
|
|
379
|
+
tool: 'arrow',
|
|
380
|
+
color: '#1a8f3e',
|
|
381
|
+
width: 3,
|
|
382
|
+
x1: 0,
|
|
383
|
+
y1: 0,
|
|
384
|
+
x2: 100,
|
|
385
|
+
y2: 20,
|
|
386
|
+
startHead: start,
|
|
387
|
+
endHead: end,
|
|
388
|
+
};
|
|
389
|
+
const svg = strokesToSvg([arrow]);
|
|
390
|
+
const [parsed] = svgToStrokes(svg) as ArrowStroke[];
|
|
391
|
+
expect(parsed?.startHead ?? 'none').toBe(start);
|
|
392
|
+
expect(parsed?.endHead ?? 'triangle').toBe(end);
|
|
393
|
+
expect(reparse(svg)).toBe(svg);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
for (const lineType of ['straight', 'curved', 'elbow'] as const) {
|
|
399
|
+
test(`lineType=${lineType} round-trips + endpoints recover from the shaft`, () => {
|
|
400
|
+
const arrow: ArrowStroke = {
|
|
401
|
+
id: 'a',
|
|
402
|
+
tool: 'arrow',
|
|
403
|
+
color: '#3b82f6',
|
|
404
|
+
width: 2,
|
|
405
|
+
x1: 12,
|
|
406
|
+
y1: 8,
|
|
407
|
+
x2: 90,
|
|
408
|
+
y2: 64,
|
|
409
|
+
lineType,
|
|
410
|
+
};
|
|
411
|
+
const svg = strokesToSvg([arrow]);
|
|
412
|
+
const [parsed] = svgToStrokes(svg) as ArrowStroke[];
|
|
413
|
+
expect(parsed?.lineType ?? 'straight').toBe(lineType);
|
|
414
|
+
// Endpoints survive whether the shaft is a <line> or a <path>.
|
|
415
|
+
expect(parsed?.x1).toBeCloseTo(12, 4);
|
|
416
|
+
expect(parsed?.y1).toBeCloseTo(8, 4);
|
|
417
|
+
expect(parsed?.x2).toBeCloseTo(90, 4);
|
|
418
|
+
expect(parsed?.y2).toBeCloseTo(64, 4);
|
|
419
|
+
expect(reparse(svg)).toBe(svg);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe('annotations round-trip / Phase 24 text + sticky bold/strike/align', () => {
|
|
425
|
+
for (const align of ['left', 'center', 'right'] as const) {
|
|
426
|
+
test(`standalone text align=${align} + bold + strike round-trips`, () => {
|
|
427
|
+
const t: TextStroke = {
|
|
428
|
+
id: 't',
|
|
429
|
+
tool: 'text',
|
|
430
|
+
color: '#1a1a1a',
|
|
431
|
+
fontSize: 36,
|
|
432
|
+
text: 'huge',
|
|
433
|
+
x: 10,
|
|
434
|
+
y: 20,
|
|
435
|
+
bold: true,
|
|
436
|
+
strike: true,
|
|
437
|
+
align,
|
|
438
|
+
};
|
|
439
|
+
const [parsed] = svgToStrokes(strokesToSvg([t])) as TextStroke[];
|
|
440
|
+
expect(parsed?.bold).toBe(true);
|
|
441
|
+
expect(parsed?.strike).toBe(true);
|
|
442
|
+
// 'left' is the standalone default → serializer omits it → parser leaves undefined.
|
|
443
|
+
expect(parsed?.align ?? 'left').toBe(align);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
test('anchored text default (centre, no bold/strike) stays byte-identical', () => {
|
|
448
|
+
const t: TextStroke = {
|
|
449
|
+
id: 't',
|
|
450
|
+
tool: 'text',
|
|
451
|
+
color: '#1a1a1a',
|
|
452
|
+
fontSize: 14,
|
|
453
|
+
text: 'x',
|
|
454
|
+
anchorId: 'h',
|
|
455
|
+
};
|
|
456
|
+
const svg = strokesToSvg([t]);
|
|
457
|
+
expect(svg).not.toContain('data-align');
|
|
458
|
+
expect(svg).not.toContain('font-weight');
|
|
459
|
+
expect(svg).not.toContain('text-decoration');
|
|
460
|
+
expect(reparse(svg)).toBe(svg);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test('sticky bold/strike/align round-trips; a plain sticky gains no attrs', () => {
|
|
464
|
+
const plain: StickyStroke = {
|
|
465
|
+
id: 'st',
|
|
466
|
+
tool: 'sticky',
|
|
467
|
+
color: '#fce8a6',
|
|
468
|
+
x: 0,
|
|
469
|
+
y: 0,
|
|
470
|
+
w: 200,
|
|
471
|
+
h: 200,
|
|
472
|
+
text: 'note',
|
|
473
|
+
fontSize: 16,
|
|
474
|
+
};
|
|
475
|
+
const plainSvg = strokesToSvg([plain]);
|
|
476
|
+
expect(plainSvg).not.toContain('data-bold');
|
|
477
|
+
expect(plainSvg).not.toContain('data-align');
|
|
478
|
+
const styled: StickyStroke = { ...plain, bold: true, strike: true, align: 'center' };
|
|
479
|
+
const svg = strokesToSvg([styled]);
|
|
480
|
+
expect(svg).toContain('data-bold="1"');
|
|
481
|
+
expect(svg).toContain('data-strike="1"');
|
|
482
|
+
expect(svg).toContain('data-align="center"');
|
|
483
|
+
const [parsed] = svgToStrokes(svg) as StickyStroke[];
|
|
484
|
+
expect(parsed?.bold).toBe(true);
|
|
485
|
+
expect(parsed?.strike).toBe(true);
|
|
486
|
+
expect(parsed?.align).toBe('center');
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
describe('annotations round-trip / Phase 24 arrowhead parse-clamp (DDR-067 security)', () => {
|
|
491
|
+
// A hub-pushed SVG with an out-of-vocabulary / poisoned data-*-head must be
|
|
492
|
+
// REJECTED on parse (not cast through unchecked) so it can never reach the
|
|
493
|
+
// serializer to attempt a quote-breakout.
|
|
494
|
+
test('an out-of-vocab data-start-head is dropped, not cast through', () => {
|
|
495
|
+
const dirty =
|
|
496
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
497
|
+
'<g data-id="a" data-tool="arrow" stroke="#111" stroke-width="2" fill="none" ' +
|
|
498
|
+
'data-start-head="t"onload="alert(1)" data-end-head="evil">' +
|
|
499
|
+
'<line x1="0" y1="0" x2="50" y2="0"/></g></svg>';
|
|
500
|
+
const [parsed] = svgToStrokes(dirty) as ArrowStroke[];
|
|
501
|
+
expect(parsed?.tool).toBe('arrow');
|
|
502
|
+
expect(parsed?.startHead).toBeUndefined(); // poisoned → rejected
|
|
503
|
+
expect(parsed?.endHead).toBeUndefined(); // 'evil' → rejected
|
|
504
|
+
// Re-serializing the clamped stroke emits NO poisoned attribute.
|
|
505
|
+
const reserialized = strokesToSvg(svgToStrokes(dirty));
|
|
506
|
+
expect(reserialized).not.toContain('onload');
|
|
507
|
+
expect(reserialized).not.toContain('evil');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('a valid expanded head (circle/diamond) still round-trips', () => {
|
|
511
|
+
const ok =
|
|
512
|
+
'<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
|
|
513
|
+
'<g data-id="a" data-tool="arrow" stroke="#111" stroke-width="2" fill="none" data-start-head="circle" data-end-head="diamond">' +
|
|
514
|
+
'<line x1="0" y1="0" x2="50" y2="0"/><polygon points="0,0 0,0 0,0" fill="#111"/><polygon points="0,0 0,0 0,0 0,0" fill="#111"/></g></svg>';
|
|
515
|
+
const [parsed] = svgToStrokes(ok) as ArrowStroke[];
|
|
516
|
+
expect(parsed?.startHead).toBe('circle');
|
|
517
|
+
expect(parsed?.endHead).toBe('diamond');
|
|
518
|
+
});
|
|
519
|
+
});
|
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from 'bun:test';
|
|
6
6
|
|
|
7
|
-
import { TOOL_CURSORS } from '../canvas-cursors.ts';
|
|
7
|
+
import { TOOL_CURSORS, resolveToolCursor } from '../canvas-cursors.ts';
|
|
8
8
|
|
|
9
9
|
const ALL_TOOLS = [
|
|
10
10
|
'move',
|
|
11
11
|
'hand',
|
|
12
12
|
'comment',
|
|
13
13
|
'pen',
|
|
14
|
+
'shape',
|
|
14
15
|
'rect',
|
|
15
16
|
'ellipse',
|
|
16
17
|
'sticky',
|
|
@@ -27,15 +28,20 @@ describe('canvas-cursors / TOOL_CURSORS', () => {
|
|
|
27
28
|
}
|
|
28
29
|
});
|
|
29
30
|
|
|
30
|
-
test('move
|
|
31
|
-
|
|
31
|
+
test('Phase 24 — every tool ships a Kenney cursor (move is no longer the bare system arrow)', () => {
|
|
32
|
+
// The brief: ONE unified cursor library for ALL tools. `move` now ships a
|
|
33
|
+
// Kenney arrow glyph with a `default` native fallback (not bare 'default').
|
|
34
|
+
expect(TOOL_CURSORS.move).toContain('url("data:image/svg+xml,');
|
|
35
|
+
expect(TOOL_CURSORS.move).toMatch(/, default$/);
|
|
32
36
|
});
|
|
33
37
|
|
|
34
38
|
test('custom cursors are well-formed data-URI CSS values with hotspot + fallback', () => {
|
|
35
39
|
const fallback: Record<string, string> = {
|
|
40
|
+
move: 'default',
|
|
36
41
|
hand: 'grab',
|
|
37
42
|
comment: 'crosshair',
|
|
38
43
|
pen: 'crosshair',
|
|
44
|
+
shape: 'crosshair',
|
|
39
45
|
rect: 'crosshair',
|
|
40
46
|
ellipse: 'crosshair',
|
|
41
47
|
sticky: 'crosshair',
|
|
@@ -49,10 +55,12 @@ describe('canvas-cursors / TOOL_CURSORS', () => {
|
|
|
49
55
|
expect(v).toMatch(
|
|
50
56
|
new RegExp(`^url\\("data:image/svg\\+xml,%3Csvg[^"]+"\\) \\d+ \\d+, ${fb}$`)
|
|
51
57
|
);
|
|
52
|
-
// The SVG
|
|
58
|
+
// The SVG renders at 24px (Phase 24 — smaller than the 32-unit viewBox)
|
|
59
|
+
// and is valid encoded markup.
|
|
53
60
|
const decoded = decodeURIComponent(v.slice(v.indexOf(',') + 1, v.indexOf('")')));
|
|
54
|
-
expect(decoded).toContain("width='
|
|
55
|
-
expect(decoded).toContain("height='
|
|
61
|
+
expect(decoded).toContain("width='24'");
|
|
62
|
+
expect(decoded).toContain("height='24'");
|
|
63
|
+
expect(decoded).toContain("viewBox='0 0 32 32'");
|
|
56
64
|
expect(decoded).toContain('</svg>');
|
|
57
65
|
}
|
|
58
66
|
});
|
|
@@ -71,3 +79,84 @@ describe('canvas-cursors / TOOL_CURSORS', () => {
|
|
|
71
79
|
}
|
|
72
80
|
});
|
|
73
81
|
});
|
|
82
|
+
|
|
83
|
+
// Phase 24 (DDR-067) — the canvas→shell `tool-cursor` bridge echoes a tool
|
|
84
|
+
// TOKEN and the shell resolves it HERE to a trusted cursor. This is the control
|
|
85
|
+
// that closes the phase-24 ethical-hacker Finding 2 (an untrusted synced canvas
|
|
86
|
+
// posting an invisible/displaced SVG cursor as a clickjacking aid over the
|
|
87
|
+
// un-CSP'd shell). These tests assert the confinement so it can't silently
|
|
88
|
+
// regress: only known tool ids resolve, and they always resolve to a trusted,
|
|
89
|
+
// visible data-URI cursor — never to attacker-supplied bytes.
|
|
90
|
+
describe('canvas-cursors / resolveToolCursor (untrusted token → trusted cursor)', () => {
|
|
91
|
+
test('every known tool resolves to its trusted TOOL_CURSORS string', () => {
|
|
92
|
+
for (const t of ALL_TOOLS) {
|
|
93
|
+
expect(resolveToolCursor(t)).toBe(TOOL_CURSORS[t]);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('resolved value is always a trusted data-URI cursor (or native fallback) — never raw bytes', () => {
|
|
98
|
+
for (const t of ALL_TOOLS) {
|
|
99
|
+
const v = resolveToolCursor(t);
|
|
100
|
+
expect(typeof v).toBe('string');
|
|
101
|
+
// Each is one of our own frozen entries: a `url("data:image/svg+xml,…")`
|
|
102
|
+
// glyph with a hotspot + native fallback. The shell interpolates THIS,
|
|
103
|
+
// not the message, so there is no attacker-controlled region.
|
|
104
|
+
expect(v).toMatch(/^url\("data:image\/svg\+xml,%3Csvg[^"]+"\) \d+ \d+, [a-z-]+$/);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('rejects unknown / prototype-polluting / malformed tokens', () => {
|
|
109
|
+
const hostile = [
|
|
110
|
+
// not a known tool
|
|
111
|
+
'pwn',
|
|
112
|
+
'banana',
|
|
113
|
+
// prototype chain — `in` would match these; `hasOwnProperty` must not
|
|
114
|
+
'constructor',
|
|
115
|
+
'toString',
|
|
116
|
+
'hasOwnProperty',
|
|
117
|
+
'__proto__',
|
|
118
|
+
// shape-gate violations (uppercase / digits / punctuation / whitespace)
|
|
119
|
+
'Move',
|
|
120
|
+
'pen1',
|
|
121
|
+
'pen ',
|
|
122
|
+
' pen',
|
|
123
|
+
'pen;',
|
|
124
|
+
'pe.n',
|
|
125
|
+
'',
|
|
126
|
+
// the OLD attack vector: a full cursor STRING is no longer accepted —
|
|
127
|
+
// only a bare token is, so an invisible/displaced SVG cursor can't pass
|
|
128
|
+
'url("data:image/svg+xml,%3Csvg/%3E") 0 0, none',
|
|
129
|
+
];
|
|
130
|
+
for (const tok of hostile) {
|
|
131
|
+
expect(resolveToolCursor(tok)).toBeNull();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('rejects non-string tokens (typeof short-circuits BEFORE coercion)', () => {
|
|
136
|
+
// The `{ toString }` / `[ 'pen' ]` cases prove the guard checks `typeof`
|
|
137
|
+
// first and never coerces — a malicious `toString`/`Symbol.toPrimitive`
|
|
138
|
+
// that returns a valid tool id must NOT smuggle a cursor through.
|
|
139
|
+
let coerced = false;
|
|
140
|
+
const hostileCoercible = {
|
|
141
|
+
toString() {
|
|
142
|
+
coerced = true;
|
|
143
|
+
return 'pen';
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
for (const tok of [
|
|
147
|
+
null,
|
|
148
|
+
undefined,
|
|
149
|
+
42,
|
|
150
|
+
{},
|
|
151
|
+
[],
|
|
152
|
+
['pen'],
|
|
153
|
+
true,
|
|
154
|
+
Symbol('pen'),
|
|
155
|
+
hostileCoercible,
|
|
156
|
+
]) {
|
|
157
|
+
// biome-ignore lint/suspicious/noExplicitAny: deliberate hostile-input test
|
|
158
|
+
expect(resolveToolCursor(tok as any)).toBeNull();
|
|
159
|
+
}
|
|
160
|
+
expect(coerced).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -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.
|
|
@@ -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"><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,13 +222,20 @@ 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
229
|
});
|
|
230
230
|
});
|
|
231
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
|
+
|
|
232
239
|
test('N → tool sticky (Phase 21)', () => {
|
|
233
240
|
expect(classify(base({ type: 'keydown', key: 'n' }))).toEqual({
|
|
234
241
|
kind: 'tool',
|