@1agh/maude 0.16.0 → 0.17.1

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 (44) hide show
  1. package/README.md +4 -0
  2. package/cli/commands/design.mjs +108 -2
  3. package/package.json +12 -18
  4. package/plugins/design/dev-server/annotations-context-toolbar.tsx +8 -8
  5. package/plugins/design/dev-server/annotations-layer.tsx +8 -10
  6. package/plugins/design/dev-server/api.ts +41 -0
  7. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +40 -0
  8. package/plugins/design/dev-server/bin/_html-playwright.mjs +129 -0
  9. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +105 -0
  10. package/plugins/design/dev-server/bin/_png-playwright.mjs +143 -0
  11. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +98 -0
  12. package/plugins/design/dev-server/bin/_svg-playwright.mjs +141 -0
  13. package/plugins/design/dev-server/canvas-lib.tsx +12 -13
  14. package/plugins/design/dev-server/canvas-shell.tsx +32 -4
  15. package/plugins/design/dev-server/client/app.jsx +18 -1
  16. package/plugins/design/dev-server/context-menu.tsx +36 -9
  17. package/plugins/design/dev-server/dist/client.bundle.js +11 -3
  18. package/plugins/design/dev-server/export-dialog.tsx +401 -0
  19. package/plugins/design/dev-server/exporters/_browser-bundles.ts +89 -0
  20. package/plugins/design/dev-server/exporters/canva-handoff-prompt.ts +74 -0
  21. package/plugins/design/dev-server/exporters/canva.ts +126 -0
  22. package/plugins/design/dev-server/exporters/html.ts +103 -0
  23. package/plugins/design/dev-server/exporters/index.ts +135 -0
  24. package/plugins/design/dev-server/exporters/pdf.ts +109 -0
  25. package/plugins/design/dev-server/exporters/png.ts +136 -0
  26. package/plugins/design/dev-server/exporters/pptx.ts +263 -0
  27. package/plugins/design/dev-server/exporters/scope.ts +196 -0
  28. package/plugins/design/dev-server/exporters/svg.ts +122 -0
  29. package/plugins/design/dev-server/exporters/zip.ts +109 -0
  30. package/plugins/design/dev-server/http.ts +80 -0
  31. package/plugins/design/dev-server/inspect.ts +1 -1
  32. package/plugins/design/dev-server/server.mjs +1 -1
  33. package/plugins/design/dev-server/test/exporters/canva.test.ts +64 -0
  34. package/plugins/design/dev-server/test/exporters/endpoint.test.ts +121 -0
  35. package/plugins/design/dev-server/test/exporters/history.test.ts +79 -0
  36. package/plugins/design/dev-server/test/exporters/html.test.ts +26 -0
  37. package/plugins/design/dev-server/test/exporters/pdf.test.ts +53 -0
  38. package/plugins/design/dev-server/test/exporters/png.test.ts +32 -0
  39. package/plugins/design/dev-server/test/exporters/pptx.test.ts +31 -0
  40. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  41. package/plugins/design/dev-server/test/exporters/svg.test.ts +29 -0
  42. package/plugins/design/dev-server/test/exporters/zip.test.ts +105 -0
  43. package/plugins/design/dev-server/tool-palette.tsx +34 -16
  44. package/plugins/design/templates/_shell.html +33 -0
@@ -13,6 +13,8 @@ import { buildCanvasModule } from './canvas-build.ts';
13
13
  import { canvasLibPath } from './canvas-lib-resolver.ts';
14
14
  import { TranspileError } from './canvas-pipeline.ts';
15
15
  import type { Context } from './context.ts';
16
+ import { isFormat, isScope, runExport } from './exporters/index.ts';
17
+ import type { ActiveJsonShape } from './exporters/scope.ts';
16
18
  import type { Inspect } from './inspect.ts';
17
19
  import { canvasSlug, writeLocator } from './locator.ts';
18
20
  import { RUNTIME_PACKAGES, getRuntimeBundle, packageForSlug, slugFor } from './runtime-bundle.ts';
@@ -295,6 +297,84 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect): Http {
295
297
  return new Response('Method not allowed', { status: 405 });
296
298
  },
297
299
 
300
+ '/_api/export-history': async (req: Request) => {
301
+ // Phase 6.5 T10 — read-only recent-exports feed for the dialog's
302
+ // Recent tab. Writes happen as a side-effect of `/_api/export`.
303
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
304
+ const history = await api.loadExportHistory();
305
+ return Response.json({ history }, { headers: { 'Cache-Control': 'no-store' } });
306
+ },
307
+
308
+ '/_api/export': async (req: Request) => {
309
+ // Phase 6.5 — single dispatch endpoint for the export pipeline.
310
+ // POST body { format, scope, options? } → binary stream with
311
+ // Content-Disposition + Content-Type set by the adapter.
312
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
313
+ const body = await readJson<{
314
+ format?: unknown;
315
+ scope?: unknown;
316
+ options?: Record<string, unknown>;
317
+ }>(req, 64 * 1024);
318
+ if (!body) return new Response('body required', { status: 400 });
319
+ if (!isFormat(body.format)) return new Response('unknown or missing format', { status: 400 });
320
+ if (!isScope(body.scope)) return new Response('unknown or missing scope', { status: 400 });
321
+ // `inspect.state` is the live `_active.json` — readers narrow to the
322
+ // resolver's subset locally so the export pipeline doesn't pin the
323
+ // wider ActiveState interface.
324
+ const activeJson = inspect.state as unknown as ActiveJsonShape;
325
+ try {
326
+ const result = await runExport({
327
+ format: body.format,
328
+ scope: body.scope,
329
+ options: body.options ?? {},
330
+ resolve: { activeJson, designRoot: ctx.paths.designRoot, repoRoot: ctx.paths.repoRoot },
331
+ ctx: {
332
+ designRoot: ctx.paths.designRoot,
333
+ repoRoot: ctx.paths.repoRoot,
334
+ // Adapters reach back into the server via this origin only when
335
+ // they need Playwright rendering (PNG / PDF / SVG / HTML). The
336
+ // host that received this request is, by definition, the one
337
+ // serving the canvas.
338
+ serverOrigin: new URL(req.url).origin,
339
+ // Mirror `client/app.jsx:85` — the per-DS tokensCssRel wins over
340
+ // the legacy top-level default (which still points at the pre-
341
+ // multi-DS layout `system/colors_and_type.css`). Without the
342
+ // per-DS path, the standalone `_canvas-shell.html` 404s on the
343
+ // tokens link and the rendered DOM uses `var(--bg-0)` unresolved
344
+ // → screenshots come out blank. See canvasShellUrl().
345
+ tokensCssRel: ctx.cfg.designSystems?.[0]?.tokensCssRel ?? ctx.cfg.tokensCssRel,
346
+ },
347
+ });
348
+ // Fire-and-forget history append — failure here doesn't block the
349
+ // download. Synchronous await keeps the order: history reflects the
350
+ // export the moment the client sees a 200.
351
+ try {
352
+ await api.appendExportHistory({
353
+ format: body.format,
354
+ scope: body.scope,
355
+ options: body.options ?? {},
356
+ filename: result.filename,
357
+ at: new Date().toISOString(),
358
+ });
359
+ } catch {
360
+ /* ignore — history is best-effort */
361
+ }
362
+ // Bun.serve accepts Uint8Array directly; the cast satisfies the
363
+ // SharedArrayBuffer-strict BodyInit narrowing on @types/bun.
364
+ return new Response(result.body as unknown as BodyInit, {
365
+ status: 200,
366
+ headers: {
367
+ 'Content-Type': result.contentType,
368
+ 'Content-Disposition': `attachment; filename="${result.filename}"`,
369
+ 'Cache-Control': 'no-store',
370
+ },
371
+ });
372
+ } catch (err) {
373
+ const msg = err instanceof Error ? err.message : String(err);
374
+ return new Response(`export failed: ${msg}`, { status: 500 });
375
+ }
376
+ },
377
+
298
378
  '/_canvas-state': async (req: Request) => {
299
379
  const url = new URL(req.url);
300
380
  if (req.method === 'GET') {
@@ -227,7 +227,7 @@ const INSPECTOR_SCRIPT = `
227
227
 
228
228
  var styleEl = document.createElement('style');
229
229
  styleEl.textContent = [
230
- '.dgn-pin { position: absolute; top: 0; left: 0; z-index: 2147483646; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 999px 999px 999px 4px; background: #facc15; color: #1c1917; font: 600 11px/22px ui-sans-serif, system-ui, sans-serif; text-align: center; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,0,0,0.4); transition: filter 120ms; transform-origin: bottom left; will-change: transform; }',
230
+ '.dgn-pin { position: absolute; top: 0; left: 0; z-index: 2147483646; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 999px 999px 999px 4px; background: #facc15; color: #1c1917; font: 600 11px/22px var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); text-align: center; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,0,0,0.4); transition: filter 120ms; transform-origin: bottom left; will-change: transform; }',
231
231
  '.dgn-pin:hover { filter: brightness(1.1); outline: 2px solid rgba(0,0,0,0.3); }',
232
232
  '.dgn-pin.resolved { background: #22c55e; color: #052e16; }',
233
233
  '.dgn-pin.focused { box-shadow: 0 4px 12px rgba(0,0,0,0.6), 0 0 0 2px #fff; outline: 2px solid #fff; }'
@@ -573,7 +573,7 @@ const INSPECTOR_SCRIPT = `
573
573
  '.dgn-insp-selected { outline: 2px solid #00D4E4 !important; outline-offset: 1px !important; box-shadow: 0 0 0 4px rgba(0,212,228,0.18) !important; }',
574
574
  '.dgn-insp-label { position: fixed; z-index: 2147483647; font: 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace; background: #00D4E4; color: #000; padding: 4px 8px; border-radius: 4px; pointer-events: none; box-shadow: 0 2px 8px rgba(0,0,0,0.4); transform: translate(0, -110%); white-space: nowrap; max-width: 320px; overflow: hidden; text-overflow: ellipsis; }',
575
575
  '.dgn-insp-label.warn { background: #ef4444; color: #fff; }',
576
- '.dgn-pin { position: absolute; top: 0; left: 0; z-index: 2147483646; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 999px 999px 999px 4px; background: #facc15; color: #1c1917; font: 600 11px/22px ui-sans-serif, system-ui, sans-serif; text-align: center; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,0,0,0.4); transition: filter 120ms; transform-origin: bottom left; will-change: transform; }',
576
+ '.dgn-pin { position: absolute; top: 0; left: 0; z-index: 2147483646; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 999px 999px 999px 4px; background: #facc15; color: #1c1917; font: 600 11px/22px var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); text-align: center; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.5), 0 0 0 1px rgba(0,0,0,0.4); transition: filter 120ms; transform-origin: bottom left; will-change: transform; }',
577
577
  '.dgn-pin:hover { filter: brightness(1.1); outline: 2px solid rgba(0,0,0,0.3); }',
578
578
  '.dgn-pin.resolved { background: #22c55e; color: #052e16; }',
579
579
  '.dgn-pin.focused { box-shadow: 0 4px 12px rgba(0,0,0,0.6), 0 0 0 2px #fff; outline: 2px solid #fff; }'
@@ -0,0 +1,64 @@
1
+ // Phase 6.5 T6c — Canva handoff adapter + prompt artifact tests.
2
+ //
3
+ // Real browser walk is integration. Here we cover the pure markdown builder
4
+ // + the empty/file-tree branches.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { buildHandoffMarkdown } from '../../exporters/canva-handoff-prompt.ts';
9
+ import { run } from '../../exporters/canva.ts';
10
+
11
+ const CTX = {
12
+ designRoot: '/tmp/.design',
13
+ repoRoot: '/tmp',
14
+ serverOrigin: 'http://localhost:0',
15
+ };
16
+
17
+ describe('canva-handoff-prompt — buildHandoffMarkdown', () => {
18
+ test('emits a self-contained markdown with all three sections', () => {
19
+ const md = buildHandoffMarkdown({
20
+ pptxFilename: 'home.pptx',
21
+ absolutePath: '/Users/dev/Downloads/home.pptx',
22
+ canvasSlug: 'home',
23
+ artboardCount: 5,
24
+ artboardTitles: ['Hero', 'Pricing', 'FAQ', 'Footer A', 'Footer B'],
25
+ });
26
+ expect(md).toContain('# Canva handoff — home');
27
+ expect(md).toContain('**5** artboards');
28
+ expect(md).toContain('## Option A — drag-drop');
29
+ expect(md).toContain('## Option B — automate via your Canva MCP');
30
+ // Prompt block present, slot-filled.
31
+ expect(md).toContain('```text');
32
+ expect(md).toContain('/Users/dev/Downloads/home.pptx');
33
+ expect(md).toContain('Slides expected: 5');
34
+ expect(md).toContain('## Fidelity caveats');
35
+ // Hero through Footer B listed.
36
+ expect(md).toContain('1. Hero');
37
+ expect(md).toContain('5. Footer B');
38
+ });
39
+
40
+ test('singular-vs-plural copy switches at count=1', () => {
41
+ const md = buildHandoffMarkdown({
42
+ pptxFilename: 'solo.pptx',
43
+ absolutePath: '<your-unzip-location>/solo.pptx',
44
+ canvasSlug: 'solo',
45
+ artboardCount: 1,
46
+ });
47
+ expect(md).toContain('**1** artboard ');
48
+ expect(md).toContain('1 Canva page on import');
49
+ });
50
+ });
51
+
52
+ describe('canva adapter — contract', () => {
53
+ test('empty targets → zero-byte ZIP placeholder', async () => {
54
+ const r = await run([], {}, CTX);
55
+ expect(r.contentType).toBe('application/zip');
56
+ expect(r.body.byteLength).toBe(0);
57
+ });
58
+
59
+ test('file-tree targets → throws', async () => {
60
+ await expect(run([{ kind: 'file-tree', paths: ['ui/Home.tsx'] }], {}, CTX)).rejects.toThrow(
61
+ /element targets/i
62
+ );
63
+ });
64
+ });
@@ -0,0 +1,121 @@
1
+ // Phase 6.5 T1 — `/_api/export` endpoint smoke.
2
+ //
3
+ // Boots a real server (per `_helpers.bootServer`) and confirms every format
4
+ // stub returns 200 with the right MIME + filename. The stubs themselves emit
5
+ // zero-byte bodies; this test only checks the dispatch contract, not adapter
6
+ // fidelity (those tests land alongside each adapter in T2…T7).
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import { bootServer, killProc, makeSandbox, nextPort } from '../_helpers.ts';
11
+
12
+ // Each adapter is paired with a scope that, against the empty sandbox `_active.json`,
13
+ // resolves to zero targets so the adapter takes its empty-input branch. That keeps
14
+ // the dispatch contract test isolated from real screenshot/render integration
15
+ // (those land alongside each adapter's own unit tests).
16
+ const FORMATS = [
17
+ // PNG/PDF/SVG/HTML/PPTX/Canva expect element targets — `canvas-as-separate`
18
+ // against `active: null` resolves to [] → adapters short-circuit.
19
+ { format: 'png', scope: 'canvas-as-separate', expectedExt: '.png', expectedMime: 'image/png' },
20
+ {
21
+ format: 'pdf',
22
+ scope: 'canvas-as-separate',
23
+ expectedExt: '.pdf',
24
+ expectedMime: 'application/pdf',
25
+ },
26
+ {
27
+ format: 'svg',
28
+ scope: 'canvas-as-separate',
29
+ expectedExt: '.svg',
30
+ expectedMime: 'image/svg+xml',
31
+ },
32
+ {
33
+ format: 'html',
34
+ scope: 'canvas-as-separate',
35
+ expectedExt: '.zip',
36
+ expectedMime: 'application/zip',
37
+ },
38
+ {
39
+ format: 'pptx',
40
+ scope: 'canvas-as-separate',
41
+ expectedExt: '.pptx',
42
+ expectedMime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
43
+ },
44
+ {
45
+ format: 'canva',
46
+ scope: 'canvas-as-separate',
47
+ expectedExt: '.zip',
48
+ expectedMime: 'application/zip',
49
+ },
50
+ // ZIP consumes `project-raw` — designRoot walk over the sandbox returns at
51
+ // least the fixture file.
52
+ { format: 'zip', scope: 'project-raw', expectedExt: '.zip', expectedMime: 'application/zip' },
53
+ ] as const;
54
+
55
+ describe('/_api/export — endpoint dispatch', () => {
56
+ for (const { format, scope, expectedExt, expectedMime } of FORMATS) {
57
+ test(`POST format=${format} scope=${scope} returns 200 + ${expectedMime}`, async () => {
58
+ const { root } = makeSandbox();
59
+ const port = nextPort();
60
+ const proc = await bootServer(root, port);
61
+ try {
62
+ const r = await fetch(`http://localhost:${port}/_api/export`, {
63
+ method: 'POST',
64
+ headers: { 'content-type': 'application/json' },
65
+ body: JSON.stringify({ format, scope }),
66
+ });
67
+ expect(r.status).toBe(200);
68
+ expect(r.headers.get('Content-Type')).toBe(expectedMime);
69
+ const disp = r.headers.get('Content-Disposition') ?? '';
70
+ expect(disp).toMatch(/^attachment; filename=/);
71
+ expect(disp.endsWith(`${expectedExt}"`)).toBe(true);
72
+ } finally {
73
+ await killProc(proc);
74
+ }
75
+ });
76
+ }
77
+
78
+ test('rejects unknown format with 400', async () => {
79
+ const { root } = makeSandbox();
80
+ const port = nextPort();
81
+ const proc = await bootServer(root, port);
82
+ try {
83
+ const r = await fetch(`http://localhost:${port}/_api/export`, {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/json' },
86
+ body: JSON.stringify({ format: 'jpg', scope: 'project-raw' }),
87
+ });
88
+ expect(r.status).toBe(400);
89
+ } finally {
90
+ await killProc(proc);
91
+ }
92
+ });
93
+
94
+ test('rejects unknown scope with 400', async () => {
95
+ const { root } = makeSandbox();
96
+ const port = nextPort();
97
+ const proc = await bootServer(root, port);
98
+ try {
99
+ const r = await fetch(`http://localhost:${port}/_api/export`, {
100
+ method: 'POST',
101
+ headers: { 'content-type': 'application/json' },
102
+ body: JSON.stringify({ format: 'png', scope: 'everything' }),
103
+ });
104
+ expect(r.status).toBe(400);
105
+ } finally {
106
+ await killProc(proc);
107
+ }
108
+ });
109
+
110
+ test('rejects non-POST with 405', async () => {
111
+ const { root } = makeSandbox();
112
+ const port = nextPort();
113
+ const proc = await bootServer(root, port);
114
+ try {
115
+ const r = await fetch(`http://localhost:${port}/_api/export`);
116
+ expect(r.status).toBe(405);
117
+ } finally {
118
+ await killProc(proc);
119
+ }
120
+ });
121
+ });
@@ -0,0 +1,79 @@
1
+ // Phase 6.5 T10 — export history persistence + endpoint.
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { bootServer, killProc, makeSandbox, nextPort } from '../_helpers.ts';
9
+
10
+ interface HistoryShape {
11
+ history: Array<{ format: string; scope: string; filename: string; at: string }>;
12
+ }
13
+
14
+ describe('/_api/export-history — GET', () => {
15
+ test('returns empty list on fresh sandbox', async () => {
16
+ const { root } = makeSandbox();
17
+ const port = nextPort();
18
+ const proc = await bootServer(root, port);
19
+ try {
20
+ const r = await fetch(`http://localhost:${port}/_api/export-history`);
21
+ expect(r.status).toBe(200);
22
+ const body = (await r.json()) as HistoryShape;
23
+ expect(body.history).toEqual([]);
24
+ } finally {
25
+ await killProc(proc);
26
+ }
27
+ });
28
+
29
+ test('POST /_api/export appends a history entry, GET surfaces it', async () => {
30
+ const { root, designRoot } = makeSandbox();
31
+ const port = nextPort();
32
+ const proc = await bootServer(root, port);
33
+ try {
34
+ // Trigger a project-raw zip export (resolver returns file-tree → zip
35
+ // adapter consumes; no Playwright dependency).
36
+ const exp = await fetch(`http://localhost:${port}/_api/export`, {
37
+ method: 'POST',
38
+ headers: { 'content-type': 'application/json' },
39
+ body: JSON.stringify({ format: 'zip', scope: 'project-raw' }),
40
+ });
41
+ expect(exp.status).toBe(200);
42
+ // Read-back via the dedicated endpoint.
43
+ const r = await fetch(`http://localhost:${port}/_api/export-history`);
44
+ const body = (await r.json()) as HistoryShape;
45
+ expect(body.history.length).toBe(1);
46
+ expect(body.history[0].format).toBe('zip');
47
+ expect(body.history[0].scope).toBe('project-raw');
48
+ expect(body.history[0].filename).toMatch(/\.zip$/);
49
+ // On-disk file persists across server restarts (writes during request).
50
+ const onDisk = JSON.parse(
51
+ readFileSync(join(designRoot, '_export-history.json'), 'utf8')
52
+ ) as HistoryShape['history'];
53
+ expect(onDisk.length).toBe(1);
54
+ } finally {
55
+ await killProc(proc);
56
+ }
57
+ });
58
+
59
+ test('caps to 5 most-recent entries', async () => {
60
+ const { root } = makeSandbox();
61
+ const port = nextPort();
62
+ const proc = await bootServer(root, port);
63
+ try {
64
+ for (let i = 0; i < 7; i += 1) {
65
+ const r = await fetch(`http://localhost:${port}/_api/export`, {
66
+ method: 'POST',
67
+ headers: { 'content-type': 'application/json' },
68
+ body: JSON.stringify({ format: 'zip', scope: 'project-raw' }),
69
+ });
70
+ expect(r.status).toBe(200);
71
+ }
72
+ const r = await fetch(`http://localhost:${port}/_api/export-history`);
73
+ const body = (await r.json()) as HistoryShape;
74
+ expect(body.history.length).toBe(5);
75
+ } finally {
76
+ await killProc(proc);
77
+ }
78
+ });
79
+ });
@@ -0,0 +1,26 @@
1
+ // Phase 6.5 T5 — HTML adapter contract tests.
2
+
3
+ import { describe, expect, test } from 'bun:test';
4
+
5
+ import { run } from '../../exporters/html.ts';
6
+
7
+ const CTX = {
8
+ designRoot: '/tmp/.design',
9
+ repoRoot: '/tmp',
10
+ serverOrigin: 'http://localhost:0',
11
+ };
12
+
13
+ describe('html adapter — contract', () => {
14
+ test('empty targets → zero-byte ZIP placeholder', async () => {
15
+ const r = await run([], {}, CTX);
16
+ expect(r.contentType).toBe('application/zip');
17
+ expect(r.body.byteLength).toBe(0);
18
+ expect(r.filename.endsWith('.zip')).toBe(true);
19
+ });
20
+
21
+ test('file-tree targets → throws', async () => {
22
+ await expect(run([{ kind: 'file-tree', paths: ['ui/Home.tsx'] }], {}, CTX)).rejects.toThrow(
23
+ /element targets/i
24
+ );
25
+ });
26
+ });
@@ -0,0 +1,53 @@
1
+ // Phase 6.5 T3 — PDF adapter unit tests.
2
+ //
3
+ // Real PNG capture is integration-shape — covered by the export scenario.
4
+ // Here we cover the empty-input + file-tree-rejection contract, and the
5
+ // raster→pdf assembly itself by feeding a known PNG into the gather path
6
+ // via a synthetic single-target run that short-circuits screenshot.
7
+
8
+ import { describe, expect, test } from 'bun:test';
9
+
10
+ import { PDFDocument } from 'pdf-lib';
11
+ import { run } from '../../exporters/pdf.ts';
12
+
13
+ const CTX = {
14
+ designRoot: '/tmp/.design',
15
+ repoRoot: '/tmp',
16
+ serverOrigin: 'http://localhost:0',
17
+ };
18
+
19
+ describe('pdf adapter — contract', () => {
20
+ test('empty targets → zero-byte PDF placeholder', async () => {
21
+ const r = await run([], {}, CTX);
22
+ expect(r.contentType).toBe('application/pdf');
23
+ expect(r.body.byteLength).toBe(0);
24
+ expect(r.filename.endsWith('.pdf')).toBe(true);
25
+ });
26
+
27
+ test('file-tree targets → throws', async () => {
28
+ await expect(run([{ kind: 'file-tree', paths: ['ui/Home.tsx'] }], {}, CTX)).rejects.toThrow(
29
+ /element targets/i
30
+ );
31
+ });
32
+ });
33
+
34
+ describe('pdf-lib smoke — embed + save', () => {
35
+ test('embeds a PNG and produces a valid PDF buffer', async () => {
36
+ const pdf = await PDFDocument.create();
37
+ // 1x1 transparent PNG, hand-encoded — keeps the test hermetic.
38
+ const png = Uint8Array.from(
39
+ Buffer.from(
40
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=',
41
+ 'base64'
42
+ )
43
+ );
44
+ const img = await pdf.embedPng(png);
45
+ expect(img.width).toBe(1);
46
+ expect(img.height).toBe(1);
47
+ const page = pdf.addPage([100, 100]);
48
+ page.drawImage(img, { x: 0, y: 0, width: 100, height: 100 });
49
+ const bytes = await pdf.save();
50
+ // PDF magic — every spec-conformant PDF starts with `%PDF-`.
51
+ expect(new TextDecoder().decode(bytes.slice(0, 5))).toBe('%PDF-');
52
+ });
53
+ });
@@ -0,0 +1,32 @@
1
+ // Phase 6.5 T2 — PNG adapter unit tests.
2
+ //
3
+ // Skips the real `screenshot.sh` invocation — that path lands as a scenario
4
+ // under `.ai/scenarios/export-from-toolbar/` (T2 plan §Validate). Here we
5
+ // cover the contract-shape branches:
6
+ // - empty target list → zero-byte placeholder
7
+ // - file-tree-only targets → throws (PNG adapter rejects)
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import { run } from '../../exporters/png.ts';
12
+
13
+ const CTX = {
14
+ designRoot: '/tmp/.design',
15
+ repoRoot: '/tmp',
16
+ serverOrigin: 'http://localhost:0',
17
+ };
18
+
19
+ describe('png adapter — contract', () => {
20
+ test('empty targets → zero-byte PNG placeholder', async () => {
21
+ const r = await run([], {}, CTX);
22
+ expect(r.contentType).toBe('image/png');
23
+ expect(r.body.byteLength).toBe(0);
24
+ expect(r.filename.endsWith('.png')).toBe(true);
25
+ });
26
+
27
+ test('file-tree targets → throws (PNG cannot render a project)', async () => {
28
+ await expect(run([{ kind: 'file-tree', paths: ['ui/Home.tsx'] }], {}, CTX)).rejects.toThrow(
29
+ /element targets/i
30
+ );
31
+ });
32
+ });
@@ -0,0 +1,31 @@
1
+ // Phase 6.5 T6b — PPTX adapter contract.
2
+ //
3
+ // dom-to-pptx runs inside playwright + the headless browser; real conversion
4
+ // is integration-shape (covered by the export scenario). Here we cover empty
5
+ // targets + file-tree rejection so the API surface stays guarded.
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import { run } from '../../exporters/pptx.ts';
10
+
11
+ const CTX = {
12
+ designRoot: '/tmp/.design',
13
+ repoRoot: '/tmp',
14
+ serverOrigin: 'http://localhost:0',
15
+ };
16
+
17
+ describe('pptx adapter — contract', () => {
18
+ test('empty targets → zero-byte PPTX placeholder', async () => {
19
+ const r = await run([], {}, CTX);
20
+ expect(r.contentType).toBe(
21
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
22
+ );
23
+ expect(r.body.byteLength).toBe(0);
24
+ });
25
+
26
+ test('file-tree targets → throws', async () => {
27
+ await expect(run([{ kind: 'file-tree', paths: ['ui/Home.tsx'] }], {}, CTX)).rejects.toThrow(
28
+ /element targets/i
29
+ );
30
+ });
31
+ });
@@ -0,0 +1,29 @@
1
+ // Phase 6.5 T4 — SVG adapter contract tests.
2
+ //
3
+ // Real Playwright walk is integration-shape (lands as scenario). Here we
4
+ // cover empty-input + file-tree-rejection.
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { run } from '../../exporters/svg.ts';
9
+
10
+ const CTX = {
11
+ designRoot: '/tmp/.design',
12
+ repoRoot: '/tmp',
13
+ serverOrigin: 'http://localhost:0',
14
+ };
15
+
16
+ describe('svg adapter — contract', () => {
17
+ test('empty targets → zero-byte SVG placeholder', async () => {
18
+ const r = await run([], {}, CTX);
19
+ expect(r.contentType).toBe('image/svg+xml');
20
+ expect(r.body.byteLength).toBe(0);
21
+ expect(r.filename.endsWith('.svg')).toBe(true);
22
+ });
23
+
24
+ test('file-tree targets → throws', async () => {
25
+ await expect(run([{ kind: 'file-tree', paths: ['ui/Home.tsx'] }], {}, CTX)).rejects.toThrow(
26
+ /element targets/i
27
+ );
28
+ });
29
+ });
@@ -0,0 +1,105 @@
1
+ // Phase 6.5 T7 — project-raw ZIP adapter tests.
2
+ //
3
+ // Real walk + bundle against a sandboxed designRoot, then unzip the result
4
+ // and diff against expected contents.
5
+
6
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+ import JSZip from 'jszip';
12
+
13
+ import { resolveScope } from '../../exporters/scope.ts';
14
+ import { run } from '../../exporters/zip.ts';
15
+
16
+ function setupTree(): { root: string; designRoot: string } {
17
+ const root = mkdtempSync(join(tmpdir(), 'zip-adapter-'));
18
+ const designRoot = join(root, '.design');
19
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
20
+ mkdirSync(join(designRoot, 'system', 'project'), { recursive: true });
21
+ mkdirSync(join(designRoot, '_history', 'old'), { recursive: true });
22
+ writeFileSync(join(designRoot, 'config.json'), '{"name":"x"}');
23
+ writeFileSync(join(designRoot, 'README.md'), '# proj');
24
+ writeFileSync(join(designRoot, 'ui', 'Home.tsx'), 'export default ()=>null');
25
+ writeFileSync(join(designRoot, 'system', 'project', 'README.md'), '# ds');
26
+ writeFileSync(join(designRoot, '_history', 'old', 'snap.tsx'), '// snap');
27
+ writeFileSync(join(designRoot, '.DS_Store'), ' ');
28
+ return { root, designRoot };
29
+ }
30
+
31
+ describe('zip adapter — project-raw', () => {
32
+ test('bundles designRoot and excludes runtime artefacts by default', async () => {
33
+ const { root, designRoot } = setupTree();
34
+ const targets = await resolveScope({
35
+ scope: 'project-raw',
36
+ activeJson: { active: null, selected: null },
37
+ designRoot,
38
+ repoRoot: root,
39
+ });
40
+ const r = await run(targets, {}, { designRoot, repoRoot: root, serverOrigin: '' });
41
+ expect(r.contentType).toBe('application/zip');
42
+ expect(r.body.byteLength).toBeGreaterThan(0);
43
+
44
+ const unzipped = await JSZip.loadAsync(r.body);
45
+ const names = Object.keys(unzipped.files).sort();
46
+ expect(names).toContain('config.json');
47
+ expect(names).toContain('README.md');
48
+ expect(names).toContain('ui/Home.tsx');
49
+ expect(names).toContain('system/project/README.md');
50
+ expect(names).not.toContain('.DS_Store');
51
+ expect(names.every((n) => !n.startsWith('_history/'))).toBe(true);
52
+ });
53
+
54
+ test('options.exclude prunes additional paths', async () => {
55
+ const { root, designRoot } = setupTree();
56
+ const targets = await resolveScope({
57
+ scope: 'project-raw',
58
+ activeJson: { active: null, selected: null },
59
+ designRoot,
60
+ repoRoot: root,
61
+ });
62
+ const r = await run(
63
+ targets,
64
+ { exclude: ['ui/**'] },
65
+ { designRoot, repoRoot: root, serverOrigin: '' }
66
+ );
67
+ const unzipped = await JSZip.loadAsync(r.body);
68
+ const names = Object.keys(unzipped.files);
69
+ expect(names.every((n) => !n.startsWith('ui/'))).toBe(true);
70
+ expect(names).toContain('system/project/README.md');
71
+ });
72
+
73
+ test('options.include narrows to a single subtree', async () => {
74
+ const { root, designRoot } = setupTree();
75
+ const targets = await resolveScope({
76
+ scope: 'project-raw',
77
+ activeJson: { active: null, selected: null },
78
+ designRoot,
79
+ repoRoot: root,
80
+ });
81
+ const r = await run(
82
+ targets,
83
+ { include: ['system'] },
84
+ { designRoot, repoRoot: root, serverOrigin: '' }
85
+ );
86
+ const unzipped = await JSZip.loadAsync(r.body);
87
+ const names = Object.keys(unzipped.files);
88
+ expect(names.every((n) => n.startsWith('system/'))).toBe(true);
89
+ });
90
+
91
+ test('empty targets → zero-byte ZIP placeholder', async () => {
92
+ const r = await run([], {}, { designRoot: '/tmp/.design', repoRoot: '/tmp', serverOrigin: '' });
93
+ expect(r.body.byteLength).toBe(0);
94
+ });
95
+
96
+ test('element targets → throws', async () => {
97
+ await expect(
98
+ run(
99
+ [{ kind: 'element', cssPath: '.x', canvasSlug: 'x', file: 'ui/x.tsx' }],
100
+ {},
101
+ { designRoot: '/tmp/.design', repoRoot: '/tmp', serverOrigin: '' }
102
+ )
103
+ ).rejects.toThrow(/file-tree targets/i);
104
+ });
105
+ });