@codeyam-editor/codeyam-editor
Advanced tools
| const { createIssue } = require("./scenario-issues"); | ||
| function getInitScript() { | ||
| return ` | ||
| window.__codeyamUnhandledRejections = []; | ||
| window.addEventListener("unhandledrejection", (event) => { | ||
| const reason = event.reason; | ||
| const message = | ||
| reason instanceof Error ? reason.message : String(reason); | ||
| window.__codeyamUnhandledRejections.push(message); | ||
| }); | ||
| // Stub WebSocket during capture to prevent terminal reconnection spam. | ||
| window.WebSocket = class StubWebSocket { | ||
| static CONNECTING = 0; | ||
| static OPEN = 1; | ||
| static CLOSING = 2; | ||
| static CLOSED = 3; | ||
| readyState = 3; | ||
| onopen = null; | ||
| onclose = null; | ||
| onerror = null; | ||
| onmessage = null; | ||
| send() {} | ||
| close() {} | ||
| addEventListener() {} | ||
| removeEventListener() {} | ||
| dispatchEvent() { return false; } | ||
| constructor() { | ||
| setTimeout(() => { | ||
| if (this.onerror) this.onerror(new Event("error")); | ||
| if (this.onclose) this.onclose(new CloseEvent("close")); | ||
| }, 0); | ||
| } | ||
| }; | ||
| `; | ||
| } | ||
| function handleConsoleMessage(message) { | ||
| if (message.type() !== "error") return null; | ||
| const text = message.text(); | ||
| // Ignore known dev-server WebSocket/HMR errors from Vite proxy | ||
| if ( | ||
| text.includes("WebSocket connection to") || | ||
| text.includes("Unsupported Media Type") | ||
| ) { | ||
| return null; | ||
| } | ||
| return createIssue("console", text); | ||
| } | ||
| function handlePageError(error) { | ||
| return createIssue("pageerror", error.message || String(error)); | ||
| } | ||
| function handleRequestFailed(request) { | ||
| return createIssue( | ||
| "requestfailed", | ||
| request.failure()?.errorText || "Request failed", | ||
| { url: request.url() } | ||
| ); | ||
| } | ||
| module.exports = { | ||
| getInitScript, | ||
| handleConsoleMessage, | ||
| handlePageError, | ||
| handleRequestFailed, | ||
| }; |
| function createIssue(kind, message, extra = {}) { | ||
| const issue = { | ||
| kind, | ||
| message, | ||
| url: extra.url ?? null, | ||
| status: extra.status ?? null, | ||
| }; | ||
| if (extra.matchedPattern != null) issue.matchedPattern = extra.matchedPattern; | ||
| if (extra.contextSnippet != null) issue.contextSnippet = extra.contextSnippet; | ||
| return issue; | ||
| } | ||
| function pushIssue(issues, issue) { | ||
| const key = JSON.stringify(issue); | ||
| if (!issues.some((existing) => JSON.stringify(existing) === key)) { | ||
| issues.push(issue); | ||
| } | ||
| } | ||
| function buildResult({ loaded, hasContent, issues, outputPath, url }) { | ||
| return { | ||
| ok: loaded && hasContent && issues.length === 0, | ||
| loaded, | ||
| hasContent, | ||
| url, | ||
| outputPath: outputPath ?? null, | ||
| issues, | ||
| }; | ||
| } | ||
| module.exports = { | ||
| createIssue, | ||
| pushIssue, | ||
| buildResult, | ||
| }; |
| const LOADING_MARKERS = [ | ||
| "Loading scenario...", | ||
| "Loading tests...", | ||
| "Loading scenarios...", | ||
| "disconnected", | ||
| ]; | ||
| function hasLoadingMarkers(text) { | ||
| return LOADING_MARKERS.some((marker) => text.includes(marker)); | ||
| } | ||
| function hasRenderableContent(state) { | ||
| if (!state) return false; | ||
| if ( | ||
| state.rootChildCount > 0 || | ||
| state.rootTextLength > 0 || | ||
| state.bodyTextLength > 0 | ||
| ) { | ||
| return true; | ||
| } | ||
| if ((state.loadedImageCount || 0) > 0) return true; | ||
| if ((state.mediaBboxCount || 0) > 0) return true; | ||
| return false; | ||
| } | ||
| function describeBlankReason(state) { | ||
| if (!state) return "no content state collected"; | ||
| const parts = []; | ||
| if (!(state.bodyTextLength > 0)) { | ||
| parts.push("no text"); | ||
| } | ||
| const imageCount = state.imageCount || 0; | ||
| const loadedImageCount = state.loadedImageCount || 0; | ||
| if (imageCount > 0 && loadedImageCount === 0) { | ||
| parts.push(`${imageCount} unloaded image${imageCount === 1 ? "" : "s"}`); | ||
| } else if (imageCount === 0) { | ||
| parts.push("no images"); | ||
| } | ||
| if (!((state.mediaBboxCount || 0) > 0)) { | ||
| parts.push("no svg/canvas/video"); | ||
| } | ||
| return parts.join(", "); | ||
| } | ||
| function shouldStopWaitingForImages(images, options = {}) { | ||
| const { elapsedMs = 0, overallTimeoutMs = 5000 } = options; | ||
| if (!Array.isArray(images) || images.length === 0) return true; | ||
| if (elapsedMs >= overallTimeoutMs) return true; | ||
| return images.every((img) => img && img.complete === true); | ||
| } | ||
| const ERROR_PATTERNS = [ | ||
| "not found in registry", | ||
| "Component not found", | ||
| "Scenario Error", | ||
| ]; | ||
| function hasErrorPatterns(text) { | ||
| return ERROR_PATTERNS.some((pattern) => text.includes(pattern)); | ||
| } | ||
| function findErrorPattern(text) { | ||
| if (!text) return null; | ||
| for (const pattern of ERROR_PATTERNS) { | ||
| if (text.includes(pattern)) return pattern; | ||
| } | ||
| return null; | ||
| } | ||
| const ERROR_CONTEXT_RADIUS = 60; | ||
| function buildErrorContextSnippet(text, pattern) { | ||
| if (!text || !pattern) return null; | ||
| const index = text.indexOf(pattern); | ||
| if (index < 0) return null; | ||
| const start = Math.max(0, index - ERROR_CONTEXT_RADIUS); | ||
| const end = Math.min(text.length, index + pattern.length + ERROR_CONTEXT_RADIUS); | ||
| const slice = text.slice(start, end).replace(/\s+/g, " ").trim(); | ||
| const prefix = start > 0 ? "…" : ""; | ||
| const suffix = end < text.length ? "…" : ""; | ||
| return `${prefix}${slice}${suffix}`; | ||
| } | ||
| module.exports = { | ||
| hasLoadingMarkers, | ||
| hasRenderableContent, | ||
| describeBlankReason, | ||
| shouldStopWaitingForImages, | ||
| hasErrorPatterns, | ||
| findErrorPattern, | ||
| buildErrorContextSnippet, | ||
| ERROR_PATTERNS, | ||
| ERROR_CONTEXT_RADIUS, | ||
| }; |
| function normalizeMockCandidates(url) { | ||
| try { | ||
| const parsed = new URL(url); | ||
| return [url, `${parsed.pathname}${parsed.search}`, parsed.pathname]; | ||
| } catch { | ||
| return [url]; | ||
| } | ||
| } | ||
| function findHttpMock(httpMocks, request) { | ||
| const method = request.method().toUpperCase(); | ||
| const candidates = normalizeMockCandidates(request.url()); | ||
| for (const candidate of candidates) { | ||
| const key = `${method} ${candidate}`; | ||
| if (httpMocks[key]) { | ||
| return httpMocks[key]; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| async function attachHttpMocks(page, httpMocks) { | ||
| if (!httpMocks || Object.keys(httpMocks).length === 0) return; | ||
| await page.route("**/*", async (route) => { | ||
| const mock = findHttpMock(httpMocks, route.request()); | ||
| if (!mock) { | ||
| await route.continue(); | ||
| return; | ||
| } | ||
| const headers = { ...(mock.headers || {}) }; | ||
| let body; | ||
| if (mock.body !== undefined) { | ||
| body = | ||
| typeof mock.body === "string" ? mock.body : JSON.stringify(mock.body); | ||
| const hasContentType = Object.keys(headers).some( | ||
| (key) => key.toLowerCase() === "content-type", | ||
| ); | ||
| if (!hasContentType) { | ||
| headers["content-type"] = "application/json"; | ||
| } | ||
| } | ||
| await route.fulfill({ | ||
| status: mock.status || 200, | ||
| headers, | ||
| body, | ||
| }); | ||
| }); | ||
| // Disable the in-page fetch mock by returning an empty active-mocks.json. | ||
| // The HTML injects a script that synchronously loads this file and | ||
| // monkey-patches window.fetch, which would bypass Playwright's route | ||
| // interception. This route is registered AFTER **/* so it takes priority | ||
| // (Playwright uses LIFO ordering for route handlers). | ||
| await page.route("**/active-mocks.json", async (route) => { | ||
| await route.fulfill({ | ||
| status: 200, | ||
| headers: { "content-type": "application/json" }, | ||
| body: "[]", | ||
| }); | ||
| }); | ||
| } | ||
| module.exports = { | ||
| normalizeMockCandidates, | ||
| findHttpMock, | ||
| attachHttpMocks, | ||
| }; |
| const { | ||
| hasLoadingMarkers, | ||
| shouldStopWaitingForImages, | ||
| } = require("./scenario-metrics"); | ||
| function escapeHtmlAttribute(value) { | ||
| return String(value).replaceAll("&", "&").replaceAll('"', """); | ||
| } | ||
| // The harness background defaults to transparent so the iframe's own | ||
| // <body> background paints through — matching what users see in the Live | ||
| // Preview. Callers (via scenario-check.js) pass a concrete color when the | ||
| // UI has detected a background it wants the capture to paint behind the | ||
| // iframe, e.g. `var(--bg-deep)` from the editor shell. | ||
| function buildIframeHarness(url, { background = "transparent" } = {}) { | ||
| const escapedUrl = escapeHtmlAttribute(url); | ||
| const bg = String(background); | ||
| return `<!doctype html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <style> | ||
| html, body { | ||
| margin: 0; | ||
| width: 100%; | ||
| height: 100%; | ||
| overflow: hidden; | ||
| background: ${bg}; | ||
| } | ||
| iframe { | ||
| display: block; | ||
| width: 100%; | ||
| height: 100%; | ||
| border: 0; | ||
| background: ${bg}; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <iframe id="scenario-frame" title="Scenario Preview" src="${escapedUrl}"></iframe> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| async function collectContentState(target) { | ||
| return target.evaluate(() => { | ||
| const root = document.getElementById("root"); | ||
| const imgs = Array.from(document.images || []); | ||
| const loadedImageCount = imgs.filter( | ||
| (img) => img.complete && img.naturalWidth > 0, | ||
| ).length; | ||
| const mediaSelectors = ["svg", "canvas", "video"]; | ||
| let mediaBboxCount = 0; | ||
| for (const selector of mediaSelectors) { | ||
| const nodes = document.querySelectorAll(selector); | ||
| for (const node of nodes) { | ||
| const rect = node.getBoundingClientRect(); | ||
| if (rect.width > 0 && rect.height > 0) { | ||
| mediaBboxCount += 1; | ||
| } | ||
| } | ||
| } | ||
| return { | ||
| bodyTextLength: document.body.innerText.trim().length, | ||
| rootChildCount: root ? root.childElementCount : 0, | ||
| rootTextLength: root ? (root.textContent || "").trim().length : 0, | ||
| imageCount: imgs.length, | ||
| loadedImageCount, | ||
| mediaBboxCount, | ||
| }; | ||
| }); | ||
| } | ||
| async function collectImageStates(target) { | ||
| return target.evaluate(() => | ||
| Array.from(document.images || []).map((img) => ({ | ||
| complete: img.complete, | ||
| naturalWidth: img.naturalWidth, | ||
| src: img.currentSrc || img.src || "", | ||
| })), | ||
| ); | ||
| } | ||
| async function waitForImagesSettled( | ||
| target, | ||
| { overallTimeoutMs = 5000, pollIntervalMs = 100 } = {}, | ||
| ) { | ||
| const started = Date.now(); | ||
| let images = await collectImageStates(target); | ||
| while ( | ||
| !shouldStopWaitingForImages(images, { | ||
| elapsedMs: Date.now() - started, | ||
| overallTimeoutMs, | ||
| }) | ||
| ) { | ||
| await new Promise((r) => setTimeout(r, pollIntervalMs)); | ||
| images = await collectImageStates(target); | ||
| } | ||
| const elapsedMs = Date.now() - started; | ||
| const allComplete = images.every((img) => img && img.complete === true); | ||
| return { settled: allComplete, images, elapsedMs }; | ||
| } | ||
| async function waitForAnimationsSettled( | ||
| target, | ||
| { timeoutMs = 2000, pollIntervalMs = 100 } = {}, | ||
| ) { | ||
| const started = Date.now(); | ||
| while (Date.now() - started < timeoutMs) { | ||
| const runningCount = await target.evaluate(() => | ||
| document | ||
| .getAnimations() | ||
| .filter((a) => a.playState === "running").length, | ||
| ); | ||
| if (runningCount === 0) { | ||
| return { settled: true, elapsedMs: Date.now() - started }; | ||
| } | ||
| await new Promise((r) => setTimeout(r, pollIntervalMs)); | ||
| } | ||
| return { settled: false, elapsedMs: Date.now() - started }; | ||
| } | ||
| async function waitForStablePage(page, target, timeoutMs = 10000) { | ||
| const started = Date.now(); | ||
| let lastHtml = ""; | ||
| let stableCount = 0; | ||
| while (Date.now() - started < timeoutMs) { | ||
| await page.waitForTimeout(500); | ||
| const pageState = await target.evaluate(() => ({ | ||
| bodyText: document.body.innerText, | ||
| html: document.body.innerHTML, | ||
| })); | ||
| if (!hasLoadingMarkers(pageState.bodyText) && pageState.html === lastHtml) { | ||
| stableCount += 1; | ||
| if (stableCount >= 2) { | ||
| const remaining = () => Math.max(0, timeoutMs - (Date.now() - started)); | ||
| await waitForAnimationsSettled(target, { | ||
| timeoutMs: Math.min(2000, remaining()), | ||
| }); | ||
| await waitForImagesSettled(target, { overallTimeoutMs: remaining() }); | ||
| return; | ||
| } | ||
| } else { | ||
| stableCount = 0; | ||
| } | ||
| lastHtml = pageState.html; | ||
| } | ||
| } | ||
| async function loadScenarioInIframe(page, url, { background } = {}) { | ||
| const responsePromise = page | ||
| .waitForResponse( | ||
| (response) => | ||
| response.request().resourceType() === "document" && | ||
| response.url() === url, | ||
| { timeout: 30000 }, | ||
| ) | ||
| .catch(() => null); | ||
| await page.setContent(buildIframeHarness(url, { background }), { | ||
| waitUntil: "domcontentloaded", | ||
| }); | ||
| const frameHandle = await page.waitForSelector("#scenario-frame", { | ||
| state: "attached", | ||
| timeout: 30000, | ||
| }); | ||
| const frame = await frameHandle.contentFrame(); | ||
| if (!frame) { | ||
| throw new Error("Scenario iframe did not attach"); | ||
| } | ||
| await frame.waitForLoadState("load", { timeout: 30000 }); | ||
| const response = await responsePromise; | ||
| return { frame, response }; | ||
| } | ||
| module.exports = { | ||
| escapeHtmlAttribute, | ||
| buildIframeHarness, | ||
| collectContentState, | ||
| collectImageStates, | ||
| waitForImagesSettled, | ||
| waitForAnimationsSettled, | ||
| waitForStablePage, | ||
| loadScenarioInIframe, | ||
| }; |
+11
-60
| #!/usr/bin/env node | ||
| /** | ||
| * Production launcher for the codeyam-editor. | ||
| * | ||
| * Run from a project directory after installing the npm package: | ||
| * cd ~/workspace/my-app | ||
| * codeyam-editor # start fresh | ||
| * codeyam-editor --restart # kill existing and restart | ||
| * | ||
| * The pre-built binary and UI are resolved from the package install location. | ||
| * process.cwd() is treated as the target project directory. | ||
| */ | ||
| const { spawn } = require("child_process"); | ||
| const { ensureBinary, uiDistDir } = require("./utils"); | ||
| const path = require("path"); | ||
| const fs = require("fs"); | ||
| const { ensureServer, restartServer, waitForPort, ensureBinary, uiDistDir } = require("./utils"); | ||
| const { open } = require("./open"); | ||
| const binary = ensureBinary(); | ||
| const SERVER_PORT = 14199; | ||
| const shouldRestart = process.argv.includes("--restart"); | ||
| const child = spawn(binary, process.argv.slice(2), { | ||
| stdio: "inherit", | ||
| cwd: process.cwd(), | ||
| env: { ...process.env, CODEYAM_EDITOR_UI_DIR: uiDistDir() }, | ||
| }); | ||
| /** Read the target project's .codeyam/editor.json to find the app port. */ | ||
| function readProjectPort() { | ||
| try { | ||
| const configPath = path.join(process.cwd(), ".codeyam", "editor.json"); | ||
| const config = JSON.parse(fs.readFileSync(configPath, "utf8")); | ||
| return config.port || 3000; | ||
| } catch { | ||
| return 3000; | ||
| } | ||
| } | ||
| async function main() { | ||
| const projectDir = process.cwd(); | ||
| console.error(`Project: ${projectDir}`); | ||
| // Start the Rust backend — binary from package, CWD = target project | ||
| const serverChild = shouldRestart | ||
| ? await restartServer(SERVER_PORT) | ||
| : await ensureServer(SERVER_PORT); | ||
| // Wait for the target project's dev server (port + 1 is the internal port) | ||
| const appPort = readProjectPort(); | ||
| const viteInternalPort = appPort + 1; | ||
| console.error(`Waiting for dev server on port ${viteInternalPort}...`); | ||
| const viteReady = await waitForPort(viteInternalPort, 15000, 300); | ||
| if (!viteReady) { | ||
| console.error(`Warning: Dev server not ready on port ${viteInternalPort}. Live Preview may not work initially.`); | ||
| } | ||
| const url = `http://localhost:${SERVER_PORT}`; | ||
| console.error(`Editor UI: ${url}`); | ||
| console.error(`Live Preview: http://localhost:${appPort}`); | ||
| open(url); | ||
| const keepAlive = setInterval(() => {}, 60_000); | ||
| process.on("SIGINT", () => { | ||
| clearInterval(keepAlive); | ||
| if (serverChild) serverChild.kill("SIGINT"); | ||
| process.exit(0); | ||
| }); | ||
| } | ||
| main(); | ||
| child.on("exit", (code) => { | ||
| process.exit(code ?? 1); | ||
| }); |
+7
-3
@@ -11,3 +11,3 @@ #!/usr/bin/env node | ||
| const { ensureServer, rootDir } = require("./utils"); | ||
| const { ensureServer, rootDir, killChildProcess } = require("./utils"); | ||
| const { open } = require("./open"); | ||
@@ -27,3 +27,3 @@ | ||
| clearInterval(keepAlive); | ||
| if (serverChild) serverChild.kill("SIGINT"); | ||
| killChildProcess(serverChild); | ||
| process.exit(0); | ||
@@ -33,2 +33,6 @@ }); | ||
| main(); | ||
| module.exports = { main }; | ||
| if (require.main === module) { | ||
| main(); | ||
| } |
+100
-62
| #!/usr/bin/env node | ||
| /** | ||
| * Development launcher for running the codeyam-editor against any project. | ||
| * Development CLI for running the codeyam-editor against any project. | ||
| * | ||
| * Run from the target project directory: | ||
| * cd ~/workspace/my-app | ||
| * codeyam-editor-dev # start fresh | ||
| * codeyam-editor-dev --restart # kill existing and restart | ||
| * Two modes: | ||
| * | ||
| * Uses the locally-built binary and UI from the editor repo (resolved via | ||
| * __dirname), but treats process.cwd() as the target project directory. | ||
| * Launcher — no subcommand (or --restart): builds the Rust binary and UI | ||
| * from the editor repo, starts the server, waits for readiness, opens browser. | ||
| * codeyam-editor-dev | ||
| * codeyam-editor-dev --restart | ||
| * | ||
| * Passthrough — any subcommand (start, init, editor, ...): rebuilds the | ||
| * Rust binary, then forwards args to it. | ||
| * codeyam-editor-dev editor step 1 | ||
| * codeyam-editor-dev editor advance | ||
| */ | ||
| const { execSync } = require("child_process"); | ||
| const { execSync, spawn } = require("child_process"); | ||
| const path = require("path"); | ||
| const fs = require("fs"); | ||
| const { ensureServer, restartServer, waitForPort, rootDir } = require("./utils"); | ||
| const { ensureServer, restartServer, waitForPort, ensureBinary, uiDistDir, rootDir, killChildProcess } = require("./utils"); | ||
| const { open } = require("./open"); | ||
| const SERVER_PORT = 14199; | ||
| const shouldRestart = process.argv.includes("--restart"); | ||
| // Tell the Rust binary to emit "codeyam-editor-dev" in all instructions, | ||
| // permissions, and installed files instead of "codeyam-editor". | ||
| process.env.CODEYAM_CLI = "codeyam-editor-dev"; | ||
| /** Read the target project's .codeyam/editor.json to find the app port. */ | ||
| function readProjectPort() { | ||
| try { | ||
| const configPath = path.join(process.cwd(), ".codeyam", "editor.json"); | ||
| const config = JSON.parse(fs.readFileSync(configPath, "utf8")); | ||
| return config.port || 3000; | ||
| } catch { | ||
| return 3000; | ||
| const args = process.argv.slice(2); | ||
| const KNOWN_SUBCOMMANDS = ["start", "init", "editor"]; | ||
| /** | ||
| * Check if the first non-flag argument is a known subcommand. | ||
| * Accepts an explicit argv array so the helper is testable without mutating | ||
| * process.argv (the module-level `args` const is the production default). | ||
| */ | ||
| function hasSubcommand(argv = args) { | ||
| for (const arg of argv) { | ||
| if (arg.startsWith("-")) continue; | ||
| return KNOWN_SUBCOMMANDS.includes(arg); | ||
| } | ||
| return false; | ||
| } | ||
| async function main() { | ||
| const projectDir = process.cwd(); | ||
| console.error(`Editor repo: ${rootDir}`); | ||
| console.error(`Target project: ${projectDir}`); | ||
| // Build the Rust binary from the editor repo | ||
| /** Build the Rust binary from the editor repo. */ | ||
| function buildBinary() { | ||
| console.error("Building Rust binary..."); | ||
| try { | ||
| execSync("cargo build", { | ||
| stdio: "inherit", | ||
| cwd: rootDir, | ||
| }); | ||
| execSync("cargo build", { stdio: "inherit", cwd: rootDir }); | ||
| } catch { | ||
| console.error("Failed to build Rust binary. Continuing with existing build (if any)..."); | ||
| } | ||
| } | ||
| // Build the UI from the editor repo | ||
| console.error("Building UI..."); | ||
| try { | ||
| execSync("npx vite build", { | ||
| module.exports = { hasSubcommand, buildBinary }; | ||
| if (require.main === module) { | ||
| // --- Passthrough mode: build binary, then forward to it --- | ||
| if (hasSubcommand()) { | ||
| buildBinary(); | ||
| const binary = ensureBinary(); | ||
| const child = spawn(binary, args, { | ||
| stdio: "inherit", | ||
| cwd: path.join(rootDir, "ui"), | ||
| cwd: process.cwd(), | ||
| env: { ...process.env, CODEYAM_EDITOR_UI_DIR: uiDistDir() }, | ||
| }); | ||
| } catch { | ||
| console.error("Failed to build UI. Continuing with existing build (if any)..."); | ||
| } | ||
| child.on("exit", (code) => { | ||
| process.exit(code ?? 1); | ||
| }); | ||
| } else { | ||
| // --- Launcher mode: build everything, start server, open browser --- | ||
| const SERVER_PORT = 14199; | ||
| const shouldRestart = args.includes("--restart"); | ||
| // Start the Rust backend — binary from editor repo, CWD = target project | ||
| const serverChild = shouldRestart | ||
| ? await restartServer(SERVER_PORT) | ||
| : await ensureServer(SERVER_PORT); | ||
| const readProjectPort = () => { | ||
| try { | ||
| const configPath = path.join(process.cwd(), ".codeyam", "editor.json"); | ||
| const config = JSON.parse(fs.readFileSync(configPath, "utf8")); | ||
| return config.port || 3000; | ||
| } catch { | ||
| return 3000; | ||
| } | ||
| }; | ||
| // Wait for the target project's Vite dev server (port + 1 is the internal port) | ||
| const appPort = readProjectPort(); | ||
| const viteInternalPort = appPort + 1; | ||
| console.error(`Waiting for dev server on port ${viteInternalPort}...`); | ||
| const viteReady = await waitForPort(viteInternalPort, 15000, 300); | ||
| if (!viteReady) { | ||
| console.error(`Warning: Dev server not ready on port ${viteInternalPort}. Live Preview may not work initially.`); | ||
| } | ||
| const launcherMain = async () => { | ||
| const projectDir = process.cwd(); | ||
| console.error(`Editor repo: ${rootDir}`); | ||
| console.error(`Target project: ${projectDir}`); | ||
| const url = `http://localhost:${SERVER_PORT}`; | ||
| console.error(`Editor UI: ${url}`); | ||
| console.error(`Live Preview: http://localhost:${appPort}`); | ||
| console.error(`Project: ${projectDir}`); | ||
| open(url); | ||
| const keepAlive = setInterval(() => {}, 60_000); | ||
| buildBinary(); | ||
| process.on("SIGINT", () => { | ||
| clearInterval(keepAlive); | ||
| if (serverChild) serverChild.kill("SIGINT"); | ||
| process.exit(0); | ||
| }); | ||
| // Build the UI from the editor repo | ||
| console.error("Building UI..."); | ||
| try { | ||
| execSync("npx vite build", { stdio: "inherit", cwd: path.join(rootDir, "ui") }); | ||
| } catch { | ||
| console.error("Failed to build UI. Continuing with existing build (if any)..."); | ||
| } | ||
| const serverChild = shouldRestart | ||
| ? await restartServer(SERVER_PORT) | ||
| : await ensureServer(SERVER_PORT); | ||
| const appPort = readProjectPort(); | ||
| const viteInternalPort = appPort + 1; | ||
| console.error(`Waiting for dev server on port ${viteInternalPort}...`); | ||
| const viteReady = await waitForPort(viteInternalPort, 15000, 300); | ||
| if (!viteReady) { | ||
| console.error(`Warning: Dev server not ready on port ${viteInternalPort}. Live Preview may not work initially.`); | ||
| } | ||
| const url = `http://localhost:${SERVER_PORT}`; | ||
| console.error(`Editor UI: ${url}`); | ||
| console.error(`Live Preview: http://localhost:${appPort}`); | ||
| console.error(`Project: ${projectDir}`); | ||
| open(url); | ||
| const keepAlive = setInterval(() => {}, 60_000); | ||
| process.on("SIGINT", () => { | ||
| clearInterval(keepAlive); | ||
| killChildProcess(serverChild); | ||
| process.exit(0); | ||
| }); | ||
| }; | ||
| launcherMain(); | ||
| } | ||
| } | ||
| main(); |
+133
-9
@@ -16,8 +16,10 @@ #!/usr/bin/env node | ||
| const { execSync } = require("child_process"); | ||
| const fs = require("fs"); | ||
| const os = require("os"); | ||
| const path = require("path"); | ||
| const { ensureServer, restartServer, waitForPort, rootDir } = require("./utils"); | ||
| const { ensureServer, stopServer, isPortInUse, waitForPort, rootDir, killChildProcess } = require("./utils"); | ||
| const { open } = require("./open"); | ||
| const SERVER_PORT = 14199; | ||
| const VITE_PORT = 5173; | ||
| const SERVER_PORT = parseInt(process.env.CODEYAM_EDITOR_PORT || "14199", 10); | ||
| const VITE_PORT = parseInt(process.env.PORT || "5173", 10); | ||
| // The reverse proxy sits on VITE_PORT; the actual Vite dev server runs on | ||
@@ -28,3 +30,108 @@ // VITE_PORT + 1. We need to wait for the real Vite server, not the proxy. | ||
| function buildBinary() { | ||
| console.error("Building Rust binary..."); | ||
| try { | ||
| execSync("cargo build", { stdio: "inherit", cwd: rootDir }); | ||
| return true; | ||
| } catch { | ||
| console.error("Failed to build Rust binary. Continuing with existing build (if any)..."); | ||
| return false; | ||
| } | ||
| } | ||
| // Resolve <CARGO_HOME>/bin (falling back to $HOME/.cargo/bin). Mirrors | ||
| // `installed_binary_path()` in crates/control-api/src/pty_broker_startup.rs | ||
| // — the Rust broker auto-spawn execs the binary at this path, so we MUST | ||
| // keep it in sync with the freshly-built debug binary or `npm run editor` | ||
| // will start a server whose PTY-broker auto-spawn execs a stale install. | ||
| function cargoBinDir() { | ||
| if (process.env.CARGO_HOME) return path.join(process.env.CARGO_HOME, "bin"); | ||
| return path.join(os.homedir(), ".cargo", "bin"); | ||
| } | ||
| // Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor | ||
| // atomically (write to tmp in same dir, then rename), and refresh the | ||
| // codeyam-editor-pty-broker hardlink so it points at the new inode. | ||
| // | ||
| // The PTY broker auto-spawn (control-api/src/pty_broker_startup.rs) always | ||
| // resolves the *installed* path, NOT current_exe() — that was a deliberate | ||
| // fix to prevent a fork-bomb where test binaries spawned themselves. So a | ||
| // fresh `target/debug/codeyam-editor` is not enough; the install path must | ||
| // be refreshed too. Without this, build tabs hang on "Reconnecting..." | ||
| // because the broker daemon errors out (`unrecognized subcommand`) before | ||
| // the UDS socket opens. | ||
| function installBinary() { | ||
| const srcPath = path.join(rootDir, "target", "debug", "codeyam-editor"); | ||
| if (!fs.existsSync(srcPath)) { | ||
| console.error(`Skipping install: ${srcPath} not found.`); | ||
| return; | ||
| } | ||
| const binDir = cargoBinDir(); | ||
| const dstPath = path.join(binDir, "codeyam-editor"); | ||
| const linkPath = path.join(binDir, "codeyam-editor-pty-broker"); | ||
| const tmpPath = path.join(binDir, `.codeyam-editor.tmp.${process.pid}`); | ||
| console.error(`Installing fresh binary to ${dstPath}...`); | ||
| try { | ||
| fs.mkdirSync(binDir, { recursive: true }); | ||
| fs.copyFileSync(srcPath, tmpPath); | ||
| fs.chmodSync(tmpPath, 0o755); | ||
| fs.renameSync(tmpPath, dstPath); | ||
| try { | ||
| fs.unlinkSync(linkPath); | ||
| } catch (e) { | ||
| if (e.code !== "ENOENT") throw e; | ||
| } | ||
| fs.linkSync(dstPath, linkPath); | ||
| } catch (err) { | ||
| console.error(`Failed to install binary: ${err.message}`); | ||
| try { fs.unlinkSync(tmpPath); } catch {} | ||
| } | ||
| } | ||
| // Evict Rust build artifacts not touched in the last day so target/ doesn't | ||
| // grow unboundedly. Must run after the old server is stopped (it holds files | ||
| // in target/debug/ open) and before the new server starts. Non-fatal. | ||
| function sweepStaleArtifacts() { | ||
| console.error("Sweeping stale Rust build artifacts (idle >1 day)..."); | ||
| try { | ||
| execSync("cargo sweep --time 1", { stdio: "inherit", cwd: rootDir }); | ||
| } catch { | ||
| console.error("cargo-sweep failed (install with `cargo install cargo-sweep`). Continuing."); | ||
| } | ||
| } | ||
| async function main() { | ||
| // Sync ports to editor.json so the Rust backend uses them | ||
| const editorJsonPath = path.join(rootDir, ".codeyam", "editor.json"); | ||
| if (fs.existsSync(editorJsonPath)) { | ||
| const editorJson = JSON.parse(fs.readFileSync(editorJsonPath, "utf8")); | ||
| editorJson.port = VITE_PORT; | ||
| editorJson.startCommand = `cd ui && npx vite --port ${VITE_PORT}`; | ||
| const uiApp = editorJson.apps?.find(a => a.dir === "ui"); | ||
| if (uiApp) { | ||
| uiApp.port = VITE_PORT; | ||
| uiApp.startCommand = `npx vite --port ${VITE_PORT}`; | ||
| } | ||
| if (editorJson.proxy) { | ||
| editorJson.proxy.controlPort = SERVER_PORT; | ||
| editorJson.proxy.httpPort = SERVER_PORT - 99; | ||
| editorJson.proxy.dbPort = SERVER_PORT - 98; | ||
| } | ||
| fs.writeFileSync(editorJsonPath, JSON.stringify(editorJson, null, 2)); | ||
| } | ||
| // Rebuild the Rust binary so code changes are picked up on restart. | ||
| // This script is only invoked from a source checkout (via `npm run editor`), | ||
| // so Cargo.toml and the crates tree are always present. | ||
| const buildOk = buildBinary(); | ||
| // Refresh <cargo_bin>/codeyam-editor (and the pty-broker hardlink) so | ||
| // the broker auto-spawn execs a binary that knows the `pty-broker | ||
| // daemon` subcommand. Skip if the build failed — installing a stale | ||
| // debug binary over a working install is worse than leaving it. | ||
| if (buildOk) { | ||
| installBinary(); | ||
| } | ||
| // Build the UI so the backend can serve it as static files | ||
@@ -42,6 +149,13 @@ console.error("Building UI..."); | ||
| // Start the Rust backend — it serves ui/dist/ and launches the Vite dev | ||
| // server (configured via startCommand in editor.json) for Live Preview HMR | ||
| const serverChild = shouldRestart | ||
| ? await restartServer(SERVER_PORT) | ||
| : await ensureServer(SERVER_PORT); | ||
| // server (configured via startCommand in editor.json) for Live Preview HMR. | ||
| // On --restart: stop the existing server first, sweep stale artifacts while | ||
| // target/ is idle (no process holding files open), then start fresh. | ||
| if (shouldRestart && (await isPortInUse(SERVER_PORT))) { | ||
| console.error(`Stopping existing server on port ${SERVER_PORT}...`); | ||
| await stopServer(SERVER_PORT); | ||
| } | ||
| if (shouldRestart) { | ||
| sweepStaleArtifacts(); | ||
| } | ||
| const serverChild = await ensureServer(SERVER_PORT); | ||
@@ -65,3 +179,3 @@ // Wait for the actual Vite dev server on the internal port (not the reverse | ||
| clearInterval(keepAlive); | ||
| if (serverChild) serverChild.kill("SIGINT"); | ||
| killChildProcess(serverChild); | ||
| process.exit(0); | ||
@@ -71,2 +185,12 @@ }); | ||
| main(); | ||
| module.exports = { | ||
| buildBinary, | ||
| cargoBinDir, | ||
| installBinary, | ||
| main, | ||
| sweepStaleArtifacts, | ||
| }; | ||
| if (require.main === module) { | ||
| main(); | ||
| } |
+48
-11
@@ -8,2 +8,7 @@ #!/usr/bin/env node | ||
| * Skipped when running in local development (cargo builds from source instead). | ||
| * | ||
| * Cross-platform: uses Node-native `fetch` + the `tar` npm package to download | ||
| * and extract. Avoids depending on `curl` / `tar` system binaries — those exist | ||
| * on macOS and Linux but not on a default Windows install, and the previous | ||
| * `curl | tar xz` shell pipe was the original Windows-install blocker. | ||
| */ | ||
@@ -13,3 +18,4 @@ | ||
| const path = require("path"); | ||
| const { execSync } = require("child_process"); | ||
| const { Readable } = require("stream"); | ||
| const tar = require("tar"); | ||
@@ -22,4 +28,11 @@ const rootDir = path.resolve(__dirname, ".."); | ||
| "linux-x64": "codeyam-editor-linux-x64", | ||
| "win32-x64": "codeyam-editor-win32-x64", | ||
| }; | ||
| function binaryName() { | ||
| return process.platform === "win32" | ||
| ? "codeyam-editor.exe" | ||
| : "codeyam-editor"; | ||
| } | ||
| function getArtifactName() { | ||
@@ -32,3 +45,6 @@ const key = `${process.platform}-${process.arch}`; | ||
| ); | ||
| console.error("Supported: macOS (arm64, x64), Linux (x64)"); | ||
| console.error("Supported: macOS (arm64, x64), Linux (x64), Windows (x64)"); | ||
| console.error( | ||
| "You can build from source instead: cargo build --release" | ||
| ); | ||
| process.exit(0); // Don't fail the install — user may be building from source | ||
@@ -54,5 +70,18 @@ } | ||
| function download() { | ||
| async function downloadAndExtract(url, dest) { | ||
| const res = await fetch(url); | ||
| if (!res.ok) { | ||
| throw new Error(`HTTP ${res.status} ${res.statusText}`); | ||
| } | ||
| await new Promise((resolve, reject) => { | ||
| Readable.fromWeb(res.body) | ||
| .pipe(tar.x({ cwd: dest })) | ||
| .on("finish", resolve) | ||
| .on("error", reject); | ||
| }); | ||
| } | ||
| async function download() { | ||
| // Skip if a binary already exists (e.g. local dev with cargo build) | ||
| const binPath = path.join(rootDir, "bin", "codeyam-editor"); | ||
| const binPath = path.join(rootDir, "bin", binaryName()); | ||
| if (fs.existsSync(binPath)) { | ||
@@ -79,9 +108,5 @@ return; | ||
| try { | ||
| // Download and extract in one step using curl + tar | ||
| execSync( | ||
| `curl -fsSL "${url}" | tar xz -C "${rootDir}"`, | ||
| { stdio: ["ignore", "inherit", "inherit"], timeout: 120000 } | ||
| ); | ||
| await downloadAndExtract(url, rootDir); | ||
| // Ensure binary is executable | ||
| // Ensure binary is executable. No-op on Windows but harmless. | ||
| if (fs.existsSync(binPath)) { | ||
@@ -94,2 +119,3 @@ fs.chmodSync(binPath, 0o755); | ||
| console.error(`Failed to download codeyam-editor binary from ${url}`); | ||
| console.error(` Reason: ${err.message}`); | ||
| console.error( | ||
@@ -102,2 +128,13 @@ "You can build from source instead: cargo build --release" | ||
| download(); | ||
| module.exports = { | ||
| PLATFORM_MAP, | ||
| binaryName, | ||
| getArtifactName, | ||
| getReleaseTag, | ||
| downloadAndExtract, | ||
| download, | ||
| }; | ||
| if (require.main === module) { | ||
| download(); | ||
| } |
+85
-304
| #!/usr/bin/env node | ||
| // Render environment (colorScheme, deviceScaleFactor, userAgent, locale, | ||
| // timezoneId, reduceMotion, forcedColors) is read from config when present | ||
| // and passed to browser.newContext(). This is what makes screenshots match | ||
| // the Live Preview iframe's host browser — see docs/rendering.md. | ||
| // | ||
| // iframeBackground is forwarded to buildIframeHarness so the capture paints | ||
| // the user's editor-shell background (or whatever the UI detected) behind | ||
| // the iframe instead of a hardcoded white. | ||
| const fs = require("fs"); | ||
@@ -7,224 +16,34 @@ const path = require("path"); | ||
| const LOADING_MARKERS = [ | ||
| "Loading scenario...", | ||
| "Loading tests...", | ||
| "Loading scenarios...", | ||
| "disconnected", | ||
| ]; | ||
| const { | ||
| findErrorPattern, | ||
| buildErrorContextSnippet, | ||
| hasRenderableContent, | ||
| describeBlankReason, | ||
| } = require("./scenario-metrics"); | ||
| function hasLoadingMarkers(text) { | ||
| return LOADING_MARKERS.some((marker) => text.includes(marker)); | ||
| } | ||
| const { | ||
| createIssue, | ||
| pushIssue, | ||
| buildResult, | ||
| } = require("./scenario-issues"); | ||
| function hasRenderableContent(state) { | ||
| return Boolean( | ||
| state && | ||
| (state.rootChildCount > 0 || | ||
| state.rootTextLength > 0 || | ||
| state.bodyTextLength > 0), | ||
| ); | ||
| } | ||
| const { | ||
| attachHttpMocks, | ||
| } = require("./scenario-mocks"); | ||
| const ERROR_PATTERNS = [ | ||
| "not found in registry", | ||
| "Component not found", | ||
| "Scenario Error", | ||
| ]; | ||
| const { | ||
| loadScenarioInIframe, | ||
| waitForStablePage, | ||
| collectContentState, | ||
| } = require("./scenario-playwright"); | ||
| function hasErrorPatterns(text) { | ||
| return ERROR_PATTERNS.some((pattern) => text.includes(pattern)); | ||
| } | ||
| const { | ||
| getInitScript, | ||
| handleConsoleMessage, | ||
| handlePageError, | ||
| handleRequestFailed, | ||
| } = require("./scenario-handlers"); | ||
| function createIssue(kind, message, extra = {}) { | ||
| return { | ||
| kind, | ||
| message, | ||
| url: extra.url ?? null, | ||
| status: extra.status ?? null, | ||
| }; | ||
| } | ||
| const BLANK_RETRY_DELAY_MS = 500; | ||
| function pushIssue(issues, issue) { | ||
| const key = JSON.stringify(issue); | ||
| if (!issues.some((existing) => JSON.stringify(existing) === key)) { | ||
| issues.push(issue); | ||
| } | ||
| } | ||
| function escapeHtmlAttribute(value) { | ||
| return String(value) | ||
| .replaceAll("&", "&") | ||
| .replaceAll('"', """); | ||
| } | ||
| function buildIframeHarness(url) { | ||
| const escapedUrl = escapeHtmlAttribute(url); | ||
| return `<!doctype html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8"> | ||
| <style> | ||
| html, body { | ||
| margin: 0; | ||
| width: 100%; | ||
| height: 100%; | ||
| overflow: hidden; | ||
| background: #fff; | ||
| } | ||
| iframe { | ||
| display: block; | ||
| width: 100%; | ||
| height: 100%; | ||
| border: 0; | ||
| background: #fff; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <iframe id="scenario-frame" title="Scenario Preview" src="${escapedUrl}"></iframe> | ||
| </body> | ||
| </html>`; | ||
| } | ||
| function buildResult({ loaded, hasContent, issues, outputPath, url }) { | ||
| return { | ||
| ok: loaded && hasContent && issues.length === 0, | ||
| loaded, | ||
| hasContent, | ||
| url, | ||
| outputPath: outputPath ?? null, | ||
| issues, | ||
| }; | ||
| } | ||
| function normalizeMockCandidates(url) { | ||
| try { | ||
| const parsed = new URL(url); | ||
| return [url, `${parsed.pathname}${parsed.search}`, parsed.pathname]; | ||
| } catch { | ||
| return [url]; | ||
| } | ||
| } | ||
| function findHttpMock(httpMocks, request) { | ||
| const method = request.method().toUpperCase(); | ||
| const candidates = normalizeMockCandidates(request.url()); | ||
| for (const candidate of candidates) { | ||
| const key = `${method} ${candidate}`; | ||
| if (httpMocks[key]) { | ||
| return httpMocks[key]; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| async function attachHttpMocks(page, httpMocks) { | ||
| if (!httpMocks || Object.keys(httpMocks).length === 0) return; | ||
| await page.route("**/*", async (route) => { | ||
| const mock = findHttpMock(httpMocks, route.request()); | ||
| if (!mock) { | ||
| await route.continue(); | ||
| return; | ||
| } | ||
| const headers = { ...(mock.headers || {}) }; | ||
| let body; | ||
| if (mock.body !== undefined) { | ||
| body = | ||
| typeof mock.body === "string" ? mock.body : JSON.stringify(mock.body); | ||
| const hasContentType = Object.keys(headers).some( | ||
| (key) => key.toLowerCase() === "content-type", | ||
| ); | ||
| if (!hasContentType) { | ||
| headers["content-type"] = "application/json"; | ||
| } | ||
| } | ||
| await route.fulfill({ | ||
| status: mock.status || 200, | ||
| headers, | ||
| body, | ||
| }); | ||
| }); | ||
| // Disable the in-page fetch mock by returning an empty active-mocks.json. | ||
| // The HTML injects a script that synchronously loads this file and | ||
| // monkey-patches window.fetch, which would bypass Playwright's route | ||
| // interception. This route is registered AFTER **/* so it takes priority | ||
| // (Playwright uses LIFO ordering for route handlers). | ||
| await page.route("**/active-mocks.json", async (route) => { | ||
| await route.fulfill({ | ||
| status: 200, | ||
| headers: { "content-type": "application/json" }, | ||
| body: "[]", | ||
| }); | ||
| }); | ||
| } | ||
| async function collectContentState(target) { | ||
| return target.evaluate(() => { | ||
| const root = document.getElementById("root"); | ||
| return { | ||
| bodyTextLength: document.body.innerText.trim().length, | ||
| rootChildCount: root ? root.childElementCount : 0, | ||
| rootTextLength: root ? (root.textContent || "").trim().length : 0, | ||
| }; | ||
| }); | ||
| } | ||
| async function waitForStablePage(page, target, timeoutMs = 10000) { | ||
| const started = Date.now(); | ||
| let lastHtml = ""; | ||
| let stableCount = 0; | ||
| while (Date.now() - started < timeoutMs) { | ||
| await page.waitForTimeout(500); | ||
| const pageState = await target.evaluate(() => ({ | ||
| bodyText: document.body.innerText, | ||
| html: document.body.innerHTML, | ||
| })); | ||
| if ( | ||
| !hasLoadingMarkers(pageState.bodyText) && | ||
| pageState.html === lastHtml | ||
| ) { | ||
| stableCount += 1; | ||
| if (stableCount >= 2) return; | ||
| } else { | ||
| stableCount = 0; | ||
| } | ||
| lastHtml = pageState.html; | ||
| } | ||
| } | ||
| async function loadScenarioInIframe(page, url) { | ||
| const responsePromise = page | ||
| .waitForResponse( | ||
| (response) => | ||
| response.request().resourceType() === "document" && | ||
| response.url() === url, | ||
| { timeout: 30000 }, | ||
| ) | ||
| .catch(() => null); | ||
| await page.setContent(buildIframeHarness(url), { waitUntil: "domcontentloaded" }); | ||
| const frameHandle = await page.waitForSelector("#scenario-frame", { | ||
| state: "attached", | ||
| timeout: 30000, | ||
| }); | ||
| const frame = await frameHandle.contentFrame(); | ||
| if (!frame) { | ||
| throw new Error("Scenario iframe did not attach"); | ||
| } | ||
| await frame.waitForLoadState("load", { timeout: 30000 }); | ||
| const response = await responsePromise; | ||
| return { frame, response }; | ||
| } | ||
| async function runScenarioCheck(config) { | ||
@@ -234,44 +53,18 @@ const { url, outputPath, width, height, httpMocks = {} } = config; | ||
| const browser = await chromium.launch(); | ||
| const context = await browser.newContext({ | ||
| const contextOptions = { | ||
| viewport: { width: width || 1440, height: height || 900 }, | ||
| }); | ||
| }; | ||
| if (config.colorScheme) contextOptions.colorScheme = config.colorScheme; | ||
| if (config.deviceScaleFactor) | ||
| contextOptions.deviceScaleFactor = config.deviceScaleFactor; | ||
| if (config.userAgent) contextOptions.userAgent = config.userAgent; | ||
| if (config.locale) contextOptions.locale = config.locale; | ||
| if (config.timezoneId) contextOptions.timezoneId = config.timezoneId; | ||
| if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion; | ||
| if (config.forcedColors) contextOptions.forcedColors = config.forcedColors; | ||
| const context = await browser.newContext(contextOptions); | ||
| // Context-level init script runs in ALL frames (including cross-origin iframes) | ||
| await context.addInitScript(() => { | ||
| window.__codeyamUnhandledRejections = []; | ||
| window.addEventListener("unhandledrejection", (event) => { | ||
| const reason = event.reason; | ||
| const message = | ||
| reason instanceof Error ? reason.message : String(reason); | ||
| window.__codeyamUnhandledRejections.push(message); | ||
| }); | ||
| await context.addInitScript(getInitScript()); | ||
| // Stub WebSocket during capture to prevent terminal reconnection spam. | ||
| // Returns an object that behaves like a closed WebSocket — the terminal | ||
| // will attempt to reconnect a few times then give up silently. | ||
| window.WebSocket = class StubWebSocket { | ||
| static CONNECTING = 0; | ||
| static OPEN = 1; | ||
| static CLOSING = 2; | ||
| static CLOSED = 3; | ||
| readyState = 3; | ||
| onopen = null; | ||
| onclose = null; | ||
| onerror = null; | ||
| onmessage = null; | ||
| send() {} | ||
| close() {} | ||
| addEventListener() {} | ||
| removeEventListener() {} | ||
| dispatchEvent() { return false; } | ||
| constructor() { | ||
| // Fire onerror then onclose on next tick so the terminal sees a failed connection | ||
| setTimeout(() => { | ||
| if (this.onerror) this.onerror(new Event("error")); | ||
| if (this.onclose) this.onclose(new CloseEvent("close")); | ||
| }, 0); | ||
| } | ||
| }; | ||
| }); | ||
| const page = await context.newPage(); | ||
@@ -281,37 +74,14 @@ await attachHttpMocks(page, httpMocks); | ||
| page.on("pageerror", (error) => { | ||
| pushIssue( | ||
| issues, | ||
| createIssue("pageerror", error.message || String(error), { | ||
| url: page.url() || url, | ||
| }), | ||
| ); | ||
| pushIssue(issues, handlePageError(error)); | ||
| }); | ||
| page.on("console", (message) => { | ||
| if (message.type() !== "error") return; | ||
| const text = message.text(); | ||
| // Ignore known dev-server WebSocket/HMR errors from Vite proxy | ||
| if ( | ||
| text.includes("WebSocket connection to") || | ||
| text.includes("Unsupported Media Type") | ||
| ) { | ||
| return; | ||
| const issue = handleConsoleMessage(message); | ||
| if (issue) { | ||
| pushIssue(issues, issue); | ||
| } | ||
| pushIssue( | ||
| issues, | ||
| createIssue("console", text, { | ||
| url: page.url() || url, | ||
| }), | ||
| ); | ||
| }); | ||
| page.on("requestfailed", (request) => { | ||
| pushIssue( | ||
| issues, | ||
| createIssue( | ||
| "requestfailed", | ||
| request.failure()?.errorText || "Request failed", | ||
| { url: request.url() }, | ||
| ), | ||
| ); | ||
| pushIssue(issues, handleRequestFailed(request)); | ||
| }); | ||
@@ -322,3 +92,5 @@ | ||
| try { | ||
| const { frame, response } = await loadScenarioInIframe(page, url); | ||
| const { frame, response } = await loadScenarioInIframe(page, url, { | ||
| background: config.iframeBackground, | ||
| }); | ||
| loaded = true; | ||
@@ -350,4 +122,14 @@ | ||
| const contentState = await collectContentState(frame); | ||
| const hasContent = hasRenderableContent(contentState); | ||
| // Cold-start retry: a single re-collect after a short pause covers | ||
| // the React.lazy / Suspense-fallback race that flagged 4-of-59 | ||
| // scenarios as blank against a cold Vite dev server on 2026-04-30. | ||
| // One retry is enough — the Suspense window is sub-second in | ||
| // practice, longer waits just slow down genuine blank-render bugs. | ||
| let contentState = await collectContentState(frame); | ||
| let hasContent = hasRenderableContent(contentState); | ||
| if (!hasContent) { | ||
| await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS)); | ||
| contentState = await collectContentState(frame); | ||
| hasContent = hasRenderableContent(contentState); | ||
| } | ||
@@ -357,5 +139,7 @@ if (!hasContent) { | ||
| issues, | ||
| createIssue("blank", "Page rendered no visible content", { | ||
| url: page.url() || url, | ||
| }), | ||
| createIssue( | ||
| "blank", | ||
| `Page rendered no visible content (${describeBlankReason(contentState)})`, | ||
| { url: page.url() || url }, | ||
| ), | ||
| ); | ||
@@ -366,8 +150,16 @@ } | ||
| const bodyText = await frame.evaluate(() => document.body.innerText || ""); | ||
| if (hasErrorPatterns(bodyText)) { | ||
| const matchedPattern = findErrorPattern(bodyText); | ||
| if (matchedPattern) { | ||
| const contextSnippet = buildErrorContextSnippet(bodyText, matchedPattern); | ||
| pushIssue( | ||
| issues, | ||
| createIssue("error-state", `Page contains error content: ${bodyText.slice(0, 200)}`, { | ||
| url: page.url() || url, | ||
| }), | ||
| createIssue( | ||
| "error-state", | ||
| `Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? bodyText.slice(0, 200)}`, | ||
| { | ||
| url: page.url() || url, | ||
| matchedPattern, | ||
| contextSnippet, | ||
| }, | ||
| ), | ||
| ); | ||
@@ -421,15 +213,4 @@ } | ||
| module.exports = { | ||
| attachHttpMocks, | ||
| buildResult, | ||
| buildIframeHarness, | ||
| createIssue, | ||
| findHttpMock, | ||
| hasErrorPatterns, | ||
| hasLoadingMarkers, | ||
| hasRenderableContent, | ||
| loadScenarioInIframe, | ||
| normalizeMockCandidates, | ||
| pushIssue, | ||
| runScenarioCheck, | ||
| waitForStablePage, | ||
| main, | ||
| }; | ||
@@ -436,0 +217,0 @@ |
+44
-4
@@ -9,8 +9,18 @@ const { execSync, spawn, spawnSync } = require("child_process"); | ||
| /** | ||
| * Platform-correct executable filename for the codeyam-editor binary. | ||
| * Windows requires the `.exe` extension; on Unix the binary has no extension. | ||
| * Accepts an explicit platform so the helper is testable across platforms. | ||
| */ | ||
| function binaryName(platform = process.platform) { | ||
| return platform === "win32" ? "codeyam-editor.exe" : "codeyam-editor"; | ||
| } | ||
| /** Find the compiled binary — check npm-installed location first, then dev builds. */ | ||
| function findBinary() { | ||
| const name = binaryName(); | ||
| const candidates = [ | ||
| path.join(rootDir, "bin", "codeyam-editor"), // npm install (postinstall) | ||
| path.join(rootDir, "target", "debug", "codeyam-editor"), // local dev (debug) | ||
| path.join(rootDir, "target", "release", "codeyam-editor"), // local dev (release) | ||
| path.join(rootDir, "bin", name), // npm install (postinstall) | ||
| path.join(rootDir, "target", "debug", name), // local dev (debug) | ||
| path.join(rootDir, "target", "release", name), // local dev (release) | ||
| ]; | ||
@@ -140,3 +150,10 @@ | ||
| try { | ||
| process.kill(pid, "SIGINT"); | ||
| // On Windows, `process.kill(pid, "SIGINT")` either errors or no-ops | ||
| // depending on Node version. Drop the signal so Node maps to a | ||
| // platform TerminateProcess. Less graceful, but the child does exit. | ||
| if (process.platform === "win32") { | ||
| process.kill(pid); | ||
| } else { | ||
| process.kill(pid, "SIGINT"); | ||
| } | ||
| return true; | ||
@@ -148,3 +165,20 @@ } catch { | ||
| /** | ||
| * Send the platform-correct termination signal to a spawned child process. | ||
| * Used by editor.js / editor-dev.js / container.js when the user hits Ctrl+C — | ||
| * `child.kill("SIGINT")` is unreliable on Windows. | ||
| */ | ||
| function killChildProcess(child) { | ||
| if (!child) return; | ||
| if (process.platform === "win32") { | ||
| child.kill(); | ||
| } else { | ||
| child.kill("SIGINT"); | ||
| } | ||
| } | ||
| function findListeningPids(port) { | ||
| // Process discovery on Windows is best-effort — `lsof` is not installed and | ||
| // a `netstat -ano` parser is a follow-up. The API-shutdown path and the | ||
| // PID-file path together stop the server cleanly in normal operation. | ||
| if (process.platform === "win32") return []; | ||
@@ -235,2 +269,3 @@ | ||
| serverStatePath, | ||
| binaryName, | ||
| findBinary, | ||
@@ -244,5 +279,10 @@ ensureBinary, | ||
| readServerState, | ||
| clearServerState, | ||
| requestServerShutdown, | ||
| tryKillPid, | ||
| findListeningPids, | ||
| stopServer, | ||
| restartServer, | ||
| ensureServer, | ||
| killChildProcess, | ||
| }; |
+3
-2
| { | ||
| "name": "@codeyam-editor/codeyam-editor", | ||
| "version": "0.1.0-staging.398fa2d", | ||
| "version": "0.1.0-staging.3db0f53", | ||
| "description": "Language-agnostic managed execution sandbox for scenario-driven development", | ||
@@ -12,3 +12,4 @@ "bin": { | ||
| "dependencies": { | ||
| "playwright": "^1.58.2" | ||
| "playwright": "^1.58.2", | ||
| "tar": "^7.5.13" | ||
| }, | ||
@@ -15,0 +16,0 @@ "keywords": [ |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 4 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
44121
50.88%14
55.56%1299
44.49%2
100%16
60%9
28.57%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added