@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,155 @@
|
|
|
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
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
22
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
23
|
+
/** Files the walk parses for imports. */
|
|
24
|
+
const SOURCE_EXT = /\.(?:[mc]?[jt]sx?)$/;
|
|
25
|
+
/** Names never walked into. */
|
|
26
|
+
const SKIP = new Set(["node_modules", ".git", ".spectest", "dist", "build", ".next"]);
|
|
27
|
+
/** The walk gives up past this many files and answers with the root. */
|
|
28
|
+
const FILE_BUDGET = 20_000;
|
|
29
|
+
const IMPORT_RE = /\b(?:import|export)\b[^;'"`]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]|\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
30
|
+
/** Specifiers a source text imports, as written. */
|
|
31
|
+
export function importSpecifiers(text) {
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const m of text.matchAll(IMPORT_RE))
|
|
34
|
+
out.push(m[1] ?? m[2] ?? m[3] ?? "");
|
|
35
|
+
return out.filter((s) => s.length > 0);
|
|
36
|
+
}
|
|
37
|
+
/** Local targets an import map (`deno.json` `imports`, `import_map.json`)
|
|
38
|
+
* names, as written: relative values, and root-relative `/…` ones. */
|
|
39
|
+
export function importMapLocalTargets(text) {
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(text);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const imports = parsed?.imports;
|
|
48
|
+
if (typeof imports !== "object" || imports === null)
|
|
49
|
+
return [];
|
|
50
|
+
return Object.values(imports).filter((v) => typeof v === "string" && (v.startsWith("./") || v.startsWith("../") || v.startsWith("/")));
|
|
51
|
+
}
|
|
52
|
+
function toPosix(p) {
|
|
53
|
+
return sep === "/" ? p : p.split(sep).join("/");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The directories, project-relative and sorted, that the functions under
|
|
57
|
+
* `fnRelPath` can load modules from: `fnRelPath` itself, and every
|
|
58
|
+
* directory a relative import or a local import-map entry leads to,
|
|
59
|
+
* transitively. `["."]` when the walk runs out of budget. A directory
|
|
60
|
+
* nested under another in the answer is dropped.
|
|
61
|
+
*/
|
|
62
|
+
export function functionsReach(root, fnRelPath) {
|
|
63
|
+
const rootAbs = resolve(root);
|
|
64
|
+
const dirs = new Set();
|
|
65
|
+
const queue = [resolve(rootAbs, fnRelPath)];
|
|
66
|
+
let budget = FILE_BUDGET;
|
|
67
|
+
const inRoot = (p) => p === rootAbs || p.startsWith(rootAbs + sep);
|
|
68
|
+
const enqueue = (target) => {
|
|
69
|
+
if (!inRoot(target) || !existsSync(target))
|
|
70
|
+
return;
|
|
71
|
+
let st;
|
|
72
|
+
try {
|
|
73
|
+
st = statSync(target);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const dir = st.isDirectory() ? target : dirname(target);
|
|
79
|
+
if (![...dirs].some((d) => dir === d || dir.startsWith(d + sep)))
|
|
80
|
+
queue.push(dir);
|
|
81
|
+
};
|
|
82
|
+
const follow = (fromDir, spec) => {
|
|
83
|
+
if (spec.startsWith("./") || spec.startsWith("../"))
|
|
84
|
+
enqueue(resolve(fromDir, spec));
|
|
85
|
+
else if (spec.startsWith("/"))
|
|
86
|
+
enqueue(resolve(rootAbs, spec.slice(1)));
|
|
87
|
+
};
|
|
88
|
+
while (queue.length > 0) {
|
|
89
|
+
const dir = queue.shift();
|
|
90
|
+
if ([...dirs].some((d) => dir === d || dir.startsWith(d + sep)))
|
|
91
|
+
continue;
|
|
92
|
+
if (!existsSync(dir))
|
|
93
|
+
continue;
|
|
94
|
+
dirs.add(dir);
|
|
95
|
+
// Every file below it, parsed for what it imports.
|
|
96
|
+
const stack = [dir];
|
|
97
|
+
while (stack.length > 0) {
|
|
98
|
+
const d = stack.pop();
|
|
99
|
+
let entries;
|
|
100
|
+
try {
|
|
101
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
for (const e of entries) {
|
|
107
|
+
const p = join(d, e.name);
|
|
108
|
+
if (e.isDirectory()) {
|
|
109
|
+
if (!SKIP.has(e.name))
|
|
110
|
+
stack.push(p);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!e.isFile())
|
|
114
|
+
continue;
|
|
115
|
+
if (--budget < 0)
|
|
116
|
+
return ["."];
|
|
117
|
+
if (e.name === "deno.json" || e.name === "import_map.json") {
|
|
118
|
+
for (const t of importMapLocalTargets(safeRead(p)))
|
|
119
|
+
follow(d, t.endsWith("/") ? t.slice(0, -1) : t);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!SOURCE_EXT.test(e.name))
|
|
123
|
+
continue;
|
|
124
|
+
for (const spec of importSpecifiers(safeRead(p)))
|
|
125
|
+
follow(d, spec);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Minimal, project-relative, sorted.
|
|
130
|
+
const rel = [...dirs].map((d) => (d === rootAbs ? "." : toPosix(relative(rootAbs, d))));
|
|
131
|
+
const minimal = rel.filter((d) => !rel.some((o) => o !== d && (o === "." || d.startsWith(o + "/"))));
|
|
132
|
+
return [...new Set(minimal)].sort();
|
|
133
|
+
}
|
|
134
|
+
function safeRead(p) {
|
|
135
|
+
try {
|
|
136
|
+
return readFileSync(p, "utf8");
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** The `exclude` list that narrows a root build context to `dirs`: drop
|
|
143
|
+
* everything, then re-include each directory and its subtree, then drop
|
|
144
|
+
* again what no context should carry (a re-included `packages/shared`
|
|
145
|
+
* would otherwise bring its `node_modules` along). */
|
|
146
|
+
export function excludeAllBut(dirs) {
|
|
147
|
+
if (dirs.includes("."))
|
|
148
|
+
return [];
|
|
149
|
+
return ["**", ...dirs.flatMap((d) => [`!${d}`, `!${d}/**`]), ...REEXCLUDE];
|
|
150
|
+
}
|
|
151
|
+
/** Re-excluded after the re-includes: the names the default ignore drops. */
|
|
152
|
+
const REEXCLUDE = ["**/node_modules/**", "**/.git/**", "**/.spectest/**", "**/__pycache__/**", "**/.venv/**"];
|
|
153
|
+
// Keep `isAbsolute` referenced for callers that pass absolute fnRelPath by
|
|
154
|
+
// mistake: the walk resolves it against the root either way.
|
|
155
|
+
void isAbsolute;
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
// (`sb.url`, `sb.dbUrl`) embeds it — a mismatch errors at load.
|
|
51
51
|
import { createHmac } from "node:crypto";
|
|
52
52
|
import { existsSync, readFileSync } from "node:fs";
|
|
53
|
+
import { excludeAllBut, functionsReach } from "./supabase-functions-reach.js";
|
|
53
54
|
import { readdir, stat } from "node:fs/promises";
|
|
54
55
|
import { dirname, join } from "node:path";
|
|
55
56
|
import { serviceGroup, } from "../index.js";
|
|
@@ -137,6 +138,9 @@ function providerCredentials(providerName, declared, env) {
|
|
|
137
138
|
* cache rides into the image as a layer — so at run time the worker resolves
|
|
138
139
|
* those URLs locally and never reaches for the network.
|
|
139
140
|
*
|
|
141
|
+
* The context is the module graph the functions can load (the functions
|
|
142
|
+
* directory and what its imports lead to, `functionsReach`), copied whole
|
|
143
|
+
* so a monorepo function's `../../packages/…` resolves at bundle time too.
|
|
140
144
|
* One synthetic entrypoint imports every function, so the whole set is one
|
|
141
145
|
* graph rather than one per function. Bundling is best-effort: a function that
|
|
142
146
|
* fails to bundle (a type error, an import that only resolves at run time)
|
|
@@ -146,13 +150,13 @@ function providerCredentials(providerName, declared, env) {
|
|
|
146
150
|
function functionsDockerfile(fnRelPath) {
|
|
147
151
|
return `FROM ${IMAGES.functions}
|
|
148
152
|
ENV DENO_DIR=${DENO_DIR}
|
|
149
|
-
COPY
|
|
153
|
+
COPY . /warm/repo
|
|
150
154
|
RUN set -eu; \
|
|
151
155
|
: > /warm/all.ts; \
|
|
152
|
-
for d in /warm/
|
|
156
|
+
for d in /warm/repo/${fnRelPath}/*/; do \
|
|
153
157
|
[ -f "$d/index.ts" ] || continue; \
|
|
154
158
|
n=$(basename "$d"); \
|
|
155
|
-
printf 'import "./
|
|
159
|
+
printf 'import "./repo/${fnRelPath}/%s/index.ts";\\n' "$n" >> /warm/all.ts; \
|
|
156
160
|
done; \
|
|
157
161
|
edge-runtime bundle --entrypoint /warm/all.ts --output /tmp/warm.eszip --quiet || true; \
|
|
158
162
|
rm -rf /tmp/warm.eszip /warm
|
|
@@ -1247,6 +1251,10 @@ export function supabase(opts = {}) {
|
|
|
1247
1251
|
? `${REPO_MOUNT}/${fnDir.slice(root.length + 1)}`
|
|
1248
1252
|
: `${REPO_MOUNT}/supabase/functions`;
|
|
1249
1253
|
const migrationsPath = inDir("migrations");
|
|
1254
|
+
// The directories the functions can load modules from: the functions
|
|
1255
|
+
// directory and what its imports lead to (`supabase-functions-reach.ts`).
|
|
1256
|
+
// The build context and the reach are narrowed to exactly these.
|
|
1257
|
+
const fnReachDirs = withFunctions ? functionsReach(root, fnRelPath) : [];
|
|
1250
1258
|
// ── config file (config.toml / config.json) ───────────────────────────
|
|
1251
1259
|
// The CLI's own project config, mapped onto the stack. Read at build time:
|
|
1252
1260
|
// the values become container config, so they must be in hand before the
|
|
@@ -1415,8 +1423,20 @@ export function supabase(opts = {}) {
|
|
|
1415
1423
|
const realtimeHost = `realtime-dev.${g.key("realtime")}`;
|
|
1416
1424
|
const parts = {};
|
|
1417
1425
|
// ── db ──────────────────────────────────────────────────────────
|
|
1426
|
+
// What the stack reads from the project: the Supabase folder
|
|
1427
|
+
// (config.toml, migrations, seed, functions) and the mail templates,
|
|
1428
|
+
// which may sit anywhere in the repo. Declared as inputs so a change
|
|
1429
|
+
// to any of them is never taken as one that can affect no test; the
|
|
1430
|
+
// migrations volume carries the folder too, but only when there is
|
|
1431
|
+
// a migrations directory, and a config-only project must count as
|
|
1432
|
+
// well.
|
|
1433
|
+
const inputs = [
|
|
1434
|
+
...(opts.dir !== undefined && !opts.dir.startsWith("/") ? [opts.dir] : []),
|
|
1435
|
+
...templateBodies.map(([, spec]) => spec.path).filter((t) => !t.startsWith("/")),
|
|
1436
|
+
];
|
|
1418
1437
|
const dbDef = {
|
|
1419
1438
|
image: { type: "registry", reference: opts.db?.image ?? IMAGES.db },
|
|
1439
|
+
...(inputs.length > 0 ? { inputs } : {}),
|
|
1420
1440
|
// `args` (not `command`): keep the image's docker-entrypoint.sh so it
|
|
1421
1441
|
// runs initdb + its baked init-scripts before starting.
|
|
1422
1442
|
args: [
|
|
@@ -1721,6 +1741,12 @@ export function supabase(opts = {}) {
|
|
|
1721
1741
|
image: {
|
|
1722
1742
|
type: "dockerfile",
|
|
1723
1743
|
content: functionsDockerfile(fnRelPath),
|
|
1744
|
+
// The root, narrowed to the module graph: only the functions
|
|
1745
|
+
// directory and what its imports lead to enter the context, so
|
|
1746
|
+
// a change anywhere else neither uploads nor counts as reaching
|
|
1747
|
+
// the functions.
|
|
1748
|
+
context: ".",
|
|
1749
|
+
exclude: excludeAllBut(fnReachDirs),
|
|
1724
1750
|
},
|
|
1725
1751
|
// The **project root**, mounted with the functions directory in its
|
|
1726
1752
|
// real position underneath. Mounting only `supabase/functions`
|
|
@@ -1729,8 +1755,10 @@ export function supabase(opts = {}) {
|
|
|
1729
1755
|
// (`../../../../packages/…`), and that only resolves if the
|
|
1730
1756
|
// directory sits where it sits in the repo. Read-only: the runtime
|
|
1731
1757
|
// only reads sources, and a function writing into its own directory
|
|
1732
|
-
// would be writing into the user's checkout.
|
|
1733
|
-
|
|
1758
|
+
// would be writing into the user's checkout. For the reach the
|
|
1759
|
+
// mount is exempt: what the functions can load is the module graph
|
|
1760
|
+
// above, which the build context already states.
|
|
1761
|
+
volumes: [{ source: projectRootPath(), target: REPO_MOUNT, readOnly: true, reachExempt: true }],
|
|
1734
1762
|
// Our router (see FUNCTIONS_MAIN_TS) sits outside that mount, so
|
|
1735
1763
|
// the project never has to carry a `main/` function of ours.
|
|
1736
1764
|
files: [{ path: "/home/deno/main/index.ts", content: FUNCTIONS_MAIN_TS }],
|
package/dist/daemon.js
CHANGED
|
@@ -28,8 +28,9 @@ import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCover
|
|
|
28
28
|
import { configureBrowserCoverage } from "./browser-coverage.js";
|
|
29
29
|
import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, coverageReportsMode, validateCoverage, } from "./coverage.js";
|
|
30
30
|
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
31
|
-
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
31
|
+
import { acquirePersistentBrowser, acquirePersistentDesktop, mobileKey } from "./browser.js";
|
|
32
32
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
33
|
+
import { isDesktopApp } from "./desktop.js";
|
|
33
34
|
// Pure ingress hostname matching, ported out of this file (see
|
|
34
35
|
// harness/hostmatch.ts). Keeping ONE implementation is the point: the
|
|
35
36
|
// exact-then-longest-suffix rule and the one-label certificate rule are
|
|
@@ -3585,7 +3586,8 @@ async function computeProjectReachInner(all) {
|
|
|
3585
3586
|
for (const svc of all) {
|
|
3586
3587
|
const src = {
|
|
3587
3588
|
service: svc.name,
|
|
3588
|
-
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
|
|
3589
|
+
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
|
|
3590
|
+
...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
|
|
3589
3591
|
};
|
|
3590
3592
|
if (svc.image.type === "dockerfile") {
|
|
3591
3593
|
try {
|
|
@@ -3753,6 +3755,7 @@ function newBrowserSession(testStart, idScope, frame = "browser", artifacts, nam
|
|
|
3753
3755
|
record,
|
|
3754
3756
|
recorder: {
|
|
3755
3757
|
sessionId: record.sessionId,
|
|
3758
|
+
frame,
|
|
3756
3759
|
...(name ? { sessionName: name } : {}),
|
|
3757
3760
|
recordStep(step) {
|
|
3758
3761
|
if (closed)
|
|
@@ -3777,6 +3780,33 @@ function newBrowserSession(testStart, idScope, frame = "browser", artifacts, nam
|
|
|
3777
3780
|
},
|
|
3778
3781
|
};
|
|
3779
3782
|
}
|
|
3783
|
+
/** Same desktop recording path for test runs and interactive evals. */
|
|
3784
|
+
function desktopOpener(start, scope, sessions, detaches, artifacts) {
|
|
3785
|
+
const memo = new Map();
|
|
3786
|
+
return async (app, options = {}) => {
|
|
3787
|
+
if (!isDesktopApp(app))
|
|
3788
|
+
throw new Error("ctx.desktop(app): pass ctx.svc.app from an electron() service");
|
|
3789
|
+
const window = options.window ?? 0;
|
|
3790
|
+
const key = JSON.stringify([app.endpoint, window]);
|
|
3791
|
+
const existing = memo.get(key);
|
|
3792
|
+
if (existing)
|
|
3793
|
+
return existing;
|
|
3794
|
+
const session = newBrowserSession(start, scope, "desktop", artifacts, window === 0 ? app.name : `${app.name} · window ${window + 1}`);
|
|
3795
|
+
const { browser, detach, desktopChrome } = await acquirePersistentDesktop(app.endpoint, window, session.recorder);
|
|
3796
|
+
session.record.desktopChrome = desktopChrome;
|
|
3797
|
+
session.record.initialUrl = browser.url();
|
|
3798
|
+
sessions.push(session);
|
|
3799
|
+
detaches.push(async () => { await detach(); session.markClosed(); });
|
|
3800
|
+
const close = browser.close.bind(browser);
|
|
3801
|
+
browser.close = async () => {
|
|
3802
|
+
await close();
|
|
3803
|
+
if (memo.get(key) === browser)
|
|
3804
|
+
memo.delete(key);
|
|
3805
|
+
};
|
|
3806
|
+
memo.set(key, browser);
|
|
3807
|
+
return browser;
|
|
3808
|
+
};
|
|
3809
|
+
}
|
|
3780
3810
|
/** New terminal session bookkeeping for a single `ctx.terminal(...)` call. */
|
|
3781
3811
|
function newTerminalSession(testStart, service, command, cols, rows, idScope) {
|
|
3782
3812
|
const record = {
|
|
@@ -4648,6 +4678,7 @@ async function runOne(testCase) {
|
|
|
4648
4678
|
openTerminal: recordedOpenTerminal,
|
|
4649
4679
|
browser: trackedOpenBrowser,
|
|
4650
4680
|
mobile: trackedOpenMobile,
|
|
4681
|
+
desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
|
|
4651
4682
|
mcp: openMcp,
|
|
4652
4683
|
testName: testCase.name,
|
|
4653
4684
|
parent,
|
|
@@ -5132,6 +5163,7 @@ async function evalCode(code, secrets) {
|
|
|
5132
5163
|
openTerminal: evalOpenTerminal,
|
|
5133
5164
|
browser: trackedOpenBrowser,
|
|
5134
5165
|
mobile: trackedOpenMobile,
|
|
5166
|
+
desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
|
|
5135
5167
|
mcp: openMcp,
|
|
5136
5168
|
testName: "eval",
|
|
5137
5169
|
parent: undefined,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const DESKTOP_INPUT_SYNC_SCRIPT: string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A late recorder cannot interpose on setters React cached during mounting.
|
|
2
|
+
// Reconcile controls at action boundaries, without changing the app's DOM or
|
|
3
|
+
// descriptors. Normal rrweb Input events win; synthesize only missing changes.
|
|
4
|
+
export const DESKTOP_INPUT_SYNC_SCRIPT = String.raw `(() => {
|
|
5
|
+
if (window.__spectestSyncInputs) return;
|
|
6
|
+
const known = new WeakMap();
|
|
7
|
+
window.__spectestSyncInputs = (timestamp = Date.now()) => {
|
|
8
|
+
const rec = window.__spectestRec;
|
|
9
|
+
const events = window.__spectestRrwebEvents;
|
|
10
|
+
if (!rec?.mirror || !events) return;
|
|
11
|
+
const inputs = new Map();
|
|
12
|
+
for (const event of events) {
|
|
13
|
+
if (event.type === 3 && event.data?.source === 5) inputs.set(event.data.id, event.data);
|
|
14
|
+
}
|
|
15
|
+
for (const element of document.querySelectorAll('input, textarea, select')) {
|
|
16
|
+
const type = element.hasAttribute('data-rr-is-password') ? 'password' : element.type?.toLowerCase();
|
|
17
|
+
if (type === 'file' || element.closest('.rr-block, .rr-ignore')) continue;
|
|
18
|
+
const id = rec.mirror.getId(element);
|
|
19
|
+
if (!(id > 0)) continue;
|
|
20
|
+
const text = type === 'password' ? '*'.repeat(element.value.length) : element.value;
|
|
21
|
+
const isChecked = (type === 'checkbox' || type === 'radio') ? element.checked : false;
|
|
22
|
+
const current = { text, isChecked };
|
|
23
|
+
const previous = inputs.get(id) ?? known.get(element);
|
|
24
|
+
known.set(element, current);
|
|
25
|
+
if (!previous || (previous.text === text && previous.isChecked === isChecked)) continue;
|
|
26
|
+
events.push({ type: 3, data: { source: 5, id, text, isChecked }, timestamp });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
window.__spectestSyncInputs();
|
|
30
|
+
})();`;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Browser } from "./browser.js";
|
|
2
|
+
export declare const DESKTOP_APP: unique symbol;
|
|
3
|
+
/** An Electron service handle produced by electron(). */
|
|
4
|
+
export interface DesktopApp {
|
|
5
|
+
readonly [DESKTOP_APP]: true;
|
|
6
|
+
readonly endpoint: string;
|
|
7
|
+
readonly name: string;
|
|
8
|
+
}
|
|
9
|
+
/** Real renderer window: user input, locators, assertions, and replay capture.
|
|
10
|
+
* close() closes this window only. Native OS dialogs are outside DOM replay. */
|
|
11
|
+
export interface Desktop extends Browser {
|
|
12
|
+
}
|
|
13
|
+
/** Observed window configuration at the start of a replay session. */
|
|
14
|
+
export interface DesktopChrome {
|
|
15
|
+
titleBarStyle: string;
|
|
16
|
+
frame: boolean;
|
|
17
|
+
trafficLightPosition: {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
} | null;
|
|
21
|
+
titleBarOverlay: boolean | {
|
|
22
|
+
height?: number;
|
|
23
|
+
color?: string;
|
|
24
|
+
symbolColor?: string;
|
|
25
|
+
};
|
|
26
|
+
roundedCorners: boolean;
|
|
27
|
+
buttonVisibility: boolean | null;
|
|
28
|
+
titlebarArea?: {
|
|
29
|
+
x: number;
|
|
30
|
+
y: number;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export interface DesktopOptions {
|
|
36
|
+
/** Zero-based window creation order. Defaults to the first window.
|
|
37
|
+
* Open additional windows using the app UI before selecting them.
|
|
38
|
+
* A closed window keeps its index; other windows never change identity. */
|
|
39
|
+
window?: number;
|
|
40
|
+
}
|
|
41
|
+
export declare function desktopApp(endpoint: string, name: string): DesktopApp;
|
|
42
|
+
export declare function isDesktopApp(value: unknown): value is DesktopApp;
|
package/dist/desktop.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const DESKTOP_APP = Symbol.for("spectest.desktopApp");
|
|
2
|
+
export function desktopApp(endpoint, name) {
|
|
3
|
+
return { [DESKTOP_APP]: true, endpoint, name };
|
|
4
|
+
}
|
|
5
|
+
export function isDesktopApp(value) {
|
|
6
|
+
return typeof value === "object" && value !== null &&
|
|
7
|
+
value[DESKTOP_APP] === true &&
|
|
8
|
+
typeof value.endpoint === "string" &&
|
|
9
|
+
typeof value.name === "string";
|
|
10
|
+
}
|
package/dist/harness/reach.d.ts
CHANGED
|
@@ -25,6 +25,10 @@ export interface ReachSource {
|
|
|
25
25
|
/** Volume sources, project-relative; an absolute source is not a
|
|
26
26
|
* project path and is skipped. */
|
|
27
27
|
volumeSources?: string[];
|
|
28
|
+
/** The service's declared `inputs`: project files or directories it
|
|
29
|
+
* consumes some other way (packaged at setup, read at load). A
|
|
30
|
+
* directory covers its subtree. */
|
|
31
|
+
inputs?: string[];
|
|
28
32
|
}
|
|
29
33
|
/**
|
|
30
34
|
* The project's reach: every project file (`files`, project-relative)
|
package/dist/harness/reach.js
CHANGED
|
@@ -142,7 +142,7 @@ export function projectReach(files, sources) {
|
|
|
142
142
|
if (rel)
|
|
143
143
|
out.add(rel);
|
|
144
144
|
}
|
|
145
|
-
for (const v of s.volumeSources ?? []) {
|
|
145
|
+
for (const v of [...(s.volumeSources ?? []), ...(s.inputs ?? [])]) {
|
|
146
146
|
if (v.startsWith("/"))
|
|
147
147
|
continue;
|
|
148
148
|
const src = normRel(v);
|
|
@@ -236,7 +236,7 @@ export function sourceOf(sources, path) {
|
|
|
236
236
|
for (const e of s.extraFiles ?? [])
|
|
237
237
|
if (normRel(e) === p)
|
|
238
238
|
return s;
|
|
239
|
-
for (const v of s.volumeSources ?? []) {
|
|
239
|
+
for (const v of [...(s.volumeSources ?? []), ...(s.inputs ?? [])]) {
|
|
240
240
|
if (v.startsWith("/"))
|
|
241
241
|
continue;
|
|
242
242
|
const src = normRel(v);
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type { UrlPattern } from "./url-match.js";
|
|
|
19
19
|
import type { UrlPattern } from "./url-match.js";
|
|
20
20
|
export type { Mobile, MobileApp } from "./mobile.js";
|
|
21
21
|
import type { Mobile, MobileApp } from "./mobile.js";
|
|
22
|
+
export type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
|
|
23
|
+
import type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
|
|
22
24
|
export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
|
|
23
25
|
import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
|
|
24
26
|
export { type CoverageAdapter, type CoverageCaptureContext, type CoverageConfigureInfo, type CoverageLoadContext, type ServiceCoverage, } from "./coverage.js";
|
|
@@ -141,6 +143,13 @@ export interface ServiceConfig {
|
|
|
141
143
|
tls?: readonly ServiceTls[];
|
|
142
144
|
/** Bind-mounted volumes for state that survives snapshot/fork. */
|
|
143
145
|
volumes?: readonly VolumeMount[];
|
|
146
|
+
/**
|
|
147
|
+
* @internal Spectest's own components only: project files or
|
|
148
|
+
* directories the service consumes outside its build context and
|
|
149
|
+
* volumes (a lambda's `source`, Supabase's folder). Project-relative; a
|
|
150
|
+
* directory covers its subtree. The reach counts them.
|
|
151
|
+
*/
|
|
152
|
+
inputs?: readonly string[];
|
|
144
153
|
/**
|
|
145
154
|
* Opt this service in to **code coverage collection** (experimental).
|
|
146
155
|
*
|
|
@@ -814,7 +823,7 @@ export type ServiceImage = {
|
|
|
814
823
|
* Runs in `defineEnvironment`, and in the daemon's runtime `startService`
|
|
815
824
|
* twin, inside the VM, where the repo sits at the project root. Existence
|
|
816
825
|
* checks go through the project-file resolver, so the same rules as
|
|
817
|
-
* `ctx.readProjectFile` apply (a `spectest/.
|
|
826
|
+
* `ctx.readProjectFile` apply (a `spectest/.specignore`d path is refused
|
|
818
827
|
* instead of read stale).
|
|
819
828
|
*/
|
|
820
829
|
export declare function validateServiceImage(serviceName: string, image: ServiceImage): ServiceImage;
|
|
@@ -841,6 +850,13 @@ export interface VolumeMount {
|
|
|
841
850
|
* so unlike a volume directory it is never created or wiped by spectest.
|
|
842
851
|
*/
|
|
843
852
|
source?: string;
|
|
853
|
+
/**
|
|
854
|
+
* @internal Spectest's own components only. The mount does not count
|
|
855
|
+
* for the project's reach: what the service can load from it is stated
|
|
856
|
+
* some other way (a narrowed build context, `inputs`). Never set this
|
|
857
|
+
* for a mount the service reads freely.
|
|
858
|
+
*/
|
|
859
|
+
reachExempt?: boolean;
|
|
844
860
|
/** Container path. */
|
|
845
861
|
target: string;
|
|
846
862
|
readOnly?: boolean;
|
|
@@ -1295,6 +1311,16 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
|
|
|
1295
1311
|
* like named desktop browsers.
|
|
1296
1312
|
*/
|
|
1297
1313
|
mobile(app: MobileApp, name?: string): Promise<Mobile>;
|
|
1314
|
+
/** Drive a real Electron renderer window through locators and user input.
|
|
1315
|
+
* Acquires the app started by electron(); keeps its live state across forks.
|
|
1316
|
+
* Each test gets a self-contained graphical replay. Selecting another window
|
|
1317
|
+
* does not create a new app or isolate its storage from sibling windows.
|
|
1318
|
+
*
|
|
1319
|
+
* const app = await ctx.desktop(ctx.svc.app);
|
|
1320
|
+
* await app.getByRole("textbox", { name: "New note" }).fill("Hello");
|
|
1321
|
+
* await app.getByRole("button", { name: "Add note" }).click();
|
|
1322
|
+
*/
|
|
1323
|
+
desktop(app: DesktopApp, options?: DesktopOptions): Promise<Desktop>;
|
|
1298
1324
|
/** The test's display name. */
|
|
1299
1325
|
readonly testName: string;
|
|
1300
1326
|
/**
|
package/dist/index.js
CHANGED
|
@@ -253,7 +253,7 @@ const BUILD_TARGET_RE = /^[^\s]+$/;
|
|
|
253
253
|
* Runs in `defineEnvironment`, and in the daemon's runtime `startService`
|
|
254
254
|
* twin, inside the VM, where the repo sits at the project root. Existence
|
|
255
255
|
* checks go through the project-file resolver, so the same rules as
|
|
256
|
-
* `ctx.readProjectFile` apply (a `spectest/.
|
|
256
|
+
* `ctx.readProjectFile` apply (a `spectest/.specignore`d path is refused
|
|
257
257
|
* instead of read stale).
|
|
258
258
|
*/
|
|
259
259
|
export function validateServiceImage(serviceName, image) {
|
|
@@ -322,6 +322,11 @@ function validateEnvironmentConfig(config) {
|
|
|
322
322
|
}
|
|
323
323
|
}
|
|
324
324
|
validateCoverage(name, svc.coverage);
|
|
325
|
+
for (const input of svc.inputs ?? []) {
|
|
326
|
+
if (typeof input !== "string" || input.trim() === "" || input.startsWith("/") || input.split("/").includes("..")) {
|
|
327
|
+
throw new Error(`service "${name}" has an invalid \`inputs\` entry ${JSON.stringify(input)} — a project-relative file or directory path`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
325
330
|
for (const raw of svc.hostnames ?? []) {
|
|
326
331
|
const h = raw.toLowerCase();
|
|
327
332
|
if (!HOSTNAME_RE.test(hostPatternBody(h))) {
|
package/dist/project-files.js
CHANGED
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
// is correct for every file that is IN that hash. It can be behind for the two
|
|
13
13
|
// kinds of file the hash excludes: `spectest/tests/**` (excluded so a test-only
|
|
14
14
|
// edit keeps the fast start) and anything a project lists in
|
|
15
|
-
// `spectest/.
|
|
15
|
+
// `spectest/.specignore`.
|
|
16
16
|
//
|
|
17
17
|
// Hence the rule below: a path under `spectest/` resolves against the app copy
|
|
18
18
|
// first, because that copy is always current — this is what lets a fixture live
|
|
19
19
|
// in `spectest/tests/fixtures/` and still be found after it was added. Any
|
|
20
20
|
// other path resolves against /workspace, which the hash keeps current.
|
|
21
21
|
//
|
|
22
|
-
// The remaining hole is a file that `.
|
|
22
|
+
// The remaining hole is a file that `.specignore` excludes AND that sits outside
|
|
23
23
|
// `spectest/`: /workspace holds whatever the cold start uploaded, so a later
|
|
24
24
|
// edit is invisible. Nothing can repair those bytes at read time, so we refuse
|
|
25
25
|
// to read them instead of returning stale content. The control plane writes the
|
|
@@ -31,8 +31,8 @@ export const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
|
|
|
31
31
|
/** The app dir; the user's `spectest/` sits directly under it. */
|
|
32
32
|
export const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
|
|
33
33
|
/** Paths (repo-relative) the warm-template hash skipped because of
|
|
34
|
-
* `spectest/.
|
|
35
|
-
* when the project ships no `.
|
|
34
|
+
* `spectest/.specignore`. Written by the control plane at env start; absent
|
|
35
|
+
* when the project ships no `.specignore`. */
|
|
36
36
|
const ENV_IGNORED_FILE = "/run/spectest-env-ignored.json";
|
|
37
37
|
let _envIgnored;
|
|
38
38
|
function envIgnored() {
|
|
@@ -44,7 +44,7 @@ function envIgnored() {
|
|
|
44
44
|
paths = raw.paths ?? [];
|
|
45
45
|
}
|
|
46
46
|
catch {
|
|
47
|
-
/* No file (no .
|
|
47
|
+
/* No file (no .specignore, or an older env) — nothing to refuse. */
|
|
48
48
|
}
|
|
49
49
|
_envIgnored = new Set(paths);
|
|
50
50
|
return _envIgnored;
|
|
@@ -90,7 +90,7 @@ export function resolveProjectPath(p, opts = {}) {
|
|
|
90
90
|
return app;
|
|
91
91
|
}
|
|
92
92
|
if (envIgnored().has(rel)) {
|
|
93
|
-
throw new Error(`project file "${p}" is excluded by
|
|
93
|
+
throw new Error(`project file "${p}" is excluded by the environment cache ignore rules, so the copy in the VM ` +
|
|
94
94
|
`is whatever a cold start uploaded and can be out of date. Move it under ` +
|
|
95
95
|
`spectest/tests/ (still cache-free, and always re-uploaded), or drop the pattern.`);
|
|
96
96
|
}
|
package/dist/recorder.d.ts
CHANGED
|
@@ -464,6 +464,8 @@ export interface BrowserEvent extends BaseEvent {
|
|
|
464
464
|
* bundle to look the session up in.
|
|
465
465
|
*/
|
|
466
466
|
sessionName?: string;
|
|
467
|
+
/** Presentation kind; Electron and mobile share the browser event transport. */
|
|
468
|
+
frame?: "browser" | "mobile" | "desktop";
|
|
467
469
|
/**
|
|
468
470
|
* Wall-clock `Date.now()` captured at the moment this op *finished*.
|
|
469
471
|
* Lives in the same time base as rrweb's `event.timestamp` fields,
|