@1agh/maude 1.0.6 → 1.0.8

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.
Files changed (48) hide show
  1. package/apps/studio/bin/_html-playwright.mjs +198 -19
  2. package/apps/studio/bin/_pdf-playwright.mjs +1 -0
  3. package/apps/studio/bin/_png-playwright.mjs +28 -8
  4. package/apps/studio/bin/_pptx-playwright.mjs +4 -1
  5. package/apps/studio/bin/_pw-launch.mjs +48 -0
  6. package/apps/studio/bin/_svg-playwright.mjs +63 -149
  7. package/apps/studio/bin/_video-playwright.mjs +123 -11
  8. package/apps/studio/canvas-edit.ts +33 -0
  9. package/apps/studio/canvas-lib.tsx +136 -0
  10. package/apps/studio/client/app.jsx +316 -28
  11. package/apps/studio/client/export-lane.js +191 -0
  12. package/apps/studio/dist/client.bundle.js +907 -907
  13. package/apps/studio/dist/runtime/.min-sizes.json +2 -1
  14. package/apps/studio/dist/runtime/dom-to-svg.js +31 -0
  15. package/apps/studio/export-dialog.tsx +24 -19
  16. package/apps/studio/exporters/_browser-bundles.ts +30 -0
  17. package/apps/studio/exporters/_runtime.ts +31 -1
  18. package/apps/studio/exporters/capture-chrome.ts +111 -0
  19. package/apps/studio/exporters/capture-core.ts +947 -0
  20. package/apps/studio/exporters/format-scopes.ts +88 -0
  21. package/apps/studio/exporters/html.ts +3 -2
  22. package/apps/studio/exporters/jobs.ts +180 -18
  23. package/apps/studio/exporters/pdf.ts +21 -1
  24. package/apps/studio/exporters/pptx.ts +46 -11
  25. package/apps/studio/exporters/remote.ts +94 -11
  26. package/apps/studio/exporters/scope.ts +14 -3
  27. package/apps/studio/exporters/svg.ts +17 -3
  28. package/apps/studio/exporters/video.ts +75 -8
  29. package/apps/studio/http.ts +175 -7
  30. package/apps/studio/runtime-bundle.ts +6 -0
  31. package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
  32. package/apps/studio/test/capture-core.test.ts +32 -0
  33. package/apps/studio/test/export-assemble.test.ts +53 -0
  34. package/apps/studio/test/export-browser-lane.test.ts +126 -0
  35. package/apps/studio/test/export-capture-fidelity.test.ts +146 -0
  36. package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
  37. package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
  38. package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
  39. package/apps/studio/test/exporters/jobs.test.ts +73 -0
  40. package/apps/studio/test/exporters/scope.test.ts +33 -0
  41. package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
  42. package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
  43. package/apps/studio/test/photo-canvas-bundle.test.ts +18 -0
  44. package/apps/studio/test/workspace-containment.test.ts +8 -0
  45. package/apps/studio/whats-new.json +18 -0
  46. package/apps/studio/workspace-mode.ts +15 -2
  47. package/package.json +8 -8
  48. package/plugins/design/templates/_shell.html +2 -1
@@ -0,0 +1,126 @@
1
+ // export-browser-lane.test.ts — DDR-231 lane-resolution matrix for the client
2
+ // half of the hybrid export lanes (client/export-lane.js). Fail-first: red
3
+ // before the browser lane existed (the module didn't).
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+
7
+ import {
8
+ BROWSER_CAPTURE_FORMATS,
9
+ browserCaptureEligible,
10
+ captureScale,
11
+ MAX_CAPTURE_ITEMS,
12
+ sanitizeCapturedItems,
13
+ } from '../client/export-lane.js';
14
+
15
+ const PNG_1PX = Uint8Array.from(
16
+ atob(
17
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
18
+ ),
19
+ (c) => c.charCodeAt(0)
20
+ );
21
+ const pngBlob = () => new Blob([PNG_1PX], { type: 'image/png' });
22
+
23
+ describe('browserCaptureEligible — who captures where', () => {
24
+ const base = { format: 'png', scope: 'artboard', artboardId: 'board-1' };
25
+
26
+ test('desktop/local NEVER routes through the browser bridge (playwright spine owns it)', () => {
27
+ expect(browserCaptureEligible({ ...base, exportLane: 'local' })).toBe(false);
28
+ expect(browserCaptureEligible({ ...base, exportLane: undefined })).toBe(false);
29
+ });
30
+
31
+ test('a workspace WITH a render service still prefers the browser for png/svg artboard', () => {
32
+ expect(browserCaptureEligible({ ...base, exportLane: 'remote' })).toBe(true);
33
+ expect(browserCaptureEligible({ ...base, exportLane: 'remote', format: 'svg' })).toBe(true);
34
+ });
35
+
36
+ test('a workspace WITHOUT a render service gets png/svg artboard too (the DDR-231 win)', () => {
37
+ expect(browserCaptureEligible({ ...base, exportLane: 'none' })).toBe(true);
38
+ });
39
+
40
+ test('video formats stay on the jobs lane (Canva/Kapwing pattern — full-CSS fidelity)', () => {
41
+ for (const format of ['mp4', 'webm', 'gif', 'pdf', 'html', 'zip', 'canva']) {
42
+ expect(browserCaptureEligible({ ...base, exportLane: 'remote', format })).toBe(false);
43
+ }
44
+ });
45
+
46
+ test('the pptx deck rides the browser lane on its (only) canvas-as-separate scope', () => {
47
+ const deck = { format: 'pptx', scope: 'canvas-as-separate' };
48
+ expect(browserCaptureEligible({ ...deck, exportLane: 'remote' })).toBe(true);
49
+ expect(browserCaptureEligible({ ...deck, exportLane: 'none' })).toBe(true);
50
+ expect(browserCaptureEligible({ ...deck, exportLane: 'local' })).toBe(false);
51
+ expect(
52
+ browserCaptureEligible({ format: 'pptx', scope: 'artboard', exportLane: 'remote' })
53
+ ).toBe(false);
54
+ });
55
+
56
+ test('non-artboard scopes stay on the jobs lane (v1: capture what you are looking at)', () => {
57
+ for (const scope of ['selection', 'canvas-as-separate', 'project-raw']) {
58
+ expect(browserCaptureEligible({ ...base, exportLane: 'remote', scope })).toBe(false);
59
+ }
60
+ });
61
+
62
+ test('no known artboard → no browser capture (the bridge selects by id)', () => {
63
+ expect(browserCaptureEligible({ ...base, exportLane: 'remote', artboardId: null })).toBe(false);
64
+ });
65
+
66
+ test('the format set matches what canvas-lib can actually produce', () => {
67
+ expect([...BROWSER_CAPTURE_FORMATS].sort()).toEqual(['png', 'svg']);
68
+ });
69
+ });
70
+
71
+ describe('sanitizeCapturedItems — the untrusted-reply neutralizer (F1)', () => {
72
+ test('a forged non-PNG blob named .png is REJECTED by magic sniffing', async () => {
73
+ const evil = [
74
+ { name: 'invoice.png', blob: new Blob(['<script>alert(1)</script>'], { type: 'image/png' }) },
75
+ ];
76
+ await expect(sanitizeCapturedItems(evil, 'png')).rejects.toThrow(/not a valid png/);
77
+ });
78
+
79
+ test('a real PNG passes and gets a path-stripped name + forced MIME', async () => {
80
+ const [safe] = await sanitizeCapturedItems(
81
+ [{ name: '../../etc/passwd\r\n.png', blob: pngBlob() }],
82
+ 'png'
83
+ );
84
+ expect(safe.name).not.toContain('/');
85
+ expect(safe.name).not.toContain('..');
86
+ expect(safe.name.endsWith('.png')).toBe(true);
87
+ expect(safe.blob.type).toBe('image/png');
88
+ });
89
+
90
+ test('svg magic (<svg / <?xml) is accepted; other bytes rejected', async () => {
91
+ const [ok] = await sanitizeCapturedItems(
92
+ [{ name: 'b.svg', blob: new Blob(['<svg/>']) }],
93
+ 'svg'
94
+ );
95
+ expect(ok.name).toBe('b.svg');
96
+ expect(ok.blob.type).toBe('image/svg+xml');
97
+ await expect(
98
+ sanitizeCapturedItems([{ name: 'x.svg', blob: new Blob(['not svg']) }], 'svg')
99
+ ).rejects.toThrow(/not a valid svg/);
100
+ });
101
+
102
+ test('too many items is refused (a forged reply cannot drive N downloads)', async () => {
103
+ const many = Array.from({ length: MAX_CAPTURE_ITEMS + 1 }, () => ({ blob: pngBlob() }));
104
+ await expect(sanitizeCapturedItems(many, 'png')).rejects.toThrow(/too many items/);
105
+ });
106
+
107
+ test('a non-blob item is refused', async () => {
108
+ await expect(
109
+ sanitizeCapturedItems([{ name: 'x.png', blob: 'not a blob' as unknown as Blob }], 'png')
110
+ ).rejects.toThrow(/not a blob/);
111
+ });
112
+ });
113
+
114
+ describe('captureScale — dpi folds over scale, clamped to the raster guard window', () => {
115
+ test('scale multiplier passes through', () => {
116
+ expect(captureScale({ scale: 2 })).toBe(2);
117
+ });
118
+ test('dpi wins over scale (mirrors exporters/png.ts resolveDeviceScale)', () => {
119
+ expect(captureScale({ dpi: 300, scale: 2 })).toBe(300 / 96);
120
+ });
121
+ test('clamped to [1, 8]', () => {
122
+ expect(captureScale({ dpi: 1200 })).toBe(8);
123
+ expect(captureScale({ scale: 0 })).toBe(1);
124
+ expect(captureScale()).toBe(1);
125
+ });
126
+ });
@@ -0,0 +1,146 @@
1
+ // export-capture-fidelity.test.ts — DDR-231 T6: the fidelity gate for the
2
+ // browser export lane.
3
+ //
4
+ // The browser lane rasterizes dom-to-svg output (capture-core.ts); the
5
+ // reference spine screenshots the live element (the playwright path the
6
+ // desktop + render worker use). dom-to-svg RECONSTRUCTS the DOM as SVG
7
+ // primitives, so the two are not byte-identical — this gate pins how far
8
+ // apart they may drift on a representative artboard (tokens-styled text,
9
+ // gradient, border-radius, shadow, nested layout). A capture regression
10
+ // (dropped background, missing text, wrong geometry) blows well past these
11
+ // thresholds; anti-aliasing and shadow-softness differences do not.
12
+ //
13
+ // Runs a real Chromium (repo playwright) — lives in the studio suite, not the
14
+ // sync lane.
15
+
16
+ import { describe, expect, test } from 'bun:test';
17
+
18
+ import { getBrowserBundle, getCaptureCoreBundle } from '../exporters/_browser-bundles.ts';
19
+
20
+ const FIXTURE = `<!doctype html><html><body style="margin:0;background:#222">
21
+ <div class="dc-world">
22
+ <div data-dc-screen="board-1" style="position:absolute;left:0;top:0;width:420px;height:300px;
23
+ background:linear-gradient(135deg,#1c2340,#3a1f47);border-radius:16px;overflow:hidden;
24
+ font-family:Arial,Helvetica,sans-serif;color:#f5f2ea">
25
+ <div style="padding:28px">
26
+ <div style="font-size:24px;font-weight:700;letter-spacing:-0.4px">Summer Camp</div>
27
+ <div style="font-size:13px;opacity:.75;margin-top:6px">Brno · 12–16 August</div>
28
+ <div style="display:flex;gap:10px;margin-top:18px">
29
+ <div style="flex:1;height:64px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:10px"></div>
30
+ <div style="flex:1;height:64px;background:#e4572e;border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.4)"></div>
31
+ </div>
32
+ <div style="margin-top:16px;font-size:12px;line-height:1.5;opacity:.85">
33
+ Registrace do konce července. Kapacita 60 hráčů.
34
+ </div>
35
+ </div>
36
+ </div>
37
+ </div></body></html>`;
38
+
39
+ describe('browser-lane capture vs playwright reference', () => {
40
+ test(
41
+ 'raster stays within the drift thresholds on a representative artboard',
42
+ async () => {
43
+ const [iife, core] = await Promise.all([
44
+ getBrowserBundle('dom-to-svg', 'domToSvg'),
45
+ getCaptureCoreBundle(),
46
+ ]);
47
+ const { launchChromium } = (await import('../bin/_pw-launch.mjs')) as {
48
+ launchChromium: () => Promise<{
49
+ newContext: (o: unknown) => Promise<{ newPage: () => Promise<unknown> }>;
50
+ close: () => Promise<void>;
51
+ }>;
52
+ };
53
+ const browser = await launchChromium();
54
+ try {
55
+ const ctx = await browser.newContext({
56
+ viewport: { width: 800, height: 600 },
57
+ deviceScaleFactor: 1,
58
+ });
59
+ // biome-ignore lint/suspicious/noExplicitAny: playwright page surface
60
+ const page: any = await ctx.newPage();
61
+ await page.setContent(FIXTURE);
62
+ await page.evaluate(() => document.fonts.ready);
63
+ await page.addScriptTag({ path: iife });
64
+ await page.addScriptTag({ path: core, type: 'module' });
65
+ await page.waitForFunction(
66
+ () => !!(window as never as Record<string, unknown>)['__maudeCaptureCore']
67
+ );
68
+
69
+ // Reference: the playwright spine's capture of the same element.
70
+ const el = page.locator('[data-dc-screen]');
71
+ const refBytes: Uint8Array = await el.screenshot();
72
+ const refB64 = Buffer.from(refBytes).toString('base64');
73
+
74
+ const verdict = await page.evaluate(async (refDataUri: string) => {
75
+ // biome-ignore lint/suspicious/noExplicitAny: injected globals
76
+ const w = window as any;
77
+ const core = w.__maudeCaptureCore;
78
+ const target = document.querySelector('[data-dc-screen]') as Element;
79
+ const svg = await core.svgForElement(target, w.domToSvg);
80
+ const blob: Blob = await core.rasterizeSvg(svg, 1);
81
+
82
+ const load = (src: string) =>
83
+ new Promise<HTMLImageElement>((resolve, reject) => {
84
+ const img = new Image();
85
+ img.onload = () => resolve(img);
86
+ img.onerror = () => reject(new Error('decode failed'));
87
+ img.src = src;
88
+ });
89
+ const candUrl = URL.createObjectURL(blob);
90
+ const [ref, cand] = await Promise.all([load(refDataUri), load(candUrl)]);
91
+ const W = ref.naturalWidth;
92
+ const H = ref.naturalHeight;
93
+ const draw = (img: HTMLImageElement) => {
94
+ const c = document.createElement('canvas');
95
+ c.width = W;
96
+ c.height = H;
97
+ const g = c.getContext('2d') as CanvasRenderingContext2D;
98
+ g.drawImage(img, 0, 0, W, H);
99
+ return g.getImageData(0, 0, W, H).data;
100
+ };
101
+ const a = draw(ref);
102
+ const b = draw(cand);
103
+ let sum = 0;
104
+ let close = 0;
105
+ const n = W * H;
106
+ for (let i = 0; i < n * 4; i += 4) {
107
+ const d =
108
+ (Math.abs(a[i] - b[i]) +
109
+ Math.abs(a[i + 1] - b[i + 1]) +
110
+ Math.abs(a[i + 2] - b[i + 2])) /
111
+ 3;
112
+ sum += d;
113
+ if (d <= 32) close += 1;
114
+ }
115
+ URL.revokeObjectURL(candUrl);
116
+ return {
117
+ refW: W,
118
+ refH: H,
119
+ candW: cand.naturalWidth,
120
+ candH: cand.naturalHeight,
121
+ meanDelta: sum / n,
122
+ closeRatio: close / n,
123
+ };
124
+ }, `data:image/png;base64,${refB64}`);
125
+
126
+ // Geometry must agree exactly — a size mismatch is a broken capture,
127
+ // not drift.
128
+ expect(verdict.candW).toBe(verdict.refW);
129
+ expect(verdict.candH).toBe(verdict.refH);
130
+ // Thresholds set from the MEASURED baseline (fail-first evidence,
131
+ // 2026-08-23): healthy capture = meanDelta 1.48, closeRatio 1.0;
132
+ // a simulated dropped-text regression = meanDelta 5.37, closeRatio
133
+ // 0.974. Both captures render in the SAME engine, so platform font
134
+ // variance cancels — the residual is dom-to-svg reconstruction only.
135
+ // meanDelta < 4 / closeRatio > 0.99 keeps ~2.7× headroom over healthy
136
+ // while catching the text-drop class (mean-only thresholds missed it —
137
+ // text is a small pixel fraction, hence the paired closeRatio gate).
138
+ expect(verdict.meanDelta).toBeLessThan(4);
139
+ expect(verdict.closeRatio).toBeGreaterThan(0.99);
140
+ } finally {
141
+ await browser.close();
142
+ }
143
+ },
144
+ { timeout: 60_000 }
145
+ );
146
+ });