@1agh/maude 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/cli/bin/maude.exe +15 -0
- package/cli/bin/maude.mjs +45 -0
- package/cli/bin/mdcc.exe +10 -0
- package/cli/bin/mdcc.mjs +7 -0
- package/cli/cli-wrapper.cjs +67 -0
- package/cli/commands/config.mjs +94 -0
- package/cli/commands/design.mjs +386 -0
- package/cli/commands/help.mjs +57 -0
- package/cli/commands/init.mjs +178 -0
- package/cli/commands/version.mjs +7 -0
- package/cli/install.cjs +113 -0
- package/cli/lib/argv.mjs +37 -0
- package/cli/lib/argv.test.mjs +46 -0
- package/cli/lib/copy-tree.mjs +78 -0
- package/package.json +94 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +397 -0
- package/plugins/design/dev-server/annotations-layer.tsx +1717 -0
- package/plugins/design/dev-server/api.ts +674 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +50 -0
- package/plugins/design/dev-server/bin/bootstrap-check.sh +83 -0
- package/plugins/design/dev-server/bin/canvas-edit.sh +48 -0
- package/plugins/design/dev-server/bin/handoff.sh +27 -0
- package/plugins/design/dev-server/bin/screenshot.sh +232 -0
- package/plugins/design/dev-server/bin/server-up.sh +135 -0
- package/plugins/design/dev-server/bin/slug.sh +22 -0
- package/plugins/design/dev-server/bin/smoke.sh +272 -0
- package/plugins/design/dev-server/build.ts +267 -0
- package/plugins/design/dev-server/canvas-build.ts +219 -0
- package/plugins/design/dev-server/canvas-edit.ts +388 -0
- package/plugins/design/dev-server/canvas-header.ts +165 -0
- package/plugins/design/dev-server/canvas-icons.tsx +131 -0
- package/plugins/design/dev-server/canvas-lib-inline.ts +260 -0
- package/plugins/design/dev-server/canvas-lib-resolver.ts +85 -0
- package/plugins/design/dev-server/canvas-lib.tsx +1995 -0
- package/plugins/design/dev-server/canvas-meta.schema.json +181 -0
- package/plugins/design/dev-server/canvas-pipeline.ts +270 -0
- package/plugins/design/dev-server/canvas-shell.tsx +813 -0
- package/plugins/design/dev-server/client/app.jsx +2027 -0
- package/plugins/design/dev-server/client/hmr.mjs +85 -0
- package/plugins/design/dev-server/client/iframe-lazy.mjs +121 -0
- package/plugins/design/dev-server/client/index.html +15 -0
- package/plugins/design/dev-server/client/styles/0-reset.css +18 -0
- package/plugins/design/dev-server/client/styles/1-tokens.css +297 -0
- package/plugins/design/dev-server/client/styles/2-layout.css +35 -0
- package/plugins/design/dev-server/client/styles/3-shell.css +906 -0
- package/plugins/design/dev-server/client/styles/4-components.css +1268 -0
- package/plugins/design/dev-server/client/styles/5-utilities.css +4 -0
- package/plugins/design/dev-server/client/styles/_index.css +24 -0
- package/plugins/design/dev-server/client/styles.css +1419 -0
- package/plugins/design/dev-server/config.schema.json +147 -0
- package/plugins/design/dev-server/context-menu.tsx +343 -0
- package/plugins/design/dev-server/context.ts +173 -0
- package/plugins/design/dev-server/dist/client.bundle.js +20323 -0
- package/plugins/design/dev-server/dist/styles.css +2875 -0
- package/plugins/design/dev-server/examples/README.md +9 -0
- package/plugins/design/dev-server/examples/perf-100-artboards.tsx +113 -0
- package/plugins/design/dev-server/fs-watch.ts +63 -0
- package/plugins/design/dev-server/handoff.ts +721 -0
- package/plugins/design/dev-server/history.ts +125 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +114 -0
- package/plugins/design/dev-server/http.ts +413 -0
- package/plugins/design/dev-server/input-router.tsx +485 -0
- package/plugins/design/dev-server/inspect.ts +365 -0
- package/plugins/design/dev-server/locator.ts +159 -0
- package/plugins/design/dev-server/mem.ts +97 -0
- package/plugins/design/dev-server/runtime-bundle.ts +235 -0
- package/plugins/design/dev-server/server.mjs +1246 -0
- package/plugins/design/dev-server/server.ts +131 -0
- package/plugins/design/dev-server/test/_helpers.ts +81 -0
- package/plugins/design/dev-server/test/active-state.test.ts +145 -0
- package/plugins/design/dev-server/test/annotations-api.test.ts +146 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +419 -0
- package/plugins/design/dev-server/test/binary-smoke.test.ts +47 -0
- package/plugins/design/dev-server/test/bundle-smoke.test.ts +29 -0
- package/plugins/design/dev-server/test/canvas-build.test.ts +78 -0
- package/plugins/design/dev-server/test/canvas-edit.test.ts +139 -0
- package/plugins/design/dev-server/test/canvas-header.test.ts +127 -0
- package/plugins/design/dev-server/test/canvas-lib-inline.test.ts +146 -0
- package/plugins/design/dev-server/test/canvas-lib-resolver.test.ts +112 -0
- package/plugins/design/dev-server/test/canvas-meta-api.test.ts +236 -0
- package/plugins/design/dev-server/test/canvas-pipeline.test.ts +180 -0
- package/plugins/design/dev-server/test/canvas-route.test.ts +176 -0
- package/plugins/design/dev-server/test/fs-watch.test.ts +41 -0
- package/plugins/design/dev-server/test/handoff-static-frames.test.ts +215 -0
- package/plugins/design/dev-server/test/handoff.test.ts +281 -0
- package/plugins/design/dev-server/test/history-rollback.test.ts +62 -0
- package/plugins/design/dev-server/test/hmr-broadcast.test.ts +108 -0
- package/plugins/design/dev-server/test/input-router.test.ts +316 -0
- package/plugins/design/dev-server/test/locator.test.ts +214 -0
- package/plugins/design/dev-server/test/perf-harness.ts +193 -0
- package/plugins/design/dev-server/test/phase-3.6-smoke.test.ts +77 -0
- package/plugins/design/dev-server/test/runtime-bundle.test.ts +69 -0
- package/plugins/design/dev-server/test/server-lifecycle.test.ts +28 -0
- package/plugins/design/dev-server/test/tool-palette.test.tsx +55 -0
- package/plugins/design/dev-server/test/use-annotation-selection.test.tsx +77 -0
- package/plugins/design/dev-server/test/use-artboard-drag.test.ts +325 -0
- package/plugins/design/dev-server/test/use-selection-set.test.tsx +166 -0
- package/plugins/design/dev-server/test/use-snap-guides.test.ts +190 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +93 -0
- package/plugins/design/dev-server/test/ws-handshake.test.ts +33 -0
- package/plugins/design/dev-server/tool-palette.tsx +278 -0
- package/plugins/design/dev-server/tsconfig.json +26 -0
- package/plugins/design/dev-server/use-annotation-selection.tsx +92 -0
- package/plugins/design/dev-server/use-annotations-visibility.tsx +43 -0
- package/plugins/design/dev-server/use-artboard-drag.tsx +445 -0
- package/plugins/design/dev-server/use-selection-set.tsx +224 -0
- package/plugins/design/dev-server/use-snap-guides.tsx +215 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +114 -0
- package/plugins/design/dev-server/ws.ts +90 -0
- package/plugins/design/templates/_shell.html +177 -0
- package/plugins/design/templates/canvas.tsx.template +54 -0
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +277 -0
- package/plugins/design/templates/design-system-inspiration/_README.md +71 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-banner.html +68 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-empty-state-generous.html +39 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-feature-grid.html +62 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-marketing-card.html +63 -0
- package/plugins/design/templates/design-system-inspiration/audience-consumer/components-testimonial.html +80 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-code-block.html +71 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-diff-view.html +65 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-log-stream.html +62 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-monospace-table.html +57 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/components-terminal-pane.html +67 -0
- package/plugins/design/templates/design-system-inspiration/audience-developer/type-mono.html +57 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/colors-presence.html +74 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-command-palette.html +90 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-keyboard.html +51 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-list.html +89 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-shortcuts-overlay.html +85 -0
- package/plugins/design/templates/design-system-inspiration/audience-pro/components-toast-menu.html +80 -0
- package/plugins/design/templates/design-system-inspiration/core/INDEX.md.tpl +25 -0
- package/plugins/design/templates/design-system-inspiration/core/README.orchestration.md.tpl +71 -0
- package/plugins/design/templates/design-system-inspiration/core/README.philosophy.md.tpl +77 -0
- package/plugins/design/templates/design-system-inspiration/core/SKILL.md.tpl +50 -0
- package/plugins/design/templates/design-system-inspiration/core/colors_and_type.css.tpl +111 -0
- package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +28 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/_layout.css +113 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-accent.html +48 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-surfaces.html +49 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/colors-text.html +52 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-buttons.html +83 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-cards.html +79 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/components-inputs.html +82 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/motion.html +66 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/spacing-scale.html +53 -0
- package/plugins/design/templates/design-system-inspiration/core/preview/type-scale.html +62 -0
- package/plugins/design/templates/design-system-inspiration/foundations/borders.html +39 -0
- package/plugins/design/templates/design-system-inspiration/foundations/elevation.html +46 -0
- package/plugins/design/templates/design-system-inspiration/foundations/focus.html +48 -0
- package/plugins/design/templates/design-system-inspiration/foundations/grid.html +51 -0
- package/plugins/design/templates/design-system-inspiration/foundations/iconography.html +73 -0
- package/plugins/design/templates/design-system-inspiration/foundations/opacity.html +45 -0
- package/plugins/design/templates/design-system-inspiration/foundations/radii.html +40 -0
- package/plugins/design/templates/design-system-inspiration/foundations/selection.html +45 -0
- package/plugins/design/templates/design-system-inspiration/meta/accessibility.html +62 -0
- package/plugins/design/templates/design-system-inspiration/meta/i18n.html +73 -0
- package/plugins/design/templates/design-system-inspiration/meta/presence-multiplayer.html +71 -0
- package/plugins/design/templates/design-system-inspiration/meta/tokens-index.html +80 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-auth.html +78 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-data-density.html +61 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-error-pages.html +70 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-form-layouts.html +70 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-onboarding.html +71 -0
- package/plugins/design/templates/design-system-inspiration/patterns/patterns-pricing.html +83 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/components-resize-panels.html +63 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-index.html +55 -0
- package/plugins/design/templates/design-system-inspiration/platform-desktop/ui_kits-desktop-showcase.html +302 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-bottom-sheet.html +63 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-pull-to-refresh.html +74 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-segmented-control.html +51 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/components-tab-bar.html +57 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-index.html +58 -0
- package/plugins/design/templates/design-system-inspiration/platform-mobile/ui_kits-mobile-showcase.html +237 -0
- package/plugins/design/templates/design-system-inspiration/status/colors-status.html +49 -0
- package/plugins/design/templates/design-system-inspiration/status/components-status.html +63 -0
- package/plugins/design/templates/design-system-inspiration/status/skeletons.html +74 -0
- package/plugins/design/templates/design-system-inspiration/theme-both/colors-themes-side-by-side.html +59 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-callout.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-dialogs.html +81 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-tables.html +101 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-toggles.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/components-tooltips.html +74 -0
- package/plugins/design/templates/design-system-inspiration/universal/empty-state.html.tpl +34 -0
- package/plugins/design/templates/design-system-inspiration/universal/logo.html +42 -0
- package/plugins/design/templates/ds-specimen.tsx.template +59 -0
- package/plugins/flow/.claude-plugin/config.schema.json +398 -0
- package/plugins/flow/README.md +45 -0
- package/plugins/flow/templates/ai-skeleton/INDEX.md +50 -0
- package/plugins/flow/templates/ai-skeleton/README.md +46 -0
- package/plugins/flow/templates/ai-skeleton/browser/har/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/browser/snapshots/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/business/README.md +12 -0
- package/plugins/flow/templates/ai-skeleton/context/README.md +12 -0
- package/plugins/flow/templates/ai-skeleton/decisions/README.md +20 -0
- package/plugins/flow/templates/ai-skeleton/design-import/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/dev-logs/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/device/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/docs/README.md +5 -0
- package/plugins/flow/templates/ai-skeleton/logs/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/logs/README.md +13 -0
- package/plugins/flow/templates/ai-skeleton/plans/README.md +16 -0
- package/plugins/flow/templates/ai-skeleton/plans/archive/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/release-guide.md +35 -0
- package/plugins/flow/templates/ai-skeleton/reviews/README.md +15 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/README.md +26 -0
- package/plugins/flow/templates/ai-skeleton/scenarios/_lib/.gitkeep +0 -0
- package/plugins/flow/templates/ai-skeleton/state/STATE.md +25 -0
- package/plugins/flow/templates/ai-skeleton/templates/HANDOFF.md +32 -0
- package/plugins/flow/templates/ai-skeleton/workflows.config.json +74 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Phase 4 T5 — `/_api/canvas-meta` GET/PATCH endpoint round-trip.
|
|
2
|
+
//
|
|
3
|
+
// Verifies:
|
|
4
|
+
// - PATCH merges `viewport` into an existing `<canvas>.meta.json`
|
|
5
|
+
// - PATCH preserves other top-level keys (title, sections, ai_context …)
|
|
6
|
+
// - PATCH clamps zoom to [0.1, 4.0]
|
|
7
|
+
// - PATCH rejects non-finite viewport coords (no write, returns prior)
|
|
8
|
+
// - GET returns the merged meta
|
|
9
|
+
// - Paths that escape repoRoot are 400
|
|
10
|
+
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
|
|
16
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
17
|
+
|
|
18
|
+
interface MetaShape {
|
|
19
|
+
title?: string;
|
|
20
|
+
sections?: unknown[];
|
|
21
|
+
viewport?: { x: number; y: number; zoom: number };
|
|
22
|
+
layout?: { artboards: unknown[] };
|
|
23
|
+
last_modified?: string;
|
|
24
|
+
[k: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function seedCanvas(designRoot: string, name = 'Phase4.tsx', meta?: MetaShape): string {
|
|
28
|
+
const ui = join(designRoot, 'ui');
|
|
29
|
+
mkdirSync(ui, { recursive: true });
|
|
30
|
+
const tsxPath = join(ui, name);
|
|
31
|
+
writeFileSync(tsxPath, 'export default function P(){return <main/>}\n');
|
|
32
|
+
const metaPath = tsxPath.replace(/\.tsx$/, '.meta.json');
|
|
33
|
+
if (meta) writeFileSync(metaPath, JSON.stringify(meta, null, 2));
|
|
34
|
+
return tsxPath.replace(`${designRoot.replace(/\.design$/, '')}`, '').replace(/^\/+/, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function repoRel(designRoot: string, abs: string): string {
|
|
38
|
+
// designRoot ends in `.design`. repoRoot is its parent.
|
|
39
|
+
const repoRoot = designRoot.replace(/\.design$/, '').replace(/\/+$/, '');
|
|
40
|
+
return abs.startsWith(`${repoRoot}/`) ? abs.slice(repoRoot.length + 1) : abs;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('/_api/canvas-meta — GET/PATCH', () => {
|
|
44
|
+
test('PATCH merges viewport onto existing meta and preserves other keys', async () => {
|
|
45
|
+
const { root, designRoot } = makeSandbox();
|
|
46
|
+
const port = nextPort();
|
|
47
|
+
const proc = await bootServer(root, port);
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
50
|
+
const tsxAbs = join(designRoot, 'ui', 'Phase4.tsx');
|
|
51
|
+
writeFileSync(tsxAbs, 'export default function P(){return <main/>}\n');
|
|
52
|
+
const metaAbs = tsxAbs.replace(/\.tsx$/, '.meta.json');
|
|
53
|
+
writeFileSync(
|
|
54
|
+
metaAbs,
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
title: 'Phase 4',
|
|
57
|
+
sections: [{ id: 'overview', label: 'Overview' }],
|
|
58
|
+
ai_context: { pinned_decisions: ['keep dc-* classes'] },
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
62
|
+
|
|
63
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
64
|
+
method: 'PATCH',
|
|
65
|
+
headers: { 'content-type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 12, y: 34, zoom: 1.5 } } }),
|
|
67
|
+
});
|
|
68
|
+
expect(r.status).toBe(200);
|
|
69
|
+
const merged = (await r.json()) as MetaShape;
|
|
70
|
+
expect(merged.title).toBe('Phase 4');
|
|
71
|
+
expect(merged.sections).toBeDefined();
|
|
72
|
+
expect(merged.ai_context).toBeDefined();
|
|
73
|
+
expect(merged.viewport).toEqual({ x: 12, y: 34, zoom: 1.5 });
|
|
74
|
+
expect(typeof merged.last_modified).toBe('string');
|
|
75
|
+
|
|
76
|
+
// On-disk reflects the merge.
|
|
77
|
+
const onDisk = JSON.parse(readFileSync(metaAbs, 'utf8')) as MetaShape;
|
|
78
|
+
expect(onDisk.viewport?.zoom).toBe(1.5);
|
|
79
|
+
expect(onDisk.title).toBe('Phase 4');
|
|
80
|
+
} finally {
|
|
81
|
+
await killProc(proc);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('PATCH clamps zoom to [0.1, 4.0]', async () => {
|
|
86
|
+
const { root, designRoot } = makeSandbox();
|
|
87
|
+
const port = nextPort();
|
|
88
|
+
const proc = await bootServer(root, port);
|
|
89
|
+
try {
|
|
90
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
91
|
+
const tsxAbs = join(designRoot, 'ui', 'Clamp.tsx');
|
|
92
|
+
writeFileSync(tsxAbs, 'export default function C(){return <main/>}\n');
|
|
93
|
+
writeFileSync(tsxAbs.replace(/\.tsx$/, '.meta.json'), '{"title":"Clamp"}');
|
|
94
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
95
|
+
|
|
96
|
+
const r1 = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
97
|
+
method: 'PATCH',
|
|
98
|
+
headers: { 'content-type': 'application/json' },
|
|
99
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 0, y: 0, zoom: 99 } } }),
|
|
100
|
+
});
|
|
101
|
+
const m1 = (await r1.json()) as MetaShape;
|
|
102
|
+
expect(m1.viewport?.zoom).toBe(4);
|
|
103
|
+
|
|
104
|
+
const r2 = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
105
|
+
method: 'PATCH',
|
|
106
|
+
headers: { 'content-type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 0, y: 0, zoom: 0.001 } } }),
|
|
108
|
+
});
|
|
109
|
+
const m2 = (await r2.json()) as MetaShape;
|
|
110
|
+
expect(m2.viewport?.zoom).toBe(0.1);
|
|
111
|
+
} finally {
|
|
112
|
+
await killProc(proc);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('PATCH ignores non-finite viewport (NaN/Infinity)', async () => {
|
|
117
|
+
const { root, designRoot } = makeSandbox();
|
|
118
|
+
const port = nextPort();
|
|
119
|
+
const proc = await bootServer(root, port);
|
|
120
|
+
try {
|
|
121
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
122
|
+
const tsxAbs = join(designRoot, 'ui', 'Bad.tsx');
|
|
123
|
+
writeFileSync(tsxAbs, 'export default function B(){return <main/>}\n');
|
|
124
|
+
writeFileSync(
|
|
125
|
+
tsxAbs.replace(/\.tsx$/, '.meta.json'),
|
|
126
|
+
'{"title":"Bad","viewport":{"x":1,"y":2,"zoom":1}}'
|
|
127
|
+
);
|
|
128
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
129
|
+
|
|
130
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
131
|
+
method: 'PATCH',
|
|
132
|
+
headers: { 'content-type': 'application/json' },
|
|
133
|
+
body: JSON.stringify({ file, patch: { viewport: { x: 'nope', y: null, zoom: 1 } } }),
|
|
134
|
+
});
|
|
135
|
+
const m = (await r.json()) as MetaShape;
|
|
136
|
+
// Prior value preserved.
|
|
137
|
+
expect(m.viewport).toEqual({ x: 1, y: 2, zoom: 1 });
|
|
138
|
+
} finally {
|
|
139
|
+
await killProc(proc);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('GET returns the meta document', async () => {
|
|
144
|
+
const { root, designRoot } = makeSandbox();
|
|
145
|
+
const port = nextPort();
|
|
146
|
+
const proc = await bootServer(root, port);
|
|
147
|
+
try {
|
|
148
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
149
|
+
const tsxAbs = join(designRoot, 'ui', 'Read.tsx');
|
|
150
|
+
writeFileSync(tsxAbs, 'export default function R(){return <main/>}\n');
|
|
151
|
+
writeFileSync(
|
|
152
|
+
tsxAbs.replace(/\.tsx$/, '.meta.json'),
|
|
153
|
+
JSON.stringify({ title: 'Read', viewport: { x: 5, y: 6, zoom: 0.5 } })
|
|
154
|
+
);
|
|
155
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
156
|
+
|
|
157
|
+
const r = await fetch(
|
|
158
|
+
`http://localhost:${port}/_api/canvas-meta?file=${encodeURIComponent(file)}`
|
|
159
|
+
);
|
|
160
|
+
expect(r.status).toBe(200);
|
|
161
|
+
const m = (await r.json()) as MetaShape;
|
|
162
|
+
expect(m.title).toBe('Read');
|
|
163
|
+
expect(m.viewport?.zoom).toBe(0.5);
|
|
164
|
+
} finally {
|
|
165
|
+
await killProc(proc);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('PATCH layout persists position-only entries (Phase 4.2 strip-on-write)', async () => {
|
|
170
|
+
// DDR-027: artboard w/h is JSX-authoritative. The client-side writer
|
|
171
|
+
// (canvas-lib.tsx patchCanvasMeta) strips w/h before PATCH; the server
|
|
172
|
+
// must round-trip whatever shape it receives without rejecting partial
|
|
173
|
+
// entries.
|
|
174
|
+
const { root, designRoot } = makeSandbox();
|
|
175
|
+
const port = nextPort();
|
|
176
|
+
const proc = await bootServer(root, port);
|
|
177
|
+
try {
|
|
178
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
179
|
+
const tsxAbs = join(designRoot, 'ui', 'Phase42.tsx');
|
|
180
|
+
writeFileSync(tsxAbs, 'export default function P(){return <main/>}\n');
|
|
181
|
+
const metaAbs = tsxAbs.replace(/\.tsx$/, '.meta.json');
|
|
182
|
+
writeFileSync(metaAbs, JSON.stringify({ title: 'P', sections: [] }));
|
|
183
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
184
|
+
|
|
185
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
186
|
+
method: 'PATCH',
|
|
187
|
+
headers: { 'content-type': 'application/json' },
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
file,
|
|
190
|
+
patch: {
|
|
191
|
+
layout: {
|
|
192
|
+
artboards: [
|
|
193
|
+
{ id: 'a', x: 100, y: 50 },
|
|
194
|
+
{ id: 'b', x: 1500, y: 50 },
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
expect(r.status).toBe(200);
|
|
201
|
+
const merged = (await r.json()) as MetaShape;
|
|
202
|
+
const arts = merged.layout?.artboards as Array<Record<string, unknown>> | undefined;
|
|
203
|
+
expect(arts).toBeDefined();
|
|
204
|
+
expect(arts?.length).toBe(2);
|
|
205
|
+
// No w/h written.
|
|
206
|
+
expect(arts?.[0]).toEqual({ id: 'a', x: 100, y: 50 });
|
|
207
|
+
expect(arts?.[1]).toEqual({ id: 'b', x: 1500, y: 50 });
|
|
208
|
+
|
|
209
|
+
const onDisk = JSON.parse(readFileSync(metaAbs, 'utf8')) as MetaShape;
|
|
210
|
+
const diskArts = onDisk.layout?.artboards as Array<Record<string, unknown>> | undefined;
|
|
211
|
+
expect(diskArts?.[0]).not.toHaveProperty('w');
|
|
212
|
+
expect(diskArts?.[0]).not.toHaveProperty('h');
|
|
213
|
+
} finally {
|
|
214
|
+
await killProc(proc);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('PATCH rejects paths that escape repoRoot', async () => {
|
|
219
|
+
const { root } = makeSandbox();
|
|
220
|
+
const port = nextPort();
|
|
221
|
+
const proc = await bootServer(root, port);
|
|
222
|
+
try {
|
|
223
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
224
|
+
method: 'PATCH',
|
|
225
|
+
headers: { 'content-type': 'application/json' },
|
|
226
|
+
body: JSON.stringify({
|
|
227
|
+
file: '../escape.tsx',
|
|
228
|
+
patch: { viewport: { x: 0, y: 0, zoom: 1 } },
|
|
229
|
+
}),
|
|
230
|
+
});
|
|
231
|
+
expect(r.status).toBe(404);
|
|
232
|
+
} finally {
|
|
233
|
+
await killProc(proc);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { parseSync } from 'oxc-parser';
|
|
3
|
+
|
|
4
|
+
import { TranspileError, transpileCanvasSource } from '../canvas-pipeline.ts';
|
|
5
|
+
|
|
6
|
+
const FIXTURE = `
|
|
7
|
+
import { useState } from 'react';
|
|
8
|
+
|
|
9
|
+
export default function DocsSite() {
|
|
10
|
+
const [count, setCount] = useState(0);
|
|
11
|
+
return (
|
|
12
|
+
<section className="hero">
|
|
13
|
+
<h1>Hello, world.</h1>
|
|
14
|
+
<p data-dc-element="hero-copy">Subhead with <em>emphasis</em>.</p>
|
|
15
|
+
<button type="button" onClick={() => setCount((c) => c + 1)}>
|
|
16
|
+
Click {count}
|
|
17
|
+
</button>
|
|
18
|
+
</section>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
describe('transpileCanvasSource — Pass 1 (ID injection)', () => {
|
|
24
|
+
test('determinism: same source produces same locator map across calls', () => {
|
|
25
|
+
const a = transpileCanvasSource('/x/DocsSite.tsx', FIXTURE);
|
|
26
|
+
const b = transpileCanvasSource('/x/DocsSite.tsx', FIXTURE);
|
|
27
|
+
expect(Object.keys(a.locator).sort()).toEqual(Object.keys(b.locator).sort());
|
|
28
|
+
expect(a.withIds).toEqual(b.withIds);
|
|
29
|
+
expect(a.etag).toEqual(b.etag);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('every JSX element gets exactly one data-cd-id', () => {
|
|
33
|
+
const r = transpileCanvasSource('/x/DocsSite.tsx', FIXTURE);
|
|
34
|
+
// Fixture has 5 JSX elements: section, h1, p, em, button.
|
|
35
|
+
const ids = r.withIds.match(/ data-cd-id="[0-9a-f]{8}"/g) ?? [];
|
|
36
|
+
expect(ids.length).toBe(5);
|
|
37
|
+
expect(Object.keys(r.locator).length).toBe(5);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('IDs are 8 lowercase hex characters', () => {
|
|
41
|
+
const r = transpileCanvasSource('/x/DocsSite.tsx', FIXTURE);
|
|
42
|
+
for (const id of Object.keys(r.locator)) {
|
|
43
|
+
expect(id).toMatch(/^[0-9a-f]{8}$/);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('whitespace-only edits inside the component preserve IDs', () => {
|
|
48
|
+
const a = transpileCanvasSource('/x/DocsSite.tsx', FIXTURE);
|
|
49
|
+
const withExtraSpaces = FIXTURE.replace('Hello, world.', ' Hello, world. ');
|
|
50
|
+
const b = transpileCanvasSource('/x/DocsSite.tsx', withExtraSpaces);
|
|
51
|
+
// Whitespace doesn't change componentName or pre-order index of any JSX
|
|
52
|
+
// element, so the same ID set must result.
|
|
53
|
+
expect(Object.keys(b.locator).sort()).toEqual(Object.keys(a.locator).sort());
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('inserting a sibling shifts the element-to-ID mapping (documented contract)', () => {
|
|
57
|
+
// The contract per DDR-019:
|
|
58
|
+
// - ID is computed from (componentName, preOrderIndex).
|
|
59
|
+
// - The SET of IDs is therefore a function of element count, not content
|
|
60
|
+
// — every position-hash that existed before still exists (superset).
|
|
61
|
+
// - But the SOURCE ELEMENT attached to a given ID shifts: ID at idx=1 used
|
|
62
|
+
// to point to <h1>, after inserting <span> above it now points to <span>.
|
|
63
|
+
// This is the "selection jumps" failure mode Phase-12's (componentName,
|
|
64
|
+
// jsxPath) fallback recovers.
|
|
65
|
+
const a = transpileCanvasSource('/x/DocsSite.tsx', FIXTURE);
|
|
66
|
+
const withInsert = FIXTURE.replace(
|
|
67
|
+
'<section className="hero">',
|
|
68
|
+
'<section className="hero">\n <span>NEW</span>'
|
|
69
|
+
);
|
|
70
|
+
const b = transpileCanvasSource('/x/DocsSite.tsx', withInsert);
|
|
71
|
+
|
|
72
|
+
// Count: one new JSX element => one new ID.
|
|
73
|
+
expect(Object.keys(b.locator).length).toBe(Object.keys(a.locator).length + 1);
|
|
74
|
+
|
|
75
|
+
// Superset: every old position-hash still appears.
|
|
76
|
+
for (const id of Object.keys(a.locator)) expect(b.locator[id]).toBeDefined();
|
|
77
|
+
|
|
78
|
+
// jsxPath-at-ID shifts: the IDs that used to point at h1/p/em/button now
|
|
79
|
+
// point at different element types (or at the new <span>). Concretely:
|
|
80
|
+
// count how many shared IDs have an UNCHANGED jsxPath after the insert.
|
|
81
|
+
let unchangedPaths = 0;
|
|
82
|
+
for (const id of Object.keys(a.locator)) {
|
|
83
|
+
const before = a.locator[id]?.jsxPath.join('>');
|
|
84
|
+
const after = b.locator[id]?.jsxPath.join('>');
|
|
85
|
+
if (before === after) unchangedPaths += 1;
|
|
86
|
+
}
|
|
87
|
+
// Only the first element (the <section> wrapper, idx 0) is unaffected by
|
|
88
|
+
// the insert that happens inside it.
|
|
89
|
+
expect(unchangedPaths).toBe(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('locator entries carry canvas, line, col, jsxPath, componentName', () => {
|
|
93
|
+
const r = transpileCanvasSource('/x/DocsSite.tsx', FIXTURE);
|
|
94
|
+
for (const entry of Object.values(r.locator)) {
|
|
95
|
+
expect(entry.canvas).toBe('/x/DocsSite.tsx');
|
|
96
|
+
expect(typeof entry.line).toBe('number');
|
|
97
|
+
expect(entry.line).toBeGreaterThan(0);
|
|
98
|
+
expect(typeof entry.col).toBe('number');
|
|
99
|
+
expect(Array.isArray(entry.jsxPath)).toBe(true);
|
|
100
|
+
expect(entry.jsxPath.length).toBeGreaterThan(0);
|
|
101
|
+
expect(entry.componentName).toBe('DocsSite');
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('jsxPath reflects element-type nesting', () => {
|
|
106
|
+
const r = transpileCanvasSource('/x/DocsSite.tsx', FIXTURE);
|
|
107
|
+
const paths = Object.values(r.locator)
|
|
108
|
+
.map((e) => e.jsxPath.join('>'))
|
|
109
|
+
.sort();
|
|
110
|
+
// Expected, sorted: section, section>button, section>h1, section>p, section>p>em
|
|
111
|
+
expect(paths).toEqual(['section', 'section>button', 'section>h1', 'section>p', 'section>p>em']);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('transpileCanvasSource — Pass 2 (JSX -> JS)', () => {
|
|
116
|
+
test('output is parseable JS (no syntax errors)', () => {
|
|
117
|
+
const r = transpileCanvasSource('/x/DocsSite.tsx', FIXTURE);
|
|
118
|
+
const parsed = parseSync('/x/DocsSite.js', r.js, { sourceType: 'module' });
|
|
119
|
+
expect(parsed.errors.length).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('output preserves the default export', () => {
|
|
123
|
+
const r = transpileCanvasSource('/x/DocsSite.tsx', FIXTURE);
|
|
124
|
+
expect(r.js).toMatch(/export default/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('output references data-cd-id on every emitted element', () => {
|
|
128
|
+
const r = transpileCanvasSource('/x/DocsSite.tsx', FIXTURE);
|
|
129
|
+
const dcCount = (r.js.match(/"data-cd-id":\s*"[0-9a-f]{8}"/g) ?? []).length;
|
|
130
|
+
expect(dcCount).toBe(5);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('transpileCanvasSource — robustness', () => {
|
|
135
|
+
test('idempotent: re-running on the post-pass-1 source does not double-inject', () => {
|
|
136
|
+
// Re-parsing the post-pass-1 source through the pipeline should detect the
|
|
137
|
+
// existing data-cd-id attribute on every element and skip injection.
|
|
138
|
+
const first = transpileCanvasSource('/x/DocsSite.tsx', FIXTURE);
|
|
139
|
+
const second = transpileCanvasSource('/x/DocsSite.tsx', first.withIds);
|
|
140
|
+
const firstIds = (first.withIds.match(/ data-cd-id="/g) ?? []).length;
|
|
141
|
+
const secondIds = (second.withIds.match(/ data-cd-id="/g) ?? []).length;
|
|
142
|
+
expect(secondIds).toBe(firstIds);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('handles arrow-function component bound to a PascalCase const', () => {
|
|
146
|
+
const src = `
|
|
147
|
+
const Hello = () => <h1>hi</h1>;
|
|
148
|
+
export default Hello;
|
|
149
|
+
`;
|
|
150
|
+
const r = transpileCanvasSource('/x/Hello.tsx', src);
|
|
151
|
+
expect(Object.keys(r.locator).length).toBe(1);
|
|
152
|
+
expect(Object.values(r.locator)[0]?.componentName).toBe('Hello');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('handles JSXMemberExpression element names (motion.div)', () => {
|
|
156
|
+
const src = `
|
|
157
|
+
const motion = { div: 'div' };
|
|
158
|
+
export default function MotionCanvas() {
|
|
159
|
+
return <motion.div>boop</motion.div>;
|
|
160
|
+
}
|
|
161
|
+
`;
|
|
162
|
+
const r = transpileCanvasSource('/x/MotionCanvas.tsx', src);
|
|
163
|
+
expect(Object.keys(r.locator).length).toBe(1);
|
|
164
|
+
const entry = Object.values(r.locator)[0];
|
|
165
|
+
expect(entry?.jsxPath).toEqual(['motion.div']);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('throws TranspileError on a malformed source', () => {
|
|
169
|
+
const broken = 'export default function X() { return <div></span>; }';
|
|
170
|
+
expect(() => transpileCanvasSource('/x/Broken.tsx', broken)).toThrow(TranspileError);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('source with no JSX produces empty locator + still-valid JS', () => {
|
|
174
|
+
const src = 'export default function X() { return 42; }';
|
|
175
|
+
const r = transpileCanvasSource('/x/X.tsx', src);
|
|
176
|
+
expect(Object.keys(r.locator).length).toBe(0);
|
|
177
|
+
const parsed = parseSync('/x/X.js', r.js, { sourceType: 'module' });
|
|
178
|
+
expect(parsed.errors.length).toBe(0);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Integration test for the /.design/ui/*.tsx canvas route.
|
|
2
|
+
//
|
|
3
|
+
// Boots a real Bun.serve dev-server against a sandbox that contains a single
|
|
4
|
+
// canvas TSX file, then verifies:
|
|
5
|
+
// - GET returns 200 + application/javascript + an ETag header
|
|
6
|
+
// - If-None-Match: <etag> returns 304 with no body
|
|
7
|
+
// - The cached body parses as JS via the same Bun.Transpiler the route uses
|
|
8
|
+
// - _locator.json is written for the canvas slug
|
|
9
|
+
// - Path-traversal attempts get rejected
|
|
10
|
+
// - Non-existent canvases return 404
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import type { Subprocess } from 'bun';
|
|
17
|
+
|
|
18
|
+
import { readLocator } from '../locator.ts';
|
|
19
|
+
import { bootServer, killProc, nextPort } from './_helpers.ts';
|
|
20
|
+
|
|
21
|
+
interface CanvasSandbox {
|
|
22
|
+
root: string;
|
|
23
|
+
designRoot: string;
|
|
24
|
+
cleanup: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeCanvasSandbox(canvasName: string, canvasSource: string): CanvasSandbox {
|
|
28
|
+
const root = mkdtempSync(join(tmpdir(), 'mdcc-canvas-route-'));
|
|
29
|
+
const designRoot = join(root, '.design');
|
|
30
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
31
|
+
writeFileSync(
|
|
32
|
+
join(designRoot, 'config.json'),
|
|
33
|
+
JSON.stringify({
|
|
34
|
+
name: 'test',
|
|
35
|
+
designRoot: '.design',
|
|
36
|
+
canvasGroups: [
|
|
37
|
+
{ label: 'UI', path: 'ui' },
|
|
38
|
+
{ label: 'System', path: 'system' },
|
|
39
|
+
],
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
writeFileSync(join(designRoot, 'ui', canvasName), canvasSource);
|
|
43
|
+
return { root, designRoot, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SIMPLE_TSX = `
|
|
47
|
+
export default function Hello() {
|
|
48
|
+
return (
|
|
49
|
+
<section>
|
|
50
|
+
<h1>Hello, world.</h1>
|
|
51
|
+
<p>Subhead.</p>
|
|
52
|
+
</section>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
describe('TSX canvas route', () => {
|
|
58
|
+
let proc: Subprocess | null = null;
|
|
59
|
+
let port = 0;
|
|
60
|
+
let sb: CanvasSandbox | null = null;
|
|
61
|
+
|
|
62
|
+
async function boot(canvasName: string, source: string) {
|
|
63
|
+
sb = makeCanvasSandbox(canvasName, source);
|
|
64
|
+
port = nextPort();
|
|
65
|
+
proc = await bootServer(sb.root, port);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function teardown() {
|
|
69
|
+
if (proc) await killProc(proc);
|
|
70
|
+
if (sb) sb.cleanup();
|
|
71
|
+
proc = null;
|
|
72
|
+
sb = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test('returns 200 + JS + ETag for a valid canvas', async () => {
|
|
76
|
+
await boot('Hello.tsx', SIMPLE_TSX);
|
|
77
|
+
try {
|
|
78
|
+
const r = await fetch(`http://localhost:${port}/.design/ui/Hello.tsx`);
|
|
79
|
+
expect(r.status).toBe(200);
|
|
80
|
+
expect(r.headers.get('content-type')).toMatch(/javascript/);
|
|
81
|
+
const etag = r.headers.get('etag');
|
|
82
|
+
expect(etag).toBeTruthy();
|
|
83
|
+
expect(etag).toMatch(/^[0-9a-f]+$/i);
|
|
84
|
+
const body = await r.text();
|
|
85
|
+
// Body should contain data-cd-id metadata + an export — proves the two
|
|
86
|
+
// passes ran end to end.
|
|
87
|
+
expect(body).toMatch(/data-cd-id/);
|
|
88
|
+
// Bun.build emits `export { Hello as default }` (renamed-default form)
|
|
89
|
+
// rather than `export default Hello`. Both are valid ESM; match either.
|
|
90
|
+
expect(body).toMatch(/export\s+\{[\s\S]*\bdefault\b[\s\S]*\}|export\s+default\b/);
|
|
91
|
+
} finally {
|
|
92
|
+
await teardown();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('returns 304 on a matching If-None-Match', async () => {
|
|
97
|
+
await boot('Hello.tsx', SIMPLE_TSX);
|
|
98
|
+
try {
|
|
99
|
+
const first = await fetch(`http://localhost:${port}/.design/ui/Hello.tsx`);
|
|
100
|
+
const etag = first.headers.get('etag') as string;
|
|
101
|
+
const second = await fetch(`http://localhost:${port}/.design/ui/Hello.tsx`, {
|
|
102
|
+
headers: { 'If-None-Match': etag },
|
|
103
|
+
});
|
|
104
|
+
expect(second.status).toBe(304);
|
|
105
|
+
// 304 body should be empty.
|
|
106
|
+
const body = await second.text();
|
|
107
|
+
expect(body.length).toBe(0);
|
|
108
|
+
} finally {
|
|
109
|
+
await teardown();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('writes _locator.json with the canvas slug populated', async () => {
|
|
114
|
+
await boot('Docs Site.tsx', SIMPLE_TSX);
|
|
115
|
+
try {
|
|
116
|
+
await fetch(`http://localhost:${port}/.design/ui/Docs%20Site.tsx`);
|
|
117
|
+
const locFile = join(sb?.designRoot, '_locator.json');
|
|
118
|
+
// The locator is written synchronously before the response — the file
|
|
119
|
+
// must exist + carry the canvas slug.
|
|
120
|
+
expect(existsSync(locFile)).toBe(true);
|
|
121
|
+
const map = await readLocator(locFile, 'ui/Docs Site');
|
|
122
|
+
expect(map).not.toBeNull();
|
|
123
|
+
// SIMPLE_TSX has three JSX elements: section, h1, p.
|
|
124
|
+
expect(Object.keys(map as Record<string, unknown>).length).toBe(3);
|
|
125
|
+
} finally {
|
|
126
|
+
await teardown();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('returns 404 for a missing canvas', async () => {
|
|
131
|
+
await boot('Hello.tsx', SIMPLE_TSX);
|
|
132
|
+
try {
|
|
133
|
+
const r = await fetch(`http://localhost:${port}/.design/ui/Missing.tsx`);
|
|
134
|
+
expect(r.status).toBe(404);
|
|
135
|
+
} finally {
|
|
136
|
+
await teardown();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('rejects path traversal via .. (403 from safePathUnderRoot)', async () => {
|
|
141
|
+
await boot('Hello.tsx', SIMPLE_TSX);
|
|
142
|
+
try {
|
|
143
|
+
const r = await fetch(`http://localhost:${port}/.design/ui/../../etc/passwd.tsx`);
|
|
144
|
+
// safePathUnderRoot kicks the request out before our TSX dispatch runs.
|
|
145
|
+
expect([400, 403, 404]).toContain(r.status);
|
|
146
|
+
} finally {
|
|
147
|
+
await teardown();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('returns 500 on a malformed canvas with a useful error body', async () => {
|
|
152
|
+
await boot('Broken.tsx', 'export default function X() { return <div></span>; }');
|
|
153
|
+
try {
|
|
154
|
+
const r = await fetch(`http://localhost:${port}/.design/ui/Broken.tsx`);
|
|
155
|
+
expect(r.status).toBe(500);
|
|
156
|
+
const body = await r.text();
|
|
157
|
+
expect(body).toMatch(/Transpile error/i);
|
|
158
|
+
} finally {
|
|
159
|
+
await teardown();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('cache: second GET returns the same ETag without re-reading the file', async () => {
|
|
164
|
+
await boot('Hello.tsx', SIMPLE_TSX);
|
|
165
|
+
try {
|
|
166
|
+
const a = await fetch(`http://localhost:${port}/.design/ui/Hello.tsx`);
|
|
167
|
+
const b = await fetch(`http://localhost:${port}/.design/ui/Hello.tsx`);
|
|
168
|
+
expect(a.headers.get('etag')).toBe(b.headers.get('etag'));
|
|
169
|
+
const bodyA = await a.text();
|
|
170
|
+
const bodyB = await b.text();
|
|
171
|
+
expect(bodyA).toBe(bodyB);
|
|
172
|
+
} finally {
|
|
173
|
+
await teardown();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Smoke: recursive fs.watch fires when a file is written under designRoot.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from 'bun:test';
|
|
4
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { createContext } from '../context.ts';
|
|
9
|
+
import { createFsWatch } from '../fs-watch.ts';
|
|
10
|
+
|
|
11
|
+
describe('fs-watch.ts', () => {
|
|
12
|
+
test('emits fs:html on recursive write', async () => {
|
|
13
|
+
const root = mkdtempSync(join(tmpdir(), 'mdcc-fswatch-'));
|
|
14
|
+
mkdirSync(join(root, '.design', 'ui', 'nested'), { recursive: true });
|
|
15
|
+
writeFileSync(join(root, '.design', 'config.json'), '{"name":"t"}');
|
|
16
|
+
|
|
17
|
+
const origArgv = process.argv;
|
|
18
|
+
process.argv = [...origArgv, '--root', root];
|
|
19
|
+
let watch: ReturnType<typeof createFsWatch> | null = null;
|
|
20
|
+
try {
|
|
21
|
+
const ctx = createContext();
|
|
22
|
+
watch = createFsWatch(ctx);
|
|
23
|
+
|
|
24
|
+
const seen: string[] = [];
|
|
25
|
+
ctx.bus.on('fs:html', (file) => seen.push(String(file)));
|
|
26
|
+
|
|
27
|
+
watch.start();
|
|
28
|
+
// give the watcher a moment to register.
|
|
29
|
+
await Bun.sleep(80);
|
|
30
|
+
|
|
31
|
+
const target = join(root, '.design', 'ui', 'nested', 'evt.html');
|
|
32
|
+
writeFileSync(target, '<doc>1</doc>');
|
|
33
|
+
await Bun.sleep(250);
|
|
34
|
+
|
|
35
|
+
expect(seen.some((f) => f.endsWith('evt.html'))).toBe(true);
|
|
36
|
+
} finally {
|
|
37
|
+
watch?.stop();
|
|
38
|
+
process.argv = origArgv;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|