@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
package/src/daemon.ts CHANGED
@@ -67,8 +67,9 @@ import {
67
67
  type CoverageInventoryContext,
68
68
  } from "./coverage.js";
69
69
  import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
70
- import { acquirePersistentBrowser, mobileKey } from "./browser.js";
70
+ import { acquirePersistentBrowser, acquirePersistentDesktop, mobileKey } from "./browser.js";
71
71
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
72
+ import { isDesktopApp, type Desktop, type DesktopApp, type DesktopOptions, type DesktopChrome } from "./desktop.js";
72
73
  // Pure ingress hostname matching, ported out of this file (see
73
74
  // harness/hostmatch.ts). Keeping ONE implementation is the point: the
74
75
  // exact-then-longest-suffix rule and the one-label certificate rule are
@@ -4255,7 +4256,8 @@ async function computeProjectReachInner(all: NamedService[]): Promise<ReachRules
4255
4256
  for (const svc of all) {
4256
4257
  const src: ReachSource = {
4257
4258
  service: svc.name,
4258
- volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
4259
+ volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
4260
+ ...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
4259
4261
  };
4260
4262
  if (svc.image.type === "dockerfile") {
4261
4263
  try {
@@ -4352,10 +4354,11 @@ interface BrowserSessionRecord {
4352
4354
  openedAtMs: number;
4353
4355
  closedAtMs?: number;
4354
4356
  initialUrl?: string;
4355
- /** Replay frame: `"browser"` (desktop window, default) or `"mobile"`
4356
- * (phone bezel). Drives the dashboard's chrome; the rrweb events are
4357
+ /** Replay frame: `"browser"` (default), `"mobile"` (phone bezel), or
4358
+ * `"desktop"` (native app window). Drives the dashboard's chrome; the rrweb events are
4357
4359
  * identical either way. */
4358
- frame?: "browser" | "mobile";
4360
+ frame?: "browser" | "mobile" | "desktop";
4361
+ desktopChrome?: DesktopChrome;
4359
4362
  /** Safe-area insets that were CDP-emulated during capture (mobile
4360
4363
  * sessions on a Chromium that supports the override). The dashboard
4361
4364
  * substitutes these for `env(safe-area-inset-*)` in the replayed CSS —
@@ -4463,7 +4466,7 @@ function browserArgs(
4463
4466
  function newBrowserSession(
4464
4467
  testStart: number,
4465
4468
  idScope: string,
4466
- frame: "browser" | "mobile" = "browser",
4469
+ frame: "browser" | "mobile" | "desktop" = "browser",
4467
4470
  artifacts?: ReturnType<typeof newArtifactCollector>,
4468
4471
  name = "",
4469
4472
  ): {
@@ -4486,6 +4489,7 @@ function newBrowserSession(
4486
4489
  record,
4487
4490
  recorder: {
4488
4491
  sessionId: record.sessionId,
4492
+ frame,
4489
4493
  ...(name ? { sessionName: name } : {}),
4490
4494
  recordStep(step) {
4491
4495
  if (closed) return;
@@ -4507,6 +4511,37 @@ function newBrowserSession(
4507
4511
  };
4508
4512
  }
4509
4513
 
4514
+ /** Same desktop recording path for test runs and interactive evals. */
4515
+ function desktopOpener(
4516
+ start: number,
4517
+ scope: string,
4518
+ sessions: Array<ReturnType<typeof newBrowserSession>>,
4519
+ detaches: Array<() => Promise<void>>,
4520
+ artifacts?: ReturnType<typeof newArtifactCollector>,
4521
+ ) {
4522
+ const memo = new Map<string, Desktop>();
4523
+ return async (app: DesktopApp, options: DesktopOptions = {}): Promise<Desktop> => {
4524
+ if (!isDesktopApp(app)) throw new Error("ctx.desktop(app): pass ctx.svc.app from an electron() service");
4525
+ const window = options.window ?? 0;
4526
+ const key = JSON.stringify([app.endpoint, window]);
4527
+ const existing = memo.get(key);
4528
+ if (existing) return existing;
4529
+ const session = newBrowserSession(start, scope, "desktop", artifacts, window === 0 ? app.name : `${app.name} · window ${window + 1}`);
4530
+ const { browser, detach, desktopChrome } = await acquirePersistentDesktop(app.endpoint, window, session.recorder);
4531
+ session.record.desktopChrome = desktopChrome;
4532
+ session.record.initialUrl = browser.url();
4533
+ sessions.push(session);
4534
+ detaches.push(async () => { await detach(); session.markClosed(); });
4535
+ const close = browser.close.bind(browser);
4536
+ browser.close = async () => {
4537
+ await close();
4538
+ if (memo.get(key) === browser) memo.delete(key);
4539
+ };
4540
+ memo.set(key, browser);
4541
+ return browser;
4542
+ };
4543
+ }
4544
+
4510
4545
  /** Per-Terminal asciicast session shipped to the control plane. */
4511
4546
  interface TerminalSessionRecord {
4512
4547
  sessionId: string;
@@ -5599,6 +5634,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5599
5634
  openTerminal: recordedOpenTerminal,
5600
5635
  browser: trackedOpenBrowser,
5601
5636
  mobile: trackedOpenMobile,
5637
+ desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
5602
5638
  mcp: openMcp,
5603
5639
  testName: testCase.name,
5604
5640
  parent,
@@ -6154,6 +6190,7 @@ async function evalCode(
6154
6190
  openTerminal: evalOpenTerminal,
6155
6191
  browser: trackedOpenBrowser,
6156
6192
  mobile: trackedOpenMobile,
6193
+ desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
6157
6194
  mcp: openMcp,
6158
6195
  testName: "eval",
6159
6196
  parent: undefined,
@@ -0,0 +1,55 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { runInNewContext } from 'node:vm';
3
+ import { DESKTOP_INPUT_SYNC_SCRIPT } from './desktop-recorder.js';
4
+
5
+ function recorder(type = 'textarea', blocked = false) {
6
+ const element = { type, value: 'draft', checked: false, hasAttribute: (_name: string) => false, closest: () => blocked ? {} : null };
7
+ const window: any = { __spectestRec: { mirror: { getId: () => 7 } }, __spectestRrwebEvents: [] };
8
+ runInNewContext(DESKTOP_INPUT_SYNC_SCRIPT, { window, document: { querySelectorAll: () => [element] } });
9
+ return { element, window, events: window.__spectestRrwebEvents as any[] };
10
+ }
11
+
12
+ test('late attachment captures a controlled input clearing without a DOM input event', () => {
13
+ const { element, window, events } = recorder();
14
+ expect(events).toHaveLength(0);
15
+ element.value = '';
16
+ window.__spectestSyncInputs(123);
17
+ expect(JSON.parse(JSON.stringify(events))).toEqual([
18
+ { type: 3, data: { source: 5, id: 7, text: '', isChecked: false }, timestamp: 123 },
19
+ ]);
20
+ window.__spectestSyncInputs(124);
21
+ expect(events).toHaveLength(1);
22
+ });
23
+
24
+ test('rrweb input events need no duplicate correction; a snapshot does not hide later changes', () => {
25
+ const { element, window, events } = recorder();
26
+ element.value = 'typed';
27
+ events.push({ type: 3, data: { source: 5, id: 7, text: 'typed', isChecked: false } });
28
+ window.__spectestSyncInputs();
29
+ expect(events).toHaveLength(1);
30
+ events.length = 0;
31
+ events.push({ type: 2, data: {} });
32
+ element.value = '';
33
+ window.__spectestSyncInputs();
34
+ expect(events).toHaveLength(2);
35
+ expect(events[1].data.text).toBe('');
36
+ });
37
+
38
+ test('correction respects password masking, blocked controls and file inputs', () => {
39
+ const password = recorder('password');
40
+ password.element.value = 'secret-value';
41
+ password.window.__spectestSyncInputs();
42
+ expect(password.events[0].data.text).toBe('************');
43
+ expect(JSON.stringify(password.events)).not.toContain('secret-value');
44
+ const revealed = recorder('text');
45
+ revealed.element.hasAttribute = name => name === 'data-rr-is-password';
46
+ revealed.element.value = 'revealed-secret';
47
+ revealed.window.__spectestSyncInputs();
48
+ expect(revealed.events[0].data.text).toBe('***************');
49
+ expect(JSON.stringify(revealed.events)).not.toContain('revealed-secret');
50
+ for (const fixture of [recorder('textarea', true), recorder('file')]) {
51
+ fixture.element.value = 'private';
52
+ fixture.window.__spectestSyncInputs();
53
+ expect(fixture.events).toHaveLength(0);
54
+ }
55
+ });
@@ -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
+ })();`;
package/src/desktop.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { Browser } from "./browser.js";
2
+
3
+ export const DESKTOP_APP: unique symbol = Symbol.for("spectest.desktopApp");
4
+
5
+ /** An Electron service handle produced by electron(). */
6
+ export interface DesktopApp {
7
+ readonly [DESKTOP_APP]: true;
8
+ readonly endpoint: string;
9
+ readonly name: string;
10
+ }
11
+
12
+ /** Real renderer window: user input, locators, assertions, and replay capture.
13
+ * close() closes this window only. Native OS dialogs are outside DOM replay. */
14
+ export interface Desktop extends Browser {}
15
+
16
+ /** Observed window configuration at the start of a replay session. */
17
+ export interface DesktopChrome {
18
+ titleBarStyle: string;
19
+ frame: boolean;
20
+ trafficLightPosition: { x: number; y: number } | null;
21
+ titleBarOverlay: boolean | { height?: number; color?: string; symbolColor?: string };
22
+ roundedCorners: boolean;
23
+ buttonVisibility: boolean | null;
24
+ titlebarArea?: { x: number; y: number; width: number; height: number };
25
+ }
26
+
27
+ export interface DesktopOptions {
28
+ /** Zero-based window creation order. Defaults to the first window.
29
+ * Open additional windows using the app UI before selecting them.
30
+ * A closed window keeps its index; other windows never change identity. */
31
+ window?: number;
32
+ }
33
+
34
+ export function desktopApp(endpoint: string, name: string): DesktopApp {
35
+ return { [DESKTOP_APP]: true, endpoint, name };
36
+ }
37
+
38
+ export function isDesktopApp(value: unknown): value is DesktopApp {
39
+ return typeof value === "object" && value !== null &&
40
+ (value as DesktopApp)[DESKTOP_APP] === true &&
41
+ typeof (value as DesktopApp).endpoint === "string" &&
42
+ typeof (value as DesktopApp).name === "string";
43
+ }
@@ -128,6 +128,18 @@ describe("reach rules", () => {
128
128
  expect(reachContains({ version: 1, sources: [{ context: "packages/x", ignore: "**" }], prefixes: [] }, "packages/x/Dockerfile")).toBe(true);
129
129
  });
130
130
 
131
+ test("a service's declared inputs count: a lambda's source directory, a config file", () => {
132
+ const srcs = [{ service: "aws", inputs: ["lambda/add", "supabase/config.toml"] }];
133
+ expect(projectReach(["lambda/add/index.js", "lambda/other/index.js", "supabase/config.toml", "supabase/config.toml.bak"], srcs)).toEqual([
134
+ "lambda/add/index.js",
135
+ "supabase/config.toml",
136
+ ]);
137
+ const rules = { version: 1 as const, sources: srcs, prefixes: [] };
138
+ expect(reachContains(rules, "lambda/add/new.js")).toBe(true);
139
+ expect(sourceOf(srcs, "lambda/add/new.js")?.service).toBe("aws");
140
+ expect(reachContains(rules, "lambda/other/index.js")).toBe(false);
141
+ });
142
+
131
143
  test("the whole project as one prefix", () => {
132
144
  expect(reachContains({ version: 1, sources: [], prefixes: ["."] }, "anything/at/all")).toBe(true);
133
145
  expect(reachContains({ version: 1, sources: [], prefixes: [] }, "anything/at/all")).toBe(false);
@@ -125,6 +125,10 @@ export interface ReachSource {
125
125
  /** Volume sources, project-relative; an absolute source is not a
126
126
  * project path and is skipped. */
127
127
  volumeSources?: string[];
128
+ /** The service's declared `inputs`: project files or directories it
129
+ * consumes some other way (packaged at setup, read at load). A
130
+ * directory covers its subtree. */
131
+ inputs?: string[];
128
132
  }
129
133
 
130
134
  function normRel(p: string): string {
@@ -157,7 +161,7 @@ export function projectReach(files: readonly string[], sources: readonly ReachSo
157
161
  const rel = normRel(e);
158
162
  if (rel) out.add(rel);
159
163
  }
160
- for (const v of s.volumeSources ?? []) {
164
+ for (const v of [...(s.volumeSources ?? []), ...(s.inputs ?? [])]) {
161
165
  if (v.startsWith("/")) continue;
162
166
  const src = normRel(v);
163
167
  for (const f of files) {
@@ -252,7 +256,7 @@ export function sourceOf(sources: readonly ReachSource[], path: string): ReachSo
252
256
  }
253
257
  }
254
258
  for (const e of s.extraFiles ?? []) if (normRel(e) === p) return s;
255
- for (const v of s.volumeSources ?? []) {
259
+ for (const v of [...(s.volumeSources ?? []), ...(s.inputs ?? [])]) {
256
260
  if (v.startsWith("/")) continue;
257
261
  const src = normRel(v);
258
262
  if (p === src || p.startsWith(`${src}/`)) return s;
package/src/index.ts CHANGED
@@ -141,6 +141,8 @@ import { describeUrlPattern, matchesUrl } from "./url-match.js";
141
141
  // `expo()` component.
142
142
  export type { Mobile, MobileApp } from "./mobile.js";
143
143
  import type { Mobile, MobileApp } from "./mobile.js";
144
+ export type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
145
+ import type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
144
146
 
145
147
  export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
146
148
 
@@ -308,6 +310,13 @@ export interface ServiceConfig {
308
310
  tls?: readonly ServiceTls[];
309
311
  /** Bind-mounted volumes for state that survives snapshot/fork. */
310
312
  volumes?: readonly VolumeMount[];
313
+ /**
314
+ * @internal Spectest's own components only: project files or
315
+ * directories the service consumes outside its build context and
316
+ * volumes (a lambda's `source`, Supabase's folder). Project-relative; a
317
+ * directory covers its subtree. The reach counts them.
318
+ */
319
+ inputs?: readonly string[];
311
320
  /**
312
321
  * Opt this service in to **code coverage collection** (experimental).
313
322
  *
@@ -1320,6 +1329,13 @@ export interface VolumeMount {
1320
1329
  * so unlike a volume directory it is never created or wiped by spectest.
1321
1330
  */
1322
1331
  source?: string;
1332
+ /**
1333
+ * @internal Spectest's own components only. The mount does not count
1334
+ * for the project's reach: what the service can load from it is stated
1335
+ * some other way (a narrowed build context, `inputs`). Never set this
1336
+ * for a mount the service reads freely.
1337
+ */
1338
+ reachExempt?: boolean;
1323
1339
  /** Container path. */
1324
1340
  target: string;
1325
1341
  readOnly?: boolean;
@@ -1553,6 +1569,13 @@ function validateEnvironmentConfig<S extends ServicesMap>(
1553
1569
  }
1554
1570
  }
1555
1571
  validateCoverage(name, svc.coverage);
1572
+ for (const input of svc.inputs ?? []) {
1573
+ if (typeof input !== "string" || input.trim() === "" || input.startsWith("/") || input.split("/").includes("..")) {
1574
+ throw new Error(
1575
+ `service "${name}" has an invalid \`inputs\` entry ${JSON.stringify(input)} — a project-relative file or directory path`,
1576
+ );
1577
+ }
1578
+ }
1556
1579
  for (const raw of svc.hostnames ?? []) {
1557
1580
  const h = raw.toLowerCase();
1558
1581
  if (!HOSTNAME_RE.test(hostPatternBody(h))) {
@@ -1928,6 +1951,16 @@ export interface TestContext<
1928
1951
  * like named desktop browsers.
1929
1952
  */
1930
1953
  mobile(app: MobileApp, name?: string): Promise<Mobile>;
1954
+ /** Drive a real Electron renderer window through locators and user input.
1955
+ * Acquires the app started by electron(); keeps its live state across forks.
1956
+ * Each test gets a self-contained graphical replay. Selecting another window
1957
+ * does not create a new app or isolate its storage from sibling windows.
1958
+ *
1959
+ * const app = await ctx.desktop(ctx.svc.app);
1960
+ * await app.getByRole("textbox", { name: "New note" }).fill("Hello");
1961
+ * await app.getByRole("button", { name: "Add note" }).click();
1962
+ */
1963
+ desktop(app: DesktopApp, options?: DesktopOptions): Promise<Desktop>;
1931
1964
  /** The test's display name. */
1932
1965
  readonly testName: string;
1933
1966
  /**
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineEnvironment } from "./index.js";
3
+
4
+ const image = { type: "registry" as const, reference: "busybox" };
5
+
6
+ describe("service inputs", () => {
7
+ test("project-relative files and directories are accepted and kept on the config", () => {
8
+ const env = defineEnvironment({
9
+ name: "t",
10
+ services: { app: { image, inputs: ["lambda/add", "supabase/config.toml"] } },
11
+ });
12
+ expect(env.config.services.app!.inputs).toEqual(["lambda/add", "supabase/config.toml"]);
13
+ });
14
+
15
+ test("an absolute path, an empty entry and a parent reference are refused", () => {
16
+ for (const bad of ["/etc/passwd", "", "../outside"]) {
17
+ expect(() => defineEnvironment({ name: "t", services: { app: { image, inputs: [bad] } } })).toThrow(/invalid `inputs` entry/);
18
+ }
19
+ });
20
+ });
package/src/recorder.ts CHANGED
@@ -520,6 +520,8 @@ export interface BrowserEvent extends BaseEvent {
520
520
  * bundle to look the session up in.
521
521
  */
522
522
  sessionName?: string;
523
+ /** Presentation kind; Electron and mobile share the browser event transport. */
524
+ frame?: "browser" | "mobile" | "desktop";
523
525
  /**
524
526
  * Wall-clock `Date.now()` captured at the moment this op *finished*.
525
527
  * Lives in the same time base as rrweb's `event.timestamp` fields,