|
| 1 | +// Desktop-only: proves the *telemetry* contract around a dying sidecar — the |
| 2 | +// distinction the on-screen crash flow can't show, because a clean shutdown and |
| 3 | +// a hard kill paint the SAME recovery screen. The only observable difference is |
| 4 | +// what reaches Sentry, so this launches the real Electron app pointed at a local |
| 5 | +// Sentry envelope sink (via the non-packaged EXECUTOR_DESKTOP_SENTRY_DSN seam) |
| 6 | +// and asserts: |
| 7 | +// - SIGKILL (hard kill) → a "Sidecar exited unexpectedly" crash IS reported |
| 8 | +// - SIGINT (clean stop) → the recovery screen shows but NOTHING is reported |
| 9 | +// |
| 10 | +// The negative is made conclusive with a fence: a second SIGKILL after the |
| 11 | +// SIGINT. Sentry's transport is FIFO per client, so once the fence's crash |
| 12 | +// envelope has arrived, any envelope the SIGINT would have produced (enqueued |
| 13 | +// earlier) must already be present too — its absence is real, not just slow. |
| 14 | +import { execFile } from "node:child_process"; |
| 15 | +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; |
| 16 | +import { createServer, type Server } from "node:http"; |
| 17 | +import { createRequire } from "node:module"; |
| 18 | +import type { AddressInfo } from "node:net"; |
| 19 | +import { tmpdir } from "node:os"; |
| 20 | +import { join } from "node:path"; |
| 21 | +import { fileURLToPath } from "node:url"; |
| 22 | +import { promisify } from "node:util"; |
| 23 | +import { gunzipSync } from "node:zlib"; |
| 24 | + |
| 25 | +import { expect } from "@effect/vitest"; |
| 26 | +import { Effect } from "effect"; |
| 27 | +import { _electron } from "playwright"; |
| 28 | + |
| 29 | +import { scenario } from "../src/scenario"; |
| 30 | +import { RunDir } from "../src/services"; |
| 31 | + |
| 32 | +const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); |
| 33 | +const electronBinary = createRequire(join(appDir, "package.json"))("electron") as string; |
| 34 | + |
| 35 | +const SIDECAR_CRASH_MESSAGE = "Sidecar exited unexpectedly"; |
| 36 | + |
| 37 | +scenario( |
| 38 | + "Desktop · a clean sidecar shutdown recovers without a crash report", |
| 39 | + { timeout: 300_000 }, |
| 40 | + Effect.gen(function* () { |
| 41 | + const runDir = yield* RunDir; |
| 42 | + yield* Effect.promise(() => run(runDir)); |
| 43 | + }), |
| 44 | +); |
| 45 | + |
| 46 | +/** Minimal Sentry ingest: every POSTed envelope body (gunzipped) is buffered. */ |
| 47 | +const startSentrySink = async (): Promise<{ |
| 48 | + server: Server; |
| 49 | + dsn: string; |
| 50 | + envelopes: string[]; |
| 51 | +}> => { |
| 52 | + const envelopes: string[] = []; |
| 53 | + const server = createServer((req, res) => { |
| 54 | + if (req.method !== "POST") { |
| 55 | + res.writeHead(200); |
| 56 | + res.end("ok"); |
| 57 | + return; |
| 58 | + } |
| 59 | + const chunks: Buffer[] = []; |
| 60 | + req.on("data", (chunk: Buffer) => chunks.push(chunk)); |
| 61 | + req.on("end", () => { |
| 62 | + let body = Buffer.concat(chunks); |
| 63 | + if (req.headers["content-encoding"] === "gzip") { |
| 64 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: tolerate a non-gzip body rather than dropping the envelope |
| 65 | + try { |
| 66 | + body = gunzipSync(body); |
| 67 | + } catch { |
| 68 | + // fall through with the raw bytes |
| 69 | + } |
| 70 | + } |
| 71 | + envelopes.push(body.toString("utf8")); |
| 72 | + res.writeHead(200, { "content-type": "application/json" }); |
| 73 | + res.end(JSON.stringify({ id: "e2e" })); |
| 74 | + }); |
| 75 | + }); |
| 76 | + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve())); |
| 77 | + const { port } = server.address() as AddressInfo; |
| 78 | + // DSN shape: http://<publicKey>@<host>/<projectId> — the SDK POSTs envelopes |
| 79 | + // to http://<host>/api/<projectId>/envelope/, which the sink accepts wholesale. |
| 80 | + return { server, dsn: `http://e2e@127.0.0.1:${port}/1`, envelopes }; |
| 81 | +}; |
| 82 | + |
| 83 | +const run = async (runDir: string) => { |
| 84 | + const { server: sink, dsn, envelopes } = await startSentrySink(); |
| 85 | + const home = mkdtempSync(join(tmpdir(), "executor-desktop-sentry-e2e-")); |
| 86 | + const videoTmp = join(runDir, ".video-tmp"); |
| 87 | + let stepIndex = 0; |
| 88 | + |
| 89 | + const crashReports = () => envelopes.filter((e) => e.includes(SIDECAR_CRASH_MESSAGE)); |
| 90 | + const sigkillReports = () => |
| 91 | + crashReports().filter((e) => e.includes(`${SIDECAR_CRASH_MESSAGE} (code=null signal=SIGKILL)`)); |
| 92 | + |
| 93 | + const waitFor = async (predicate: () => boolean, label: string, timeoutMs = 60_000) => { |
| 94 | + const start = Date.now(); |
| 95 | + while (!predicate()) { |
| 96 | + if (Date.now() - start > timeoutMs) { |
| 97 | + throw new Error(`timed out waiting for ${label}`); |
| 98 | + } |
| 99 | + await new Promise((resolve) => setTimeout(resolve, 200)); |
| 100 | + } |
| 101 | + }; |
| 102 | + |
| 103 | + const app = await _electron.launch({ |
| 104 | + executablePath: electronBinary, |
| 105 | + args: [appDir], |
| 106 | + cwd: appDir, |
| 107 | + // EXECUTOR_DESKTOP_SENTRY_DSN turns on main-process crash reporting in the |
| 108 | + // non-packaged build and routes it at the sink. HOME isolates a fresh data |
| 109 | + // dir from any real install on this machine. |
| 110 | + env: { ...process.env, HOME: home, EXECUTOR_DESKTOP_SENTRY_DSN: dsn }, |
| 111 | + recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, |
| 112 | + timeout: 120_000, |
| 113 | + }); |
| 114 | + |
| 115 | + const manifestPath = join(home, ".executor/server-control/server.json"); |
| 116 | + const sidecarPid = (): number => { |
| 117 | + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { pid: number }; |
| 118 | + expect(manifest.pid, "sidecar pid recorded in the server manifest").toBeGreaterThan(0); |
| 119 | + return manifest.pid; |
| 120 | + }; |
| 121 | + |
| 122 | + try { |
| 123 | + const page = await app.firstWindow({ timeout: 120_000 }); |
| 124 | + const step = async (label: string, body: () => Promise<void>) => { |
| 125 | + await body(); |
| 126 | + stepIndex += 1; |
| 127 | + const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, "-"); |
| 128 | + await page.screenshot({ |
| 129 | + path: join(runDir, `${String(stepIndex).padStart(2, "0")}-${slug}.png`), |
| 130 | + }); |
| 131 | + }; |
| 132 | + |
| 133 | + await step("app boots into the web console", async () => { |
| 134 | + await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); |
| 135 | + }); |
| 136 | + |
| 137 | + // SIGKILL #1 — the report path. Establishes that the sink works and a hard |
| 138 | + // kill IS reported, which also calibrates that envelopes arrive in time. |
| 139 | + await step("a hard kill (SIGKILL) is reported as a crash", async () => { |
| 140 | + process.kill(sidecarPid(), "SIGKILL"); |
| 141 | + await page.getByText("stopped unexpectedly").waitFor({ timeout: 30_000 }); |
| 142 | + await waitFor(() => sigkillReports().length >= 1, "the first SIGKILL crash report"); |
| 143 | + }); |
| 144 | + |
| 145 | + await step("restart heals the app", async () => { |
| 146 | + await page.locator("#restart").click(); |
| 147 | + await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); |
| 148 | + }); |
| 149 | + |
| 150 | + // SIGINT — the clean-shutdown path. The dev sidecar handles SIGINT and exits |
| 151 | + // 0; the app surfaces the SAME recovery screen, but this must NOT be reported. |
| 152 | + await step("a clean stop (SIGINT) shows recovery but is not reported", async () => { |
| 153 | + process.kill(sidecarPid(), "SIGINT"); |
| 154 | + await page.getByText("stopped unexpectedly").waitFor({ timeout: 30_000 }); |
| 155 | + }); |
| 156 | + |
| 157 | + await step("restart heals the app again", async () => { |
| 158 | + await page.locator("#restart").click(); |
| 159 | + await page.getByText("Settings").first().waitFor({ timeout: 120_000 }); |
| 160 | + }); |
| 161 | + |
| 162 | + // SIGKILL #2 — the fence. Once its crash envelope has landed, FIFO ordering |
| 163 | + // guarantees any envelope the SIGINT would have produced is already here. |
| 164 | + await step("fence: a second hard kill is reported", async () => { |
| 165 | + process.kill(sidecarPid(), "SIGKILL"); |
| 166 | + await page.getByText("stopped unexpectedly").waitFor({ timeout: 30_000 }); |
| 167 | + await waitFor(() => sigkillReports().length >= 2, "the fence SIGKILL crash report"); |
| 168 | + }); |
| 169 | + |
| 170 | + // The verdict: every sidecar-exit crash reported was a SIGKILL — the SIGINT |
| 171 | + // in between contributed nothing. |
| 172 | + const reports = crashReports(); |
| 173 | + expect( |
| 174 | + reports.length, |
| 175 | + `only the two SIGKILLs were reported, got: ${JSON.stringify(reports.map(firstLine))}`, |
| 176 | + ).toBe(2); |
| 177 | + expect( |
| 178 | + reports.every((e) => e.includes("signal=SIGKILL")), |
| 179 | + "no clean SIGINT shutdown (code=0 / signal=null) was reported as a crash", |
| 180 | + ).toBe(true); |
| 181 | + } finally { |
| 182 | + const page = app.windows()[0]; |
| 183 | + const video = page?.video(); |
| 184 | + await app.close().catch(() => {}); |
| 185 | + await new Promise<void>((resolve) => sink.close(() => resolve())); |
| 186 | + const recordedPath = await video?.path().catch(() => undefined); |
| 187 | + if (recordedPath && existsSync(recordedPath)) { |
| 188 | + await promisify(execFile)("ffmpeg", [ |
| 189 | + "-y", |
| 190 | + "-i", |
| 191 | + recordedPath, |
| 192 | + "-c:v", |
| 193 | + "libx264", |
| 194 | + "-preset", |
| 195 | + "veryfast", |
| 196 | + "-crf", |
| 197 | + "26", |
| 198 | + "-pix_fmt", |
| 199 | + "yuv420p", |
| 200 | + "-movflags", |
| 201 | + "+faststart", |
| 202 | + join(runDir, "session.mp4"), |
| 203 | + ]).catch(() => {}); |
| 204 | + } |
| 205 | + rmSync(videoTmp, { recursive: true, force: true }); |
| 206 | + rmSync(home, { recursive: true, force: true }); |
| 207 | + } |
| 208 | +}; |
| 209 | + |
| 210 | +const firstLine = (envelope: string): string => { |
| 211 | + const match = envelope.match(new RegExp(`${SIDECAR_CRASH_MESSAGE}[^"\\\\]*`)); |
| 212 | + return match ? match[0] : envelope.slice(0, 80); |
| 213 | +}; |
0 commit comments