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 +1 -0
- package/docs/cli.md +53 -0
- package/mcp-server/index.ts +7 -3
- package/mcp-server/screenshot-shared.ts +3 -4
- package/mcp-server/screenshot.ts +285 -70
- package/package.json +1 -1
- package/schema/semantic-lint.ts +5 -3
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
|
|
package/mcp-server/index.ts
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
51
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
52
|
+
|
|
53
|
+
export type ScreenshotResult = CallToolResult;
|
|
55
54
|
|
|
56
55
|
// ── manifest loading ────────────────────────────────────────────────
|
|
57
56
|
|
package/mcp-server/screenshot.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
// ──
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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("
|
|
258
|
+
execSync(fw.installCommand.join(" "), { cwd: webDir, stdio: "pipe", timeout: 120_000 });
|
|
85
259
|
} catch (err) {
|
|
86
|
-
throw new Error(`Failed to install
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
99
|
-
const port =
|
|
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
|
-
|
|
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
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
547
|
+
function killAllServers() {
|
|
333
548
|
for (const [, instance] of servers) {
|
|
334
|
-
|
|
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
package/schema/semantic-lint.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
221
|
-
|
|
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 };
|