diff --git a/.agents/session-log.md b/.agents/session-log.md index e5c23bb..ac54d13 100644 --- a/.agents/session-log.md +++ b/.agents/session-log.md @@ -385,4 +385,5 @@ - 2026-04-08: Updated `.agents/workflow.md` so chat ownership is now a constant repo rule. The workflow file now defines stable Engine versus Desktop/UI ownership, cross-boundary stop rules, branch and dirty-tree preflight checks, and a requirement to record the validation actually run. - 2026-04-08: Started issue #214 renderer tab binding registry hardening. This slice is limited to `desktop/renderer/app.js` plus `.agents` continuity, and replaces the long `bindTabContentEvents` conditional chain with a local handler registry without changing renderer behavior. - 2026-04-08: Completed issue #214 renderer tab binding registry hardening. `bindTabContentEvents` now dispatches through a local `TAB_CONTENT_EVENT_BINDERS` registry in `desktop/renderer/app.js`, and `npm run smoke` still passes via local runs fallback. +- 2026-04-10: Started issue #346 to harden `desktop-smoke` result persistence after CI repeatedly failed with raw `ENOENT` on missing `result.json` in a planning-only PR. The slice is limited to `desktop/main.js`, `desktop/scripts/smoke.js`, and `.agents` continuity so smoke emits structured failures instead of crashing when Electron exits too early. - 2026-04-10: Added the desktop layout regression remediation block after reviewing the post-merge desktop state in real screenshots. Opened issues #342, #343, and #344 to target empty-pane collapse, stronger active-surface focus and context containment, and better runs-family density plus right-rail space budgeting without reopening core or `research_ui` scope. diff --git a/.agents/tasks/issue-346-desktop-smoke-result-persistence.md b/.agents/tasks/issue-346-desktop-smoke-result-persistence.md new file mode 100644 index 0000000..14dca6a --- /dev/null +++ b/.agents/tasks/issue-346-desktop-smoke-result-persistence.md @@ -0,0 +1,44 @@ +# Issue #346 — Desktop smoke result persistence in CI + +## Goal +Prevent `desktop-smoke` from failing with a raw missing-file error before the smoke harness can read or emit a structured result. + +--- + +## Why this matters +Planning-only Desktop/UI PRs are being blocked by a smoke harness failure that occurs before `result.json` exists. The harness needs a reliable failure path even when Electron exits before the normal smoke callback completes. + +--- + +## Scope + +### In scope +- smoke result persistence +- early-exit and renderer-failure handling for smoke runs +- minimal harness hardening in `desktop/scripts/smoke.js` + +### Out of scope +- renderer UI changes +- `research_ui` changes +- core, broker, CLI, or CI workflow edits + +--- + +## Relevant files + +- `desktop/main.js` +- `desktop/scripts/smoke.js` + +--- + +## Expected deliverable + +A smoke harness that always produces a structured result or a clear structured failure message instead of crashing with `ENOENT` on missing `result.json`. + +--- + +## Done when + +- CI no longer fails with raw `ENOENT` for missing smoke output +- smoke runs persist a result even when Electron exits too early +- local smoke validation still passes for normal fallback and real-path cases diff --git a/desktop/main.js b/desktop/main.js index 3836ad7..aae0c6a 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -28,6 +28,8 @@ const ELECTRON_STATE_ROOT = path.join(DESKTOP_OUTPUTS_ROOT, "electron"); const IS_SMOKE_RUN = process.env.QUANTLAB_DESKTOP_SMOKE === "1"; const SMOKE_OUTPUT_PATH = process.env.QUANTLAB_DESKTOP_SMOKE_OUTPUT || ""; const SKIP_RESEARCH_UI_BOOT = process.env.QUANTLAB_DESKTOP_DISABLE_SERVER_BOOT === "1"; +let smokeResultPersisted = false; +let smokeDidFinishLoadSeen = false; app.setPath("userData", path.join(ELECTRON_STATE_ROOT, "user-data")); app.setPath("cache", path.join(ELECTRON_STATE_ROOT, "cache")); @@ -830,8 +832,8 @@ function createMainWindow() { }); } -async function runDesktopSmoke() { - const result = { +function defaultSmokeResult(overrides = {}) { + return { bridgeReady: false, shellReady: false, serverReady: false, @@ -839,7 +841,34 @@ async function runDesktopSmoke() { localRunsReady: false, serverUrl: "", error: "", + ...overrides, }; +} + +async function persistSmokeResult(resultOverrides = {}) { + if (!SMOKE_OUTPUT_PATH || smokeResultPersisted) return; + const result = defaultSmokeResult(resultOverrides); + await fsp.mkdir(path.dirname(SMOKE_OUTPUT_PATH), { recursive: true }); + await fsp.writeFile(SMOKE_OUTPUT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + smokeResultPersisted = true; +} + +async function failSmokeAndQuit(errorMessage, overrides = {}) { + try { + await persistSmokeResult({ + error: errorMessage, + ...overrides, + }); + } catch (_error) { + // Best effort persistence; the outer smoke harness also handles missing files. + } finally { + process.exitCode = 1; + app.quit(); + } +} + +async function runDesktopSmoke() { + const result = defaultSmokeResult(); try { result.bridgeReady = await mainWindow.webContents.executeJavaScript( "Boolean(window.quantlabDesktop && typeof window.quantlabDesktop.getWorkspaceState === 'function')", @@ -871,10 +900,7 @@ async function runDesktopSmoke() { result.error = error.message; } - if (SMOKE_OUTPUT_PATH) { - await fsp.mkdir(path.dirname(SMOKE_OUTPUT_PATH), { recursive: true }); - await fsp.writeFile(SMOKE_OUTPUT_PATH, `${JSON.stringify(result, null, 2)}\n`, "utf8"); - } + await persistSmokeResult(result); if (!result.bridgeReady || !result.shellReady) { process.exitCode = 1; @@ -993,19 +1019,16 @@ if (singleInstanceLock) { createMainWindow(); if (!SKIP_RESEARCH_UI_BOOT) startResearchUiServer(); if (IS_SMOKE_RUN) { + mainWindow.webContents.once("did-fail-load", (_event, errorCode, errorDescription) => { + failSmokeAndQuit(`Desktop smoke load failed: ${errorDescription || `code ${errorCode}`}`); + }); + mainWindow.webContents.once("render-process-gone", (_event, details) => { + failSmokeAndQuit(`Desktop smoke renderer exited before completion: ${details?.reason || "unknown"}`); + }); mainWindow.webContents.once("did-finish-load", () => { + smokeDidFinishLoadSeen = true; runDesktopSmoke().catch((error) => { - if (SMOKE_OUTPUT_PATH) { - fsp.mkdir(path.dirname(SMOKE_OUTPUT_PATH), { recursive: true }) - .then(() => fsp.writeFile(SMOKE_OUTPUT_PATH, `${JSON.stringify({ bridgeReady: false, shellReady: false, serverReady: false, apiReady: false, localRunsReady: false, error: error.message }, null, 2)}\n`, "utf8")) - .finally(() => { - process.exitCode = 1; - app.quit(); - }); - } else { - process.exitCode = 1; - app.quit(); - } + failSmokeAndQuit(error.message); }); }); } @@ -1019,6 +1042,14 @@ if (singleInstanceLock) { } app.on("window-all-closed", () => { + if (IS_SMOKE_RUN && !smokeResultPersisted) { + failSmokeAndQuit( + smokeDidFinishLoadSeen + ? "Desktop smoke window closed before the result was persisted." + : "Desktop smoke window closed before the renderer finished loading.", + ); + return; + } if (process.platform !== "darwin") { app.quit(); } @@ -1027,3 +1058,14 @@ app.on("window-all-closed", () => { app.on("before-quit", () => { stopResearchUiServer(); }); + +process.on("uncaughtException", (error) => { + if (!IS_SMOKE_RUN) throw error; + failSmokeAndQuit(`Desktop smoke uncaught exception: ${error?.message || String(error)}`); +}); + +process.on("unhandledRejection", (reason) => { + if (!IS_SMOKE_RUN) throw reason; + const message = reason instanceof Error ? reason.message : String(reason); + failSmokeAndQuit(`Desktop smoke unhandled rejection: ${message}`); +}); diff --git a/desktop/scripts/smoke.js b/desktop/scripts/smoke.js index 79614f6..a058163 100644 --- a/desktop/scripts/smoke.js +++ b/desktop/scripts/smoke.js @@ -7,6 +7,7 @@ async function main() { const desktopRoot = path.resolve(__dirname, ".."); const projectRoot = path.resolve(desktopRoot, ".."); const electronBinary = require("electron"); + const electronArgs = process.platform === "linux" ? ["--no-sandbox", "."] : ["."]; const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "quantlab-desktop-smoke-")); const outputPath = path.join(tempRoot, "result.json"); const desktopOutputsRoot = path.join(tempRoot, "outputs"); @@ -75,7 +76,7 @@ async function main() { } try { - const child = spawn(electronBinary, ["."], { + const child = spawn(electronBinary, electronArgs, { cwd: desktopRoot, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], @@ -105,9 +106,24 @@ async function main() { child.on("exit", (code) => resolve(code ?? 1)); }); clearTimeout(timeout); - - const raw = await fs.readFile(outputPath, "utf8"); - const result = JSON.parse(raw); + let result = null; + for (let attempt = 0; attempt < 10; attempt += 1) { + try { + const raw = await fs.readFile(outputPath, "utf8"); + result = JSON.parse(raw); + break; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + if (!result) { + throw new Error( + `Desktop smoke did not persist result.json before Electron exited (code ${exitCode}).` + + `${stdout.trim() ? ` stdout: ${stdout.trim()}` : ""}` + + `${stderr.trim() ? ` stderr: ${stderr.trim()}` : ""}`, + ); + } if (exitCode !== 0 || !result.bridgeReady || !result.shellReady) { if (stdout.trim()) console.error(stdout.trim());