@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
|
@@ -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
|
@@ -24,12 +24,13 @@ import path from "node:path";
|
|
|
24
24
|
import { pathToFileURL } from "node:url";
|
|
25
25
|
import { ENVIRONMENT_CA_PATH as CA_PATH } from "./environment-ca.js";
|
|
26
26
|
import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, proxy as makeProxyDecl, } from "./index.js";
|
|
27
|
-
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, ENVIRONMENT_SERVICE, REACH_REPORT, } from "./harness/coverage.js";
|
|
27
|
+
import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, ENVIRONMENT_SERVICE, REACH_REPORT, READS_REPORT, } from "./harness/coverage.js";
|
|
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
|
|
@@ -41,10 +42,10 @@ import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
|
41
42
|
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
|
|
42
43
|
import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
|
|
43
44
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
44
|
-
import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
45
|
+
import { fsUsageDiagnostics, runWrapperRules } from "./harness/wrapper-rules.js";
|
|
45
46
|
import { cpus } from "node:os";
|
|
46
47
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
|
|
47
|
-
import { projectReach,
|
|
48
|
+
import { compactReach, projectReach, renderReachRules, renderReadsLcov } from "./harness/reach.js";
|
|
48
49
|
import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
49
50
|
import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
|
|
50
51
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
@@ -3584,7 +3585,9 @@ async function computeProjectReachInner(all) {
|
|
|
3584
3585
|
const sources = [];
|
|
3585
3586
|
for (const svc of all) {
|
|
3586
3587
|
const src = {
|
|
3587
|
-
|
|
3588
|
+
service: svc.name,
|
|
3589
|
+
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
|
|
3590
|
+
...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
|
|
3588
3591
|
};
|
|
3589
3592
|
if (svc.image.type === "dockerfile") {
|
|
3590
3593
|
try {
|
|
@@ -3603,29 +3606,33 @@ async function computeProjectReachInner(all) {
|
|
|
3603
3606
|
}
|
|
3604
3607
|
sources.push(src);
|
|
3605
3608
|
}
|
|
3606
|
-
|
|
3609
|
+
const reach = projectReach(files, sources);
|
|
3610
|
+
return { version: 1, sources, prefixes: compactReach(files, reach) };
|
|
3607
3611
|
}
|
|
3608
3612
|
/**
|
|
3609
3613
|
* The synthetic `__environment__` capture: what the environment itself
|
|
3610
|
-
* knows — the project's reach (bring-up only) and the project
|
|
3611
|
-
* harness read through the SDK since the previous capture, as
|
|
3612
|
-
* lcov records. Present
|
|
3613
|
-
* the
|
|
3614
|
+
* knows — the project's reach as rules (bring-up only) and the project
|
|
3615
|
+
* files the harness read through the SDK since the previous capture, as
|
|
3616
|
+
* whole lcov records. Present on every capture of every project, coverage
|
|
3617
|
+
* adapters or not: the reach is a property of the environment config,
|
|
3618
|
+
* and it is what lets the control plane tell that a change to `site/**`
|
|
3619
|
+
* can affect no test at all. When services opted in, the two ride the
|
|
3620
|
+
* same bundle as their reports.
|
|
3614
3621
|
*/
|
|
3615
3622
|
async function captureEnvironmentCoverage(all, baseline) {
|
|
3616
3623
|
const reports = [];
|
|
3617
3624
|
if (baseline) {
|
|
3618
3625
|
try {
|
|
3619
|
-
const
|
|
3620
|
-
reports.push({ name: REACH_REPORT, content:
|
|
3621
|
-
console.log(`[coverage] project reach: ${
|
|
3626
|
+
const rules = await computeProjectReach(all);
|
|
3627
|
+
reports.push({ name: REACH_REPORT, content: renderReachRules(rules) });
|
|
3628
|
+
console.log(`[coverage] project reach: ${rules.sources.length} source(s), ${rules.prefixes.length} prefix(es) some build or volume can carry`);
|
|
3622
3629
|
}
|
|
3623
3630
|
catch (err) {
|
|
3624
3631
|
console.warn(`[coverage] project reach not computed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3625
3632
|
}
|
|
3626
3633
|
}
|
|
3627
3634
|
const reads = takeProjectReads();
|
|
3628
|
-
reports.push({ name:
|
|
3635
|
+
reports.push({ name: READS_REPORT, content: renderReadsLcov(reads) });
|
|
3629
3636
|
return { service: ENVIRONMENT_SERVICE, reports };
|
|
3630
3637
|
}
|
|
3631
3638
|
async function captureServiceCoverage(baseline = false) {
|
|
@@ -3639,8 +3646,8 @@ async function captureServiceCoverage(baseline = false) {
|
|
|
3639
3646
|
byName.set(name, s);
|
|
3640
3647
|
const all = [...byName.values()];
|
|
3641
3648
|
const services = all.filter((s) => s.coverage !== undefined);
|
|
3642
|
-
|
|
3643
|
-
|
|
3649
|
+
// The environment's own capture is unconditional (see above); the
|
|
3650
|
+
// services' come from the ones that opted in, none included.
|
|
3644
3651
|
const environment = captureEnvironmentCoverage(all, baseline);
|
|
3645
3652
|
const captures = await Promise.all(services.map(async (svc) => {
|
|
3646
3653
|
const { error, deltaReports } = await runCoverageAdapters(svc);
|
|
@@ -3748,6 +3755,7 @@ function newBrowserSession(testStart, idScope, frame = "browser", artifacts, nam
|
|
|
3748
3755
|
record,
|
|
3749
3756
|
recorder: {
|
|
3750
3757
|
sessionId: record.sessionId,
|
|
3758
|
+
frame,
|
|
3751
3759
|
...(name ? { sessionName: name } : {}),
|
|
3752
3760
|
recordStep(step) {
|
|
3753
3761
|
if (closed)
|
|
@@ -3772,6 +3780,33 @@ function newBrowserSession(testStart, idScope, frame = "browser", artifacts, nam
|
|
|
3772
3780
|
},
|
|
3773
3781
|
};
|
|
3774
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
|
+
}
|
|
3775
3810
|
/** New terminal session bookkeeping for a single `ctx.terminal(...)` call. */
|
|
3776
3811
|
function newTerminalSession(testStart, service, command, cols, rows, idScope) {
|
|
3777
3812
|
const record = {
|
|
@@ -4643,6 +4678,7 @@ async function runOne(testCase) {
|
|
|
4643
4678
|
openTerminal: recordedOpenTerminal,
|
|
4644
4679
|
browser: trackedOpenBrowser,
|
|
4645
4680
|
mobile: trackedOpenMobile,
|
|
4681
|
+
desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
|
|
4646
4682
|
mcp: openMcp,
|
|
4647
4683
|
testName: testCase.name,
|
|
4648
4684
|
parent,
|
|
@@ -5127,6 +5163,7 @@ async function evalCode(code, secrets) {
|
|
|
5127
5163
|
openTerminal: evalOpenTerminal,
|
|
5128
5164
|
browser: trackedOpenBrowser,
|
|
5129
5165
|
mobile: trackedOpenMobile,
|
|
5166
|
+
desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
|
|
5130
5167
|
mcp: openMcp,
|
|
5131
5168
|
testName: "eval",
|
|
5132
5169
|
parent: undefined,
|
|
@@ -5352,7 +5389,7 @@ async function runTypecheck() {
|
|
|
5352
5389
|
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
5353
5390
|
}
|
|
5354
5391
|
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
|
|
5355
|
-
const wrapper = await runWrapperRulesReport(config);
|
|
5392
|
+
const wrapper = [...(await runWrapperRulesReport(config)), ...(await rawFsReport())];
|
|
5356
5393
|
const strip = ({ file, line, column, code, message }) => ({
|
|
5357
5394
|
file,
|
|
5358
5395
|
line,
|
|
@@ -5389,6 +5426,31 @@ async function runTypecheck() {
|
|
|
5389
5426
|
...(blocking.length > 0 ? { blocking } : {}),
|
|
5390
5427
|
};
|
|
5391
5428
|
}
|
|
5429
|
+
/**
|
|
5430
|
+
* The raw-fs rule (`SPECTEST2003`, advisory) over the test tree: a test
|
|
5431
|
+
* that opens a project file through `node:fs` hides a dependence from the
|
|
5432
|
+
* subset selector. Textual, so it needs no compiler; best-effort.
|
|
5433
|
+
*/
|
|
5434
|
+
async function rawFsReport() {
|
|
5435
|
+
const root = path.join(APP_DIR, "tests");
|
|
5436
|
+
if (!existsSync(root))
|
|
5437
|
+
return [];
|
|
5438
|
+
try {
|
|
5439
|
+
const files = await walkProjectFiles(root, ["node_modules"]);
|
|
5440
|
+
const out = [];
|
|
5441
|
+
for (const rel of files) {
|
|
5442
|
+
if (!/\.(ts|tsx|js|jsx|mts|cts)$/.test(rel))
|
|
5443
|
+
continue;
|
|
5444
|
+
const text = await fs.readFile(path.join(root, rel), "utf8");
|
|
5445
|
+
out.push(...fsUsageDiagnostics(path.join("tests", rel), text));
|
|
5446
|
+
}
|
|
5447
|
+
return out;
|
|
5448
|
+
}
|
|
5449
|
+
catch (err) {
|
|
5450
|
+
console.warn(`[typecheck] raw fs rule threw: ${err?.message ?? err}`);
|
|
5451
|
+
return [];
|
|
5452
|
+
}
|
|
5453
|
+
}
|
|
5392
5454
|
/**
|
|
5393
5455
|
* The blocking rules, run against the BAKED compiler whatever the project
|
|
5394
5456
|
* pins. Best-effort in every direction: an install that predates the API, a
|
|
@@ -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
|
+
}
|
|
@@ -3,9 +3,12 @@ export declare const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
|
|
|
3
3
|
/** The synthetic service the environment's own reports ride under: the
|
|
4
4
|
* project's reach (bring-up) and the project files the harness read. */
|
|
5
5
|
export declare const ENVIRONMENT_SERVICE = "__environment__";
|
|
6
|
-
/** The bring-up report
|
|
7
|
-
*
|
|
8
|
-
export declare const REACH_REPORT = "reach.
|
|
6
|
+
/** The bring-up report carrying the project's reach as rules plus a
|
|
7
|
+
* compacted prefix list (`harness/reach.ts`, `ReachRules`). */
|
|
8
|
+
export declare const REACH_REPORT = "reach.json";
|
|
9
|
+
/** The per-capture report naming the project files the harness read
|
|
10
|
+
* through the SDK since the previous capture, as whole lcov records. */
|
|
11
|
+
export declare const READS_REPORT = "reads.lcov";
|
|
9
12
|
/** Raw bytes of reports one service may ship per capture. Reports are
|
|
10
13
|
* bounded by code size times chain depth, so anything past this is a
|
|
11
14
|
* runaway (a tool writing a new file per request), not coverage. */
|
|
@@ -82,6 +85,9 @@ export declare function isUnitsReport(text: string): boolean;
|
|
|
82
85
|
/** True when `text` is a report spectest stores: lcov, V8 JSON, a node()
|
|
83
86
|
* script record, or (by name) a `.units` list. */
|
|
84
87
|
export declare function isWellFormedReport(text: string, name?: string): boolean;
|
|
88
|
+
/** True when `text` is a `reach.json` document: a JSON object with a
|
|
89
|
+
* `sources` array and a `prefixes` array. */
|
|
90
|
+
export declare function isReachRules(text: string): boolean;
|
|
85
91
|
/** True when a well-formed report names no source file at all (an lcov
|
|
86
92
|
* with no `SF:`, a V8 document with an empty `result`, a units list
|
|
87
93
|
* with no unit). */
|
package/dist/harness/coverage.js
CHANGED
|
@@ -36,9 +36,12 @@ export const COVERAGE_CONTAINER_DIR = "/spectest/coverage";
|
|
|
36
36
|
/** The synthetic service the environment's own reports ride under: the
|
|
37
37
|
* project's reach (bring-up) and the project files the harness read. */
|
|
38
38
|
export const ENVIRONMENT_SERVICE = "__environment__";
|
|
39
|
-
/** The bring-up report
|
|
40
|
-
*
|
|
41
|
-
export const REACH_REPORT = "reach.
|
|
39
|
+
/** The bring-up report carrying the project's reach as rules plus a
|
|
40
|
+
* compacted prefix list (`harness/reach.ts`, `ReachRules`). */
|
|
41
|
+
export const REACH_REPORT = "reach.json";
|
|
42
|
+
/** The per-capture report naming the project files the harness read
|
|
43
|
+
* through the SDK since the previous capture, as whole lcov records. */
|
|
44
|
+
export const READS_REPORT = "reads.lcov";
|
|
42
45
|
/** Raw bytes of reports one service may ship per capture. Reports are
|
|
43
46
|
* bounded by code size times chain depth, so anything past this is a
|
|
44
47
|
* runaway (a tool writing a new file per request), not coverage. */
|
|
@@ -89,12 +92,27 @@ export function isUnitsReport(text) {
|
|
|
89
92
|
export function isWellFormedReport(text, name = "") {
|
|
90
93
|
if (name.endsWith(".units"))
|
|
91
94
|
return isUnitsReport(text);
|
|
95
|
+
if (name === REACH_REPORT)
|
|
96
|
+
return isReachRules(text);
|
|
92
97
|
return isLcovShaped(text) || isV8CoverageJson(text) || isSpectestScriptRecord(text);
|
|
93
98
|
}
|
|
99
|
+
/** True when `text` is a `reach.json` document: a JSON object with a
|
|
100
|
+
* `sources` array and a `prefixes` array. */
|
|
101
|
+
export function isReachRules(text) {
|
|
102
|
+
try {
|
|
103
|
+
const v = JSON.parse(text);
|
|
104
|
+
return typeof v === "object" && v !== null && Array.isArray(v.sources) && Array.isArray(v.prefixes);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
94
110
|
/** True when a well-formed report names no source file at all (an lcov
|
|
95
111
|
* with no `SF:`, a V8 document with an empty `result`, a units list
|
|
96
112
|
* with no unit). */
|
|
97
113
|
export function isEmptyReport(text) {
|
|
114
|
+
if (isReachRules(text))
|
|
115
|
+
return false;
|
|
98
116
|
if (isV8CoverageJson(text))
|
|
99
117
|
return /"result"\s*:\s*\[\s*\]/.test(text);
|
|
100
118
|
if (isSpectestScriptRecord(text))
|
package/dist/harness/reach.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export declare function compileDockerignore(text: string): Pattern[];
|
|
|
12
12
|
export declare function isExcluded(patterns: Pattern[], rel: string): boolean;
|
|
13
13
|
/** One service's contribution to the reach. */
|
|
14
14
|
export interface ReachSource {
|
|
15
|
+
/** The service the source belongs to (for the shadow page's verdicts). */
|
|
16
|
+
service?: string;
|
|
15
17
|
/** Build context directory, project-relative (`.` for the root); absent
|
|
16
18
|
* for a registry image. */
|
|
17
19
|
context?: string;
|
|
@@ -23,6 +25,10 @@ export interface ReachSource {
|
|
|
23
25
|
/** Volume sources, project-relative; an absolute source is not a
|
|
24
26
|
* project path and is skipped. */
|
|
25
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[];
|
|
26
32
|
}
|
|
27
33
|
/**
|
|
28
34
|
* The project's reach: every project file (`files`, project-relative)
|
|
@@ -30,8 +36,33 @@ export interface ReachSource {
|
|
|
30
36
|
* Dockerfiles the builds read. Sorted, unique.
|
|
31
37
|
*/
|
|
32
38
|
export declare function projectReach(files: readonly string[], sources: readonly ReachSource[]): string[];
|
|
33
|
-
/** The `reach.
|
|
34
|
-
export
|
|
39
|
+
/** The `reach.json` report. Mirrors `reach::ReachRules` on the server. */
|
|
40
|
+
export interface ReachRules {
|
|
41
|
+
version: 1;
|
|
42
|
+
/** Every service's contribution, as rules. */
|
|
43
|
+
sources: ReachSource[];
|
|
44
|
+
/** The current file reach, compacted: `dir/` for a directory every
|
|
45
|
+
* project file of which is in the reach, a bare path for a file on its
|
|
46
|
+
* own, `.` when the whole project is. */
|
|
47
|
+
prefixes: string[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Compact a file reach against the project's file list: a directory
|
|
51
|
+
* whose every file is in the reach becomes one `dir/` prefix (the root
|
|
52
|
+
* becomes `.`), and a file whose directory is not wholly reached stays a
|
|
53
|
+
* path. Only directories that hold a project file exist here; an empty
|
|
54
|
+
* directory has nothing to be in or out of the reach.
|
|
55
|
+
*/
|
|
56
|
+
export declare function compactReach(files: readonly string[], reach: readonly string[]): string[];
|
|
57
|
+
/** Is `path` (project-relative) in the reach the rules describe? The
|
|
58
|
+
* compacted prefixes answer first; a path outside them is evaluated
|
|
59
|
+
* against every source, so a file that did not exist when the prefixes
|
|
60
|
+
* were computed gets the same answer the build would give. */
|
|
61
|
+
export declare function reachContains(rules: ReachRules, path: string): boolean;
|
|
62
|
+
/** The first source whose rules carry `path`, or `undefined`. */
|
|
63
|
+
export declare function sourceOf(sources: readonly ReachSource[], path: string): ReachSource | undefined;
|
|
64
|
+
/** The `reach.json` report text. */
|
|
65
|
+
export declare function renderReachRules(rules: ReachRules): string;
|
|
35
66
|
/** An lcov document that names `paths` as files with no line data —
|
|
36
67
|
* "whole" records, which the index reads as every line unknown, so any
|
|
37
68
|
* change to the file selects the case that read it. */
|
package/dist/harness/reach.js
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
|
|
@@ -132,7 +142,7 @@ export function projectReach(files, sources) {
|
|
|
132
142
|
if (rel)
|
|
133
143
|
out.add(rel);
|
|
134
144
|
}
|
|
135
|
-
for (const v of s.volumeSources ?? []) {
|
|
145
|
+
for (const v of [...(s.volumeSources ?? []), ...(s.inputs ?? [])]) {
|
|
136
146
|
if (v.startsWith("/"))
|
|
137
147
|
continue;
|
|
138
148
|
const src = normRel(v);
|
|
@@ -144,9 +154,101 @@ export function projectReach(files, sources) {
|
|
|
144
154
|
}
|
|
145
155
|
return [...out].sort();
|
|
146
156
|
}
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
/**
|
|
158
|
+
* Compact a file reach against the project's file list: a directory
|
|
159
|
+
* whose every file is in the reach becomes one `dir/` prefix (the root
|
|
160
|
+
* becomes `.`), and a file whose directory is not wholly reached stays a
|
|
161
|
+
* path. Only directories that hold a project file exist here; an empty
|
|
162
|
+
* directory has nothing to be in or out of the reach.
|
|
163
|
+
*/
|
|
164
|
+
export function compactReach(files, reach) {
|
|
165
|
+
const inReach = new Set(reach);
|
|
166
|
+
// Per directory: how many project files live below it, how many of
|
|
167
|
+
// those the reach holds. "" is the root.
|
|
168
|
+
const total = new Map();
|
|
169
|
+
const hit = new Map();
|
|
170
|
+
const children = new Map();
|
|
171
|
+
const filesIn = new Map();
|
|
172
|
+
for (const f of files) {
|
|
173
|
+
const parts = f.split("/");
|
|
174
|
+
let dir = "";
|
|
175
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
176
|
+
total.set(dir, (total.get(dir) ?? 0) + 1);
|
|
177
|
+
if (inReach.has(f))
|
|
178
|
+
hit.set(dir, (hit.get(dir) ?? 0) + 1);
|
|
179
|
+
if (i === parts.length - 1) {
|
|
180
|
+
let list = filesIn.get(dir);
|
|
181
|
+
if (!list)
|
|
182
|
+
filesIn.set(dir, (list = []));
|
|
183
|
+
list.push(f);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
const child = dir === "" ? parts[i] : `${dir}/${parts[i]}`;
|
|
187
|
+
let set = children.get(dir);
|
|
188
|
+
if (!set)
|
|
189
|
+
children.set(dir, (set = new Set()));
|
|
190
|
+
set.add(child);
|
|
191
|
+
dir = child;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const out = [];
|
|
196
|
+
const walk = (dir) => {
|
|
197
|
+
const n = total.get(dir) ?? 0;
|
|
198
|
+
if (n > 0 && (hit.get(dir) ?? 0) === n) {
|
|
199
|
+
out.push(dir === "" ? "." : `${dir}/`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
for (const f of filesIn.get(dir) ?? [])
|
|
203
|
+
if (inReach.has(f))
|
|
204
|
+
out.push(f);
|
|
205
|
+
for (const c of children.get(dir) ?? [])
|
|
206
|
+
walk(c);
|
|
207
|
+
};
|
|
208
|
+
walk("");
|
|
209
|
+
return out.sort();
|
|
210
|
+
}
|
|
211
|
+
/** Is `path` (project-relative) in the reach the rules describe? The
|
|
212
|
+
* compacted prefixes answer first; a path outside them is evaluated
|
|
213
|
+
* against every source, so a file that did not exist when the prefixes
|
|
214
|
+
* were computed gets the same answer the build would give. */
|
|
215
|
+
export function reachContains(rules, path) {
|
|
216
|
+
const p = normRel(path);
|
|
217
|
+
for (const pre of rules.prefixes) {
|
|
218
|
+
if (pre === "." || pre === p || (pre.endsWith("/") && p.startsWith(pre)))
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
return sourceOf(rules.sources, p) !== undefined;
|
|
222
|
+
}
|
|
223
|
+
/** The first source whose rules carry `path`, or `undefined`. */
|
|
224
|
+
export function sourceOf(sources, path) {
|
|
225
|
+
const p = normRel(path);
|
|
226
|
+
for (const s of sources) {
|
|
227
|
+
if (s.context !== undefined) {
|
|
228
|
+
const ctx = normRel(s.context);
|
|
229
|
+
const prefix = ctx === "" ? "" : `${ctx}/`;
|
|
230
|
+
if (!prefix || p.startsWith(prefix)) {
|
|
231
|
+
const rel = p.slice(prefix.length);
|
|
232
|
+
if (rel === ".dockerignore" || rel === "Dockerfile" || !isExcluded(compileDockerignore(s.ignore ?? ""), rel))
|
|
233
|
+
return s;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const e of s.extraFiles ?? [])
|
|
237
|
+
if (normRel(e) === p)
|
|
238
|
+
return s;
|
|
239
|
+
for (const v of [...(s.volumeSources ?? []), ...(s.inputs ?? [])]) {
|
|
240
|
+
if (v.startsWith("/"))
|
|
241
|
+
continue;
|
|
242
|
+
const src = normRel(v);
|
|
243
|
+
if (p === src || p.startsWith(`${src}/`))
|
|
244
|
+
return s;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
/** The `reach.json` report text. */
|
|
250
|
+
export function renderReachRules(rules) {
|
|
251
|
+
return JSON.stringify(rules);
|
|
150
252
|
}
|
|
151
253
|
/** An lcov document that names `paths` as files with no line data —
|
|
152
254
|
* "whole" records, which the index reads as every line unknown, so any
|