@specific.dev/spectest 0.86.2 → 0.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- 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 +79 -17
- 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/coverage.d.ts +9 -3
- package/dist/harness/coverage.js +21 -3
- package/dist/harness/reach.d.ts +33 -2
- package/dist/harness/reach.js +111 -9
- package/dist/harness/wrapper-rules.d.ts +13 -0
- package/dist/harness/wrapper-rules.js +37 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +5 -0
- 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/index.ts +1 -0
- 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 +90 -21
- package/src/desktop-recorder.test.ts +55 -0
- package/src/desktop-recorder.ts +30 -0
- package/src/desktop.ts +43 -0
- package/src/harness/coverage.ts +20 -3
- package/src/harness/reach.test.ts +79 -1
- package/src/harness/reach.ts +118 -9
- package/src/harness/wrapper-rules.test.ts +26 -0
- package/src/harness/wrapper-rules.ts +39 -0
- package/src/index.ts +33 -0
- package/src/inputs.test.ts +20 -0
- package/src/recorder.ts +2 -0
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
|
+
}
|
package/src/harness/coverage.ts
CHANGED
|
@@ -40,9 +40,13 @@ export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
|
|
|
40
40
|
* project's reach (bring-up) and the project files the harness read. */
|
|
41
41
|
export const ENVIRONMENT_SERVICE = "__environment__";
|
|
42
42
|
|
|
43
|
-
/** The bring-up report
|
|
44
|
-
*
|
|
45
|
-
export const REACH_REPORT = "reach.
|
|
43
|
+
/** The bring-up report carrying the project's reach as rules plus a
|
|
44
|
+
* compacted prefix list (`harness/reach.ts`, `ReachRules`). */
|
|
45
|
+
export const REACH_REPORT = "reach.json";
|
|
46
|
+
|
|
47
|
+
/** The per-capture report naming the project files the harness read
|
|
48
|
+
* through the SDK since the previous capture, as whole lcov records. */
|
|
49
|
+
export const READS_REPORT = "reads.lcov";
|
|
46
50
|
|
|
47
51
|
/** Raw bytes of reports one service may ship per capture. Reports are
|
|
48
52
|
* bounded by code size times chain depth, so anything past this is a
|
|
@@ -145,13 +149,26 @@ export function isUnitsReport(text: string): boolean {
|
|
|
145
149
|
* script record, or (by name) a `.units` list. */
|
|
146
150
|
export function isWellFormedReport(text: string, name = ""): boolean {
|
|
147
151
|
if (name.endsWith(".units")) return isUnitsReport(text);
|
|
152
|
+
if (name === REACH_REPORT) return isReachRules(text);
|
|
148
153
|
return isLcovShaped(text) || isV8CoverageJson(text) || isSpectestScriptRecord(text);
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
/** True when `text` is a `reach.json` document: a JSON object with a
|
|
157
|
+
* `sources` array and a `prefixes` array. */
|
|
158
|
+
export function isReachRules(text: string): boolean {
|
|
159
|
+
try {
|
|
160
|
+
const v = JSON.parse(text) as { sources?: unknown; prefixes?: unknown };
|
|
161
|
+
return typeof v === "object" && v !== null && Array.isArray(v.sources) && Array.isArray(v.prefixes);
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
151
167
|
/** True when a well-formed report names no source file at all (an lcov
|
|
152
168
|
* with no `SF:`, a V8 document with an empty `result`, a units list
|
|
153
169
|
* with no unit). */
|
|
154
170
|
export function isEmptyReport(text: string): boolean {
|
|
171
|
+
if (isReachRules(text)) return false;
|
|
155
172
|
if (isV8CoverageJson(text)) return /"result"\s*:\s*\[\s*\]/.test(text);
|
|
156
173
|
if (isSpectestScriptRecord(text)) return false;
|
|
157
174
|
if (isUnitsReport(text) && !isLcovShaped(text)) return text.trim().length === 0;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { compileDockerignore, isExcluded, projectReach, renderReadsLcov } from "./reach.js";
|
|
2
|
+
import { compactReach, compileDockerignore, isExcluded, projectReach, reachContains, renderReadsLcov, sourceOf } from "./reach.js";
|
|
3
3
|
|
|
4
4
|
describe("dockerignore semantics", () => {
|
|
5
5
|
const ex = (text: string, rel: string) => isExcluded(compileDockerignore(text), rel);
|
|
@@ -67,3 +67,81 @@ describe("projectReach", () => {
|
|
|
67
67
|
expect(renderReadsLcov(["docs/x.md"])).toBe("TN:\nSF:docs/x.md\nend_of_record\n");
|
|
68
68
|
});
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
describe("reach rules", () => {
|
|
72
|
+
const files = [
|
|
73
|
+
"package.json",
|
|
74
|
+
"packages/api/src/a.ts",
|
|
75
|
+
"packages/api/src/b.ts",
|
|
76
|
+
"packages/api/Dockerfile",
|
|
77
|
+
"packages/api/README.md",
|
|
78
|
+
"packages/dashboard/src/b.tsx",
|
|
79
|
+
"site/app/page.tsx",
|
|
80
|
+
".github/workflows/ci.yml",
|
|
81
|
+
"docs/x.md",
|
|
82
|
+
"spectest/index.ts",
|
|
83
|
+
"data/seed.sql",
|
|
84
|
+
];
|
|
85
|
+
const sources = [
|
|
86
|
+
{ service: "api", context: ".", ignore: "site/**\ndocs/**\n**/*.md\n", extraFiles: ["packages/api/Dockerfile"] },
|
|
87
|
+
{ service: "db", volumeSources: ["data", "/var/run/docker.sock"] },
|
|
88
|
+
];
|
|
89
|
+
const reach = projectReach(files, sources);
|
|
90
|
+
const rules = { version: 1 as const, sources, prefixes: compactReach(files, reach) };
|
|
91
|
+
|
|
92
|
+
test("compactReach folds a wholly reached directory into one prefix and keeps the odd file", () => {
|
|
93
|
+
// packages/api holds README.md, which the ignore drops, so the
|
|
94
|
+
// directory is not whole; its src/ subdirectory is.
|
|
95
|
+
expect(rules.prefixes).toEqual([
|
|
96
|
+
".github/",
|
|
97
|
+
"data/",
|
|
98
|
+
"package.json",
|
|
99
|
+
"packages/api/Dockerfile",
|
|
100
|
+
"packages/api/src/",
|
|
101
|
+
"packages/dashboard/",
|
|
102
|
+
"spectest/",
|
|
103
|
+
]);
|
|
104
|
+
expect(compactReach(files, files)).toEqual(["."]);
|
|
105
|
+
expect(compactReach(files, [])).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("reachContains answers from the prefixes, then from the rules for a path the prefixes do not know", () => {
|
|
109
|
+
expect(reachContains(rules, "packages/api/src/a.ts")).toBe(true);
|
|
110
|
+
expect(reachContains(rules, "packages/api/src/new.ts")).toBe(true);
|
|
111
|
+
expect(reachContains(rules, "site/app/page.tsx")).toBe(false);
|
|
112
|
+
expect(reachContains(rules, "site/new/thing.tsx")).toBe(false);
|
|
113
|
+
expect(reachContains(rules, "docs/new.md")).toBe(false);
|
|
114
|
+
// A new file the ignore's *.md rule drops, in a directory no prefix
|
|
115
|
+
// covers whole: the rules decide, and they say out.
|
|
116
|
+
expect(reachContains(rules, "packages/api/NOTES.md")).toBe(false);
|
|
117
|
+
// A new file under a whole prefix that the rules would drop: the
|
|
118
|
+
// prefix answers first, on the safe side (in).
|
|
119
|
+
expect(reachContains(rules, "packages/api/src/NOTES.md")).toBe(true);
|
|
120
|
+
// A volume source subtree, and an absolute source that is no project path.
|
|
121
|
+
expect(reachContains(rules, "data/new.sql")).toBe(true);
|
|
122
|
+
// The root context carries data/ too, and it is asked first.
|
|
123
|
+
expect(sourceOf(sources, "data/new.sql")?.service).toBe("api");
|
|
124
|
+
expect(sourceOf([sources[1]!], "data/new.sql")?.service).toBe("db");
|
|
125
|
+
expect(sourceOf([sources[1]!], "var/run/docker.sock")).toBeUndefined();
|
|
126
|
+
// The Dockerfile named by path, and the one at the context root.
|
|
127
|
+
expect(sourceOf(sources, "packages/api/Dockerfile")?.service).toBe("api");
|
|
128
|
+
expect(reachContains({ version: 1, sources: [{ context: "packages/x", ignore: "**" }], prefixes: [] }, "packages/x/Dockerfile")).toBe(true);
|
|
129
|
+
});
|
|
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
|
+
|
|
143
|
+
test("the whole project as one prefix", () => {
|
|
144
|
+
expect(reachContains({ version: 1, sources: [], prefixes: ["."] }, "anything/at/all")).toBe(true);
|
|
145
|
+
expect(reachContains({ version: 1, sources: [], prefixes: [] }, "anything/at/all")).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/harness/reach.ts
CHANGED
|
@@ -4,14 +4,24 @@
|
|
|
4
4
|
// a service's **build context** (so an image may carry it), it is the
|
|
5
5
|
// **source of a volume** a service mounts, or a test **reads** it through
|
|
6
6
|
// the SDK. The first two are known from the environment config before any
|
|
7
|
-
// test runs; the harness
|
|
8
|
-
// **
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
7
|
+
// test runs; the harness ships them with the bring-up capture as
|
|
8
|
+
// **rules** (`reach.json`, under the synthetic `__environment__`
|
|
9
|
+
// service): per service its context directory, the composed
|
|
10
|
+
// `.dockerignore` text, the Dockerfile it reads and the volume sources it
|
|
11
|
+
// mounts. Rules, not a file list: the control plane evaluates any changed
|
|
12
|
+
// path against them, a file that did not exist at the base included, and
|
|
13
|
+
// a project of ten thousand files ships a few lines. Beside the rules
|
|
14
|
+
// rides a **compacted** view of the current file reach — a directory
|
|
15
|
+
// every file of which is in the reach as one `dir/` prefix, the odd file
|
|
16
|
+
// alone — which the control plane uses as a fast path and the shadow page
|
|
17
|
+
// as the summary. The subset selector then treats a changed file outside
|
|
18
|
+
// the reach as one that cannot affect the suite: `site/**` behind a
|
|
12
19
|
// `.dockerignore`, `.github/**` outside every context. The third door is
|
|
13
20
|
// recorded as it happens (`project-files.ts`, `takeProjectReads`).
|
|
14
21
|
//
|
|
22
|
+
// The rules are shipped for every project, coverage adapters or not: the
|
|
23
|
+
// reach is a property of the environment config alone.
|
|
24
|
+
//
|
|
15
25
|
// The context of a build is what `docker build` would send: every file
|
|
16
26
|
// under the context directory minus what the composed `.dockerignore`
|
|
17
27
|
// (the project's own rules plus the service's `exclude`) matches, with
|
|
@@ -102,6 +112,8 @@ export function isExcluded(patterns: Pattern[], rel: string): boolean {
|
|
|
102
112
|
|
|
103
113
|
/** One service's contribution to the reach. */
|
|
104
114
|
export interface ReachSource {
|
|
115
|
+
/** The service the source belongs to (for the shadow page's verdicts). */
|
|
116
|
+
service?: string;
|
|
105
117
|
/** Build context directory, project-relative (`.` for the root); absent
|
|
106
118
|
* for a registry image. */
|
|
107
119
|
context?: string;
|
|
@@ -113,6 +125,10 @@ export interface ReachSource {
|
|
|
113
125
|
/** Volume sources, project-relative; an absolute source is not a
|
|
114
126
|
* project path and is skipped. */
|
|
115
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[];
|
|
116
132
|
}
|
|
117
133
|
|
|
118
134
|
function normRel(p: string): string {
|
|
@@ -145,7 +161,7 @@ export function projectReach(files: readonly string[], sources: readonly ReachSo
|
|
|
145
161
|
const rel = normRel(e);
|
|
146
162
|
if (rel) out.add(rel);
|
|
147
163
|
}
|
|
148
|
-
for (const v of s.volumeSources ?? []) {
|
|
164
|
+
for (const v of [...(s.volumeSources ?? []), ...(s.inputs ?? [])]) {
|
|
149
165
|
if (v.startsWith("/")) continue;
|
|
150
166
|
const src = normRel(v);
|
|
151
167
|
for (const f of files) {
|
|
@@ -156,9 +172,102 @@ export function projectReach(files: readonly string[], sources: readonly ReachSo
|
|
|
156
172
|
return [...out].sort();
|
|
157
173
|
}
|
|
158
174
|
|
|
159
|
-
/** The `reach.
|
|
160
|
-
export
|
|
161
|
-
|
|
175
|
+
/** The `reach.json` report. Mirrors `reach::ReachRules` on the server. */
|
|
176
|
+
export interface ReachRules {
|
|
177
|
+
version: 1;
|
|
178
|
+
/** Every service's contribution, as rules. */
|
|
179
|
+
sources: ReachSource[];
|
|
180
|
+
/** The current file reach, compacted: `dir/` for a directory every
|
|
181
|
+
* project file of which is in the reach, a bare path for a file on its
|
|
182
|
+
* own, `.` when the whole project is. */
|
|
183
|
+
prefixes: string[];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Compact a file reach against the project's file list: a directory
|
|
188
|
+
* whose every file is in the reach becomes one `dir/` prefix (the root
|
|
189
|
+
* becomes `.`), and a file whose directory is not wholly reached stays a
|
|
190
|
+
* path. Only directories that hold a project file exist here; an empty
|
|
191
|
+
* directory has nothing to be in or out of the reach.
|
|
192
|
+
*/
|
|
193
|
+
export function compactReach(files: readonly string[], reach: readonly string[]): string[] {
|
|
194
|
+
const inReach = new Set(reach);
|
|
195
|
+
// Per directory: how many project files live below it, how many of
|
|
196
|
+
// those the reach holds. "" is the root.
|
|
197
|
+
const total = new Map<string, number>();
|
|
198
|
+
const hit = new Map<string, number>();
|
|
199
|
+
const children = new Map<string, Set<string>>();
|
|
200
|
+
const filesIn = new Map<string, string[]>();
|
|
201
|
+
for (const f of files) {
|
|
202
|
+
const parts = f.split("/");
|
|
203
|
+
let dir = "";
|
|
204
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
205
|
+
total.set(dir, (total.get(dir) ?? 0) + 1);
|
|
206
|
+
if (inReach.has(f)) hit.set(dir, (hit.get(dir) ?? 0) + 1);
|
|
207
|
+
if (i === parts.length - 1) {
|
|
208
|
+
let list = filesIn.get(dir);
|
|
209
|
+
if (!list) filesIn.set(dir, (list = []));
|
|
210
|
+
list.push(f);
|
|
211
|
+
} else {
|
|
212
|
+
const child = dir === "" ? parts[i]! : `${dir}/${parts[i]!}`;
|
|
213
|
+
let set = children.get(dir);
|
|
214
|
+
if (!set) children.set(dir, (set = new Set()));
|
|
215
|
+
set.add(child);
|
|
216
|
+
dir = child;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const out: string[] = [];
|
|
221
|
+
const walk = (dir: string): void => {
|
|
222
|
+
const n = total.get(dir) ?? 0;
|
|
223
|
+
if (n > 0 && (hit.get(dir) ?? 0) === n) {
|
|
224
|
+
out.push(dir === "" ? "." : `${dir}/`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
for (const f of filesIn.get(dir) ?? []) if (inReach.has(f)) out.push(f);
|
|
228
|
+
for (const c of children.get(dir) ?? []) walk(c);
|
|
229
|
+
};
|
|
230
|
+
walk("");
|
|
231
|
+
return out.sort();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Is `path` (project-relative) in the reach the rules describe? The
|
|
235
|
+
* compacted prefixes answer first; a path outside them is evaluated
|
|
236
|
+
* against every source, so a file that did not exist when the prefixes
|
|
237
|
+
* were computed gets the same answer the build would give. */
|
|
238
|
+
export function reachContains(rules: ReachRules, path: string): boolean {
|
|
239
|
+
const p = normRel(path);
|
|
240
|
+
for (const pre of rules.prefixes) {
|
|
241
|
+
if (pre === "." || pre === p || (pre.endsWith("/") && p.startsWith(pre))) return true;
|
|
242
|
+
}
|
|
243
|
+
return sourceOf(rules.sources, p) !== undefined;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The first source whose rules carry `path`, or `undefined`. */
|
|
247
|
+
export function sourceOf(sources: readonly ReachSource[], path: string): ReachSource | undefined {
|
|
248
|
+
const p = normRel(path);
|
|
249
|
+
for (const s of sources) {
|
|
250
|
+
if (s.context !== undefined) {
|
|
251
|
+
const ctx = normRel(s.context);
|
|
252
|
+
const prefix = ctx === "" ? "" : `${ctx}/`;
|
|
253
|
+
if (!prefix || p.startsWith(prefix)) {
|
|
254
|
+
const rel = p.slice(prefix.length);
|
|
255
|
+
if (rel === ".dockerignore" || rel === "Dockerfile" || !isExcluded(compileDockerignore(s.ignore ?? ""), rel)) return s;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const e of s.extraFiles ?? []) if (normRel(e) === p) return s;
|
|
259
|
+
for (const v of [...(s.volumeSources ?? []), ...(s.inputs ?? [])]) {
|
|
260
|
+
if (v.startsWith("/")) continue;
|
|
261
|
+
const src = normRel(v);
|
|
262
|
+
if (p === src || p.startsWith(`${src}/`)) return s;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The `reach.json` report text. */
|
|
269
|
+
export function renderReachRules(rules: ReachRules): string {
|
|
270
|
+
return JSON.stringify(rules);
|
|
162
271
|
}
|
|
163
272
|
|
|
164
273
|
/** An lcov document that names `paths` as files with no line data —
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
splitUnion,
|
|
11
11
|
startOfNode,
|
|
12
12
|
truthyMessage,
|
|
13
|
+
CODE_RAW_FS,
|
|
14
|
+
fsUsageDiagnostics,
|
|
13
15
|
} from "./wrapper-rules.js";
|
|
14
16
|
|
|
15
17
|
describe("splitUnion", () => {
|
|
@@ -168,3 +170,27 @@ describe("startOfNode", () => {
|
|
|
168
170
|
expect(startOfNode("a;\n/* open", 2)).toBe(10);
|
|
169
171
|
});
|
|
170
172
|
});
|
|
173
|
+
|
|
174
|
+
describe("raw fs reads in tests (SPECTEST2003, advisory)", () => {
|
|
175
|
+
test("imports, requires, dynamic imports and Bun.file are found; other modules are not", () => {
|
|
176
|
+
const text = [
|
|
177
|
+
'import { readFileSync } from "node:fs";',
|
|
178
|
+
'import fsp from "fs/promises";',
|
|
179
|
+
'import path from "node:path";',
|
|
180
|
+
'const fs = require("fs");',
|
|
181
|
+
'const p = await import("node:fs/promises");',
|
|
182
|
+
'const t = await Bun.file("x").text();',
|
|
183
|
+
'import { z } from "zod";',
|
|
184
|
+
].join("\n");
|
|
185
|
+
const d = fsUsageDiagnostics("tests/a.ts", text);
|
|
186
|
+
expect(d.map((x) => [x.line, x.code, x.blocking])).toEqual([
|
|
187
|
+
[1, CODE_RAW_FS, false],
|
|
188
|
+
[2, CODE_RAW_FS, false],
|
|
189
|
+
[4, CODE_RAW_FS, false],
|
|
190
|
+
[5, CODE_RAW_FS, false],
|
|
191
|
+
[6, CODE_RAW_FS, false],
|
|
192
|
+
]);
|
|
193
|
+
expect(d[0]!.message).toContain("ctx.readProjectFile");
|
|
194
|
+
expect(fsUsageDiagnostics("tests/b.ts", 'import x from "./fs-helpers.js";\nconst fsLike = 1;')).toEqual([]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -56,6 +56,45 @@
|
|
|
56
56
|
// that happens to be called `Carrier<T>` would also be flagged — and would
|
|
57
57
|
// also be an object at runtime, so the finding stays true.
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* A test that opens a project file through `node:fs` (or `Bun.file`)
|
|
61
|
+
* instead of `ctx.readProjectFile`. **Advisory.** The subset selector
|
|
62
|
+
* learns which project files a test depends on from the reads the SDK
|
|
63
|
+
* serves (`project-files.ts`); a raw read is a dependence it never sees,
|
|
64
|
+
* so a change to that file can skip the test. The rule is textual — an
|
|
65
|
+
* import or `require` of the fs modules, or a `Bun.file(` call — and is
|
|
66
|
+
* applied to the test tree only, where the alternative exists. It turns
|
|
67
|
+
* blocking when the reach gate enforces (`SUBSET_RUNS.md`).
|
|
68
|
+
*/
|
|
69
|
+
export const CODE_RAW_FS = "SPECTEST2003";
|
|
70
|
+
|
|
71
|
+
const FS_MODULES = new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"]);
|
|
72
|
+
|
|
73
|
+
/** The raw-fs findings of one test file, from its text alone. */
|
|
74
|
+
export function fsUsageDiagnostics(file: string, text: string): WrapperDiagnostic[] {
|
|
75
|
+
const out: WrapperDiagnostic[] = [];
|
|
76
|
+
const push = (pos: number, what: string) => {
|
|
77
|
+
const { line, column } = lineColumnAt(text, pos);
|
|
78
|
+
out.push({
|
|
79
|
+
file,
|
|
80
|
+
line,
|
|
81
|
+
column,
|
|
82
|
+
code: CODE_RAW_FS,
|
|
83
|
+
message:
|
|
84
|
+
`${what} reads project files behind the SDK's back; use ctx.readProjectFile (or ctx.readProjectFileBytes) ` +
|
|
85
|
+
"so the subset selector knows the test depends on the file",
|
|
86
|
+
blocking: false,
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
const importRe = /\b(?:import|export)\b[^;'"]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]|\brequire\(\s*['"]([^'"]+)['"]\s*\)|\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
90
|
+
for (const m of text.matchAll(importRe)) {
|
|
91
|
+
const spec = m[1] ?? m[2] ?? m[3] ?? m[4] ?? "";
|
|
92
|
+
if (FS_MODULES.has(spec)) push(m.index ?? 0, `an import of "${spec}"`);
|
|
93
|
+
}
|
|
94
|
+
for (const m of text.matchAll(/\bBun\.file\(/g)) push(m.index ?? 0, "Bun.file()");
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
59
98
|
/** A finding, shaped like the `TypecheckError` the report already carries. */
|
|
60
99
|
export interface WrapperDiagnostic {
|
|
61
100
|
/** Path relative to the app dir, matching the advisory diagnostics. */
|
package/src/index.ts
CHANGED
|
@@ -141,6 +141,8 @@ import { describeUrlPattern, matchesUrl } from "./url-match.js";
|
|
|
141
141
|
// `expo()` component.
|
|
142
142
|
export type { Mobile, MobileApp } from "./mobile.js";
|
|
143
143
|
import type { Mobile, MobileApp } from "./mobile.js";
|
|
144
|
+
export type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
|
|
145
|
+
import type { Desktop, DesktopApp, DesktopOptions } from "./desktop.js";
|
|
144
146
|
|
|
145
147
|
export type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
|
|
146
148
|
|
|
@@ -308,6 +310,13 @@ export interface ServiceConfig {
|
|
|
308
310
|
tls?: readonly ServiceTls[];
|
|
309
311
|
/** Bind-mounted volumes for state that survives snapshot/fork. */
|
|
310
312
|
volumes?: readonly VolumeMount[];
|
|
313
|
+
/**
|
|
314
|
+
* @internal Spectest's own components only: project files or
|
|
315
|
+
* directories the service consumes outside its build context and
|
|
316
|
+
* volumes (a lambda's `source`, Supabase's folder). Project-relative; a
|
|
317
|
+
* directory covers its subtree. The reach counts them.
|
|
318
|
+
*/
|
|
319
|
+
inputs?: readonly string[];
|
|
311
320
|
/**
|
|
312
321
|
* Opt this service in to **code coverage collection** (experimental).
|
|
313
322
|
*
|
|
@@ -1320,6 +1329,13 @@ export interface VolumeMount {
|
|
|
1320
1329
|
* so unlike a volume directory it is never created or wiped by spectest.
|
|
1321
1330
|
*/
|
|
1322
1331
|
source?: string;
|
|
1332
|
+
/**
|
|
1333
|
+
* @internal Spectest's own components only. The mount does not count
|
|
1334
|
+
* for the project's reach: what the service can load from it is stated
|
|
1335
|
+
* some other way (a narrowed build context, `inputs`). Never set this
|
|
1336
|
+
* for a mount the service reads freely.
|
|
1337
|
+
*/
|
|
1338
|
+
reachExempt?: boolean;
|
|
1323
1339
|
/** Container path. */
|
|
1324
1340
|
target: string;
|
|
1325
1341
|
readOnly?: boolean;
|
|
@@ -1553,6 +1569,13 @@ function validateEnvironmentConfig<S extends ServicesMap>(
|
|
|
1553
1569
|
}
|
|
1554
1570
|
}
|
|
1555
1571
|
validateCoverage(name, svc.coverage);
|
|
1572
|
+
for (const input of svc.inputs ?? []) {
|
|
1573
|
+
if (typeof input !== "string" || input.trim() === "" || input.startsWith("/") || input.split("/").includes("..")) {
|
|
1574
|
+
throw new Error(
|
|
1575
|
+
`service "${name}" has an invalid \`inputs\` entry ${JSON.stringify(input)} — a project-relative file or directory path`,
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1556
1579
|
for (const raw of svc.hostnames ?? []) {
|
|
1557
1580
|
const h = raw.toLowerCase();
|
|
1558
1581
|
if (!HOSTNAME_RE.test(hostPatternBody(h))) {
|
|
@@ -1928,6 +1951,16 @@ export interface TestContext<
|
|
|
1928
1951
|
* like named desktop browsers.
|
|
1929
1952
|
*/
|
|
1930
1953
|
mobile(app: MobileApp, name?: string): Promise<Mobile>;
|
|
1954
|
+
/** Drive a real Electron renderer window through locators and user input.
|
|
1955
|
+
* Acquires the app started by electron(); keeps its live state across forks.
|
|
1956
|
+
* Each test gets a self-contained graphical replay. Selecting another window
|
|
1957
|
+
* does not create a new app or isolate its storage from sibling windows.
|
|
1958
|
+
*
|
|
1959
|
+
* const app = await ctx.desktop(ctx.svc.app);
|
|
1960
|
+
* await app.getByRole("textbox", { name: "New note" }).fill("Hello");
|
|
1961
|
+
* await app.getByRole("button", { name: "Add note" }).click();
|
|
1962
|
+
*/
|
|
1963
|
+
desktop(app: DesktopApp, options?: DesktopOptions): Promise<Desktop>;
|
|
1931
1964
|
/** The test's display name. */
|
|
1932
1965
|
readonly testName: string;
|
|
1933
1966
|
/**
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineEnvironment } from "./index.js";
|
|
3
|
+
|
|
4
|
+
const image = { type: "registry" as const, reference: "busybox" };
|
|
5
|
+
|
|
6
|
+
describe("service inputs", () => {
|
|
7
|
+
test("project-relative files and directories are accepted and kept on the config", () => {
|
|
8
|
+
const env = defineEnvironment({
|
|
9
|
+
name: "t",
|
|
10
|
+
services: { app: { image, inputs: ["lambda/add", "supabase/config.toml"] } },
|
|
11
|
+
});
|
|
12
|
+
expect(env.config.services.app!.inputs).toEqual(["lambda/add", "supabase/config.toml"]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("an absolute path, an empty entry and a parent reference are refused", () => {
|
|
16
|
+
for (const bad of ["/etc/passwd", "", "../outside"]) {
|
|
17
|
+
expect(() => defineEnvironment({ name: "t", services: { app: { image, inputs: [bad] } } })).toThrow(/invalid `inputs` entry/);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
});
|
package/src/recorder.ts
CHANGED
|
@@ -520,6 +520,8 @@ export interface BrowserEvent extends BaseEvent {
|
|
|
520
520
|
* bundle to look the session up in.
|
|
521
521
|
*/
|
|
522
522
|
sessionName?: string;
|
|
523
|
+
/** Presentation kind; Electron and mobile share the browser event transport. */
|
|
524
|
+
frame?: "browser" | "mobile" | "desktop";
|
|
523
525
|
/**
|
|
524
526
|
* Wall-clock `Date.now()` captured at the moment this op *finished*.
|
|
525
527
|
* Lives in the same time base as rrweb's `event.timestamp` fields,
|