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.
- package/README.md +6 -5
- package/cli/index.ts +18 -12
- package/cli/init.ts +79 -13
- package/docs/cli.md +134 -27
- package/docs/file-formats.md +51 -1
- package/drift/index.ts +7 -2
- package/examples/social-app/openuispec/README.md +2 -1
- package/examples/social-app/openuispec/mock/chat_detail.yaml +25 -0
- package/examples/social-app/openuispec/mock/discover.yaml +17 -0
- package/examples/social-app/openuispec/mock/edit_profile.yaml +9 -0
- package/examples/social-app/openuispec/mock/home_feed.yaml +32 -0
- package/examples/social-app/openuispec/mock/messages_inbox.yaml +15 -0
- package/examples/social-app/openuispec/mock/notifications.yaml +30 -0
- package/examples/social-app/openuispec/mock/post_detail.yaml +26 -0
- package/examples/social-app/openuispec/mock/profile_self.yaml +28 -0
- package/examples/social-app/openuispec/mock/profile_user.yaml +32 -0
- package/examples/social-app/openuispec/mock/search_results.yaml +17 -0
- package/examples/social-app/openuispec/mock/settings.yaml +7 -0
- package/examples/social-app/openuispec/openuispec.yaml +3 -2
- package/examples/taskflow/README.md +4 -2
- package/examples/taskflow/openuispec/README.md +2 -1
- package/examples/taskflow/openuispec/components/media_player.yaml +92 -0
- package/examples/taskflow/openuispec/contracts/README.md +2 -2
- package/examples/taskflow/openuispec/locales/en.json +1 -0
- package/examples/taskflow/openuispec/mock/home.yaml +64 -0
- package/examples/taskflow/openuispec/mock/profile_edit.yaml +6 -0
- package/examples/taskflow/openuispec/mock/project_detail.yaml +33 -0
- package/examples/taskflow/openuispec/mock/settings.yaml +13 -0
- package/examples/taskflow/openuispec/mock/task_detail.yaml +18 -0
- package/examples/taskflow/openuispec/openuispec.yaml +3 -4
- package/examples/taskflow/openuispec/platform/ios.yaml +0 -4
- package/examples/taskflow/openuispec/screens/task_detail.yaml +5 -8
- package/examples/taskflow/openuispec/tokens/icons.yaml +16 -0
- package/examples/todo-orbit/README.md +3 -2
- package/examples/todo-orbit/openuispec/README.md +2 -1
- package/examples/todo-orbit/openuispec/components/task_trend_chart.yaml +85 -0
- package/examples/todo-orbit/openuispec/locales/en.json +3 -0
- package/examples/todo-orbit/openuispec/locales/ru.json +3 -0
- package/examples/todo-orbit/openuispec/mock/analytics.yaml +26 -0
- package/examples/todo-orbit/openuispec/mock/home.yaml +33 -0
- package/examples/todo-orbit/openuispec/mock/settings.yaml +7 -0
- package/examples/todo-orbit/openuispec/mock/task_detail.yaml +14 -0
- package/examples/todo-orbit/openuispec/openuispec.yaml +3 -3
- package/examples/todo-orbit/openuispec/platform/android.yaml +0 -3
- package/examples/todo-orbit/openuispec/platform/ios.yaml +0 -3
- package/examples/todo-orbit/openuispec/platform/web.yaml +0 -3
- package/examples/todo-orbit/openuispec/screens/analytics.yaml +1 -4
- package/mcp-server/index.ts +87 -6
- package/mcp-server/preview-render.ts +1922 -0
- package/mcp-server/preview.ts +292 -0
- package/mcp-server/screenshot-shared.ts +41 -4
- package/mcp-server/screenshot.ts +283 -97
- package/package.json +1 -1
- package/prepare/index.ts +1 -1
- package/schema/component.schema.json +278 -0
- package/schema/openuispec.schema.json +5 -1
- package/schema/screen.schema.json +12 -1
- package/schema/semantic-lint.ts +29 -5
- package/schema/validate.ts +21 -0
- package/scripts/regenerate-previews.ts +136 -0
- package/spec/{openuispec-v0.1.md → openuispec-v0.2.md} +266 -8
- package/examples/taskflow/openuispec/contracts/x_media_player.yaml +0 -185
- package/examples/todo-orbit/openuispec/contracts/x_task_trend_chart.yaml +0 -139
package/mcp-server/screenshot.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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("
|
|
258
|
+
execSync(fw.installCommand.join(" "), { cwd: webDir, stdio: "pipe", timeout: 120_000 });
|
|
89
259
|
} catch (err) {
|
|
90
|
-
throw new Error(`Failed to install
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
const port =
|
|
286
|
+
const fw = detectFramework(webDir);
|
|
287
|
+
const port = resolvePort(webDir, fw);
|
|
104
288
|
|
|
105
|
-
|
|
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
|
-
//
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
if (browserInstance?.connected) return browserInstance;
|
|
345
|
+
// ── init_script URL injection ────────────────────────────────────────
|
|
154
346
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
547
|
+
function killAllServers() {
|
|
359
548
|
for (const [, instance] of servers) {
|
|
360
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
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.
|
|
938
|
+
join(packageRoot, "spec", "openuispec-v0.2.md"),
|
|
939
939
|
join(packageRoot, "examples", "taskflow", "openuispec"),
|
|
940
940
|
join(packageRoot, "schema"),
|
|
941
941
|
];
|