@1agh/maude 0.37.0 → 0.38.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 +2 -0
- package/apps/studio/acp/bootstrap-brief.ts +93 -0
- package/apps/studio/acp/bridge.ts +114 -1
- package/apps/studio/acp/index.ts +184 -21
- package/apps/studio/acp/plugin-bootstrap.ts +115 -0
- package/apps/studio/acp/transcript.ts +36 -3
- package/apps/studio/activity.ts +45 -0
- package/apps/studio/api.ts +265 -47
- package/apps/studio/bin/_ensure-browser.mjs +305 -0
- package/apps/studio/bin/ensure-browser.sh +26 -0
- package/apps/studio/bin/screenshot.sh +33 -8
- package/apps/studio/bin/smoke.sh +46 -0
- package/apps/studio/canvas-edit.ts +422 -6
- package/apps/studio/canvas-lib.tsx +48 -0
- package/apps/studio/canvas-shell.tsx +684 -12
- package/apps/studio/client/app.jsx +683 -33
- package/apps/studio/client/panels/ChatPanel.jsx +593 -31
- package/apps/studio/client/panels/acp-runtime.js +227 -70
- package/apps/studio/client/panels/chat-context.js +124 -0
- package/apps/studio/client/panels/slash-commands.js +147 -0
- package/apps/studio/client/styles/3-shell-maude.css +15 -0
- package/apps/studio/client/styles/6-acp-chat.css +244 -0
- package/apps/studio/commands/reorder-command.ts +77 -0
- package/apps/studio/config.schema.json +6 -0
- package/apps/studio/dist/client.bundle.js +25 -53617
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +27 -19
- package/apps/studio/http.ts +168 -26
- package/apps/studio/inspect.ts +108 -1
- package/apps/studio/paths.ts +32 -0
- package/apps/studio/readiness.ts +79 -30
- package/apps/studio/server.ts +11 -2
- package/apps/studio/test/acp-activity.test.ts +154 -0
- package/apps/studio/test/acp-ai-activity.test.ts +182 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +167 -0
- package/apps/studio/test/acp-commands.test.ts +108 -0
- package/apps/studio/test/acp-origin-gate.test.ts +64 -1
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +89 -0
- package/apps/studio/test/acp-session-plugins.test.ts +132 -0
- package/apps/studio/test/acp-transcript.test.ts +53 -0
- package/apps/studio/test/active-state.test.ts +41 -0
- package/apps/studio/test/canvas-freshness-deps.test.ts +64 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/canvas-reorder.test.ts +211 -0
- package/apps/studio/test/chat-context.test.ts +129 -0
- package/apps/studio/test/csrf-write-guard.test.ts +24 -0
- package/apps/studio/test/edit-suppress.test.ts +170 -0
- package/apps/studio/test/ensure-browser.test.ts +92 -0
- package/apps/studio/test/fixtures/mock-acp-agent-commands.mjs +44 -0
- package/apps/studio/test/hmr-broadcast.test.ts +44 -0
- package/apps/studio/test/inspect-selections.test.ts +219 -0
- package/apps/studio/test/paths.test.ts +57 -0
- package/apps/studio/test/readiness.test.ts +83 -13
- package/apps/studio/test/reorder-api.test.ts +210 -0
- package/apps/studio/test/slash-commands.test.ts +117 -0
- package/apps/studio/undo-stack.ts +6 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +37 -0
- package/cli/commands/design.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +3 -3
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// _ensure-browser.mjs — resolve (and if needed download) a headless Chromium for
|
|
2
|
+
// screenshots. Prints the resolved browser executable path to stdout.
|
|
3
|
+
//
|
|
4
|
+
// Screenshots (agent-browser preferred, playwright fallback) need a Chrome-family
|
|
5
|
+
// engine. On a fresh desktop machine there's no globally-installed browser, so we
|
|
6
|
+
// provision `chrome-headless-shell` — the ~94 MB headless-ONLY Chrome-for-Testing
|
|
7
|
+
// build (vs ~172 MB full Chrome) — on first need into a machine-global Maude
|
|
8
|
+
// cache, and screenshot.sh points `AGENT_BROWSER_EXECUTABLE_PATH` at it. Verified
|
|
9
|
+
// end-to-end: agent-browser + a headless-shell renders a Maude canvas faithfully.
|
|
10
|
+
//
|
|
11
|
+
// Resolution priority (first hit wins):
|
|
12
|
+
// 1. MAUDE_BROWSER_EXECUTABLE / AGENT_BROWSER_EXECUTABLE_PATH override (if valid)
|
|
13
|
+
// 2. a Maude-cached chrome-headless-shell (previous provision)
|
|
14
|
+
// 3. a Playwright chromium-headless-shell already on disk (~/.cache/ms-playwright)
|
|
15
|
+
// 4. system Google Chrome / Chromium
|
|
16
|
+
// 5. download chrome-headless-shell → cache (skipped under --no-download)
|
|
17
|
+
//
|
|
18
|
+
// Flags: --no-download (resolve only — for readiness probes, never triggers a
|
|
19
|
+
// 94 MB fetch) · --json · --quiet.
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
22
|
+
import {
|
|
23
|
+
chmodSync,
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
rmSync,
|
|
29
|
+
statSync,
|
|
30
|
+
} from 'node:fs';
|
|
31
|
+
import { homedir, tmpdir } from 'node:os';
|
|
32
|
+
import { basename, join } from 'node:path';
|
|
33
|
+
import { pathToFileURL } from 'node:url';
|
|
34
|
+
|
|
35
|
+
const args = process.argv.slice(2);
|
|
36
|
+
const NO_DOWNLOAD = args.includes('--no-download');
|
|
37
|
+
const JSON_OUT = args.includes('--json');
|
|
38
|
+
const QUIET = args.includes('--quiet') || JSON_OUT;
|
|
39
|
+
|
|
40
|
+
const log = (m) => {
|
|
41
|
+
if (!QUIET) process.stderr.write(`${m}\n`);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const CDN_VERSIONS =
|
|
45
|
+
'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json';
|
|
46
|
+
|
|
47
|
+
// The download URL comes from a remote manifest and its bytes are chmod +x'd and
|
|
48
|
+
// executed — pin the scheme + host so a tampered/redirecting manifest can't point
|
|
49
|
+
// us at http:// or a foreign host (defender F2). Chrome-for-Testing artifacts live
|
|
50
|
+
// on storage.googleapis.com; the manifest on googlechromelabs.github.io.
|
|
51
|
+
export const CDN_HOSTS = new Set(['storage.googleapis.com', 'googlechromelabs.github.io']);
|
|
52
|
+
// The CfT version string flows into cache paths and (on Windows) a PowerShell
|
|
53
|
+
// `-Command` string, so validate it before ANY use (defender F3).
|
|
54
|
+
export const CFT_VERSION_RE = /^[0-9]+(?:\.[0-9]+)*$/;
|
|
55
|
+
|
|
56
|
+
/** True when a resolved download URL is https + an allowlisted CDN host (defender F2). */
|
|
57
|
+
export function isTrustedDownloadUrl(url) {
|
|
58
|
+
try {
|
|
59
|
+
const u = new URL(url);
|
|
60
|
+
return u.protocol === 'https:' && CDN_HOSTS.has(u.hostname);
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Machine-global cache (shared across projects) — NOT the per-project .ai/cache. */
|
|
67
|
+
function browsersDir() {
|
|
68
|
+
return process.env.MAUDE_BROWSERS_DIR || join(homedir(), '.maude', 'browsers');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** node/bun platform → Chrome-for-Testing platform slug (null = no CfT build). */
|
|
72
|
+
function cftPlatform() {
|
|
73
|
+
const p = process.platform;
|
|
74
|
+
const a = process.arch;
|
|
75
|
+
if (p === 'darwin') return a === 'arm64' ? 'mac-arm64' : 'mac-x64';
|
|
76
|
+
if (p === 'win32') return a === 'arm64' ? null : 'win64';
|
|
77
|
+
if (p === 'linux') return a === 'arm64' ? null : 'linux64'; // CfT ships no linux-arm64
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The headless-shell executable's basename for this platform. */
|
|
82
|
+
function shellExeName() {
|
|
83
|
+
return process.platform === 'win32' ? 'chrome-headless-shell.exe' : 'chrome-headless-shell';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isRunnable(p) {
|
|
87
|
+
try {
|
|
88
|
+
return !!p && existsSync(p) && statSync(p).isFile();
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Recursively find the first `chrome-headless-shell[.exe]` under dir. */
|
|
95
|
+
function findShell(dir) {
|
|
96
|
+
if (!existsSync(dir)) return null;
|
|
97
|
+
const want = shellExeName();
|
|
98
|
+
const stack = [dir];
|
|
99
|
+
while (stack.length) {
|
|
100
|
+
const cur = stack.pop();
|
|
101
|
+
let entries;
|
|
102
|
+
try {
|
|
103
|
+
entries = readdirSync(cur, { withFileTypes: true });
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
for (const e of entries) {
|
|
108
|
+
const full = join(cur, e.name);
|
|
109
|
+
if (e.isDirectory()) stack.push(full);
|
|
110
|
+
else if (e.name === want && isRunnable(full)) return full;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** (2) A previously-provisioned headless-shell in the Maude cache. */
|
|
117
|
+
function cachedShell() {
|
|
118
|
+
return findShell(browsersDir());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** (3) A Playwright chromium-headless-shell already installed. */
|
|
122
|
+
function playwrightShell() {
|
|
123
|
+
const roots = [
|
|
124
|
+
process.env.PLAYWRIGHT_BROWSERS_PATH,
|
|
125
|
+
join(homedir(), 'Library', 'Caches', 'ms-playwright'), // macOS
|
|
126
|
+
join(homedir(), '.cache', 'ms-playwright'), // linux
|
|
127
|
+
join(process.env.LOCALAPPDATA || '', 'ms-playwright'), // windows
|
|
128
|
+
].filter(Boolean);
|
|
129
|
+
for (const r of roots) {
|
|
130
|
+
const hit = findShell(r);
|
|
131
|
+
if (hit) return hit;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** (4) System Chrome / Chromium — a full browser is fine for headless capture. */
|
|
137
|
+
function systemChrome() {
|
|
138
|
+
const candidates =
|
|
139
|
+
process.platform === 'darwin'
|
|
140
|
+
? [
|
|
141
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
142
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
143
|
+
'/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
|
|
144
|
+
]
|
|
145
|
+
: process.platform === 'win32'
|
|
146
|
+
? [
|
|
147
|
+
join(
|
|
148
|
+
process.env.PROGRAMFILES || 'C:/Program Files',
|
|
149
|
+
'Google/Chrome/Application/chrome.exe'
|
|
150
|
+
),
|
|
151
|
+
join(
|
|
152
|
+
process.env['PROGRAMFILES(X86)'] || 'C:/Program Files (x86)',
|
|
153
|
+
'Google/Chrome/Application/chrome.exe'
|
|
154
|
+
),
|
|
155
|
+
]
|
|
156
|
+
: [];
|
|
157
|
+
for (const c of candidates) if (isRunnable(c)) return c;
|
|
158
|
+
// PATH lookup (linux `google-chrome` / `chromium`, or any platform).
|
|
159
|
+
for (const bin of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']) {
|
|
160
|
+
try {
|
|
161
|
+
const p = execFileSync(process.platform === 'win32' ? 'where' : 'command', ['-v', bin], {
|
|
162
|
+
encoding: 'utf8',
|
|
163
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
164
|
+
})
|
|
165
|
+
.split('\n')[0]
|
|
166
|
+
.trim();
|
|
167
|
+
if (isRunnable(p)) return p;
|
|
168
|
+
} catch {
|
|
169
|
+
/* not on PATH */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function unzip(zipPath, destDir) {
|
|
176
|
+
mkdirSync(destDir, { recursive: true });
|
|
177
|
+
// Prefer `unzip` (macOS/Linux); fall back to bsdtar (`tar -xf` handles zip on
|
|
178
|
+
// macOS + Windows 10+), then PowerShell on Windows.
|
|
179
|
+
const attempts =
|
|
180
|
+
process.platform === 'win32'
|
|
181
|
+
? [
|
|
182
|
+
['tar', ['-xf', zipPath, '-C', destDir]],
|
|
183
|
+
[
|
|
184
|
+
'powershell',
|
|
185
|
+
[
|
|
186
|
+
'-NoProfile',
|
|
187
|
+
'-Command',
|
|
188
|
+
`Expand-Archive -Force -Path '${zipPath}' -DestinationPath '${destDir}'`,
|
|
189
|
+
],
|
|
190
|
+
],
|
|
191
|
+
]
|
|
192
|
+
: [
|
|
193
|
+
['unzip', ['-q', '-o', zipPath, '-d', destDir]],
|
|
194
|
+
['tar', ['-xf', zipPath, '-C', destDir]],
|
|
195
|
+
];
|
|
196
|
+
let lastErr;
|
|
197
|
+
for (const [cmd, a] of attempts) {
|
|
198
|
+
try {
|
|
199
|
+
execFileSync(cmd, a, { stdio: 'ignore' });
|
|
200
|
+
return;
|
|
201
|
+
} catch (e) {
|
|
202
|
+
lastErr = e;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
throw new Error(`unzip failed (${lastErr?.message ?? 'no extractor'})`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** (5) Download chrome-headless-shell for this platform into the Maude cache. */
|
|
209
|
+
async function downloadShell() {
|
|
210
|
+
const plat = cftPlatform();
|
|
211
|
+
if (!plat) {
|
|
212
|
+
log(`ensure-browser: no Chrome-for-Testing build for ${process.platform}/${process.arch}`);
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
log('ensure-browser: no browser found — downloading chrome-headless-shell (~94 MB, one-time)…');
|
|
216
|
+
const versions = await fetch(CDN_VERSIONS).then((r) => r.json());
|
|
217
|
+
const stable = versions.channels?.Stable;
|
|
218
|
+
const version = stable?.version;
|
|
219
|
+
// F3 — reject a suspicious version before it reaches a path or a shell string.
|
|
220
|
+
if (typeof version !== 'string' || !CFT_VERSION_RE.test(version)) {
|
|
221
|
+
log(`ensure-browser: refusing malformed Chrome-for-Testing version ${JSON.stringify(version)}`);
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
const url = (stable?.downloads?.['chrome-headless-shell'] || []).find(
|
|
225
|
+
(d) => d.platform === plat
|
|
226
|
+
)?.url;
|
|
227
|
+
if (!url) {
|
|
228
|
+
log(`ensure-browser: CDN has no chrome-headless-shell for ${plat}`);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
// F2 — the resolved URL is remote-controlled + its bytes get executed. Pin
|
|
232
|
+
// https + an allowlisted host so a tampered manifest/redirect can't land a
|
|
233
|
+
// foreign or plaintext-fetched binary. (CDN-of-record compromise is the
|
|
234
|
+
// accepted residual — same trust anchor Playwright/Puppeteer rely on.)
|
|
235
|
+
if (!isTrustedDownloadUrl(url)) {
|
|
236
|
+
log(`ensure-browser: refusing non-allowlisted download URL ${url}`);
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
// F3 — the cache is exec-forever, so keep it 0700 (only this user can plant a
|
|
240
|
+
// binary that later runs as the screenshot engine). Created before the download.
|
|
241
|
+
mkdirSync(browsersDir(), { recursive: true, mode: 0o700 });
|
|
242
|
+
try {
|
|
243
|
+
chmodSync(browsersDir(), 0o700);
|
|
244
|
+
} catch {
|
|
245
|
+
/* pre-existing dir owned by another mode — best-effort */
|
|
246
|
+
}
|
|
247
|
+
const dest = join(browsersDir(), `chrome-headless-shell-${version}-${plat}`);
|
|
248
|
+
if (findShell(dest)) return findShell(dest); // concurrent provision won the race
|
|
249
|
+
const tmp = join(tmpdir(), `maude-hshell-${version}-${plat}.zip`);
|
|
250
|
+
// F1 — do NOT follow redirects: the host allowlist above validated THIS url, but
|
|
251
|
+
// a 3xx would bounce us to an unvalidated host. The CfT CDN serves the artifact
|
|
252
|
+
// directly (200), so redirect:'error' fails safe instead of chasing a redirect.
|
|
253
|
+
const buf = Buffer.from(await fetch(url, { redirect: 'error' }).then((r) => r.arrayBuffer()));
|
|
254
|
+
const { writeFileSync } = await import('node:fs');
|
|
255
|
+
writeFileSync(tmp, buf);
|
|
256
|
+
// Extract to a sibling temp dir, then atomically rename into place.
|
|
257
|
+
const staging = `${dest}.staging-${process.pid}`;
|
|
258
|
+
rmSync(staging, { recursive: true, force: true });
|
|
259
|
+
unzip(tmp, staging);
|
|
260
|
+
rmSync(tmp, { force: true });
|
|
261
|
+
rmSync(dest, { recursive: true, force: true });
|
|
262
|
+
renameSync(staging, dest);
|
|
263
|
+
const exe = findShell(dest);
|
|
264
|
+
if (exe && process.platform !== 'win32') chmodSync(exe, 0o755);
|
|
265
|
+
log(`ensure-browser: installed → ${exe}`);
|
|
266
|
+
return exe;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Resolve a screenshot browser executable. `download:false` resolves an EXISTING
|
|
271
|
+
* browser only (for readiness probes — never fetches ~94 MB). Exported so the
|
|
272
|
+
* dev-server readiness probe can resolve in-process without spawning.
|
|
273
|
+
*/
|
|
274
|
+
export async function resolveBrowser({ download = true } = {}) {
|
|
275
|
+
// (1) explicit override
|
|
276
|
+
const override =
|
|
277
|
+
process.env.MAUDE_BROWSER_EXECUTABLE || process.env.AGENT_BROWSER_EXECUTABLE_PATH;
|
|
278
|
+
if (isRunnable(override)) return { path: override, source: 'override' };
|
|
279
|
+
// (2) Maude cache
|
|
280
|
+
const cached = cachedShell();
|
|
281
|
+
if (cached) return { path: cached, source: 'cache' };
|
|
282
|
+
// (3) Playwright headless-shell
|
|
283
|
+
const pw = playwrightShell();
|
|
284
|
+
if (pw) return { path: pw, source: 'playwright' };
|
|
285
|
+
// (4) system Chrome
|
|
286
|
+
const sys = systemChrome();
|
|
287
|
+
if (sys) return { path: sys, source: 'system' };
|
|
288
|
+
// (5) download (unless probing)
|
|
289
|
+
if (!download) return { path: null, source: 'none' };
|
|
290
|
+
const dl = await downloadShell();
|
|
291
|
+
return { path: dl, source: dl ? 'downloaded' : 'none' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// CLI entry — only when invoked directly (not when imported by readiness.ts).
|
|
295
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
296
|
+
const result = await resolveBrowser({ download: !NO_DOWNLOAD });
|
|
297
|
+
if (JSON_OUT) {
|
|
298
|
+
process.stdout.write(
|
|
299
|
+
`${JSON.stringify({ ...result, name: result.path ? basename(result.path) : null })}\n`
|
|
300
|
+
);
|
|
301
|
+
} else if (result.path) {
|
|
302
|
+
process.stdout.write(`${result.path}\n`);
|
|
303
|
+
}
|
|
304
|
+
process.exit(result.path ? 0 : 1);
|
|
305
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ensure-browser.sh — resolve (and if needed download) a headless Chromium for
|
|
3
|
+
# screenshots; prints the browser executable path to stdout. Reached via
|
|
4
|
+
# `maude design ensure-browser` (DDR-062), never a raw bin path.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# ensure-browser.sh [--no-download] [--json] [--quiet]
|
|
8
|
+
#
|
|
9
|
+
# --no-download resolves an already-present browser only (never fetches ~94 MB) —
|
|
10
|
+
# use it from readiness probes. Default may download chrome-headless-shell into
|
|
11
|
+
# the Maude browsers cache (~/.maude/browsers, override MAUDE_BROWSERS_DIR).
|
|
12
|
+
# Exit 0 with the path on stdout when a browser is available; exit 1 otherwise.
|
|
13
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
14
|
+
|
|
15
|
+
case "$1" in
|
|
16
|
+
--help|-h) sed -n '2,12p' "$0" | sed 's/^# \?//'; exit 0 ;;
|
|
17
|
+
esac
|
|
18
|
+
|
|
19
|
+
if command -v node >/dev/null 2>&1; then
|
|
20
|
+
exec node "$SCRIPT_DIR/_ensure-browser.mjs" "$@"
|
|
21
|
+
elif command -v bun >/dev/null 2>&1; then
|
|
22
|
+
exec bun run "$SCRIPT_DIR/_ensure-browser.mjs" "$@"
|
|
23
|
+
else
|
|
24
|
+
echo "ensure-browser.sh: node or bun is required to resolve/download the screenshot browser." >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
@@ -126,13 +126,38 @@ case "$MODE" in
|
|
|
126
126
|
esac
|
|
127
127
|
|
|
128
128
|
# ---------- engine resolution ----------
|
|
129
|
+
# Prefer the bundled agent-browser the desktop sidecar pins via MAUDE_AGENT_BROWSER
|
|
130
|
+
# (DDR-144 attacker-F4: an explicit single-binary pointer, NOT a PATH prepend that
|
|
131
|
+
# would let a same-user attacker shadow node/chrome in the app dir), else the one
|
|
132
|
+
# on PATH.
|
|
133
|
+
AB="${MAUDE_AGENT_BROWSER:-agent-browser}"
|
|
129
134
|
if [ "$ENGINE" = "auto" ]; then
|
|
130
|
-
if command -v
|
|
135
|
+
if command -v "$AB" >/dev/null 2>&1; then
|
|
131
136
|
ENGINE="agent-browser"
|
|
132
137
|
else
|
|
133
138
|
ENGINE="playwright"
|
|
134
139
|
fi
|
|
135
140
|
fi
|
|
141
|
+
|
|
142
|
+
# ---------- browser resolution for agent-browser (DDR — bundled screenshots) ----------
|
|
143
|
+
# agent-browser needs a Chrome-family engine. On a fresh desktop machine there's
|
|
144
|
+
# no system Chrome, so resolve (or, on the desktop path only, one-time download)
|
|
145
|
+
# `chrome-headless-shell` and point agent-browser at it via
|
|
146
|
+
# AGENT_BROWSER_EXECUTABLE_PATH. Honor a caller-set value; leave the web/CLI path
|
|
147
|
+
# untouched (--no-download → resolve an EXISTING browser only, never a surprise
|
|
148
|
+
# ~94 MB fetch; agent-browser keeps its own system-Chrome default if nothing's
|
|
149
|
+
# found). The desktop bundle sets MAUDE_DEV_SERVER_ROOT (sidecar.rs) — the signal
|
|
150
|
+
# that a one-time provisioning download is wanted for the zero-install experience.
|
|
151
|
+
if [ "$ENGINE" = "agent-browser" ] && [ -z "$AGENT_BROWSER_EXECUTABLE_PATH" ]; then
|
|
152
|
+
ES_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
153
|
+
ES_FLAGS="--quiet"
|
|
154
|
+
[ -z "$MAUDE_DEV_SERVER_ROOT" ] && ES_FLAGS="--quiet --no-download"
|
|
155
|
+
BROWSER_PATH="$(bash "$ES_DIR/ensure-browser.sh" $ES_FLAGS 2>/dev/null)"
|
|
156
|
+
if [ -n "$BROWSER_PATH" ] && [ -x "$BROWSER_PATH" ]; then
|
|
157
|
+
export AGENT_BROWSER_EXECUTABLE_PATH="$BROWSER_PATH"
|
|
158
|
+
echo "→ agent-browser browser: $BROWSER_PATH" >&2
|
|
159
|
+
fi
|
|
160
|
+
fi
|
|
136
161
|
echo "→ screenshot engine: $ENGINE | url: $URL" >&2
|
|
137
162
|
|
|
138
163
|
# ---------- engine: agent-browser ----------
|
|
@@ -140,9 +165,9 @@ ab_screenshot() {
|
|
|
140
165
|
local css="$1"
|
|
141
166
|
local out="$2"
|
|
142
167
|
if [ -n "$css" ]; then
|
|
143
|
-
|
|
168
|
+
"$AB" screenshot "$css" "$out" >&2 || return 1
|
|
144
169
|
else
|
|
145
|
-
|
|
170
|
+
"$AB" screenshot --full "$out" >&2 || return 1
|
|
146
171
|
fi
|
|
147
172
|
# agent-browser sometimes reports success without writing — verify size.
|
|
148
173
|
if [ ! -s "$out" ]; then
|
|
@@ -175,7 +200,7 @@ pw_screenshot() {
|
|
|
175
200
|
|
|
176
201
|
navigate_once() {
|
|
177
202
|
if [ "$ENGINE" = "agent-browser" ]; then
|
|
178
|
-
|
|
203
|
+
"$AB" open "$URL" >&2
|
|
179
204
|
# Wait for canvas to mount — Babel/React canvases take 2–4s to settle.
|
|
180
205
|
# Poll for [data-dc-screen] or [data-dc-slot] up to $TIMEOUT seconds; fall
|
|
181
206
|
# through to a fixed sleep when the page isn't a DC canvas.
|
|
@@ -185,7 +210,7 @@ navigate_once() {
|
|
|
185
210
|
sleep 1
|
|
186
211
|
poll=$((poll + 1))
|
|
187
212
|
local raw
|
|
188
|
-
raw=$(
|
|
213
|
+
raw=$("$AB" eval "document.querySelectorAll('[data-dc-screen],[data-dc-slot]').length" 2>/dev/null)
|
|
189
214
|
# raw is a plain number on success — strip whitespace, validate.
|
|
190
215
|
local has=$(printf '%s' "$raw" | tr -d '[:space:]')
|
|
191
216
|
case "$has" in
|
|
@@ -270,7 +295,7 @@ if [ $ALL_SCREENS -eq 1 ]; then
|
|
|
270
295
|
if [ "$ENGINE" = "agent-browser" ]; then
|
|
271
296
|
# agent-browser wraps string results in quotes and escapes newlines as
|
|
272
297
|
# literal `\n`; comma-join + tr is simpler than parsing JSON for IDs.
|
|
273
|
-
raw=$(
|
|
298
|
+
raw=$("$AB" eval \
|
|
274
299
|
"Array.from(document.querySelectorAll('[data-dc-screen],[data-dc-slot]')).map(e => e.getAttribute('data-dc-screen') || e.getAttribute('data-dc-slot')).filter(Boolean).join(',')" \
|
|
275
300
|
2>/dev/null)
|
|
276
301
|
# Strip surrounding quotes, then split on comma.
|
|
@@ -281,7 +306,7 @@ if [ $ALL_SCREENS -eq 1 ]; then
|
|
|
281
306
|
if [ -z "$IDS" ] && relive_url; then
|
|
282
307
|
navigate_once
|
|
283
308
|
if [ "$ENGINE" = "agent-browser" ]; then
|
|
284
|
-
raw=$(
|
|
309
|
+
raw=$("$AB" eval \
|
|
285
310
|
"Array.from(document.querySelectorAll('[data-dc-screen],[data-dc-slot]')).map(e => e.getAttribute('data-dc-screen') || e.getAttribute('data-dc-slot')).filter(Boolean).join(',')" \
|
|
286
311
|
2>/dev/null)
|
|
287
312
|
IDS=$(printf '%s' "$raw" | sed 's/^"//; s/"$//' | tr ',' '\n')
|
|
@@ -298,7 +323,7 @@ if [ $ALL_SCREENS -eq 1 ]; then
|
|
|
298
323
|
NN=$(printf "%03d" "$N")
|
|
299
324
|
OUT_FILE="$OUT_DIR/${NN}-screen-${ID}.png"
|
|
300
325
|
if [ "$ENGINE" = "agent-browser" ]; then
|
|
301
|
-
|
|
326
|
+
"$AB" eval "document.querySelector('[data-dc-screen=\"$ID\"], [data-dc-slot=\"$ID\"]').scrollIntoView({block:'center'})" >/dev/null 2>&1
|
|
302
327
|
sleep 0.6
|
|
303
328
|
fi
|
|
304
329
|
if capture_resilient "[data-dc-screen=\"$ID\"], [data-dc-slot=\"$ID\"]" "$OUT_FILE"; then
|
package/apps/studio/bin/smoke.sh
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
# else the canvas's import graph lost the token CSS and every var()-driven rule
|
|
13
13
|
# is dead — the specimen mounts with content but renders unstyled).
|
|
14
14
|
#
|
|
15
|
+
# Also runs an ADVISORY artboard-isolation lint over ui/ canvases: `@media`
|
|
16
|
+
# width queries + viewport units (vw/vh/*-screen) resolve against the studio
|
|
17
|
+
# canvas stage, not the fixed artboard box, so they reflow the mock when the
|
|
18
|
+
# panel/sidebar/window resizes. Warns only — never fails the exit code.
|
|
19
|
+
#
|
|
15
20
|
# Usage:
|
|
16
21
|
# smoke.sh [--root <repo>]
|
|
17
22
|
# [--out-dir <dir>]
|
|
@@ -237,6 +242,41 @@ lint_specimen_imports() {
|
|
|
237
242
|
fi
|
|
238
243
|
}
|
|
239
244
|
|
|
245
|
+
# Static artboard-isolation lint (advisory). An artboard is a fixed-size design
|
|
246
|
+
# surface, but `@media` width queries + viewport units (vh/vw/vmin/vmax + the
|
|
247
|
+
# dynamic svh/dvh/lvh family, incl. Tailwind's *-screen utilities) resolve
|
|
248
|
+
# against the IFRAME VIEWPORT — i.e. the studio's canvas stage — not the
|
|
249
|
+
# artboard box. So a ui/ mock that uses them reflows when the Assistant panel /
|
|
250
|
+
# sidebar / window resizes, even at a fixed zoom. `container-type` on
|
|
251
|
+
# .dc-artboard-body (canvas-lib ENGINE_CSS) gives an isolated responsive path
|
|
252
|
+
# via @container + cqw/cqh; viewport units still escape by spec. This warns —
|
|
253
|
+
# it never fails the exit code (existing DS canvases legitimately use vw/vh in
|
|
254
|
+
# full-bleed specimens, and the fix is an author rewrite, not a smoke gate).
|
|
255
|
+
# Scopes ui/ canvases + their sibling .css only (preview specimens render
|
|
256
|
+
# standalone, so viewport units there are fine). Sets ISO_WARN.
|
|
257
|
+
ISO_WARN=0
|
|
258
|
+
lint_artboard_isolation() {
|
|
259
|
+
local f n=0 hits total=0 pat
|
|
260
|
+
# Length units (vh/vw/…), Tailwind *-screen utilities, raw viewport @media.
|
|
261
|
+
pat='[0-9.]+(vh|vw|vmin|vmax|dvh|svh|lvh|dvw|svw|lvw)([^a-z]|$)|(min-|max-)?[hw]-screen|@media[[:space:]]*\((min|max)-width'
|
|
262
|
+
[ -d "$DESIGN_ROOT/ui" ] || { echo "→ artboard-isolation lint: no ui/ dir — skipped" >&2; return; }
|
|
263
|
+
while IFS= read -r f; do
|
|
264
|
+
[ -e "$f" ] || continue
|
|
265
|
+
case "$(basename "$f")" in _*) continue ;; esac
|
|
266
|
+
hits=$(grep -cnE "$pat" "$f" 2>/dev/null || true)
|
|
267
|
+
[ "${hits:-0}" -gt 0 ] || continue
|
|
268
|
+
n=$((n + 1)); total=$((total + hits))
|
|
269
|
+
printf 'ISO-WARN %s — %s viewport-escape(s) (@media/vw/vh/*-screen leak to the studio stage)\n' "${f#$DESIGN_ROOT/}" "$hits" >&2
|
|
270
|
+
printf '| ⚠ ISO | `%s` | — | %s viewport-escape(s) — use %%%%/px or @container+cqw |\n' "${f#$DESIGN_ROOT/}" "$hits" >> "$MD"
|
|
271
|
+
done < <(find "$DESIGN_ROOT/ui" -type f \( -name '*.tsx' -o -name '*.css' \) 2>/dev/null | sort)
|
|
272
|
+
ISO_WARN=$n
|
|
273
|
+
if [ "$n" -eq 0 ]; then
|
|
274
|
+
echo "→ artboard-isolation lint: clean" >&2
|
|
275
|
+
else
|
|
276
|
+
echo "⚠ artboard-isolation lint: $n file(s), $total viewport-escape(s) — advisory, see report" >&2
|
|
277
|
+
fi
|
|
278
|
+
}
|
|
279
|
+
|
|
240
280
|
# Probe a canvas via agent-browser. Returns three lines on stdout:
|
|
241
281
|
# <status> one of OK / BLANK / ERROR
|
|
242
282
|
# <detail> short summary string
|
|
@@ -371,6 +411,8 @@ printf 'status\tfile\tscreenshot\tdetail\n' > "$TSV"
|
|
|
371
411
|
if [ "$INCLUDE_SYSTEM" = "1" ] && [ -d "$DESIGN_ROOT/system" ]; then
|
|
372
412
|
lint_specimen_imports
|
|
373
413
|
fi
|
|
414
|
+
# Artboard-isolation lint always runs (ui/ canvases are always in scope). Advisory.
|
|
415
|
+
lint_artboard_isolation
|
|
374
416
|
|
|
375
417
|
FAILED=0
|
|
376
418
|
N=0
|
|
@@ -435,6 +477,10 @@ TOTAL_FAIL=$((FAILED + LINT_FAILED))
|
|
|
435
477
|
else
|
|
436
478
|
echo "**Result:** ✗ $FAILED / $COUNT canvases failed render/style; $LINT_FAILED import-graph lint violation(s)."
|
|
437
479
|
fi
|
|
480
|
+
if [ "${ISO_WARN:-0}" -gt 0 ]; then
|
|
481
|
+
echo
|
|
482
|
+
echo "> ⚠ **Artboard isolation:** $ISO_WARN ui/ file(s) use viewport-escaping CSS (\`@media\`/\`vw\`/\`vh\`/\`*-screen\`) that reflows with the studio chrome. Advisory — not a failure. Replace with fixed px / \`%\` / \`@container\`+\`cqw\`."
|
|
483
|
+
fi
|
|
438
484
|
} >> "$MD"
|
|
439
485
|
|
|
440
486
|
echo "→ report: $MD" >&2
|