From 8f2582bec13e913e8cfae11afd4a3df63a99ac60 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 24 Jul 2026 21:30:37 +0300 Subject: [PATCH] test(release): prove packaged desktop startup --- .github/workflows/release.yml | 10 + apps/desktop/src/main.ts | 10 + docs/release.md | 3 + scripts/release-smoke.ts | 44 ++ .../verify-packaged-desktop-startup.test.ts | 321 ++++++++++ scripts/verify-packaged-desktop-startup.ts | 579 ++++++++++++++++++ 6 files changed, 967 insertions(+) create mode 100644 scripts/verify-packaged-desktop-startup.test.ts create mode 100644 scripts/verify-packaged-desktop-startup.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 686b4c1a..f6d2f9c1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -380,6 +380,16 @@ jobs: fi fi + - name: Smoke exact packaged desktop startup + if: ${{ matrix.platform != 'linux' }} + shell: bash + run: | + node scripts/verify-packaged-desktop-startup.ts \ + --assets-dir release-publish \ + --platform "${{ matrix.platform }}" \ + --arch "${{ matrix.arch }}" \ + --version "${{ needs.preflight.outputs.version }}" + - name: Upload build artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ff680d47..94278e1b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3514,9 +3514,19 @@ function createWindow(): BrowserWindow { window.setTitle(APP_DISPLAY_NAME); }); window.webContents.on("did-finish-load", () => { + writeDesktopLogHeader("renderer main frame loaded"); window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); }); + window.webContents.on( + "did-fail-load", + (_event, errorCode, errorDescription, _validatedUrl, isMainFrame) => { + if (!isMainFrame) return; + writeDesktopLogHeader( + `renderer main frame load failed code=${errorCode} message=${errorDescription}`, + ); + }, + ); window.once("ready-to-show", () => { // Preserve the original first-launch behavior, then respect the state saved // by subsequent closes. Normal bounds are restored before maximizing so the diff --git a/docs/release.md b/docs/release.md index 3cfa04d9..f447b265 100644 --- a/docs/release.md +++ b/docs/release.md @@ -15,6 +15,9 @@ This document covers build-only native validation, promotion through the protect - macOS `x64` DMG - Linux `x64` AppImage - Windows `x64` NSIS installer +- Before upload, launches the exact collected macOS and Windows payloads from + isolated Scient state and requires stable app readiness, backend readiness, + and successful renderer main-frame loading. - Publishes one versioned GitHub Release with all produced files. - Versions with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases. - Stable 0.5.x releases are GitHub Latest; the 0.4.x compatibility release remains historical. diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index af70e70b..ac6c2311 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -288,6 +288,50 @@ function verifyReleaseWorkflowSafety(): void { ) { throw new Error("Expected the release-note gate to verify the exact resolved version."); } + const collectAssetsIndex = buildSteps.findIndex((step) => step.name === "Collect release assets"); + const packagedStartupIndex = buildSteps.findIndex( + (step) => step.name === "Smoke exact packaged desktop startup", + ); + const uploadArtifactsIndex = buildSteps.findIndex( + (step) => step.name === "Upload build artifacts", + ); + const packagedStartupStep = buildSteps[packagedStartupIndex]; + if ( + collectAssetsIndex < 0 || + packagedStartupIndex <= collectAssetsIndex || + uploadArtifactsIndex <= packagedStartupIndex || + packagedStartupStep?.if !== "${{ matrix.platform != 'linux' }}" || + !packagedStartupStep.run?.includes("node scripts/verify-packaged-desktop-startup.ts") || + !packagedStartupStep.run.includes("--assets-dir release-publish") + ) { + throw new Error( + "Expected exact macOS and Windows packaged-startup proof after collection and before upload.", + ); + } + const packagedStartupVerifier = readFileSync( + resolve(repoRoot, "scripts/verify-packaged-desktop-startup.ts"), + "utf8", + ).replaceAll("\r\n", "\n"); + assertContains( + packagedStartupVerifier, + "SCIENT_HOME: scientHome", + "Expected packaged startup verification to isolate Scient state.", + ); + assertContains( + packagedStartupVerifier, + "PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST", + "Expected packaged startup verification to inherit only explicit host variables.", + ); + assertContains( + packagedStartupVerifier, + 'log.includes("renderer main frame loaded")', + "Expected packaged startup proof to require successful renderer loading.", + ); + assertNotContains( + packagedStartupVerifier, + '"linux" | "mac" | "win"', + "The extracted startup verifier must not absorb deferred Linux packaging policy.", + ); const appleSigningNames = [ "CSC_LINK", "CSC_KEY_PASSWORD", diff --git a/scripts/verify-packaged-desktop-startup.test.ts b/scripts/verify-packaged-desktop-startup.test.ts new file mode 100644 index 00000000..e6aea2fd --- /dev/null +++ b/scripts/verify-packaged-desktop-startup.test.ts @@ -0,0 +1,321 @@ +import type { ChildProcess } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + assertPackagedLaunchCommandSafety, + createPackagedDesktopSmokeEnvironment, + expectedPackagedDesktopStartupAssetName, + hasPackagedStartupProof, + isScientWindowsExecutable, + parsePackagedDesktopStartupArgs, + resolveExactPackagedDesktopStartupAsset, + resolveNativePackagedDesktopPlatform, + resolvePackagedDesktopLogPath, + sanitizePackagedDesktopInheritedEnvironment, + terminateProcessTree, + waitForPackagedStartupProof, +} from "./verify-packaged-desktop-startup.ts"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("packaged desktop startup verification", () => { + it("parses a bounded native payload request", () => { + expect( + parsePackagedDesktopStartupArgs([ + "--assets-dir", + "./release-publish", + "--platform", + "mac", + "--arch", + "x64", + "--version", + "1.2.3", + ]), + ).toEqual({ + assetsDirectory: expect.stringMatching(/release-publish$/), + platform: "mac", + arch: "x64", + version: "1.2.3", + timeoutMs: 60_000, + }); + + expect(() => + parsePackagedDesktopStartupArgs([ + "--assets-dir", + "./release-publish", + "--platform", + "mac", + "--arch", + "x64", + "--version", + "1.2.3", + "--timeout-ms", + "4999", + ]), + ).toThrow("--timeout-ms must be an integer between 5000 and 180000"); + }); + + it("isolates Scient state and removes inherited runtime authority", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-env-test-")); + temporaryRoots.push(root); + + const env = createPackagedDesktopSmokeEnvironment( + root, + { platform: "mac", version: "1.2.3" }, + { + DISPLAY: ":99", + NODE_OPTIONS: "--require /tmp/untrusted.js", + OPENAI_API_KEY: "must-not-leak", + PATH: process.env.PATH, + SCIENT_DEV_ALLOW_NO_SANDBOX: "1", + SCIENT_HOME: "/must/not/leak", + LEGACY_PRODUCT_HOME: "/must/not/leak-either", + PROVIDER_AUTH_TOKEN: "must-not-leak", + ELECTRON_RUN_AS_NODE: "1", + }, + ); + + expect(env.LEGACY_PRODUCT_HOME).toBeUndefined(); + expect(env.NODE_OPTIONS).toBeUndefined(); + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.PROVIDER_AUTH_TOKEN).toBeUndefined(); + expect(env.SCIENT_DEV_ALLOW_NO_SANDBOX).toBeUndefined(); + expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined(); + expect(env.DISPLAY).toBe(":99"); + for (const name of [ + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "XDG_CONFIG_HOME", + "XDG_CACHE_HOME", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", + "SCIENT_HOME", + ] as const) { + expect(env[name]?.startsWith(root)).toBe(true); + expect(existsSync(env[name]!)).toBe(true); + } + if (process.platform !== "win32") { + expect(statSync(env.XDG_RUNTIME_DIR!).mode & 0o777).toBe(0o700); + } + expect(resolvePackagedDesktopLogPath(env)).toBe( + join(env.SCIENT_HOME!, "userdata", "logs", "desktop-main.log"), + ); + }); + + it("allowlists only host variables needed to launch a native packaged app", () => { + expect( + sanitizePackagedDesktopInheritedEnvironment({ + DISPLAY: ":99", + ELECTRON_RUN_AS_NODE: "1", + NODE_OPTIONS: "--inspect", + OPENAI_API_KEY: "secret", + PATH: "/usr/bin", + SystemRoot: "C:\\Windows", + }), + ).toEqual({ DISPLAY: ":99", PATH: "/usr/bin", SystemRoot: "C:\\Windows" }); + }); + + it("requires the exact versioned and architecture-specific release asset", () => { + expect(expectedPackagedDesktopStartupAssetName("mac", "arm64", "1.2.3")).toBe( + "Scient-1.2.3-arm64.zip", + ); + expect(expectedPackagedDesktopStartupAssetName("win", "x64", "1.2.3")).toBe( + "Scient-1.2.3-x64.exe", + ); + + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-assets-test-")); + temporaryRoots.push(root); + const expected = join(root, "Scient-1.2.3-arm64.zip"); + writeFileSync(expected, "payload"); + expect(resolveExactPackagedDesktopStartupAsset(root, "Scient-1.2.3-arm64.zip")).toBe(expected); + + writeFileSync(join(root, "Scient-1.2.2-arm64.zip"), "stale payload"); + expect(() => resolveExactPackagedDesktopStartupAsset(root, "Scient-1.2.3-arm64.zip")).toThrow( + "found 2 .zip payloads", + ); + }); + + it("does not accept proof from a packaged process that exits immediately", async () => { + let now = 0; + let outcome = { exited: null, launchError: null } as { + exited: { code: number | null; signal: NodeJS.Signals | null } | null; + launchError: Error | null; + }; + + await expect( + waitForPackagedStartupProof({ + timeoutMs: 5_000, + hasProof: () => true, + readOutcome: () => outcome, + now: () => now, + delay: async (milliseconds) => { + now += milliseconds; + outcome = { exited: { code: 1, signal: null }, launchError: null }; + }, + }), + ).rejects.toThrow("exited before stable startup proof"); + }); + + it("requires app, window, backend, and renderer readiness from the isolated log", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-proof-test-")); + temporaryRoots.push(root); + const logPath = join(root, "desktop-main.log"); + const requiredMarkers = [ + "app ready", + "bootstrap main window created", + "bootstrap backend ready source=packaged", + "renderer main frame loaded", + ]; + + for (const omittedMarker of requiredMarkers) { + writeFileSync( + logPath, + requiredMarkers.filter((marker) => marker !== omittedMarker).join("\n"), + ); + expect(hasPackagedStartupProof(logPath)).toBe(false); + } + + writeFileSync(logPath, requiredMarkers.join("\n")); + expect(hasPackagedStartupProof(logPath)).toBe(true); + }); + + it("accepts startup proof only after the process remains alive for the stability window", async () => { + let now = 0; + await expect( + waitForPackagedStartupProof({ + timeoutMs: 5_000, + hasProof: () => true, + readOutcome: () => ({ exited: null, launchError: null }), + now: () => now, + delay: async (milliseconds) => { + now += milliseconds; + }, + }), + ).resolves.toBeUndefined(); + expect(now).toBeGreaterThanOrEqual(1_000); + }); + + it("fails when Windows process-tree cleanup cannot prove exit", async () => { + const child = { + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const runTaskkill = vi.fn((_pid: number) => ({ status: 1 })); + await expect( + terminateProcessTree( + child, + { + platform: "win32", + runTaskkill, + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("survived Windows cleanup"); + expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([42, 84]); + }); + + it("still cleans a detached Windows backend after the packaged root exits", async () => { + const child = { + exitCode: 0, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const runTaskkill = vi.fn((_pid: number) => ({ status: 0 })); + + await terminateProcessTree( + child, + { + platform: "win32", + runTaskkill, + waitForTargetsExit: async () => true, + }, + [84], + ); + + expect(runTaskkill.mock.calls.map(([pid]) => pid)).toEqual([84]); + }); + + it("fails when a POSIX process tree survives TERM and KILL", async () => { + const child = { + exitCode: null, + pid: 42, + signalCode: null, + } as unknown as ChildProcess; + const signals: Array<{ pid: number; signal: NodeJS.Signals }> = []; + await expect( + terminateProcessTree( + child, + { + platform: "darwin", + sendSignal: (target, signal) => signals.push({ pid: target.pid, signal }), + waitForTargetsExit: async () => false, + }, + [84], + ), + ).rejects.toThrow("survived SIGTERM and SIGKILL"); + expect(signals).toEqual([ + { pid: 42, signal: "SIGTERM" }, + { pid: 84, signal: "SIGTERM" }, + { pid: 42, signal: "SIGKILL" }, + { pid: 84, signal: "SIGKILL" }, + ]); + }); + + it("prepares the isolated Scient macOS profile marker", () => { + const root = mkdtempSync(join(tmpdir(), "scient-packaged-smoke-mac-env-test-")); + temporaryRoots.push(root); + + const env = createPackagedDesktopSmokeEnvironment( + root, + { platform: "mac", version: "1.2.3" }, + { PATH: process.env.PATH }, + ); + const markerPath = join( + env.HOME!, + "Library", + "Application Support", + "scient", + "last-launch-version.json", + ); + + expect(JSON.parse(readFileSync(markerPath, "utf8"))).toEqual({ version: "1.2.3" }); + }); + + it("recognizes only the Scient Windows executable identity", () => { + expect(isScientWindowsExecutable("C:\\payload\\Scient.exe")).toBe(true); + expect(isScientWindowsExecutable("C:\\payload\\scient.EXE")).toBe(true); + expect(isScientWindowsExecutable("C:\\payload\\Synara.exe")).toBe(false); + expect(isScientWindowsExecutable("C:\\payload\\Scient Helper.exe")).toBe(false); + }); + + it("rejects unsafe arguments from every native packaged launch", () => { + const launch = { command: "/tmp/Scient", args: [], cwd: "/tmp" }; + expect(() => assertPackagedLaunchCommandSafety(launch)).not.toThrow(); + expect(() => + assertPackagedLaunchCommandSafety({ + ...launch, + args: [...launch.args, "--no-sandbox"], + }), + ).toThrow("must exercise the real sandboxed command line"); + }); + + it("maps Node host platforms to release platform names", () => { + expect(resolveNativePackagedDesktopPlatform("darwin")).toBe("mac"); + expect(resolveNativePackagedDesktopPlatform("win32")).toBe("win"); + expect(resolveNativePackagedDesktopPlatform("linux")).toBeNull(); + }); +}); diff --git a/scripts/verify-packaged-desktop-startup.ts b/scripts/verify-packaged-desktop-startup.ts new file mode 100644 index 00000000..b0bcf0b1 --- /dev/null +++ b/scripts/verify-packaged-desktop-startup.ts @@ -0,0 +1,579 @@ +#!/usr/bin/env node +// FILE: verify-packaged-desktop-startup.ts +// Purpose: Launches an exact collected desktop release payload from isolated temporary state. +// Layer: Release verification script + +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export type PackagedDesktopPlatform = "mac" | "win"; + +export interface PackagedDesktopStartupOptions { + readonly assetsDirectory: string; + readonly platform: PackagedDesktopPlatform; + readonly arch: string; + readonly version: string; + readonly timeoutMs: number; +} + +export function parsePackagedDesktopStartupArgs( + argv: ReadonlyArray, +): PackagedDesktopStartupOptions { + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith("--") || value === undefined || values.has(name)) { + throw new Error(`Invalid packaged startup argument near ${name ?? ""}.`); + } + values.set(name, value); + } + + const known = new Set(["--assets-dir", "--platform", "--arch", "--version", "--timeout-ms"]); + for (const name of values.keys()) { + if (!known.has(name)) throw new Error(`Unknown packaged startup argument: ${name}.`); + } + + const required = (name: string): string => { + const value = values.get(name)?.trim(); + if (!value) throw new Error(`Missing packaged startup argument: ${name}.`); + return value; + }; + + const platform = required("--platform"); + if (platform !== "mac" && platform !== "win") { + throw new Error(`Unsupported packaged startup platform: ${platform}.`); + } + + const timeoutMs = Number(values.get("--timeout-ms") ?? "60000"); + if (!Number.isInteger(timeoutMs) || timeoutMs < 5_000 || timeoutMs > 180_000) { + throw new Error("--timeout-ms must be an integer between 5000 and 180000."); + } + + return { + assetsDirectory: resolve(required("--assets-dir")), + platform, + arch: required("--arch"), + version: required("--version"), + timeoutMs, + }; +} + +function runCommand(command: string, args: ReadonlyArray, cwd?: string): void { + const result = spawnSync(command, [...args], { + cwd, + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + shell: false, + windowsHide: true, + }); + if (result.error) { + throw new Error(`${command} could not start: ${result.error.message}`); + } + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + const detail = output ? `\n${output}` : ""; + throw new Error( + `${command} ${args.join(" ")} failed with exit ${result.status ?? "unknown"}.${detail}`, + ); + } +} + +function findFiles(root: string, predicate: (path: string) => boolean): string[] { + const matches: string[] = []; + const pending = [root]; + while (pending.length > 0) { + const current = pending.shift(); + if (!current) continue; + for (const entry of readdirSync(current, { withFileTypes: true })) { + const candidate = join(current, entry.name); + if (entry.isDirectory()) { + pending.push(candidate); + } else if (entry.isFile() && predicate(candidate)) { + matches.push(candidate); + } + } + } + return matches.toSorted((left, right) => left.localeCompare(right)); +} + +export function expectedPackagedDesktopStartupAssetName( + platform: PackagedDesktopPlatform, + arch: string, + version: string, +): string { + const extension = platform === "mac" ? ".zip" : ".exe"; + return `Scient-${version}-${arch}${extension}`; +} + +export function resolveExactPackagedDesktopStartupAsset( + directory: string, + expectedName: string, +): string { + const suffix = expectedName.slice(expectedName.lastIndexOf(".")); + const matches = readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(suffix)) + .map((entry) => join(directory, entry.name)); + if (matches.length !== 1) { + throw new Error( + `Expected exactly ${expectedName}; found ${matches.length} ${suffix} payloads: ${matches.map((match) => basename(match)).join(", ") || "none"}.`, + ); + } + if (basename(matches[0]!) !== expectedName) { + throw new Error( + `Expected exact release asset ${expectedName}, found ${basename(matches[0]!)}.`, + ); + } + return matches[0]!; +} + +export interface PackagedDesktopLaunchCommand { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string; +} + +export function assertPackagedLaunchCommandSafety(launch: PackagedDesktopLaunchCommand): void { + const forbiddenArgument = launch.args.find( + (argument) => argument === "--no-sandbox" || argument.startsWith("--no-sandbox="), + ); + if (forbiddenArgument) { + throw new Error( + `Packaged desktop verification must exercise the real sandboxed command line; refusing ${forbiddenArgument}.`, + ); + } +} + +function prepareMacLaunch( + assetsDirectory: string, + extractionRoot: string, + expectedAssetName: string, +): PackagedDesktopLaunchCommand { + const archive = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); + runCommand("ditto", ["-x", "-k", archive, extractionRoot]); + const appBundles = readdirSync(extractionRoot).filter((entry) => entry.endsWith(".app")); + if (appBundles.length !== 1) { + throw new Error(`Expected one packaged macOS app in ${basename(archive)}.`); + } + const appBundle = join(extractionRoot, appBundles[0]!); + const executables = findFiles(join(appBundle, "Contents", "MacOS"), (candidate) => + statSync(candidate).isFile(), + ); + if (executables.length !== 1) { + throw new Error(`Expected one macOS main executable, found ${executables.length}.`); + } + return { command: executables[0]!, args: [], cwd: appBundle }; +} + +export function isScientWindowsExecutable(candidate: string): boolean { + return /[/\\]Scient\.exe$/i.test(candidate); +} + +function prepareWindowsLaunch( + assetsDirectory: string, + extractionRoot: string, + expectedAssetName: string, +): PackagedDesktopLaunchCommand { + const installer = resolveExactPackagedDesktopStartupAsset(assetsDirectory, expectedAssetName); + const installerRoot = join(extractionRoot, "installer"); + const applicationRoot = join(extractionRoot, "application"); + mkdirSync(installerRoot, { recursive: true }); + mkdirSync(applicationRoot, { recursive: true }); + runCommand("7z", ["x", "-y", `-o${installerRoot}`, installer]); + const applicationArchives = findFiles(installerRoot, (candidate) => + /[/\\]app-(?:32|64|arm64)\.7z$/i.test(candidate), + ); + if (applicationArchives.length !== 1) { + throw new Error( + `Expected one embedded NSIS application archive, found ${applicationArchives.length}.`, + ); + } + runCommand("7z", ["x", "-y", `-o${applicationRoot}`, applicationArchives[0]!]); + const executables = findFiles(applicationRoot, isScientWindowsExecutable); + if (executables.length !== 1) { + throw new Error(`Expected one extracted Scient.exe, found ${executables.length}.`); + } + return { command: executables[0]!, args: [], cwd: dirname(executables[0]!) }; +} + +function prepareLaunch( + options: PackagedDesktopStartupOptions, + extractionRoot: string, +): PackagedDesktopLaunchCommand { + const expectedAssetName = expectedPackagedDesktopStartupAssetName( + options.platform, + options.arch, + options.version, + ); + const launch = + options.platform === "mac" + ? prepareMacLaunch(options.assetsDirectory, extractionRoot, expectedAssetName) + : prepareWindowsLaunch(options.assetsDirectory, extractionRoot, expectedAssetName); + assertPackagedLaunchCommandSafety(launch); + return launch; +} + +export function createPackagedDesktopSmokeEnvironment( + root: string, + options: Pick, + inheritedEnvironment: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + const isolatedHome = join(root, "home"); + const scientHome = join(root, "scient-home"); + const env = sanitizePackagedDesktopInheritedEnvironment(inheritedEnvironment); + Object.assign(env, { + HOME: isolatedHome, + USERPROFILE: isolatedHome, + APPDATA: join(root, "appdata"), + LOCALAPPDATA: join(root, "localappdata"), + XDG_CONFIG_HOME: join(root, "xdg-config"), + XDG_CACHE_HOME: join(root, "xdg-cache"), + XDG_DATA_HOME: join(root, "xdg-data"), + XDG_RUNTIME_DIR: join(root, "xdg-runtime"), + SCIENT_HOME: scientHome, + SYNARA_DISABLE_AUTO_UPDATE: "1", + ELECTRON_ENABLE_LOGGING: "1", + }); + for (const path of [ + env.HOME, + env.APPDATA, + env.LOCALAPPDATA, + env.XDG_CONFIG_HOME, + env.XDG_CACHE_HOME, + env.XDG_DATA_HOME, + env.SCIENT_HOME, + ]) { + if (path) mkdirSync(path, { recursive: true }); + } + if (env.XDG_RUNTIME_DIR) { + mkdirSync(env.XDG_RUNTIME_DIR, { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") chmodSync(env.XDG_RUNTIME_DIR, 0o700); + } + + if (options.platform === "mac") { + const userDataPath = join(isolatedHome, "Library", "Application Support", "scient"); + mkdirSync(userDataPath, { recursive: true }); + // Prevent the packaged app's update-only icon repair from registering this + // temporary bundle in the runner's normal Launch Services database. + const launchVersionPath = join(userDataPath, "last-launch-version.json"); + writeFileSync(launchVersionPath, `${JSON.stringify({ version: options.version }, null, 2)}\n`); + } + return env; +} + +const PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST = new Set([ + "COMSPEC", + "ComSpec", + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + "LANG", + "LC_ALL", + "LC_CTYPE", + "PATH", + "PATHEXT", + "Path", + "SYSTEMROOT", + "SystemRoot", + "TEMP", + "TMP", + "TMPDIR", + "WAYLAND_DISPLAY", + "WINDIR", + "XAUTHORITY", + "XDG_CURRENT_DESKTOP", + "XDG_DATA_DIRS", + "XDG_SESSION_TYPE", + "windir", +]); + +export function sanitizePackagedDesktopInheritedEnvironment( + inheritedEnvironment: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(inheritedEnvironment).filter( + ([name, value]) => + value !== undefined && PACKAGED_SMOKE_INHERITED_ENVIRONMENT_ALLOWLIST.has(name), + ), + ); +} + +export function resolvePackagedDesktopLogPath(environment: NodeJS.ProcessEnv): string { + const scientHome = environment.SCIENT_HOME; + if (!scientHome) throw new Error("Packaged startup smoke requires an isolated SCIENT_HOME."); + return join(scientHome, "userdata", "logs", "desktop-main.log"); +} + +export interface ProcessTerminationTarget { + readonly pid: number; + readonly processGroup: boolean; +} + +export interface ProcessTerminationDependencies { + readonly platform?: NodeJS.Platform; + readonly runTaskkill?: (pid: number) => { + readonly error?: Error; + readonly status: number | null; + }; + readonly sendSignal?: (target: ProcessTerminationTarget, signal: NodeJS.Signals) => void; + readonly waitForTargetsExit?: ( + targets: ReadonlyArray, + timeoutMs: number, + ) => Promise; +} + +function processTerminationTargetIsAlive(target: ProcessTerminationTarget): boolean { + try { + process.kill(target.processGroup ? -target.pid : target.pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +function waitForProcessTerminationTargets( + targets: ReadonlyArray, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolveExit) => { + const poll = () => { + if (targets.every((target) => !processTerminationTargetIsAlive(target))) { + resolveExit(true); + return; + } + if (Date.now() >= deadline) { + resolveExit(false); + return; + } + setTimeout(poll, 100); + }; + poll(); + }); +} + +function sendProcessTreeSignal(target: ProcessTerminationTarget, signal: NodeJS.Signals): void { + try { + process.kill(target.processGroup ? -target.pid : target.pid, signal); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } +} + +export async function terminateProcessTree( + child: ChildProcess, + dependencies: ProcessTerminationDependencies = {}, + additionalProcessIds: ReadonlyArray = [], +): Promise { + const platform = dependencies.platform ?? process.platform; + const childCanStillOwnProcesses = + platform !== "win32" || (child.exitCode === null && child.signalCode === null); + const targets = [ + ...(child.pid && childCanStillOwnProcesses + ? [{ pid: child.pid, processGroup: platform !== "win32" }] + : []), + ...additionalProcessIds.map((pid) => ({ pid, processGroup: platform !== "win32" })), + ].filter( + (target, index, allTargets) => + target.pid > 0 && allTargets.findIndex((candidate) => candidate.pid === target.pid) === index, + ); + if (targets.length === 0) return; + const awaitTargetsExit = dependencies.waitForTargetsExit ?? waitForProcessTerminationTargets; + if (platform === "win32") { + const taskkillResults = targets.map((target) => ({ + pid: target.pid, + result: + dependencies.runTaskkill?.(target.pid) ?? + spawnSync("taskkill", ["/pid", String(target.pid), "/t", "/f"], { + stdio: "ignore", + windowsHide: true, + }), + })); + if (await awaitTargetsExit(targets, 5_000)) return; + const taskkillResult = taskkillResults + .map(({ pid, result }) => + result.error + ? `${pid}: could not start (${result.error.message})` + : `${pid}: status ${result.status ?? "unknown"}`, + ) + .join(", "); + throw new Error( + `Packaged process trees survived Windows cleanup; taskkill results: ${taskkillResult}.`, + ); + } + const sendSignal = dependencies.sendSignal ?? sendProcessTreeSignal; + for (const target of targets) sendSignal(target, "SIGTERM"); + if (await awaitTargetsExit(targets, 5_000)) return; + for (const target of targets) sendSignal(target, "SIGKILL"); + if (await awaitTargetsExit(targets, 2_000)) return; + throw new Error( + `Packaged process trees ${targets.map(({ pid }) => pid).join(", ")} survived SIGTERM and SIGKILL.`, + ); +} + +export function hasPackagedStartupProof(logPath: string): boolean { + try { + const log = readFileSync(logPath, "utf8"); + return ( + log.includes("app ready") && + log.includes("bootstrap main window created") && + log.includes("renderer main frame loaded") && + log.includes("bootstrap backend ready source=") + ); + } catch { + return false; + } +} + +function readPackagedBackendProcessId(environment: NodeJS.ProcessEnv | null): number | null { + const scientHome = environment?.SCIENT_HOME; + if (!scientHome) return null; + try { + const state = JSON.parse( + readFileSync(join(scientHome, "userdata", "server-runtime.json"), "utf8"), + ) as { readonly pid?: unknown }; + return Number.isInteger(state.pid) && Number(state.pid) > 0 ? Number(state.pid) : null; + } catch { + return null; + } +} + +export interface PackagedDesktopChildOutcome { + readonly exited: { readonly code: number | null; readonly signal: NodeJS.Signals | null } | null; + readonly launchError: Error | null; +} + +interface PackagedStartupProofWaitOptions { + readonly timeoutMs: number; + readonly hasProof: () => boolean; + readonly readOutcome: () => PackagedDesktopChildOutcome; + readonly now?: () => number; + readonly delay?: (milliseconds: number) => Promise; + readonly stableForMs?: number; +} + +export async function waitForPackagedStartupProof({ + timeoutMs, + hasProof, + readOutcome, + now = Date.now, + delay = (milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)), + stableForMs = 1_000, +}: PackagedStartupProofWaitOptions): Promise { + const deadline = now() + timeoutMs; + let proofObservedAt: number | null = null; + while (now() < deadline) { + const outcome = readOutcome(); + if (outcome.launchError) { + throw new Error(`Packaged app could not start: ${outcome.launchError.message}`); + } + if (outcome.exited) { + throw new Error( + `Packaged app exited before stable startup proof (code=${outcome.exited.code ?? "null"}, signal=${outcome.exited.signal ?? "null"}).`, + ); + } + const currentTime = now(); + if (hasProof()) { + proofObservedAt ??= currentTime; + if (currentTime - proofObservedAt >= stableForMs) return; + } else { + proofObservedAt = null; + } + await delay(Math.min(200, Math.max(1, deadline - currentTime))); + } + throw new Error(`Packaged startup proof timed out after ${timeoutMs}ms.`); +} + +export function resolveNativePackagedDesktopPlatform( + platform: NodeJS.Platform, +): PackagedDesktopPlatform | null { + if (platform === "darwin") return "mac"; + if (platform === "win32") return "win"; + return null; +} + +export async function verifyPackagedDesktopStartup( + options: PackagedDesktopStartupOptions, +): Promise { + const nativePlatform = resolveNativePackagedDesktopPlatform(process.platform); + if (nativePlatform !== options.platform) { + throw new Error( + `Packaged ${options.platform} startup smoke must run on its native host, not ${process.platform}.`, + ); + } + + const temporaryRoot = mkdtempSync(join(tmpdir(), `scient-packaged-smoke-${options.platform}-`)); + const extractionRoot = join(temporaryRoot, "payload"); + mkdirSync(extractionRoot, { recursive: true }); + + let child: ChildProcess | null = null; + let environment: NodeJS.ProcessEnv | null = null; + let output = ""; + try { + const launch = prepareLaunch(options, extractionRoot); + environment = createPackagedDesktopSmokeEnvironment(join(temporaryRoot, "state"), options); + const logPath = resolvePackagedDesktopLogPath(environment); + child = spawn(launch.command, [...launch.args], { + cwd: launch.cwd, + env: environment, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + const childOutcome: { + exited: PackagedDesktopChildOutcome["exited"]; + launchError: PackagedDesktopChildOutcome["launchError"]; + } = { exited: null, launchError: null }; + child.once("exit", (code, signal) => { + childOutcome.exited = { code, signal }; + }); + child.once("error", (error) => { + childOutcome.launchError = error; + }); + const recordOutput = (chunk: unknown) => { + output = `${output}${String(chunk)}`.slice(-200_000); + }; + child.stdout?.on("data", recordOutput); + child.stderr?.on("data", recordOutput); + + await waitForPackagedStartupProof({ + timeoutMs: options.timeoutMs, + hasProof: () => hasPackagedStartupProof(logPath), + readOutcome: () => childOutcome, + }); + console.log( + `Packaged ${options.platform}/${options.arch} startup smoke passed from isolated Scient state.`, + ); + } catch (error) { + const detail = output.trim() ? `\nPackaged process output:\n${output.trim()}` : ""; + throw new Error(`${error instanceof Error ? error.message : String(error)}${detail}`, { + cause: error, + }); + } finally { + try { + if (child) { + const backendProcessId = readPackagedBackendProcessId(environment); + await terminateProcessTree(child, {}, backendProcessId === null ? [] : [backendProcessId]); + } + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } + } +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null; +if (invokedPath === fileURLToPath(import.meta.url)) { + await verifyPackagedDesktopStartup(parsePackagedDesktopStartupArgs(process.argv.slice(2))); +}