@1agh/maude 0.39.1 → 0.41.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/README.md +1 -1
- package/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +306 -5
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +1316 -67
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/_html-playwright.mjs +9 -1
- package/apps/studio/bin/_pdf-playwright.mjs +8 -1
- package/apps/studio/bin/_png-playwright.mjs +8 -1
- package/apps/studio/bin/_pw-launch.mjs +54 -0
- package/apps/studio/bin/_svg-playwright.mjs +8 -1
- package/apps/studio/bin/_video-playwright.mjs +452 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +2625 -111
- package/apps/studio/canvas-lib.tsx +238 -2
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +514 -20
- package/apps/studio/client/app.jsx +2480 -52
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +15 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
- package/apps/studio/client/panels/timeline-parse.js +229 -0
- package/apps/studio/client/panels/timeline-snap.js +55 -0
- package/apps/studio/client/styles/3-shell-maude.css +155 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +16 -6
- package/apps/studio/context.ts +113 -1
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +89 -17
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/runtime/.min-sizes.json +11 -1
- package/apps/studio/dist/runtime/@remotion_media.js +491 -0
- package/apps/studio/dist/runtime/@remotion_player.js +56 -0
- package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
- package/apps/studio/dist/runtime/remotion.js +42 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +117 -0
- package/apps/studio/exporters/_runtime.ts +69 -0
- package/apps/studio/exporters/html.ts +4 -3
- package/apps/studio/exporters/index.ts +28 -2
- package/apps/studio/exporters/pdf.ts +4 -3
- package/apps/studio/exporters/png.ts +5 -3
- package/apps/studio/exporters/pptx.ts +6 -4
- package/apps/studio/exporters/svg.ts +9 -5
- package/apps/studio/exporters/video-encode-lib.ts +200 -0
- package/apps/studio/exporters/video-render-lib.ts +108 -0
- package/apps/studio/exporters/video.ts +184 -0
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +758 -27
- package/apps/studio/input-router.tsx +19 -1
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +140 -0
- package/apps/studio/test/canvas-list-watch.test.ts +49 -0
- package/apps/studio/test/canvas-media-drop.test.ts +30 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/config-reload.test.ts +230 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/test/video-asset.test.ts +163 -0
- package/apps/studio/test/video-comp-fixture.test.ts +50 -0
- package/apps/studio/test/video-comp.test.ts +168 -0
- package/apps/studio/test/video-render-bridge.test.ts +149 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +55 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
- package/plugins/design/templates/_shell.html +25 -2
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// comments-overlay — pure helpers covering the two comment-anchoring bugs in
|
|
2
|
+
// .ai/logs/rca/issue-comment-anchor-drift-and-popup-edge-clamp.md:
|
|
3
|
+
//
|
|
4
|
+
// 1. `resolveCommentTarget` — a stale `data-cd-id` selector (post-rewrite
|
|
5
|
+
// renumbering, DDR-019) must not be trusted when it resolves to an
|
|
6
|
+
// element of the wrong tag; it must fall back to the structural matcher,
|
|
7
|
+
// and return null (not a wrong element) when the target is truly gone.
|
|
8
|
+
// 2. `placeNearPoint` — the composer/thread popup must flip to whichever
|
|
9
|
+
// side of the anchor actually fits the viewport, instead of always
|
|
10
|
+
// growing down-right and clipping off-screen near a canvas edge.
|
|
11
|
+
//
|
|
12
|
+
// Needs a live DOM for (1) — register happy-dom for THIS file only, like
|
|
13
|
+
// annotations-roundtrip.test.ts.
|
|
14
|
+
|
|
15
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
16
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
17
|
+
|
|
18
|
+
beforeAll(() => {
|
|
19
|
+
GlobalRegistrator.register();
|
|
20
|
+
});
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
GlobalRegistrator.unregister();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
import { placeNearPoint, resolveCommentTarget } from '../comments-overlay.tsx';
|
|
26
|
+
|
|
27
|
+
describe('resolveCommentTarget', () => {
|
|
28
|
+
test('trusts a direct data-cd-id hit when the tag matches', () => {
|
|
29
|
+
document.body.innerHTML = `
|
|
30
|
+
<div data-dc-screen="art1">
|
|
31
|
+
<button class="cta" data-cd-id="aaa11111">Buy now</button>
|
|
32
|
+
</div>
|
|
33
|
+
`;
|
|
34
|
+
const el = resolveCommentTarget({
|
|
35
|
+
selector: '[data-dc-screen="art1"] [data-cd-id="aaa11111"]',
|
|
36
|
+
tag: 'button',
|
|
37
|
+
classes: 'cta',
|
|
38
|
+
dom_path: ['html', 'body', 'div[data-dc-screen="art1"]', 'button.cta'],
|
|
39
|
+
});
|
|
40
|
+
expect(el?.getAttribute('data-cd-id')).toBe('aaa11111');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('falls back to the structural match when the id now belongs to a different tag', () => {
|
|
44
|
+
// Renumbering: "aaa11111" now sits on an unrelated <div>; the button the
|
|
45
|
+
// comment was anchored to kept its structure but got a fresh id.
|
|
46
|
+
document.body.innerHTML = `
|
|
47
|
+
<div data-dc-screen="art1">
|
|
48
|
+
<div data-cd-id="aaa11111" class="decoy">Wrong element now has the old id</div>
|
|
49
|
+
<section class="hero">
|
|
50
|
+
<button class="cta" data-cd-id="zzz99999">Buy now</button>
|
|
51
|
+
</section>
|
|
52
|
+
</div>
|
|
53
|
+
`;
|
|
54
|
+
const el = resolveCommentTarget({
|
|
55
|
+
selector: '[data-dc-screen="art1"] [data-cd-id="aaa11111"]',
|
|
56
|
+
tag: 'button',
|
|
57
|
+
classes: 'cta',
|
|
58
|
+
dom_path: ['html', 'body', 'div[data-dc-screen="art1"]', 'section.hero', 'button.cta'],
|
|
59
|
+
});
|
|
60
|
+
expect(el?.getAttribute('data-cd-id')).toBe('zzz99999');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('returns null (not a wrong element) when the target has genuinely been removed', () => {
|
|
64
|
+
document.body.innerHTML = `
|
|
65
|
+
<div data-dc-screen="art1">
|
|
66
|
+
<p>The button that used to be here is gone.</p>
|
|
67
|
+
</div>
|
|
68
|
+
`;
|
|
69
|
+
const el = resolveCommentTarget({
|
|
70
|
+
selector: '[data-dc-screen="art1"] [data-cd-id="aaa11111"]',
|
|
71
|
+
tag: 'button',
|
|
72
|
+
classes: 'cta',
|
|
73
|
+
dom_path: ['html', 'body', 'div[data-dc-screen="art1"]', 'button.cta'],
|
|
74
|
+
});
|
|
75
|
+
expect(el).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('placeNearPoint', () => {
|
|
80
|
+
beforeAll(() => {
|
|
81
|
+
window.innerWidth = 1000;
|
|
82
|
+
window.innerHeight = 800;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('keeps the default down-right placement when there is room', () => {
|
|
86
|
+
const placed = placeNearPoint({ x: 100, y: 100 }, { w: 300, h: 200 });
|
|
87
|
+
expect(placed).toEqual({ x: 100, y: 100 });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('flips to the left of the anchor when the card would overflow the right edge', () => {
|
|
91
|
+
const placed = placeNearPoint({ x: 900, y: 100 }, { w: 300, h: 200 });
|
|
92
|
+
// 900 + 300 = 1200 > 1000 → flip: 900 - 300 = 600, which has room (>= margin).
|
|
93
|
+
expect(placed.x).toBe(600);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('flips above the anchor when the card would overflow the bottom edge', () => {
|
|
97
|
+
const placed = placeNearPoint({ x: 100, y: 700 }, { w: 300, h: 200 });
|
|
98
|
+
// 700 + 200 = 900 > 800 → flip: 700 - 200 = 500, which has room.
|
|
99
|
+
expect(placed.y).toBe(500);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('flips both axes when the anchor sits in a corner (near right AND bottom edge)', () => {
|
|
103
|
+
const placed = placeNearPoint({ x: 950, y: 750 }, { w: 300, h: 200 });
|
|
104
|
+
// Flipped x (950-300=650) has room, so it flips rather than clamps.
|
|
105
|
+
expect(placed.x).toBe(650);
|
|
106
|
+
// Flipped y (750-200=550) has room too.
|
|
107
|
+
expect(placed.y).toBe(550);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('clamps inward when even the flipped position would go negative', () => {
|
|
111
|
+
const placed = placeNearPoint({ x: 50, y: 50 }, { w: 2000, h: 2000 });
|
|
112
|
+
// Card wider/taller than the viewport in both directions — neither the
|
|
113
|
+
// default nor the flip fits, so it clamps to the margin-in-from-edge spot.
|
|
114
|
+
expect(placed.x).toBe(8);
|
|
115
|
+
expect(placed.y).toBe(8);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Config hot-reload — `/design:setup-ds` rewrites `.design/config.json`
|
|
2
|
+
// mid-session; the server must re-read it so a newly added canvas group
|
|
3
|
+
// (`system`) reaches /_index-data and scaffolded DS files show in the tree.
|
|
4
|
+
// reloadConfig mutates ctx.cfg IN PLACE so every `const { cfg } = ctx`
|
|
5
|
+
// capture sees the fresh values.
|
|
6
|
+
// RCA: .ai/logs/rca/issue-ds-scaffold-files-not-in-filetree-stale-config.md
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { mkdirSync, mkdtempSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { type Context, createBus, reloadConfig } from '../context.ts';
|
|
14
|
+
|
|
15
|
+
/** The pre-scaffold e2e fixture shape — UI-only group, no design systems. */
|
|
16
|
+
const BOOT_CONFIG = {
|
|
17
|
+
name: 'e2e-fixture',
|
|
18
|
+
designRoot: '.design',
|
|
19
|
+
canvasGroups: [{ label: 'UI kit', path: 'ui' }],
|
|
20
|
+
designSystems: [],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** What /design:setup-ds writes: system group + DS entry + tokens path. */
|
|
24
|
+
const SCAFFOLDED_CONFIG = {
|
|
25
|
+
...BOOT_CONFIG,
|
|
26
|
+
tokensCssRel: 'system/kanban-glass/colors_and_type.css',
|
|
27
|
+
canvasGroups: [
|
|
28
|
+
{ label: 'Design system', path: 'system' },
|
|
29
|
+
{ label: 'UI kit', path: 'ui' },
|
|
30
|
+
],
|
|
31
|
+
designSystems: [{ name: 'kanban-glass', path: 'system/kanban-glass' }],
|
|
32
|
+
defaultDesignSystem: 'kanban-glass',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function sandbox(): { root: string; designRoot: string } {
|
|
36
|
+
const root = mkdtempSync(join(tmpdir(), 'cfg-reload-test-'));
|
|
37
|
+
const designRoot = join(root, '.design');
|
|
38
|
+
mkdirSync(designRoot, { recursive: true });
|
|
39
|
+
writeFileSync(join(designRoot, 'config.json'), JSON.stringify(BOOT_CONFIG, null, 2));
|
|
40
|
+
return { root, designRoot };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Mirrors what createContext builds at boot from BOOT_CONFIG. */
|
|
44
|
+
function mkCtx(root: string, designRoot: string): Context {
|
|
45
|
+
return {
|
|
46
|
+
cfg: {
|
|
47
|
+
name: 'e2e-fixture',
|
|
48
|
+
projectLabel: null,
|
|
49
|
+
designRoot: '.design',
|
|
50
|
+
canvasGroups: [{ label: 'UI kit', path: 'ui' }],
|
|
51
|
+
designSystems: [],
|
|
52
|
+
rootClass: 'app',
|
|
53
|
+
themeDefault: 'dark',
|
|
54
|
+
tokensCssRel: 'system/colors_and_type.css',
|
|
55
|
+
teamAccentDefault: null,
|
|
56
|
+
handoffTargets: [],
|
|
57
|
+
newCanvasDir: 'ui',
|
|
58
|
+
newComponentDir: 'ui/components',
|
|
59
|
+
_source: '.design/config.json',
|
|
60
|
+
},
|
|
61
|
+
projectLabel: 'e2e-fixture Design',
|
|
62
|
+
paths: {
|
|
63
|
+
repoRoot: root,
|
|
64
|
+
designRel: '.design',
|
|
65
|
+
designRoot,
|
|
66
|
+
serverInfoFile: join(designRoot, '_server.json'),
|
|
67
|
+
activeFile: join(designRoot, '_active.json'),
|
|
68
|
+
commentsDir: join(designRoot, '_comments'),
|
|
69
|
+
canvasStateDir: join(designRoot, '_canvas-state'),
|
|
70
|
+
historyDir: join(designRoot, '_history'),
|
|
71
|
+
tokensUrlRel: '.design/system/colors_and_type.css',
|
|
72
|
+
systemDirRel: 'system',
|
|
73
|
+
},
|
|
74
|
+
bus: createBus(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('reloadConfig', () => {
|
|
79
|
+
test('a group added on disk reaches a boot-time captured cfg reference (the bug)', () => {
|
|
80
|
+
const { root, designRoot } = sandbox();
|
|
81
|
+
const ctx = mkCtx(root, designRoot);
|
|
82
|
+
// What every module factory holds: the object reference, captured at boot.
|
|
83
|
+
const captured = ctx.cfg;
|
|
84
|
+
try {
|
|
85
|
+
writeFileSync(join(designRoot, 'config.json'), JSON.stringify(SCAFFOLDED_CONFIG, null, 2));
|
|
86
|
+
expect(reloadConfig(ctx)).toBe(true);
|
|
87
|
+
// The capture sees the scaffolded groups — no restart needed.
|
|
88
|
+
expect(captured.canvasGroups.map((g) => g.path)).toEqual(['system', 'ui']);
|
|
89
|
+
expect(captured.designSystems?.[0]?.name).toBe('kanban-glass');
|
|
90
|
+
// normalizeDesignSystems runs on reload too (derived per-DS tokens path).
|
|
91
|
+
expect(captured.designSystems?.[0]?.tokensCssRel).toBe(
|
|
92
|
+
'system/kanban-glass/colors_and_type.css'
|
|
93
|
+
);
|
|
94
|
+
expect(captured.defaultDesignSystem).toBe('kanban-glass');
|
|
95
|
+
// Derived paths recompute in place.
|
|
96
|
+
expect(ctx.paths.tokensUrlRel).toBe('.design/system/kanban-glass/colors_and_type.css');
|
|
97
|
+
expect(ctx.paths.systemDirRel).toBe('system');
|
|
98
|
+
} finally {
|
|
99
|
+
rmSync(root, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('an unchanged config reports false (no spurious nudges)', () => {
|
|
104
|
+
const { root, designRoot } = sandbox();
|
|
105
|
+
const ctx = mkCtx(root, designRoot);
|
|
106
|
+
try {
|
|
107
|
+
reloadConfig(ctx); // sync ctx.cfg to the on-disk file once
|
|
108
|
+
expect(reloadConfig(ctx)).toBe(false);
|
|
109
|
+
} finally {
|
|
110
|
+
rmSync(root, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('invalid JSON mid-edit keeps the running config', () => {
|
|
115
|
+
const { root, designRoot } = sandbox();
|
|
116
|
+
const ctx = mkCtx(root, designRoot);
|
|
117
|
+
try {
|
|
118
|
+
writeFileSync(join(designRoot, 'config.json'), '{ "name": "half-writ');
|
|
119
|
+
expect(reloadConfig(ctx)).toBe(false);
|
|
120
|
+
expect(ctx.cfg.name).toBe('e2e-fixture');
|
|
121
|
+
expect(ctx.cfg.canvasGroups.map((g) => g.path)).toEqual(['ui']);
|
|
122
|
+
} finally {
|
|
123
|
+
rmSync(root, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('a deleted config keeps the running config (no downgrade to defaults)', () => {
|
|
128
|
+
const { root, designRoot } = sandbox();
|
|
129
|
+
const ctx = mkCtx(root, designRoot);
|
|
130
|
+
try {
|
|
131
|
+
unlinkSync(join(designRoot, 'config.json'));
|
|
132
|
+
expect(reloadConfig(ctx)).toBe(false);
|
|
133
|
+
expect(ctx.cfg.name).toBe('e2e-fixture');
|
|
134
|
+
expect(ctx.cfg._source).toBe('.design/config.json');
|
|
135
|
+
} finally {
|
|
136
|
+
rmSync(root, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('designRoot is not hot-reloadable — kept, rest of the change applies', () => {
|
|
141
|
+
const { root, designRoot } = sandbox();
|
|
142
|
+
const ctx = mkCtx(root, designRoot);
|
|
143
|
+
try {
|
|
144
|
+
writeFileSync(
|
|
145
|
+
join(designRoot, 'config.json'),
|
|
146
|
+
JSON.stringify({ ...BOOT_CONFIG, name: 'renamed', designRoot: 'designs' }, null, 2)
|
|
147
|
+
);
|
|
148
|
+
expect(reloadConfig(ctx)).toBe(true);
|
|
149
|
+
expect(ctx.cfg.name).toBe('renamed');
|
|
150
|
+
expect(ctx.cfg.designRoot).toBe('.design');
|
|
151
|
+
expect(ctx.paths.designRel).toBe('.design');
|
|
152
|
+
expect(ctx.projectLabel).toBe('renamed Design');
|
|
153
|
+
} finally {
|
|
154
|
+
rmSync(root, { recursive: true, force: true });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('linkedHub is boot-pinned — a live add is ignored, the rest applies', () => {
|
|
159
|
+
const { root, designRoot } = sandbox();
|
|
160
|
+
const ctx = mkCtx(root, designRoot);
|
|
161
|
+
try {
|
|
162
|
+
writeFileSync(
|
|
163
|
+
join(designRoot, 'config.json'),
|
|
164
|
+
JSON.stringify(
|
|
165
|
+
{
|
|
166
|
+
...BOOT_CONFIG,
|
|
167
|
+
name: 'renamed',
|
|
168
|
+
linkedHub: { url: 'wss://evil.example', linkedAt: 1, syncTsx: true },
|
|
169
|
+
},
|
|
170
|
+
null,
|
|
171
|
+
2
|
|
172
|
+
)
|
|
173
|
+
);
|
|
174
|
+
expect(reloadConfig(ctx)).toBe(true);
|
|
175
|
+
expect(ctx.cfg.name).toBe('renamed');
|
|
176
|
+
expect(ctx.cfg.linkedHub).toBeUndefined();
|
|
177
|
+
} finally {
|
|
178
|
+
rmSync(root, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('design-root escapes are clamped: bad group dropped, bad tokensCssRel reset', () => {
|
|
183
|
+
const { root, designRoot } = sandbox();
|
|
184
|
+
const ctx = mkCtx(root, designRoot);
|
|
185
|
+
try {
|
|
186
|
+
writeFileSync(
|
|
187
|
+
join(designRoot, 'config.json'),
|
|
188
|
+
JSON.stringify(
|
|
189
|
+
{
|
|
190
|
+
...SCAFFOLDED_CONFIG,
|
|
191
|
+
tokensCssRel: '../../../etc/passwd',
|
|
192
|
+
canvasGroups: [
|
|
193
|
+
{ label: 'Escape', path: '../..' },
|
|
194
|
+
{ label: 'Abs', path: '/etc' },
|
|
195
|
+
{ label: 'UI kit', path: 'ui' },
|
|
196
|
+
],
|
|
197
|
+
designSystems: [
|
|
198
|
+
{ name: 'evil', path: '../outside' },
|
|
199
|
+
{ name: 'kanban-glass', path: 'system/kanban-glass' },
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
null,
|
|
203
|
+
2
|
|
204
|
+
)
|
|
205
|
+
);
|
|
206
|
+
expect(reloadConfig(ctx)).toBe(true);
|
|
207
|
+
expect(ctx.cfg.canvasGroups.map((g) => g.path)).toEqual(['ui']);
|
|
208
|
+
expect(ctx.cfg.tokensCssRel).toBe('system/colors_and_type.css');
|
|
209
|
+
expect(ctx.cfg.designSystems?.map((d) => d.name)).toEqual(['kanban-glass']);
|
|
210
|
+
} finally {
|
|
211
|
+
rmSync(root, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('a config.json created after a defaults boot applies', () => {
|
|
216
|
+
const { root, designRoot } = sandbox();
|
|
217
|
+
const ctx = mkCtx(root, designRoot);
|
|
218
|
+
// Simulate a boot without config.json (createContext fell back to defaults).
|
|
219
|
+
unlinkSync(join(designRoot, 'config.json'));
|
|
220
|
+
ctx.cfg._source = 'defaults';
|
|
221
|
+
try {
|
|
222
|
+
writeFileSync(join(designRoot, 'config.json'), JSON.stringify(SCAFFOLDED_CONFIG, null, 2));
|
|
223
|
+
expect(reloadConfig(ctx)).toBe(true);
|
|
224
|
+
expect(ctx.cfg._source).toBe('.design/config.json');
|
|
225
|
+
expect(ctx.cfg.canvasGroups.map((g) => g.path)).toEqual(['system', 'ui']);
|
|
226
|
+
} finally {
|
|
227
|
+
rmSync(root, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// DNS-rebinding guard on the main-origin source-write/read family.
|
|
2
|
+
//
|
|
3
|
+
// The dev-server binds 127.0.0.1, but `sameOriginWrite` is a *reflective* Origin
|
|
4
|
+
// check — after a DNS-rebind (`evil.example → 127.0.0.1`) the browser sends
|
|
5
|
+
// `Origin: http://evil.example:<port>` AND `Host: evil.example:<port>`, so both
|
|
6
|
+
// sides of the origin comparison are attacker-controlled and equal → CSRF passes.
|
|
7
|
+
// Every source-write/read route now also requires a loopback `Host` (the same
|
|
8
|
+
// `isLoopbackHost` guard the /_api/git/* routes already carry). A real browser
|
|
9
|
+
// always sends `Host: localhost|127.0.0.1:<port>` → passes; a rebound foreign
|
|
10
|
+
// hostname → 403. Regression guard for the /flow:done DDR-150 attacker finding.
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
|
|
17
|
+
|
|
18
|
+
describe('DNS-rebinding guard — source-write/read family requires a loopback Host', () => {
|
|
19
|
+
test('rebound Host 403s writes + reads; a loopback Host passes the guard', async () => {
|
|
20
|
+
const { root, designRoot } = makeSandbox();
|
|
21
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
22
|
+
writeFileSync(join(designRoot, 'ui', 'C.tsx'), 'export default function C(){return <main/>}\n');
|
|
23
|
+
|
|
24
|
+
const port = nextPort();
|
|
25
|
+
const proc = await bootServer(root, port);
|
|
26
|
+
try {
|
|
27
|
+
const base = `http://localhost:${port}`;
|
|
28
|
+
// A rebound page: its Host header is the attacker's rebound hostname.
|
|
29
|
+
const evilHost = `evil.example:${port}`;
|
|
30
|
+
|
|
31
|
+
// WRITE route (POST) — the loopback guard fires (before the op runs).
|
|
32
|
+
const wEvil = await fetch(`${base}/_api/toggle-hide`, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'content-type': 'application/json', host: evilHost },
|
|
35
|
+
body: JSON.stringify({ canvas: 'ui/C.tsx', stableId: 'x' }),
|
|
36
|
+
});
|
|
37
|
+
expect(wEvil.status).toBe(403);
|
|
38
|
+
|
|
39
|
+
// A second write route, to prove it's the whole family, not one endpoint.
|
|
40
|
+
const wEvil2 = await fetch(`${base}/_api/edit-text`, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'application/json', host: evilHost },
|
|
43
|
+
body: JSON.stringify({ canvas: 'ui/C.tsx', id: 'x', text: 'y' }),
|
|
44
|
+
});
|
|
45
|
+
expect(wEvil2.status).toBe(403);
|
|
46
|
+
|
|
47
|
+
// READ routes (GET) — a rebound same-origin page could otherwise CORS-read
|
|
48
|
+
// raw project source; the guard 403s them too.
|
|
49
|
+
const rEvil = await fetch(`${base}/_api/canvas-source?file=ui/C.tsx`, {
|
|
50
|
+
headers: { host: evilHost },
|
|
51
|
+
});
|
|
52
|
+
expect(rEvil.status).toBe(403);
|
|
53
|
+
const rEvil2 = await fetch(`${base}/_api/comp-clips?canvas=ui/C.tsx`, {
|
|
54
|
+
headers: { host: evilHost },
|
|
55
|
+
});
|
|
56
|
+
expect(rEvil2.status).toBe(403);
|
|
57
|
+
|
|
58
|
+
// A real browser (loopback Host) is NOT rejected by the rebind guard — the
|
|
59
|
+
// source read succeeds (200). Proves the guard doesn't false-positive on
|
|
60
|
+
// the legitimate local request every real user makes.
|
|
61
|
+
const rOk = await fetch(`${base}/_api/canvas-source?file=ui/C.tsx`, {
|
|
62
|
+
headers: { host: `localhost:${port}` },
|
|
63
|
+
});
|
|
64
|
+
expect(rOk.status).toBe(200);
|
|
65
|
+
// 127.0.0.1 Host is equally valid (the printed URL variant).
|
|
66
|
+
const rOk2 = await fetch(`${base}/_api/canvas-source?file=ui/C.tsx`, {
|
|
67
|
+
headers: { host: `127.0.0.1:${port}` },
|
|
68
|
+
});
|
|
69
|
+
expect(rOk2.status).toBe(200);
|
|
70
|
+
} finally {
|
|
71
|
+
await killProc(proc);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// dom-selection — resolveByDomPath, the structural fallback comments-overlay
|
|
2
|
+
// (and future consumers) reach for when a stored `data-cd-id` selector no
|
|
3
|
+
// longer identifies the intended element. This is the DDR-019 renumbering
|
|
4
|
+
// scenario: a canvas rewrite (/design:edit regenerating JSX) shifts every
|
|
5
|
+
// subsequent element's positional id within a component, so the id can end up
|
|
6
|
+
// on an unrelated element, or on none at all. See
|
|
7
|
+
// .ai/logs/rca/issue-comment-anchor-drift-and-popup-edge-clamp.md.
|
|
8
|
+
//
|
|
9
|
+
// Needs a live DOM (querySelectorAll / getAttribute walk) — register happy-dom
|
|
10
|
+
// for THIS file only, like annotations-roundtrip.test.ts.
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
13
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
GlobalRegistrator.register();
|
|
17
|
+
});
|
|
18
|
+
afterAll(() => {
|
|
19
|
+
GlobalRegistrator.unregister();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
import { resolveByDomPath } from '../dom-selection.ts';
|
|
23
|
+
|
|
24
|
+
function setArtboard(html: string): void {
|
|
25
|
+
document.body.innerHTML = `<div data-dc-screen="art1">${html}</div>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('resolveByDomPath', () => {
|
|
29
|
+
test('finds the structurally-matching element when a same-id decoy has the wrong tag', () => {
|
|
30
|
+
// Simulates the renumbering bug: the stored data-cd-id ("bbb22222") now
|
|
31
|
+
// belongs to an unrelated <div>, while the ORIGINAL <button> the comment
|
|
32
|
+
// was anchored to survives under a different id ("ccc33333"). The
|
|
33
|
+
// fallback must find the button by structure, not by the stale id.
|
|
34
|
+
setArtboard(`
|
|
35
|
+
<div data-cd-id="bbb22222" class="decoy">Wrong element now has the old id</div>
|
|
36
|
+
<section class="hero">
|
|
37
|
+
<button class="cta primary" data-cd-id="ccc33333">Buy now</button>
|
|
38
|
+
</section>
|
|
39
|
+
`);
|
|
40
|
+
const found = resolveByDomPath(document, {
|
|
41
|
+
artboardId: 'art1',
|
|
42
|
+
tag: 'button',
|
|
43
|
+
classes: 'cta primary',
|
|
44
|
+
dom_path: [
|
|
45
|
+
'html',
|
|
46
|
+
'body',
|
|
47
|
+
'div[data-dc-screen="art1"]',
|
|
48
|
+
'section.hero',
|
|
49
|
+
'button.cta.primary',
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
expect(found).not.toBeNull();
|
|
53
|
+
expect(found?.getAttribute('data-cd-id')).toBe('ccc33333');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('prefers the candidate with the longer matching ancestor suffix over a shallower same-tag sibling', () => {
|
|
57
|
+
setArtboard(`
|
|
58
|
+
<button class="cta primary" data-cd-id="aaa11111">Outer decoy button</button>
|
|
59
|
+
<section class="hero">
|
|
60
|
+
<button class="cta primary" data-cd-id="bbb22222">Nested target</button>
|
|
61
|
+
</section>
|
|
62
|
+
`);
|
|
63
|
+
const found = resolveByDomPath(document, {
|
|
64
|
+
artboardId: 'art1',
|
|
65
|
+
tag: 'button',
|
|
66
|
+
classes: 'cta primary',
|
|
67
|
+
dom_path: [
|
|
68
|
+
'html',
|
|
69
|
+
'body',
|
|
70
|
+
'div[data-dc-screen="art1"]',
|
|
71
|
+
'section.hero',
|
|
72
|
+
'button.cta.primary',
|
|
73
|
+
],
|
|
74
|
+
});
|
|
75
|
+
expect(found?.getAttribute('data-cd-id')).toBe('bbb22222');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('returns null when no element in the artboard shares the stored tag', () => {
|
|
79
|
+
setArtboard(`<div data-cd-id="aaa11111" class="cta primary">Not a button</div>`);
|
|
80
|
+
const found = resolveByDomPath(document, {
|
|
81
|
+
artboardId: 'art1',
|
|
82
|
+
tag: 'button',
|
|
83
|
+
classes: 'cta primary',
|
|
84
|
+
dom_path: ['html', 'body', 'div[data-dc-screen="art1"]', 'button.cta.primary'],
|
|
85
|
+
});
|
|
86
|
+
expect(found).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('returns null with no dom_path or no tag (nothing to match against)', () => {
|
|
90
|
+
setArtboard(`<button class="cta primary" data-cd-id="aaa11111">Buy now</button>`);
|
|
91
|
+
expect(
|
|
92
|
+
resolveByDomPath(document, { artboardId: 'art1', tag: 'button', classes: 'cta primary' })
|
|
93
|
+
).toBeNull();
|
|
94
|
+
expect(
|
|
95
|
+
resolveByDomPath(document, {
|
|
96
|
+
artboardId: 'art1',
|
|
97
|
+
classes: 'cta primary',
|
|
98
|
+
dom_path: ['button.cta.primary'],
|
|
99
|
+
})
|
|
100
|
+
).toBeNull();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('scopes the search to the given artboard — a matching element in a different artboard is ignored', () => {
|
|
104
|
+
document.body.innerHTML = `
|
|
105
|
+
<div data-dc-screen="art1">
|
|
106
|
+
<button class="cta primary" data-cd-id="aaa11111">Art1 button</button>
|
|
107
|
+
</div>
|
|
108
|
+
<div data-dc-screen="art2">
|
|
109
|
+
<section class="hero">
|
|
110
|
+
<button class="cta primary" data-cd-id="bbb22222">Art2 button</button>
|
|
111
|
+
</section>
|
|
112
|
+
</div>
|
|
113
|
+
`;
|
|
114
|
+
const found = resolveByDomPath(document, {
|
|
115
|
+
artboardId: 'art1',
|
|
116
|
+
tag: 'button',
|
|
117
|
+
classes: 'cta primary',
|
|
118
|
+
dom_path: [
|
|
119
|
+
'html',
|
|
120
|
+
'body',
|
|
121
|
+
'div[data-dc-screen="art2"]',
|
|
122
|
+
'section.hero',
|
|
123
|
+
'button.cta.primary',
|
|
124
|
+
],
|
|
125
|
+
});
|
|
126
|
+
// The art2 button structurally matches perfectly, but art1 is the requested
|
|
127
|
+
// scope — the only art1 candidate is a shallower, shorter-suffix match.
|
|
128
|
+
expect(found?.getAttribute('data-cd-id')).toBe('aaa11111');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// canvas-edit.ts — Stage H3 (feature-element-editing-robustness). A whole-
|
|
2
|
+
// component-instance move/resize passes a DOM-occurrence index into edit-css so
|
|
3
|
+
// `applyEdit` routes the write to that instance's `<Component/>` USAGE (local to
|
|
4
|
+
// the dragged instance) instead of the shared inner definition (global). The knob
|
|
5
|
+
// / paste-style paths pass NO occurrence, so inner-shared-element styling stays
|
|
6
|
+
// global-and-labeled — the H2 badge is the answer there (the chosen model).
|
|
7
|
+
// Positional ids are round-tripped through transpileCanvasSource like the sibling
|
|
8
|
+
// structural test, so the arithmetic stays in lockstep with the pipeline.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
|
|
12
|
+
import { applyEdit } from '../canvas-edit.ts';
|
|
13
|
+
import { transpileCanvasSource } from '../canvas-pipeline.ts';
|
|
14
|
+
|
|
15
|
+
const CANVAS = '/abs/Canvas.tsx';
|
|
16
|
+
|
|
17
|
+
/** Map data-cd-id by element-type name (first occurrence), like canvas-edit.test.ts. */
|
|
18
|
+
function idsOf(source: string): Record<string, string> {
|
|
19
|
+
const { withIds } = transpileCanvasSource(CANVAS, source);
|
|
20
|
+
const out: Record<string, string> = {};
|
|
21
|
+
for (const m of withIds.matchAll(/<(\w+)([^>]*?)data-cd-id="([0-9a-f]{8})"/g)) {
|
|
22
|
+
if (!out[m[1] as string]) out[m[1] as string] = m[3] as string;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SHARED = [
|
|
28
|
+
'function Card() { return <article>card</article>; }',
|
|
29
|
+
'function Demo() {',
|
|
30
|
+
' return (',
|
|
31
|
+
' <section>',
|
|
32
|
+
' <Card />',
|
|
33
|
+
' <Card />',
|
|
34
|
+
' <Card />',
|
|
35
|
+
' </section>',
|
|
36
|
+
' );',
|
|
37
|
+
'}',
|
|
38
|
+
].join('\n');
|
|
39
|
+
|
|
40
|
+
describe('canvas-edit / applyEdit — Stage H3 occurrence routing', () => {
|
|
41
|
+
test('with an occurrence, a style edit routes to that <Component/> usage (local)', () => {
|
|
42
|
+
// The inner <article> id is shared across all three <Card/> usages.
|
|
43
|
+
const innerId = idsOf(SHARED).article as string;
|
|
44
|
+
const out = applyEdit(CANVAS, SHARED, innerId, 'style.left', '"40px"', 1); // 2nd instance
|
|
45
|
+
// The 2nd (index 1) <Card/> usage gains the inline style; the shared inner
|
|
46
|
+
// <article> definition is untouched, so the other two instances don't move.
|
|
47
|
+
expect(out.source).toContain('<Card style={{ left: "40px" }}');
|
|
48
|
+
expect(out.source.match(/<Card \/>/g)?.length).toBe(2);
|
|
49
|
+
expect(out.source).toContain('<article>card</article>');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('without an occurrence, the same edit stays on the shared inner element (global)', () => {
|
|
53
|
+
const innerId = idsOf(SHARED).article as string;
|
|
54
|
+
const out = applyEdit(CANVAS, SHARED, innerId, 'style.left', '"40px"');
|
|
55
|
+
// No occurrence → knob / paste-style behavior: the write lands on the inner
|
|
56
|
+
// <article>, changing every rendered instance (the "inner styling is global"
|
|
57
|
+
// model the H2 badge labels).
|
|
58
|
+
expect(out.source).toContain('<article style={{ left: "40px" }}>card</article>');
|
|
59
|
+
expect(out.source).not.toContain('<Card style=');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('a normal (non-instance) element ignores the occurrence (no-op remap)', () => {
|
|
63
|
+
const src = 'function Demo() { return <div>x</div>; }';
|
|
64
|
+
const id = idsOf(src).div as string;
|
|
65
|
+
const out = applyEdit(CANVAS, src, id, 'style.padding', '14', 3);
|
|
66
|
+
expect(out.source).toContain('<div style={{ padding: 14 }}>x</div>');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('an element in a single-usage component ignores the occurrence (not splittable)', () => {
|
|
70
|
+
const src = [
|
|
71
|
+
'function Card() { return <article>card</article>; }',
|
|
72
|
+
'function Demo() { return <section><Card /></section>; }',
|
|
73
|
+
].join('\n');
|
|
74
|
+
const innerId = idsOf(src).article as string;
|
|
75
|
+
// One usage → resolveUsageId returns the inner id (can't split) — the edit
|
|
76
|
+
// lands on <article>, never fabricates a phantom usage index.
|
|
77
|
+
const out = applyEdit(CANVAS, src, innerId, 'style.left', '"40px"', 0);
|
|
78
|
+
expect(out.source).toContain('<article style={{ left: "40px" }}>card</article>');
|
|
79
|
+
expect(out.source).not.toContain('<Card style=');
|
|
80
|
+
});
|
|
81
|
+
});
|