openuispec 0.2.19 → 0.2.21
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/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 +964 -0
- package/dist/drift/index.js +903 -0
- package/dist/mcp-server/index.js +888 -0
- package/dist/mcp-server/preview-render.js +1761 -0
- package/dist/mcp-server/preview.js +229 -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 +185 -0
- package/dist/mcp-server/screenshot.js +469 -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/package.json +13 -14
- 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/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
|
@@ -0,0 +1,185 @@
|
|
|
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 (Playwright) ─────────────────────────────
|
|
10
|
+
let browserInstance = null;
|
|
11
|
+
let launchPromise = null;
|
|
12
|
+
export async function getBrowser() {
|
|
13
|
+
if (browserInstance && browserInstance.isConnected())
|
|
14
|
+
return browserInstance;
|
|
15
|
+
if (!launchPromise) {
|
|
16
|
+
launchPromise = (async () => {
|
|
17
|
+
let playwright;
|
|
18
|
+
try {
|
|
19
|
+
playwright = await import("playwright");
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
try {
|
|
23
|
+
playwright = await import("playwright-core");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error("playwright is not installed. Run:\n npm install -g playwright\n" +
|
|
27
|
+
"or add it to your project's devDependencies.");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
browserInstance = await playwright.chromium.launch({
|
|
31
|
+
headless: true,
|
|
32
|
+
args: ["--no-sandbox", "--disable-setuid-sandbox"],
|
|
33
|
+
});
|
|
34
|
+
return browserInstance;
|
|
35
|
+
})();
|
|
36
|
+
}
|
|
37
|
+
return launchPromise;
|
|
38
|
+
}
|
|
39
|
+
export async function closeBrowser() {
|
|
40
|
+
launchPromise = null;
|
|
41
|
+
if (browserInstance) {
|
|
42
|
+
try {
|
|
43
|
+
await browserInstance.close();
|
|
44
|
+
}
|
|
45
|
+
catch { /* ignore */ }
|
|
46
|
+
browserInstance = null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function loadManifest(projectCwd) {
|
|
50
|
+
const projectDir = findProjectDir(projectCwd);
|
|
51
|
+
const manifestPath = join(projectDir, "openuispec.yaml");
|
|
52
|
+
const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
|
|
53
|
+
const projectName = manifest.project?.name ?? "app";
|
|
54
|
+
const projectRoot = resolve(projectDir, "..");
|
|
55
|
+
return { projectDir, projectRoot, manifest, projectName };
|
|
56
|
+
}
|
|
57
|
+
// ── generic platform app directory discovery ────────────────────────
|
|
58
|
+
function tryFindProjectDir(cwd) {
|
|
59
|
+
if (existsSync(join(cwd, "openuispec.yaml"))) {
|
|
60
|
+
return cwd;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
return findProjectDir(cwd);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function findPlatformAppDir(projectCwd, platform, existsCheck, directDir) {
|
|
70
|
+
const label = platform.charAt(0).toUpperCase() + platform.slice(1);
|
|
71
|
+
// If a direct project dir is provided, use it without manifest lookup
|
|
72
|
+
if (directDir) {
|
|
73
|
+
const resolved = resolve(directDir);
|
|
74
|
+
if (existsCheck(resolved))
|
|
75
|
+
return resolved;
|
|
76
|
+
throw new Error(`${label} project not found at provided path: ${resolved}`);
|
|
77
|
+
}
|
|
78
|
+
// Check if projectCwd has an openuispec.yaml — if so, use manifest-based discovery
|
|
79
|
+
const manifestDir = tryFindProjectDir(projectCwd);
|
|
80
|
+
if (manifestDir) {
|
|
81
|
+
const manifestPath = join(manifestDir, "openuispec.yaml");
|
|
82
|
+
const manifest = YAML.parse(readFileSync(manifestPath, "utf-8"));
|
|
83
|
+
const projectName = manifest.project?.name ?? "app";
|
|
84
|
+
const projectRoot = resolve(manifestDir, "..");
|
|
85
|
+
// Check custom output_dir first
|
|
86
|
+
const customDir = manifest.generation?.output_dir?.[platform];
|
|
87
|
+
if (customDir) {
|
|
88
|
+
const resolved = resolve(manifestDir, customDir);
|
|
89
|
+
if (existsCheck(resolved))
|
|
90
|
+
return resolved;
|
|
91
|
+
}
|
|
92
|
+
// Default: generated/<platform>/<project-name>/
|
|
93
|
+
const defaultDir = join(projectRoot, "generated", platform, projectName);
|
|
94
|
+
if (existsCheck(defaultDir))
|
|
95
|
+
return defaultDir;
|
|
96
|
+
throw new Error(`${label} app not found. Checked:\n` +
|
|
97
|
+
(customDir ? ` - ${resolve(manifestDir, customDir)}\n` : "") +
|
|
98
|
+
` - ${defaultDir}\n` +
|
|
99
|
+
`Generate the ${platform} target first, then try again.`);
|
|
100
|
+
}
|
|
101
|
+
// No manifest — treat projectCwd itself as the platform project dir
|
|
102
|
+
const resolved = resolve(projectCwd);
|
|
103
|
+
if (existsCheck(resolved))
|
|
104
|
+
return resolved;
|
|
105
|
+
throw new Error(`${label} project not found at ${resolved}. ` +
|
|
106
|
+
`Provide a project_dir parameter or create an openuispec.yaml manifest.`);
|
|
107
|
+
}
|
|
108
|
+
// ── file hashing / caching ──────────────────────────────────────────
|
|
109
|
+
export function hashContent(content) {
|
|
110
|
+
return createHash("md5").update(content).digest("hex");
|
|
111
|
+
}
|
|
112
|
+
export function loadHashes(dir, hashFile) {
|
|
113
|
+
const hashPath = join(dir, hashFile);
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(readFileSync(hashPath, "utf-8"));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return {};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
export function saveHashes(dir, hashFile, hashes) {
|
|
122
|
+
writeFileSync(join(dir, hashFile), JSON.stringify(hashes, null, 2));
|
|
123
|
+
}
|
|
124
|
+
// ── screen name filter ──────────────────────────────────────────────
|
|
125
|
+
export function matchesScreenFilter(screenName, filter) {
|
|
126
|
+
const normalizedFilter = filter.toLowerCase().replace(/_/g, "");
|
|
127
|
+
const normalizedScreen = screenName.toLowerCase().replace(/_/g, "");
|
|
128
|
+
return normalizedScreen.includes(normalizedFilter) || normalizedFilter.includes(normalizedScreen);
|
|
129
|
+
}
|
|
130
|
+
// ── recursive file walker ───────────────────────────────────────────
|
|
131
|
+
export function walkFiles(dir, ext, skipDirs = []) {
|
|
132
|
+
const results = [];
|
|
133
|
+
if (!existsSync(dir))
|
|
134
|
+
return results;
|
|
135
|
+
const walk = (d) => {
|
|
136
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
137
|
+
const fullPath = join(d, entry.name);
|
|
138
|
+
if (entry.isDirectory()) {
|
|
139
|
+
if (!skipDirs.includes(entry.name))
|
|
140
|
+
walk(fullPath);
|
|
141
|
+
}
|
|
142
|
+
else if (entry.name.endsWith(ext)) {
|
|
143
|
+
results.push(fullPath);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
walk(dir);
|
|
148
|
+
return results;
|
|
149
|
+
}
|
|
150
|
+
// ── PNG snapshot collector ──────────────────────────────────────────
|
|
151
|
+
export function collectPngSnapshots(dirs, rootDir, screenFilter, nameExtractor) {
|
|
152
|
+
const snapshots = [];
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
for (const dir of dirs) {
|
|
155
|
+
const pngs = walkFiles(dir, ".png");
|
|
156
|
+
for (const fullPath of pngs) {
|
|
157
|
+
const filename = fullPath.split("/").pop();
|
|
158
|
+
const screen = nameExtractor(filename);
|
|
159
|
+
if (screenFilter && !matchesScreenFilter(screen, screenFilter))
|
|
160
|
+
continue;
|
|
161
|
+
const key = relative(rootDir, fullPath);
|
|
162
|
+
if (seen.has(key))
|
|
163
|
+
continue;
|
|
164
|
+
seen.add(key);
|
|
165
|
+
const data = readFileSync(fullPath).toString("base64");
|
|
166
|
+
snapshots.push({ screen, path: key, data });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return snapshots;
|
|
170
|
+
}
|
|
171
|
+
// ── screenshot response builder ─────────────────────────────────────
|
|
172
|
+
export function buildScreenshotResponse(snapshots, metadataFn) {
|
|
173
|
+
if (snapshots.length === 0) {
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: "text", text: "No screenshots generated. Check build output." }],
|
|
176
|
+
isError: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const content = [];
|
|
180
|
+
for (const snapshot of snapshots) {
|
|
181
|
+
content.push({ type: "image", data: snapshot.data, mimeType: "image/png" });
|
|
182
|
+
content.push({ type: "text", text: JSON.stringify(metadataFn(snapshot), null, 2) });
|
|
183
|
+
}
|
|
184
|
+
return { content };
|
|
185
|
+
}
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot tool — launches dev server + headless browser, captures pages.
|
|
3
|
+
*
|
|
4
|
+
* Both the Vite dev server and the Playwright 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 (Playwright context + page)
|
|
310
|
+
const context = await browser.newContext({
|
|
311
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
312
|
+
deviceScaleFactor: scale,
|
|
313
|
+
colorScheme: theme ?? "light",
|
|
314
|
+
});
|
|
315
|
+
const page = await context.newPage();
|
|
316
|
+
try {
|
|
317
|
+
const base = server.url.replace(/\/+$/, "");
|
|
318
|
+
let targetUrl = `${base}${route.startsWith("/") ? "" : "/"}${route}`;
|
|
319
|
+
if (init_script)
|
|
320
|
+
targetUrl = appendInitParam(targetUrl, init_script);
|
|
321
|
+
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 30_000 });
|
|
322
|
+
if (wait_for > 0) {
|
|
323
|
+
await new Promise((r) => setTimeout(r, wait_for));
|
|
324
|
+
}
|
|
325
|
+
// 3. Screenshot
|
|
326
|
+
let buffer;
|
|
327
|
+
if (selector) {
|
|
328
|
+
const loc = page.locator(selector);
|
|
329
|
+
if ((await loc.count()) === 0) {
|
|
330
|
+
return {
|
|
331
|
+
content: [{ type: "text", text: `Error: Element not found for selector: ${selector}` }],
|
|
332
|
+
isError: true,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
buffer = await loc.screenshot({ type: "png" });
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
buffer = await page.screenshot({ type: "png", fullPage: full_page });
|
|
339
|
+
}
|
|
340
|
+
const base64 = buffer.toString("base64");
|
|
341
|
+
// Save to output_dir if specified
|
|
342
|
+
let savedPath;
|
|
343
|
+
if (output_dir) {
|
|
344
|
+
const outDir = resolve(webDir, output_dir);
|
|
345
|
+
mkdirSync(outDir, { recursive: true });
|
|
346
|
+
const routeSlug = route.replace(/^\//, "").replace(/\//g, "_") || "index";
|
|
347
|
+
const themeLabel = theme ?? "default";
|
|
348
|
+
savedPath = join(outDir, `${routeSlug}_${themeLabel}.png`);
|
|
349
|
+
writeFileSync(savedPath, buffer);
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
content: [
|
|
353
|
+
{ type: "image", data: base64, mimeType: "image/png" },
|
|
354
|
+
{
|
|
355
|
+
type: "text",
|
|
356
|
+
text: JSON.stringify({
|
|
357
|
+
route,
|
|
358
|
+
url: targetUrl,
|
|
359
|
+
viewport,
|
|
360
|
+
scale,
|
|
361
|
+
theme: theme ?? "default",
|
|
362
|
+
full_page,
|
|
363
|
+
selector: selector ?? null,
|
|
364
|
+
path: savedPath ?? null,
|
|
365
|
+
init_script: init_script ?? null,
|
|
366
|
+
}, null, 2),
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
await page.close();
|
|
373
|
+
await context.close();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// ── batch screenshot ─────────────────────────────────────────────────
|
|
377
|
+
export async function takeScreenshotBatch(projectCwd, options) {
|
|
378
|
+
const { captures, viewport = { width: 1280, height: 800 }, scale = 2, theme, output_dir, init_script: sharedInitScript } = options;
|
|
379
|
+
if (captures.length === 0) {
|
|
380
|
+
return { content: [{ type: "text", text: "No web captures specified." }], isError: true };
|
|
381
|
+
}
|
|
382
|
+
const webDir = findWebAppDir(projectCwd);
|
|
383
|
+
const server = await startDevServer(webDir);
|
|
384
|
+
const browser = await getBrowser();
|
|
385
|
+
const context = await browser.newContext({
|
|
386
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
387
|
+
deviceScaleFactor: scale,
|
|
388
|
+
colorScheme: theme ?? "light",
|
|
389
|
+
});
|
|
390
|
+
const page = await context.newPage();
|
|
391
|
+
try {
|
|
392
|
+
const base = server.url.replace(/\/+$/, "");
|
|
393
|
+
const themeLabel = theme ?? "default";
|
|
394
|
+
const snapshots = [];
|
|
395
|
+
const errors = [];
|
|
396
|
+
for (let i = 0; i < captures.length; i++) {
|
|
397
|
+
const capture = captures[i];
|
|
398
|
+
try {
|
|
399
|
+
const effectiveInitScript = capture.init_script ?? sharedInitScript;
|
|
400
|
+
let targetUrl = `${base}${capture.route.startsWith("/") ? "" : "/"}${capture.route}`;
|
|
401
|
+
if (effectiveInitScript)
|
|
402
|
+
targetUrl = appendInitParam(targetUrl, effectiveInitScript);
|
|
403
|
+
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 30_000 });
|
|
404
|
+
await new Promise((r) => setTimeout(r, capture.wait_for ?? 1000));
|
|
405
|
+
let buffer;
|
|
406
|
+
if (capture.selector) {
|
|
407
|
+
const loc = page.locator(capture.selector);
|
|
408
|
+
buffer = (await loc.count()) > 0
|
|
409
|
+
? await loc.screenshot({ type: "png" })
|
|
410
|
+
: await page.screenshot({ type: "png" });
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
buffer = await page.screenshot({ type: "png", fullPage: capture.full_page ?? false });
|
|
414
|
+
}
|
|
415
|
+
const filename = `${capture.screen}_${themeLabel}.png`;
|
|
416
|
+
let savedPath = filename;
|
|
417
|
+
if (output_dir) {
|
|
418
|
+
const outDir = resolve(webDir, output_dir);
|
|
419
|
+
mkdirSync(outDir, { recursive: true });
|
|
420
|
+
savedPath = join(outDir, filename);
|
|
421
|
+
writeFileSync(savedPath, buffer);
|
|
422
|
+
}
|
|
423
|
+
snapshots.push({ screen: capture.screen, path: savedPath, data: buffer.toString("base64"), init_script: effectiveInitScript });
|
|
424
|
+
options.onProgress?.({ screen: capture.screen, index: i + 1, total: captures.length, ok: true });
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
428
|
+
errors.push({ screen: capture.screen, error: msg });
|
|
429
|
+
options.onProgress?.({ screen: capture.screen, index: i + 1, total: captures.length, ok: false, error: msg });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const content = [];
|
|
433
|
+
content.push({
|
|
434
|
+
type: "text",
|
|
435
|
+
text: JSON.stringify({ captured: snapshots.length, failed: errors.length, errors }, null, 2),
|
|
436
|
+
});
|
|
437
|
+
for (const s of snapshots) {
|
|
438
|
+
content.push({ type: "image", data: s.data, mimeType: "image/png" });
|
|
439
|
+
content.push({
|
|
440
|
+
type: "text",
|
|
441
|
+
text: JSON.stringify({ screen: s.screen, path: s.path, viewport, scale, theme: themeLabel, init_script: s.init_script ?? null }, null, 2),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
return { content };
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
await page.close();
|
|
448
|
+
await context.close();
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// ── cleanup ─────────────────────────────────────────────────────────
|
|
452
|
+
function killAllServers() {
|
|
453
|
+
for (const [, instance] of servers) {
|
|
454
|
+
if (instance.process) {
|
|
455
|
+
try {
|
|
456
|
+
instance.process.kill();
|
|
457
|
+
}
|
|
458
|
+
catch { /* already dead */ }
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
servers.clear();
|
|
462
|
+
}
|
|
463
|
+
export async function shutdownAll() {
|
|
464
|
+
killAllServers();
|
|
465
|
+
await closeBrowser();
|
|
466
|
+
}
|
|
467
|
+
process.on("exit", killAllServers);
|
|
468
|
+
process.on("SIGINT", () => { shutdownAll().then(() => process.exit(0)); });
|
|
469
|
+
process.on("SIGTERM", () => { shutdownAll().then(() => process.exit(0)); });
|