@specific.dev/spectest 0.87.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/browser.d.ts +7 -0
  2. package/dist/browser.js +102 -10
  3. package/dist/components/aws.d.ts +8 -7
  4. package/dist/components/aws.js +6 -0
  5. package/dist/components/electron-chrome.d.ts +1 -0
  6. package/dist/components/electron-chrome.js +76 -0
  7. package/dist/components/electron.d.ts +46 -0
  8. package/dist/components/electron.js +99 -0
  9. package/dist/components/index.d.ts +1 -0
  10. package/dist/components/index.js +1 -0
  11. package/dist/components/supabase-functions-reach.d.ts +18 -0
  12. package/dist/components/supabase-functions-reach.js +155 -0
  13. package/dist/components/supabase.js +33 -5
  14. package/dist/daemon.js +34 -2
  15. package/dist/desktop-recorder.d.ts +1 -0
  16. package/dist/desktop-recorder.js +30 -0
  17. package/dist/desktop.d.ts +42 -0
  18. package/dist/desktop.js +10 -0
  19. package/dist/harness/reach.d.ts +4 -0
  20. package/dist/harness/reach.js +2 -2
  21. package/dist/index.d.ts +26 -0
  22. package/dist/index.js +5 -0
  23. package/dist/recorder.d.ts +2 -0
  24. package/package.json +1 -1
  25. package/src/browser.ts +106 -10
  26. package/src/components/aws.ts +6 -0
  27. package/src/components/electron-chrome.ts +76 -0
  28. package/src/components/electron.test.ts +38 -0
  29. package/src/components/electron.ts +115 -0
  30. package/src/components/index.ts +1 -0
  31. package/src/components/supabase-functions-reach.test.ts +90 -0
  32. package/src/components/supabase-functions-reach.ts +153 -0
  33. package/src/components/supabase.ts +33 -5
  34. package/src/daemon.ts +43 -6
  35. package/src/desktop-recorder.test.ts +55 -0
  36. package/src/desktop-recorder.ts +30 -0
  37. package/src/desktop.ts +43 -0
  38. package/src/harness/reach.test.ts +12 -0
  39. package/src/harness/reach.ts +6 -2
  40. package/src/index.ts +33 -0
  41. package/src/inputs.test.ts +20 -0
  42. package/src/recorder.ts +2 -0
@@ -50,6 +50,7 @@
50
50
  // (`sb.url`, `sb.dbUrl`) embeds it — a mismatch errors at load.
51
51
  import { createHmac } from "node:crypto";
52
52
  import { existsSync, readFileSync } from "node:fs";
53
+ import { excludeAllBut, functionsReach } from "./supabase-functions-reach.js";
53
54
  import { readdir, stat } from "node:fs/promises";
54
55
  import { dirname, join } from "node:path";
55
56
  import { serviceGroup, } from "../index.js";
@@ -137,6 +138,9 @@ function providerCredentials(providerName, declared, env) {
137
138
  * cache rides into the image as a layer — so at run time the worker resolves
138
139
  * those URLs locally and never reaches for the network.
139
140
  *
141
+ * The context is the module graph the functions can load (the functions
142
+ * directory and what its imports lead to, `functionsReach`), copied whole
143
+ * so a monorepo function's `../../packages/…` resolves at bundle time too.
140
144
  * One synthetic entrypoint imports every function, so the whole set is one
141
145
  * graph rather than one per function. Bundling is best-effort: a function that
142
146
  * fails to bundle (a type error, an import that only resolves at run time)
@@ -146,13 +150,13 @@ function providerCredentials(providerName, declared, env) {
146
150
  function functionsDockerfile(fnRelPath) {
147
151
  return `FROM ${IMAGES.functions}
148
152
  ENV DENO_DIR=${DENO_DIR}
149
- COPY ${fnRelPath} /warm/functions
153
+ COPY . /warm/repo
150
154
  RUN set -eu; \
151
155
  : > /warm/all.ts; \
152
- for d in /warm/functions/*/; do \
156
+ for d in /warm/repo/${fnRelPath}/*/; do \
153
157
  [ -f "$d/index.ts" ] || continue; \
154
158
  n=$(basename "$d"); \
155
- printf 'import "./functions/%s/index.ts";\\n' "$n" >> /warm/all.ts; \
159
+ printf 'import "./repo/${fnRelPath}/%s/index.ts";\\n' "$n" >> /warm/all.ts; \
156
160
  done; \
157
161
  edge-runtime bundle --entrypoint /warm/all.ts --output /tmp/warm.eszip --quiet || true; \
158
162
  rm -rf /tmp/warm.eszip /warm
@@ -1247,6 +1251,10 @@ export function supabase(opts = {}) {
1247
1251
  ? `${REPO_MOUNT}/${fnDir.slice(root.length + 1)}`
1248
1252
  : `${REPO_MOUNT}/supabase/functions`;
1249
1253
  const migrationsPath = inDir("migrations");
1254
+ // The directories the functions can load modules from: the functions
1255
+ // directory and what its imports lead to (`supabase-functions-reach.ts`).
1256
+ // The build context and the reach are narrowed to exactly these.
1257
+ const fnReachDirs = withFunctions ? functionsReach(root, fnRelPath) : [];
1250
1258
  // ── config file (config.toml / config.json) ───────────────────────────
1251
1259
  // The CLI's own project config, mapped onto the stack. Read at build time:
1252
1260
  // the values become container config, so they must be in hand before the
@@ -1415,8 +1423,20 @@ export function supabase(opts = {}) {
1415
1423
  const realtimeHost = `realtime-dev.${g.key("realtime")}`;
1416
1424
  const parts = {};
1417
1425
  // ── db ──────────────────────────────────────────────────────────
1426
+ // What the stack reads from the project: the Supabase folder
1427
+ // (config.toml, migrations, seed, functions) and the mail templates,
1428
+ // which may sit anywhere in the repo. Declared as inputs so a change
1429
+ // to any of them is never taken as one that can affect no test; the
1430
+ // migrations volume carries the folder too, but only when there is
1431
+ // a migrations directory, and a config-only project must count as
1432
+ // well.
1433
+ const inputs = [
1434
+ ...(opts.dir !== undefined && !opts.dir.startsWith("/") ? [opts.dir] : []),
1435
+ ...templateBodies.map(([, spec]) => spec.path).filter((t) => !t.startsWith("/")),
1436
+ ];
1418
1437
  const dbDef = {
1419
1438
  image: { type: "registry", reference: opts.db?.image ?? IMAGES.db },
1439
+ ...(inputs.length > 0 ? { inputs } : {}),
1420
1440
  // `args` (not `command`): keep the image's docker-entrypoint.sh so it
1421
1441
  // runs initdb + its baked init-scripts before starting.
1422
1442
  args: [
@@ -1721,6 +1741,12 @@ export function supabase(opts = {}) {
1721
1741
  image: {
1722
1742
  type: "dockerfile",
1723
1743
  content: functionsDockerfile(fnRelPath),
1744
+ // The root, narrowed to the module graph: only the functions
1745
+ // directory and what its imports lead to enter the context, so
1746
+ // a change anywhere else neither uploads nor counts as reaching
1747
+ // the functions.
1748
+ context: ".",
1749
+ exclude: excludeAllBut(fnReachDirs),
1724
1750
  },
1725
1751
  // The **project root**, mounted with the functions directory in its
1726
1752
  // real position underneath. Mounting only `supabase/functions`
@@ -1729,8 +1755,10 @@ export function supabase(opts = {}) {
1729
1755
  // (`../../../../packages/…`), and that only resolves if the
1730
1756
  // directory sits where it sits in the repo. Read-only: the runtime
1731
1757
  // only reads sources, and a function writing into its own directory
1732
- // would be writing into the user's checkout.
1733
- volumes: [{ source: projectRootPath(), target: REPO_MOUNT, readOnly: true }],
1758
+ // would be writing into the user's checkout. For the reach the
1759
+ // mount is exempt: what the functions can load is the module graph
1760
+ // above, which the build context already states.
1761
+ volumes: [{ source: projectRootPath(), target: REPO_MOUNT, readOnly: true, reachExempt: true }],
1734
1762
  // Our router (see FUNCTIONS_MAIN_TS) sits outside that mount, so
1735
1763
  // the project never has to carry a `main/` function of ours.
1736
1764
  files: [{ path: "/home/deno/main/index.ts", content: FUNCTIONS_MAIN_TS }],
package/dist/daemon.js CHANGED
@@ -28,8 +28,9 @@ import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCover
28
28
  import { configureBrowserCoverage } from "./browser-coverage.js";
29
29
  import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, } from "./coverage.js";
30
30
  import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
31
- import { acquirePersistentBrowser, mobileKey } from "./browser.js";
31
+ import { acquirePersistentBrowser, acquirePersistentDesktop, mobileKey } from "./browser.js";
32
32
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
33
+ import { isDesktopApp } from "./desktop.js";
33
34
  // Pure ingress hostname matching, ported out of this file (see
34
35
  // harness/hostmatch.ts). Keeping ONE implementation is the point: the
35
36
  // exact-then-longest-suffix rule and the one-label certificate rule are
@@ -3585,7 +3586,8 @@ async function computeProjectReachInner(all) {
3585
3586
  for (const svc of all) {
3586
3587
  const src = {
3587
3588
  service: svc.name,
3588
- volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
3589
+ volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
3590
+ ...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
3589
3591
  };
3590
3592
  if (svc.image.type === "dockerfile") {
3591
3593
  try {
@@ -3753,6 +3755,7 @@ function newBrowserSession(testStart, idScope, frame = "browser", artifacts, nam
3753
3755
  record,
3754
3756
  recorder: {
3755
3757
  sessionId: record.sessionId,
3758
+ frame,
3756
3759
  ...(name ? { sessionName: name } : {}),
3757
3760
  recordStep(step) {
3758
3761
  if (closed)
@@ -3777,6 +3780,33 @@ function newBrowserSession(testStart, idScope, frame = "browser", artifacts, nam
3777
3780
  },
3778
3781
  };
3779
3782
  }
3783
+ /** Same desktop recording path for test runs and interactive evals. */
3784
+ function desktopOpener(start, scope, sessions, detaches, artifacts) {
3785
+ const memo = new Map();
3786
+ return async (app, options = {}) => {
3787
+ if (!isDesktopApp(app))
3788
+ throw new Error("ctx.desktop(app): pass ctx.svc.app from an electron() service");
3789
+ const window = options.window ?? 0;
3790
+ const key = JSON.stringify([app.endpoint, window]);
3791
+ const existing = memo.get(key);
3792
+ if (existing)
3793
+ return existing;
3794
+ const session = newBrowserSession(start, scope, "desktop", artifacts, window === 0 ? app.name : `${app.name} · window ${window + 1}`);
3795
+ const { browser, detach, desktopChrome } = await acquirePersistentDesktop(app.endpoint, window, session.recorder);
3796
+ session.record.desktopChrome = desktopChrome;
3797
+ session.record.initialUrl = browser.url();
3798
+ sessions.push(session);
3799
+ detaches.push(async () => { await detach(); session.markClosed(); });
3800
+ const close = browser.close.bind(browser);
3801
+ browser.close = async () => {
3802
+ await close();
3803
+ if (memo.get(key) === browser)
3804
+ memo.delete(key);
3805
+ };
3806
+ memo.set(key, browser);
3807
+ return browser;
3808
+ };
3809
+ }
3780
3810
  /** New terminal session bookkeeping for a single `ctx.terminal(...)` call. */
3781
3811
  function newTerminalSession(testStart, service, command, cols, rows, idScope) {
3782
3812
  const record = {
@@ -4648,6 +4678,7 @@ async function runOne(testCase) {
4648
4678
  openTerminal: recordedOpenTerminal,
4649
4679
  browser: trackedOpenBrowser,
4650
4680
  mobile: trackedOpenMobile,
4681
+ desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
4651
4682
  mcp: openMcp,
4652
4683
  testName: testCase.name,
4653
4684
  parent,
@@ -5132,6 +5163,7 @@ async function evalCode(code, secrets) {
5132
5163
  openTerminal: evalOpenTerminal,
5133
5164
  browser: trackedOpenBrowser,
5134
5165
  mobile: trackedOpenMobile,
5166
+ desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
5135
5167
  mcp: openMcp,
5136
5168
  testName: "eval",
5137
5169
  parent: undefined,
@@ -0,0 +1 @@
1
+ export declare const DESKTOP_INPUT_SYNC_SCRIPT: string;
@@ -0,0 +1,30 @@
1
+ // A late recorder cannot interpose on setters React cached during mounting.
2
+ // Reconcile controls at action boundaries, without changing the app's DOM or
3
+ // descriptors. Normal rrweb Input events win; synthesize only missing changes.
4
+ export const DESKTOP_INPUT_SYNC_SCRIPT = String.raw `(() => {
5
+ if (window.__spectestSyncInputs) return;
6
+ const known = new WeakMap();
7
+ window.__spectestSyncInputs = (timestamp = Date.now()) => {
8
+ const rec = window.__spectestRec;
9
+ const events = window.__spectestRrwebEvents;
10
+ if (!rec?.mirror || !events) return;
11
+ const inputs = new Map();
12
+ for (const event of events) {
13
+ if (event.type === 3 && event.data?.source === 5) inputs.set(event.data.id, event.data);
14
+ }
15
+ for (const element of document.querySelectorAll('input, textarea, select')) {
16
+ const type = element.hasAttribute('data-rr-is-password') ? 'password' : element.type?.toLowerCase();
17
+ if (type === 'file' || element.closest('.rr-block, .rr-ignore')) continue;
18
+ const id = rec.mirror.getId(element);
19
+ if (!(id > 0)) continue;
20
+ const text = type === 'password' ? '*'.repeat(element.value.length) : element.value;
21
+ const isChecked = (type === 'checkbox' || type === 'radio') ? element.checked : false;
22
+ const current = { text, isChecked };
23
+ const previous = inputs.get(id) ?? known.get(element);
24
+ known.set(element, current);
25
+ if (!previous || (previous.text === text && previous.isChecked === isChecked)) continue;
26
+ events.push({ type: 3, data: { source: 5, id, text, isChecked }, timestamp });
27
+ }
28
+ };
29
+ window.__spectestSyncInputs();
30
+ })();`;
@@ -0,0 +1,42 @@
1
+ import type { Browser } from "./browser.js";
2
+ export declare const DESKTOP_APP: unique symbol;
3
+ /** An Electron service handle produced by electron(). */
4
+ export interface DesktopApp {
5
+ readonly [DESKTOP_APP]: true;
6
+ readonly endpoint: string;
7
+ readonly name: string;
8
+ }
9
+ /** Real renderer window: user input, locators, assertions, and replay capture.
10
+ * close() closes this window only. Native OS dialogs are outside DOM replay. */
11
+ export interface Desktop extends Browser {
12
+ }
13
+ /** Observed window configuration at the start of a replay session. */
14
+ export interface DesktopChrome {
15
+ titleBarStyle: string;
16
+ frame: boolean;
17
+ trafficLightPosition: {
18
+ x: number;
19
+ y: number;
20
+ } | null;
21
+ titleBarOverlay: boolean | {
22
+ height?: number;
23
+ color?: string;
24
+ symbolColor?: string;
25
+ };
26
+ roundedCorners: boolean;
27
+ buttonVisibility: boolean | null;
28
+ titlebarArea?: {
29
+ x: number;
30
+ y: number;
31
+ width: number;
32
+ height: number;
33
+ };
34
+ }
35
+ export interface DesktopOptions {
36
+ /** Zero-based window creation order. Defaults to the first window.
37
+ * Open additional windows using the app UI before selecting them.
38
+ * A closed window keeps its index; other windows never change identity. */
39
+ window?: number;
40
+ }
41
+ export declare function desktopApp(endpoint: string, name: string): DesktopApp;
42
+ export declare function isDesktopApp(value: unknown): value is DesktopApp;
@@ -0,0 +1,10 @@
1
+ export const DESKTOP_APP = Symbol.for("spectest.desktopApp");
2
+ export function desktopApp(endpoint, name) {
3
+ return { [DESKTOP_APP]: true, endpoint, name };
4
+ }
5
+ export function isDesktopApp(value) {
6
+ return typeof value === "object" && value !== null &&
7
+ value[DESKTOP_APP] === true &&
8
+ typeof value.endpoint === "string" &&
9
+ typeof value.name === "string";
10
+ }
@@ -25,6 +25,10 @@ export interface ReachSource {
25
25
  /** Volume sources, project-relative; an absolute source is not a
26
26
  * project path and is skipped. */
27
27
  volumeSources?: string[];
28
+ /** The service's declared `inputs`: project files or directories it
29
+ * consumes some other way (packaged at setup, read at load). A
30
+ * directory covers its subtree. */
31
+ inputs?: string[];
28
32
  }
29
33
  /**
30
34
  * The project's reach: every project file (`files`, project-relative)
@@ -142,7 +142,7 @@ export function projectReach(files, sources) {
142
142
  if (rel)
143
143
  out.add(rel);
144
144
  }
145
- for (const v of s.volumeSources ?? []) {
145
+ for (const v of [...(s.volumeSources ?? []), ...(s.inputs ?? [])]) {
146
146
  if (v.startsWith("/"))
147
147
  continue;
148
148
  const src = normRel(v);
@@ -236,7 +236,7 @@ export function sourceOf(sources, path) {
236
236
  for (const e of s.extraFiles ?? [])
237
237
  if (normRel(e) === p)
238
238
  return s;
239
- for (const v of s.volumeSources ?? []) {
239
+ for (const v of [...(s.volumeSources ?? []), ...(s.inputs ?? [])]) {
240
240
  if (v.startsWith("/"))
241
241
  continue;
242
242
  const src = normRel(v);
package/dist/index.d.ts CHANGED
@@ -19,6 +19,8 @@ export type { UrlPattern } from "./url-match.js";
19
19
  import type { UrlPattern } from "./url-match.js";
20
20
  export type { Mobile, MobileApp } from "./mobile.js";
21
21
  import type { Mobile, MobileApp } from "./mobile.js";
22
+ export type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
23
+ import type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
22
24
  export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
23
25
  import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
24
26
  export { type CoverageAdapter, type CoverageCaptureContext, type CoverageConfigureInfo, type CoverageLoadContext, type ServiceCoverage, } from "./coverage.js";
@@ -141,6 +143,13 @@ export interface ServiceConfig {
141
143
  tls?: readonly ServiceTls[];
142
144
  /** Bind-mounted volumes for state that survives snapshot/fork. */
143
145
  volumes?: readonly VolumeMount[];
146
+ /**
147
+ * @internal Spectest's own components only: project files or
148
+ * directories the service consumes outside its build context and
149
+ * volumes (a lambda's `source`, Supabase's folder). Project-relative; a
150
+ * directory covers its subtree. The reach counts them.
151
+ */
152
+ inputs?: readonly string[];
144
153
  /**
145
154
  * Opt this service in to **code coverage collection** (experimental).
146
155
  *
@@ -841,6 +850,13 @@ export interface VolumeMount {
841
850
  * so unlike a volume directory it is never created or wiped by spectest.
842
851
  */
843
852
  source?: string;
853
+ /**
854
+ * @internal Spectest's own components only. The mount does not count
855
+ * for the project's reach: what the service can load from it is stated
856
+ * some other way (a narrowed build context, `inputs`). Never set this
857
+ * for a mount the service reads freely.
858
+ */
859
+ reachExempt?: boolean;
844
860
  /** Container path. */
845
861
  target: string;
846
862
  readOnly?: boolean;
@@ -1295,6 +1311,16 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
1295
1311
  * like named desktop browsers.
1296
1312
  */
1297
1313
  mobile(app: MobileApp, name?: string): Promise<Mobile>;
1314
+ /** Drive a real Electron renderer window through locators and user input.
1315
+ * Acquires the app started by electron(); keeps its live state across forks.
1316
+ * Each test gets a self-contained graphical replay. Selecting another window
1317
+ * does not create a new app or isolate its storage from sibling windows.
1318
+ *
1319
+ * const app = await ctx.desktop(ctx.svc.app);
1320
+ * await app.getByRole("textbox", { name: "New note" }).fill("Hello");
1321
+ * await app.getByRole("button", { name: "Add note" }).click();
1322
+ */
1323
+ desktop(app: DesktopApp, options?: DesktopOptions): Promise<Desktop>;
1298
1324
  /** The test's display name. */
1299
1325
  readonly testName: string;
1300
1326
  /**
package/dist/index.js CHANGED
@@ -322,6 +322,11 @@ function validateEnvironmentConfig(config) {
322
322
  }
323
323
  }
324
324
  validateCoverage(name, svc.coverage);
325
+ for (const input of svc.inputs ?? []) {
326
+ if (typeof input !== "string" || input.trim() === "" || input.startsWith("/") || input.split("/").includes("..")) {
327
+ throw new Error(`service "${name}" has an invalid \`inputs\` entry ${JSON.stringify(input)} — a project-relative file or directory path`);
328
+ }
329
+ }
325
330
  for (const raw of svc.hostnames ?? []) {
326
331
  const h = raw.toLowerCase();
327
332
  if (!HOSTNAME_RE.test(hostPatternBody(h))) {
@@ -464,6 +464,8 @@ export interface BrowserEvent extends BaseEvent {
464
464
  * bundle to look the session up in.
465
465
  */
466
466
  sessionName?: string;
467
+ /** Presentation kind; Electron and mobile share the browser event transport. */
468
+ frame?: "browser" | "mobile" | "desktop";
467
469
  /**
468
470
  * Wall-clock `Date.now()` captured at the moment this op *finished*.
469
471
  * Lives in the same time base as rrweb's `event.timestamp` fields,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.87.0",
3
+ "version": "0.88.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/browser.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  // Headless browser handle for tests. Thin wrapper around playwright-core
2
2
  // driving the guest's system Chromium (pipe transport — `chromium.launch`
3
- // with an explicit `executablePath`; `connectOverCDP` is off-limits, its
4
- // bundled `ws` client hangs under Bun). The wrapper keeps the public
5
- // surface stable (we swapped from Bun.WebView to Playwright without
3
+ // with an explicit `executablePath`). Electron attaches to the app-owned
4
+ // Chromium over CDP instead; its client also lives inside the VM. The wrapper
5
+ // keeps the public surface stable (we swapped from Bun.WebView to Playwright without
6
6
  // changing tests) and routes every call through the recorder so browser
7
7
  // actions show up in a test's event log alongside exec/fetch/assertion
8
8
  // events. Playwright's client state (browser/context/page objects, the
@@ -34,6 +34,9 @@ import path from "node:path";
34
34
  import { fileURLToPath } from "node:url";
35
35
 
36
36
  import { generateId } from "./ids.js";
37
+ import { rawFetch } from "./harness/raw-fetch.js";
38
+ import { DESKTOP_INPUT_SYNC_SCRIPT } from "./desktop-recorder.js";
39
+ import type { DesktopChrome } from "./desktop.js";
37
40
  import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
38
41
  import { wrap } from "./inspect.js";
39
42
  import type { Wrapped } from "./inspect.js";
@@ -150,6 +153,7 @@ export interface BrowserSessionRecorder {
150
153
  * from the persisted events alone, with no replay bundle).
151
154
  */
152
155
  readonly sessionName?: string;
156
+ readonly frame?: "browser" | "mobile" | "desktop";
153
157
  /** Called for each drained chunk of rrweb events. */
154
158
  recordStep(step: BrowserSessionStep): void;
155
159
  /** Optional: called whenever `Browser.goto(url)` is invoked. */
@@ -1144,7 +1148,7 @@ const PAGE_INIT_SCRIPT = RRWEB_BUNDLE
1144
1148
  // snapshot keeps steady-state steps (clicks/types on an unchanged page,
1145
1149
  // or navigations where `load` already fired) from bloating the stream
1146
1150
  // with redundant snapshots.
1147
- function drainExpr(forceFullIfMissing: boolean): string {
1151
+ function drainExpr(forceFullIfMissing: boolean, timestamp = Date.now()): string {
1148
1152
  return `(function () {
1149
1153
  try {
1150
1154
  var rec = window.__spectestRec;
@@ -1159,6 +1163,9 @@ function drainExpr(forceFullIfMissing: boolean): string {
1159
1163
  if (!hasFull) rec.takeFullSnapshot();
1160
1164
  }
1161
1165
  } catch (e) { /* recording inactive or DOM detached — drain what's there. */ }
1166
+ if (typeof window.__spectestSyncInputs === "function") {
1167
+ window.__spectestSyncInputs(${timestamp});
1168
+ }
1162
1169
  var buf = window.__spectestRrwebEvents;
1163
1170
  if (!buf || !buf.length) return [];
1164
1171
  window.__spectestRrwebEvents = [];
@@ -1551,6 +1558,92 @@ export async function acquirePersistentMobileBackend(
1551
1558
  return { browser: backend, attached: existing !== undefined, detach };
1552
1559
  }
1553
1560
 
1561
+ /** Live CDP clients and window identities ride the harness snapshot. */
1562
+ const ELECTRON_APPS = new Map<string, Promise<{
1563
+ browser: PlaywrightBrowser;
1564
+ context: BrowserContext;
1565
+ windows: Page[];
1566
+ holders: Map<Page, ViewHolder>;
1567
+ }>>();
1568
+
1569
+ /** Attach the shared locator/recorder backend to an actual Electron window.
1570
+ * Never navigate, reload, emulate a viewport, or replace its renderer. */
1571
+ export async function acquirePersistentDesktop(
1572
+ endpoint: string,
1573
+ windowIndex: number,
1574
+ recorder: BrowserSessionRecorder | null,
1575
+ ): Promise<PersistentBrowser & { desktopChrome?: DesktopChrome }> {
1576
+ if (!Number.isInteger(windowIndex) || windowIndex < 0) {
1577
+ throw new Error("ctx.desktop(): window must be a non-negative integer (creation order)");
1578
+ }
1579
+ let pending = ELECTRON_APPS.get(endpoint);
1580
+ if (!pending) {
1581
+ pending = (async () => {
1582
+ const response = await rawFetch(new URL("/json/version", endpoint), {
1583
+ signal: AbortSignal.timeout(15_000),
1584
+ });
1585
+ if (!response.ok) throw new Error(`Electron debugger discovery failed: HTTP ${response.status}`);
1586
+ const info = await response.json() as { webSocketDebuggerUrl: string };
1587
+ const websocket = new URL(info.webSocketDebuggerUrl);
1588
+ websocket.host = new URL(endpoint).host;
1589
+ const browser = await chromium.connectOverCDP(websocket.toString(), { timeout: 15_000 });
1590
+ const context = browser.contexts()[0];
1591
+ if (!context) { await browser.close(); throw new Error("Electron has no renderer context"); }
1592
+ const windows = context.pages().filter(page => !page.url().startsWith("devtools:"));
1593
+ context.on("page", page => { if (!page.url().startsWith("devtools:")) windows.push(page); });
1594
+ browser.on("disconnected", () => { ELECTRON_APPS.delete(endpoint); });
1595
+ return { browser, context, windows, holders: new Map<Page, ViewHolder>() };
1596
+ })();
1597
+ ELECTRON_APPS.set(endpoint, pending);
1598
+ pending.catch(() => { if (ELECTRON_APPS.get(endpoint) === pending) ELECTRON_APPS.delete(endpoint); });
1599
+ }
1600
+ const app = await pending;
1601
+ const deadline = Date.now() + 15_000;
1602
+ while (!app.windows[windowIndex]) {
1603
+ const remaining = deadline - Date.now();
1604
+ if (remaining <= 0) throw new Error(`Electron window ${windowIndex} did not open`);
1605
+ await app.context.waitForEvent("page", { timeout: remaining });
1606
+ }
1607
+ const page = app.windows[windowIndex];
1608
+ if (page.isClosed()) throw new Error(`Electron window ${windowIndex} is closed; window indices retain creation order`);
1609
+ await page.waitForLoadState("domcontentloaded");
1610
+ // The main-process observer publishes only window presentation options.
1611
+ // A popup created internally by Chromium might not pass through BrowserWindow.
1612
+ await page.waitForFunction(() => Boolean((window as any).__spectestElectronChrome), null, { timeout: 3000 }).catch(() => {});
1613
+ const desktopChrome = await page.evaluate(() => {
1614
+ const chrome = (window as any).__spectestElectronChrome;
1615
+ if (!chrome) return undefined;
1616
+ const overlay = (navigator as any).windowControlsOverlay;
1617
+ return { ...chrome, ...(overlay?.visible ? { titlebarArea: overlay.getTitlebarAreaRect().toJSON() } : {}) };
1618
+ }) as DesktopChrome | undefined;
1619
+ let holder = app.holders.get(page);
1620
+ const attached = holder !== undefined;
1621
+ if (!holder) {
1622
+ const cdp = await app.context.newCDPSession(page);
1623
+ await cdp.send("Page.enable");
1624
+ if (!PAGE_INIT_SCRIPT) throw new Error("Electron replay recorder bundle is missing");
1625
+ const init = `${PAGE_INIT_SCRIPT};\n${DESKTOP_INPUT_SYNC_SCRIPT}`;
1626
+ await cdp.send("Page.addScriptToEvaluateOnNewDocument", { source: init });
1627
+ // The app owns navigation and may already have loaded. Start on its live
1628
+ // DOM, without losing drafts or replaying any application startup effects.
1629
+ await page.evaluate(init);
1630
+ const size = await page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
1631
+ holder = {
1632
+ context: app.context, page, cdp, lastTitle: await page.title(),
1633
+ recordingInstalled: true, device: null, width: size.width, height: size.height,
1634
+ safeAreaInsets: null, initScripts: [],
1635
+ };
1636
+ app.holders.set(page, holder);
1637
+ page.on("close", () => { app.holders.delete(page); });
1638
+ }
1639
+ await attachReset(holder);
1640
+ const { backend, detach } = buildBackend(holder, recorder, {
1641
+ persistent: false, ownsContext: false,
1642
+ onDestroy: () => { app.holders.delete(page); },
1643
+ });
1644
+ return { browser: backend, attached, detach, desktopChrome };
1645
+ }
1646
+
1554
1647
  /** True when a navigation failed on Chromium name resolution. */
1555
1648
  function isNameNotResolved(err: unknown): boolean {
1556
1649
  return String((err as Error)?.message ?? err).includes("ERR_NAME_NOT_RESOLVED");
@@ -1617,6 +1710,8 @@ interface BackendBuildOptions {
1617
1710
  /** Called when close() destroys the underlying view — the acquire
1618
1711
  * functions use it to drop the holder from the shared registry. */
1619
1712
  onDestroy?: () => void;
1713
+ /** Electron windows share an app context; closing one must leave siblings alive. */
1714
+ ownsContext?: boolean;
1620
1715
  }
1621
1716
 
1622
1717
  function buildBackend(
@@ -1678,15 +1773,16 @@ function buildBackend(
1678
1773
  return {
1679
1774
  sessionId: recorder.sessionId,
1680
1775
  sessionTimestamp: endT,
1776
+ ...(recorder.frame ? { frame: recorder.frame } : {}),
1681
1777
  ...(recorder.sessionName ? { sessionName: recorder.sessionName } : {}),
1682
1778
  };
1683
1779
  }
1684
1780
 
1685
- async function drain(action: BrowserAction | "close"): Promise<void> {
1781
+ async function drain(action: BrowserAction | "close", timestamp = Date.now()): Promise<void> {
1686
1782
  if (!holder.recordingInstalled || !recorder || recordingEnded) return;
1687
1783
  try {
1688
1784
  const urlChanged = holder.page.url() !== lastDrainUrl;
1689
- const events = (await holder.page.evaluate(drainExpr(urlChanged))) as unknown[];
1785
+ const events = (await holder.page.evaluate(drainExpr(urlChanged, timestamp))) as unknown[];
1690
1786
  lastDrainUrl = holder.page.url();
1691
1787
  if (Array.isArray(events) && events.length > 0) {
1692
1788
  recorder.recordStep({
@@ -1731,7 +1827,7 @@ function buildBackend(
1731
1827
  ...sessionFields(endT),
1732
1828
  durationMs: endT - t,
1733
1829
  }, resv);
1734
- await drain(action);
1830
+ await drain(action, endT);
1735
1831
  // Fold this page's V8 counters into the per-service totals now — a
1736
1832
  // navigation to another origin replaces the renderer and its
1737
1833
  // counters, so harvesting per op is what keeps them.
@@ -1766,7 +1862,7 @@ function buildBackend(
1766
1862
  }, resv);
1767
1863
  // Still try to drain — the failure itself may have produced
1768
1864
  // useful rrweb events (mutations from a half-loaded page, etc.).
1769
- await drain(action);
1865
+ await drain(action, endT);
1770
1866
  throw err;
1771
1867
  }
1772
1868
  }
@@ -2067,7 +2163,7 @@ function buildBackend(
2067
2163
  // The context, not the page, holds the cookies and storage — closing
2068
2164
  // only the page would leave the next session signed in as this one.
2069
2165
  try {
2070
- await holder.context.close();
2166
+ if (buildOpts.ownsContext !== false) await holder.context.close();
2071
2167
  } catch {
2072
2168
  /* context already gone (browser died) */
2073
2169
  }
@@ -2149,7 +2245,7 @@ function buildBackend(
2149
2245
  }, reserveBackdated(waitedMs));
2150
2246
  // Drain the rrweb the page buffered while the matcher waited into this
2151
2247
  // step's chunk, so `settledTarget` has bounds to seek into.
2152
- await drain(action);
2248
+ await drain(action, endT);
2153
2249
  return seq;
2154
2250
  },
2155
2251
  pageOp<T>(
@@ -249,6 +249,12 @@ export function aws(opts: AwsOptions = {}) {
249
249
  LAMBDA_DOCKER_NETWORK: SPECTEST_NETWORK,
250
250
  },
251
251
  volumes: [{ source: DOCKER_SOCKET, target: DOCKER_SOCKET }],
252
+ // Each lambda's code is packaged from the project at setup: no build
253
+ // context or volume carries it, so it is declared as an input and a
254
+ // change to it is never taken as one that can affect no test.
255
+ ...(Object.keys(lambdas).length > 0
256
+ ? { inputs: Object.values(lambdas).map((l) => l.source).filter((s) => typeof s === "string" && s.trim() !== "") }
257
+ : {}),
252
258
  ports: [AWS_PORT],
253
259
  readyCheck: {
254
260
  type: "http" as const,