@1agh/maude 0.15.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 (53) hide show
  1. package/README.md +4 -2
  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 +227 -3
  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 +111 -9
  15. package/plugins/design/dev-server/client/app.jsx +71 -143
  16. package/plugins/design/dev-server/client/comments-overlay.css +381 -0
  17. package/plugins/design/dev-server/client/styles/3-shell.css +1 -10
  18. package/plugins/design/dev-server/client/styles/4-components.css +5 -161
  19. package/plugins/design/dev-server/client/styles.css +5 -160
  20. package/plugins/design/dev-server/comments-overlay.tsx +1156 -0
  21. package/plugins/design/dev-server/context-menu.tsx +36 -9
  22. package/plugins/design/dev-server/dist/client.bundle.js +52 -211
  23. package/plugins/design/dev-server/dist/styles.css +1 -218
  24. package/plugins/design/dev-server/export-dialog.tsx +401 -0
  25. package/plugins/design/dev-server/exporters/_browser-bundles.ts +89 -0
  26. package/plugins/design/dev-server/exporters/canva-handoff-prompt.ts +74 -0
  27. package/plugins/design/dev-server/exporters/canva.ts +126 -0
  28. package/plugins/design/dev-server/exporters/html.ts +103 -0
  29. package/plugins/design/dev-server/exporters/index.ts +135 -0
  30. package/plugins/design/dev-server/exporters/pdf.ts +109 -0
  31. package/plugins/design/dev-server/exporters/png.ts +136 -0
  32. package/plugins/design/dev-server/exporters/pptx.ts +263 -0
  33. package/plugins/design/dev-server/exporters/scope.ts +196 -0
  34. package/plugins/design/dev-server/exporters/svg.ts +122 -0
  35. package/plugins/design/dev-server/exporters/zip.ts +109 -0
  36. package/plugins/design/dev-server/http.ts +109 -0
  37. package/plugins/design/dev-server/input-router.tsx +21 -0
  38. package/plugins/design/dev-server/inspect.ts +1 -1
  39. package/plugins/design/dev-server/server.mjs +1 -1
  40. package/plugins/design/dev-server/test/canvas-meta-api.test.ts +0 -10
  41. package/plugins/design/dev-server/test/comments-api.test.ts +229 -0
  42. package/plugins/design/dev-server/test/exporters/canva.test.ts +64 -0
  43. package/plugins/design/dev-server/test/exporters/endpoint.test.ts +121 -0
  44. package/plugins/design/dev-server/test/exporters/history.test.ts +79 -0
  45. package/plugins/design/dev-server/test/exporters/html.test.ts +26 -0
  46. package/plugins/design/dev-server/test/exporters/pdf.test.ts +53 -0
  47. package/plugins/design/dev-server/test/exporters/png.test.ts +32 -0
  48. package/plugins/design/dev-server/test/exporters/pptx.test.ts +31 -0
  49. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  50. package/plugins/design/dev-server/test/exporters/svg.test.ts +29 -0
  51. package/plugins/design/dev-server/test/exporters/zip.test.ts +105 -0
  52. package/plugins/design/dev-server/tool-palette.tsx +34 -16
  53. package/plugins/design/templates/_shell.html +33 -0
@@ -0,0 +1,229 @@
1
+ // Phase 6 — comments API extensions: author / thread / mentions schema,
2
+ // POST /_api/comments/<id>/reply, GET /_api/git-committers.
3
+ //
4
+ // Verifies:
5
+ // - New comments include `author`, `thread`, `mentions`
6
+ // - parseMentions in `text` lands in `mentions[]`
7
+ // - POST .../reply appends to thread + folds reply mentions into the union
8
+ // - Legacy comments (no author/thread/mentions on disk) round-trip with
9
+ // defaults filled in memory; disk shape stays untouched until next write
10
+ // - GET /_api/git-committers returns the committer list (≥1 entry in a real
11
+ // git sandbox; gracefully empty if git fails)
12
+ // - Reply on unknown id → 404
13
+
14
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+
19
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
20
+
21
+ async function initGit(root: string) {
22
+ // Make the sandbox a tiny git repo so `git config user.name` + `git shortlog`
23
+ // can answer. The boot path doesn't need a real repo, but the gitCommitters /
24
+ // gitCurrentUser helpers do.
25
+ //
26
+ // GIT_CONFIG_GLOBAL=/dev/null + GIT_CONFIG_SYSTEM=/dev/null isolate the
27
+ // sandbox from the developer's global git config (which may require GPG
28
+ // signing, set a non-test identity, or otherwise interfere with the
29
+ // throwaway commit). This is repo isolation, not a sign-bypass on a real
30
+ // commit — the test never produces an artifact outside its temp dir.
31
+ const env = {
32
+ ...process.env,
33
+ GIT_TERMINAL_PROMPT: '0',
34
+ GIT_CONFIG_GLOBAL: '/dev/null',
35
+ GIT_CONFIG_SYSTEM: '/dev/null',
36
+ };
37
+ await Bun.spawn(['git', 'init', '-q'], { cwd: root, env }).exited;
38
+ await Bun.spawn(['git', 'config', 'user.email', 'tester@example.com'], { cwd: root, env }).exited;
39
+ await Bun.spawn(['git', 'config', 'user.name', 'Test User'], { cwd: root, env }).exited;
40
+ writeFileSync(join(root, 'README.md'), '# sandbox\n');
41
+ await Bun.spawn(['git', 'add', '.'], { cwd: root, env }).exited;
42
+ await Bun.spawn(['git', 'commit', '-q', '-m', 'init'], { cwd: root, env }).exited;
43
+ }
44
+
45
+ describe('Phase 6 — comments author + thread + mentions', () => {
46
+ test('commentsAdd populates author + empty thread + parsed mentions', async () => {
47
+ const { root, designRoot } = makeSandbox();
48
+ await initGit(root);
49
+ const port = nextPort();
50
+ const proc = await bootServer(root, port);
51
+ try {
52
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
53
+ writeFileSync(
54
+ join(designRoot, 'ui', 'Foo.tsx'),
55
+ 'export default function P(){return <main/>}\n'
56
+ );
57
+ const r = await fetch(`http://localhost:${port}/_comments-all`);
58
+ expect(r.status).toBe(200);
59
+
60
+ // POST through the WS path is the normal route, but for unit-coverage we
61
+ // use the file-system view: write a comment via the http endpoint
62
+ // doesn't exist (commentsAdd is WS-driven). Instead, hand-author a
63
+ // single comment via filesystem + read it back to verify the loader
64
+ // backfill, then exercise reply via /_api/comments/<id>/reply.
65
+ const slug = 'ui-foo';
66
+ mkdirSync(join(designRoot, '_comments'), { recursive: true });
67
+ const legacy = [
68
+ {
69
+ id: 'c_legacy0',
70
+ file: '.design/ui/Foo.tsx',
71
+ selector: 'main',
72
+ dom_path: ['main'],
73
+ tag: 'main',
74
+ classes: '',
75
+ bounds: null,
76
+ html_excerpt: '',
77
+ text: 'old comment',
78
+ status: 'open',
79
+ created: '2026-01-01T00:00:00.000Z',
80
+ resolved_at: null,
81
+ // no author / thread / mentions
82
+ },
83
+ ];
84
+ writeFileSync(join(designRoot, '_comments', `${slug}.json`), JSON.stringify(legacy, null, 2));
85
+
86
+ const list = await fetch(
87
+ `http://localhost:${port}/_comments?file=${encodeURIComponent('.design/ui/Foo.tsx')}`
88
+ ).then((x) => x.json());
89
+ expect(list.comments).toHaveLength(1);
90
+ expect(list.comments[0].author).toBe('');
91
+ expect(list.comments[0].thread).toEqual([]);
92
+ expect(list.comments[0].mentions).toEqual([]);
93
+
94
+ // Disk shape preserved — legacy file not rewritten on read.
95
+ const onDisk = JSON.parse(
96
+ readFileSync(join(designRoot, '_comments', `${slug}.json`), 'utf8')
97
+ );
98
+ expect(onDisk[0].author).toBeUndefined();
99
+ expect(onDisk[0].thread).toBeUndefined();
100
+ } finally {
101
+ await killProc(proc);
102
+ }
103
+ });
104
+
105
+ test('POST /_api/comments/<id>/reply appends to thread + folds mentions', async () => {
106
+ const { root, designRoot } = makeSandbox();
107
+ await initGit(root);
108
+ const port = nextPort();
109
+ const proc = await bootServer(root, port);
110
+ try {
111
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
112
+ writeFileSync(
113
+ join(designRoot, 'ui', 'Bar.tsx'),
114
+ 'export default function P(){return <main/>}\n'
115
+ );
116
+ mkdirSync(join(designRoot, '_comments'), { recursive: true });
117
+ const seed = [
118
+ {
119
+ id: 'c_seed01',
120
+ file: '.design/ui/Bar.tsx',
121
+ selector: 'button.cta',
122
+ dom_path: ['main', 'button.cta'],
123
+ tag: 'button',
124
+ classes: 'cta',
125
+ bounds: { x: 10, y: 20, w: 100, h: 40 },
126
+ html_excerpt: '<button class="cta">x</button>',
127
+ text: 'needs more padding @ada',
128
+ status: 'open',
129
+ created: '2026-05-20T10:00:00.000Z',
130
+ resolved_at: null,
131
+ author: 'Original Author',
132
+ thread: [],
133
+ mentions: ['@ada'],
134
+ },
135
+ ];
136
+ writeFileSync(join(designRoot, '_comments', 'ui-bar.json'), JSON.stringify(seed, null, 2));
137
+
138
+ const reply = await fetch(`http://localhost:${port}/_api/comments/c_seed01/reply`, {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({ body: 'fixed in next pass @lin', author: 'Replier' }),
142
+ });
143
+ expect(reply.status).toBe(200);
144
+ const updated = await reply.json();
145
+ expect(updated.thread).toHaveLength(1);
146
+ expect(updated.thread[0].author).toBe('Replier');
147
+ expect(updated.thread[0].body).toBe('fixed in next pass @lin');
148
+ expect(updated.thread[0].id).toMatch(/^r_[0-9a-f]+$/);
149
+ expect(new Set(updated.mentions)).toEqual(new Set(['@ada', '@lin']));
150
+
151
+ // Persisted to disk in the v2 shape.
152
+ const onDisk = JSON.parse(readFileSync(join(designRoot, '_comments', 'ui-bar.json'), 'utf8'));
153
+ expect(onDisk[0].thread).toHaveLength(1);
154
+ expect(onDisk[0].thread[0].body).toBe('fixed in next pass @lin');
155
+ expect(new Set(onDisk[0].mentions)).toEqual(new Set(['@ada', '@lin']));
156
+ } finally {
157
+ await killProc(proc);
158
+ }
159
+ });
160
+
161
+ test('POST /_api/comments/<id>/reply 404s on unknown id', async () => {
162
+ const { root } = makeSandbox();
163
+ await initGit(root);
164
+ const port = nextPort();
165
+ const proc = await bootServer(root, port);
166
+ try {
167
+ const r = await fetch(`http://localhost:${port}/_api/comments/c_ghost/reply`, {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json' },
170
+ body: JSON.stringify({ body: 'orphan' }),
171
+ });
172
+ expect(r.status).toBe(404);
173
+ } finally {
174
+ await killProc(proc);
175
+ }
176
+ });
177
+
178
+ test('POST /_api/comments/<id>/reply 400 on empty body', async () => {
179
+ const { root } = makeSandbox();
180
+ await initGit(root);
181
+ const port = nextPort();
182
+ const proc = await bootServer(root, port);
183
+ try {
184
+ const r = await fetch(`http://localhost:${port}/_api/comments/c_anyid/reply`, {
185
+ method: 'POST',
186
+ headers: { 'Content-Type': 'application/json' },
187
+ body: JSON.stringify({ body: ' ' }),
188
+ });
189
+ expect(r.status).toBe(400);
190
+ } finally {
191
+ await killProc(proc);
192
+ }
193
+ });
194
+
195
+ test('GET /_api/git-committers returns the committer list', async () => {
196
+ const { root } = makeSandbox();
197
+ await initGit(root);
198
+ const port = nextPort();
199
+ const proc = await bootServer(root, port);
200
+ try {
201
+ const r = await fetch(`http://localhost:${port}/_api/git-committers`);
202
+ expect(r.status).toBe(200);
203
+ const data = await r.json();
204
+ expect(Array.isArray(data.committers)).toBe(true);
205
+ // The sandbox just made one commit, so at least one committer should exist.
206
+ expect(data.committers.length).toBeGreaterThanOrEqual(1);
207
+ expect(data.committers[0]).toMatchObject({
208
+ name: 'Test User',
209
+ email: 'tester@example.com',
210
+ });
211
+ expect(data.committers[0].commits).toBeGreaterThanOrEqual(1);
212
+ } finally {
213
+ await killProc(proc);
214
+ }
215
+ });
216
+
217
+ test('GET /_api/git-committers 405 on POST', async () => {
218
+ const { root } = makeSandbox();
219
+ await initGit(root);
220
+ const port = nextPort();
221
+ const proc = await bootServer(root, port);
222
+ try {
223
+ const r = await fetch(`http://localhost:${port}/_api/git-committers`, { method: 'POST' });
224
+ expect(r.status).toBe(405);
225
+ } finally {
226
+ await killProc(proc);
227
+ }
228
+ });
229
+ });
@@ -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
+ });