openuispec 0.2.13 → 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.
Files changed (63) hide show
  1. package/README.md +6 -5
  2. package/cli/index.ts +18 -12
  3. package/cli/init.ts +79 -13
  4. package/docs/cli.md +134 -27
  5. package/docs/file-formats.md +51 -1
  6. package/drift/index.ts +7 -2
  7. package/examples/social-app/openuispec/README.md +2 -1
  8. package/examples/social-app/openuispec/mock/chat_detail.yaml +25 -0
  9. package/examples/social-app/openuispec/mock/discover.yaml +17 -0
  10. package/examples/social-app/openuispec/mock/edit_profile.yaml +9 -0
  11. package/examples/social-app/openuispec/mock/home_feed.yaml +32 -0
  12. package/examples/social-app/openuispec/mock/messages_inbox.yaml +15 -0
  13. package/examples/social-app/openuispec/mock/notifications.yaml +30 -0
  14. package/examples/social-app/openuispec/mock/post_detail.yaml +26 -0
  15. package/examples/social-app/openuispec/mock/profile_self.yaml +28 -0
  16. package/examples/social-app/openuispec/mock/profile_user.yaml +32 -0
  17. package/examples/social-app/openuispec/mock/search_results.yaml +17 -0
  18. package/examples/social-app/openuispec/mock/settings.yaml +7 -0
  19. package/examples/social-app/openuispec/openuispec.yaml +3 -2
  20. package/examples/taskflow/README.md +4 -2
  21. package/examples/taskflow/openuispec/README.md +2 -1
  22. package/examples/taskflow/openuispec/components/media_player.yaml +92 -0
  23. package/examples/taskflow/openuispec/contracts/README.md +2 -2
  24. package/examples/taskflow/openuispec/locales/en.json +1 -0
  25. package/examples/taskflow/openuispec/mock/home.yaml +64 -0
  26. package/examples/taskflow/openuispec/mock/profile_edit.yaml +6 -0
  27. package/examples/taskflow/openuispec/mock/project_detail.yaml +33 -0
  28. package/examples/taskflow/openuispec/mock/settings.yaml +13 -0
  29. package/examples/taskflow/openuispec/mock/task_detail.yaml +18 -0
  30. package/examples/taskflow/openuispec/openuispec.yaml +3 -4
  31. package/examples/taskflow/openuispec/platform/ios.yaml +0 -4
  32. package/examples/taskflow/openuispec/screens/task_detail.yaml +5 -8
  33. package/examples/taskflow/openuispec/tokens/icons.yaml +16 -0
  34. package/examples/todo-orbit/README.md +3 -2
  35. package/examples/todo-orbit/openuispec/README.md +2 -1
  36. package/examples/todo-orbit/openuispec/components/task_trend_chart.yaml +85 -0
  37. package/examples/todo-orbit/openuispec/locales/en.json +3 -0
  38. package/examples/todo-orbit/openuispec/locales/ru.json +3 -0
  39. package/examples/todo-orbit/openuispec/mock/analytics.yaml +26 -0
  40. package/examples/todo-orbit/openuispec/mock/home.yaml +33 -0
  41. package/examples/todo-orbit/openuispec/mock/settings.yaml +7 -0
  42. package/examples/todo-orbit/openuispec/mock/task_detail.yaml +14 -0
  43. package/examples/todo-orbit/openuispec/openuispec.yaml +3 -3
  44. package/examples/todo-orbit/openuispec/platform/android.yaml +0 -3
  45. package/examples/todo-orbit/openuispec/platform/ios.yaml +0 -3
  46. package/examples/todo-orbit/openuispec/platform/web.yaml +0 -3
  47. package/examples/todo-orbit/openuispec/screens/analytics.yaml +1 -4
  48. package/mcp-server/index.ts +87 -6
  49. package/mcp-server/preview-render.ts +1922 -0
  50. package/mcp-server/preview.ts +292 -0
  51. package/mcp-server/screenshot-shared.ts +41 -4
  52. package/mcp-server/screenshot.ts +283 -97
  53. package/package.json +1 -1
  54. package/prepare/index.ts +1 -1
  55. package/schema/component.schema.json +278 -0
  56. package/schema/openuispec.schema.json +5 -1
  57. package/schema/screen.schema.json +12 -1
  58. package/schema/semantic-lint.ts +29 -5
  59. package/schema/validate.ts +21 -0
  60. package/scripts/regenerate-previews.ts +136 -0
  61. package/spec/{openuispec-v0.1.md → openuispec-v0.2.md} +266 -8
  62. package/examples/taskflow/openuispec/contracts/x_media_player.yaml +0 -185
  63. package/examples/todo-orbit/openuispec/contracts/x_task_trend_chart.yaml +0 -139
@@ -8,9 +8,10 @@
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
+ import { getBrowser, closeBrowser, type ScreenshotResult } from "./screenshot-shared.js";
14
15
 
15
16
  // ── types ───────────────────────────────────────────────────────────
16
17
 
@@ -23,23 +24,186 @@ export interface ScreenshotOptions {
23
24
  full_page?: boolean;
24
25
  selector?: string;
25
26
  output_dir?: string;
27
+ init_script?: string;
26
28
  }
27
29
 
28
- export interface ScreenshotResult {
29
- content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
30
- isError?: true;
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;
31
47
  }
32
48
 
33
- // ── free port finder ────────────────────────────────────────────────
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
+ }
34
152
 
35
- function findFreePort(): Promise<number> {
36
- return new Promise((resolve, reject) => {
37
- const srv = createServer();
38
- srv.listen(0, () => {
39
- const port = (srv.address() as AddressInfo).port;
40
- srv.close(() => resolve(port));
41
- });
42
- srv.on("error", reject);
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));
43
207
  });
44
208
  }
45
209
 
@@ -51,18 +215,22 @@ export function findWebAppDir(projectCwd: string): string {
51
215
  const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
52
216
  const projectName = manifest.project?.name ?? "app";
53
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
+
54
222
  // Check custom output_dir first
55
223
  const customDir = manifest.generation?.output_dir?.web;
56
224
  if (customDir) {
57
225
  const resolved = resolve(projectDir, customDir);
58
- if (existsSync(join(resolved, "package.json"))) return resolved;
226
+ if (isWebDir(resolved)) return resolved;
59
227
  }
60
228
 
61
229
  // Default: generated/web/<project-name>/
62
230
  // Try from the project root (parent of openuispec/)
63
231
  const projectRoot = resolve(projectDir, "..");
64
232
  const defaultDir = join(projectRoot, "generated", "web", projectName);
65
- if (existsSync(join(defaultDir, "package.json"))) return defaultDir;
233
+ if (isWebDir(defaultDir)) return defaultDir;
66
234
 
67
235
  throw new Error(
68
236
  `Web app not found. Checked:\n` +
@@ -75,98 +243,112 @@ export function findWebAppDir(projectCwd: string): string {
75
243
  // ── dev server manager ──────────────────────────────────────────────
76
244
 
77
245
  interface ServerInstance {
78
- process: ChildProcess;
246
+ process: ChildProcess | null; // null = using an externally running server
79
247
  port: number;
80
248
  url: string;
81
249
  }
82
250
 
83
251
  const servers = new Map<string, ServerInstance>();
84
252
 
85
- function ensureDepsInstalled(webDir: string): void {
86
- 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;
87
257
  try {
88
- execSync("npm install", { cwd: webDir, stdio: "pipe", timeout: 90_000 });
258
+ execSync(fw.installCommand.join(" "), { cwd: webDir, stdio: "pipe", timeout: 120_000 });
89
259
  } catch (err) {
90
- 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}`);
261
+ }
262
+ }
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));
91
272
  }
273
+ return false;
92
274
  }
93
275
 
94
276
  async function startDevServer(webDir: string): Promise<ServerInstance> {
95
277
  const existing = servers.get(webDir);
96
278
  if (existing) {
97
- // Verify still running
98
- 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;
99
283
  servers.delete(webDir);
100
284
  }
101
285
 
102
- ensureDepsInstalled(webDir);
103
- const port = await findFreePort();
286
+ const fw = detectFramework(webDir);
287
+ const port = resolvePort(webDir, fw);
104
288
 
105
- const child = spawn("npx", ["vite", "--port", String(port), "--strictPort"], {
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
+ }
295
+
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, {
106
318
  cwd: webDir,
107
319
  stdio: ["ignore", "pipe", "pipe"],
108
- env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none" },
320
+ env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none", [fw.portEnvVar]: String(port) },
109
321
  });
110
322
 
111
- // Wait for "Local:" line from Vite
112
- const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
113
-
114
- const url = await new Promise<string>((resolveUrl, reject) => {
115
- const timeout = setTimeout(() => {
116
- child.kill();
117
- reject(new Error("Vite dev server failed to start within 30s"));
118
- }, 30_000);
119
-
120
- let output = "";
121
- const onData = (chunk: Buffer) => {
122
- output += chunk.toString();
123
- const clean = stripAnsi(output);
124
- const match = clean.match(/Local:\s+(https?:\/\/[^\s]+)/);
125
- if (match) {
126
- clearTimeout(timeout);
127
- child.stdout?.off("data", onData);
128
- child.stderr?.off("data", onData);
129
- resolveUrl(match[1]);
130
- }
131
- };
132
- child.stdout?.on("data", onData);
133
- child.stderr?.on("data", onData);
134
- child.on("error", (err) => { clearTimeout(timeout); reject(err); });
135
- child.on("exit", (code) => {
136
- clearTimeout(timeout);
137
- if (!output.includes("Local:")) {
138
- reject(new Error(`Vite exited with code ${code} before ready. Output:\n${output.slice(-500)}`));
139
- }
140
- });
141
- });
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(); });
142
326
 
143
- const instance: ServerInstance = { process: child, port, url };
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
+ }
337
+
338
+ const instance: ServerInstance = { process: child, port, url: `http://localhost:${port}` };
144
339
  servers.set(webDir, instance);
145
340
  return instance;
146
341
  }
147
342
 
148
- // ── browser manager ─────────────────────────────────────────────────
149
-
150
- let browserInstance: any = null;
343
+ // ── browser manager (imported from screenshot-shared.ts) ────────────
151
344
 
152
- async function getBrowser(): Promise<any> {
153
- if (browserInstance?.connected) return browserInstance;
345
+ // ── init_script URL injection ────────────────────────────────────────
154
346
 
155
- let puppeteer: any;
156
- try {
157
- puppeteer = await import("puppeteer");
158
- } catch {
159
- throw new Error(
160
- "puppeteer is not installed. Run:\n npm install -g puppeteer\n" +
161
- "or add it to your project's devDependencies.",
162
- );
163
- }
164
-
165
- browserInstance = await puppeteer.launch({
166
- headless: true,
167
- args: ["--no-sandbox", "--disable-setuid-sandbox"],
168
- });
169
- return browserInstance;
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();
170
352
  }
171
353
 
172
354
  // ── screenshot capture ──────────────────────────────────────────────
@@ -184,6 +366,7 @@ export async function takeScreenshot(
184
366
  full_page = false,
185
367
  selector,
186
368
  output_dir,
369
+ init_script,
187
370
  } = options;
188
371
 
189
372
  // 1. Find and start
@@ -207,7 +390,8 @@ export async function takeScreenshot(
207
390
  }
208
391
 
209
392
  const base = server.url.replace(/\/+$/, "");
210
- const targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
393
+ let targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
394
+ if (init_script) targetUrl = appendInitParam(targetUrl, init_script);
211
395
  await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
212
396
 
213
397
  if (wait_for > 0) {
@@ -256,6 +440,7 @@ export async function takeScreenshot(
256
440
  full_page,
257
441
  selector: selector ?? null,
258
442
  path: savedPath ?? null,
443
+ init_script: init_script ?? null,
259
444
  }, null, 2),
260
445
  },
261
446
  ],
@@ -273,6 +458,7 @@ export interface WebBatchCapture {
273
458
  selector?: string;
274
459
  full_page?: boolean;
275
460
  wait_for?: number;
461
+ init_script?: string;
276
462
  }
277
463
 
278
464
  export interface WebScreenshotBatchOptions {
@@ -281,6 +467,7 @@ export interface WebScreenshotBatchOptions {
281
467
  scale?: number;
282
468
  theme?: "light" | "dark";
283
469
  output_dir?: string;
470
+ init_script?: string;
284
471
  }
285
472
 
286
473
  // ── batch screenshot ─────────────────────────────────────────────────
@@ -289,7 +476,7 @@ export async function takeScreenshotBatch(
289
476
  projectCwd: string,
290
477
  options: WebScreenshotBatchOptions,
291
478
  ): Promise<ScreenshotResult> {
292
- 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;
293
480
 
294
481
  if (captures.length === 0) {
295
482
  return { content: [{ type: "text", text: "No web captures specified." }], isError: true };
@@ -312,10 +499,12 @@ export async function takeScreenshotBatch(
312
499
 
313
500
  const base = server.url.replace(/\/+$/, "");
314
501
  const themeLabel = theme ?? "default";
315
- const snapshots: Array<{ screen: string; path: string; data: string }> = [];
502
+ const snapshots: Array<{ screen: string; path: string; data: string; init_script?: string }> = [];
316
503
 
317
504
  for (const capture of captures) {
318
- 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);
319
508
  await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
320
509
  await new Promise((r) => setTimeout(r, capture.wait_for ?? 1000));
321
510
 
@@ -336,7 +525,7 @@ export async function takeScreenshotBatch(
336
525
  writeFileSync(savedPath, buffer);
337
526
  }
338
527
 
339
- 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 });
340
529
  }
341
530
 
342
531
  const content: ScreenshotResult["content"] = [];
@@ -344,7 +533,7 @@ export async function takeScreenshotBatch(
344
533
  content.push({ type: "image" as const, data: s.data, mimeType: "image/png" });
345
534
  content.push({
346
535
  type: "text" as const,
347
- 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),
348
537
  });
349
538
  }
350
539
  return { content };
@@ -355,23 +544,20 @@ export async function takeScreenshotBatch(
355
544
 
356
545
  // ── cleanup ─────────────────────────────────────────────────────────
357
546
 
358
- export async function shutdownAll() {
547
+ function killAllServers() {
359
548
  for (const [, instance] of servers) {
360
- try { instance.process.kill(); } catch { /* already dead */ }
549
+ if (instance.process) {
550
+ try { instance.process.kill(); } catch { /* already dead */ }
551
+ }
361
552
  }
362
553
  servers.clear();
363
- if (browserInstance) {
364
- try { await browserInstance.close(); } catch { /* ignore */ }
365
- browserInstance = null;
366
- }
367
554
  }
368
555
 
369
- process.on("exit", () => {
370
- // Sync-only fallback for process exit
371
- for (const [, instance] of servers) {
372
- try { instance.process.kill(); } catch { /* already dead */ }
373
- }
374
- servers.clear();
375
- });
556
+ export async function shutdownAll() {
557
+ killAllServers();
558
+ await closeBrowser();
559
+ }
560
+
561
+ process.on("exit", killAllServers);
376
562
  process.on("SIGINT", () => { shutdownAll().then(() => process.exit(0)); });
377
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.13",
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",
package/prepare/index.ts CHANGED
@@ -935,7 +935,7 @@ function referenceExamples(): string[] {
935
935
  const packageRoot = resolvePackageRoot();
936
936
  const candidates = [
937
937
  join(packageRoot, "README.md"),
938
- join(packageRoot, "spec", "openuispec-v0.1.md"),
938
+ join(packageRoot, "spec", "openuispec-v0.2.md"),
939
939
  join(packageRoot, "examples", "taskflow", "openuispec"),
940
940
  join(packageRoot, "schema"),
941
941
  ];