openuispec 0.2.18 → 0.2.20

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 (45) hide show
  1. package/README.md +2 -10
  2. package/dist/check/audit.js +392 -0
  3. package/dist/check/index.js +216 -0
  4. package/dist/cli/configure-target.js +391 -0
  5. package/dist/cli/index.js +510 -0
  6. package/dist/cli/init.js +1047 -0
  7. package/dist/drift/index.js +903 -0
  8. package/dist/mcp-server/index.js +886 -0
  9. package/dist/mcp-server/preview-render.js +1761 -0
  10. package/dist/mcp-server/preview.js +233 -0
  11. package/dist/mcp-server/screenshot-android.js +458 -0
  12. package/dist/mcp-server/screenshot-ios.js +639 -0
  13. package/dist/mcp-server/screenshot-shared.js +180 -0
  14. package/dist/mcp-server/screenshot.js +459 -0
  15. package/dist/prepare/index.js +1216 -0
  16. package/dist/runtime/package-paths.js +33 -0
  17. package/dist/schema/semantic-lint.js +564 -0
  18. package/dist/schema/validate.js +689 -0
  19. package/dist/status/index.js +194 -0
  20. package/docs/images/how-it-works.svg +56 -0
  21. package/docs/images/workflows.svg +76 -0
  22. package/package.json +12 -13
  23. package/check/audit.ts +0 -426
  24. package/check/index.ts +0 -320
  25. package/cli/configure-target.ts +0 -523
  26. package/cli/index.ts +0 -537
  27. package/cli/init.ts +0 -1253
  28. package/docs/images/how-it-works-dark.png +0 -0
  29. package/docs/images/how-it-works-light.png +0 -0
  30. package/docs/images/workflows-dark.png +0 -0
  31. package/docs/images/workflows-light.png +0 -0
  32. package/drift/index.ts +0 -1165
  33. package/mcp-server/index.ts +0 -1041
  34. package/mcp-server/preview-render.ts +0 -1922
  35. package/mcp-server/preview.ts +0 -292
  36. package/mcp-server/screenshot-android.ts +0 -621
  37. package/mcp-server/screenshot-ios.ts +0 -753
  38. package/mcp-server/screenshot-shared.ts +0 -237
  39. package/mcp-server/screenshot.ts +0 -563
  40. package/prepare/index.ts +0 -1530
  41. package/schema/semantic-lint.ts +0 -692
  42. package/schema/validate.ts +0 -870
  43. package/scripts/regenerate-previews.ts +0 -136
  44. package/scripts/take-all-screenshots.ts +0 -507
  45. package/status/index.ts +0 -275
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Shared utilities for platform screenshot tools (web, android, ios).
3
+ */
4
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
5
+ import { join, resolve, relative } from "node:path";
6
+ import { createHash } from "node:crypto";
7
+ import YAML from "yaml";
8
+ import { findProjectDir } from "../drift/index.js";
9
+ // ── shared browser manager ──────────────────────────────────────────
10
+ let browserInstance = null;
11
+ let launchPromise = null;
12
+ export async function getBrowser() {
13
+ if (browserInstance?.connected)
14
+ return browserInstance;
15
+ if (!launchPromise) {
16
+ launchPromise = (async () => {
17
+ let puppeteer;
18
+ try {
19
+ puppeteer = await import("puppeteer");
20
+ }
21
+ catch {
22
+ throw new Error("puppeteer is not installed. Run:\n npm install -g puppeteer\n" +
23
+ "or add it to your project's devDependencies.");
24
+ }
25
+ browserInstance = await puppeteer.launch({
26
+ headless: true,
27
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
28
+ });
29
+ return browserInstance;
30
+ })();
31
+ }
32
+ return launchPromise;
33
+ }
34
+ export async function closeBrowser() {
35
+ launchPromise = null;
36
+ if (browserInstance) {
37
+ try {
38
+ await browserInstance.close();
39
+ }
40
+ catch { /* ignore */ }
41
+ browserInstance = null;
42
+ }
43
+ }
44
+ export function loadManifest(projectCwd) {
45
+ const projectDir = findProjectDir(projectCwd);
46
+ const manifestPath = join(projectDir, "openuispec.yaml");
47
+ const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
48
+ const projectName = manifest.project?.name ?? "app";
49
+ const projectRoot = resolve(projectDir, "..");
50
+ return { projectDir, projectRoot, manifest, projectName };
51
+ }
52
+ // ── generic platform app directory discovery ────────────────────────
53
+ function tryFindProjectDir(cwd) {
54
+ if (existsSync(join(cwd, "openuispec.yaml"))) {
55
+ return cwd;
56
+ }
57
+ try {
58
+ return findProjectDir(cwd);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ export function findPlatformAppDir(projectCwd, platform, existsCheck, directDir) {
65
+ const label = platform.charAt(0).toUpperCase() + platform.slice(1);
66
+ // If a direct project dir is provided, use it without manifest lookup
67
+ if (directDir) {
68
+ const resolved = resolve(directDir);
69
+ if (existsCheck(resolved))
70
+ return resolved;
71
+ throw new Error(`${label} project not found at provided path: ${resolved}`);
72
+ }
73
+ // Check if projectCwd has an openuispec.yaml — if so, use manifest-based discovery
74
+ const manifestDir = tryFindProjectDir(projectCwd);
75
+ if (manifestDir) {
76
+ const manifestPath = join(manifestDir, "openuispec.yaml");
77
+ const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
78
+ const projectName = manifest.project?.name ?? "app";
79
+ const projectRoot = resolve(manifestDir, "..");
80
+ // Check custom output_dir first
81
+ const customDir = manifest.generation?.output_dir?.[platform];
82
+ if (customDir) {
83
+ const resolved = resolve(manifestDir, customDir);
84
+ if (existsCheck(resolved))
85
+ return resolved;
86
+ }
87
+ // Default: generated/<platform>/<project-name>/
88
+ const defaultDir = join(projectRoot, "generated", platform, projectName);
89
+ if (existsCheck(defaultDir))
90
+ return defaultDir;
91
+ throw new Error(`${label} app not found. Checked:\n` +
92
+ (customDir ? ` - ${resolve(manifestDir, customDir)}\n` : "") +
93
+ ` - ${defaultDir}\n` +
94
+ `Generate the ${platform} target first, then try again.`);
95
+ }
96
+ // No manifest — treat projectCwd itself as the platform project dir
97
+ const resolved = resolve(projectCwd);
98
+ if (existsCheck(resolved))
99
+ return resolved;
100
+ throw new Error(`${label} project not found at ${resolved}. ` +
101
+ `Provide a project_dir parameter or create an openuispec.yaml manifest.`);
102
+ }
103
+ // ── file hashing / caching ──────────────────────────────────────────
104
+ export function hashContent(content) {
105
+ return createHash("md5").update(content).digest("hex");
106
+ }
107
+ export function loadHashes(dir, hashFile) {
108
+ const hashPath = join(dir, hashFile);
109
+ try {
110
+ return JSON.parse(readFileSync(hashPath, "utf-8"));
111
+ }
112
+ catch {
113
+ return {};
114
+ }
115
+ }
116
+ export function saveHashes(dir, hashFile, hashes) {
117
+ writeFileSync(join(dir, hashFile), JSON.stringify(hashes, null, 2));
118
+ }
119
+ // ── screen name filter ──────────────────────────────────────────────
120
+ export function matchesScreenFilter(screenName, filter) {
121
+ const normalizedFilter = filter.toLowerCase().replace(/_/g, "");
122
+ const normalizedScreen = screenName.toLowerCase().replace(/_/g, "");
123
+ return normalizedScreen.includes(normalizedFilter) || normalizedFilter.includes(normalizedScreen);
124
+ }
125
+ // ── recursive file walker ───────────────────────────────────────────
126
+ export function walkFiles(dir, ext, skipDirs = []) {
127
+ const results = [];
128
+ if (!existsSync(dir))
129
+ return results;
130
+ const walk = (d) => {
131
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
132
+ const fullPath = join(d, entry.name);
133
+ if (entry.isDirectory()) {
134
+ if (!skipDirs.includes(entry.name))
135
+ walk(fullPath);
136
+ }
137
+ else if (entry.name.endsWith(ext)) {
138
+ results.push(fullPath);
139
+ }
140
+ }
141
+ };
142
+ walk(dir);
143
+ return results;
144
+ }
145
+ // ── PNG snapshot collector ──────────────────────────────────────────
146
+ export function collectPngSnapshots(dirs, rootDir, screenFilter, nameExtractor) {
147
+ const snapshots = [];
148
+ const seen = new Set();
149
+ for (const dir of dirs) {
150
+ const pngs = walkFiles(dir, ".png");
151
+ for (const fullPath of pngs) {
152
+ const filename = fullPath.split("/").pop();
153
+ const screen = nameExtractor(filename);
154
+ if (screenFilter && !matchesScreenFilter(screen, screenFilter))
155
+ continue;
156
+ const key = relative(rootDir, fullPath);
157
+ if (seen.has(key))
158
+ continue;
159
+ seen.add(key);
160
+ const data = readFileSync(fullPath).toString("base64");
161
+ snapshots.push({ screen, path: key, data });
162
+ }
163
+ }
164
+ return snapshots;
165
+ }
166
+ // ── screenshot response builder ─────────────────────────────────────
167
+ export function buildScreenshotResponse(snapshots, metadataFn) {
168
+ if (snapshots.length === 0) {
169
+ return {
170
+ content: [{ type: "text", text: "No screenshots generated. Check build output." }],
171
+ isError: true,
172
+ };
173
+ }
174
+ const content = [];
175
+ for (const snapshot of snapshots) {
176
+ content.push({ type: "image", data: snapshot.data, mimeType: "image/png" });
177
+ content.push({ type: "text", text: JSON.stringify(metadataFn(snapshot), null, 2) });
178
+ }
179
+ return { content };
180
+ }
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Screenshot tool — launches dev server + headless browser, captures pages.
3
+ *
4
+ * Both the Vite dev server and the Puppeteer browser are kept alive between
5
+ * calls and torn down when the MCP server process exits.
6
+ */
7
+ import { spawn, execSync } from "node:child_process";
8
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
9
+ import { join, resolve } from "node:path";
10
+ import { createConnection } from "node:net";
11
+ import YAML from "yaml";
12
+ import { findProjectDir } from "../drift/index.js";
13
+ import { getBrowser, closeBrowser } from "./screenshot-shared.js";
14
+ // Ordered by detection priority (most specific first)
15
+ const FRAMEWORKS = [
16
+ {
17
+ kind: "bun",
18
+ name: "Bun",
19
+ indicators: ["bun.lockb", "bun.lock"],
20
+ defaultPort: 3000,
21
+ devCommand: ["bun", "run", "dev"],
22
+ installCommand: ["bun", "install"],
23
+ portEnvVar: "PORT",
24
+ },
25
+ {
26
+ kind: "deno",
27
+ name: "Deno",
28
+ indicators: ["deno.json", "deno.jsonc"],
29
+ defaultPort: 8000,
30
+ devCommand: ["deno", "task", "dev"],
31
+ installCommand: null,
32
+ portEnvVar: "PORT",
33
+ },
34
+ {
35
+ kind: "node",
36
+ name: "Node.js (npm/yarn/pnpm)",
37
+ indicators: ["package.json"],
38
+ defaultPort: 3000,
39
+ devCommand: ["npm", "run", "dev"],
40
+ installCommand: ["npm", "install"],
41
+ portEnvVar: "PORT",
42
+ },
43
+ {
44
+ kind: "python-django",
45
+ name: "Django",
46
+ indicators: ["manage.py"],
47
+ defaultPort: 8000,
48
+ devCommand: ["python", "manage.py", "runserver", "PORT"],
49
+ installCommand: null,
50
+ portEnvVar: "PORT",
51
+ },
52
+ {
53
+ kind: "python-flask",
54
+ name: "Flask / FastAPI",
55
+ indicators: ["requirements.txt", "Pipfile", "pyproject.toml"],
56
+ defaultPort: 5000,
57
+ devCommand: ["python", "-m", "flask", "run", "--port", "PORT"],
58
+ installCommand: null,
59
+ portEnvVar: "PORT",
60
+ },
61
+ {
62
+ kind: "ruby",
63
+ name: "Ruby on Rails",
64
+ indicators: ["Gemfile", "config/application.rb"],
65
+ defaultPort: 3000,
66
+ devCommand: ["bin/rails", "server", "-p", "PORT"],
67
+ installCommand: ["bundle", "install"],
68
+ portEnvVar: "PORT",
69
+ },
70
+ {
71
+ kind: "php",
72
+ name: "PHP (Laravel)",
73
+ indicators: ["artisan"],
74
+ defaultPort: 8000,
75
+ devCommand: ["php", "artisan", "serve", "--port=PORT"],
76
+ installCommand: null,
77
+ portEnvVar: "PORT",
78
+ },
79
+ {
80
+ kind: "go",
81
+ name: "Go",
82
+ indicators: ["go.mod"],
83
+ defaultPort: 8080,
84
+ devCommand: ["go", "run", "."],
85
+ installCommand: null,
86
+ portEnvVar: "PORT",
87
+ },
88
+ {
89
+ kind: "rust",
90
+ name: "Rust (Trunk)",
91
+ indicators: ["Trunk.toml", "Cargo.toml"],
92
+ defaultPort: 8080,
93
+ devCommand: ["trunk", "serve", "--port", "PORT"],
94
+ installCommand: null,
95
+ portEnvVar: "PORT",
96
+ },
97
+ {
98
+ kind: "java",
99
+ name: "Java / Spring Boot",
100
+ indicators: ["pom.xml", "build.gradle", "build.gradle.kts"],
101
+ defaultPort: 8080,
102
+ devCommand: ["./mvnw", "spring-boot:run"],
103
+ installCommand: null,
104
+ portEnvVar: "SERVER_PORT",
105
+ },
106
+ ];
107
+ // ── framework detection ──────────────────────────────────────────────
108
+ function detectFramework(webDir) {
109
+ for (const fw of FRAMEWORKS) {
110
+ if (fw.indicators.some((f) => existsSync(join(webDir, f))))
111
+ return fw;
112
+ }
113
+ // Fallback: generic Node
114
+ return FRAMEWORKS.find((f) => f.kind === "node");
115
+ }
116
+ // ── port resolution ──────────────────────────────────────────────────
117
+ /** Parse --port / -p / --port=N from a script string. */
118
+ function parsePortFromScript(script) {
119
+ const m = script.match(/(?:--port|-p)[=\s]+(\d+)/);
120
+ return m ? parseInt(m[1], 10) : null;
121
+ }
122
+ /** Read PORT (or custom var) from .env.local / .env.development / .env. */
123
+ function readEnvPort(webDir, varName = "PORT") {
124
+ for (const name of [".env.local", ".env.development", ".env"]) {
125
+ const envPath = join(webDir, name);
126
+ if (!existsSync(envPath))
127
+ continue;
128
+ try {
129
+ const re = new RegExp(`^\\s*${varName}\\s*=\\s*(\\d+)`, "m");
130
+ const m = readFileSync(envPath, "utf-8").match(re);
131
+ if (m)
132
+ return parseInt(m[1], 10);
133
+ }
134
+ catch { /* skip */ }
135
+ }
136
+ return null;
137
+ }
138
+ /** Resolve the port this project's dev server will use. */
139
+ function resolvePort(webDir, fw) {
140
+ // 1. .env files
141
+ const envPort = readEnvPort(webDir, fw.portEnvVar);
142
+ if (envPort)
143
+ return envPort;
144
+ // 2. package.json dev/start script (Node-like only)
145
+ if (existsSync(join(webDir, "package.json"))) {
146
+ try {
147
+ const pkg = JSON.parse(readFileSync(join(webDir, "package.json"), "utf-8"));
148
+ const scripts = pkg.scripts ?? {};
149
+ for (const name of ["dev", "start", "serve", "develop"]) {
150
+ if (scripts[name]) {
151
+ const p = parsePortFromScript(scripts[name]);
152
+ if (p)
153
+ return p;
154
+ break;
155
+ }
156
+ }
157
+ }
158
+ catch { /* ignore */ }
159
+ }
160
+ // 3. Framework default
161
+ return fw.defaultPort;
162
+ }
163
+ function isPortListening(port, host = "127.0.0.1") {
164
+ return new Promise((resolve) => {
165
+ const socket = createConnection({ port, host });
166
+ socket.setTimeout(500);
167
+ socket.on("connect", () => { socket.destroy(); resolve(true); });
168
+ socket.on("timeout", () => { socket.destroy(); resolve(false); });
169
+ socket.on("error", () => resolve(false));
170
+ });
171
+ }
172
+ // ── web app directory discovery ─────────────────────────────────────
173
+ export function findWebAppDir(projectCwd) {
174
+ const projectDir = findProjectDir(projectCwd);
175
+ const manifestPath = join(projectDir, "openuispec.yaml");
176
+ const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
177
+ const projectName = manifest.project?.name ?? "app";
178
+ // Derive indicators from the FRAMEWORKS table so they stay in sync
179
+ const isWebDir = (d) => FRAMEWORKS.some((fw) => fw.indicators.some((f) => existsSync(join(d, f))));
180
+ // Check custom output_dir first
181
+ const customDir = manifest.generation?.output_dir?.web;
182
+ if (customDir) {
183
+ const resolved = resolve(projectDir, customDir);
184
+ if (isWebDir(resolved))
185
+ return resolved;
186
+ }
187
+ // Default: generated/web/<project-name>/
188
+ // Try from the project root (parent of openuispec/)
189
+ const projectRoot = resolve(projectDir, "..");
190
+ const defaultDir = join(projectRoot, "generated", "web", projectName);
191
+ if (isWebDir(defaultDir))
192
+ return defaultDir;
193
+ throw new Error(`Web app not found. Checked:\n` +
194
+ (customDir ? ` - ${resolve(projectDir, customDir)}\n` : "") +
195
+ ` - ${defaultDir}\n` +
196
+ `Generate the web target first, then try again.`);
197
+ }
198
+ const servers = new Map();
199
+ const serverStarts = new Map();
200
+ function ensureDepsInstalled(webDir, fw) {
201
+ if (!fw.installCommand)
202
+ return;
203
+ // For Node.js check node_modules; for others always run
204
+ if (fw.kind === "node" && existsSync(join(webDir, "node_modules")))
205
+ return;
206
+ try {
207
+ execSync(fw.installCommand.join(" "), { cwd: webDir, stdio: "pipe", timeout: 120_000 });
208
+ }
209
+ catch (err) {
210
+ throw new Error(`Failed to install dependencies in ${webDir}: ${err instanceof Error ? err.message : err}`);
211
+ }
212
+ }
213
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
214
+ /** Poll until the port accepts connections, or the timeout expires. */
215
+ async function waitForPort(port, timeoutMs = 60_000) {
216
+ const deadline = Date.now() + timeoutMs;
217
+ while (Date.now() < deadline) {
218
+ if (await isPortListening(port))
219
+ return true;
220
+ await new Promise((r) => setTimeout(r, 500));
221
+ }
222
+ return false;
223
+ }
224
+ async function startDevServer(webDir) {
225
+ const pendingStart = serverStarts.get(webDir);
226
+ if (pendingStart)
227
+ return pendingStart;
228
+ const existing = servers.get(webDir);
229
+ if (existing) {
230
+ const alive = existing.process === null
231
+ ? await isPortListening(existing.port) // external server
232
+ : existing.process.exitCode === null; // managed process
233
+ if (alive)
234
+ return existing;
235
+ servers.delete(webDir);
236
+ }
237
+ const startPromise = (async () => {
238
+ const fw = detectFramework(webDir);
239
+ const port = resolvePort(webDir, fw);
240
+ // Always prefer an already-running server on the expected port
241
+ if (await isPortListening(port)) {
242
+ const instance = { process: null, port, url: `http://localhost:${port}` };
243
+ servers.set(webDir, instance);
244
+ return instance;
245
+ }
246
+ // Start the dev server for this framework
247
+ ensureDepsInstalled(webDir, fw);
248
+ // Build command: replace "PORT" placeholder with actual port string
249
+ const [cmd, ...args] = fw.devCommand.map((part) => part === "PORT" ? String(port) : part);
250
+ // For Node.js, use the project's own dev script from package.json if available
251
+ let spawnCmd = cmd;
252
+ let spawnArgs = args;
253
+ if (fw.kind === "node" && existsSync(join(webDir, "package.json"))) {
254
+ try {
255
+ const pkg = JSON.parse(readFileSync(join(webDir, "package.json"), "utf-8"));
256
+ const scripts = pkg.scripts ?? {};
257
+ const scriptName = ["dev", "start", "serve", "develop"].find((n) => n in scripts);
258
+ if (scriptName) {
259
+ spawnCmd = "npm";
260
+ spawnArgs = ["run", scriptName];
261
+ }
262
+ }
263
+ catch { /* ignore */ }
264
+ }
265
+ const child = spawn(spawnCmd, spawnArgs, {
266
+ cwd: webDir,
267
+ stdio: ["ignore", "pipe", "pipe"],
268
+ env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none", [fw.portEnvVar]: String(port) },
269
+ });
270
+ // Collect stderr from the start so we have output for error messages
271
+ let stderr = "";
272
+ child.stderr?.on("data", (d) => { stderr += d.toString(); });
273
+ // Wait for the port to open (framework-agnostic — no stdout parsing needed)
274
+ const ready = await waitForPort(port, 60_000);
275
+ if (!ready) {
276
+ child.kill();
277
+ throw new Error(`${fw.name} dev server did not open port ${port} within 60s.\n` +
278
+ (stderr ? `stderr:\n${stripAnsi(stderr).slice(-500)}` : ""));
279
+ }
280
+ const instance = { process: child, port, url: `http://localhost:${port}` };
281
+ servers.set(webDir, instance);
282
+ return instance;
283
+ })();
284
+ serverStarts.set(webDir, startPromise);
285
+ try {
286
+ return await startPromise;
287
+ }
288
+ finally {
289
+ if (serverStarts.get(webDir) === startPromise) {
290
+ serverStarts.delete(webDir);
291
+ }
292
+ }
293
+ }
294
+ // ── browser manager (imported from screenshot-shared.ts) ────────────
295
+ // ── init_script URL injection ────────────────────────────────────────
296
+ /** Append ?__ous_init=<base64> to a URL, respecting existing query params. */
297
+ function appendInitParam(targetUrl, initScript) {
298
+ const url = new URL(targetUrl);
299
+ url.searchParams.set("__ous_init", Buffer.from(initScript).toString("base64"));
300
+ return url.toString();
301
+ }
302
+ // ── screenshot capture ──────────────────────────────────────────────
303
+ export async function takeScreenshot(projectCwd, options) {
304
+ const { route = "/", viewport = { width: 1280, height: 800 }, scale = 2, theme, wait_for = 1000, full_page = false, selector, output_dir, init_script, } = options;
305
+ // 1. Find and start
306
+ const webDir = findWebAppDir(projectCwd);
307
+ const server = await startDevServer(webDir);
308
+ const browser = await getBrowser();
309
+ // 2. Navigate
310
+ const page = await browser.newPage();
311
+ try {
312
+ await page.setViewport({
313
+ width: viewport.width,
314
+ height: viewport.height,
315
+ deviceScaleFactor: scale,
316
+ });
317
+ if (theme) {
318
+ await page.emulateMediaFeatures([
319
+ { name: "prefers-color-scheme", value: theme },
320
+ ]);
321
+ }
322
+ const base = server.url.replace(/\/+$/, "");
323
+ let targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
324
+ if (init_script)
325
+ targetUrl = appendInitParam(targetUrl, init_script);
326
+ await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
327
+ if (wait_for > 0) {
328
+ await new Promise((r) => setTimeout(r, wait_for));
329
+ }
330
+ // 3. Screenshot
331
+ let buffer;
332
+ if (selector) {
333
+ const element = await page.$(selector);
334
+ if (!element) {
335
+ return {
336
+ content: [{ type: "text", text: `Error: Element not found for selector: ${selector}` }],
337
+ isError: true,
338
+ };
339
+ }
340
+ buffer = await element.screenshot({ type: "png" });
341
+ }
342
+ else {
343
+ buffer = await page.screenshot({ type: "png", fullPage: full_page });
344
+ }
345
+ const base64 = buffer.toString("base64");
346
+ // Save to output_dir if specified
347
+ let savedPath;
348
+ if (output_dir) {
349
+ const outDir = resolve(webDir, output_dir);
350
+ mkdirSync(outDir, { recursive: true });
351
+ const routeSlug = route.replace(/^\//, "").replace(/\//g, "_") || "index";
352
+ const themeLabel = theme ?? "default";
353
+ savedPath = join(outDir, `${routeSlug}_${themeLabel}.png`);
354
+ writeFileSync(savedPath, buffer);
355
+ }
356
+ return {
357
+ content: [
358
+ { type: "image", data: base64, mimeType: "image/png" },
359
+ {
360
+ type: "text",
361
+ text: JSON.stringify({
362
+ route,
363
+ url: targetUrl,
364
+ viewport,
365
+ scale,
366
+ theme: theme ?? "default",
367
+ full_page,
368
+ selector: selector ?? null,
369
+ path: savedPath ?? null,
370
+ init_script: init_script ?? null,
371
+ }, null, 2),
372
+ },
373
+ ],
374
+ };
375
+ }
376
+ finally {
377
+ await page.close();
378
+ }
379
+ }
380
+ // ── batch screenshot ─────────────────────────────────────────────────
381
+ export async function takeScreenshotBatch(projectCwd, options) {
382
+ const { captures, viewport = { width: 1280, height: 800 }, scale = 2, theme, output_dir, init_script: sharedInitScript } = options;
383
+ if (captures.length === 0) {
384
+ return { content: [{ type: "text", text: "No web captures specified." }], isError: true };
385
+ }
386
+ const webDir = findWebAppDir(projectCwd);
387
+ const server = await startDevServer(webDir);
388
+ const browser = await getBrowser();
389
+ const page = await browser.newPage();
390
+ try {
391
+ await page.setViewport({
392
+ width: viewport.width,
393
+ height: viewport.height,
394
+ deviceScaleFactor: scale,
395
+ });
396
+ if (theme) {
397
+ await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: theme }]);
398
+ }
399
+ const base = server.url.replace(/\/+$/, "");
400
+ const themeLabel = theme ?? "default";
401
+ const snapshots = [];
402
+ for (const capture of captures) {
403
+ const effectiveInitScript = capture.init_script ?? sharedInitScript;
404
+ let targetUrl = `${base}${capture.route.startsWith("/") ? "" : "/"}${capture.route}`;
405
+ if (effectiveInitScript)
406
+ targetUrl = appendInitParam(targetUrl, effectiveInitScript);
407
+ await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
408
+ await new Promise((r) => setTimeout(r, capture.wait_for ?? 1000));
409
+ let buffer;
410
+ if (capture.selector) {
411
+ const el = await page.$(capture.selector);
412
+ buffer = el ? await el.screenshot({ type: "png" }) : await page.screenshot({ type: "png" });
413
+ }
414
+ else {
415
+ buffer = await page.screenshot({ type: "png", fullPage: capture.full_page ?? false });
416
+ }
417
+ const filename = `${capture.screen}_${themeLabel}.png`;
418
+ let savedPath = filename;
419
+ if (output_dir) {
420
+ const outDir = resolve(webDir, output_dir);
421
+ mkdirSync(outDir, { recursive: true });
422
+ savedPath = join(outDir, filename);
423
+ writeFileSync(savedPath, buffer);
424
+ }
425
+ snapshots.push({ screen: capture.screen, path: savedPath, data: buffer.toString("base64"), init_script: effectiveInitScript });
426
+ }
427
+ const content = [];
428
+ for (const s of snapshots) {
429
+ content.push({ type: "image", data: s.data, mimeType: "image/png" });
430
+ content.push({
431
+ type: "text",
432
+ text: JSON.stringify({ screen: s.screen, path: s.path, viewport, scale, theme: themeLabel, init_script: s.init_script ?? null }, null, 2),
433
+ });
434
+ }
435
+ return { content };
436
+ }
437
+ finally {
438
+ await page.close();
439
+ }
440
+ }
441
+ // ── cleanup ─────────────────────────────────────────────────────────
442
+ function killAllServers() {
443
+ for (const [, instance] of servers) {
444
+ if (instance.process) {
445
+ try {
446
+ instance.process.kill();
447
+ }
448
+ catch { /* already dead */ }
449
+ }
450
+ }
451
+ servers.clear();
452
+ }
453
+ export async function shutdownAll() {
454
+ killAllServers();
455
+ await closeBrowser();
456
+ }
457
+ process.on("exit", killAllServers);
458
+ process.on("SIGINT", () => { shutdownAll().then(() => process.exit(0)); });
459
+ process.on("SIGTERM", () => { shutdownAll().then(() => process.exit(0)); });