@1agh/maude 0.46.0 → 0.48.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 +7 -6
- package/apps/studio/acp/bootstrap-brief.ts +8 -0
- package/apps/studio/acp/bridge.ts +121 -6
- package/apps/studio/acp/plugin-bootstrap.ts +15 -1
- package/apps/studio/annotations-layer.tsx +42 -0
- package/apps/studio/api.ts +417 -2
- package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
- package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
- package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
- package/apps/studio/bin/_curl-local.mjs +349 -0
- package/apps/studio/bin/_curl-local.test.mjs +280 -0
- package/apps/studio/bin/agent-browser-safe.sh +29 -0
- package/apps/studio/bin/curl-local.sh +28 -0
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-cursors.ts +6 -0
- package/apps/studio/canvas-edit.ts +678 -11
- package/apps/studio/canvas-icons.tsx +13 -0
- package/apps/studio/canvas-lib.tsx +3 -0
- package/apps/studio/canvas-shell.tsx +646 -26
- package/apps/studio/client/app.jsx +898 -52
- package/apps/studio/client/export-center.jsx +7 -6
- package/apps/studio/client/github.js +11 -4
- package/apps/studio/client/panels/ChatPanel.jsx +47 -2
- package/apps/studio/client/styles/3-shell-maude.css +39 -0
- package/apps/studio/contextual-toolbar.tsx +5 -3
- package/apps/studio/dist/client.bundle.js +1103 -1103
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/examples/perf-100-artboards.tsx +1 -1
- package/apps/studio/exporters/pdf.ts +35 -1
- package/apps/studio/git/service.ts +4 -1
- package/apps/studio/grid-track-handles.ts +179 -0
- package/apps/studio/handoff.ts +35 -0
- package/apps/studio/http.ts +118 -0
- package/apps/studio/input-router.tsx +73 -17
- package/apps/studio/paths.ts +37 -1
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
- package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
- package/apps/studio/test/acp-session-plugins.test.ts +6 -0
- package/apps/studio/test/browse-posture.test.tsx +107 -0
- package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
- package/apps/studio/test/canvas-meta-api.test.ts +70 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
- package/apps/studio/test/comment-mount.test.ts +2 -1
- package/apps/studio/test/component-map.test.ts +48 -0
- package/apps/studio/test/convert-to-absolute.test.ts +333 -0
- package/apps/studio/test/detach-component.test.ts +94 -0
- package/apps/studio/test/edit-scope-api.test.ts +8 -4
- package/apps/studio/test/element-structural-api.test.ts +74 -0
- package/apps/studio/test/element-structural-edit.test.ts +113 -0
- package/apps/studio/test/exporters/pdf.test.ts +33 -1
- package/apps/studio/test/grid-track-handles.test.ts +160 -0
- package/apps/studio/test/handoff.test.ts +48 -0
- package/apps/studio/test/input-router.test.ts +82 -8
- package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
- package/apps/studio/test/use-tool-mode.test.tsx +10 -2
- package/apps/studio/tool-palette.tsx +3 -1
- package/apps/studio/use-canvas-media-drop.tsx +126 -0
- package/apps/studio/use-element-resize.tsx +3 -1
- package/apps/studio/use-grid-track-handles.tsx +364 -0
- package/apps/studio/use-keyboard-discipline.tsx +15 -0
- package/apps/studio/use-tool-mode.tsx +30 -3
- package/apps/studio/web-overlay-content.tsx +52 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/design.mjs +16 -0
- package/cli/commands/init.mjs +80 -3
- package/cli/commands/kg.mjs +368 -0
- package/cli/commands/kg.test.mjs +118 -0
- package/cli/lib/ddr-to-kgai.mjs +648 -0
- package/cli/lib/ddr-to-kgai.test.mjs +99 -0
- package/cli/lib/flow-design-integration.test.mjs +2 -2
- package/cli/lib/gitignore-block.mjs +4 -0
- package/cli/lib/plugin-name-namespace.test.mjs +71 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +4 -0
- package/plugins/flow/.claude-plugin/config.schema.json +66 -0
- package/plugins/flow/dependencies.json +17 -0
|
@@ -16,6 +16,7 @@ import { computeSessionPlugins } from '../acp/plugin-bootstrap.ts';
|
|
|
16
16
|
|
|
17
17
|
const DESIGN = '/bundle/plugins/design';
|
|
18
18
|
const FLOW = '/bundle/plugins/flow';
|
|
19
|
+
const KGAI = '/bundle/plugins/kgai';
|
|
19
20
|
|
|
20
21
|
describe('computeSessionPlugins — gates', () => {
|
|
21
22
|
// `/flow` auto-load is intentionally OFF for now (2026-07-03) — only `design`
|
|
@@ -52,4 +53,37 @@ describe('computeSessionPlugins — gates', () => {
|
|
|
52
53
|
const out = computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW });
|
|
53
54
|
expect(out.every((c) => c.skipMcpDiscovery === true && c.type === 'local')).toBe(true);
|
|
54
55
|
});
|
|
56
|
+
|
|
57
|
+
// feature-kgai-ecosystem-integration Phase 8 — the third-party kgai plugin
|
|
58
|
+
// carries the `Stop` hook that IS autonomous decision capture. Without
|
|
59
|
+
// injection the packaged app records nothing (settingSources:['user'], and a
|
|
60
|
+
// terminal-less DDR-177 user never marketplace-installs it).
|
|
61
|
+
test('kgai bundled ⇒ injected alongside design (autonomous capture in the .app)', () => {
|
|
62
|
+
const out = computeSessionPlugins({
|
|
63
|
+
native: true,
|
|
64
|
+
designDir: DESIGN,
|
|
65
|
+
flowDir: FLOW,
|
|
66
|
+
kgaiDir: KGAI,
|
|
67
|
+
});
|
|
68
|
+
expect(out).toEqual([
|
|
69
|
+
{ type: 'local', path: DESIGN, skipMcpDiscovery: true },
|
|
70
|
+
{ type: 'local', path: KGAI, skipMcpDiscovery: true },
|
|
71
|
+
]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('kgai absent (dev tree / npm layout) ⇒ design only, no crash', () => {
|
|
75
|
+
expect(
|
|
76
|
+
computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW, kgaiDir: null })
|
|
77
|
+
).toEqual([{ type: 'local', path: DESIGN, skipMcpDiscovery: true }]);
|
|
78
|
+
// omitted entirely (optional field) behaves the same
|
|
79
|
+
expect(computeSessionPlugins({ native: true, designDir: DESIGN, flowDir: FLOW })).toEqual([
|
|
80
|
+
{ type: 'local', path: DESIGN, skipMcpDiscovery: true },
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('not native ⇒ kgai is NOT injected either (web-serve gate holds)', () => {
|
|
85
|
+
expect(
|
|
86
|
+
computeSessionPlugins({ native: false, designDir: DESIGN, flowDir: FLOW, kgaiDir: KGAI })
|
|
87
|
+
).toEqual([]);
|
|
88
|
+
});
|
|
55
89
|
});
|
|
@@ -1,16 +1,27 @@
|
|
|
1
|
-
// DDR-184
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// DDR-184 (+ DDR-185, hardened per its security addendum) — the curated tool
|
|
2
|
+
// allow-list the bridge auto-approves so the design workflow never stalls on
|
|
3
|
+
// a per-edit / per-`maude` permission prompt (the "Manual mode blocks every
|
|
4
|
+
// edit" complaint). Three things under test:
|
|
4
5
|
//
|
|
5
6
|
// (1) WIRE contract — the list lands on `_meta.claudeCode.options.allowedTools`
|
|
6
7
|
// (SDK `allowedTools`, sdk.d.ts:1331), coexisting with the DDR-144
|
|
7
8
|
// `settingSources` narrowing and the DDR-143 `plugins` carrier. Same
|
|
8
9
|
// adapter/SDK-INTERNAL `_meta` path as acp-session-plugins.test.ts — a
|
|
9
10
|
// bump that stops forwarding it must fail HERE, not silently re-prompt.
|
|
10
|
-
// (2) SOURCE-OF-TRUTH guard — the
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
11
|
+
// (2) SOURCE-OF-TRUTH guard — the Bash rules are EXACTLY `Bash(maude:*)`
|
|
12
|
+
// plus a closed read-only fs verb set (DDR-185) — nothing else. A
|
|
13
|
+
// security fan-out found the ORIGINAL DDR-185 cut's `Bash(agent-browser:*)`
|
|
14
|
+
// entry was a zero-confirmation session-hijack primitive (agent-browser's
|
|
15
|
+
// own bundled skill recommends a persistent, real-login Chrome profile)
|
|
16
|
+
// and `Bash(find:*)` was unrestricted command execution via `-exec`, not
|
|
17
|
+
// "mutation" as first characterized — both are cut, not patched, because
|
|
18
|
+
// neither residual is fixable via prefix-matching. `agent-browser` is
|
|
19
|
+
// now reached ONLY through the hardened `agent-browser-safe` verb
|
|
20
|
+
// (`Bash(maude:*)` covers it — no separate allow-list entry). If any of
|
|
21
|
+
// this drifts, this test fails loudly.
|
|
22
|
+
// (3) `WebSearch`/`WebFetch` (DDR-185) are present as bare native tool names
|
|
23
|
+
// — no prefix-matching involved, so no source-of-truth guard needed the
|
|
24
|
+
// way the Bash rules need one.
|
|
14
25
|
|
|
15
26
|
import { describe, expect, test } from 'bun:test';
|
|
16
27
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
@@ -68,14 +79,40 @@ describe('MAUDE_DEFAULT_ALLOWED_TOOLS — source-of-truth guard (DDR-184 / DDR-0
|
|
|
68
79
|
}
|
|
69
80
|
});
|
|
70
81
|
|
|
71
|
-
test('
|
|
82
|
+
test('WebSearch and WebFetch are present (DDR-185)', () => {
|
|
83
|
+
for (const t of ['WebSearch', 'WebFetch']) {
|
|
84
|
+
expect(MAUDE_DEFAULT_ALLOWED_TOOLS).toContain(t);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('the Bash rules are EXACTLY this closed set — never a bare/un-scoped Bash', () => {
|
|
72
89
|
// A bare `Bash` (or `Bash(*)`) would auto-approve arbitrary command execution
|
|
73
90
|
// — exactly the blanket surface DDR-179 kept behind the prompt. Guard against
|
|
74
|
-
// anyone widening it: the list may auto-approve `maude` and
|
|
75
|
-
|
|
91
|
+
// anyone widening it: the list may auto-approve `maude` and the closed
|
|
92
|
+
// read-only fs verb set (DDR-185) — nothing else. In particular:
|
|
93
|
+
// - `Bash(curl:*)` must NEVER appear — curl is routed through the
|
|
94
|
+
// `curl-local` verb (covered by `Bash(maude:*)`) instead.
|
|
95
|
+
// - `Bash(agent-browser:*)` must NEVER appear — a security fan-out found
|
|
96
|
+
// it was a zero-confirmation session-hijack primitive; agent-browser is
|
|
97
|
+
// routed through the `agent-browser-safe` verb instead.
|
|
98
|
+
// - `Bash(find:*)` must NEVER appear — `find -exec` is unrestricted
|
|
99
|
+
// command execution, not "mutation"; there is no verb substitute, it
|
|
100
|
+
// is simply cut.
|
|
101
|
+
expect(bashRules).toEqual([
|
|
102
|
+
'Bash(maude:*)',
|
|
103
|
+
'Bash(ls:*)',
|
|
104
|
+
'Bash(cat:*)',
|
|
105
|
+
'Bash(pwd:*)',
|
|
106
|
+
'Bash(head:*)',
|
|
107
|
+
'Bash(tail:*)',
|
|
108
|
+
'Bash(wc:*)',
|
|
109
|
+
'Bash(tree:*)',
|
|
110
|
+
'Bash(file:*)',
|
|
111
|
+
'Bash(stat:*)',
|
|
112
|
+
]);
|
|
76
113
|
});
|
|
77
114
|
|
|
78
|
-
test('the
|
|
115
|
+
test('the `maude` Bash rule is load-bearing on DDR-062: every design helper is a `maude design <verb>`', () => {
|
|
79
116
|
// Resolve cli/commands/design.mjs relative to this test file's real path
|
|
80
117
|
// (mirrors acp-session-plugins.test.ts's realpath dance for compiled roots).
|
|
81
118
|
const here = dirname(realpathSync(import.meta.url.replace('file://', '')));
|
|
@@ -88,4 +125,63 @@ describe('MAUDE_DEFAULT_ALLOWED_TOOLS — source-of-truth guard (DDR-184 / DDR-0
|
|
|
88
125
|
expect(src).toContain('BIN_VERBS');
|
|
89
126
|
expect(src).toMatch(/maude design <verb>/);
|
|
90
127
|
});
|
|
128
|
+
|
|
129
|
+
test('the `curl-local` verb exists and is reached via `maude design <verb>` (DDR-185)', () => {
|
|
130
|
+
// The mechanism the `Bash(agent-browser:*)`/fs-list comment in bridge.ts
|
|
131
|
+
// promises for curl: real host enforcement via a first-party verb, not a
|
|
132
|
+
// Bash prefix rule. If this verb disappears from BIN_VERBS, the DDR-185
|
|
133
|
+
// curl story (and the bootstrap-brief nudge pointing at it) goes stale.
|
|
134
|
+
const here = dirname(realpathSync(import.meta.url.replace('file://', '')));
|
|
135
|
+
const designCli = join(here, '..', '..', '..', 'cli', 'commands', 'design.mjs');
|
|
136
|
+
const src = readFileSync(designCli, 'utf8');
|
|
137
|
+
expect(src).toMatch(/'curl-local'/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('the `agent-browser-safe` verb exists and is reached via `maude design <verb>` (DDR-185 security addendum)', () => {
|
|
141
|
+
// The replacement for the reverted `Bash(agent-browser:*)` entry — real
|
|
142
|
+
// subcommand/flag enforcement via a first-party verb, not a Bash prefix
|
|
143
|
+
// rule. If this verb disappears from BIN_VERBS, motion-critic.md/edit.md's
|
|
144
|
+
// documented raw agent-browser calls go back to prompting (not silently
|
|
145
|
+
// insecure — but the friction-reduction this DDR promises goes stale).
|
|
146
|
+
const here = dirname(realpathSync(import.meta.url.replace('file://', '')));
|
|
147
|
+
const designCli = join(here, '..', '..', '..', 'cli', 'commands', 'design.mjs');
|
|
148
|
+
const src = readFileSync(designCli, 'utf8');
|
|
149
|
+
expect(src).toMatch(/'agent-browser-safe'/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('plugin markdown no longer documents a RAW agent-browser SCREENSHOT/OPEN/NAVIGATE call (DDR-185 security addendum)', () => {
|
|
153
|
+
// motion-critic.md and edit.md are the two files that originally justified
|
|
154
|
+
// `Bash(agent-browser:*)` (raw calls not reached via `maude design <verb>`).
|
|
155
|
+
// screenshot/open/navigate now go through the hardened wrapper — a
|
|
156
|
+
// regression here means either those calls silently went back to
|
|
157
|
+
// prompting every time, or (worse) someone re-introduced a raw call the
|
|
158
|
+
// allow-list no longer covers. `eval` is the deliberate exception (round
|
|
159
|
+
// 3: removed from the wrapper's own allow-list entirely — arbitrary JS
|
|
160
|
+
// execution can't be argv-constrained — so it's EXPECTED to stay a raw,
|
|
161
|
+
// prompting `agent-browser eval` call; see both files' own comments).
|
|
162
|
+
const here = dirname(realpathSync(import.meta.url.replace('file://', '')));
|
|
163
|
+
const pluginsDir = join(here, '..', '..', '..', 'plugins', 'design');
|
|
164
|
+
for (const rel of ['agents/motion-critic.md', 'commands/edit.md']) {
|
|
165
|
+
const src = readFileSync(join(pluginsDir, rel), 'utf8');
|
|
166
|
+
expect(src).not.toMatch(/(?<!maude design )agent-browser (screenshot|open|navigate)\b/);
|
|
167
|
+
expect(src).toContain('agent-browser eval'); // deliberately raw — see comment above
|
|
168
|
+
}
|
|
169
|
+
// Only motion-critic.md actually calls the wrapper (for `screenshot`) —
|
|
170
|
+
// edit.md's sole agent-browser use was `eval`, which reverted fully to
|
|
171
|
+
// raw, so it has no `agent-browser-safe` invocation left at all.
|
|
172
|
+
const motionCritic = readFileSync(join(pluginsDir, 'agents/motion-critic.md'), 'utf8');
|
|
173
|
+
expect(motionCritic).toContain('maude design agent-browser-safe');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('agent-browser-safe does NOT support eval (DDR-185 round-3 security addendum)', () => {
|
|
177
|
+
// A future accidental re-add of eval to the wrapper's own allow-list
|
|
178
|
+
// would silently reopen the zero-click session-hijack chain (ethical-
|
|
179
|
+
// hacker Finding 1: eval's fetch()/window.location defeat
|
|
180
|
+
// --allowed-domains entirely, and no argv check can constrain arbitrary
|
|
181
|
+
// JS execution). Source-of-truth guard against bridge.ts's own wrapper.
|
|
182
|
+
const here = dirname(realpathSync(import.meta.url.replace('file://', '')));
|
|
183
|
+
const wrapperPath = join(here, '..', 'bin', '_agent-browser-safe.mjs');
|
|
184
|
+
const src = readFileSync(wrapperPath, 'utf8');
|
|
185
|
+
expect(src).not.toMatch(/^\s*eval:\s*\[/m);
|
|
186
|
+
});
|
|
91
187
|
});
|
|
@@ -94,6 +94,12 @@ describe('newSessionParams — plugin carrier shape', () => {
|
|
|
94
94
|
const p = newSessionParams('/repo', undefined, PLUGINS);
|
|
95
95
|
expect((p._meta as Meta).claudeCode?.options?.settings?.enabledPlugins).toEqual({
|
|
96
96
|
'design@maude': false,
|
|
97
|
+
// kgai (third-party, injected by the desktop bundle for autonomous capture —
|
|
98
|
+
// feature-kgai-ecosystem-integration Phase 8). Suppressing a user-installed
|
|
99
|
+
// copy matters MORE here than for our own plugins: it would run its own
|
|
100
|
+
// SessionStart install.sh and point at a different engine version than the
|
|
101
|
+
// pinned, signed sidecar we ship.
|
|
102
|
+
kgai: false,
|
|
97
103
|
});
|
|
98
104
|
});
|
|
99
105
|
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// browse-posture — feature-4 (browse/move split, DDR-187). The LOAD-BEARING
|
|
2
|
+
// gate: a DOM-level proof that the boot-default `browse` tool is a pure native
|
|
3
|
+
// pass-through (a real click handler under the cursor fires, nothing is
|
|
4
|
+
// claimed), while the `move` (select) tool CLAIMS the same bare click (native
|
|
5
|
+
// handler suppressed, onSelect dispatched). This promotes the input-router
|
|
6
|
+
// comment-invariant ("bare clicks pass through") into an enforced test — the
|
|
7
|
+
// canvas-origin-gate equivalent for the input posture.
|
|
8
|
+
//
|
|
9
|
+
// Needs a live DOM (capture-phase listeners + event dispatch + a real React
|
|
10
|
+
// effect) — register happy-dom + drive a createRoot, same convention as
|
|
11
|
+
// canvas-hmr-runtime.test.tsx / specimen-select.test.ts.
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
14
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator';
|
|
15
|
+
|
|
16
|
+
beforeAll(() => {
|
|
17
|
+
GlobalRegistrator.register();
|
|
18
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
19
|
+
});
|
|
20
|
+
afterAll(() => {
|
|
21
|
+
GlobalRegistrator.unregister();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
import { act, useRef } from 'react';
|
|
25
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
26
|
+
|
|
27
|
+
import { type RouterCallbacks, type Tool, useInputRouter } from '../input-router.tsx';
|
|
28
|
+
|
|
29
|
+
// A minimal host that mounts the router over a single native <button>. The
|
|
30
|
+
// active tool is read fresh at event time via `getActiveTool` (a ref-backed
|
|
31
|
+
// getter), so a test can flip the tool without re-rendering.
|
|
32
|
+
function Harness({ getTool, callbacks }: { getTool: () => Tool; callbacks: RouterCallbacks }) {
|
|
33
|
+
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
34
|
+
useInputRouter({ hostRef, getActiveTool: getTool, callbacks });
|
|
35
|
+
return (
|
|
36
|
+
<div ref={hostRef} className="dc-canvas">
|
|
37
|
+
<button type="button" id="native-btn">
|
|
38
|
+
Click me
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fireBareLeftClick(el: Element): void {
|
|
45
|
+
// The full native sequence the router gates: pointerdown → mousedown → click.
|
|
46
|
+
el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: 0 }));
|
|
47
|
+
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0 }));
|
|
48
|
+
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('browse tool — native pass-through (boot default)', () => {
|
|
52
|
+
test('bare click: native button handler FIRES, onSelect NOT dispatched', () => {
|
|
53
|
+
const el = document.createElement('div');
|
|
54
|
+
document.body.appendChild(el);
|
|
55
|
+
const root: Root = createRoot(el);
|
|
56
|
+
let selectCalls = 0;
|
|
57
|
+
let nativeClicks = 0;
|
|
58
|
+
|
|
59
|
+
act(() => {
|
|
60
|
+
root.render(
|
|
61
|
+
<Harness getTool={() => 'browse'} callbacks={{ onSelect: () => (selectCalls += 1) }} />
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const btn = el.querySelector('#native-btn') as HTMLButtonElement;
|
|
66
|
+
btn.addEventListener('click', () => (nativeClicks += 1));
|
|
67
|
+
act(() => {
|
|
68
|
+
fireBareLeftClick(btn);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(selectCalls).toBe(0); // browse never selects
|
|
72
|
+
expect(nativeClicks).toBe(1); // the mock stays alive
|
|
73
|
+
|
|
74
|
+
act(() => root.unmount());
|
|
75
|
+
el.remove();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('move (select) tool — claims the bare click', () => {
|
|
80
|
+
test('bare click: native button handler SUPPRESSED, onSelect dispatched', () => {
|
|
81
|
+
const el = document.createElement('div');
|
|
82
|
+
document.body.appendChild(el);
|
|
83
|
+
const root: Root = createRoot(el);
|
|
84
|
+
let selectCalls = 0;
|
|
85
|
+
let nativeClicks = 0;
|
|
86
|
+
|
|
87
|
+
act(() => {
|
|
88
|
+
root.render(
|
|
89
|
+
<Harness getTool={() => 'move'} callbacks={{ onSelect: () => (selectCalls += 1) }} />
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const btn = el.querySelector('#native-btn') as HTMLButtonElement;
|
|
94
|
+
btn.addEventListener('click', () => (nativeClicks += 1));
|
|
95
|
+
act(() => {
|
|
96
|
+
fireBareLeftClick(btn);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Router claims the pointerdown (dispatches select) and swallows the
|
|
100
|
+
// synthetic click so the button under the cursor never activates.
|
|
101
|
+
expect(selectCalls).toBe(1);
|
|
102
|
+
expect(nativeClicks).toBe(0);
|
|
103
|
+
|
|
104
|
+
act(() => root.unmount());
|
|
105
|
+
el.remove();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// canvas-hide-chrome.test.ts — regression guard for RCA
|
|
2
|
+
// issue-pdf-print-export-marks-missing (Bug A). `_shell.html`'s
|
|
3
|
+
// `#canvas-hide-chrome` style block is the sanctioned "never leaks into an
|
|
4
|
+
// export" list every exporter relies on (`?hide-chrome=1` flips it from
|
|
5
|
+
// `media="not all"` to `media="all"` — see exporters/index.ts
|
|
6
|
+
// canvasShellUrl()). `.dc-artboard-guides` (the print bleed/trim/margin guide
|
|
7
|
+
// overlay, artboard-guides-overlay.tsx) was never added to this list when
|
|
8
|
+
// feature-2-print-artboards shipped, so it rendered straight into every PDF/
|
|
9
|
+
// PNG/SVG capture whenever a canvas had it toggled on. Grep-based, mirrors
|
|
10
|
+
// shell-importmap.test.ts's own style for the same file.
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { DEV_SERVER_ROOT } from '../paths.ts';
|
|
17
|
+
|
|
18
|
+
const SHELL_HTML_PATH = join(
|
|
19
|
+
DEV_SERVER_ROOT,
|
|
20
|
+
'..',
|
|
21
|
+
'..',
|
|
22
|
+
'plugins',
|
|
23
|
+
'design',
|
|
24
|
+
'templates',
|
|
25
|
+
'_shell.html'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
describe('_shell.html #canvas-hide-chrome covers every overlay mount class', () => {
|
|
29
|
+
const shellHtml = readFileSync(SHELL_HTML_PATH, 'utf8');
|
|
30
|
+
const blockMatch = shellHtml.match(/<style id="canvas-hide-chrome"[^>]*>([\s\S]*?)<\/style>/);
|
|
31
|
+
|
|
32
|
+
test('the #canvas-hide-chrome style block exists', () => {
|
|
33
|
+
expect(blockMatch).not.toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const block = blockMatch?.[1] ?? '';
|
|
37
|
+
|
|
38
|
+
// Every editor-chrome overlay class that must never leak into a capture
|
|
39
|
+
// render. `.dc-artboard-guides` is the one this RCA adds; the rest already
|
|
40
|
+
// guard against the same class of regression for their own overlays.
|
|
41
|
+
const REQUIRED_SELECTORS = [
|
|
42
|
+
'.dc-mm',
|
|
43
|
+
'.dc-tool-palette',
|
|
44
|
+
'.dc-participants',
|
|
45
|
+
'.dc-cv-halo',
|
|
46
|
+
'.dc-snap-guide',
|
|
47
|
+
'.dc-annot-svg',
|
|
48
|
+
'.cm-layer',
|
|
49
|
+
'.dc-artboard-label',
|
|
50
|
+
'.dc-artboard-guides',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
for (const selector of REQUIRED_SELECTORS) {
|
|
54
|
+
test(`"${selector}" is hidden during capture`, () => {
|
|
55
|
+
expect(block.includes(selector)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
@@ -524,3 +524,73 @@ describe('/_api/canvas-meta — overlays lane (T6)', () => {
|
|
|
524
524
|
}
|
|
525
525
|
});
|
|
526
526
|
});
|
|
527
|
+
|
|
528
|
+
// feature-4 T7b — `locked` (per-user locked layer keys) is the third view-file
|
|
529
|
+
// lane: same split contract as viewport/overlays (never the versioned meta),
|
|
530
|
+
// REPLACE semantics (the client sends the full set), bounded shape.
|
|
531
|
+
describe('/_api/canvas-meta — locked lane (feature-4 T7b)', () => {
|
|
532
|
+
test('PATCH locked leaves meta byte-unchanged + writes the view file; GET merges it back', async () => {
|
|
533
|
+
const { root, designRoot } = makeSandbox();
|
|
534
|
+
const port = nextPort();
|
|
535
|
+
const proc = await bootServer(root, port);
|
|
536
|
+
try {
|
|
537
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
538
|
+
const tsxAbs = join(designRoot, 'ui', 'Locked.tsx');
|
|
539
|
+
writeFileSync(tsxAbs, 'export default function L(){return <main/>}\n');
|
|
540
|
+
const metaAbs = tsxAbs.replace(/\.tsx$/, '.meta.json');
|
|
541
|
+
writeFileSync(metaAbs, JSON.stringify({ title: 'Locked', sections: [] }));
|
|
542
|
+
const before = readFileSync(metaAbs, 'utf8');
|
|
543
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
544
|
+
|
|
545
|
+
const r = await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
546
|
+
method: 'PATCH',
|
|
547
|
+
headers: { 'content-type': 'application/json' },
|
|
548
|
+
body: JSON.stringify({ file, patch: { locked: ['aabbccdd:0', 'deadbeef:2'] } }),
|
|
549
|
+
});
|
|
550
|
+
expect(r.status).toBe(200);
|
|
551
|
+
const merged = (await r.json()) as MetaShape;
|
|
552
|
+
expect(merged.locked).toEqual(['aabbccdd:0', 'deadbeef:2']);
|
|
553
|
+
// Versioned meta byte-identical (runtime state never lands there).
|
|
554
|
+
expect(readFileSync(metaAbs, 'utf8')).toBe(before);
|
|
555
|
+
const view = JSON.parse(readFileSync(viewPath(designRoot, 'ui-locked'), 'utf8'));
|
|
556
|
+
expect(view.locked).toEqual(['aabbccdd:0', 'deadbeef:2']);
|
|
557
|
+
|
|
558
|
+
// REPLACE semantics — a smaller set unlocks the dropped key.
|
|
559
|
+
await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
560
|
+
method: 'PATCH',
|
|
561
|
+
headers: { 'content-type': 'application/json' },
|
|
562
|
+
body: JSON.stringify({ file, patch: { locked: ['aabbccdd:0'] } }),
|
|
563
|
+
});
|
|
564
|
+
const g = await fetch(
|
|
565
|
+
`http://localhost:${port}/_api/canvas-meta?file=${encodeURIComponent(file)}`
|
|
566
|
+
);
|
|
567
|
+
expect(((await g.json()) as MetaShape).locked).toEqual(['aabbccdd:0']);
|
|
568
|
+
} finally {
|
|
569
|
+
await killProc(proc);
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test('malformed locked keys are a silent no-op (shape-gated)', async () => {
|
|
574
|
+
const { root, designRoot } = makeSandbox();
|
|
575
|
+
const port = nextPort();
|
|
576
|
+
const proc = await bootServer(root, port);
|
|
577
|
+
try {
|
|
578
|
+
mkdirSync(join(designRoot, 'ui'), { recursive: true });
|
|
579
|
+
const tsxAbs = join(designRoot, 'ui', 'BadLock.tsx');
|
|
580
|
+
writeFileSync(tsxAbs, 'export default function B(){return <main/>}\n');
|
|
581
|
+
writeFileSync(tsxAbs.replace(/\.tsx$/, '.meta.json'), JSON.stringify({ title: 'B' }));
|
|
582
|
+
const file = repoRel(designRoot, tsxAbs);
|
|
583
|
+
await fetch(`http://localhost:${port}/_api/canvas-meta`, {
|
|
584
|
+
method: 'PATCH',
|
|
585
|
+
headers: { 'content-type': 'application/json' },
|
|
586
|
+
body: JSON.stringify({ file, patch: { locked: ['../../etc/passwd', 'not-a-key'] } }),
|
|
587
|
+
});
|
|
588
|
+
const g = await fetch(
|
|
589
|
+
`http://localhost:${port}/_api/canvas-meta?file=${encodeURIComponent(file)}`
|
|
590
|
+
);
|
|
591
|
+
expect(((await g.json()) as MetaShape).locked).toBeUndefined();
|
|
592
|
+
} finally {
|
|
593
|
+
await killProc(proc);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
});
|
|
@@ -111,6 +111,10 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
111
111
|
'/_api/insert-artboard',
|
|
112
112
|
'/_api/resize-artboard',
|
|
113
113
|
'/_api/delete-artboard',
|
|
114
|
+
// feature-3-web-artboards T3 — "Duplicate at width…" is a source-write,
|
|
115
|
+
// same MAIN-ORIGIN-ONLY posture as the other artboard structural ops
|
|
116
|
+
// right above.
|
|
117
|
+
'/_api/duplicate-artboard',
|
|
114
118
|
// Stage F1 — the AssetPicker's asset-listing GET is a shell (main-origin)
|
|
115
119
|
// concern; the untrusted canvas iframe must not enumerate project media.
|
|
116
120
|
'/_api/assets',
|
|
@@ -39,7 +39,8 @@ describe('comment-mount / MaybeToolProvider dedup', () => {
|
|
|
39
39
|
const html = renderToStaticMarkup(
|
|
40
40
|
createElement(MaybeToolProvider, null, createElement(ToolReader))
|
|
41
41
|
);
|
|
42
|
-
|
|
42
|
+
// feature-4 (browse/move split) — the default boot tool is `browse`.
|
|
43
|
+
expect(html).toContain('data-tool="browse"');
|
|
43
44
|
});
|
|
44
45
|
});
|
|
45
46
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// component-map — feature-4 T7a. The Layers panel's purple-instance source:
|
|
2
|
+
// `componentMapForCanvas` reports every element that renders through an
|
|
3
|
+
// INSTANTIATED component (`<Card/>` referenced as JSX in the file), keyed by
|
|
4
|
+
// cd-id. The top-level canvas component (exported/mounted, never referenced as
|
|
5
|
+
// JSX here) must NOT mark its elements as instances.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
|
|
9
|
+
import { componentMapForCanvas } from '../canvas-edit.ts';
|
|
10
|
+
import { transpileCanvasSource } from '../canvas-pipeline.ts';
|
|
11
|
+
|
|
12
|
+
const CANVAS = '/abs/Canvas.tsx';
|
|
13
|
+
|
|
14
|
+
function idsOf(source: string): Record<string, string> {
|
|
15
|
+
const { withIds } = transpileCanvasSource(CANVAS, source);
|
|
16
|
+
const out: Record<string, string> = {};
|
|
17
|
+
for (const m of withIds.matchAll(/<(\w+)([^>]*?)data-cd-id="([0-9a-f]{8})"/g)) {
|
|
18
|
+
if (!out[m[1] as string]) out[m[1] as string] = m[3] as string;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('canvas-edit / componentMapForCanvas', () => {
|
|
24
|
+
test('elements of an instantiated component are instances; the canvas root is not', () => {
|
|
25
|
+
const src = `
|
|
26
|
+
function Card() { return <article className="card"><h2>Hi</h2></article>; }
|
|
27
|
+
function Demo() { return <div><Card /><Card /><p>plain</p></div>; }
|
|
28
|
+
`;
|
|
29
|
+
const ids = idsOf(src);
|
|
30
|
+
const map = componentMapForCanvas(CANVAS, src);
|
|
31
|
+
// Card's own elements → instances of Card (2 usages); article is the root.
|
|
32
|
+
expect(map[ids.article as string]).toEqual({ component: 'Card', root: true, usages: 2 });
|
|
33
|
+
expect(map[ids.h2 as string]).toEqual({ component: 'Card', root: false, usages: 2 });
|
|
34
|
+
// Demo (the canvas component, never referenced as JSX) → NOT in the map.
|
|
35
|
+
expect(map[ids.div as string]).toBeUndefined();
|
|
36
|
+
expect(map[ids.p as string]).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('no components → empty map', () => {
|
|
40
|
+
const src = `function Demo() { return <div><h1>T</h1></div>; }`;
|
|
41
|
+
const map = componentMapForCanvas(CANVAS, src);
|
|
42
|
+
expect(Object.keys(map)).toHaveLength(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('unparsable source → empty map (no throw)', () => {
|
|
46
|
+
expect(componentMapForCanvas(CANVAS, 'function {{{')).toEqual({});
|
|
47
|
+
});
|
|
48
|
+
});
|