Sign In

@codeyam-editor/codeyam-editor

Package Overview
Dependencies
Maintainers
1
Versions
42
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@codeyam-editor/codeyam-editor - npm Package Compare versions

Comparing version
0.1.0-staging.398fa2d
to
0.1.0-staging.3db0f53
+71
npm/scenario-handlers.js
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("&", "&amp;").replaceAll('"', "&quot;");
}
// 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);
});

@@ -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();
}
#!/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();

@@ -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();
}

@@ -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();
}
#!/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("&", "&amp;")
.replaceAll('"', "&quot;");
}
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 @@

@@ -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,
};
{
"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": [