@codeyam-editor/codeyam-editor 0.1.0-staging.c9412f0 → 0.1.0-staging.f8d201b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/npm/container.js CHANGED
@@ -8,12 +8,26 @@ const path = require("path");
8
8
  * inline monitoring UI at http://localhost:14199.
9
9
  */
10
10
 
11
- const { ensureServer, rootDir } = require("./utils");
11
+ const {
12
+ ensureServer,
13
+ rootDir,
14
+ killChildProcess,
15
+ readMergedEditorConfig,
16
+ resolveLauncherPort,
17
+ } = require("./utils");
12
18
  const { open } = require("./open");
13
19
 
14
- const PORT = 14199;
15
-
16
20
  async function main() {
21
+ // Same resolution order as the editor launcher: env var
22
+ // (CODEYAM_EDITOR_PORT) → merged editor.json + editor.local.json
23
+ // (proxy.controlPort) → hard fallback (14199).
24
+ const merged = readMergedEditorConfig(rootDir);
25
+ const PORT = resolveLauncherPort(
26
+ process.env.CODEYAM_EDITOR_PORT,
27
+ merged.proxy && merged.proxy.controlPort,
28
+ 14199,
29
+ );
30
+
17
31
  const serverChild = await ensureServer(PORT);
18
32
  const url = `http://localhost:${PORT}`;
19
33
  console.error(`Container UI: ${url}`);
@@ -23,9 +37,13 @@ async function main() {
23
37
 
24
38
  process.on("SIGINT", () => {
25
39
  clearInterval(keepAlive);
26
- if (serverChild) serverChild.kill("SIGINT");
40
+ killChildProcess(serverChild);
27
41
  process.exit(0);
28
42
  });
29
43
  }
30
44
 
31
- main();
45
+ module.exports = { main };
46
+
47
+ if (require.main === module) {
48
+ main();
49
+ }
package/npm/editor-dev.js CHANGED
@@ -18,8 +18,17 @@
18
18
 
19
19
  const { execSync, spawn } = require("child_process");
20
20
  const path = require("path");
21
- const fs = require("fs");
22
- const { ensureServer, restartServer, waitForPort, ensureBinary, uiDistDir, rootDir } = require("./utils");
21
+ const {
22
+ ensureServer,
23
+ restartServer,
24
+ waitForPort,
25
+ ensureBinary,
26
+ uiDistDir,
27
+ rootDir,
28
+ killChildProcess,
29
+ readMergedEditorConfig,
30
+ resolveLauncherPort,
31
+ } = require("./utils");
23
32
  const { open } = require("./open");
24
33
 
25
34
  // Tell the Rust binary to emit "codeyam-editor-dev" in all instructions,
@@ -29,9 +38,13 @@ process.env.CODEYAM_CLI = "codeyam-editor-dev";
29
38
  const args = process.argv.slice(2);
30
39
  const KNOWN_SUBCOMMANDS = ["start", "init", "editor"];
31
40
 
32
- /** Check if the first non-flag argument is a known subcommand. */
33
- function hasSubcommand() {
34
- for (const arg of args) {
41
+ /**
42
+ * Check if the first non-flag argument is a known subcommand.
43
+ * Accepts an explicit argv array so the helper is testable without mutating
44
+ * process.argv (the module-level `args` const is the production default).
45
+ */
46
+ function hasSubcommand(argv = args) {
47
+ for (const arg of argv) {
35
48
  if (arg.startsWith("-")) continue;
36
49
  return KNOWN_SUBCOMMANDS.includes(arg);
37
50
  }
@@ -48,73 +61,83 @@ function buildBinary() {
48
61
  }
49
62
  }
50
63
 
51
- // --- Passthrough mode: build binary, then forward to it ---
52
- if (hasSubcommand()) {
53
- buildBinary();
54
- const binary = ensureBinary();
55
- const child = spawn(binary, args, {
56
- stdio: "inherit",
57
- cwd: process.cwd(),
58
- env: { ...process.env, CODEYAM_EDITOR_UI_DIR: uiDistDir() },
59
- });
60
- child.on("exit", (code) => {
61
- process.exit(code ?? 1);
62
- });
63
- } else {
64
- // --- Launcher mode: build everything, start server, open browser ---
65
- const SERVER_PORT = 14199;
66
- const shouldRestart = args.includes("--restart");
67
-
68
- function readProjectPort() {
69
- try {
70
- const configPath = path.join(process.cwd(), ".codeyam", "editor.json");
71
- const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
72
- return config.port || 3000;
73
- } catch {
74
- return 3000;
75
- }
76
- }
77
-
78
- async function main() {
79
- const projectDir = process.cwd();
80
- console.error(`Editor repo: ${rootDir}`);
81
- console.error(`Target project: ${projectDir}`);
64
+ module.exports = { hasSubcommand, buildBinary };
82
65
 
66
+ if (require.main === module) {
67
+ // --- Passthrough mode: build binary, then forward to it ---
68
+ if (hasSubcommand()) {
83
69
  buildBinary();
84
-
85
- // Build the UI from the editor repo
86
- console.error("Building UI...");
87
- try {
88
- execSync("npx vite build", { stdio: "inherit", cwd: path.join(rootDir, "ui") });
89
- } catch {
90
- console.error("Failed to build UI. Continuing with existing build (if any)...");
91
- }
92
-
93
- const serverChild = shouldRestart
94
- ? await restartServer(SERVER_PORT)
95
- : await ensureServer(SERVER_PORT);
96
-
97
- const appPort = readProjectPort();
98
- const viteInternalPort = appPort + 1;
99
- console.error(`Waiting for dev server on port ${viteInternalPort}...`);
100
- const viteReady = await waitForPort(viteInternalPort, 15000, 300);
101
- if (!viteReady) {
102
- console.error(`Warning: Dev server not ready on port ${viteInternalPort}. Live Preview may not work initially.`);
103
- }
104
-
105
- const url = `http://localhost:${SERVER_PORT}`;
106
- console.error(`Editor UI: ${url}`);
107
- console.error(`Live Preview: http://localhost:${appPort}`);
108
- console.error(`Project: ${projectDir}`);
109
- open(url);
110
- const keepAlive = setInterval(() => {}, 60_000);
111
-
112
- process.on("SIGINT", () => {
113
- clearInterval(keepAlive);
114
- if (serverChild) serverChild.kill("SIGINT");
115
- process.exit(0);
70
+ const binary = ensureBinary();
71
+ const child = spawn(binary, args, {
72
+ stdio: "inherit",
73
+ cwd: process.cwd(),
74
+ env: { ...process.env, CODEYAM_EDITOR_UI_DIR: uiDistDir() },
75
+ });
76
+ child.on("exit", (code) => {
77
+ process.exit(code ?? 1);
116
78
  });
79
+ } else {
80
+ // --- Launcher mode: build everything, start server, open browser ---
81
+ const shouldRestart = args.includes("--restart");
82
+
83
+ const launcherMain = async () => {
84
+ const projectDir = process.cwd();
85
+ console.error(`Editor repo: ${rootDir}`);
86
+ console.error(`Target project: ${projectDir}`);
87
+
88
+ // Editor server's control port is owned by the editor repo's
89
+ // merged config (rootDir), not the target project's. The target
90
+ // project's own `editor.local.json` only contributes to the
91
+ // app/dev-server port below.
92
+ const editorMerged = readMergedEditorConfig(rootDir);
93
+ const SERVER_PORT = resolveLauncherPort(
94
+ process.env.CODEYAM_EDITOR_PORT,
95
+ editorMerged.proxy && editorMerged.proxy.controlPort,
96
+ 14199,
97
+ );
98
+
99
+ // Target project's dev-server port comes from its own
100
+ // editor.json + editor.local.json (developers iterating on the
101
+ // editor against a real client project rely on the override
102
+ // file here too).
103
+ const projectMerged = readMergedEditorConfig(projectDir);
104
+ const appPort = resolveLauncherPort(undefined, projectMerged.port, 3000);
105
+
106
+ buildBinary();
107
+
108
+ // Build the UI from the editor repo
109
+ console.error("Building UI...");
110
+ try {
111
+ execSync("npx vite build", { stdio: "inherit", cwd: path.join(rootDir, "ui") });
112
+ } catch {
113
+ console.error("Failed to build UI. Continuing with existing build (if any)...");
114
+ }
115
+
116
+ const serverChild = shouldRestart
117
+ ? await restartServer(SERVER_PORT)
118
+ : await ensureServer(SERVER_PORT);
119
+
120
+ const viteInternalPort = appPort + 1;
121
+ console.error(`Waiting for dev server on port ${viteInternalPort}...`);
122
+ const viteReady = await waitForPort(viteInternalPort, 15000, 300);
123
+ if (!viteReady) {
124
+ console.error(`Warning: Dev server not ready on port ${viteInternalPort}. Live Preview may not work initially.`);
125
+ }
126
+
127
+ const url = `http://localhost:${SERVER_PORT}`;
128
+ console.error(`Editor UI: ${url}`);
129
+ console.error(`Live Preview: http://localhost:${appPort}`);
130
+ console.error(`Project: ${projectDir}`);
131
+ open(url);
132
+ const keepAlive = setInterval(() => {}, 60_000);
133
+
134
+ process.on("SIGINT", () => {
135
+ clearInterval(keepAlive);
136
+ killChildProcess(serverChild);
137
+ process.exit(0);
138
+ });
139
+ };
140
+
141
+ launcherMain();
117
142
  }
118
-
119
- main();
120
143
  }
package/npm/editor.js CHANGED
@@ -13,31 +13,125 @@
13
13
  */
14
14
 
15
15
  const { execSync } = require("child_process");
16
+ const fs = require("fs");
17
+ const os = require("os");
16
18
  const path = require("path");
17
- const { ensureServer, restartServer, waitForPort, rootDir } = require("./utils");
19
+ const {
20
+ ensureServer,
21
+ stopServer,
22
+ isPortInUse,
23
+ waitForPort,
24
+ rootDir,
25
+ killChildProcess,
26
+ readMergedEditorConfig,
27
+ resolveLauncherPort,
28
+ } = require("./utils");
18
29
  const { open } = require("./open");
19
30
 
20
- const SERVER_PORT = 14199;
21
- const VITE_PORT = 5173;
22
- // The reverse proxy sits on VITE_PORT; the actual Vite dev server runs on
23
- // VITE_PORT + 1. We need to wait for the real Vite server, not the proxy.
24
- const VITE_INTERNAL_PORT = VITE_PORT + 1;
25
31
  const shouldRestart = process.argv.includes("--restart");
26
32
 
27
33
  function buildBinary() {
28
34
  console.error("Building Rust binary...");
29
35
  try {
30
36
  execSync("cargo build", { stdio: "inherit", cwd: rootDir });
37
+ return true;
31
38
  } catch {
32
39
  console.error("Failed to build Rust binary. Continuing with existing build (if any)...");
40
+ return false;
41
+ }
42
+ }
43
+
44
+ // Resolve <CARGO_HOME>/bin (falling back to $HOME/.cargo/bin). Mirrors
45
+ // `installed_binary_path()` in crates/control-api/src/pty_broker_startup.rs
46
+ // — the Rust broker auto-spawn execs the binary at this path, so we MUST
47
+ // keep it in sync with the freshly-built debug binary or `npm run editor`
48
+ // will start a server whose PTY-broker auto-spawn execs a stale install.
49
+ function cargoBinDir() {
50
+ if (process.env.CARGO_HOME) return path.join(process.env.CARGO_HOME, "bin");
51
+ return path.join(os.homedir(), ".cargo", "bin");
52
+ }
53
+
54
+ // Install target/debug/codeyam-editor to <cargo_bin>/codeyam-editor
55
+ // atomically (write to tmp in same dir, then rename), and refresh the
56
+ // codeyam-editor-pty-broker hardlink so it points at the new inode.
57
+ //
58
+ // The PTY broker auto-spawn (control-api/src/pty_broker_startup.rs) always
59
+ // resolves the *installed* path, NOT current_exe() — that was a deliberate
60
+ // fix to prevent a fork-bomb where test binaries spawned themselves. So a
61
+ // fresh `target/debug/codeyam-editor` is not enough; the install path must
62
+ // be refreshed too. Without this, build tabs hang on "Reconnecting..."
63
+ // because the broker daemon errors out (`unrecognized subcommand`) before
64
+ // the UDS socket opens.
65
+ function installBinary() {
66
+ const srcPath = path.join(rootDir, "target", "debug", "codeyam-editor");
67
+ if (!fs.existsSync(srcPath)) {
68
+ console.error(`Skipping install: ${srcPath} not found.`);
69
+ return;
70
+ }
71
+ const binDir = cargoBinDir();
72
+ const dstPath = path.join(binDir, "codeyam-editor");
73
+ const linkPath = path.join(binDir, "codeyam-editor-pty-broker");
74
+ const tmpPath = path.join(binDir, `.codeyam-editor.tmp.${process.pid}`);
75
+ console.error(`Installing fresh binary to ${dstPath}...`);
76
+ try {
77
+ fs.mkdirSync(binDir, { recursive: true });
78
+ fs.copyFileSync(srcPath, tmpPath);
79
+ fs.chmodSync(tmpPath, 0o755);
80
+ fs.renameSync(tmpPath, dstPath);
81
+ try {
82
+ fs.unlinkSync(linkPath);
83
+ } catch (e) {
84
+ if (e.code !== "ENOENT") throw e;
85
+ }
86
+ fs.linkSync(dstPath, linkPath);
87
+ } catch (err) {
88
+ console.error(`Failed to install binary: ${err.message}`);
89
+ try { fs.unlinkSync(tmpPath); } catch {}
90
+ }
91
+ }
92
+
93
+ // Evict Rust build artifacts not touched in the last day so target/ doesn't
94
+ // grow unboundedly. Must run after the old server is stopped (it holds files
95
+ // in target/debug/ open) and before the new server starts. Non-fatal.
96
+ function sweepStaleArtifacts() {
97
+ console.error("Sweeping stale Rust build artifacts (idle >1 day)...");
98
+ try {
99
+ execSync("cargo sweep --time 1", { stdio: "inherit", cwd: rootDir });
100
+ } catch {
101
+ console.error("cargo-sweep failed (install with `cargo install cargo-sweep`). Continuing.");
33
102
  }
34
103
  }
35
104
 
36
105
  async function main() {
106
+ // Read ports from the merged editor config (editor.json + per-developer
107
+ // editor.local.json). The Rust backend reads the same merged config on
108
+ // its own — we resolve here so the launcher's log lines, `waitForPort`
109
+ // calls, and `--port` arg agree with what the binary will actually
110
+ // bind. Env vars still win for scripted/CI invocations
111
+ // (CODEYAM_EDITOR_PORT, PORT), then the merged config, then a hard
112
+ // fallback for fresh checkouts with no override file.
113
+ const merged = readMergedEditorConfig(rootDir);
114
+ const SERVER_PORT = resolveLauncherPort(
115
+ process.env.CODEYAM_EDITOR_PORT,
116
+ merged.proxy && merged.proxy.controlPort,
117
+ 14199,
118
+ );
119
+ const VITE_PORT = resolveLauncherPort(process.env.PORT, merged.port, 5173);
120
+ // The reverse proxy sits on VITE_PORT; the actual Vite dev server runs on
121
+ // VITE_PORT + 1. We need to wait for the real Vite server, not the proxy.
122
+ const VITE_INTERNAL_PORT = VITE_PORT + 1;
123
+
37
124
  // Rebuild the Rust binary so code changes are picked up on restart.
38
125
  // This script is only invoked from a source checkout (via `npm run editor`),
39
126
  // so Cargo.toml and the crates tree are always present.
40
- buildBinary();
127
+ const buildOk = buildBinary();
128
+ // Refresh <cargo_bin>/codeyam-editor (and the pty-broker hardlink) so
129
+ // the broker auto-spawn execs a binary that knows the `pty-broker
130
+ // daemon` subcommand. Skip if the build failed — installing a stale
131
+ // debug binary over a working install is worse than leaving it.
132
+ if (buildOk) {
133
+ installBinary();
134
+ }
41
135
 
42
136
  // Build the UI so the backend can serve it as static files
43
137
  console.error("Building UI...");
@@ -51,10 +145,17 @@ async function main() {
51
145
  }
52
146
 
53
147
  // Start the Rust backend — it serves ui/dist/ and launches the Vite dev
54
- // server (configured via startCommand in editor.json) for Live Preview HMR
55
- const serverChild = shouldRestart
56
- ? await restartServer(SERVER_PORT)
57
- : await ensureServer(SERVER_PORT);
148
+ // server (configured via startCommand in editor.json) for Live Preview HMR.
149
+ // On --restart: stop the existing server first, sweep stale artifacts while
150
+ // target/ is idle (no process holding files open), then start fresh.
151
+ if (shouldRestart && (await isPortInUse(SERVER_PORT))) {
152
+ console.error(`Stopping existing server on port ${SERVER_PORT}...`);
153
+ await stopServer(SERVER_PORT);
154
+ }
155
+ if (shouldRestart) {
156
+ sweepStaleArtifacts();
157
+ }
158
+ const serverChild = await ensureServer(SERVER_PORT);
58
159
 
59
160
  // Wait for the actual Vite dev server on the internal port (not the reverse
60
161
  // proxy on VITE_PORT, which starts immediately with the Rust binary).
@@ -73,9 +174,19 @@ async function main() {
73
174
 
74
175
  process.on("SIGINT", () => {
75
176
  clearInterval(keepAlive);
76
- if (serverChild) serverChild.kill("SIGINT");
177
+ killChildProcess(serverChild);
77
178
  process.exit(0);
78
179
  });
79
180
  }
80
181
 
81
- main();
182
+ module.exports = {
183
+ buildBinary,
184
+ cargoBinDir,
185
+ installBinary,
186
+ main,
187
+ sweepStaleArtifacts,
188
+ };
189
+
190
+ if (require.main === module) {
191
+ main();
192
+ }
@@ -43,6 +43,8 @@ const {
43
43
  handleRequestFailed,
44
44
  } = require("./scenario-handlers");
45
45
 
46
+ const BLANK_RETRY_DELAY_MS = 500;
47
+
46
48
  async function runScenarioCheck(config) {
47
49
  const { url, outputPath, width, height, httpMocks = {} } = config;
48
50
  const issues = [];
@@ -78,7 +80,10 @@ async function runScenarioCheck(config) {
78
80
  });
79
81
 
80
82
  page.on("requestfailed", (request) => {
81
- pushIssue(issues, handleRequestFailed(request));
83
+ const issue = handleRequestFailed(request);
84
+ if (issue) {
85
+ pushIssue(issues, issue);
86
+ }
82
87
  });
83
88
 
84
89
  let loaded = false;
@@ -113,8 +118,18 @@ async function runScenarioCheck(config) {
113
118
  );
114
119
  }
115
120
 
116
- const contentState = await collectContentState(frame);
117
- const hasContent = hasRenderableContent(contentState);
121
+ // Cold-start retry: a single re-collect after a short pause covers
122
+ // the React.lazy / Suspense-fallback race that flagged 4-of-59
123
+ // scenarios as blank against a cold Vite dev server on 2026-04-30.
124
+ // One retry is enough — the Suspense window is sub-second in
125
+ // practice, longer waits just slow down genuine blank-render bugs.
126
+ let contentState = await collectContentState(frame);
127
+ let hasContent = hasRenderableContent(contentState);
128
+ if (!hasContent) {
129
+ await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS));
130
+ contentState = await collectContentState(frame);
131
+ hasContent = hasRenderableContent(contentState);
132
+ }
118
133
 
119
134
  if (!hasContent) {
120
135
  pushIssue(
@@ -192,6 +207,7 @@ async function main() {
192
207
 
193
208
  module.exports = {
194
209
  runScenarioCheck,
210
+ main,
195
211
  };
196
212
 
197
213
  if (require.main === module) {
@@ -56,11 +56,22 @@ function handlePageError(error) {
56
56
  }
57
57
 
58
58
  function handleRequestFailed(request) {
59
- return createIssue(
60
- "requestfailed",
61
- request.failure()?.errorText || "Request failed",
62
- { url: request.url() }
63
- );
59
+ const errorText = request.failure()?.errorText || "Request failed";
60
+
61
+ // Filter benign request cancellations. `net::ERR_ABORTED` is what Playwright
62
+ // emits when a request is in-flight at the moment the page is closed (or the
63
+ // iframe is destroyed). For scenarios whose pages fetch large payloads (the
64
+ // editor's own EditorShell mounts a 2.2MB `/api/tests` fetch), the
65
+ // browser.close() at the end of capture races the fetch and produces this
66
+ // event AFTER the screenshot has already been taken — there is no real
67
+ // failure to surface. Genuine network failures arrive under different
68
+ // codes (net::ERR_CONNECTION_REFUSED, net::ERR_NAME_NOT_RESOLVED, etc.)
69
+ // and continue to be reported.
70
+ if (errorText.includes("net::ERR_ABORTED")) {
71
+ return null;
72
+ }
73
+
74
+ return createIssue("requestfailed", errorText, { url: request.url() });
64
75
  }
65
76
 
66
77
  module.exports = {
@@ -181,6 +181,7 @@ async function loadScenarioInIframe(page, url, { background } = {}) {
181
181
  }
182
182
 
183
183
  module.exports = {
184
+ escapeHtmlAttribute,
184
185
  buildIframeHarness,
185
186
  collectContentState,
186
187
  collectImageStates,