@1agh/maude 0.16.0 → 0.17.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/cli/cli-wrapper.cjs +0 -0
- package/cli/commands/design.mjs +264 -16
- package/package.json +12 -18
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +8 -8
- package/plugins/design/dev-server/annotations-layer.tsx +8 -10
- package/plugins/design/dev-server/api.ts +41 -0
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +40 -0
- package/plugins/design/dev-server/bin/_html-playwright.mjs +129 -0
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +105 -0
- package/plugins/design/dev-server/bin/_png-playwright.mjs +143 -0
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +98 -0
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +141 -0
- package/plugins/design/dev-server/build.ts +118 -6
- package/plugins/design/dev-server/canvas-lib.tsx +12 -13
- package/plugins/design/dev-server/canvas-pipeline.ts +5 -0
- package/plugins/design/dev-server/canvas-shell.tsx +32 -4
- package/plugins/design/dev-server/client/app.jsx +18 -1
- package/plugins/design/dev-server/context-menu.tsx +36 -9
- package/plugins/design/dev-server/dist/client.bundle.js +11 -3
- package/plugins/design/dev-server/export-dialog.tsx +401 -0
- package/plugins/design/dev-server/exporters/_browser-bundles.ts +89 -0
- package/plugins/design/dev-server/exporters/canva-handoff-prompt.ts +74 -0
- package/plugins/design/dev-server/exporters/canva.ts +126 -0
- package/plugins/design/dev-server/exporters/html.ts +103 -0
- package/plugins/design/dev-server/exporters/index.ts +135 -0
- package/plugins/design/dev-server/exporters/pdf.ts +109 -0
- package/plugins/design/dev-server/exporters/png.ts +136 -0
- package/plugins/design/dev-server/exporters/pptx.ts +263 -0
- package/plugins/design/dev-server/exporters/scope.ts +196 -0
- package/plugins/design/dev-server/exporters/svg.ts +122 -0
- package/plugins/design/dev-server/exporters/zip.ts +109 -0
- package/plugins/design/dev-server/http.ts +80 -0
- package/plugins/design/dev-server/inspect.ts +1 -1
- package/plugins/design/dev-server/server.mjs +1 -1
- package/plugins/design/dev-server/test/compile-entry.test.ts +134 -0
- package/plugins/design/dev-server/test/exporters/canva.test.ts +64 -0
- package/plugins/design/dev-server/test/exporters/endpoint.test.ts +121 -0
- package/plugins/design/dev-server/test/exporters/history.test.ts +79 -0
- package/plugins/design/dev-server/test/exporters/html.test.ts +26 -0
- package/plugins/design/dev-server/test/exporters/pdf.test.ts +53 -0
- package/plugins/design/dev-server/test/exporters/png.test.ts +32 -0
- package/plugins/design/dev-server/test/exporters/pptx.test.ts +31 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/exporters/svg.test.ts +29 -0
- package/plugins/design/dev-server/test/exporters/zip.test.ts +105 -0
- package/plugins/design/dev-server/tool-palette.tsx +34 -16
- 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-
|
|
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-
|
|
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,134 @@
|
|
|
1
|
+
// Regression test for writeCompileEntry — the per-target build helper that
|
|
2
|
+
// works around Bun 1.3.4+ --compile NAPI native-binding embedding (DDR-NNN-
|
|
3
|
+
// oxc-parser-bun-compile-workaround). The helper itself is pure (no compile,
|
|
4
|
+
// no spawn): given a target, it writes two files (`init-oxc-<slug>.ts` +
|
|
5
|
+
// `server-<slug>.ts`) under dist/.compile-entries/ and returns the entry path.
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from 'bun:test';
|
|
12
|
+
|
|
13
|
+
import { writeCompileEntry } from '../build.ts';
|
|
14
|
+
|
|
15
|
+
const ROOT = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const DEV_SERVER_ROOT = join(ROOT, '..');
|
|
17
|
+
const ENTRY_DIR = join(DEV_SERVER_ROOT, 'dist', '.compile-entries');
|
|
18
|
+
|
|
19
|
+
// PlatformTarget union mirrored from build.ts. Kept inline so the test fails
|
|
20
|
+
// loudly if build.ts adds/removes a target without updating the test.
|
|
21
|
+
const ALL_TARGETS = [
|
|
22
|
+
'bun-darwin-arm64',
|
|
23
|
+
'bun-darwin-x64',
|
|
24
|
+
'bun-linux-x64',
|
|
25
|
+
'bun-linux-arm64',
|
|
26
|
+
'bun-linux-x64-musl',
|
|
27
|
+
'bun-linux-arm64-musl',
|
|
28
|
+
'bun-windows-x64',
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
// Maude-slug → @oxc-parser/binding-<X> NAPI slug. Mirrored from
|
|
32
|
+
// build.ts:oxcBindingSlug — kept in sync intentionally.
|
|
33
|
+
const EXPECTED_OXC_SLUG: Record<string, string> = {
|
|
34
|
+
'darwin-arm64': 'darwin-arm64',
|
|
35
|
+
'darwin-x64': 'darwin-x64',
|
|
36
|
+
'linux-x64': 'linux-x64-gnu',
|
|
37
|
+
'linux-arm64': 'linux-arm64-gnu',
|
|
38
|
+
'linux-x64-musl': 'linux-x64-musl',
|
|
39
|
+
'linux-arm64-musl': 'linux-arm64-musl',
|
|
40
|
+
'win32-x64': 'win32-x64-msvc',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function maudeSlug(target: string): string {
|
|
44
|
+
const s = target.replace(/^bun-/, '');
|
|
45
|
+
return s === 'windows-x64' ? 'win32-x64' : s;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('writeCompileEntry', () => {
|
|
49
|
+
test('produces init + entry files for every supported target', () => {
|
|
50
|
+
for (const target of ALL_TARGETS) {
|
|
51
|
+
const slug = maudeSlug(target);
|
|
52
|
+
const initPath = join(ENTRY_DIR, `init-oxc-${slug}.ts`);
|
|
53
|
+
const entryPath = join(ENTRY_DIR, `server-${slug}.ts`);
|
|
54
|
+
|
|
55
|
+
const returned = writeCompileEntry(target);
|
|
56
|
+
expect(returned).toBe(entryPath);
|
|
57
|
+
expect(existsSync(initPath)).toBe(true);
|
|
58
|
+
expect(existsSync(entryPath)).toBe(true);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('init file embeds the matching oxc binding via with-type-file', () => {
|
|
63
|
+
for (const target of ALL_TARGETS) {
|
|
64
|
+
const slug = maudeSlug(target);
|
|
65
|
+
const oxcSlug = EXPECTED_OXC_SLUG[slug];
|
|
66
|
+
const initPath = join(ENTRY_DIR, `init-oxc-${slug}.ts`);
|
|
67
|
+
|
|
68
|
+
writeCompileEntry(target);
|
|
69
|
+
const content = readFileSync(initPath, 'utf8');
|
|
70
|
+
|
|
71
|
+
// Asset embed via Bun's with-type-file syntax — load-bearing for the
|
|
72
|
+
// workaround. Without `with { type: 'file' }` the import resolves to
|
|
73
|
+
// the .node's exports rather than its filesystem path.
|
|
74
|
+
expect(content).toContain(
|
|
75
|
+
`import bindingPath from "@oxc-parser/binding-${oxcSlug}/parser.${oxcSlug}.node" with { type: 'file' };`
|
|
76
|
+
);
|
|
77
|
+
// Env var must be set so oxc-parser's NAPI-RS loader (bindings.js)
|
|
78
|
+
// skips its broken platform-detection switch.
|
|
79
|
+
expect(content).toContain('process.env.NAPI_RS_NATIVE_LIBRARY_PATH = bindingPath;');
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('entry file imports init BEFORE server.ts (ESM evaluation order matters)', () => {
|
|
84
|
+
for (const target of ALL_TARGETS) {
|
|
85
|
+
const slug = maudeSlug(target);
|
|
86
|
+
const entryPath = join(ENTRY_DIR, `server-${slug}.ts`);
|
|
87
|
+
|
|
88
|
+
writeCompileEntry(target);
|
|
89
|
+
const content = readFileSync(entryPath, 'utf8');
|
|
90
|
+
|
|
91
|
+
const initIdx = content.indexOf(`./init-oxc-${slug}.ts`);
|
|
92
|
+
const serverIdx = content.indexOf('server.ts');
|
|
93
|
+
expect(initIdx).toBeGreaterThanOrEqual(0);
|
|
94
|
+
expect(serverIdx).toBeGreaterThanOrEqual(0);
|
|
95
|
+
// If server.ts is imported before init-oxc, oxc-parser evaluates first
|
|
96
|
+
// and reads NAPI_RS_NATIVE_LIBRARY_PATH before our env-var setter runs.
|
|
97
|
+
expect(initIdx).toBeLessThan(serverIdx);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('entry uses POSIX path separators in the server.ts import', () => {
|
|
102
|
+
writeCompileEntry('bun-windows-x64');
|
|
103
|
+
const content = readFileSync(join(ENTRY_DIR, 'server-win32-x64.ts'), 'utf8');
|
|
104
|
+
// Even on a Windows host the generated import specifier must use forward
|
|
105
|
+
// slashes — ESM specifiers are not OS paths.
|
|
106
|
+
expect(content).not.toMatch(/\\/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('idempotent — calling twice yields identical content', () => {
|
|
110
|
+
const target = 'bun-darwin-arm64';
|
|
111
|
+
const slug = maudeSlug(target);
|
|
112
|
+
|
|
113
|
+
writeCompileEntry(target);
|
|
114
|
+
const a1 = readFileSync(join(ENTRY_DIR, `init-oxc-${slug}.ts`), 'utf8');
|
|
115
|
+
const e1 = readFileSync(join(ENTRY_DIR, `server-${slug}.ts`), 'utf8');
|
|
116
|
+
|
|
117
|
+
writeCompileEntry(target);
|
|
118
|
+
const a2 = readFileSync(join(ENTRY_DIR, `init-oxc-${slug}.ts`), 'utf8');
|
|
119
|
+
const e2 = readFileSync(join(ENTRY_DIR, `server-${slug}.ts`), 'utf8');
|
|
120
|
+
|
|
121
|
+
expect(a2).toBe(a1);
|
|
122
|
+
expect(e2).toBe(e1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('cleanup — generated files live under dist/.compile-entries/', () => {
|
|
126
|
+
// Sanity: the helper writes only to the expected directory; nothing
|
|
127
|
+
// leaks elsewhere. Sweep a stale prior dir, regenerate, verify scope.
|
|
128
|
+
rmSync(ENTRY_DIR, { recursive: true, force: true });
|
|
129
|
+
writeCompileEntry('bun-darwin-arm64');
|
|
130
|
+
expect(existsSync(ENTRY_DIR)).toBe(true);
|
|
131
|
+
expect(existsSync(join(ENTRY_DIR, 'init-oxc-darwin-arm64.ts'))).toBe(true);
|
|
132
|
+
expect(existsSync(join(ENTRY_DIR, 'server-darwin-arm64.ts'))).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -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
|
+
});
|
|
Binary file
|