openuispec 0.2.14 → 0.2.15

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/cli/init.ts CHANGED
@@ -471,6 +471,7 @@ If MCP tools are not available, use these CLI commands with \`--json\` flag:
471
471
 
472
472
  **Visual verification:**
473
473
  - \`openuispec screenshot --route /path\` — screenshot the web app
474
+ - \`openuispec screenshot --route /path --init-script "..."\` — inject auth/role before rendering (web only; app must implement \`__ous_init\` bootstrapper)
474
475
  - \`openuispec screenshot-android [--project-dir path]\` — screenshot Android app
475
476
  - \`openuispec screenshot-ios [--project-dir path]\` — screenshot iOS app
476
477
 
package/docs/cli.md CHANGED
@@ -175,6 +175,59 @@ Each capture supports:
175
175
  - `wait_for`: per-capture wait time in ms
176
176
  - `selector`: CSS selector to screenshot a specific element (web only)
177
177
  - `full_page`: capture full scrollable page (web only)
178
+ - `init_script`: JavaScript to execute before the page renders (web only — see below)
179
+
180
+ ### `init_script` — app-level initialization
181
+
182
+ `init_script` lets you inject auth, switch roles, or set up session state before a screenshot is taken — without Puppeteer executing JS directly. The tool base64-encodes the script and appends it as a `?__ous_init=<encoded>` query param. The generated app's bootstrapper reads and runs it before rendering.
183
+
184
+ **Why app-level instead of `evaluateOnNewDocument`:** the app can `await` login APIs, set framework state, or call any async init — Puppeteer's `evaluateOnNewDocument` is sync-only and has no access to app internals.
185
+
186
+ **Single capture (MCP):**
187
+
188
+ ```json
189
+ {
190
+ "route": "/dashboard",
191
+ "init_script": "window.__auth = { token: 'test-token', role: 'admin' };"
192
+ }
193
+ ```
194
+
195
+ **Batch capture (MCP):**
196
+
197
+ ```json
198
+ {
199
+ "output_dir": "screenshots",
200
+ "init_script": "window.__auth = { token: 'test-token', role: 'viewer' };",
201
+ "captures": [
202
+ { "screen": "dashboard", "route": "/dashboard" },
203
+ { "screen": "admin_panel", "route": "/admin",
204
+ "init_script": "window.__auth = { token: 'test-token', role: 'admin' };" }
205
+ ]
206
+ }
207
+ ```
208
+
209
+ Per-capture `init_script` overrides the shared one. If neither is set, no param is appended and the app renders normally.
210
+
211
+ **Bootstrapper contract** — the generated app must include a bootstrapper that:
212
+
213
+ 1. Checks for `__ous_init` in the URL query string on load
214
+ 2. Base64-decodes it (`atob`) and `eval`s it (or parses it as structured data)
215
+ 3. Runs **before** rendering authenticated content (can be async — app awaits it)
216
+ 4. Strips the param from URL/history after processing (`history.replaceState`)
217
+
218
+ Example bootstrapper (framework-agnostic):
219
+
220
+ ```js
221
+ const param = new URLSearchParams(location.search).get('__ous_init');
222
+ if (param) {
223
+ try { eval(atob(param)); } catch (e) { console.warn('[ous] init_script error', e); }
224
+ const url = new URL(location.href);
225
+ url.searchParams.delete('__ous_init');
226
+ history.replaceState(null, '', url.toString());
227
+ }
228
+ ```
229
+
230
+ This is a **contract between the tool and generated code** — the tool appends the param; the app consumes it.
178
231
 
179
232
  ### Preview (experimental)
180
233
 
@@ -813,9 +813,10 @@ server.registerTool(
813
813
  full_page: z.boolean().optional().default(false).describe("Capture the full scrollable page instead of just the viewport"),
814
814
  selector: z.string().optional().describe("CSS selector to screenshot a specific element instead of the full page"),
815
815
  output_dir: z.string().optional().describe("Directory to save the screenshot PNG (relative to web app root). E.g. 'screenshots'. If omitted, only returns base64 in response."),
816
+ init_script: z.string().optional().describe("JavaScript to run before the page renders. Passed to the app via ?__ous_init=<base64> query param. The app's bootstrapper decodes and executes it — use for auth injection, role switching, or session setup."),
816
817
  },
817
818
  },
818
- async ({ route, viewport, scale, theme, wait_for, full_page, selector, output_dir }) => {
819
+ async ({ route, viewport, scale, theme, wait_for, full_page, selector, output_dir, init_script }) => {
819
820
  try {
820
821
  return await takeScreenshot(projectCwd, {
821
822
  route,
@@ -826,6 +827,7 @@ server.registerTool(
826
827
  full_page,
827
828
  selector,
828
829
  output_dir,
830
+ init_script,
829
831
  });
830
832
  } catch (err) {
831
833
  return toolError(err);
@@ -894,6 +896,7 @@ const webBatchCaptureSchema = z.object({
894
896
  selector: z.string().optional().describe("CSS selector to screenshot a specific element"),
895
897
  full_page: z.boolean().optional().describe("Capture full scrollable page"),
896
898
  wait_for: z.number().optional().describe("Per-capture wait time in ms"),
899
+ init_script: z.string().optional().describe("Per-capture init script (overrides shared init_script for this capture)"),
897
900
  });
898
901
 
899
902
  server.registerTool(
@@ -906,11 +909,12 @@ server.registerTool(
906
909
  scale: z.number().optional().default(2).describe("Device pixel ratio for all captures (default 2)"),
907
910
  theme: z.enum(["light", "dark"]).optional().describe("Force color scheme for all captures"),
908
911
  output_dir: z.string().optional().describe("Directory to save all PNGs (relative to web app root)"),
912
+ init_script: z.string().optional().describe("Shared init script for all captures. Passed via ?__ous_init=<base64>. Per-capture init_script overrides this."),
909
913
  },
910
914
  },
911
- async ({ captures, viewport, scale, theme, output_dir }) => {
915
+ async ({ captures, viewport, scale, theme, output_dir, init_script }) => {
912
916
  try {
913
- return await takeScreenshotBatch(projectCwd, { captures, viewport, scale, theme, output_dir });
917
+ return await takeScreenshotBatch(projectCwd, { captures, viewport, scale, theme, output_dir, init_script });
914
918
  } catch (err) {
915
919
  return toolError(err);
916
920
  }
@@ -48,10 +48,9 @@ export async function closeBrowser(): Promise<void> {
48
48
 
49
49
  // ── shared result type ──────────────────────────────────────────────
50
50
 
51
- export interface ScreenshotResult {
52
- content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
53
- isError?: true;
54
- }
51
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
52
+
53
+ export type ScreenshotResult = CallToolResult;
55
54
 
56
55
  // ── manifest loading ────────────────────────────────────────────────
57
56
 
@@ -8,7 +8,7 @@
8
8
  import { spawn, type ChildProcess, execSync } from "node:child_process";
9
9
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
10
10
  import { join, resolve } from "node:path";
11
- import { createServer, type AddressInfo } from "node:net";
11
+ import { createConnection } from "node:net";
12
12
  import YAML from "yaml";
13
13
  import { findProjectDir } from "../drift/index.js";
14
14
  import { getBrowser, closeBrowser, type ScreenshotResult } from "./screenshot-shared.js";
@@ -24,18 +24,186 @@ export interface ScreenshotOptions {
24
24
  full_page?: boolean;
25
25
  selector?: string;
26
26
  output_dir?: string;
27
+ init_script?: string;
27
28
  }
28
29
 
29
- // ── free port finder ────────────────────────────────────────────────
30
+ // ── framework config table ───────────────────────────────────────────
31
+
32
+ interface FrameworkConfig {
33
+ /** Typed discriminant for branching logic */
34
+ kind: "node" | "bun" | "deno" | "python-django" | "python-flask" | "ruby" | "php" | "go" | "rust" | "java";
35
+ /** Human-readable name */
36
+ name: string;
37
+ /** Files whose presence identifies this framework (checked in webDir) */
38
+ indicators: string[];
39
+ /** Default dev port when none is configured */
40
+ defaultPort: number;
41
+ /** Command + args to start the dev server; PORT placeholder replaced at runtime */
42
+ devCommand: string[];
43
+ /** Install command to run when dependencies are missing (null = skip) */
44
+ installCommand: string[] | null;
45
+ /** Package manager env var used to set the port (e.g. PORT, APP_PORT) */
46
+ portEnvVar: string;
47
+ }
30
48
 
31
- function findFreePort(): Promise<number> {
32
- return new Promise((resolve, reject) => {
33
- const srv = createServer();
34
- srv.listen(0, () => {
35
- const port = (srv.address() as AddressInfo).port;
36
- srv.close(() => resolve(port));
37
- });
38
- srv.on("error", reject);
49
+ // Ordered by detection priority (most specific first)
50
+ const FRAMEWORKS: FrameworkConfig[] = [
51
+ {
52
+ kind: "bun",
53
+ name: "Bun",
54
+ indicators: ["bun.lockb", "bun.lock"],
55
+ defaultPort: 3000,
56
+ devCommand: ["bun", "run", "dev"],
57
+ installCommand: ["bun", "install"],
58
+ portEnvVar: "PORT",
59
+ },
60
+ {
61
+ kind: "deno",
62
+ name: "Deno",
63
+ indicators: ["deno.json", "deno.jsonc"],
64
+ defaultPort: 8000,
65
+ devCommand: ["deno", "task", "dev"],
66
+ installCommand: null,
67
+ portEnvVar: "PORT",
68
+ },
69
+ {
70
+ kind: "node",
71
+ name: "Node.js (npm/yarn/pnpm)",
72
+ indicators: ["package.json"],
73
+ defaultPort: 3000,
74
+ devCommand: ["npm", "run", "dev"],
75
+ installCommand: ["npm", "install"],
76
+ portEnvVar: "PORT",
77
+ },
78
+ {
79
+ kind: "python-django",
80
+ name: "Django",
81
+ indicators: ["manage.py"],
82
+ defaultPort: 8000,
83
+ devCommand: ["python", "manage.py", "runserver", "PORT"],
84
+ installCommand: null,
85
+ portEnvVar: "PORT",
86
+ },
87
+ {
88
+ kind: "python-flask",
89
+ name: "Flask / FastAPI",
90
+ indicators: ["requirements.txt", "Pipfile", "pyproject.toml"],
91
+ defaultPort: 5000,
92
+ devCommand: ["python", "-m", "flask", "run", "--port", "PORT"],
93
+ installCommand: null,
94
+ portEnvVar: "PORT",
95
+ },
96
+ {
97
+ kind: "ruby",
98
+ name: "Ruby on Rails",
99
+ indicators: ["Gemfile", "config/application.rb"],
100
+ defaultPort: 3000,
101
+ devCommand: ["bin/rails", "server", "-p", "PORT"],
102
+ installCommand: ["bundle", "install"],
103
+ portEnvVar: "PORT",
104
+ },
105
+ {
106
+ kind: "php",
107
+ name: "PHP (Laravel)",
108
+ indicators: ["artisan"],
109
+ defaultPort: 8000,
110
+ devCommand: ["php", "artisan", "serve", "--port=PORT"],
111
+ installCommand: null,
112
+ portEnvVar: "PORT",
113
+ },
114
+ {
115
+ kind: "go",
116
+ name: "Go",
117
+ indicators: ["go.mod"],
118
+ defaultPort: 8080,
119
+ devCommand: ["go", "run", "."],
120
+ installCommand: null,
121
+ portEnvVar: "PORT",
122
+ },
123
+ {
124
+ kind: "rust",
125
+ name: "Rust (Trunk)",
126
+ indicators: ["Trunk.toml", "Cargo.toml"],
127
+ defaultPort: 8080,
128
+ devCommand: ["trunk", "serve", "--port", "PORT"],
129
+ installCommand: null,
130
+ portEnvVar: "PORT",
131
+ },
132
+ {
133
+ kind: "java",
134
+ name: "Java / Spring Boot",
135
+ indicators: ["pom.xml", "build.gradle", "build.gradle.kts"],
136
+ defaultPort: 8080,
137
+ devCommand: ["./mvnw", "spring-boot:run"],
138
+ installCommand: null,
139
+ portEnvVar: "SERVER_PORT",
140
+ },
141
+ ];
142
+
143
+ // ── framework detection ──────────────────────────────────────────────
144
+
145
+ function detectFramework(webDir: string): FrameworkConfig {
146
+ for (const fw of FRAMEWORKS) {
147
+ if (fw.indicators.some((f) => existsSync(join(webDir, f)))) return fw;
148
+ }
149
+ // Fallback: generic Node
150
+ return FRAMEWORKS.find((f) => f.kind === "node")!;
151
+ }
152
+
153
+ // ── port resolution ──────────────────────────────────────────────────
154
+
155
+ /** Parse --port / -p / --port=N from a script string. */
156
+ function parsePortFromScript(script: string): number | null {
157
+ const m = script.match(/(?:--port|-p)[=\s]+(\d+)/);
158
+ return m ? parseInt(m[1], 10) : null;
159
+ }
160
+
161
+ /** Read PORT (or custom var) from .env.local / .env.development / .env. */
162
+ function readEnvPort(webDir: string, varName = "PORT"): number | null {
163
+ for (const name of [".env.local", ".env.development", ".env"]) {
164
+ const envPath = join(webDir, name);
165
+ if (!existsSync(envPath)) continue;
166
+ try {
167
+ const re = new RegExp(`^\\s*${varName}\\s*=\\s*(\\d+)`, "m");
168
+ const m = readFileSync(envPath, "utf-8").match(re);
169
+ if (m) return parseInt(m[1], 10);
170
+ } catch { /* skip */ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /** Resolve the port this project's dev server will use. */
176
+ function resolvePort(webDir: string, fw: FrameworkConfig): number {
177
+ // 1. .env files
178
+ const envPort = readEnvPort(webDir, fw.portEnvVar);
179
+ if (envPort) return envPort;
180
+
181
+ // 2. package.json dev/start script (Node-like only)
182
+ if (existsSync(join(webDir, "package.json"))) {
183
+ try {
184
+ const pkg = JSON.parse(readFileSync(join(webDir, "package.json"), "utf-8"));
185
+ const scripts: Record<string, string> = pkg.scripts ?? {};
186
+ for (const name of ["dev", "start", "serve", "develop"]) {
187
+ if (scripts[name]) {
188
+ const p = parsePortFromScript(scripts[name]);
189
+ if (p) return p;
190
+ break;
191
+ }
192
+ }
193
+ } catch { /* ignore */ }
194
+ }
195
+
196
+ // 3. Framework default
197
+ return fw.defaultPort;
198
+ }
199
+
200
+ function isPortListening(port: number, host = "127.0.0.1"): Promise<boolean> {
201
+ return new Promise((resolve) => {
202
+ const socket = createConnection({ port, host });
203
+ socket.setTimeout(500);
204
+ socket.on("connect", () => { socket.destroy(); resolve(true); });
205
+ socket.on("timeout", () => { socket.destroy(); resolve(false); });
206
+ socket.on("error", () => resolve(false));
39
207
  });
40
208
  }
41
209
 
@@ -47,18 +215,22 @@ export function findWebAppDir(projectCwd: string): string {
47
215
  const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
48
216
  const projectName = manifest.project?.name ?? "app";
49
217
 
218
+ // Derive indicators from the FRAMEWORKS table so they stay in sync
219
+ const isWebDir = (d: string) =>
220
+ FRAMEWORKS.some((fw) => fw.indicators.some((f) => existsSync(join(d, f))));
221
+
50
222
  // Check custom output_dir first
51
223
  const customDir = manifest.generation?.output_dir?.web;
52
224
  if (customDir) {
53
225
  const resolved = resolve(projectDir, customDir);
54
- if (existsSync(join(resolved, "package.json"))) return resolved;
226
+ if (isWebDir(resolved)) return resolved;
55
227
  }
56
228
 
57
229
  // Default: generated/web/<project-name>/
58
230
  // Try from the project root (parent of openuispec/)
59
231
  const projectRoot = resolve(projectDir, "..");
60
232
  const defaultDir = join(projectRoot, "generated", "web", projectName);
61
- if (existsSync(join(defaultDir, "package.json"))) return defaultDir;
233
+ if (isWebDir(defaultDir)) return defaultDir;
62
234
 
63
235
  throw new Error(
64
236
  `Web app not found. Checked:\n` +
@@ -71,78 +243,114 @@ export function findWebAppDir(projectCwd: string): string {
71
243
  // ── dev server manager ──────────────────────────────────────────────
72
244
 
73
245
  interface ServerInstance {
74
- process: ChildProcess;
246
+ process: ChildProcess | null; // null = using an externally running server
75
247
  port: number;
76
248
  url: string;
77
249
  }
78
250
 
79
251
  const servers = new Map<string, ServerInstance>();
80
252
 
81
- function ensureDepsInstalled(webDir: string): void {
82
- if (existsSync(join(webDir, "node_modules"))) return;
253
+ function ensureDepsInstalled(webDir: string, fw: FrameworkConfig): void {
254
+ if (!fw.installCommand) return;
255
+ // For Node.js check node_modules; for others always run
256
+ if (fw.kind === "node" && existsSync(join(webDir, "node_modules"))) return;
83
257
  try {
84
- execSync("npm install", { cwd: webDir, stdio: "pipe", timeout: 90_000 });
258
+ execSync(fw.installCommand.join(" "), { cwd: webDir, stdio: "pipe", timeout: 120_000 });
85
259
  } catch (err) {
86
- throw new Error(`Failed to install web app dependencies in ${webDir}: ${err instanceof Error ? err.message : err}`);
260
+ throw new Error(`Failed to install dependencies in ${webDir}: ${err instanceof Error ? err.message : err}`);
87
261
  }
88
262
  }
89
263
 
264
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
265
+
266
+ /** Poll until the port accepts connections, or the timeout expires. */
267
+ async function waitForPort(port: number, timeoutMs = 60_000): Promise<boolean> {
268
+ const deadline = Date.now() + timeoutMs;
269
+ while (Date.now() < deadline) {
270
+ if (await isPortListening(port)) return true;
271
+ await new Promise((r) => setTimeout(r, 500));
272
+ }
273
+ return false;
274
+ }
275
+
90
276
  async function startDevServer(webDir: string): Promise<ServerInstance> {
91
277
  const existing = servers.get(webDir);
92
278
  if (existing) {
93
- // Verify still running
94
- if (existing.process.exitCode === null) return existing;
279
+ const alive = existing.process === null
280
+ ? await isPortListening(existing.port) // external server
281
+ : existing.process.exitCode === null; // managed process
282
+ if (alive) return existing;
95
283
  servers.delete(webDir);
96
284
  }
97
285
 
98
- ensureDepsInstalled(webDir);
99
- const port = await findFreePort();
286
+ const fw = detectFramework(webDir);
287
+ const port = resolvePort(webDir, fw);
288
+
289
+ // Always prefer an already-running server on the expected port
290
+ if (await isPortListening(port)) {
291
+ const instance: ServerInstance = { process: null, port, url: `http://localhost:${port}` };
292
+ servers.set(webDir, instance);
293
+ return instance;
294
+ }
100
295
 
101
- const child = spawn("npx", ["vite", "--port", String(port), "--strictPort"], {
296
+ // Start the dev server for this framework
297
+ ensureDepsInstalled(webDir, fw);
298
+
299
+ // Build command: replace "PORT" placeholder with actual port string
300
+ const [cmd, ...args] = fw.devCommand.map((part) => part === "PORT" ? String(port) : part);
301
+
302
+ // For Node.js, use the project's own dev script from package.json if available
303
+ let spawnCmd = cmd;
304
+ let spawnArgs = args;
305
+ if (fw.kind === "node" && existsSync(join(webDir, "package.json"))) {
306
+ try {
307
+ const pkg = JSON.parse(readFileSync(join(webDir, "package.json"), "utf-8"));
308
+ const scripts: Record<string, string> = pkg.scripts ?? {};
309
+ const scriptName = ["dev", "start", "serve", "develop"].find((n) => n in scripts);
310
+ if (scriptName) {
311
+ spawnCmd = "npm";
312
+ spawnArgs = ["run", scriptName];
313
+ }
314
+ } catch { /* ignore */ }
315
+ }
316
+
317
+ const child = spawn(spawnCmd, spawnArgs, {
102
318
  cwd: webDir,
103
319
  stdio: ["ignore", "pipe", "pipe"],
104
- env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none" },
320
+ env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none", [fw.portEnvVar]: String(port) },
105
321
  });
106
322
 
107
- // Wait for "Local:" line from Vite
108
- const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
109
-
110
- const url = await new Promise<string>((resolveUrl, reject) => {
111
- const timeout = setTimeout(() => {
112
- child.kill();
113
- reject(new Error("Vite dev server failed to start within 30s"));
114
- }, 30_000);
115
-
116
- let output = "";
117
- const onData = (chunk: Buffer) => {
118
- output += chunk.toString();
119
- const clean = stripAnsi(output);
120
- const match = clean.match(/Local:\s+(https?:\/\/[^\s]+)/);
121
- if (match) {
122
- clearTimeout(timeout);
123
- child.stdout?.off("data", onData);
124
- child.stderr?.off("data", onData);
125
- resolveUrl(match[1]);
126
- }
127
- };
128
- child.stdout?.on("data", onData);
129
- child.stderr?.on("data", onData);
130
- child.on("error", (err) => { clearTimeout(timeout); reject(err); });
131
- child.on("exit", (code) => {
132
- clearTimeout(timeout);
133
- if (!output.includes("Local:")) {
134
- reject(new Error(`Vite exited with code ${code} before ready. Output:\n${output.slice(-500)}`));
135
- }
136
- });
137
- });
323
+ // Collect stderr from the start so we have output for error messages
324
+ let stderr = "";
325
+ child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
326
+
327
+ // Wait for the port to open (framework-agnostic no stdout parsing needed)
328
+ const ready = await waitForPort(port, 60_000);
329
+
330
+ if (!ready) {
331
+ child.kill();
332
+ throw new Error(
333
+ `${fw.name} dev server did not open port ${port} within 60s.\n` +
334
+ (stderr ? `stderr:\n${stripAnsi(stderr).slice(-500)}` : ""),
335
+ );
336
+ }
138
337
 
139
- const instance: ServerInstance = { process: child, port, url };
338
+ const instance: ServerInstance = { process: child, port, url: `http://localhost:${port}` };
140
339
  servers.set(webDir, instance);
141
340
  return instance;
142
341
  }
143
342
 
144
343
  // ── browser manager (imported from screenshot-shared.ts) ────────────
145
344
 
345
+ // ── init_script URL injection ────────────────────────────────────────
346
+
347
+ /** Append ?__ous_init=<base64> to a URL, respecting existing query params. */
348
+ function appendInitParam(targetUrl: string, initScript: string): string {
349
+ const url = new URL(targetUrl);
350
+ url.searchParams.set("__ous_init", Buffer.from(initScript).toString("base64"));
351
+ return url.toString();
352
+ }
353
+
146
354
  // ── screenshot capture ──────────────────────────────────────────────
147
355
 
148
356
  export async function takeScreenshot(
@@ -158,6 +366,7 @@ export async function takeScreenshot(
158
366
  full_page = false,
159
367
  selector,
160
368
  output_dir,
369
+ init_script,
161
370
  } = options;
162
371
 
163
372
  // 1. Find and start
@@ -181,7 +390,8 @@ export async function takeScreenshot(
181
390
  }
182
391
 
183
392
  const base = server.url.replace(/\/+$/, "");
184
- const targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
393
+ let targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
394
+ if (init_script) targetUrl = appendInitParam(targetUrl, init_script);
185
395
  await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
186
396
 
187
397
  if (wait_for > 0) {
@@ -230,6 +440,7 @@ export async function takeScreenshot(
230
440
  full_page,
231
441
  selector: selector ?? null,
232
442
  path: savedPath ?? null,
443
+ init_script: init_script ?? null,
233
444
  }, null, 2),
234
445
  },
235
446
  ],
@@ -247,6 +458,7 @@ export interface WebBatchCapture {
247
458
  selector?: string;
248
459
  full_page?: boolean;
249
460
  wait_for?: number;
461
+ init_script?: string;
250
462
  }
251
463
 
252
464
  export interface WebScreenshotBatchOptions {
@@ -255,6 +467,7 @@ export interface WebScreenshotBatchOptions {
255
467
  scale?: number;
256
468
  theme?: "light" | "dark";
257
469
  output_dir?: string;
470
+ init_script?: string;
258
471
  }
259
472
 
260
473
  // ── batch screenshot ─────────────────────────────────────────────────
@@ -263,7 +476,7 @@ export async function takeScreenshotBatch(
263
476
  projectCwd: string,
264
477
  options: WebScreenshotBatchOptions,
265
478
  ): Promise<ScreenshotResult> {
266
- const { captures, viewport = { width: 1280, height: 800 }, scale = 2, theme, output_dir } = options;
479
+ const { captures, viewport = { width: 1280, height: 800 }, scale = 2, theme, output_dir, init_script: sharedInitScript } = options;
267
480
 
268
481
  if (captures.length === 0) {
269
482
  return { content: [{ type: "text", text: "No web captures specified." }], isError: true };
@@ -286,10 +499,12 @@ export async function takeScreenshotBatch(
286
499
 
287
500
  const base = server.url.replace(/\/+$/, "");
288
501
  const themeLabel = theme ?? "default";
289
- const snapshots: Array<{ screen: string; path: string; data: string }> = [];
502
+ const snapshots: Array<{ screen: string; path: string; data: string; init_script?: string }> = [];
290
503
 
291
504
  for (const capture of captures) {
292
- const targetUrl = `${base}${capture.route.startsWith("/") ? "" : "/"}${capture.route}`;
505
+ const effectiveInitScript = capture.init_script ?? sharedInitScript;
506
+ let targetUrl = `${base}${capture.route.startsWith("/") ? "" : "/"}${capture.route}`;
507
+ if (effectiveInitScript) targetUrl = appendInitParam(targetUrl, effectiveInitScript);
293
508
  await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
294
509
  await new Promise((r) => setTimeout(r, capture.wait_for ?? 1000));
295
510
 
@@ -310,7 +525,7 @@ export async function takeScreenshotBatch(
310
525
  writeFileSync(savedPath, buffer);
311
526
  }
312
527
 
313
- snapshots.push({ screen: capture.screen, path: savedPath, data: buffer.toString("base64") });
528
+ snapshots.push({ screen: capture.screen, path: savedPath, data: buffer.toString("base64"), init_script: effectiveInitScript });
314
529
  }
315
530
 
316
531
  const content: ScreenshotResult["content"] = [];
@@ -318,7 +533,7 @@ export async function takeScreenshotBatch(
318
533
  content.push({ type: "image" as const, data: s.data, mimeType: "image/png" });
319
534
  content.push({
320
535
  type: "text" as const,
321
- text: JSON.stringify({ screen: s.screen, path: s.path, viewport, scale, theme: themeLabel }, null, 2),
536
+ text: JSON.stringify({ screen: s.screen, path: s.path, viewport, scale, theme: themeLabel, init_script: s.init_script ?? null }, null, 2),
322
537
  });
323
538
  }
324
539
  return { content };
@@ -329,20 +544,20 @@ export async function takeScreenshotBatch(
329
544
 
330
545
  // ── cleanup ─────────────────────────────────────────────────────────
331
546
 
332
- export async function shutdownAll() {
547
+ function killAllServers() {
333
548
  for (const [, instance] of servers) {
334
- try { instance.process.kill(); } catch { /* already dead */ }
549
+ if (instance.process) {
550
+ try { instance.process.kill(); } catch { /* already dead */ }
551
+ }
335
552
  }
336
553
  servers.clear();
554
+ }
555
+
556
+ export async function shutdownAll() {
557
+ killAllServers();
337
558
  await closeBrowser();
338
559
  }
339
560
 
340
- process.on("exit", () => {
341
- // Sync-only fallback for process exit
342
- for (const [, instance] of servers) {
343
- try { instance.process.kill(); } catch { /* already dead */ }
344
- }
345
- servers.clear();
346
- });
561
+ process.on("exit", killAllServers);
347
562
  process.on("SIGINT", () => { shutdownAll().then(() => process.exit(0)); });
348
563
  process.on("SIGTERM", () => { shutdownAll().then(() => process.exit(0)); });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openuispec",
3
- "version": "0.2.14",
3
+ "version": "0.2.15",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "A semantic UI specification format for AI-native, platform-native app development",
@@ -184,7 +184,8 @@ function collectIconRefs(filePath: string): { refs: Set<string>; suffixes: strin
184
184
  if (!isRecord(data) || !isRecord(data.icons)) return { refs, suffixes };
185
185
 
186
186
  const icons = data.icons as UnknownRecord;
187
- const variantSuffixes = isRecord(icons.variants?.suffixes) ? icons.variants.suffixes : {};
187
+ const variants = isRecord(icons.variants) ? icons.variants : {};
188
+ const variantSuffixes = isRecord(variants.suffixes) ? variants.suffixes : {};
188
189
  for (const suffix of Object.keys(variantSuffixes)) {
189
190
  if (suffix.trim()) suffixes.push(suffix);
190
191
  }
@@ -217,8 +218,9 @@ function collectIconRefs(filePath: string): { refs: Set<string>; suffixes: strin
217
218
  }
218
219
  }
219
220
 
220
- if (typeof icons.fallback?.missing_icon === "string") {
221
- refs.add(icons.fallback.missing_icon);
221
+ const fallback = isRecord(icons.fallback) ? icons.fallback : {};
222
+ if (typeof fallback.missing_icon === "string") {
223
+ refs.add(fallback.missing_icon);
222
224
  }
223
225
 
224
226
  return { refs, suffixes };