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.
- package/README.md +2 -10
- package/dist/check/audit.js +392 -0
- package/dist/check/index.js +216 -0
- package/dist/cli/configure-target.js +391 -0
- package/dist/cli/index.js +510 -0
- package/dist/cli/init.js +1047 -0
- package/dist/drift/index.js +903 -0
- package/dist/mcp-server/index.js +886 -0
- package/dist/mcp-server/preview-render.js +1761 -0
- package/dist/mcp-server/preview.js +233 -0
- package/dist/mcp-server/screenshot-android.js +458 -0
- package/dist/mcp-server/screenshot-ios.js +639 -0
- package/dist/mcp-server/screenshot-shared.js +180 -0
- package/dist/mcp-server/screenshot.js +459 -0
- package/dist/prepare/index.js +1216 -0
- package/dist/runtime/package-paths.js +33 -0
- package/dist/schema/semantic-lint.js +564 -0
- package/dist/schema/validate.js +689 -0
- package/dist/status/index.js +194 -0
- package/docs/images/how-it-works.svg +56 -0
- package/docs/images/workflows.svg +76 -0
- package/package.json +12 -13
- package/check/audit.ts +0 -426
- package/check/index.ts +0 -320
- package/cli/configure-target.ts +0 -523
- package/cli/index.ts +0 -537
- package/cli/init.ts +0 -1253
- package/docs/images/how-it-works-dark.png +0 -0
- package/docs/images/how-it-works-light.png +0 -0
- package/docs/images/workflows-dark.png +0 -0
- package/docs/images/workflows-light.png +0 -0
- package/drift/index.ts +0 -1165
- package/mcp-server/index.ts +0 -1041
- package/mcp-server/preview-render.ts +0 -1922
- package/mcp-server/preview.ts +0 -292
- package/mcp-server/screenshot-android.ts +0 -621
- package/mcp-server/screenshot-ios.ts +0 -753
- package/mcp-server/screenshot-shared.ts +0 -237
- package/mcp-server/screenshot.ts +0 -563
- package/prepare/index.ts +0 -1530
- package/schema/semantic-lint.ts +0 -692
- package/schema/validate.ts +0 -870
- package/scripts/regenerate-previews.ts +0 -136
- package/scripts/take-all-screenshots.ts +0 -507
- package/status/index.ts +0 -275
package/mcp-server/screenshot.ts
DELETED
|
@@ -1,563 +0,0 @@
|
|
|
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
|
-
|
|
8
|
-
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
|
9
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
10
|
-
import { join, resolve } from "node:path";
|
|
11
|
-
import { createConnection } from "node:net";
|
|
12
|
-
import YAML from "yaml";
|
|
13
|
-
import { findProjectDir } from "../drift/index.js";
|
|
14
|
-
import { getBrowser, closeBrowser, type ScreenshotResult } from "./screenshot-shared.js";
|
|
15
|
-
|
|
16
|
-
// ── types ───────────────────────────────────────────────────────────
|
|
17
|
-
|
|
18
|
-
export interface ScreenshotOptions {
|
|
19
|
-
route: string;
|
|
20
|
-
viewport?: { width: number; height: number };
|
|
21
|
-
scale?: number;
|
|
22
|
-
theme?: "light" | "dark";
|
|
23
|
-
wait_for?: number;
|
|
24
|
-
full_page?: boolean;
|
|
25
|
-
selector?: string;
|
|
26
|
-
output_dir?: string;
|
|
27
|
-
init_script?: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
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
|
-
}
|
|
48
|
-
|
|
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));
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// ── web app directory discovery ─────────────────────────────────────
|
|
211
|
-
|
|
212
|
-
export function findWebAppDir(projectCwd: string): string {
|
|
213
|
-
const projectDir = findProjectDir(projectCwd);
|
|
214
|
-
const manifestPath = join(projectDir, "openuispec.yaml");
|
|
215
|
-
const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
|
|
216
|
-
const projectName = manifest.project?.name ?? "app";
|
|
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
|
-
|
|
222
|
-
// Check custom output_dir first
|
|
223
|
-
const customDir = manifest.generation?.output_dir?.web;
|
|
224
|
-
if (customDir) {
|
|
225
|
-
const resolved = resolve(projectDir, customDir);
|
|
226
|
-
if (isWebDir(resolved)) return resolved;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Default: generated/web/<project-name>/
|
|
230
|
-
// Try from the project root (parent of openuispec/)
|
|
231
|
-
const projectRoot = resolve(projectDir, "..");
|
|
232
|
-
const defaultDir = join(projectRoot, "generated", "web", projectName);
|
|
233
|
-
if (isWebDir(defaultDir)) return defaultDir;
|
|
234
|
-
|
|
235
|
-
throw new Error(
|
|
236
|
-
`Web app not found. Checked:\n` +
|
|
237
|
-
(customDir ? ` - ${resolve(projectDir, customDir)}\n` : "") +
|
|
238
|
-
` - ${defaultDir}\n` +
|
|
239
|
-
`Generate the web target first, then try again.`,
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ── dev server manager ──────────────────────────────────────────────
|
|
244
|
-
|
|
245
|
-
interface ServerInstance {
|
|
246
|
-
process: ChildProcess | null; // null = using an externally running server
|
|
247
|
-
port: number;
|
|
248
|
-
url: string;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const servers = new Map<string, ServerInstance>();
|
|
252
|
-
|
|
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;
|
|
257
|
-
try {
|
|
258
|
-
execSync(fw.installCommand.join(" "), { cwd: webDir, stdio: "pipe", timeout: 120_000 });
|
|
259
|
-
} catch (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));
|
|
272
|
-
}
|
|
273
|
-
return false;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
async function startDevServer(webDir: string): Promise<ServerInstance> {
|
|
277
|
-
const existing = servers.get(webDir);
|
|
278
|
-
if (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;
|
|
283
|
-
servers.delete(webDir);
|
|
284
|
-
}
|
|
285
|
-
|
|
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
|
-
}
|
|
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, {
|
|
318
|
-
cwd: webDir,
|
|
319
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
320
|
-
env: { ...process.env, FORCE_COLOR: "0", BROWSER: "none", [fw.portEnvVar]: String(port) },
|
|
321
|
-
});
|
|
322
|
-
|
|
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
|
-
}
|
|
337
|
-
|
|
338
|
-
const instance: ServerInstance = { process: child, port, url: `http://localhost:${port}` };
|
|
339
|
-
servers.set(webDir, instance);
|
|
340
|
-
return instance;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// ── browser manager (imported from screenshot-shared.ts) ────────────
|
|
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
|
-
|
|
354
|
-
// ── screenshot capture ──────────────────────────────────────────────
|
|
355
|
-
|
|
356
|
-
export async function takeScreenshot(
|
|
357
|
-
projectCwd: string,
|
|
358
|
-
options: ScreenshotOptions,
|
|
359
|
-
): Promise<ScreenshotResult> {
|
|
360
|
-
const {
|
|
361
|
-
route = "/",
|
|
362
|
-
viewport = { width: 1280, height: 800 },
|
|
363
|
-
scale = 2,
|
|
364
|
-
theme,
|
|
365
|
-
wait_for = 1000,
|
|
366
|
-
full_page = false,
|
|
367
|
-
selector,
|
|
368
|
-
output_dir,
|
|
369
|
-
init_script,
|
|
370
|
-
} = options;
|
|
371
|
-
|
|
372
|
-
// 1. Find and start
|
|
373
|
-
const webDir = findWebAppDir(projectCwd);
|
|
374
|
-
const server = await startDevServer(webDir);
|
|
375
|
-
const browser = await getBrowser();
|
|
376
|
-
|
|
377
|
-
// 2. Navigate
|
|
378
|
-
const page = await browser.newPage();
|
|
379
|
-
try {
|
|
380
|
-
await page.setViewport({
|
|
381
|
-
width: viewport.width,
|
|
382
|
-
height: viewport.height,
|
|
383
|
-
deviceScaleFactor: scale,
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
if (theme) {
|
|
387
|
-
await page.emulateMediaFeatures([
|
|
388
|
-
{ name: "prefers-color-scheme", value: theme },
|
|
389
|
-
]);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
const base = server.url.replace(/\/+$/, "");
|
|
393
|
-
let targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
|
|
394
|
-
if (init_script) targetUrl = appendInitParam(targetUrl, init_script);
|
|
395
|
-
await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
|
|
396
|
-
|
|
397
|
-
if (wait_for > 0) {
|
|
398
|
-
await new Promise((r) => setTimeout(r, wait_for));
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// 3. Screenshot
|
|
402
|
-
let buffer: Buffer;
|
|
403
|
-
if (selector) {
|
|
404
|
-
const element = await page.$(selector);
|
|
405
|
-
if (!element) {
|
|
406
|
-
return {
|
|
407
|
-
content: [{ type: "text", text: `Error: Element not found for selector: ${selector}` }],
|
|
408
|
-
isError: true,
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
buffer = await element.screenshot({ type: "png" });
|
|
412
|
-
} else {
|
|
413
|
-
buffer = await page.screenshot({ type: "png", fullPage: full_page });
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
const base64 = buffer.toString("base64");
|
|
417
|
-
|
|
418
|
-
// Save to output_dir if specified
|
|
419
|
-
let savedPath: string | undefined;
|
|
420
|
-
if (output_dir) {
|
|
421
|
-
const outDir = resolve(webDir, output_dir);
|
|
422
|
-
mkdirSync(outDir, { recursive: true });
|
|
423
|
-
const routeSlug = route.replace(/^\//, "").replace(/\//g, "_") || "index";
|
|
424
|
-
const themeLabel = theme ?? "default";
|
|
425
|
-
savedPath = join(outDir, `${routeSlug}_${themeLabel}.png`);
|
|
426
|
-
writeFileSync(savedPath, buffer);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
return {
|
|
430
|
-
content: [
|
|
431
|
-
{ type: "image" as const, data: base64, mimeType: "image/png" },
|
|
432
|
-
{
|
|
433
|
-
type: "text" as const,
|
|
434
|
-
text: JSON.stringify({
|
|
435
|
-
route,
|
|
436
|
-
url: targetUrl,
|
|
437
|
-
viewport,
|
|
438
|
-
scale,
|
|
439
|
-
theme: theme ?? "default",
|
|
440
|
-
full_page,
|
|
441
|
-
selector: selector ?? null,
|
|
442
|
-
path: savedPath ?? null,
|
|
443
|
-
init_script: init_script ?? null,
|
|
444
|
-
}, null, 2),
|
|
445
|
-
},
|
|
446
|
-
],
|
|
447
|
-
};
|
|
448
|
-
} finally {
|
|
449
|
-
await page.close();
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// ── batch types ──────────────────────────────────────────────────────
|
|
454
|
-
|
|
455
|
-
export interface WebBatchCapture {
|
|
456
|
-
screen: string;
|
|
457
|
-
route: string;
|
|
458
|
-
selector?: string;
|
|
459
|
-
full_page?: boolean;
|
|
460
|
-
wait_for?: number;
|
|
461
|
-
init_script?: string;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
export interface WebScreenshotBatchOptions {
|
|
465
|
-
captures: WebBatchCapture[];
|
|
466
|
-
viewport?: { width: number; height: number };
|
|
467
|
-
scale?: number;
|
|
468
|
-
theme?: "light" | "dark";
|
|
469
|
-
output_dir?: string;
|
|
470
|
-
init_script?: string;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// ── batch screenshot ─────────────────────────────────────────────────
|
|
474
|
-
|
|
475
|
-
export async function takeScreenshotBatch(
|
|
476
|
-
projectCwd: string,
|
|
477
|
-
options: WebScreenshotBatchOptions,
|
|
478
|
-
): Promise<ScreenshotResult> {
|
|
479
|
-
const { captures, viewport = { width: 1280, height: 800 }, scale = 2, theme, output_dir, init_script: sharedInitScript } = options;
|
|
480
|
-
|
|
481
|
-
if (captures.length === 0) {
|
|
482
|
-
return { content: [{ type: "text", text: "No web captures specified." }], isError: true };
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
const webDir = findWebAppDir(projectCwd);
|
|
486
|
-
const server = await startDevServer(webDir);
|
|
487
|
-
const browser = await getBrowser();
|
|
488
|
-
const page = await browser.newPage();
|
|
489
|
-
|
|
490
|
-
try {
|
|
491
|
-
await page.setViewport({
|
|
492
|
-
width: viewport.width,
|
|
493
|
-
height: viewport.height,
|
|
494
|
-
deviceScaleFactor: scale,
|
|
495
|
-
});
|
|
496
|
-
if (theme) {
|
|
497
|
-
await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: theme }]);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
const base = server.url.replace(/\/+$/, "");
|
|
501
|
-
const themeLabel = theme ?? "default";
|
|
502
|
-
const snapshots: Array<{ screen: string; path: string; data: string; init_script?: string }> = [];
|
|
503
|
-
|
|
504
|
-
for (const capture of captures) {
|
|
505
|
-
const effectiveInitScript = capture.init_script ?? sharedInitScript;
|
|
506
|
-
let targetUrl = `${base}${capture.route.startsWith("/") ? "" : "/"}${capture.route}`;
|
|
507
|
-
if (effectiveInitScript) targetUrl = appendInitParam(targetUrl, effectiveInitScript);
|
|
508
|
-
await page.goto(targetUrl, { waitUntil: "networkidle2", timeout: 30_000 });
|
|
509
|
-
await new Promise((r) => setTimeout(r, capture.wait_for ?? 1000));
|
|
510
|
-
|
|
511
|
-
let buffer: Buffer;
|
|
512
|
-
if (capture.selector) {
|
|
513
|
-
const el = await page.$(capture.selector);
|
|
514
|
-
buffer = el ? await el.screenshot({ type: "png" }) : await page.screenshot({ type: "png" });
|
|
515
|
-
} else {
|
|
516
|
-
buffer = await page.screenshot({ type: "png", fullPage: capture.full_page ?? false });
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const filename = `${capture.screen}_${themeLabel}.png`;
|
|
520
|
-
let savedPath = filename;
|
|
521
|
-
if (output_dir) {
|
|
522
|
-
const outDir = resolve(webDir, output_dir);
|
|
523
|
-
mkdirSync(outDir, { recursive: true });
|
|
524
|
-
savedPath = join(outDir, filename);
|
|
525
|
-
writeFileSync(savedPath, buffer);
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
snapshots.push({ screen: capture.screen, path: savedPath, data: buffer.toString("base64"), init_script: effectiveInitScript });
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const content: ScreenshotResult["content"] = [];
|
|
532
|
-
for (const s of snapshots) {
|
|
533
|
-
content.push({ type: "image" as const, data: s.data, mimeType: "image/png" });
|
|
534
|
-
content.push({
|
|
535
|
-
type: "text" as const,
|
|
536
|
-
text: JSON.stringify({ screen: s.screen, path: s.path, viewport, scale, theme: themeLabel, init_script: s.init_script ?? null }, null, 2),
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
return { content };
|
|
540
|
-
} finally {
|
|
541
|
-
await page.close();
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
// ── cleanup ─────────────────────────────────────────────────────────
|
|
546
|
-
|
|
547
|
-
function killAllServers() {
|
|
548
|
-
for (const [, instance] of servers) {
|
|
549
|
-
if (instance.process) {
|
|
550
|
-
try { instance.process.kill(); } catch { /* already dead */ }
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
servers.clear();
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
export async function shutdownAll() {
|
|
557
|
-
killAllServers();
|
|
558
|
-
await closeBrowser();
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
process.on("exit", killAllServers);
|
|
562
|
-
process.on("SIGINT", () => { shutdownAll().then(() => process.exit(0)); });
|
|
563
|
-
process.on("SIGTERM", () => { shutdownAll().then(() => process.exit(0)); });
|