@specific.dev/spectest 0.87.0 → 0.88.1

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 (50) 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/emulate/service.js +3 -4
  10. package/dist/components/index.d.ts +1 -0
  11. package/dist/components/index.js +1 -0
  12. package/dist/components/microsoft.d.ts +2 -2
  13. package/dist/components/microsoft.js +5 -6
  14. package/dist/components/supabase-functions-reach.d.ts +18 -0
  15. package/dist/components/supabase-functions-reach.js +155 -0
  16. package/dist/components/supabase.js +33 -5
  17. package/dist/daemon.js +34 -2
  18. package/dist/desktop-recorder.d.ts +1 -0
  19. package/dist/desktop-recorder.js +30 -0
  20. package/dist/desktop.d.ts +42 -0
  21. package/dist/desktop.js +10 -0
  22. package/dist/harness/reach.d.ts +4 -0
  23. package/dist/harness/reach.js +2 -2
  24. package/dist/index.d.ts +27 -1
  25. package/dist/index.js +6 -1
  26. package/dist/project-files.js +6 -6
  27. package/dist/recorder.d.ts +2 -0
  28. package/package.json +1 -1
  29. package/src/browser.ts +106 -10
  30. package/src/components/aws.ts +6 -0
  31. package/src/components/electron-chrome.ts +76 -0
  32. package/src/components/electron.test.ts +38 -0
  33. package/src/components/electron.ts +115 -0
  34. package/src/components/emulate/entry.mjs +5 -7
  35. package/src/components/emulate/service.ts +3 -4
  36. package/src/components/index.ts +1 -0
  37. package/src/components/microsoft.ts +7 -8
  38. package/src/components/supabase-functions-reach.test.ts +90 -0
  39. package/src/components/supabase-functions-reach.ts +153 -0
  40. package/src/components/supabase.ts +33 -5
  41. package/src/daemon.ts +43 -6
  42. package/src/desktop-recorder.test.ts +55 -0
  43. package/src/desktop-recorder.ts +30 -0
  44. package/src/desktop.ts +43 -0
  45. package/src/harness/reach.test.ts +12 -0
  46. package/src/harness/reach.ts +6 -2
  47. package/src/index.ts +34 -1
  48. package/src/inputs.test.ts +20 -0
  49. package/src/project-files.ts +6 -6
  50. package/src/recorder.ts +2 -0
@@ -0,0 +1,153 @@
1
+ // What the edge functions can load from the project — the pure part.
2
+ //
3
+ // The edge runtime serves each function from the project root, mounted
4
+ // whole so that a monorepo function's `../../packages/shared/mod.ts`
5
+ // resolves where it sits. Whole is right for the runtime and wrong for
6
+ // the reach: with the root in a build context and a volume, every file of
7
+ // the project could "reach" the functions, and a change to `docs/` could
8
+ // never skip a run. So the component works out the **module graph** the
9
+ // functions can load, statically, and hands the build context and the
10
+ // reach exactly that: the functions directory, and every directory a
11
+ // relative import or a local import-map entry leads to, followed
12
+ // transitively. Directories rather than files, so a sibling module a
13
+ // dynamic `import()` picks at run time comes along.
14
+ //
15
+ // What it cannot see: a computed specifier that leaves the directories
16
+ // found (`import(\`../../${x}/mod.ts\`)`), and a `Deno.readTextFile` of a
17
+ // path outside them. Both are rare in a function, and both are the same
18
+ // class of hole as a test reading the workspace through `node:fs`. A tree
19
+ // too large to walk within the budget is answered with the whole project,
20
+ // the safe side.
21
+
22
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
23
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
24
+
25
+ /** Files the walk parses for imports. */
26
+ const SOURCE_EXT = /\.(?:[mc]?[jt]sx?)$/;
27
+ /** Names never walked into. */
28
+ const SKIP = new Set(["node_modules", ".git", ".spectest", "dist", "build", ".next"]);
29
+ /** The walk gives up past this many files and answers with the root. */
30
+ const FILE_BUDGET = 20_000;
31
+
32
+ const IMPORT_RE =
33
+ /\b(?:import|export)\b[^;'"`]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]|\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
34
+
35
+ /** Specifiers a source text imports, as written. */
36
+ export function importSpecifiers(text: string): string[] {
37
+ const out: string[] = [];
38
+ for (const m of text.matchAll(IMPORT_RE)) out.push(m[1] ?? m[2] ?? m[3] ?? "");
39
+ return out.filter((s) => s.length > 0);
40
+ }
41
+
42
+ /** Local targets an import map (`deno.json` `imports`, `import_map.json`)
43
+ * names, as written: relative values, and root-relative `/…` ones. */
44
+ export function importMapLocalTargets(text: string): string[] {
45
+ let parsed: unknown;
46
+ try {
47
+ parsed = JSON.parse(text);
48
+ } catch {
49
+ return [];
50
+ }
51
+ const imports = (parsed as { imports?: unknown })?.imports;
52
+ if (typeof imports !== "object" || imports === null) return [];
53
+ return Object.values(imports as Record<string, unknown>).filter(
54
+ (v): v is string => typeof v === "string" && (v.startsWith("./") || v.startsWith("../") || v.startsWith("/")),
55
+ );
56
+ }
57
+
58
+ function toPosix(p: string): string {
59
+ return sep === "/" ? p : p.split(sep).join("/");
60
+ }
61
+
62
+ /**
63
+ * The directories, project-relative and sorted, that the functions under
64
+ * `fnRelPath` can load modules from: `fnRelPath` itself, and every
65
+ * directory a relative import or a local import-map entry leads to,
66
+ * transitively. `["."]` when the walk runs out of budget. A directory
67
+ * nested under another in the answer is dropped.
68
+ */
69
+ export function functionsReach(root: string, fnRelPath: string): string[] {
70
+ const rootAbs = resolve(root);
71
+ const dirs = new Set<string>();
72
+ const queue: string[] = [resolve(rootAbs, fnRelPath)];
73
+ let budget = FILE_BUDGET;
74
+
75
+ const inRoot = (p: string): boolean => p === rootAbs || p.startsWith(rootAbs + sep);
76
+ const enqueue = (target: string): void => {
77
+ if (!inRoot(target) || !existsSync(target)) return;
78
+ let st;
79
+ try {
80
+ st = statSync(target);
81
+ } catch {
82
+ return;
83
+ }
84
+ const dir = st.isDirectory() ? target : dirname(target);
85
+ if (![...dirs].some((d) => dir === d || dir.startsWith(d + sep))) queue.push(dir);
86
+ };
87
+ const follow = (fromDir: string, spec: string): void => {
88
+ if (spec.startsWith("./") || spec.startsWith("../")) enqueue(resolve(fromDir, spec));
89
+ else if (spec.startsWith("/")) enqueue(resolve(rootAbs, spec.slice(1)));
90
+ };
91
+
92
+ while (queue.length > 0) {
93
+ const dir = queue.shift()!;
94
+ if ([...dirs].some((d) => dir === d || dir.startsWith(d + sep))) continue;
95
+ if (!existsSync(dir)) continue;
96
+ dirs.add(dir);
97
+ // Every file below it, parsed for what it imports.
98
+ const stack: string[] = [dir];
99
+ while (stack.length > 0) {
100
+ const d = stack.pop()!;
101
+ let entries;
102
+ try {
103
+ entries = readdirSync(d, { withFileTypes: true });
104
+ } catch {
105
+ continue;
106
+ }
107
+ for (const e of entries) {
108
+ const p = join(d, e.name);
109
+ if (e.isDirectory()) {
110
+ if (!SKIP.has(e.name)) stack.push(p);
111
+ continue;
112
+ }
113
+ if (!e.isFile()) continue;
114
+ if (--budget < 0) return ["."];
115
+ if (e.name === "deno.json" || e.name === "import_map.json") {
116
+ for (const t of importMapLocalTargets(safeRead(p))) follow(d, t.endsWith("/") ? t.slice(0, -1) : t);
117
+ continue;
118
+ }
119
+ if (!SOURCE_EXT.test(e.name)) continue;
120
+ for (const spec of importSpecifiers(safeRead(p))) follow(d, spec);
121
+ }
122
+ }
123
+ }
124
+
125
+ // Minimal, project-relative, sorted.
126
+ const rel = [...dirs].map((d) => (d === rootAbs ? "." : toPosix(relative(rootAbs, d))));
127
+ const minimal = rel.filter((d) => !rel.some((o) => o !== d && (o === "." || d.startsWith(o + "/"))));
128
+ return [...new Set(minimal)].sort();
129
+ }
130
+
131
+ function safeRead(p: string): string {
132
+ try {
133
+ return readFileSync(p, "utf8");
134
+ } catch {
135
+ return "";
136
+ }
137
+ }
138
+
139
+ /** The `exclude` list that narrows a root build context to `dirs`: drop
140
+ * everything, then re-include each directory and its subtree, then drop
141
+ * again what no context should carry (a re-included `packages/shared`
142
+ * would otherwise bring its `node_modules` along). */
143
+ export function excludeAllBut(dirs: readonly string[]): string[] {
144
+ if (dirs.includes(".")) return [];
145
+ return ["**", ...dirs.flatMap((d) => [`!${d}`, `!${d}/**`]), ...REEXCLUDE];
146
+ }
147
+
148
+ /** Re-excluded after the re-includes: the names the default ignore drops. */
149
+ const REEXCLUDE = ["**/node_modules/**", "**/.git/**", "**/.spectest/**", "**/__pycache__/**", "**/.venv/**"];
150
+
151
+ // Keep `isAbsolute` referenced for callers that pass absolute fnRelPath by
152
+ // mistake: the walk resolves it against the root either way.
153
+ void isAbsolute;
@@ -51,6 +51,7 @@
51
51
 
52
52
  import { createHmac } from "node:crypto";
53
53
  import { existsSync, readFileSync } from "node:fs";
54
+ import { excludeAllBut, functionsReach } from "./supabase-functions-reach.js";
54
55
  import { readdir, stat } from "node:fs/promises";
55
56
  import { dirname, join } from "node:path";
56
57
 
@@ -166,6 +167,9 @@ function providerCredentials(
166
167
  * cache rides into the image as a layer — so at run time the worker resolves
167
168
  * those URLs locally and never reaches for the network.
168
169
  *
170
+ * The context is the module graph the functions can load (the functions
171
+ * directory and what its imports lead to, `functionsReach`), copied whole
172
+ * so a monorepo function's `../../packages/…` resolves at bundle time too.
169
173
  * One synthetic entrypoint imports every function, so the whole set is one
170
174
  * graph rather than one per function. Bundling is best-effort: a function that
171
175
  * fails to bundle (a type error, an import that only resolves at run time)
@@ -175,13 +179,13 @@ function providerCredentials(
175
179
  function functionsDockerfile(fnRelPath: string): string {
176
180
  return `FROM ${IMAGES.functions}
177
181
  ENV DENO_DIR=${DENO_DIR}
178
- COPY ${fnRelPath} /warm/functions
182
+ COPY . /warm/repo
179
183
  RUN set -eu; \
180
184
  : > /warm/all.ts; \
181
- for d in /warm/functions/*/; do \
185
+ for d in /warm/repo/${fnRelPath}/*/; do \
182
186
  [ -f "$d/index.ts" ] || continue; \
183
187
  n=$(basename "$d"); \
184
- printf 'import "./functions/%s/index.ts";\\n' "$n" >> /warm/all.ts; \
188
+ printf 'import "./repo/${fnRelPath}/%s/index.ts";\\n' "$n" >> /warm/all.ts; \
185
189
  done; \
186
190
  edge-runtime bundle --entrypoint /warm/all.ts --output /tmp/warm.eszip --quiet || true; \
187
191
  rm -rf /tmp/warm.eszip /warm
@@ -1684,6 +1688,10 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1684
1688
  ? `${REPO_MOUNT}/${fnDir.slice(root.length + 1)}`
1685
1689
  : `${REPO_MOUNT}/supabase/functions`;
1686
1690
  const migrationsPath = inDir("migrations");
1691
+ // The directories the functions can load modules from: the functions
1692
+ // directory and what its imports lead to (`supabase-functions-reach.ts`).
1693
+ // The build context and the reach are narrowed to exactly these.
1694
+ const fnReachDirs = withFunctions ? functionsReach(root, fnRelPath) : [];
1687
1695
 
1688
1696
  // ── config file (config.toml / config.json) ───────────────────────────
1689
1697
  // The CLI's own project config, mapped onto the stack. Read at build time:
@@ -1890,8 +1898,20 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
1890
1898
  const parts: ServicesMap = {};
1891
1899
 
1892
1900
  // ── db ──────────────────────────────────────────────────────────
1901
+ // What the stack reads from the project: the Supabase folder
1902
+ // (config.toml, migrations, seed, functions) and the mail templates,
1903
+ // which may sit anywhere in the repo. Declared as inputs so a change
1904
+ // to any of them is never taken as one that can affect no test; the
1905
+ // migrations volume carries the folder too, but only when there is
1906
+ // a migrations directory, and a config-only project must count as
1907
+ // well.
1908
+ const inputs: string[] = [
1909
+ ...(opts.dir !== undefined && !opts.dir.startsWith("/") ? [opts.dir] : []),
1910
+ ...templateBodies.map(([, spec]) => spec.path).filter((t) => !t.startsWith("/")),
1911
+ ];
1893
1912
  const dbDef: ServiceDefinition = {
1894
1913
  image: { type: "registry", reference: opts.db?.image ?? IMAGES.db },
1914
+ ...(inputs.length > 0 ? { inputs } : {}),
1895
1915
  // `args` (not `command`): keep the image's docker-entrypoint.sh so it
1896
1916
  // runs initdb + its baked init-scripts before starting.
1897
1917
  args: [
@@ -2211,6 +2231,12 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2211
2231
  image: {
2212
2232
  type: "dockerfile",
2213
2233
  content: functionsDockerfile(fnRelPath),
2234
+ // The root, narrowed to the module graph: only the functions
2235
+ // directory and what its imports lead to enter the context, so
2236
+ // a change anywhere else neither uploads nor counts as reaching
2237
+ // the functions.
2238
+ context: ".",
2239
+ exclude: excludeAllBut(fnReachDirs),
2214
2240
  },
2215
2241
  // The **project root**, mounted with the functions directory in its
2216
2242
  // real position underneath. Mounting only `supabase/functions`
@@ -2219,8 +2245,10 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
2219
2245
  // (`../../../../packages/…`), and that only resolves if the
2220
2246
  // directory sits where it sits in the repo. Read-only: the runtime
2221
2247
  // only reads sources, and a function writing into its own directory
2222
- // would be writing into the user's checkout.
2223
- volumes: [{ source: projectRootPath(), target: REPO_MOUNT, readOnly: true }],
2248
+ // would be writing into the user's checkout. For the reach the
2249
+ // mount is exempt: what the functions can load is the module graph
2250
+ // above, which the build context already states.
2251
+ volumes: [{ source: projectRootPath(), target: REPO_MOUNT, readOnly: true, reachExempt: true }],
2224
2252
  // Our router (see FUNCTIONS_MAIN_TS) sits outside that mount, so
2225
2253
  // the project never has to carry a `main/` function of ours.
2226
2254
  files: [{ path: "/home/deno/main/index.ts", content: FUNCTIONS_MAIN_TS }],
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
  *
@@ -1245,7 +1254,7 @@ const BUILD_TARGET_RE = /^[^\s]+$/;
1245
1254
  * Runs in `defineEnvironment`, and in the daemon's runtime `startService`
1246
1255
  * twin, inside the VM, where the repo sits at the project root. Existence
1247
1256
  * checks go through the project-file resolver, so the same rules as
1248
- * `ctx.readProjectFile` apply (a `spectest/.envignore`d path is refused
1257
+ * `ctx.readProjectFile` apply (a `spectest/.specignore`d path is refused
1249
1258
  * instead of read stale).
1250
1259
  */
1251
1260
  export function validateServiceImage(serviceName: string, image: ServiceImage): ServiceImage {
@@ -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
+ });