diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b09df56..e72c221 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,13 +31,17 @@ jobs: - run: pnpm lint - run: pnpm format:check - run: pnpm typecheck - - run: pnpm test:coverage - # The desktop app, built and launched under a virtual display. Ubuntu 24 restricts the user - # namespaces Chromium's sandbox needs: allowed back, so the app runs sandboxed as shipped. + # Ubuntu 24 restricts the user namespaces Chromium's sandbox needs: allowed back (Electron runs + # sandboxed as shipped). The tests run under a virtual display: the Electron target's tests + # launch a fixture desktop app. + - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - run: xvfb-run -a pnpm test:coverage + env: + # Slow shutdown phases of a desktop app's launch, said (the Electron target's evidence). + KIFRAME_ELECTRON_TIMING: "1" + # The desktop app, built and launched under a virtual display. - name: Desktop end-to-end - run: | - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - xvfb-run -a pnpm --filter @kiframe/desktop e2e + run: xvfb-run -a pnpm --filter @kiframe/desktop e2e - name: Upload coverage to Codecov if: ${{ !cancelled() }} uses: codecov/codecov-action@v5 diff --git a/docs/OBJECT-MODEL.md b/docs/OBJECT-MODEL.md index 2c7c526..99e661e 100644 --- a/docs/OBJECT-MODEL.md +++ b/docs/OBJECT-MODEL.md @@ -227,7 +227,10 @@ One scene, three phases. The agent's own instructions say the same (`packages/st - **Named by bundle id, never a path or a program** (a project may come from someone else): the desktop app finds the bundle on each machine and keeps the user's approval in its own settings (bundle id, real path, signing team); only `.app` bundles holding `Electron Framework.framework`; App Store (sandboxed) apps refused. `args` are positional only (no switches). `origins`: the https sites a wrapper app (Slack, Notion) shows as its own. - **No address**: a goto or a URL condition on it is refused; a scene starting in it opens on its first window; it takes no secrets in v0 (the user signs in by hand: the handover). - **Each run launches it sandboxed** (its own HOME, `CFFIXED_USER_HOME`, XDG and temp folders, `--user-data-dir`; an allowlisted environment, never Kiframe's), attached with `--remote-debugging-port=0` + `connectOverCDP`; the viewport is emulated (a spike measured frames and clicks with the window covered, off-screen, minimized or hidden: a run never needs the user's screen; the window itself can't be moved over CDP). Isolation is verified when the app is added: after a trial launch its state must be inside the sandbox, else refused. Its process group killed after each run, leftovers swept. -- **Stated limits:** native dialogs and menus are unreachable; links open in the user's real browser; a sign-in through the browser with an app-scheme callback isn't supported in v0; the keychain is the user's (an app reading its own item signs in as them); a window built from several views films one view. +- **Confinement: a guard, sealed at launch (PR 2, redesigned after three review rounds, design reviewed).** Once the app settles (its windows unchanged 1.5 s), its main window is the last opened still open; its own scheme or dev server is learned from that window alone, then sealed (nothing a run does adds to it). A main window that isn't the app's own (a wrapper's unlisted site) refuses the launch ("list it in its origins"). From then a **guard** checks every navigation of every frame of every window as it happens: a page that isn't the app's own is stopped at once (a popup closed, any other frame blanked: never left for a read or a frame) and recorded; the step fails `off-app`. The app's own: a local `file:` in its bundle or sandbox (decoded, normalized), its sealed scheme or dev server, a listed https site (`sameApp`, never plain http), a blob of those. Stated (as a web run): the guard acts as a page commits, so a page that isn't the app's own has loaded (its scripts ran, with the app's own rights) before it's stopped: following a link the app offers is what its user does too (blocking before the load needs request interception, which never sees `file:`); and the runner's own navigation events name it as they do for web runs (origin and path, known secret values scrubbed). What the main window embeds as it opens (a frame of another site) is the app as shipped: its origin is the app's own. A main window stopped goes back to the app's page (no goto there to bring it back); a failed load is said as one. **One app per run:** a desktop app's run never shows a site (no goto, URL condition or web preset; a web interrupt rule's goto skipped, said), is confined while grounding too, and keeps no sign-in between runs yet. +- **Windows (PR 2, known limits, to settle on real apps in PR 3 and PR 5: decided by the user 2026-10-08):** the main window is the last opened still open once the app's windows were unchanged for 1.5 s (a splash that stays up longer is taken for the main window); windows the app's main process opens are followed as popups are (a hidden helper window opened during a step may be followed: CDP can't tell it's hidden); only the launch window is sent back when it goes off-app (another window is closed); a stop that lands after its step ended counts against the next step. A window that opens blank gets a moment to load (as a web page does); a frame of a window the run isn't driving is said, never the step's failure. +- **Measured on a fixture app (PR 2, 2026-10-08):** HOME, XDG and `--user-data-dir` hold; macOS's own places don't move: Electron's `appData` (`~/Library/Application Support`) and the app's preferences (`NSUserDefaults`, written by `cfprefsd` into the user's real `~/Library/Preferences/.plist`) stay the user's whatever `HOME` or `CFFIXED_USER_HOME` say. The trial launch therefore checks both: the app's state inside the sandbox, and its preferences file unchanged; else the user is told. +- **Stated limits:** native dialogs and menus are unreachable; links open in the user's real browser; a sign-in through the browser with an app-scheme callback isn't supported in v0; the keychain and an app's macOS preferences are the user's (an app reading its own keychain item signs in as them); a window built from several views films one view. - **Built in steps** (epic `epic/electron-targets`): the format (2026-10-08), the launcher and target, the desktop approval and trial, the studio and batch on it, a real pass on a sandboxed minmux. **Existing projects convert** on open: `target` becomes `apps.app` (the rest unchanged). diff --git a/eslint.config.js b/eslint.config.js index 0763c2b..3870a05 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,16 @@ import { defineConfig } from "eslint/config" import tseslint from "typescript-eslint" export default defineConfig( - { ignores: ["**/dist/**", "**/coverage/**", ".kiframe-local/**", "apps/desktop/out/**"] }, + { + ignores: [ + "**/dist/**", + "**/coverage/**", + ".kiframe-local/**", + "apps/desktop/out/**", + // A fixture desktop app (plain CommonJS Electron loads as is: never part of a TS project). + "packages/runtime/test/fixtures/electron-app/**", + ], + }, js.configs.recommended, tseslint.configs.recommendedTypeChecked, { diff --git a/packages/runtime/src/electron.ts b/packages/runtime/src/electron.ts new file mode 100644 index 0000000..daef1db --- /dev/null +++ b/packages/runtime/src/electron.ts @@ -0,0 +1,725 @@ +import { execFile, spawn, type ChildProcess } from "node:child_process" +import { posix } from "node:path" +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + writeFileSync, +} from "node:fs" +import { readdir, readlink, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { basename, dirname, join, sep } from "node:path" +import { sameApp, type Viewport } from "@kiframe/schema" +import { untilStopped } from "./look.ts" +import { + chromium, + type Browser, + type BrowserContext, + type CDPSession, + type Frame, + type Page, +} from "playwright" + +// A desktop Electron app as a run's target (OBJECT-MODEL §0.9 "Electron apps", design reviewed +// 2026-10-08): launched from its executable (found and approved by the host: never a path from a +// project) in a fresh sandbox, attached over its debugging port (`--remote-debugging-port=0`, read +// from its own output), its windows emulated at the scene's size, its whole process group killed at +// the end and anything left holding the sandbox swept. + +/** Environment variables passed through to the app: never Kiframe's own (keys, NODE_OPTIONS, ELECTRON_*). */ +const PASSED_ENV = [ + "PATH", + "LANG", + "TZ", + "USER", + "LOGNAME", + "SHELL", + // Linux: the display and session bus the app draws on (no window without them). + "DISPLAY", + "WAYLAND_DISPLAY", + "XAUTHORITY", + "XDG_RUNTIME_DIR", + "DBUS_SESSION_BUS_ADDRESS", +] + +/** How long an app may take to open its debugging port and its first window. */ +const LAUNCH_MS = 20_000 +/** How long a terminated app may take to quit before it's killed. */ +const QUIT_MS = 2000 +/** How long closing the debugging connection may take (the group is killed after it anyway). */ +const CLOSE_MS = 2000 +/** How long an app that dropped the attach has to tell its exit. */ +const EXIT_TELL_MS = 1000 + +/** `work`, given at most `ms` (its result then never awaited). */ +async function within(work: Promise | undefined, ms: number): Promise { + let timer: ReturnType | undefined + await Promise.race([work, new Promise((resolve) => (timer = setTimeout(resolve, ms)))]) + clearTimeout(timer) +} + +/** Whether the app exited, or does within `ms`. */ +async function exitedSoon(child: ChildProcess, ms: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return true + return new Promise((resolve) => { + const timer = setTimeout(() => { + child.off("exit", exited) + resolve(false) + }, ms) + const exited = () => { + clearTimeout(timer) + resolve(true) + } + child.once("exit", exited) + }) +} + +/** Shutdown phases that took long, said on stderr (KIFRAME_ELECTRON_TIMING): CI's evidence. */ +function timing(phase: string, since: number) { + const took = Date.now() - since + if (process.env.KIFRAME_ELECTRON_TIMING !== undefined && took > 500) { + process.stderr.write(`[electron] ${phase} took ${took} ms\n`) + } +} + +const QUIT_EARLY = + "the app quit before Kiframe could attach (it may allow only one instance: quit it and try again; be a launcher for another program; or refuse automation)" + +/** An app that couldn't be launched or attached: said to the user as is. */ +export class ElectronLaunchError extends Error {} + +export interface ElectronLaunch { + /** The app's executable (`X.app/Contents/MacOS/X`), resolved and approved by the host. */ + executable: string + /** Its `.app` bundle: a `file:` page inside it is the app's own. */ + bundle?: string + /** What the app opens (positional: the project's `args`). */ + args?: readonly string[] + /** The size its windows are shown at (emulated: the window itself is never moved). */ + viewport: Pick + /** The https sites a wrapper app shows as its own (the project's `origins`). */ + origins?: readonly string[] + /** A file noting the launches still running: what a crash left is swept at the next start. */ + stateFile?: string + signal?: AbortSignal + /** Tests: how long to wait for the app, its port and its first window together (default 20 s). */ + timeoutMs?: number +} + +/** A page the guard stopped: in which window, whether it was the window itself, and where. */ +export interface GuardStop { + page: Page + /** The window's own page (sent back or closed), not a frame inside it. */ + window: boolean + place: string +} + +/** A launched app: its browser connection, its context and main window, and how to end it. */ +export interface ElectronTarget { + browser: Browser + context: BrowserContext + page: Page + /** The sandbox folder (its HOME, its data). */ + sandbox: string + /** Whether a page's address is the app's own (sealed at launch: nothing a run does adds to it). */ + allows: (url: string) => boolean + /** + * Where the app went that isn't its own since the last call (each stopped at once: the frame + * blanked, the window sent back or closed), said by place (never a full address: it may hold a + * token). + */ + stopped: () => GuardStop[] + /** A window the run follows, at the scene's size first (its emulation applied). */ + prepare: (page: Page) => Promise + /** + * Ends it (once: a second call does nothing): disconnects, kills the app's process group and + * what still points at the sandbox, removes it. `unread`: what holds the sandbox couldn't be read. + */ + close: () => Promise<{ unread: boolean }> +} + +/** A fresh sandbox: the app's HOME (and macOS's), its XDG folders, its temp and its profile. */ +function makeSandbox(): { root: string; env: NodeJS.ProcessEnv; profile: string } { + // The real path: macOS's /var is a link (a file: page's path is compared with it). + const root = realpathSync(mkdtempSync(join(tmpdir(), "kiframe-app-"))) + const dirs = { + home: join(root, "home"), + config: join(root, "home", ".config"), + data: join(root, "home", ".local", "share"), + cache: join(root, "home", ".cache"), + state: join(root, "home", ".local", "state"), + tmp: join(root, "tmp"), + profile: join(root, "profile"), + } + for (const dir of Object.values(dirs)) mkdirSync(dir, { recursive: true }) + const env: NodeJS.ProcessEnv = {} + for (const name of PASSED_ENV) if (process.env[name] !== undefined) env[name] = process.env[name] + for (const [name, value] of Object.entries(process.env)) { + if (name.startsWith("LC_")) env[name] = value + } + Object.assign(env, { + HOME: dirs.home, + // macOS: what reads NSHomeDirectory follows it (not appData, not the app's preferences: those + // stay the user's, checked by the trial launch). + CFFIXED_USER_HOME: dirs.home, + XDG_CONFIG_HOME: dirs.config, + XDG_DATA_HOME: dirs.data, + XDG_CACHE_HOME: dirs.cache, + XDG_STATE_HOME: dirs.state, + TMPDIR: `${dirs.tmp}${sep}`, + }) + return { root, env, profile: dirs.profile } +} + +/** How long the app's windows must stay as they are before its main window is chosen. */ +const SETTLE_MS = 1500 + +/** Launches `opts.executable` in a fresh sandbox and attaches to its main window. */ +export async function launchElectron(opts: ElectronLaunch): Promise { + opts.signal?.throwIfAborted() + // Before anything runs: an app moved since it was approved is said, never half-launched. + let bundles: string[] + try { + // As given and as it really is: an app may build its pages' paths either way. + bundles = opts.bundle === undefined ? [] : [opts.bundle, realpathSync(opts.bundle)] + } catch { + throw new ElectronLaunchError(`the app isn't at ${opts.bundle ?? ""} any more`) + } + const deadline = Date.now() + (opts.timeoutMs ?? LAUNCH_MS) + const sandbox = makeSandbox() + const child = spawn( + opts.executable, + [...(opts.args ?? []), `--user-data-dir=${sandbox.profile}`, "--remote-debugging-port=0"], + // Its own process group (killed whole), no terminal; its output read for the port only. + { detached: true, env: sandbox.env, stdio: ["ignore", "ignore", "pipe"], cwd: sandbox.root }, + ) + // Its spawn failing is said by the launch (never an unhandled 'error' event). + let spawnError: Error | undefined + child.on("error", (error) => (spawnError = error)) + let browser: Browser | undefined + let ended: Promise<{ unread: boolean }> | undefined + // Once: the app's group is signalled only while it's known alive (its leader running, or its + // windows still connected: a group's id is never reused while the group lives). + const end = () => + (ended ??= (async () => { + const alive = browser?.isConnected() === true + // Bounded: the group is killed right after (a close the app never answers never holds it). + const t0 = Date.now() + await within( + browser?.close().catch(() => undefined), + CLOSE_MS, + ) + timing("close", t0) + return stop(child, sandbox.root, opts.stateFile, alive) + })()) + try { + // Noted at once (a crash from here on is swept at the next start); a note that can't be + // written ends the launch (never an app left running unnoted). + note(opts.stateFile, sandbox.root, true) + const endpoint = await debuggingEndpoint( + child, + sandbox.profile, + deadline, + opts.signal, + () => spawnError, + ) + browser = await untilStopped( + chromium.connectOverCDP(endpoint, { timeout: Math.max(1, deadline - Date.now()) }), + opts.signal, + ) + const context = browser.contexts()[0] + if (context === undefined) throw new ElectronLaunchError("the app opened no window") + const connected = browser + const page = await mainWindow( + context, + deadline, + opts.signal, + () => child.exitCode !== null || child.signalCode !== null || !connected.isConnected(), + ) + // Sealed now, from the main window alone: its own scheme (app:) or dev server (loopback). + const launched = new Set() + const schemes = new Set() + const embedded = new Set() + const origin = loopbackOrigin(page.url()) + if (origin !== undefined) launched.add(origin) + const scheme = ownScheme(page.url()) + if (scheme !== undefined) schemes.add(scheme) + // What the main window embeds as it opens (a video, a widget) is part of the app as shipped. + for (const frame of page.frames()) { + const url = URL.parse(frame.url()) + // https only (a listed site's rule: never plain http in the app's name). + if (frame !== page.mainFrame() && url !== null && url.protocol === "https:") { + embedded.add(url.origin) + } + } + const own = { + bundles, + sandbox: sandbox.root, + launched, + schemes, + origins: [...(opts.origins ?? []), ...embedded], + } + const allows = (url: string) => allowedPage(url, own) + if (!allows(page.url())) { + throw new ElectronLaunchError( + `the app shows ${placeOf(page.url())}: if that's the app's own, list it in its origins`, + ) + } + // The guard: every frame of every window, every navigation, at once; one that isn't the app's + // own is stopped (a popup closed, any other frame blanked) and said. + const stops: GuardStop[] = [] + const frameAllowed = (frame: Frame): boolean => { + const url = frame.url() + if (url === "" || url === "about:blank") return true + // An inline frame (srcdoc) is its parent's content. + if (url === "about:srcdoc") { + const parent = frame.parentFrame() + return parent !== null && frameAllowed(parent) + } + // A load that failed (Chromium's error page): said as such by the step, never a place. + if (url.startsWith("chrome-error:")) return true + return allows(url) + } + const watch = (p: Page) => { + const check = (frame: Frame) => { + if (frameAllowed(frame)) return + stops.push({ page: p, window: frame === p.mainFrame(), place: placeOf(frame.url()) }) + // A popup closed; the main window back to the app's page (else nothing could bring it + // back: no goto in a desktop app); any other frame blanked. + if (frame !== p.mainFrame()) void frame.goto("about:blank").catch(() => undefined) + else if (p !== page) void p.close().catch(() => undefined) + else { + void p + .goBack() + .then((back) => (back === null ? frame.goto("about:blank") : back)) + .catch(() => undefined) + } + } + p.on("framenavigated", check) + for (const frame of p.frames()) check(frame) + } + for (const p of context.pages()) watch(p) + context.on("page", watch) + // Every window at the scene's size, for the whole run (an override ends with its session): + // started as a window opens, awaited before the run follows it (`prepare`); a failed attach is + // tried again. + const emulated = new WeakMap>() + const emulate = (p: Page): Promise => { + const known = emulated.get(p) + if (known !== undefined) return known + const done = (async () => { + if (isDevtools(p.url())) return + const session: CDPSession = await context.newCDPSession(p) + p.once("close", () => void session.detach().catch(() => undefined)) + await session.send("Emulation.setDeviceMetricsOverride", { + width: opts.viewport.width, + height: opts.viewport.height, + deviceScaleFactor: opts.viewport.deviceScaleFactor, + mobile: false, + }) + })() + emulated.set(p, done) + done.catch(() => emulated.delete(p)) + return done + } + await emulate(page) + context.on("page", (p) => void emulate(p).catch(() => undefined)) + return { + browser, + context, + page, + sandbox: sandbox.root, + allows, + stopped: () => stops.splice(0), + prepare: (p) => emulate(p), + close: end, + } + } catch (error) { + // It quit while being attached (after opening its port): said as such, never Playwright's. + // A moment for its exit to be told (it may quit as the attach drops: the drop comes first). + const quit = await exitedSoon(child, EXIT_TELL_MS) + await end() + if (opts.signal?.aborted === true) throw opts.signal.reason as Error + if (spawnError !== undefined) { + throw new ElectronLaunchError(`the app couldn't be launched (${spawnError.message})`) + } + if (quit && !(error instanceof ElectronLaunchError)) throw new ElectronLaunchError(QUIT_EARLY) + throw error + } +} + +/** Where a page is, said without its full address (a path or query may hold a token). */ +export function placeOf(url: string): string { + const parsed = URL.parse(url) + if (parsed === null) return "an unreadable address" + if (parsed.protocol === "file:") return "a file on this computer" + return parsed.host === "" ? `a ${parsed.protocol} page` : parsed.host +} + +/** The app's debugging endpoint, read whole: from its output's line, else its profile's file. */ +function debuggingEndpoint( + child: ChildProcess, + profile: string, + deadline: number, + signal: AbortSignal | undefined, + spawnError: () => Error | undefined, +): Promise { + return new Promise((resolve, reject) => { + let output = "" + const finish = (error?: Error, endpoint?: string) => { + clearTimeout(timer) + clearInterval(poll) + child.stderr?.removeAllListeners("data") + child.removeListener("exit", exited) + signal?.removeEventListener("abort", aborted) + if (error !== undefined) reject(error) + else resolve(endpoint as string) + } + child.stderr?.on("data", (chunk: Buffer) => { + output = (output + chunk.toString("utf8")).slice(-8192) + // A whole line only (a chunk may end mid-address), on this machine only. + const found = + /DevTools listening on (ws:\/\/127\.0\.0\.1:\d+\/devtools\/browser\/[0-9a-f-]+)\r?\n/.exec( + output, + )?.[1] + if (found !== undefined) finish(undefined, found) + }) + // The profile's own file, inside the sandbox (never one elsewhere: an app that ignores + // --user-data-dir would write it where Kiframe never reads), read whole. + const poll = setInterval(() => { + const failed = spawnError() + if (failed !== undefined) { + finish(new ElectronLaunchError(`the app couldn't be launched (${failed.message})`)) + return + } + try { + const [port, path] = readFileSync(join(profile, "DevToolsActivePort"), "utf8").split("\n") + if ( + port !== undefined && + /^\d+$/.test(port) && + path !== undefined && + /^\/devtools\/browser\/[0-9a-f-]+$/.test(path) + ) { + finish(undefined, `ws://127.0.0.1:${port}${path}`) + } + } catch { + // not yet + } + }, 100) + const exited = () => finish(new ElectronLaunchError(QUIT_EARLY)) + const aborted = () => finish(signal?.reason as Error) + child.once("exit", exited) + signal?.addEventListener("abort", aborted, { once: true }) + const timer = setTimeout( + () => + finish( + new ElectronLaunchError( + "the app opened no debugging port in time (it may refuse automation)", + ), + ), + Math.max(0, deadline - Date.now()), + ) + }) +} + +const isDevtools = (url: string) => url.startsWith("devtools://") + +/** + * The app's main window, once it settled: its windows unchanged for a moment (a splash that closed + * is gone), the last-opened one still open, loaded (an address and its document). + */ +async function mainWindow( + context: BrowserContext, + deadline: number, + signal: AbortSignal | undefined, + gone: () => boolean, +): Promise { + const windows = () => context.pages().filter((p) => !isDevtools(p.url()) && !p.isClosed()) + let seen = "" + let since = Date.now() + for (;;) { + signal?.throwIfAborted() + // Quit (or dropped its port) before showing a window: said at once, never a wait to the end. + if (gone()) throw new ElectronLaunchError(QUIT_EARLY) + const now = windows() + const key = now.map((p) => p.url()).join("\n") + if (key !== seen) { + seen = key + since = Date.now() + } + const last = now.at(-1) + const loaded = last !== undefined && last.url() !== "" && last.url() !== "about:blank" + if (loaded && Date.now() - since >= SETTLE_MS) { + await last + .waitForLoadState("domcontentloaded", { timeout: Math.max(1, deadline - Date.now()) }) + .catch(() => undefined) + if (!last.isClosed()) return last + } + if (Date.now() >= deadline) throw new ElectronLaunchError("the app opened no window in time") + await new Promise((resolve) => setTimeout(resolve, 100)) + } +} + +/** A loopback http(s) page's origin (a dev server the app loads), if it is one. */ +function loopbackOrigin(url: string): string | undefined { + const parsed = URL.parse(url) + if (parsed === null || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) return + return ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname) ? parsed.origin : undefined +} + +/** The schemes Chromium itself serves (never an app's own content: another site's, the browser's). */ +const BUILT_IN = new Set([ + "about:", + "blob:", + "chrome:", + "chrome-error:", + "chrome-extension:", + "chrome-untrusted:", + "data:", + "devtools:", + "file:", + "filesystem:", + "http:", + "https:", + "javascript:", + "view-source:", + "ws:", + "wss:", +]) + +/** A page's scheme if it's an app's own (`app:`, `vscode-file:`): none of Chromium's. */ +function ownScheme(url: string): string | undefined { + const parsed = URL.parse(url) + return parsed === null || BUILT_IN.has(parsed.protocol) ? undefined : parsed.protocol +} + +/** + * Whether a page is the app's own: a blank page; a local `file:` inside its bundle or its sandbox + * (never the user's files); a loopback origin or an own scheme (`app:`) its windows first showed; + * one of its sites (`origins`, as one site: `sameApp`); a blob made by one of those. Anything else + * (another site, Chromium's own pages, a site's `filesystem:`) isn't. + */ +export function allowedPage( + url: string, + own: { + bundles: readonly string[] + sandbox: string + launched: ReadonlySet + schemes: ReadonlySet + origins: readonly string[] + }, +): boolean { + if (url === "about:blank") return true + const parsed = URL.parse(url) + if (parsed === null) return false + if (parsed.protocol === "blob:") { + // Made by a page of the app's: a file: page's blob (`blob:file:///`) has no path to check, + // and only the app's own file: pages run (others are refused before they could make one). + const inner = URL.parse(parsed.pathname) + if (inner === null) return false + return inner.protocol === "file:" ? inner.host === "" : allowedPage(inner.href, own) + } + if (parsed.protocol === "file:") { + // Local only (never a file on another host). + if (parsed.host !== "") return false + let path: string + try { + path = decodeURIComponent(parsed.pathname) + } catch { + return false + } + // Decoding can make `..` the parser left (`..%2F`): normalized before it's compared. + path = posix.normalize(path) + const inside = (dir: string) => + path === dir || path.startsWith(dir.endsWith("/") ? dir : `${dir}/`) + return own.bundles.some(inside) || inside(own.sandbox) + } + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + return ( + own.launched.has(parsed.origin) || + // A listed (https) site: `sameApp` one way never takes a plain-http page for it. + own.origins.some((site) => sameApp(parsed, site)) + ) + } + return own.schemes.has(parsed.protocol) +} + +/** + * Ends a launch: its group terminated (then killed) while it's known alive (`alive`: its windows + * were still connected, or its leader runs: never a group id the system may have given another + * program since), what still points at its sandbox, the sandbox. + */ +async function stop( + child: ChildProcess, + sandbox: string, + stateFile: string | undefined, + alive: boolean, +): Promise<{ unread: boolean }> { + const pid = child.pid + const leader = child.exitCode === null && child.signalCode === null + let t0 = Date.now() + if (pid !== undefined && (leader || alive || (await orphanedGroup(pid)))) { + if (leader) { + const exited = new Promise((resolve) => child.once("exit", resolve)) + signalGroup(pid, "SIGTERM") + await Promise.race([exited, new Promise((r) => setTimeout(r, QUIT_MS))]) + } + signalGroup(pid, "SIGKILL") + } + timing("kill", t0) + // Helpers that left the group (a daemon, a pty host) still point at the sandbox. + t0 = Date.now() + const swept = await sweepSandbox(sandbox) + timing("sweep", t0) + t0 = Date.now() + await rm(sandbox, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch( + () => undefined, + ) + timing("remove", t0) + try { + note(stateFile, sandbox, false) + } catch { + // kept: swept at the next start + } + return { unread: swept.unread } +} + +/** + * Whether `pid`'s group still has members while no process holds `pid` itself: the group of a + * leader that died (a recycled id would be alive as that id, never "a group without its leader"). + */ +async function orphanedGroup(pid: number): Promise { + const table = await run("ps", ["-axo", "pid=,pgid="]) + if (table === undefined) return false + let members = 0 + for (const line of table.split("\n")) { + const [p, g] = line.trim().split(/\s+/).map(Number) + if (p === pid) return false + if (g === pid) members++ + } + return members > 0 +} + +function signalGroup(pid: number, signal: NodeJS.Signals) { + try { + process.kill(-pid, signal) + } catch { + // gone + } +} + +/** A command's output (empty when it fails: said by the caller's next check). */ +function run(command: string, args: string[]): Promise { + return new Promise((resolve) => { + execFile(command, args, { timeout: 5000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => { + // lsof exits 1 when nothing matches: its output (empty) is still the answer. + resolve(error !== null && (error as { code?: unknown }).code !== 1 ? undefined : stdout) + }) + }) +} + +/** + * Every process's working folder: Linux's /proc (quick), else `lsof` (macOS: no /proc); undefined + * when it can't be read. + */ +async function workingDirs(): Promise<[number, string][] | undefined> { + if (process.platform === "linux") { + const out: [number, string][] = [] + try { + for (const entry of await readdir("/proc")) { + if (!/^\d+$/.test(entry)) continue + try { + out.push([Number(entry), await readlink(`/proc/${entry}/cwd`)]) + } catch { + // gone, or another user's + } + } + return out + } catch { + return undefined + } + } + const listed = await run("lsof", ["-a", "-d", "cwd", "-Fpn"]) + if (listed === undefined) return undefined + const out: [number, string][] = [] + let pid: number | undefined + for (const line of listed.split("\n")) { + if (line.startsWith("p")) pid = Number(line.slice(1)) + else if (line.startsWith("n") && pid !== undefined) out.push([pid, line.slice(1)]) + } + return out +} + +/** + * Kills every process (never this one) that points at `sandbox`: its command line names it (the + * app's helpers carry `--user-data-dir=/profile`), or it works in it (a helper detached + * with its cwd there). Both are quick to read (never a walk of every file). Unread: said. + */ +export async function sweepSandbox(sandbox: string): Promise<{ swept: number; unread: boolean }> { + if (!existsSync(sandbox)) return { swept: 0, unread: false } + const [ps, cwds] = await Promise.all([run("ps", ["-axo", "pid=,command="]), workingDirs()]) + const pids = new Set() + const within = (path: string) => path === sandbox || path.startsWith(`${sandbox}/`) + for (const line of ps?.split("\n") ?? []) { + const match = /^\s*(\d+)\s+(.*)$/.exec(line) + if (match !== null && match[2]?.includes(sandbox) === true) pids.add(Number(match[1])) + } + for (const [pid, cwd] of cwds ?? []) if (within(cwd)) pids.add(pid) + pids.delete(process.pid) + for (const p of pids) { + try { + process.kill(p, "SIGKILL") + } catch { + // gone + } + } + return { swept: pids.size, unread: ps === undefined || cwds === undefined } +} + +/** Adds (or removes) a running launch's sandbox in the state file. */ +function note(stateFile: string | undefined, sandbox: string, running: boolean) { + if (stateFile === undefined) return + const all = readState(stateFile).filter((s) => s !== sandbox) + if (running) all.push(sandbox) + writeFileSync(stateFile, JSON.stringify(all)) +} + +/** A sandbox this module made: a `kiframe-app-*` folder directly in the temp folder, nothing else. */ +function isSandbox(path: string): boolean { + try { + const real = realpathSync(path) + return dirname(real) === realpathSync(tmpdir()) && basename(real).startsWith("kiframe-app-") + } catch { + return false + } +} + +function readState(stateFile: string): string[] { + try { + const parsed: unknown = JSON.parse(readFileSync(stateFile, "utf8")) + return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === "string") : [] + } catch { + return [] + } +} + +/** + * What a crash left (launches still noted): every process pointing at their sandboxes killed (by + * the sandbox, never a process id that may be another program's by now), the sandboxes removed. + * Only folders this module makes are touched. Called at the host's start. + */ +export async function sweepElectronOrphans(stateFile: string): Promise { + const left = readState(stateFile).filter(isSandbox) + for (const sandbox of left) { + await sweepSandbox(sandbox) + await rm(sandbox, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch( + () => undefined, + ) + } + // Only what was swept: a launch noted meanwhile (a run started during the sweep) stays. + const now = readState(stateFile).filter((s) => !left.includes(s)) + writeFileSync(stateFile, JSON.stringify(now)) + return left.length +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 8266d76..0c1a248 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -8,6 +8,14 @@ export * from "./recorder.ts" export * from "./runner.ts" export * from "./scanner.ts" export { type CastFrame, watchScreencast } from "./screencast.ts" +export { + allowedPage, + type ElectronLaunch, + ElectronLaunchError, + type ElectronTarget, + launchElectron, + sweepElectronOrphans, +} from "./electron.ts" export { LookRefusal, maskedScreenshot, type MaskedShot } from "./look.ts" export { fitImage, diff --git a/packages/runtime/src/run/actions.ts b/packages/runtime/src/run/actions.ts index 7145a4f..c16f644 100644 --- a/packages/runtime/src/run/actions.ts +++ b/packages/runtime/src/run/actions.ts @@ -66,7 +66,9 @@ export async function perform(ctx: Ctx, action: AnyAction, step: StepRef): Promi switch (action.action) { case "goto": { // The app it names, else the one its step means (its scene's or its preset's). - const base = new URL(webAppNamed(ctx.apps, action.app ?? ctx.app, step, "a goto").url) + const base = new URL( + webAppNamed(ctx.apps, action.app ?? ctx.app, step, "a goto", ctx.options.electron?.app).url, + ) const url = new URL(action.url, base) // Enforced here too (not only by the schema): a goto never leaves its app. if (url.origin !== base.origin) { diff --git a/packages/runtime/src/run/apps.ts b/packages/runtime/src/run/apps.ts index 144939d..4cfbf58 100644 --- a/packages/runtime/src/run/apps.ts +++ b/packages/runtime/src/run/apps.ts @@ -1,5 +1,6 @@ import { type App, type Apps, appOf, sameApp, type WebApp, webAppsOf } from "@kiframe/schema" import type { Ctx } from "./context.ts" +import { placeOf } from "../electron.ts" import { StepError, type StepRef } from "../errors.ts" // The project's apps a scene moves between (OBJECT-MODEL §0.9). Which app a step means is read @@ -21,7 +22,22 @@ export function appNamed(apps: Apps, name: string, step: StepRef): App { * A web app by its name, for what needs an address (a goto, a URL condition): a desktop app has * none (it starts where it opens: steps move in it by clicking), refused saying so. */ -export function webAppNamed(apps: Apps, name: string, step: StepRef, what: string): WebApp { +export function webAppNamed( + apps: Apps, + name: string, + step: StepRef, + what: string, + electron?: string, +): WebApp { + // A desktop app's run has that one app: its window never goes to a site (a page loaded in it + // runs with the app's own privileges). + if (electron !== undefined) { + throw new StepError( + step, + "invalid-setup", + `${what} can't run in a desktop app's scene: it stays in "${electron}" (move in it by clicking)`, + ) + } const app = appNamed(apps, name, step) if (app.kind !== "web") { throw new StepError( @@ -59,6 +75,54 @@ export function onApps(apps: Apps, url: string): boolean { * a moment first; a stop ends the wait as a stop. */ export async function confine(ctx: Ctx, step: StepRef): Promise { + // A desktop app's run: its own pages only (its guard stopped any other at once), never a web + // app's (one app per run: its window never shows a site). + const electron = ctx.options.electron + if (electron !== undefined) { + // A stop counts against the step when it was the driven window's (or a frame in it), or a + // window itself (one the step opened, sent back); a frame of another window is said only. + const stops = electron.stopped() + for (const s of stops) { + if (s.page !== ctx.page && !s.window) { + ctx.options.onEvent?.({ + kind: "warning", + message: `a frame of another window of "${electron.app}" went to ${s.place}: Kiframe stopped it`, + }) + } + } + const went = stops.filter((s) => s.page === ctx.page || s.window) + if (went.length > 0) { + throw new StepError( + step, + "off-app", + `the app's window went to ${went[0]?.place ?? ""}, not the app's own (Kiframe stopped it): if it's the app's, list it in the app's origins; else keep the scene in "${electron.app}"`, + ) + } + // A window that opens blank and loads after some setup gets a moment (as a web page does). + const until = Date.now() + BACK_MS + while (ctx.page.url() === "about:blank" && Date.now() < until && !ctx.page.isClosed()) { + await new Promise((resolve) => setTimeout(resolve, 100)) + } + const now = ctx.page.url() + if (now.startsWith("chrome-error:")) { + throw new StepError(step, "action-failed", "the page failed to load") + } + // Still blank (stopped with nothing to go back to): said, never silent. + if (now === "about:blank") { + throw new StepError( + step, + "off-app", + `the app's window is blank (Kiframe stopped a page that wasn't the app's, or it never loaded): relaunch "${electron.app}"`, + ) + } + if (electron.allows(now)) return + throw new StepError( + step, + "off-app", + `the app's window is on ${placeOf(now)}, not the app's own: if it's the app's, list it in the app's origins; else keep the scene in "${electron.app}"`, + ) + } + const until = Date.now() + BACK_MS for (;;) { const now = ctx.page.url() @@ -73,12 +137,7 @@ export async function confine(ctx: Ctx, step: StepRef): Promise { throw new StepError(step, "stopped", "the run was stopped") } if (Date.now() >= until || ctx.page.isClosed()) { - const where = - url === null - ? "an unreadable address" - : url.host === "" - ? `a ${url.protocol} page` - : url.host + const where = placeOf(now) throw new StepError( step, "off-app", diff --git a/packages/runtime/src/run/conditions.ts b/packages/runtime/src/run/conditions.ts index b5d82c8..ff97451 100644 --- a/packages/runtime/src/run/conditions.ts +++ b/packages/runtime/src/run/conditions.ts @@ -1,4 +1,4 @@ -import { type Condition, sameApp } from "@kiframe/schema" +import { type Action, type Condition, sameApp } from "@kiframe/schema" import { StepError, type StepRef } from "../errors.ts" import { EXACT_NAMES_HINT, ProbeRefusal } from "../secret-state.ts" import type { Locator as SchemaLocator } from "@kiframe/schema" @@ -49,7 +49,13 @@ export async function waitForCondition( }) } else if ("url" in condition) { // Relative to the app it names, else the one its step means. - const app = webAppNamed(ctx.apps, condition.app ?? ctx.app, step, "a URL condition") + const app = webAppNamed( + ctx.apps, + condition.app ?? ctx.app, + step, + "a URL condition", + ctx.options.electron?.app, + ) const expected = new URL(condition.url, app.url) await page.waitForURL((url) => urlMatches(url, expected), { timeout }) } else if (!(await ctx.network.waitForIdle(timeout))) { @@ -174,3 +180,12 @@ export async function pollLocator( await new Promise((resolve) => setTimeout(resolve, 100)) } } + +/** The condition an action carries (a waitFor's `until`, an expect's `that`), if any. */ +export function conditionOf(action: Action): Condition | undefined { + return action.action === "waitFor" + ? action.until + : action.action === "expect" + ? action.that + : undefined +} diff --git a/packages/runtime/src/run/context.ts b/packages/runtime/src/run/context.ts index b712529..93f50fe 100644 --- a/packages/runtime/src/run/context.ts +++ b/packages/runtime/src/run/context.ts @@ -1,6 +1,7 @@ import type { Action, Apps, ProjectConfig, Step } from "@kiframe/schema" import type { ElementHandle, Locator, Page } from "playwright" import { type ApprovalRequest, type SecretUse, StepError, type StepRef } from "../errors.ts" +import type { ElectronTarget } from "../electron.ts" import type { HandoverRequest } from "./handover.ts" import type { Box, CursorPacing, Point, TypingPacing } from "../motion.ts" import type { NetworkTracker } from "../network.ts" @@ -127,6 +128,11 @@ export interface RunOptions { * (a replay, a recording); off for a step grounded on the live page, which says where it went. */ confineToApps?: boolean + /** + * The desktop app this run's page belongs to (its target, `launchElectron`): a scene starting in + * it runs only with it, and its pages (`allows`) are the app's own. None: no desktop app runs. + */ + electron?: { app: string } & Pick /** The org interrupt rules this run's config kept from the org (their approvals are the org's). */ orgInterrupts?: { orgId: string; ruleIds: readonly string[] } /** Resolves an `upload` step's project asset (`.`) to a file path. */ diff --git a/packages/runtime/src/run/interrupts.ts b/packages/runtime/src/run/interrupts.ts index 4f43828..3a98306 100644 --- a/packages/runtime/src/run/interrupts.ts +++ b/packages/runtime/src/run/interrupts.ts @@ -1,4 +1,5 @@ import { firstApp, type ProjectConfig } from "@kiframe/schema" +import { conditionOf } from "./conditions.ts" import type { Page } from "playwright" import { StepError, type StepRef } from "../errors.ts" import { @@ -82,12 +83,39 @@ const skippedRulesOf = new WeakMap>() const exactRulesOf = new WeakMap>() +/** Whether an action needs a web app's address: a goto, a wait or a check on a URL. */ +function needsAddress(action: ProjectConfig["interrupts"][number]["do"]): boolean { + if (action.action === "goto") return true + const condition = conditionOf(action) + return condition !== undefined && "url" in condition +} + +/** + * Whether a rule can run in this run: in a desktop app's run, never one whose `do` goes to an + * address (a web app's rule: its window stays in the app), skipped with one warning. + */ +function runsHere(ctx: Ctx, rule: ProjectConfig["interrupts"][number]): boolean { + if (ctx.options.electron === undefined || !needsAddress(rule.do)) return true + // Said once per browser context (as other skipped rules). + const context = ctx.page.context() + const skippedRules = skippedRulesOf.get(context) ?? new Set() + skippedRulesOf.set(context, skippedRules) + if (!skippedRules.has(`electron:${rule.id}`)) { + skippedRules.add(`electron:${rule.id}`) + ctx.options.onEvent?.({ + kind: "warning", + message: `interrupt rule "${rule.id}" is skipped in the desktop app: it needs a web app's address`, + }) + } + return false +} + /** The first rule (in order, not in `skip`) whose `when` is visible right now (no waiting). */ async function matchingInterrupt( ctx: Ctx, skip: ReadonlySet, ): Promise { - const rules = ctx.interrupts.filter((r) => !skip.has(r.id)) + const rules = ctx.interrupts.filter((r) => !skip.has(r.id) && runsHere(ctx, r)) // The exact-names rule (§3 A8) decided once for all the rules. const names = await exactNamesFor( ctx.page, diff --git a/packages/runtime/src/run/pages.ts b/packages/runtime/src/run/pages.ts index 4c8bb84..b99c4f4 100644 --- a/packages/runtime/src/run/pages.ts +++ b/packages/runtime/src/run/pages.ts @@ -75,6 +75,22 @@ async function switchTo(ctx: Ctx, next: Page, step: StepRef): Promise { }) } +/** + * A desktop app's window made ready before the run follows it (at the scene's size: never read or + * filmed at its own); false when it closed meanwhile (its guard closed it: the run stays). + */ +async function ready(ctx: Ctx, next: Page, step: StepRef): Promise { + const prepare = ctx.options.electron?.prepare + if (prepare === undefined) return true + try { + await prepare(next) + return true + } catch (error) { + if (next.isClosed()) return false + throw new StepError(step, "action-failed", firstLine(error), { cause: error }) + } +} + /** * Brings the driven page in line with the browser, at a step boundary (the one place pages change): * 1. the driven page closed (a popup's "Done", an OAuth window): back to the nearest open opener; @@ -95,6 +111,13 @@ export async function syncPage(ctx: Ctx, step: StepRef): Promise { "the page was closed and there's no page to return to", ) } + if (!(await ready(ctx, back, step))) { + throw new StepError( + step, + "page-closed", + "the page was closed and there's no page to return to", + ) + } await switchPage(ctx, back, step) // The opener reacts (an OAuth callback loads the app): settled before going on. await guard(step, () => settle(ctx, step.phase === "steps")) @@ -109,6 +132,7 @@ export async function syncPage(ctx: Ctx, step: StepRef): Promise { throw new StepError(step, "action-failed", firstLine(error), { cause: error }) } if (next.isClosed()) return + if (!(await ready(ctx, next, step))) return ctx.openers.push(ctx.page) await switchPage(ctx, next, step) // Settled like any page an action led to (its data may load after DOMContentLoaded). diff --git a/packages/runtime/src/run/setup.ts b/packages/runtime/src/run/setup.ts index fc381a1..8c4a25c 100644 --- a/packages/runtime/src/run/setup.ts +++ b/packages/runtime/src/run/setup.ts @@ -2,6 +2,7 @@ import { Action, appOf, firstApp, type ProjectConfig, type SetupItem } from "@ki import { StepError, type StepRef } from "../errors.ts" import { type Ctx, guard, type SessionLanding } from "./context.ts" import { syncPage } from "./pages.ts" +import { conditionOf } from "./conditions.ts" import { runOne } from "./step.ts" // Setup: presets inlined (session presets skipped or replaced by their landing). An `ensure` (from @@ -43,7 +44,7 @@ export function sessionChecks(steps: readonly (Action | { ensure: unknown })[]): */ export function checksSignedIn(steps: readonly (Action | { ensure: unknown })[]): boolean { return sessionChecks(steps).some((c) => { - const condition = c.action === "waitFor" ? c.until : c.action === "expect" ? c.that : undefined + const condition = conditionOf(c) return condition !== undefined && ("visible" in condition || "text" in condition) }) } @@ -70,6 +71,8 @@ export function expandSetup( skip: readonly string[], landings: Readonly>, start: string, + /** The desktop app this run was launched in (its presets run there); none: no desktop app runs. */ + electronApp?: string, ): SetupEntry[] { const out: SetupEntry[] = [] // Setup indexes count actions and ensures only (`preset_done` is a marker, not a step). @@ -85,9 +88,14 @@ export function expandSetup( const from = { name: item.preset, session: preset.session } // Its steps run in its app (the first by default): a desktop app has its own driver (next). const runsIn = preset.app ?? firstApp(project).name - if (appOf(project, runsIn)?.kind !== "web") { + // A desktop app's run has that one app: its presets run there, never a web app's. + if ( + electronApp !== undefined ? runsIn !== electronApp : appOf(project, runsIn)?.kind !== "web" + ) { throw invalid( - `preset "${item.preset}" runs in "${runsIn}", a desktop app: Kiframe can't run steps in desktop apps yet (give the preset a web app: \`app:\`)`, + electronApp !== undefined + ? `preset "${item.preset}" runs in "${runsIn}": a scene in the desktop app "${electronApp}" stays in it (give the preset \`app: ${electronApp}\`)` + : `preset "${item.preset}" runs in "${runsIn}", a desktop app this run isn't in (give the preset a web app: \`app:\`, or start the scene in that app)`, ) } if (preset.session && skip.includes(item.preset)) { @@ -171,7 +179,8 @@ export async function runSetupEntry(ctx: Ctx, entry: SetupEntry): Promise if (entry.held !== true) { ctx.options.onEvent?.({ kind: "preset_done", name: entry.name, session: entry.session }) } - const ready = ctx.options.onSessionReady + // A desktop app's sign-in isn't saved between runs yet. + const ready = ctx.options.electron === undefined ? ctx.options.onSessionReady : undefined if (entry.session && ready !== undefined) { // Named after the preset's last step. const ref: StepRef = { diff --git a/packages/runtime/src/run/step.ts b/packages/runtime/src/run/step.ts index 92c83b0..0d08dff 100644 --- a/packages/runtime/src/run/step.ts +++ b/packages/runtime/src/run/step.ts @@ -50,7 +50,10 @@ export async function runOne( } await syncPage(ctx, step) // Where the step ended (the page it left driven: a popup, the opener back): one of the apps. - if (ctx.options.confineToApps !== false) await confine(ctx, step) + // A desktop app's run is always confined (its window can open the user's files): grounding too. + if (ctx.options.confineToApps !== false || ctx.options.electron !== undefined) { + await confine(ctx, step) + } if (ctx.options.recording === true) { await followSecretFields(ctx, step) // The page as the step left it: not a scan that started earlier in the step. diff --git a/packages/runtime/src/runner.ts b/packages/runtime/src/runner.ts index 02e7f54..6301cb4 100644 --- a/packages/runtime/src/runner.ts +++ b/packages/runtime/src/runner.ts @@ -49,14 +49,34 @@ export async function runScenario( } // The scene's start app: what its steps mean when they name no app. const started = startAppOf(scenario, project) - // A desktop app is launched by its own driver (the Electron target, next): never run on a page. - if (started.app.kind !== "web") { + // A desktop app runs only in its own target (launched for the run): never on a browser page; and + // a target runs only its own app's scene (a web scene never in a desktop app's window). + if (options.electron !== undefined && options.electron.app !== started.name) { throw new StepError( { phase: "setup", index: 0, action: "setup" }, "invalid-setup", - `app "${started.name}" is a desktop app: Kiframe can't run scenes in desktop apps yet`, + `the scene starts in "${started.name}", not in the desktop app "${options.electron.app}" it was given`, ) } + // A desktop app's sign-in isn't kept between runs yet: its session presets run each time (a + // reuse asked for is refused; the hook that would save one is never called). + if (options.electron !== undefined && (options.skipSessionPresets?.length ?? 0) > 0) { + throw new StepError( + { phase: "setup", index: 0, action: "setup" }, + "invalid-setup", + "a desktop app's sign-in isn't kept between runs yet: its session presets run each time", + ) + } + if (started.app.kind !== "web" && options.electron === undefined) { + throw new StepError( + { phase: "setup", index: 0, action: "setup" }, + "invalid-setup", + `app "${started.name}" is a desktop app: it runs only in its own launch (not on a browser page)`, + ) + } + // What the app's guard stopped before this run (a step that failed earlier, its launch) is + // never this run's. + options.electron?.stopped() const start = started.name const setup = expandSetup( scenario.setup ?? [], @@ -64,6 +84,7 @@ export async function runScenario( options.skipSessionPresets ?? [], options.sessionLandings ?? {}, start, + options.electron?.app, ) const settleMs = scenario.overrides?.pacing?.settleMs ?? project.defaults.pacing.settleMs const network = new NetworkTracker(page) @@ -87,10 +108,17 @@ export async function runScenario( return tracker } const onPopup = (popup: Page) => { + if (opened.includes(popup)) return opened.push(popup) trackerOf(popup) watch(popup) } + // A desktop app's windows opened by its main process (no opener: no popup event) are followed + // as popups are (Preferences from a menu, the main window after a sign-in window). + const onAppWindow = (p: Page) => { + if (!p.url().startsWith("devtools://") && p !== page) onPopup(p) + } + if (options.electron !== undefined) page.context().on("page", onAppWindow) // Every main-frame navigation is reported (goto, redirects, links clicked…), attributed to the // step running at that moment. let current: StepRef | undefined @@ -266,6 +294,7 @@ export async function runScenario( await ctx.fieldsInflight?.catch(() => undefined) for (const tracker of trackers.values()) tracker.dispose() for (const p of watched) p.off("popup", onPopup) + page.context().off("page", onAppWindow) ctx.detach(ctx.page) } } diff --git a/packages/runtime/test/apps.test.ts b/packages/runtime/test/apps.test.ts index b50a20e..55d8f25 100644 --- a/packages/runtime/test/apps.test.ts +++ b/packages/runtime/test/apps.test.ts @@ -542,9 +542,7 @@ defaults: { pacing: { settleMs: 0, cursor: instant, typing: instant } } const started = await failure(`app: notes steps: [{ id: a, action: pause, ms: 1 }]`) expect(started.reason).toBe("invalid-setup") - expect(started.message).toMatch( - /app "notes" is a desktop app: Kiframe can't run scenes in desktop apps yet/, - ) + expect(started.message).toMatch(/app "notes" is a desktop app: it runs only in its own launch/) expect(page.url()).toBe("about:blank") // The web app is still its own (a desktop app never matches a page's address). await runScenario( diff --git a/packages/runtime/test/batch.test.ts b/packages/runtime/test/batch.test.ts index 34a4c96..29e03c6 100644 --- a/packages/runtime/test/batch.test.ts +++ b/packages/runtime/test/batch.test.ts @@ -44,7 +44,9 @@ ${steps}`) const signedIn = ` - { id: check, action: expect, that: { visible: { by: text, text: Signed in, exact: true } } }\n` const signOut = ` - { id: out, action: click, target: { by: role, role: button, name: Sign out } }\n` -describe("recordBatch", () => { +// Browser launches share the machine with the Electron target's tests: the budget other browser +// suites use. +describe("recordBatch", { timeout: 30_000 }, () => { const run = async (steps: (string | [string, string])[]) => { const dir = mkdtempSync(join(tmpdir(), "kiframe-batch-")) const logins: number[] = [] diff --git a/packages/runtime/test/electron.test.ts b/packages/runtime/test/electron.test.ts new file mode 100644 index 0000000..b4034fe --- /dev/null +++ b/packages/runtime/test/electron.test.ts @@ -0,0 +1,891 @@ +import { + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs" +import { createRequire } from "node:module" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { parseProjectYaml, parseScenarioYaml } from "@kiframe/schema" +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest" +import { execFileSync } from "node:child_process" +import { createServer, type Server } from "node:http" +import { createServer as createHttpsServer } from "node:https" +import type { AddressInfo } from "node:net" +import { + allowedPage, + ElectronLaunchError, + type ElectronTarget, + launchElectron, + recordScenario, + runScenario, + StepError, + sweepElectronOrphans, +} from "../src/index.ts" + +// The Electron target (OBJECT-MODEL §0.9 "Electron apps"): a fixture desktop app launched in a +// sandbox with the repo's own Electron, driven and filmed like a web page, ended whole. + +const electron = createRequire(join(import.meta.dirname, "../../../apps/desktop/package.json"))( + "electron", +) as string +const fixture = join(import.meta.dirname, "fixtures/electron-app") +const viewport = { width: 800, height: 600, deviceScaleFactor: 1 } + +const project = parseProjectYaml(`version: 2 +apps: + notes: { kind: electron, bundleId: com.kiframe.fixture, viewport: { width: 800, height: 600, deviceScaleFactor: 1 } } +defaults: { pacing: { settleMs: 0, cursor: instant, typing: instant } } +`) + +const alive = (pid: number) => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +const waitFor = async (check: () => boolean, ms = 5000) => { + const until = Date.now() + ms + while (!check() && Date.now() < until) await new Promise((r) => setTimeout(r, 50)) + return check() +} + +// Another site on this machine (never the network): where the fixture's links and windows go. +let other = "" +let secure = "" +let server: Server +let secureServer: Server +beforeAll(async () => { + // An https site on this machine too (a throwaway certificate, trusted by the fixture only). + const certs = mkdtempSync(join(tmpdir(), "kiframe-el-cert-")) + execFileSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-days", + "1", + "-subj", + "/CN=127.0.0.1", + "-keyout", + join(certs, "key.pem"), + "-out", + join(certs, "cert.pem"), + ], + { stdio: "ignore" }, + ) + secureServer = createHttpsServer( + { key: readFileSync(join(certs, "key.pem")), cert: readFileSync(join(certs, "cert.pem")) }, + (_req, res) => { + res.writeHead(200, { "content-type": "text/html" }) + res.end("

A widget

") + }, + ) + await new Promise((resolve) => secureServer.listen(0, "127.0.0.1", resolve)) + secure = `https://127.0.0.1:${(secureServer.address() as AddressInfo).port}/` + server = createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/html" }) + res.end("

Another site

") + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + other = `http://127.0.0.1:${(server.address() as AddressInfo).port}/` +}) +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())) + await new Promise((resolve) => secureServer.close(() => resolve())) +}) + +/** A run's desktop app: the fixture's target, as the host gives it to the runner. */ +const inTarget = (target: ElectronTarget) => ({ + app: "notes", + allows: target.allows, + stopped: target.stopped, + prepare: target.prepare, +}) + +let open: ElectronTarget[] = [] +const launch = async (extra: string[] = [], options: { stateFile?: string } = {}) => { + const target = await launchElectron({ + executable: electron, + bundle: fixture, + args: [fixture, "hidden", `link=${other}`, ...extra], + viewport, + ...options, + }) + open.push(target) + return target +} +// Closing sweeps the system's process table: a slow CI machine gets room. +afterEach(async () => { + for (const target of open) await target.close() + open = [] +}, 30_000) + +/** What the fixture says it saw at start (written into its own data folder). */ +const seenBy = async (target: ElectronTarget) => { + const file = join(target.sandbox, "profile", "seen.json") + await waitFor(() => existsSync(file)) + return JSON.parse(readFileSync(file, "utf8")) as { + env: string[] + home: string + userData: string + args: string[] + } +} + +describe("a desktop app's launch", { timeout: 60_000 }, () => { + it("runs in its own sandbox: its home, its data, never Kiframe's environment", async () => { + process.env.KIFRAME_TEST_SECRET = "sk-never-for-the-app" + process.env.NODE_OPTIONS = "--inspect" + // Linux's display reaches the app (never a window without it); macOS ignores it. + const display = process.env.DISPLAY + process.env.DISPLAY ??= ":kiframe-test" + try { + const target = await launch() + const seen = await seenBy(target) + expect(seen.home).toBe(join(target.sandbox, "home")) + // Its data in the sandbox (--user-data-dir). Electron's appData and macOS preferences stay + // the user's (found 2026-10-08: neither HOME nor CFFIXED_USER_HOME moves them): the trial + // launch checks an app keeps its state in the sandbox. + expect(seen.userData).toBe(join(target.sandbox, "profile")) + expect(seen.env).not.toContain("KIFRAME_TEST_SECRET") + expect(seen.env).not.toContain("NODE_OPTIONS") + expect(seen.env.filter((e) => e.startsWith("ELECTRON_"))).toEqual([]) + expect(seen.env).toEqual( + expect.arrayContaining(["HOME", "CFFIXED_USER_HOME", "TMPDIR", "DISPLAY"]), + ) + // The project's arguments come before Kiframe's switches, as given. + expect(seen.args.slice(0, 3)).toEqual([fixture, "hidden", `link=${other}`]) + } finally { + delete process.env.KIFRAME_TEST_SECRET + delete process.env.NODE_OPTIONS + if (display === undefined) delete process.env.DISPLAY + else process.env.DISPLAY = display + } + }) + + it("is driven like a web page, at the scene's size, its window hidden", async () => { + const target = await launch() + await runScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: add, action: click, target: { by: role, role: button, name: Add note } } + - { id: one, action: expect, that: { text: "Notes: 1" } } + - { id: wide, action: expect, that: { text: "Width: 800" } } + - { id: open, action: click, target: { by: role, role: link, name: Settings } } + - { id: there, action: expect, that: { visible: { by: role, role: heading, name: Settings } } } +`), + project, + { + electron: inTarget(target), + timeoutMs: 5000, + }, + ) + }) + + it("never lets a step leave the app: the user's files, a website", async () => { + for (const name of ["Hosts file", "A website"]) { + const target = await launch() + const error = await runScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: out, action: click, target: { by: role, role: link, name: ${name} } } +`), + project, + { + electron: inTarget(target), + timeoutMs: 5000, + }, + ).then( + () => undefined, + (e: unknown) => e, + ) + expect(error, name).toBeInstanceOf(StepError) + expect((error as StepError).reason, name).toBe("off-app") + } + }) + + it("makes a window the app opens ready before the run follows it", async () => { + const target = await launch() + const order: string[] = [] + await runScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: open, action: click, target: { by: role, role: button, name: Open settings window } } + - { id: there, action: expect, that: { text: "Settings width: 800" } } +`), + project, + { + electron: { + app: "notes", + allows: target.allows, + stopped: target.stopped, + prepare: async (p) => { + order.push("prepare") + await target.prepare(p) + }, + }, + onPageSwitch: () => { + order.push("switch") + return Promise.resolve() + }, + timeoutMs: 5000, + }, + ) + expect(order).toEqual(["prepare", "switch"]) + }) + + it("follows a window the app opens, at the scene's size; runs a preset in it", async () => { + const target = await launch() + const withPreset = parseProjectYaml(`version: 2 +apps: + notes: { kind: electron, bundleId: com.kiframe.fixture, viewport: { width: 800, height: 600, deviceScaleFactor: 1 } } +presets: + one: { steps: [{ action: click, target: { by: role, role: button, name: Add note } }] } +defaults: { pacing: { settleMs: 0, cursor: instant, typing: instant } } +`) + await runScenario( + target.page, + parseScenarioYaml(`version: 1 +setup: [{ preset: one }] +steps: + - { id: one, action: expect, that: { text: "Notes: 1" } } + - { id: open, action: click, target: { by: role, role: button, name: Open settings window } } + - { id: there, action: expect, that: { text: "Settings width: 800" } } +`), + withPreset, + { + electron: inTarget(target), + timeoutMs: 5000, + }, + ) + }) + + it("never runs a web scene in a desktop app's window", async () => { + const target = await launch() + const both = parseProjectYaml(`version: 2 +apps: + web: { kind: web, url: "https://app.test" } + notes: { kind: electron, bundleId: com.kiframe.fixture } +`) + const error = await runScenario( + target.page, + parseScenarioYaml(`version: 1\nsteps: [{ id: a, action: pause, ms: 1 }]\n`), + both, + { electron: inTarget(target) }, + ).then( + () => undefined, + (e: unknown) => e, + ) + expect((error as StepError).message).toMatch( + /the scene starts in "web", not in the desktop app "notes" it was given/, + ) + }) + + it("is filmed like a web page", async () => { + const target = await launch() + const out = join(mkdtempSync(join(tmpdir(), "kiframe-el-take-")), "take") + const take = await recordScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: add, action: click, target: { by: role, role: button, name: Add note } } + - { id: one, action: expect, that: { text: "Notes: 1" } } +`), + project, + { + outDir: out, + sceneId: "notes", + electron: inTarget(target), + }, + ) + expect(take.meta.appUrl).toBe("electron:com.kiframe.fixture") + expect(existsSync(join(take.dir, "frames.webm"))).toBe(true) + }) + + it("ends whole: the app, a helper it detached, its sandbox", async () => { + const target = await launch() + const pidFile = join(target.sandbox, "profile", "helper.pid") + expect(await waitFor(() => existsSync(pidFile))).toBe(true) + const helper = Number(readFileSync(pidFile, "utf8")) + const groupedFile = join(target.sandbox, "profile", "grouped.pid") + expect(await waitFor(() => existsSync(groupedFile))).toBe(true) + const grouped = Number(readFileSync(groupedFile, "utf8")) + expect(alive(helper)).toBe(true) + expect(alive(grouped)).toBe(true) + await target.close() + open = [] + // Detached (found by the sandbox it holds) and in the app's group (killed with it). + expect(await waitFor(() => !alive(helper))).toBe(true) + expect(await waitFor(() => !alive(grouped))).toBe(true) + expect(existsSync(target.sandbox)).toBe(false) + }) + + it("ends a group whose main process already died (its children never outlive it)", async () => { + const target = await launch() + const file = (name: string) => join(target.sandbox, "profile", name) + expect( + await waitFor(() => existsSync(file("main.pid")) && existsSync(file("grouped.pid"))), + ).toBe(true) + const main = Number(readFileSync(file("main.pid"), "utf8")) + const grouped = Number(readFileSync(file("grouped.pid"), "utf8")) + process.kill(main, "SIGKILL") + expect(await waitFor(() => !alive(main))).toBe(true) + expect(alive(grouped)).toBe(true) + await target.close() + open = [] + expect(await waitFor(() => !alive(grouped))).toBe(true) + }) + + it("never leaves an app running when its launch can't be noted", async () => { + const before = readdirSync(tmpdir()).filter((d) => d.startsWith("kiframe-app-")).length + await expect( + launch([], { stateFile: join(tmpdir(), "no-such-folder-x", "launches.json") }), + ).rejects.toThrow() + expect(readdirSync(tmpdir()).filter((d) => d.startsWith("kiframe-app-")).length).toBe(before) + }) + + it("takes a bundle given through a link (its pages report either path)", async () => { + const link = join(mkdtempSync(join(tmpdir(), "kiframe-el-link-")), "Fixture.app") + symlinkSync(fixture, link) + const target = await launchElectron({ + executable: electron, + bundle: link, + args: [link, "hidden"], + viewport, + }) + open.push(target) + await runScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: open, action: click, target: { by: role, role: link, name: Settings } } + - { id: there, action: expect, that: { visible: { by: role, role: heading, name: Settings } } } +`), + project, + { + electron: inTarget(target), + timeoutMs: 5000, + }, + ) + }) + + it("sweeps what a crash left (by the files they hold), at the next start", async () => { + const stateFile = join(mkdtempSync(join(tmpdir(), "kiframe-el-state-")), "launches.json") + const target = await launch([], { stateFile }) + const pidFile = join(target.sandbox, "profile", "helper.pid") + await waitFor(() => existsSync(pidFile)) + const helper = Number(readFileSync(pidFile, "utf8")) + expect(JSON.parse(readFileSync(stateFile, "utf8"))).toHaveLength(1) + // Kiframe "crashed": never closed. The next start sweeps it. + expect(await sweepElectronOrphans(stateFile)).toBe(1) + expect(await waitFor(() => !alive(helper))).toBe(true) + expect(existsSync(target.sandbox)).toBe(false) + expect(JSON.parse(readFileSync(stateFile, "utf8"))).toEqual([]) + }) + + it("says an app that quits at once, and leaves nothing behind", async () => { + const before = readdirSync(tmpdir()).filter((d) => d.startsWith("kiframe-app-")).length + await expect(launch(["quit-at-once"])).rejects.toThrow(ElectronLaunchError) + await expect(launch(["quit-at-once"])).rejects.toThrow(/quit before Kiframe could attach/) + expect(readdirSync(tmpdir()).filter((d) => d.startsWith("kiframe-app-")).length).toBe(before) + }) + + it("learns its own scheme from a window it opens after a splash (never from a step)", async () => { + const target = await launch(["splash"]) + expect( + await waitFor(() => target.context.pages().some((p) => p.url().startsWith("app:")), 8000), + ).toBe(true) + expect(target.allows("app://local/index.html")).toBe(true) + expect(target.allows("other://local/index.html")).toBe(false) + // Its main window (the splash closed or not, the last one opened) is the one driven. + expect(target.page.url()).toBe("app://local/index.html") + await runScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: add, action: click, target: { by: role, role: button, name: Add note } } + - { id: one, action: expect, that: { text: "Notes: 1" } } +`), + project, + { + electron: inTarget(target), + timeoutMs: 5000, + }, + ) + }) + + it("guards every window and frame: a site's window closed, the user's file blanked, said", async () => { + const run = async (button: string) => { + const target = await launch() + const error = await runScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: out, action: click, target: { by: role, role: button, name: ${button} } } + - { id: beat, action: pause, ms: 1500 } +`), + project, + { + electron: inTarget(target), + timeoutMs: 5000, + }, + ).then( + () => undefined, + (e: unknown) => e as StepError, + ) + return { target, error } + } + const site = await run("Open website window") + expect(site.error?.reason).toBe("off-app") + expect(site.error?.message).toMatch(/the app's window went to 127\.0\.0\.1:\d+/) + expect(site.target.context.pages().some((p) => p.url().startsWith(other))).toBe(false) + const file = await run("Embed hosts") + expect(file.error?.message).toMatch(/went to a file on this computer/) + // The frame is blank (never left showing the file to a read). + expect(file.target.page.frames().some((f) => f.url().startsWith("file:///etc"))).toBe(false) + // The app's own page in a frame is its own. + expect((await run("Embed settings")).error).toBeUndefined() + }) + + it("confines a desktop window while grounding too (the user's files never reach a read)", async () => { + const target = await launch() + const error = await runScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: out, action: click, target: { by: role, role: link, name: Hosts file } } +`), + project, + { + electron: inTarget(target), + confineToApps: false, + timeoutMs: 5000, + }, + ).then( + () => undefined, + (e: unknown) => e as StepError, + ) + expect(error?.reason).toBe("off-app") + }) + + it("keeps no sign-in between runs yet, skips a web rule's goto, ends once", async () => { + const target = await launch() + const scene = parseScenarioYaml(`version: 1\nsteps: [{ id: a, action: pause, ms: 1 }]\n`) + await expect( + runScenario(target.page, scene, project, { + electron: inTarget(target), + skipSessionPresets: ["login"], + }), + ).rejects.toThrow(/a desktop app's sign-in isn't kept between runs yet/) + const withRule = parseProjectYaml(`version: 2 +apps: + notes: { kind: electron, bundleId: com.kiframe.fixture, viewport: { width: 800, height: 600, deviceScaleFactor: 1 } } + web: { kind: web, url: "https://app.test" } +interrupts: + - { id: cookies, when: { by: role, role: button, name: Add note }, do: { action: goto, app: web, url: / } } +defaults: { pacing: { settleMs: 0, cursor: instant, typing: instant } } +`) + const warnings: string[] = [] + await runScenario( + target.page, + parseScenarioYaml(`version: 1 +app: notes +steps: [{ id: add, action: click, target: { by: role, role: button, name: Add note } }] +`), + withRule, + { + electron: inTarget(target), + onEvent: (e) => e.kind === "warning" && warnings.push(e.message), + }, + ) + expect(warnings).toContain( + 'interrupt rule "cookies" is skipped in the desktop app: it needs a web app\'s address', + ) + // Once: a second call is the same ending (never a second signal to a group id). + const first = target.close() + expect(target.close()).toBe(first) + expect(await first).toEqual({ unread: false }) + open = [] + }) + + it("keeps what the app embeds as it opens, and takes its window back from a site", async () => { + // A frame of another (https) site shown at launch is the app as shipped: never a stop. + const embeds = await launchElectron({ + executable: electron, + bundle: fixture, + args: [fixture, "hidden", `link=${secure}`, "embed-at-start", "trust-test-cert"], + viewport, + }) + open.push(embeds) + await runScenario( + embeds.page, + parseScenarioYaml(`version: 1\nsteps: [{ id: a, action: pause, ms: 300 }]\n`), + project, + { electron: inTarget(embeds) }, + ) + expect(embeds.page.frames().some((f) => f.url() === secure)).toBe(true) + // A plain-http frame at launch is never the app's: stopped (and never blamed on a run). + const plain = await launch(["embed-at-start"]) + await runScenario( + plain.page, + parseScenarioYaml(`version: 1\nsteps: [{ id: a, action: pause, ms: 300 }]\n`), + project, + { electron: inTarget(plain) }, + ) + expect(plain.page.frames().some((f) => f.url() === other)).toBe(false) + // The main window that went to a site comes back to the app (never left blank: no goto here). + const target = await launch() + const options = { + electron: inTarget(target), + timeoutMs: 5000, + } + await expect( + runScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: out, action: click, target: { by: role, role: link, name: A website } } + - { id: beat, action: pause, ms: 1000 } +`), + project, + options, + ), + ).rejects.toThrow(/went to 127\.0\.0\.1/) + expect( + await waitFor( + () => target.page.url().endsWith("index.html") || target.page.url().includes("index.html?"), + ), + ).toBe(true) + await runScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: add, action: click, target: { by: role, role: button, name: Add note } } + - { id: one, action: expect, that: { text: "Notes: 1" } } +`), + project, + options, + ) + }) + + it("never blames a run for what was stopped before it; says a failed load as one", async () => { + const target = await launch() + const options = { + electron: inTarget(target), + timeoutMs: 5000, + } + // Stopped outside any run (an earlier failed step): the next run isn't blamed. + await target.page.getByRole("button", { name: "Embed hosts" }).click() + await new Promise((r) => setTimeout(r, 500)) + await runScenario( + target.page, + parseScenarioYaml(`version: 1\nsteps: [{ id: a, action: pause, ms: 1 }]\n`), + project, + options, + ) + const dead = await runScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: out, action: click, target: { by: role, role: link, name: A dead link } } + - { id: beat, action: pause, ms: 1000 } +`), + project, + options, + ).then( + () => undefined, + (e: unknown) => e as StepError, + ) + expect(dead?.message).toMatch(/the page failed to load/) + }) + + it("runs a batch's scene (its session hook never called), skips a URL-waiting rule", async () => { + const target = await launch() + const withRule = parseProjectYaml(`version: 2 +apps: + notes: { kind: electron, bundleId: com.kiframe.fixture, viewport: { width: 800, height: 600, deviceScaleFactor: 1 } } + web: { kind: web, url: "https://app.test" } +presets: + login: { app: notes, session: true, steps: [{ action: click, target: { by: role, role: button, name: Add note } }] } +interrupts: + - { id: home, when: { by: role, role: button, name: Add note }, do: { action: waitFor, until: { url: /home, app: web } } } +defaults: { pacing: { settleMs: 0, cursor: instant, typing: instant } } +`) + const warnings: string[] = [] + let saved = 0 + await runScenario( + target.page, + parseScenarioYaml(`version: 1 +app: notes +setup: [{ preset: login }] +steps: [{ id: add, action: click, target: { by: role, role: button, name: Add note } }] +`), + withRule, + { + electron: inTarget(target), + onSessionReady: () => { + saved++ + return Promise.resolve() + }, + onEvent: (e) => e.kind === "warning" && warnings.push(e.message), + }, + ) + expect(saved).toBe(0) + expect(warnings).toContain( + 'interrupt rule "home" is skipped in the desktop app: it needs a web app\'s address', + ) + }) + + it("follows the windows its main process opens: a sign-in window replaced, Preferences", async () => { + const swap = await launch(["swap"]) + await runScenario( + swap.page, + parseScenarioYaml(`version: 1 +steps: + - { id: wait, action: pause, ms: 4000 } + - { id: add, action: click, target: { by: role, role: button, name: Add note } } + - { id: one, action: expect, that: { text: "Notes: 1" } } +`), + project, + { electron: inTarget(swap), timeoutMs: 5000 }, + ) + const prefs = await launch(["prefs"]) + await runScenario( + prefs.page, + parseScenarioYaml(`version: 1 +steps: + - { id: wait, action: pause, ms: 3500 } + - { id: there, action: expect, that: { visible: { by: role, role: heading, name: Settings } } } + - { id: wide, action: expect, that: { text: "Settings width: 800" } } +`), + project, + { electron: inTarget(prefs), timeoutMs: 5000 }, + ) + }) + + it("never counts the app's own DevTools as leaving it", async () => { + const target = await launch(["devtools"]) + await runScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: wait, action: pause, ms: 1500 } + - { id: add, action: click, target: { by: role, role: button, name: Add note } } +`), + project, + { electron: inTarget(target), timeoutMs: 5000 }, + ) + }) + + it("follows a window that opens blank and loads later; a frame of another window is said only", async () => { + const target = await launch() + const warnings: string[] = [] + const options = { + electron: inTarget(target), + timeoutMs: 5000, + onEvent: (e: { kind: string; message?: string }) => { + if (e.kind === "warning" && e.message !== undefined) warnings.push(e.message) + }, + } + await runScenario( + target.page, + parseScenarioYaml(`version: 1 +steps: + - { id: open, action: click, target: { by: role, role: button, name: Open window that loads later } } + - { id: there, action: expect, that: { visible: { by: role, role: heading, name: Settings } } } + - { id: other, action: pause, ms: 1 } +`), + project, + { + ...options, + onPageSwitch: async () => { + // The run is on the new window: the main window (not driven) embeds the user's file. + await target.page.getByRole("button", { name: "Embed hosts" }).click() + await new Promise((r) => setTimeout(r, 500)) + }, + }, + ) + expect(warnings).toContain( + 'a frame of another window of "notes" went to a file on this computer: Kiframe stopped it', + ) + }) + + it("says a window left blank (nothing to go back to): relaunch", async () => { + const target = await launch() + const step = (yaml: string) => + runScenario(target.page, parseScenarioYaml(`version: 1\nsteps:\n${yaml}`), project, { + electron: inTarget(target), + timeoutMs: 5000, + }).then( + () => "ran", + (e: unknown) => (e as Error).message, + ) + expect( + await step(` - { id: out, action: click, target: { by: role, role: button, name: Replace with website } } + - { id: beat, action: pause, ms: 1500 }`), + ).toMatch(/went to 127\.0\.0\.1/) + expect(await step(" - { id: a, action: pause, ms: 1 }")).toMatch( + /the app's window is blank .*: relaunch "notes"/, + ) + }) + + it("refuses an app whose main window isn't its own at launch, said where it is", async () => { + await expect(launch(["elsewhere"])).rejects.toThrow( + "the app shows a data: page: if that's the app's own, list it in its origins", + ) + }) + + it("says an app that isn't there any more, or can't start, never crashing the host", async () => { + await expect( + launchElectron({ executable: electron, bundle: "/Applications/Gone.app", viewport }), + ).rejects.toThrow("the app isn't at /Applications/Gone.app any more") + await expect( + launchElectron({ executable: "/no/such/executable", viewport, timeoutMs: 5000 }), + ).rejects.toThrow(/the app couldn't be launched/) + }) + + it("keeps a desktop scene in its app: no goto, URL condition or web preset there", async () => { + const target = await launch() + const both = parseProjectYaml(`version: 2 +apps: + notes: { kind: electron, bundleId: com.kiframe.fixture } + web: { kind: web, url: "https://app.test" } +presets: + login: { app: web, steps: [{ action: goto, url: /login }] } +`) + const failure = (yaml: string) => + runScenario(target.page, parseScenarioYaml(`version: 1\n${yaml}`), both, { + electron: inTarget(target), + timeoutMs: 3000, + }).then( + () => "ran", + (e: unknown) => (e as Error).message, + ) + expect( + await failure("setup: [{ preset: login }]\nsteps: [{ id: a, action: pause, ms: 1 }]"), + ).toMatch(/a scene in the desktop app "notes" stays in it/) + expect(await failure("steps: [{ id: a, action: goto, app: web, url: / }]")).toMatch( + /a goto can't run in a desktop app's scene/, + ) + expect( + await failure("steps: [{ id: a, action: waitFor, until: { url: /, app: web } }]"), + ).toMatch(/a URL condition can't run in a desktop app's scene/) + expect(target.page.url()).toMatch(/^file:/) + }) + + it("stops when the run is stopped while it launches", async () => { + const stop = new AbortController() + const launching = launchElectron({ + executable: electron, + bundle: fixture, + args: [fixture, "hidden"], + viewport, + signal: stop.signal, + }) + stop.abort(new Error("stopped")) + await expect(launching).rejects.toThrow(/stopped/) + }) +}) + +describe("a desktop app's own pages", () => { + const own = { + bundles: ["/Applications/Notes.app"], + sandbox: "/tmp/kiframe-app-x", + launched: new Set(["http://localhost:5173"]), + schemes: new Set(["app:", "vscode-file:"]), + origins: ["https://app.slack.com"], + } + it("are its bundle's and sandbox's files, its launch-time dev server and schemes, its sites", () => { + for (const url of [ + "about:blank", + "file:///Applications/Notes.app/Contents/Resources/app/index.html", + "file:///tmp/kiframe-app-x/profile/page.html", + "http://localhost:5173/settings", + "https://app.slack.com/client/T1", + "app://notes/index.html", + "vscode-file://vscode-app/x.html", + "blob:https://app.slack.com/123", + // A file: page's blob (no path to check: made by the app's own pages). + "blob:file:///3f2a-41c1", + // Its site as one site (www.). + "https://www.app.slack.com/client", + ]) { + expect(allowedPage(url, own), url).toBe(true) + } + }) + it("are never the user's files, another site or port, or a data: page", () => { + for (const url of [ + "file:///etc/hosts", + "file:///Applications/Notes.app.evil/x.html", + "file:///Applications/Notes.app/../Mail.app/x.html", + "file:///Users/me/Documents/secret.pdf", + "http://localhost:8080/", + "https://example.com/", + "https://app.slack.com.evil.test/", + "data:text/html,

x

", + "chrome-error://chromewebdata/", + "blob:https://example.com/123", + // Another app's scheme, a site's sandboxed files, Chromium's pages, another host's file. + "other-app://x/index.html", + "filesystem:https://example.com/temporary/x.html", + "chrome-extension://abc/page.html", + "about:srcdoc", + "file://server/Applications/Notes.app/index.html", + // Decoding can make `..` the URL parser left alone. + "file:///Applications/Notes.app/..%2F..%2Fetc/hosts", + "blob:file://server/abc", + // Never plain http for an https site. + "http://app.slack.com/client", + ]) { + expect(allowedPage(url, own), url).toBe(false) + } + }) +}) + +// The state file only names sandboxes Kiframe made: never a folder from a tampered file. +describe("the launches' state file", () => { + it("keeps a launch noted while it sweeps (only what it swept is removed)", async () => { + const dir = mkdtempSync(join(tmpdir(), "kiframe-el-state-")) + const left = mkdtempSync(join(tmpdir(), "kiframe-app-")) + const meanwhile = mkdtempSync(join(tmpdir(), "kiframe-app-")) + const stateFile = join(dir, "launches.json") + writeFileSync(stateFile, JSON.stringify([left])) + try { + const sweeping = sweepElectronOrphans(stateFile) + // A run starts during the sweep: its launch is noted. + writeFileSync(stateFile, JSON.stringify([left, meanwhile])) + expect(await sweeping).toBe(1) + expect(JSON.parse(readFileSync(stateFile, "utf8"))).toEqual([meanwhile]) + expect(existsSync(meanwhile)).toBe(true) + } finally { + rmSync(left, { recursive: true, force: true }) + rmSync(meanwhile, { recursive: true, force: true }) + } + }) + + it("never sweeps a folder that isn't a sandbox (named like one, or anywhere else)", async () => { + const dir = mkdtempSync(join(tmpdir(), "kiframe-el-state-")) + const keep = mkdtempSync(join(tmpdir(), "users-files-")) + // Named like a sandbox, but not one this module made (not directly in the temp folder). + const lookalike = mkdtempSync(join(dir, "kiframe-app-")) + const stateFile = join(dir, "launches.json") + writeFileSync(stateFile, JSON.stringify([keep, lookalike, `${lookalike}/../..`])) + expect(await sweepElectronOrphans(stateFile)).toBe(0) + expect(existsSync(keep)).toBe(true) + expect(existsSync(lookalike)).toBe(true) + }) +}) diff --git a/packages/runtime/test/fixtures/electron-app/index.html b/packages/runtime/test/fixtures/electron-app/index.html new file mode 100644 index 0000000..60783bf --- /dev/null +++ b/packages/runtime/test/fixtures/electron-app/index.html @@ -0,0 +1,51 @@ + + + + Fixture notes + + +

Notes

+ +

Notes: 0

+

Width:

+ Settings + + Hosts file + A website + + A dead link + + + + + + + diff --git a/packages/runtime/test/fixtures/electron-app/main.cjs b/packages/runtime/test/fixtures/electron-app/main.cjs new file mode 100644 index 0000000..3985634 --- /dev/null +++ b/packages/runtime/test/fixtures/electron-app/main.cjs @@ -0,0 +1,91 @@ +// A fixture desktop app for the Electron target's tests (never shipped): a window with a button, +// links out, a popup; it writes its data, says what it sees of its environment, and starts a +// detached helper (as a pty host or a daemon would) that only the sandbox sweep can find. It never +// writes macOS preferences: those go to the user's real ~/Library/Preferences whatever the sandbox +// (cfprefsd writes them for the app: a stated residual). +const { app, BrowserWindow, net, protocol } = require("electron") +const { pathToFileURL } = require("node:url") +const { spawn } = require("node:child_process") +const { writeFileSync } = require("node:fs") +const { homedir } = require("node:os") +const { join } = require("node:path") + +// "splash": a file: splash window first, the main window then on the app's own scheme (app:). +const splash = process.argv.includes("splash") +if (splash) + protocol.registerSchemesAsPrivileged([{ scheme: "app", privileges: { standard: true } }]) + +// "trust-test-cert": a test's own https server (a throwaway certificate) is trusted. +if (process.argv.includes("trust-test-cert")) + app.commandLine.appendSwitch("ignore-certificate-errors") + +if (process.argv.includes("quit-at-once")) { + app.quit() +} else { + app.whenReady().then(() => { + const data = app.getPath("userData") + writeFileSync( + join(data, "seen.json"), + JSON.stringify({ + env: Object.keys(process.env), + home: homedir(), + userData: data, + args: process.argv.slice(1), + }), + ) + const helper = spawn("sleep", ["300"], { detached: true, stdio: "ignore", cwd: data }) + helper.unref() + writeFileSync(join(data, "helper.pid"), String(helper.pid)) + // A child in the app's own process group holding nothing in the sandbox (cwd /): only the + // group's kill ends it. + const grouped = spawn("sleep", ["300"], { stdio: "ignore", cwd: "/" }) + writeFileSync(join(data, "grouped.pid"), String(grouped.pid)) + writeFileSync(join(data, "main.pid"), String(process.pid)) + const show = !process.argv.includes("hidden") + if (splash) { + protocol.handle("app", (request) => + net.fetch(pathToFileURL(join(__dirname, new URL(request.url).pathname)).href), + ) + const first = new BrowserWindow({ width: 300, height: 200, show }) + first.loadFile(join(__dirname, "other.html")) + setTimeout(() => { + const main = new BrowserWindow({ width: 700, height: 500, show }) + main.loadURL("app://local/index.html") + }, 800) + return + } + // "swap": a sign-in window first, replaced by the main window a moment later (no opener). + if (process.argv.includes("swap")) { + const signIn = new BrowserWindow({ width: 400, height: 300, show }) + signIn.loadFile(join(__dirname, "other.html")) + setTimeout(() => { + const main = new BrowserWindow({ width: 700, height: 500, show }) + main.loadFile(join(__dirname, "index.html")) + main.webContents.once("did-finish-load", () => signIn.close()) + }, 2500) + return + } + const win = new BrowserWindow({ width: 700, height: 500, show }) + // "prefs": its main process opens a Preferences window a moment later (no opener). + if (process.argv.includes("prefs")) { + setTimeout(() => { + const prefs = new BrowserWindow({ width: 500, height: 400, show }) + prefs.loadFile(join(__dirname, "other.html")) + }, 2500) + } + // "devtools": it opens DevTools itself (a dev build). + if (process.argv.includes("devtools")) { + win.webContents.once("did-finish-load", () => + win.webContents.openDevTools({ mode: "detach" }), + ) + } + // "elsewhere": its window shows a page that's never an app's own (a data: page). + // "link=": the other site its links and windows go to (a test's local server); + // "embed-at-start": it shows that site in a frame as it opens. + const link = process.argv.find((a) => a.startsWith("link="))?.slice(5) ?? "https://example.com/" + const query = { link, ...(process.argv.includes("embed-at-start") && { embed: "1" }) } + if (process.argv.includes("elsewhere")) win.loadURL("data:text/html,

Elsewhere

") + else win.loadFile(join(__dirname, "index.html"), { query }) + }) + app.on("window-all-closed", () => app.quit()) +} diff --git a/packages/runtime/test/fixtures/electron-app/other.html b/packages/runtime/test/fixtures/electron-app/other.html new file mode 100644 index 0000000..fb166d3 --- /dev/null +++ b/packages/runtime/test/fixtures/electron-app/other.html @@ -0,0 +1,15 @@ + + + + Settings + + +

Settings

+

Settings width:

+ + + diff --git a/packages/runtime/test/fixtures/electron-app/package.json b/packages/runtime/test/fixtures/electron-app/package.json new file mode 100644 index 0000000..a3396eb --- /dev/null +++ b/packages/runtime/test/fixtures/electron-app/package.json @@ -0,0 +1,5 @@ +{ + "name": "kiframe-fixture-electron", + "private": true, + "main": "main.cjs" +} diff --git a/packages/studio/test/snapshot-find.test.ts b/packages/studio/test/snapshot-find.test.ts index 0edd411..6f54a8e 100644 --- a/packages/studio/test/snapshot-find.test.ts +++ b/packages/studio/test/snapshot-find.test.ts @@ -55,6 +55,7 @@ describe("finding in a snapshot", () => { expect(find(SNAP, "2026_FIFA")).toContain('link "2026 World Cup" [ref=e3]') }) + // A ratio of times, never a budget: room for a CI machine busy with the Electron tests. it("stays linear on a huge snapshot with a common word", () => { // Time per size, not a wall-clock budget (CI and coverage are several times slower): four times // the snapshot takes about four times as long; a quadratic search takes about sixteen times. @@ -73,7 +74,7 @@ describe("finding in a snapshot", () => { timed(10_000) // warm up const ratio = timed(100_000) / timed(25_000) expect(ratio).toBeLessThan(8) - }) + }, 30_000) it("reads a sentence over a link, as written by a reader (dashes, quotes, case)", () => { // "He played in the 2026 World Cup and won." spans the paragraph's three children.