@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.
- package/dist/browser.d.ts +7 -0
- package/dist/browser.js +102 -10
- package/dist/components/aws.d.ts +8 -7
- package/dist/components/aws.js +6 -0
- package/dist/components/electron-chrome.d.ts +1 -0
- package/dist/components/electron-chrome.js +76 -0
- package/dist/components/electron.d.ts +46 -0
- package/dist/components/electron.js +99 -0
- package/dist/components/emulate/service.js +3 -4
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/microsoft.d.ts +2 -2
- package/dist/components/microsoft.js +5 -6
- package/dist/components/supabase-functions-reach.d.ts +18 -0
- package/dist/components/supabase-functions-reach.js +155 -0
- package/dist/components/supabase.js +33 -5
- package/dist/daemon.js +34 -2
- package/dist/desktop-recorder.d.ts +1 -0
- package/dist/desktop-recorder.js +30 -0
- package/dist/desktop.d.ts +42 -0
- package/dist/desktop.js +10 -0
- package/dist/harness/reach.d.ts +4 -0
- package/dist/harness/reach.js +2 -2
- package/dist/index.d.ts +27 -1
- package/dist/index.js +6 -1
- package/dist/project-files.js +6 -6
- package/dist/recorder.d.ts +2 -0
- package/package.json +1 -1
- package/src/browser.ts +106 -10
- package/src/components/aws.ts +6 -0
- package/src/components/electron-chrome.ts +76 -0
- package/src/components/electron.test.ts +38 -0
- package/src/components/electron.ts +115 -0
- package/src/components/emulate/entry.mjs +5 -7
- package/src/components/emulate/service.ts +3 -4
- package/src/components/index.ts +1 -0
- package/src/components/microsoft.ts +7 -8
- package/src/components/supabase-functions-reach.test.ts +90 -0
- package/src/components/supabase-functions-reach.ts +153 -0
- package/src/components/supabase.ts +33 -5
- package/src/daemon.ts +43 -6
- package/src/desktop-recorder.test.ts +55 -0
- package/src/desktop-recorder.ts +30 -0
- package/src/desktop.ts +43 -0
- package/src/harness/reach.test.ts +12 -0
- package/src/harness/reach.ts +6 -2
- package/src/index.ts +34 -1
- package/src/inputs.test.ts +20 -0
- package/src/project-files.ts +6 -6
- 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
|
|
182
|
+
COPY . /warm/repo
|
|
179
183
|
RUN set -eu; \
|
|
180
184
|
: > /warm/all.ts; \
|
|
181
|
-
for d in /warm/
|
|
185
|
+
for d in /warm/repo/${fnRelPath}/*/; do \
|
|
182
186
|
[ -f "$d/index.ts" ] || continue; \
|
|
183
187
|
n=$(basename "$d"); \
|
|
184
|
-
printf 'import "./
|
|
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
|
-
|
|
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"` (
|
|
4356
|
-
* (
|
|
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);
|
package/src/harness/reach.ts
CHANGED
|
@@ -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/.
|
|
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
|
+
});
|