@specific.dev/spectest 0.46.0 → 0.48.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 +1 -0
- package/dist/daemon.js +91 -39
- package/dist/harness/container-run.d.ts +6 -0
- package/dist/harness/container-run.js +13 -0
- package/dist/index.d.ts +5 -1
- package/dist/locator.d.ts +32 -0
- package/dist/locator.js +28 -0
- package/dist/project-files.d.ts +18 -0
- package/dist/project-files.js +97 -0
- package/dist/recorder.d.ts +3 -0
- package/package.json +1 -1
- package/src/browser.ts +1 -0
- package/src/daemon.ts +116 -47
- package/src/harness/container-run.test.ts +8 -0
- package/src/harness/container-run.ts +13 -0
- package/src/index.ts +6 -0
- package/src/locator.ts +70 -0
- package/src/project-files.ts +103 -0
- package/src/recorder.ts +3 -0
package/dist/browser.d.ts
CHANGED
package/dist/daemon.js
CHANGED
|
@@ -34,6 +34,7 @@ import { summarizeBuildKit } from "./harness/buildkit-progress.js";
|
|
|
34
34
|
import { LOG_DELTA_MAX_BYTES, capMiddle, streamDelta } from "./harness/log-delta.js";
|
|
35
35
|
import { resolveHostPath as resolveVolumeHostPath, sanitizeSegment, } from "./harness/volume-paths.js";
|
|
36
36
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
37
|
+
import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
|
|
37
38
|
import { isTextualContentType, looksBinary, omittedBody, parseContentLength, } from "./harness/http-body.js";
|
|
38
39
|
import { encodeRegistry } from "./harness/names-registry.js";
|
|
39
40
|
import { HOP_BY_HOP_HEADERS, augmentCorsResponse, corsPreflightResponse, isCorsPreflight, } from "./harness/http-proxy.js";
|
|
@@ -52,7 +53,6 @@ function namedServices(cfg) {
|
|
|
52
53
|
}
|
|
53
54
|
const DEFAULT_TEST_TIMEOUT_MS = 60_000;
|
|
54
55
|
const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
|
|
55
|
-
const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
|
|
56
56
|
// Stable hostname every service container resolves to the host (the
|
|
57
57
|
// `spectest-br0` gateway) — so apps that build or pull images at runtime
|
|
58
58
|
// can point a builder at `spectest-host:5000` (the zot Docker Hub mirror)
|
|
@@ -77,7 +77,9 @@ function hostCacheGateway() {
|
|
|
77
77
|
}
|
|
78
78
|
return _hostCacheGateway;
|
|
79
79
|
}
|
|
80
|
-
|
|
80
|
+
// WORKSPACE (/workspace) and APP_DIR (/opt/spectest/app) both live in
|
|
81
|
+
// project-files.ts, next to the rule that decides which copy of a project
|
|
82
|
+
// file is the current one.
|
|
81
83
|
// The bun the base snapshot installs (base.rs::BASE_SETUP_SH). The daemon
|
|
82
84
|
// runs under it, and eval's dependency install shells out to it.
|
|
83
85
|
const BUN_BIN = "/usr/local/bin/bun";
|
|
@@ -933,8 +935,15 @@ async function ensureCaTrustedImage(serviceName, tag) {
|
|
|
933
935
|
const ctxDir = path.join(WORKSPACE, ".spectest", "ca-trust", serviceName);
|
|
934
936
|
await fs.mkdir(ctxDir, { recursive: true });
|
|
935
937
|
await fs.copyFile(CA_PATH, path.join(ctxDir, "spectest-ca.crt"));
|
|
938
|
+
// An image that declares a non-root `USER` runs this step as that user,
|
|
939
|
+
// and writing the trust store then fails ("cannot create
|
|
940
|
+
// /etc/ssl/certs/ca-certificates.crt.new: Permission denied") — the CA
|
|
941
|
+
// silently never lands. Take root for the one command, then hand the
|
|
942
|
+
// image back its own user so containers still run as it.
|
|
943
|
+
const declaredUser = (await docker(["image", "inspect", "--format", "{{.Config.User}}", tag], 60_000)).stdout.trim();
|
|
944
|
+
const needsRoot = declaredUser !== "" && declaredUser !== "root" && declaredUser !== "0";
|
|
936
945
|
const dockerfile = `FROM ${tag}
|
|
937
|
-
COPY spectest-ca.crt /usr/local/share/ca-certificates/spectest-ca.crt
|
|
946
|
+
${needsRoot ? "USER root\n" : ""}COPY spectest-ca.crt /usr/local/share/ca-certificates/spectest-ca.crt
|
|
938
947
|
RUN if command -v update-ca-certificates >/dev/null 2>&1; then \\
|
|
939
948
|
update-ca-certificates; \\
|
|
940
949
|
elif command -v update-ca-trust >/dev/null 2>&1; then \\
|
|
@@ -942,7 +951,7 @@ RUN if command -v update-ca-certificates >/dev/null 2>&1; then \\
|
|
|
942
951
|
else \\
|
|
943
952
|
echo "[spectest] no system CA trust tool in image; env-var trust only"; \\
|
|
944
953
|
fi
|
|
945
|
-
`;
|
|
954
|
+
${needsRoot ? `USER ${declaredUser}\n` : ""}`;
|
|
946
955
|
await fs.writeFile(path.join(ctxDir, "Dockerfile"), dockerfile);
|
|
947
956
|
const build = await docker(["build", "-t", tag, ctxDir], 300_000);
|
|
948
957
|
if (build.code !== 0) {
|
|
@@ -1021,7 +1030,7 @@ async function probeTcp(host, port) {
|
|
|
1021
1030
|
return new Promise((resolve) => {
|
|
1022
1031
|
const sock = net.createConnection({ host, port });
|
|
1023
1032
|
let settled = false;
|
|
1024
|
-
const finish = (
|
|
1033
|
+
const finish = (ok, detail) => {
|
|
1025
1034
|
if (settled)
|
|
1026
1035
|
return;
|
|
1027
1036
|
settled = true;
|
|
@@ -1031,26 +1040,30 @@ async function probeTcp(host, port) {
|
|
|
1031
1040
|
catch {
|
|
1032
1041
|
/* ignore */
|
|
1033
1042
|
}
|
|
1034
|
-
resolve(
|
|
1043
|
+
resolve({ ok, detail });
|
|
1035
1044
|
};
|
|
1036
1045
|
sock.setTimeout(2000);
|
|
1037
|
-
sock.once("connect", () => finish(true));
|
|
1038
|
-
sock.once("error", () => finish(false));
|
|
1039
|
-
sock.once("timeout", () => finish(false));
|
|
1046
|
+
sock.once("connect", () => finish(true, `connected to ${host}:${port}`));
|
|
1047
|
+
sock.once("error", (err) => finish(false, `connect to ${host}:${port} failed: ${err.message}`));
|
|
1048
|
+
sock.once("timeout", () => finish(false, `connect to ${host}:${port} got no reply within 2000ms`));
|
|
1040
1049
|
});
|
|
1041
1050
|
}
|
|
1042
1051
|
async function probeHttp(host, port, urlPath, headers, expectStatus) {
|
|
1052
|
+
const url = `http://${host}:${port}${urlPath}`;
|
|
1043
1053
|
const ctrl = new AbortController();
|
|
1044
1054
|
const to = setTimeout(() => ctrl.abort(), 5000);
|
|
1045
1055
|
try {
|
|
1046
|
-
const res = await fetch(
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
});
|
|
1050
|
-
return expectStatus !== undefined ? res.status === expectStatus : res.ok;
|
|
1056
|
+
const res = await fetch(url, { signal: ctrl.signal, headers });
|
|
1057
|
+
const ok = expectStatus !== undefined ? res.status === expectStatus : res.ok;
|
|
1058
|
+
const want = expectStatus !== undefined ? String(expectStatus) : "2xx";
|
|
1059
|
+
return { ok, detail: `GET ${url} → ${res.status} (wanted ${want})` };
|
|
1051
1060
|
}
|
|
1052
|
-
catch {
|
|
1053
|
-
|
|
1061
|
+
catch (err) {
|
|
1062
|
+
const e = err;
|
|
1063
|
+
const detail = e?.name === "AbortError"
|
|
1064
|
+
? `GET ${url} got no reply within 5000ms`
|
|
1065
|
+
: `GET ${url} failed: ${e?.message ?? String(err)}`;
|
|
1066
|
+
return { ok: false, detail };
|
|
1054
1067
|
}
|
|
1055
1068
|
finally {
|
|
1056
1069
|
clearTimeout(to);
|
|
@@ -1058,28 +1071,64 @@ async function probeHttp(host, port, urlPath, headers, expectStatus) {
|
|
|
1058
1071
|
}
|
|
1059
1072
|
async function probeExec(name, command) {
|
|
1060
1073
|
const r = await docker(["exec", name, "sh", "-c", command], 10_000);
|
|
1061
|
-
|
|
1074
|
+
if (r.code === 0)
|
|
1075
|
+
return { ok: true, detail: `\`${command}\` exited 0` };
|
|
1076
|
+
// 124 is shx's own kill (see `shx`): the exec never answered, which says
|
|
1077
|
+
// the docker daemon is wedged or starved rather than anything about the
|
|
1078
|
+
// command's verdict.
|
|
1079
|
+
const why = r.code === 124
|
|
1080
|
+
? `was killed after 10000ms with no reply`
|
|
1081
|
+
: `exited ${r.code}`;
|
|
1082
|
+
const output = firstLine(r.stderr) || firstLine(r.stdout);
|
|
1083
|
+
return {
|
|
1084
|
+
ok: false,
|
|
1085
|
+
detail: `\`${command}\` ${why}${output ? `: ${output}` : ""}`,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
/** First non-empty line, trimmed and capped — probe output goes into an
|
|
1089
|
+
* error message, not a log file. */
|
|
1090
|
+
function firstLine(s) {
|
|
1091
|
+
const line = s.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
|
|
1092
|
+
return line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
|
1062
1093
|
}
|
|
1063
1094
|
async function waitForReady(svc) {
|
|
1064
1095
|
const check = svc.readyCheck;
|
|
1065
1096
|
if (!check)
|
|
1066
1097
|
return;
|
|
1067
1098
|
const timeoutSecs = check.timeoutSecs ?? 60;
|
|
1099
|
+
let last;
|
|
1068
1100
|
const probe = async () => {
|
|
1069
1101
|
if (check.type === "tcp")
|
|
1070
|
-
|
|
1071
|
-
if (check.type === "http") {
|
|
1072
|
-
|
|
1102
|
+
last = await probeTcp(svc.name, check.port);
|
|
1103
|
+
else if (check.type === "http") {
|
|
1104
|
+
last = await probeHttp(svc.name, check.port, check.path ?? "/", check.headers, check.expectStatus);
|
|
1073
1105
|
}
|
|
1074
|
-
|
|
1106
|
+
else
|
|
1107
|
+
last = await probeExec(svc.name, check.command);
|
|
1108
|
+
return last.ok;
|
|
1075
1109
|
};
|
|
1076
1110
|
// Scheduling (the ramp, and not sleeping past the deadline) lives in
|
|
1077
1111
|
// `harness/ready-poll.ts`; this supplies the probe and the diagnosis.
|
|
1078
|
-
const { ready } = await pollUntilReady(probe, {
|
|
1112
|
+
const { ready, attempts, elapsedMs } = await pollUntilReady(probe, {
|
|
1113
|
+
kind: check.type,
|
|
1114
|
+
timeoutSecs,
|
|
1115
|
+
});
|
|
1079
1116
|
if (ready)
|
|
1080
1117
|
return;
|
|
1118
|
+
// The attempt count is half the diagnosis: a probe with a 10s timeout of
|
|
1119
|
+
// its own can only run a handful of times in 60s, so "6 attempts" says the
|
|
1120
|
+
// probes were hanging where "80 attempts" says they ran and kept saying no.
|
|
1121
|
+
let msg = `service ${svc.name} not ready within ${timeoutSecs}s ` +
|
|
1122
|
+
`(${attempts} ${check.type} probe${attempts === 1 ? "" : "s"} over ${Math.round(elapsedMs / 100) / 10}s).`;
|
|
1123
|
+
if (last)
|
|
1124
|
+
msg += `\nLast probe: ${last.detail}`;
|
|
1081
1125
|
const logs = await docker(["logs", "--tail=200", svc.name], 30_000);
|
|
1082
|
-
|
|
1126
|
+
const output = `${logs.stdout}\n${logs.stderr}`.trim();
|
|
1127
|
+
// A service that logs nothing (`sleep infinity`, a quiet daemon) used to
|
|
1128
|
+
// get an empty "Recent container logs:" heading, which reads as if the
|
|
1129
|
+
// logs were the evidence and there simply weren't any.
|
|
1130
|
+
msg += output ? `\nRecent container logs:\n${output}` : `\n(the container logged nothing)`;
|
|
1131
|
+
throw new Error(msg);
|
|
1083
1132
|
}
|
|
1084
1133
|
/** Validate the `dependsOn` graph and return the name→service map used to
|
|
1085
1134
|
* walk it. Rules live in `harness/service-graph.ts`. */
|
|
@@ -2919,7 +2968,8 @@ async function spectestContext(scope = {}) {
|
|
|
2919
2968
|
const execTimeoutMs = scope.service ? COMPONENT_EXEC_DEFAULT_TIMEOUT_MS : undefined;
|
|
2920
2969
|
return {
|
|
2921
2970
|
projectRoot: WORKSPACE,
|
|
2922
|
-
readProjectFile: (p) => fs.readFile(
|
|
2971
|
+
readProjectFile: (p) => fs.readFile(resolveProjectPath(p), "utf8"),
|
|
2972
|
+
readProjectFileBytes: async (p) => new Uint8Array(await fs.readFile(resolveProjectPath(p))),
|
|
2923
2973
|
exec: (service, command, opts) => componentExec(service, command, opts, execTimeoutMs),
|
|
2924
2974
|
// Read `globalThis.fetch` at call time: the wrapper is installed for
|
|
2925
2975
|
// the duration of a test / eval / project setup, so a context built
|
|
@@ -3335,22 +3385,31 @@ async function pollCall(description, fn, opts) {
|
|
|
3335
3385
|
let value;
|
|
3336
3386
|
let success = false;
|
|
3337
3387
|
let predicateError;
|
|
3338
|
-
// Record all iterations normally
|
|
3339
|
-
//
|
|
3340
|
-
//
|
|
3341
|
-
//
|
|
3388
|
+
// Record all iterations normally, but keep only the newest one: a new
|
|
3389
|
+
// attempt drops the events the previous attempt emitted, so the timeline
|
|
3390
|
+
// never fills with polling noise. Whichever attempt is last when the loop
|
|
3391
|
+
// ends — the winning one, or the final failed one — stays, and gets marked
|
|
3392
|
+
// as a child of the wait event so the UI can render it nested.
|
|
3393
|
+
//
|
|
3394
|
+
// The failed attempt is kept deliberately. A poll that times out reports
|
|
3395
|
+
// only "timed out after 120000ms (60 attempts)", which says nothing about
|
|
3396
|
+
// WHY: an ingress answering an instant 404 for two minutes and a backend
|
|
3397
|
+
// that never returns look identical in that message. The last attempt's
|
|
3398
|
+
// events carry the status, the body and the duration, which is the whole
|
|
3399
|
+
// difference between "the route was never programmed" and "the app hung".
|
|
3342
3400
|
const beforePollIdx = recorderEventCount();
|
|
3343
3401
|
let lastIterStartIdx = beforePollIdx;
|
|
3344
|
-
let keptIterStartIdx = beforePollIdx;
|
|
3345
3402
|
while (Date.now() - start < timeoutMs) {
|
|
3346
3403
|
attempts += 1;
|
|
3404
|
+
// Drop the *previous* attempt's events, not this one's — its events are
|
|
3405
|
+
// the ones worth keeping until a newer attempt replaces them.
|
|
3406
|
+
recorderTruncate(lastIterStartIdx);
|
|
3347
3407
|
lastIterStartIdx = recorderEventCount();
|
|
3348
3408
|
try {
|
|
3349
3409
|
const v = await fn();
|
|
3350
3410
|
if (v !== null && v !== undefined && v !== false) {
|
|
3351
3411
|
value = v;
|
|
3352
3412
|
success = true;
|
|
3353
|
-
keptIterStartIdx = lastIterStartIdx;
|
|
3354
3413
|
break;
|
|
3355
3414
|
}
|
|
3356
3415
|
}
|
|
@@ -3358,17 +3417,10 @@ async function pollCall(description, fn, opts) {
|
|
|
3358
3417
|
predicateError = err;
|
|
3359
3418
|
break;
|
|
3360
3419
|
}
|
|
3361
|
-
// Failed iteration — drop the events it emitted.
|
|
3362
|
-
recorderTruncate(lastIterStartIdx);
|
|
3363
3420
|
if (Date.now() - start + intervalMs > timeoutMs)
|
|
3364
3421
|
break;
|
|
3365
3422
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
3366
3423
|
}
|
|
3367
|
-
if (!success) {
|
|
3368
|
-
// Timeout or predicate error: drop every attempt's events. The
|
|
3369
|
-
// wait event we emit below is the only trace.
|
|
3370
|
-
recorderTruncate(beforePollIdx);
|
|
3371
|
-
}
|
|
3372
3424
|
const errMsg = predicateError !== undefined
|
|
3373
3425
|
? (predicateError?.message ?? String(predicateError))
|
|
3374
3426
|
: success
|
|
@@ -3381,11 +3433,11 @@ async function pollCall(description, fn, opts) {
|
|
|
3381
3433
|
passed: success,
|
|
3382
3434
|
...(errMsg !== undefined ? { error: errMsg } : {}),
|
|
3383
3435
|
}, resv);
|
|
3384
|
-
if (
|
|
3436
|
+
if (seq !== undefined) {
|
|
3385
3437
|
// Group the kept iteration's events under the wait so the UI can
|
|
3386
3438
|
// render them inside the wait card. The wait event itself is the
|
|
3387
3439
|
// very last entry; markChildren skips it via the seq match.
|
|
3388
|
-
recorderMarkChildren(
|
|
3440
|
+
recorderMarkChildren(lastIterStartIdx, seq);
|
|
3389
3441
|
}
|
|
3390
3442
|
if (predicateError !== undefined)
|
|
3391
3443
|
throw predicateError;
|
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
* showed the problem, which is why it went unnoticed: only the appending
|
|
30
30
|
* variable was in play.
|
|
31
31
|
*
|
|
32
|
+
* **Deno is a third mechanism.** It reads none of those variables and
|
|
33
|
+
* trusts only its bundled Mozilla roots, so an edge function calling a
|
|
34
|
+
* fake fails with `UnknownIssuer` however the image is set up.
|
|
35
|
+
* `DENO_TLS_CA_STORE=mozilla,system` is what adds the platform store,
|
|
36
|
+
* and the platform store is `SSL_CERT_FILE` — the combined bundle.
|
|
37
|
+
*
|
|
32
38
|
* **`command` and `args` are different overrides.** `command` replaces the
|
|
33
39
|
* entrypoint and runs through `sh -c`; `args` keeps the entrypoint and
|
|
34
40
|
* overrides CMD, which is what init-wrapped images like postgres need to
|
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
* showed the problem, which is why it went unnoticed: only the appending
|
|
30
30
|
* variable was in play.
|
|
31
31
|
*
|
|
32
|
+
* **Deno is a third mechanism.** It reads none of those variables and
|
|
33
|
+
* trusts only its bundled Mozilla roots, so an edge function calling a
|
|
34
|
+
* fake fails with `UnknownIssuer` however the image is set up.
|
|
35
|
+
* `DENO_TLS_CA_STORE=mozilla,system` is what adds the platform store,
|
|
36
|
+
* and the platform store is `SSL_CERT_FILE` — the combined bundle.
|
|
37
|
+
*
|
|
32
38
|
* **`command` and `args` are different overrides.** `command` replaces the
|
|
33
39
|
* entrypoint and runs through `sh -c`; `args` keeps the entrypoint and
|
|
34
40
|
* overrides CMD, which is what init-wrapped images like postgres need to
|
|
@@ -87,6 +93,13 @@ export function runContainerArgs(input) {
|
|
|
87
93
|
// replacing ones take the combined bundle.
|
|
88
94
|
args.push(`--volume=${input.caPath}:${input.caPath}:ro`);
|
|
89
95
|
args.push("-e", `NODE_EXTRA_CA_CERTS=${input.caPath}`);
|
|
96
|
+
// Deno reads none of the above, and its default store is the bundled
|
|
97
|
+
// Mozilla roots alone — so a Deno runtime (a Supabase edge function is
|
|
98
|
+
// the usual one) rejects every certificate this environment mints, with
|
|
99
|
+
// or without the CA layered into the image. `system` adds the platform
|
|
100
|
+
// store, which resolves through `SSL_CERT_FILE`: the bundle below.
|
|
101
|
+
// `mozilla` stays first so the public roots survive.
|
|
102
|
+
args.push("-e", "DENO_TLS_CA_STORE=mozilla,system");
|
|
90
103
|
const bundle = input.caBundle;
|
|
91
104
|
if (bundle) {
|
|
92
105
|
if (bundle !== input.caPath)
|
package/dist/index.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js
|
|
|
7
7
|
export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
|
|
8
8
|
export type { Browser, BrowserOptions, Keyboard, Mouse, Touchscreen } from "./browser.js";
|
|
9
9
|
import type { Browser, BrowserOptions } from "./browser.js";
|
|
10
|
-
export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, } from "./locator.js";
|
|
10
|
+
export type { Locator, GetByRoleOptions, GetByTextOptions, FilterOptions, ClickOptions, BoundingBox, FilePayload, InputFiles, } from "./locator.js";
|
|
11
11
|
import type { Locator } from "./locator.js";
|
|
12
12
|
export type { UrlPattern } from "./url-match.js";
|
|
13
13
|
import type { UrlPattern } from "./url-match.js";
|
|
@@ -215,6 +215,10 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
215
215
|
/** Read a project file as UTF-8. Relative paths resolve against
|
|
216
216
|
* {@link projectRoot}; absolute paths are read as-is. */
|
|
217
217
|
readProjectFile(path: string): Promise<string>;
|
|
218
|
+
/** Read a project file as bytes — a fixture to post, hash, or compare
|
|
219
|
+
* against what the app under test received. Same path rules as
|
|
220
|
+
* {@link readProjectFile}. */
|
|
221
|
+
readProjectFileBytes(path: string): Promise<Uint8Array>;
|
|
218
222
|
/**
|
|
219
223
|
* Run a command inside a service container. Pass an **array** for exact
|
|
220
224
|
* argv with no shell (`["psql", "-f", "-"]`), or a **string** to run via
|
package/dist/locator.d.ts
CHANGED
|
@@ -40,6 +40,19 @@ export interface ClickOptions {
|
|
|
40
40
|
};
|
|
41
41
|
modifiers?: Array<"Alt" | "Control" | "Meta" | "Shift">;
|
|
42
42
|
}
|
|
43
|
+
/** A file built in the test rather than read from the repo — the argument
|
|
44
|
+
* shape playwright's `setInputFiles` takes, with `buffer` widened to a plain
|
|
45
|
+
* `Uint8Array`/string and `mimeType` optional. */
|
|
46
|
+
export interface FilePayload {
|
|
47
|
+
name: string;
|
|
48
|
+
/** Defaults to `application/octet-stream`. */
|
|
49
|
+
mimeType?: string;
|
|
50
|
+
/** Bytes, or text (encoded UTF-8). */
|
|
51
|
+
buffer: Uint8Array | string;
|
|
52
|
+
}
|
|
53
|
+
/** What `setInputFiles` accepts: repo-relative (or absolute) paths, built
|
|
54
|
+
* files, or `[]` to clear the input. */
|
|
55
|
+
export type InputFiles = string | string[] | FilePayload | FilePayload[];
|
|
43
56
|
export interface TimeoutOption {
|
|
44
57
|
timeout?: number;
|
|
45
58
|
}
|
|
@@ -240,6 +253,25 @@ export interface Locator {
|
|
|
240
253
|
value?: string;
|
|
241
254
|
index?: number;
|
|
242
255
|
}, opts?: TimeoutOption): Promise<Wrapped<string[]>>;
|
|
256
|
+
/**
|
|
257
|
+
* Give an `<input type="file">` its files — the upload primitive, since a
|
|
258
|
+
* browser refuses a script-set value on a file input. Targets the control a
|
|
259
|
+
* `<label>` points at, and does **not** require the input to be visible, so
|
|
260
|
+
* the hidden input behind a drop zone or a styled "Choose file" button is
|
|
261
|
+
* the thing to select.
|
|
262
|
+
*
|
|
263
|
+
* A string is a path in your repo, relative to `ctx.projectRoot` (absolute
|
|
264
|
+
* paths are taken as-is) — keep fixtures in `spectest/tests/fixtures/`, which
|
|
265
|
+
* is out of the environment cache key, so adding one still gives a warm
|
|
266
|
+
* start. Pass a {@link FilePayload} instead for a file the test builds, and
|
|
267
|
+
* `[]` to clear the input.
|
|
268
|
+
*
|
|
269
|
+
* ```ts
|
|
270
|
+
* await b.locator("#file").setInputFiles("spectest/tests/fixtures/invoice.pdf");
|
|
271
|
+
* await b.getByLabel("Avatar").setInputFiles({ name: "a.txt", buffer: "hi" });
|
|
272
|
+
* ```
|
|
273
|
+
*/
|
|
274
|
+
setInputFiles(files: InputFiles, opts?: TimeoutOption): Promise<void>;
|
|
243
275
|
hover(opts?: TimeoutOption): Promise<void>;
|
|
244
276
|
focus(opts?: TimeoutOption): Promise<void>;
|
|
245
277
|
blur(opts?: TimeoutOption): Promise<void>;
|
package/dist/locator.js
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
// (actions, reads, `waitFor`) run inside `backend.pageOp`, so each
|
|
22
22
|
// author-facing call is exactly one recorded browser event (with its rrweb
|
|
23
23
|
// drain), whatever playwright work it composes underneath.
|
|
24
|
+
import { Buffer } from "node:buffer";
|
|
25
|
+
import { resolveExistingProjectPath } from "./project-files.js";
|
|
24
26
|
import { truncateUtf8 } from "./recorder.js";
|
|
25
27
|
/** Default deadline for a locator action/read's target to become actionable.
|
|
26
28
|
* Playwright's own default is 30s — far too slow-failing for tests; 5s
|
|
@@ -264,6 +266,28 @@ async function stampActionPoint(loc, rec, position) {
|
|
|
264
266
|
/* Element not ready / gone / strict violation — the action reports it. */
|
|
265
267
|
}
|
|
266
268
|
}
|
|
269
|
+
/** Fold a {@link InputFiles} argument into the one playwright takes: repo
|
|
270
|
+
* paths resolved to their in-VM location (see project-files.ts), built files
|
|
271
|
+
* given a default mime type and a real `Buffer`. The names come back too —
|
|
272
|
+
* they are what the timeline step shows, and the bytes never go near it. */
|
|
273
|
+
function lowerInputFiles(files) {
|
|
274
|
+
const many = Array.isArray(files) ? files : [files];
|
|
275
|
+
// `[]` clears the input, and every() is true for it — so an empty call
|
|
276
|
+
// lands here and lowers to an empty path list, which is what clears.
|
|
277
|
+
if (many.every((f) => typeof f === "string")) {
|
|
278
|
+
const paths = many.map((f) => resolveExistingProjectPath(f, "fixture"));
|
|
279
|
+
return { arg: paths, names: paths.map((p) => p.split("/").pop() ?? p) };
|
|
280
|
+
}
|
|
281
|
+
if (many.some((f) => typeof f === "string")) {
|
|
282
|
+
throw new Error("setInputFiles: pass either repo paths or built files — not both in one call");
|
|
283
|
+
}
|
|
284
|
+
const payloads = many.map((f) => ({
|
|
285
|
+
name: f.name,
|
|
286
|
+
mimeType: f.mimeType ?? "application/octet-stream",
|
|
287
|
+
buffer: typeof f.buffer === "string" ? Buffer.from(f.buffer, "utf8") : Buffer.from(f.buffer),
|
|
288
|
+
}));
|
|
289
|
+
return { arg: payloads, names: payloads.map((f) => f.name) };
|
|
290
|
+
}
|
|
267
291
|
// ────────────────────────────────────────────────────────────────────────
|
|
268
292
|
// Factory
|
|
269
293
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -340,6 +364,10 @@ export function makeLocator(backend, strategy, chain) {
|
|
|
340
364
|
uncheck: (opts) => act("uncheck", {}, (l) => l.uncheck({ timeout: opts?.timeout })),
|
|
341
365
|
setChecked: (checked, opts) => act("setChecked", {}, (l) => l.setChecked(checked, { timeout: opts?.timeout })),
|
|
342
366
|
selectOption: (values, opts) => read("selectOption", (l) => l.selectOption(values, { timeout: opts?.timeout })),
|
|
367
|
+
setInputFiles: (files, opts) => {
|
|
368
|
+
const { arg, names } = lowerInputFiles(files);
|
|
369
|
+
return act("setInputFiles", { files: names }, (l) => l.setInputFiles(arg, { timeout: opts?.timeout }));
|
|
370
|
+
},
|
|
343
371
|
hover: (opts) => act("hover", {}, (l) => l.hover({ timeout: opts?.timeout })),
|
|
344
372
|
focus: (opts) => act("focus", {}, (l) => l.focus({ timeout: opts?.timeout })),
|
|
345
373
|
blur: (opts) => act("blur", {}, (l) => l.blur({ timeout: opts?.timeout })),
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** The extracted repo — `ctx.projectRoot`. */
|
|
2
|
+
export declare const WORKSPACE: string;
|
|
3
|
+
/** The app dir; the user's `spectest/` sits directly under it. */
|
|
4
|
+
export declare const APP_DIR: string;
|
|
5
|
+
/**
|
|
6
|
+
* Absolute in-VM path for a path in the user's repo. Absolute input is
|
|
7
|
+
* returned as-is. A relative path resolves against the project, preferring the
|
|
8
|
+
* copy that a warm start refreshes (see the header).
|
|
9
|
+
*
|
|
10
|
+
* A file that is missing everywhere resolves to its /workspace candidate, so
|
|
11
|
+
* the caller's own `fs` error names a real path — `readProjectFile` keeps
|
|
12
|
+
* reporting ENOENT the way it always has.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveProjectPath(p: string): string;
|
|
15
|
+
/** {@link resolveProjectPath}, but a missing file is an error that names every
|
|
16
|
+
* place we looked. For inputs a user hands us by name — a fixture — where an
|
|
17
|
+
* ENOENT on one guessed path reads as a spectest bug rather than a typo. */
|
|
18
|
+
export declare function resolveExistingProjectPath(p: string, what?: string): string;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Where a path in the user's repo lands inside the VM.
|
|
2
|
+
//
|
|
3
|
+
// The project exists in the guest TWICE, and the two copies are not refreshed
|
|
4
|
+
// at the same time (see `env.rs`):
|
|
5
|
+
//
|
|
6
|
+
// /workspace the whole repo. Written by a cold or delta
|
|
7
|
+
// start only — a warm start never re-uploads it.
|
|
8
|
+
// /opt/spectest/app/spectest the user's `spectest/` directory. Re-uploaded
|
|
9
|
+
// on EVERY start, warm ones included.
|
|
10
|
+
//
|
|
11
|
+
// A warm start happens only when the warm-template hash matches, so /workspace
|
|
12
|
+
// is correct for every file that is IN that hash. It can be behind for the two
|
|
13
|
+
// kinds of file the hash excludes: `spectest/tests/**` (excluded so a test-only
|
|
14
|
+
// edit keeps the fast start) and anything a project lists in
|
|
15
|
+
// `spectest/.envignore`.
|
|
16
|
+
//
|
|
17
|
+
// Hence the rule below: a path under `spectest/` resolves against the app copy
|
|
18
|
+
// first, because that copy is always current — this is what lets a fixture live
|
|
19
|
+
// in `spectest/tests/fixtures/` and still be found after it was added. Any
|
|
20
|
+
// other path resolves against /workspace, which the hash keeps current.
|
|
21
|
+
//
|
|
22
|
+
// The remaining hole is a file that `.envignore` excludes AND that sits outside
|
|
23
|
+
// `spectest/`: /workspace holds whatever the cold start uploaded, so a later
|
|
24
|
+
// edit is invisible. Nothing can repair those bytes at read time, so we refuse
|
|
25
|
+
// to read them instead of returning stale content. The control plane writes the
|
|
26
|
+
// exact path list it excluded (env.rs) into ENV_IGNORED_FILE.
|
|
27
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
/** The extracted repo — `ctx.projectRoot`. */
|
|
30
|
+
export const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
|
|
31
|
+
/** The app dir; the user's `spectest/` sits directly under it. */
|
|
32
|
+
export const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
|
|
33
|
+
/** Paths (repo-relative) the warm-template hash skipped because of
|
|
34
|
+
* `spectest/.envignore`. Written by the control plane at env start; absent
|
|
35
|
+
* when the project ships no `.envignore`. */
|
|
36
|
+
const ENV_IGNORED_FILE = "/run/spectest-env-ignored.json";
|
|
37
|
+
let _envIgnored;
|
|
38
|
+
function envIgnored() {
|
|
39
|
+
if (_envIgnored)
|
|
40
|
+
return _envIgnored;
|
|
41
|
+
let paths = [];
|
|
42
|
+
try {
|
|
43
|
+
const raw = JSON.parse(readFileSync(ENV_IGNORED_FILE, "utf8"));
|
|
44
|
+
paths = raw.paths ?? [];
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
/* No file (no .envignore, or an older env) — nothing to refuse. */
|
|
48
|
+
}
|
|
49
|
+
_envIgnored = new Set(paths);
|
|
50
|
+
return _envIgnored;
|
|
51
|
+
}
|
|
52
|
+
/** Repo-relative form of `p`: no leading `./`, POSIX separators. */
|
|
53
|
+
function relative(p) {
|
|
54
|
+
return path.normalize(p).replace(/^\.\//, "").replace(/^\/+/, "");
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Absolute in-VM path for a path in the user's repo. Absolute input is
|
|
58
|
+
* returned as-is. A relative path resolves against the project, preferring the
|
|
59
|
+
* copy that a warm start refreshes (see the header).
|
|
60
|
+
*
|
|
61
|
+
* A file that is missing everywhere resolves to its /workspace candidate, so
|
|
62
|
+
* the caller's own `fs` error names a real path — `readProjectFile` keeps
|
|
63
|
+
* reporting ENOENT the way it always has.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveProjectPath(p) {
|
|
66
|
+
if (path.isAbsolute(p))
|
|
67
|
+
return p;
|
|
68
|
+
const rel = relative(p);
|
|
69
|
+
if (rel.startsWith("spectest/")) {
|
|
70
|
+
const app = path.join(APP_DIR, rel);
|
|
71
|
+
if (existsSync(app))
|
|
72
|
+
return app;
|
|
73
|
+
}
|
|
74
|
+
if (envIgnored().has(rel)) {
|
|
75
|
+
throw new Error(`project file "${p}" is excluded by spectest/.envignore, so the copy in the VM ` +
|
|
76
|
+
`is whatever a cold start uploaded and can be out of date. Move it under ` +
|
|
77
|
+
`spectest/tests/ (still cache-free, and always re-uploaded), or drop the pattern.`);
|
|
78
|
+
}
|
|
79
|
+
return path.join(WORKSPACE, rel);
|
|
80
|
+
}
|
|
81
|
+
/** {@link resolveProjectPath}, but a missing file is an error that names every
|
|
82
|
+
* place we looked. For inputs a user hands us by name — a fixture — where an
|
|
83
|
+
* ENOENT on one guessed path reads as a spectest bug rather than a typo. */
|
|
84
|
+
export function resolveExistingProjectPath(p, what = "file") {
|
|
85
|
+
const resolved = resolveProjectPath(p);
|
|
86
|
+
if (existsSync(resolved))
|
|
87
|
+
return resolved;
|
|
88
|
+
const rel = relative(p);
|
|
89
|
+
const looked = path.isAbsolute(p)
|
|
90
|
+
? [p]
|
|
91
|
+
: rel.startsWith("spectest/")
|
|
92
|
+
? [path.join(APP_DIR, rel), path.join(WORKSPACE, rel)]
|
|
93
|
+
: [path.join(WORKSPACE, rel)];
|
|
94
|
+
throw new Error(`${what} "${p}" is not in the VM (looked in ${looked.join(", ")}). Paths are ` +
|
|
95
|
+
`relative to your repo root. Check that the file is committed and not ` +
|
|
96
|
+
`excluded by .gitignore/.spectestignore.`);
|
|
97
|
+
}
|
package/dist/recorder.d.ts
CHANGED
|
@@ -394,6 +394,9 @@ export interface BrowserEvent extends BaseEvent {
|
|
|
394
394
|
key?: string;
|
|
395
395
|
/** Attribute name (getAttribute). */
|
|
396
396
|
attribute?: string;
|
|
397
|
+
/** File names given to an `<input type="file">` (setInputFiles). Names
|
|
398
|
+
* only — a built file's bytes stay out of the timeline. */
|
|
399
|
+
files?: string[];
|
|
397
400
|
/** Scroll/click coordinates. */
|
|
398
401
|
dx?: number;
|
|
399
402
|
dy?: number;
|
package/package.json
CHANGED
package/src/browser.ts
CHANGED
package/src/daemon.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
sanitizeSegment,
|
|
58
58
|
} from "./harness/volume-paths.js";
|
|
59
59
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
60
|
+
import { APP_DIR, WORKSPACE, resolveProjectPath } from "./project-files.js";
|
|
60
61
|
import {
|
|
61
62
|
isTextualContentType,
|
|
62
63
|
looksBinary,
|
|
@@ -180,7 +181,6 @@ function namedServices(cfg: EnvironmentConfig): NamedService[] {
|
|
|
180
181
|
|
|
181
182
|
const DEFAULT_TEST_TIMEOUT_MS = 60_000;
|
|
182
183
|
const NETWORK_NAME = process.env.SPECTEST_NETWORK ?? "spectest-net";
|
|
183
|
-
const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
|
|
184
184
|
|
|
185
185
|
// Stable hostname every service container resolves to the host (the
|
|
186
186
|
// `spectest-br0` gateway) — so apps that build or pull images at runtime
|
|
@@ -207,7 +207,9 @@ function hostCacheGateway(): string | null {
|
|
|
207
207
|
}
|
|
208
208
|
return _hostCacheGateway;
|
|
209
209
|
}
|
|
210
|
-
|
|
210
|
+
// WORKSPACE (/workspace) and APP_DIR (/opt/spectest/app) both live in
|
|
211
|
+
// project-files.ts, next to the rule that decides which copy of a project
|
|
212
|
+
// file is the current one.
|
|
211
213
|
// The bun the base snapshot installs (base.rs::BASE_SETUP_SH). The daemon
|
|
212
214
|
// runs under it, and eval's dependency install shells out to it.
|
|
213
215
|
const BUN_BIN = "/usr/local/bin/bun";
|
|
@@ -1221,8 +1223,17 @@ async function ensureCaTrustedImage(serviceName: string, tag: string): Promise<v
|
|
|
1221
1223
|
const ctxDir = path.join(WORKSPACE, ".spectest", "ca-trust", serviceName);
|
|
1222
1224
|
await fs.mkdir(ctxDir, { recursive: true });
|
|
1223
1225
|
await fs.copyFile(CA_PATH, path.join(ctxDir, "spectest-ca.crt"));
|
|
1226
|
+
// An image that declares a non-root `USER` runs this step as that user,
|
|
1227
|
+
// and writing the trust store then fails ("cannot create
|
|
1228
|
+
// /etc/ssl/certs/ca-certificates.crt.new: Permission denied") — the CA
|
|
1229
|
+
// silently never lands. Take root for the one command, then hand the
|
|
1230
|
+
// image back its own user so containers still run as it.
|
|
1231
|
+
const declaredUser = (
|
|
1232
|
+
await docker(["image", "inspect", "--format", "{{.Config.User}}", tag], 60_000)
|
|
1233
|
+
).stdout.trim();
|
|
1234
|
+
const needsRoot = declaredUser !== "" && declaredUser !== "root" && declaredUser !== "0";
|
|
1224
1235
|
const dockerfile = `FROM ${tag}
|
|
1225
|
-
COPY spectest-ca.crt /usr/local/share/ca-certificates/spectest-ca.crt
|
|
1236
|
+
${needsRoot ? "USER root\n" : ""}COPY spectest-ca.crt /usr/local/share/ca-certificates/spectest-ca.crt
|
|
1226
1237
|
RUN if command -v update-ca-certificates >/dev/null 2>&1; then \\
|
|
1227
1238
|
update-ca-certificates; \\
|
|
1228
1239
|
elif command -v update-ca-trust >/dev/null 2>&1; then \\
|
|
@@ -1230,7 +1241,7 @@ RUN if command -v update-ca-certificates >/dev/null 2>&1; then \\
|
|
|
1230
1241
|
else \\
|
|
1231
1242
|
echo "[spectest] no system CA trust tool in image; env-var trust only"; \\
|
|
1232
1243
|
fi
|
|
1233
|
-
`;
|
|
1244
|
+
${needsRoot ? `USER ${declaredUser}\n` : ""}`;
|
|
1234
1245
|
await fs.writeFile(path.join(ctxDir, "Dockerfile"), dockerfile);
|
|
1235
1246
|
const build = await docker(["build", "-t", tag, ctxDir], 300_000);
|
|
1236
1247
|
if (build.code !== 0) {
|
|
@@ -1319,11 +1330,23 @@ async function runContainer(
|
|
|
1319
1330
|
}
|
|
1320
1331
|
}
|
|
1321
1332
|
|
|
1322
|
-
|
|
1333
|
+
/** What a readiness probe saw. `detail` is what the plain boolean used to
|
|
1334
|
+
* throw away, and it is the difference between the two ways a probe fails:
|
|
1335
|
+
* it ran and said no (a service that isn't up yet, a file a build never
|
|
1336
|
+
* produced) versus it never got to run (a docker daemon or a guest too
|
|
1337
|
+
* starved to answer inside the probe's own timeout). The failure message
|
|
1338
|
+
* carries the last one, since a `sleep infinity` service has no container
|
|
1339
|
+
* logs to fall back on. */
|
|
1340
|
+
interface ProbeOutcome {
|
|
1341
|
+
ok: boolean;
|
|
1342
|
+
detail: string;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
async function probeTcp(host: string, port: number): Promise<ProbeOutcome> {
|
|
1323
1346
|
return new Promise((resolve) => {
|
|
1324
1347
|
const sock = net.createConnection({ host, port });
|
|
1325
1348
|
let settled = false;
|
|
1326
|
-
const finish = (
|
|
1349
|
+
const finish = (ok: boolean, detail: string) => {
|
|
1327
1350
|
if (settled) return;
|
|
1328
1351
|
settled = true;
|
|
1329
1352
|
try {
|
|
@@ -1331,12 +1354,16 @@ async function probeTcp(host: string, port: number): Promise<boolean> {
|
|
|
1331
1354
|
} catch {
|
|
1332
1355
|
/* ignore */
|
|
1333
1356
|
}
|
|
1334
|
-
resolve(
|
|
1357
|
+
resolve({ ok, detail });
|
|
1335
1358
|
};
|
|
1336
1359
|
sock.setTimeout(2000);
|
|
1337
|
-
sock.once("connect", () => finish(true));
|
|
1338
|
-
sock.once("error", () =>
|
|
1339
|
-
|
|
1360
|
+
sock.once("connect", () => finish(true, `connected to ${host}:${port}`));
|
|
1361
|
+
sock.once("error", (err: Error) =>
|
|
1362
|
+
finish(false, `connect to ${host}:${port} failed: ${err.message}`),
|
|
1363
|
+
);
|
|
1364
|
+
sock.once("timeout", () =>
|
|
1365
|
+
finish(false, `connect to ${host}:${port} got no reply within 2000ms`),
|
|
1366
|
+
);
|
|
1340
1367
|
});
|
|
1341
1368
|
}
|
|
1342
1369
|
|
|
@@ -1346,52 +1373,92 @@ async function probeHttp(
|
|
|
1346
1373
|
urlPath: string,
|
|
1347
1374
|
headers?: Record<string, string>,
|
|
1348
1375
|
expectStatus?: number,
|
|
1349
|
-
): Promise<
|
|
1376
|
+
): Promise<ProbeOutcome> {
|
|
1377
|
+
const url = `http://${host}:${port}${urlPath}`;
|
|
1350
1378
|
const ctrl = new AbortController();
|
|
1351
1379
|
const to = setTimeout(() => ctrl.abort(), 5000);
|
|
1352
1380
|
try {
|
|
1353
|
-
const res = await fetch(
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
});
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1381
|
+
const res = await fetch(url, { signal: ctrl.signal, headers });
|
|
1382
|
+
const ok = expectStatus !== undefined ? res.status === expectStatus : res.ok;
|
|
1383
|
+
const want = expectStatus !== undefined ? String(expectStatus) : "2xx";
|
|
1384
|
+
return { ok, detail: `GET ${url} → ${res.status} (wanted ${want})` };
|
|
1385
|
+
} catch (err) {
|
|
1386
|
+
const e = err as Error;
|
|
1387
|
+
const detail =
|
|
1388
|
+
e?.name === "AbortError"
|
|
1389
|
+
? `GET ${url} got no reply within 5000ms`
|
|
1390
|
+
: `GET ${url} failed: ${e?.message ?? String(err)}`;
|
|
1391
|
+
return { ok: false, detail };
|
|
1360
1392
|
} finally {
|
|
1361
1393
|
clearTimeout(to);
|
|
1362
1394
|
}
|
|
1363
1395
|
}
|
|
1364
1396
|
|
|
1365
|
-
async function probeExec(name: string, command: string): Promise<
|
|
1397
|
+
async function probeExec(name: string, command: string): Promise<ProbeOutcome> {
|
|
1366
1398
|
const r = await docker(["exec", name, "sh", "-c", command], 10_000);
|
|
1367
|
-
|
|
1399
|
+
if (r.code === 0) return { ok: true, detail: `\`${command}\` exited 0` };
|
|
1400
|
+
// 124 is shx's own kill (see `shx`): the exec never answered, which says
|
|
1401
|
+
// the docker daemon is wedged or starved rather than anything about the
|
|
1402
|
+
// command's verdict.
|
|
1403
|
+
const why =
|
|
1404
|
+
r.code === 124
|
|
1405
|
+
? `was killed after 10000ms with no reply`
|
|
1406
|
+
: `exited ${r.code}`;
|
|
1407
|
+
const output = firstLine(r.stderr) || firstLine(r.stdout);
|
|
1408
|
+
return {
|
|
1409
|
+
ok: false,
|
|
1410
|
+
detail: `\`${command}\` ${why}${output ? `: ${output}` : ""}`,
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/** First non-empty line, trimmed and capped — probe output goes into an
|
|
1415
|
+
* error message, not a log file. */
|
|
1416
|
+
function firstLine(s: string): string {
|
|
1417
|
+
const line = s.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
|
|
1418
|
+
return line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
|
1368
1419
|
}
|
|
1369
1420
|
|
|
1370
1421
|
async function waitForReady(svc: NamedService): Promise<void> {
|
|
1371
1422
|
const check: ReadyCheck | undefined = svc.readyCheck;
|
|
1372
1423
|
if (!check) return;
|
|
1373
1424
|
const timeoutSecs = check.timeoutSecs ?? 60;
|
|
1425
|
+
let last: ProbeOutcome | undefined;
|
|
1374
1426
|
const probe = async (): Promise<boolean> => {
|
|
1375
|
-
if (check.type === "tcp")
|
|
1376
|
-
if (check.type === "http") {
|
|
1377
|
-
|
|
1427
|
+
if (check.type === "tcp") last = await probeTcp(svc.name, check.port);
|
|
1428
|
+
else if (check.type === "http") {
|
|
1429
|
+
last = await probeHttp(
|
|
1378
1430
|
svc.name,
|
|
1379
1431
|
check.port,
|
|
1380
1432
|
check.path ?? "/",
|
|
1381
1433
|
check.headers,
|
|
1382
1434
|
check.expectStatus,
|
|
1383
1435
|
);
|
|
1384
|
-
}
|
|
1385
|
-
return
|
|
1436
|
+
} else last = await probeExec(svc.name, check.command);
|
|
1437
|
+
return last.ok;
|
|
1386
1438
|
};
|
|
1387
1439
|
// Scheduling (the ramp, and not sleeping past the deadline) lives in
|
|
1388
1440
|
// `harness/ready-poll.ts`; this supplies the probe and the diagnosis.
|
|
1389
|
-
const { ready } = await pollUntilReady(probe, {
|
|
1441
|
+
const { ready, attempts, elapsedMs } = await pollUntilReady(probe, {
|
|
1442
|
+
kind: check.type,
|
|
1443
|
+
timeoutSecs,
|
|
1444
|
+
});
|
|
1390
1445
|
if (ready) return;
|
|
1446
|
+
// The attempt count is half the diagnosis: a probe with a 10s timeout of
|
|
1447
|
+
// its own can only run a handful of times in 60s, so "6 attempts" says the
|
|
1448
|
+
// probes were hanging where "80 attempts" says they ran and kept saying no.
|
|
1449
|
+
let msg =
|
|
1450
|
+
`service ${svc.name} not ready within ${timeoutSecs}s ` +
|
|
1451
|
+
`(${attempts} ${check.type} probe${attempts === 1 ? "" : "s"} over ${
|
|
1452
|
+
Math.round(elapsedMs / 100) / 10
|
|
1453
|
+
}s).`;
|
|
1454
|
+
if (last) msg += `\nLast probe: ${last.detail}`;
|
|
1391
1455
|
const logs = await docker(["logs", "--tail=200", svc.name], 30_000);
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1456
|
+
const output = `${logs.stdout}\n${logs.stderr}`.trim();
|
|
1457
|
+
// A service that logs nothing (`sleep infinity`, a quiet daemon) used to
|
|
1458
|
+
// get an empty "Recent container logs:" heading, which reads as if the
|
|
1459
|
+
// logs were the evidence and there simply weren't any.
|
|
1460
|
+
msg += output ? `\nRecent container logs:\n${output}` : `\n(the container logged nothing)`;
|
|
1461
|
+
throw new Error(msg);
|
|
1395
1462
|
}
|
|
1396
1463
|
|
|
1397
1464
|
/** Validate the `dependsOn` graph and return the name→service map used to
|
|
@@ -3625,8 +3692,9 @@ async function spectestContext(scope: ContextScope = {}): Promise<SpectestContex
|
|
|
3625
3692
|
const execTimeoutMs = scope.service ? COMPONENT_EXEC_DEFAULT_TIMEOUT_MS : undefined;
|
|
3626
3693
|
return {
|
|
3627
3694
|
projectRoot: WORKSPACE,
|
|
3628
|
-
readProjectFile: (p: string) =>
|
|
3629
|
-
|
|
3695
|
+
readProjectFile: (p: string) => fs.readFile(resolveProjectPath(p), "utf8"),
|
|
3696
|
+
readProjectFileBytes: async (p: string) =>
|
|
3697
|
+
new Uint8Array(await fs.readFile(resolveProjectPath(p))),
|
|
3630
3698
|
exec: (service, command, opts) =>
|
|
3631
3699
|
componentExec(service, command, opts, execTimeoutMs),
|
|
3632
3700
|
// Read `globalThis.fetch` at call time: the wrapper is installed for
|
|
@@ -4108,41 +4176,42 @@ async function pollCall<T>(
|
|
|
4108
4176
|
let success = false;
|
|
4109
4177
|
let predicateError: unknown;
|
|
4110
4178
|
|
|
4111
|
-
// Record all iterations normally
|
|
4112
|
-
//
|
|
4113
|
-
//
|
|
4114
|
-
//
|
|
4179
|
+
// Record all iterations normally, but keep only the newest one: a new
|
|
4180
|
+
// attempt drops the events the previous attempt emitted, so the timeline
|
|
4181
|
+
// never fills with polling noise. Whichever attempt is last when the loop
|
|
4182
|
+
// ends — the winning one, or the final failed one — stays, and gets marked
|
|
4183
|
+
// as a child of the wait event so the UI can render it nested.
|
|
4184
|
+
//
|
|
4185
|
+
// The failed attempt is kept deliberately. A poll that times out reports
|
|
4186
|
+
// only "timed out after 120000ms (60 attempts)", which says nothing about
|
|
4187
|
+
// WHY: an ingress answering an instant 404 for two minutes and a backend
|
|
4188
|
+
// that never returns look identical in that message. The last attempt's
|
|
4189
|
+
// events carry the status, the body and the duration, which is the whole
|
|
4190
|
+
// difference between "the route was never programmed" and "the app hung".
|
|
4115
4191
|
const beforePollIdx = recorderEventCount();
|
|
4116
4192
|
let lastIterStartIdx = beforePollIdx;
|
|
4117
|
-
let keptIterStartIdx = beforePollIdx;
|
|
4118
4193
|
|
|
4119
4194
|
while (Date.now() - start < timeoutMs) {
|
|
4120
4195
|
attempts += 1;
|
|
4196
|
+
// Drop the *previous* attempt's events, not this one's — its events are
|
|
4197
|
+
// the ones worth keeping until a newer attempt replaces them.
|
|
4198
|
+
recorderTruncate(lastIterStartIdx);
|
|
4121
4199
|
lastIterStartIdx = recorderEventCount();
|
|
4122
4200
|
try {
|
|
4123
4201
|
const v = await fn();
|
|
4124
4202
|
if (v !== null && v !== undefined && v !== false) {
|
|
4125
4203
|
value = v as T;
|
|
4126
4204
|
success = true;
|
|
4127
|
-
keptIterStartIdx = lastIterStartIdx;
|
|
4128
4205
|
break;
|
|
4129
4206
|
}
|
|
4130
4207
|
} catch (err) {
|
|
4131
4208
|
predicateError = err;
|
|
4132
4209
|
break;
|
|
4133
4210
|
}
|
|
4134
|
-
// Failed iteration — drop the events it emitted.
|
|
4135
|
-
recorderTruncate(lastIterStartIdx);
|
|
4136
4211
|
if (Date.now() - start + intervalMs > timeoutMs) break;
|
|
4137
4212
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
4138
4213
|
}
|
|
4139
4214
|
|
|
4140
|
-
if (!success) {
|
|
4141
|
-
// Timeout or predicate error: drop every attempt's events. The
|
|
4142
|
-
// wait event we emit below is the only trace.
|
|
4143
|
-
recorderTruncate(beforePollIdx);
|
|
4144
|
-
}
|
|
4145
|
-
|
|
4146
4215
|
const errMsg =
|
|
4147
4216
|
predicateError !== undefined
|
|
4148
4217
|
? ((predicateError as Error)?.message ?? String(predicateError))
|
|
@@ -4157,11 +4226,11 @@ async function pollCall<T>(
|
|
|
4157
4226
|
...(errMsg !== undefined ? { error: errMsg } : {}),
|
|
4158
4227
|
}, resv);
|
|
4159
4228
|
|
|
4160
|
-
if (
|
|
4229
|
+
if (seq !== undefined) {
|
|
4161
4230
|
// Group the kept iteration's events under the wait so the UI can
|
|
4162
4231
|
// render them inside the wait card. The wait event itself is the
|
|
4163
4232
|
// very last entry; markChildren skips it via the seq match.
|
|
4164
|
-
recorderMarkChildren(
|
|
4233
|
+
recorderMarkChildren(lastIterStartIdx, seq);
|
|
4165
4234
|
}
|
|
4166
4235
|
|
|
4167
4236
|
if (predicateError !== undefined) throw predicateError;
|
|
@@ -101,6 +101,14 @@ describe("certificate trust", () => {
|
|
|
101
101
|
);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
/** Deno ignores every variable above and ships its own root store, so
|
|
105
|
+
* without this an edge function calling a fake gets `UnknownIssuer`
|
|
106
|
+
* no matter what the image's trust store says. */
|
|
107
|
+
test("points Deno at the platform store as well as its own", () => {
|
|
108
|
+
const args = runContainerArgs(base({ caBundle: "/etc/spectest/ca-bundle.crt" }));
|
|
109
|
+
expect(envOf(args, "DENO_TLS_CA_STORE")).toBe("mozilla,system");
|
|
110
|
+
});
|
|
111
|
+
|
|
104
112
|
test("with no bundle, the replacing variables are left unset", () => {
|
|
105
113
|
const args = runContainerArgs(base({ caBundle: null }));
|
|
106
114
|
expect(envOf(args, "SSL_CERT_FILE")).toBeUndefined();
|
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
* showed the problem, which is why it went unnoticed: only the appending
|
|
30
30
|
* variable was in play.
|
|
31
31
|
*
|
|
32
|
+
* **Deno is a third mechanism.** It reads none of those variables and
|
|
33
|
+
* trusts only its bundled Mozilla roots, so an edge function calling a
|
|
34
|
+
* fake fails with `UnknownIssuer` however the image is set up.
|
|
35
|
+
* `DENO_TLS_CA_STORE=mozilla,system` is what adds the platform store,
|
|
36
|
+
* and the platform store is `SSL_CERT_FILE` — the combined bundle.
|
|
37
|
+
*
|
|
32
38
|
* **`command` and `args` are different overrides.** `command` replaces the
|
|
33
39
|
* entrypoint and runs through `sh -c`; `args` keeps the entrypoint and
|
|
34
40
|
* overrides CMD, which is what init-wrapped images like postgres need to
|
|
@@ -132,6 +138,13 @@ export function runContainerArgs(input: ContainerRunInput): string[] {
|
|
|
132
138
|
// replacing ones take the combined bundle.
|
|
133
139
|
args.push(`--volume=${input.caPath}:${input.caPath}:ro`);
|
|
134
140
|
args.push("-e", `NODE_EXTRA_CA_CERTS=${input.caPath}`);
|
|
141
|
+
// Deno reads none of the above, and its default store is the bundled
|
|
142
|
+
// Mozilla roots alone — so a Deno runtime (a Supabase edge function is
|
|
143
|
+
// the usual one) rejects every certificate this environment mints, with
|
|
144
|
+
// or without the CA layered into the image. `system` adds the platform
|
|
145
|
+
// store, which resolves through `SSL_CERT_FILE`: the bundle below.
|
|
146
|
+
// `mozilla` stays first so the public roots survive.
|
|
147
|
+
args.push("-e", "DENO_TLS_CA_STORE=mozilla,system");
|
|
135
148
|
const bundle = input.caBundle;
|
|
136
149
|
if (bundle) {
|
|
137
150
|
if (bundle !== input.caPath) args.push(`--volume=${bundle}:${bundle}:ro`);
|
package/src/index.ts
CHANGED
|
@@ -58,6 +58,8 @@ export type {
|
|
|
58
58
|
FilterOptions,
|
|
59
59
|
ClickOptions,
|
|
60
60
|
BoundingBox,
|
|
61
|
+
FilePayload,
|
|
62
|
+
InputFiles,
|
|
61
63
|
} from "./locator.js";
|
|
62
64
|
import type { Locator } from "./locator.js";
|
|
63
65
|
import {
|
|
@@ -312,6 +314,10 @@ export interface SpectestContext<
|
|
|
312
314
|
/** Read a project file as UTF-8. Relative paths resolve against
|
|
313
315
|
* {@link projectRoot}; absolute paths are read as-is. */
|
|
314
316
|
readProjectFile(path: string): Promise<string>;
|
|
317
|
+
/** Read a project file as bytes — a fixture to post, hash, or compare
|
|
318
|
+
* against what the app under test received. Same path rules as
|
|
319
|
+
* {@link readProjectFile}. */
|
|
320
|
+
readProjectFileBytes(path: string): Promise<Uint8Array>;
|
|
315
321
|
/**
|
|
316
322
|
* Run a command inside a service container. Pass an **array** for exact
|
|
317
323
|
* argv with no shell (`["psql", "-f", "-"]`), or a **string** to run via
|
package/src/locator.ts
CHANGED
|
@@ -22,9 +22,11 @@
|
|
|
22
22
|
// author-facing call is exactly one recorded browser event (with its rrweb
|
|
23
23
|
// drain), whatever playwright work it composes underneath.
|
|
24
24
|
|
|
25
|
+
import { Buffer } from "node:buffer";
|
|
25
26
|
import type { Page, Locator as PWLocator } from "playwright-core";
|
|
26
27
|
import type { RecordableFields } from "./browser.js";
|
|
27
28
|
import type { Wrapped } from "./inspect.js";
|
|
29
|
+
import { resolveExistingProjectPath } from "./project-files.js";
|
|
28
30
|
import { truncateUtf8 } from "./recorder.js";
|
|
29
31
|
|
|
30
32
|
/** Default deadline for a locator action/read's target to become actionable.
|
|
@@ -79,6 +81,21 @@ export interface ClickOptions {
|
|
|
79
81
|
modifiers?: Array<"Alt" | "Control" | "Meta" | "Shift">;
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
/** A file built in the test rather than read from the repo — the argument
|
|
85
|
+
* shape playwright's `setInputFiles` takes, with `buffer` widened to a plain
|
|
86
|
+
* `Uint8Array`/string and `mimeType` optional. */
|
|
87
|
+
export interface FilePayload {
|
|
88
|
+
name: string;
|
|
89
|
+
/** Defaults to `application/octet-stream`. */
|
|
90
|
+
mimeType?: string;
|
|
91
|
+
/** Bytes, or text (encoded UTF-8). */
|
|
92
|
+
buffer: Uint8Array | string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What `setInputFiles` accepts: repo-relative (or absolute) paths, built
|
|
96
|
+
* files, or `[]` to clear the input. */
|
|
97
|
+
export type InputFiles = string | string[] | FilePayload | FilePayload[];
|
|
98
|
+
|
|
82
99
|
export interface TimeoutOption {
|
|
83
100
|
timeout?: number;
|
|
84
101
|
}
|
|
@@ -454,6 +471,34 @@ async function stampActionPoint(
|
|
|
454
471
|
}
|
|
455
472
|
}
|
|
456
473
|
|
|
474
|
+
/** Fold a {@link InputFiles} argument into the one playwright takes: repo
|
|
475
|
+
* paths resolved to their in-VM location (see project-files.ts), built files
|
|
476
|
+
* given a default mime type and a real `Buffer`. The names come back too —
|
|
477
|
+
* they are what the timeline step shows, and the bytes never go near it. */
|
|
478
|
+
function lowerInputFiles(files: InputFiles): {
|
|
479
|
+
arg: string[] | { name: string; mimeType: string; buffer: Buffer }[];
|
|
480
|
+
names: string[];
|
|
481
|
+
} {
|
|
482
|
+
const many = Array.isArray(files) ? files : [files];
|
|
483
|
+
// `[]` clears the input, and every() is true for it — so an empty call
|
|
484
|
+
// lands here and lowers to an empty path list, which is what clears.
|
|
485
|
+
if (many.every((f) => typeof f === "string")) {
|
|
486
|
+
const paths = (many as string[]).map((f) => resolveExistingProjectPath(f, "fixture"));
|
|
487
|
+
return { arg: paths, names: paths.map((p) => p.split("/").pop() ?? p) };
|
|
488
|
+
}
|
|
489
|
+
if (many.some((f) => typeof f === "string")) {
|
|
490
|
+
throw new Error(
|
|
491
|
+
"setInputFiles: pass either repo paths or built files — not both in one call",
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
const payloads = (many as FilePayload[]).map((f) => ({
|
|
495
|
+
name: f.name,
|
|
496
|
+
mimeType: f.mimeType ?? "application/octet-stream",
|
|
497
|
+
buffer: typeof f.buffer === "string" ? Buffer.from(f.buffer, "utf8") : Buffer.from(f.buffer),
|
|
498
|
+
}));
|
|
499
|
+
return { arg: payloads, names: payloads.map((f) => f.name) };
|
|
500
|
+
}
|
|
501
|
+
|
|
457
502
|
// ────────────────────────────────────────────────────────────────────────
|
|
458
503
|
// The Locator interface
|
|
459
504
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -513,6 +558,25 @@ export interface Locator {
|
|
|
513
558
|
values: string | string[] | { label?: string; value?: string; index?: number },
|
|
514
559
|
opts?: TimeoutOption,
|
|
515
560
|
): Promise<Wrapped<string[]>>;
|
|
561
|
+
/**
|
|
562
|
+
* Give an `<input type="file">` its files — the upload primitive, since a
|
|
563
|
+
* browser refuses a script-set value on a file input. Targets the control a
|
|
564
|
+
* `<label>` points at, and does **not** require the input to be visible, so
|
|
565
|
+
* the hidden input behind a drop zone or a styled "Choose file" button is
|
|
566
|
+
* the thing to select.
|
|
567
|
+
*
|
|
568
|
+
* A string is a path in your repo, relative to `ctx.projectRoot` (absolute
|
|
569
|
+
* paths are taken as-is) — keep fixtures in `spectest/tests/fixtures/`, which
|
|
570
|
+
* is out of the environment cache key, so adding one still gives a warm
|
|
571
|
+
* start. Pass a {@link FilePayload} instead for a file the test builds, and
|
|
572
|
+
* `[]` to clear the input.
|
|
573
|
+
*
|
|
574
|
+
* ```ts
|
|
575
|
+
* await b.locator("#file").setInputFiles("spectest/tests/fixtures/invoice.pdf");
|
|
576
|
+
* await b.getByLabel("Avatar").setInputFiles({ name: "a.txt", buffer: "hi" });
|
|
577
|
+
* ```
|
|
578
|
+
*/
|
|
579
|
+
setInputFiles(files: InputFiles, opts?: TimeoutOption): Promise<void>;
|
|
516
580
|
hover(opts?: TimeoutOption): Promise<void>;
|
|
517
581
|
focus(opts?: TimeoutOption): Promise<void>;
|
|
518
582
|
blur(opts?: TimeoutOption): Promise<void>;
|
|
@@ -663,6 +727,12 @@ export function makeLocator(
|
|
|
663
727
|
act("setChecked", {}, (l) => l.setChecked(checked, { timeout: opts?.timeout })),
|
|
664
728
|
selectOption: (values, opts) =>
|
|
665
729
|
read("selectOption", (l) => l.selectOption(values as never, { timeout: opts?.timeout })),
|
|
730
|
+
setInputFiles: (files, opts) => {
|
|
731
|
+
const { arg, names } = lowerInputFiles(files);
|
|
732
|
+
return act("setInputFiles", { files: names }, (l) =>
|
|
733
|
+
l.setInputFiles(arg, { timeout: opts?.timeout }),
|
|
734
|
+
);
|
|
735
|
+
},
|
|
666
736
|
hover: (opts) => act("hover", {}, (l) => l.hover({ timeout: opts?.timeout })),
|
|
667
737
|
focus: (opts) => act("focus", {}, (l) => l.focus({ timeout: opts?.timeout })),
|
|
668
738
|
blur: (opts) => act("blur", {}, (l) => l.blur({ timeout: opts?.timeout })),
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Where a path in the user's repo lands inside the VM.
|
|
2
|
+
//
|
|
3
|
+
// The project exists in the guest TWICE, and the two copies are not refreshed
|
|
4
|
+
// at the same time (see `env.rs`):
|
|
5
|
+
//
|
|
6
|
+
// /workspace the whole repo. Written by a cold or delta
|
|
7
|
+
// start only — a warm start never re-uploads it.
|
|
8
|
+
// /opt/spectest/app/spectest the user's `spectest/` directory. Re-uploaded
|
|
9
|
+
// on EVERY start, warm ones included.
|
|
10
|
+
//
|
|
11
|
+
// A warm start happens only when the warm-template hash matches, so /workspace
|
|
12
|
+
// is correct for every file that is IN that hash. It can be behind for the two
|
|
13
|
+
// kinds of file the hash excludes: `spectest/tests/**` (excluded so a test-only
|
|
14
|
+
// edit keeps the fast start) and anything a project lists in
|
|
15
|
+
// `spectest/.envignore`.
|
|
16
|
+
//
|
|
17
|
+
// Hence the rule below: a path under `spectest/` resolves against the app copy
|
|
18
|
+
// first, because that copy is always current — this is what lets a fixture live
|
|
19
|
+
// in `spectest/tests/fixtures/` and still be found after it was added. Any
|
|
20
|
+
// other path resolves against /workspace, which the hash keeps current.
|
|
21
|
+
//
|
|
22
|
+
// The remaining hole is a file that `.envignore` excludes AND that sits outside
|
|
23
|
+
// `spectest/`: /workspace holds whatever the cold start uploaded, so a later
|
|
24
|
+
// edit is invisible. Nothing can repair those bytes at read time, so we refuse
|
|
25
|
+
// to read them instead of returning stale content. The control plane writes the
|
|
26
|
+
// exact path list it excluded (env.rs) into ENV_IGNORED_FILE.
|
|
27
|
+
|
|
28
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
|
|
31
|
+
/** The extracted repo — `ctx.projectRoot`. */
|
|
32
|
+
export const WORKSPACE = process.env.SPECTEST_WORKSPACE ?? "/workspace";
|
|
33
|
+
/** The app dir; the user's `spectest/` sits directly under it. */
|
|
34
|
+
export const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
|
|
35
|
+
|
|
36
|
+
/** Paths (repo-relative) the warm-template hash skipped because of
|
|
37
|
+
* `spectest/.envignore`. Written by the control plane at env start; absent
|
|
38
|
+
* when the project ships no `.envignore`. */
|
|
39
|
+
const ENV_IGNORED_FILE = "/run/spectest-env-ignored.json";
|
|
40
|
+
|
|
41
|
+
let _envIgnored: Set<string> | undefined;
|
|
42
|
+
function envIgnored(): Set<string> {
|
|
43
|
+
if (_envIgnored) return _envIgnored;
|
|
44
|
+
let paths: string[] = [];
|
|
45
|
+
try {
|
|
46
|
+
const raw = JSON.parse(readFileSync(ENV_IGNORED_FILE, "utf8")) as { paths?: string[] };
|
|
47
|
+
paths = raw.paths ?? [];
|
|
48
|
+
} catch {
|
|
49
|
+
/* No file (no .envignore, or an older env) — nothing to refuse. */
|
|
50
|
+
}
|
|
51
|
+
_envIgnored = new Set(paths);
|
|
52
|
+
return _envIgnored;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Repo-relative form of `p`: no leading `./`, POSIX separators. */
|
|
56
|
+
function relative(p: string): string {
|
|
57
|
+
return path.normalize(p).replace(/^\.\//, "").replace(/^\/+/, "");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Absolute in-VM path for a path in the user's repo. Absolute input is
|
|
62
|
+
* returned as-is. A relative path resolves against the project, preferring the
|
|
63
|
+
* copy that a warm start refreshes (see the header).
|
|
64
|
+
*
|
|
65
|
+
* A file that is missing everywhere resolves to its /workspace candidate, so
|
|
66
|
+
* the caller's own `fs` error names a real path — `readProjectFile` keeps
|
|
67
|
+
* reporting ENOENT the way it always has.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveProjectPath(p: string): string {
|
|
70
|
+
if (path.isAbsolute(p)) return p;
|
|
71
|
+
const rel = relative(p);
|
|
72
|
+
if (rel.startsWith("spectest/")) {
|
|
73
|
+
const app = path.join(APP_DIR, rel);
|
|
74
|
+
if (existsSync(app)) return app;
|
|
75
|
+
}
|
|
76
|
+
if (envIgnored().has(rel)) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`project file "${p}" is excluded by spectest/.envignore, so the copy in the VM ` +
|
|
79
|
+
`is whatever a cold start uploaded and can be out of date. Move it under ` +
|
|
80
|
+
`spectest/tests/ (still cache-free, and always re-uploaded), or drop the pattern.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return path.join(WORKSPACE, rel);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** {@link resolveProjectPath}, but a missing file is an error that names every
|
|
87
|
+
* place we looked. For inputs a user hands us by name — a fixture — where an
|
|
88
|
+
* ENOENT on one guessed path reads as a spectest bug rather than a typo. */
|
|
89
|
+
export function resolveExistingProjectPath(p: string, what = "file"): string {
|
|
90
|
+
const resolved = resolveProjectPath(p);
|
|
91
|
+
if (existsSync(resolved)) return resolved;
|
|
92
|
+
const rel = relative(p);
|
|
93
|
+
const looked = path.isAbsolute(p)
|
|
94
|
+
? [p]
|
|
95
|
+
: rel.startsWith("spectest/")
|
|
96
|
+
? [path.join(APP_DIR, rel), path.join(WORKSPACE, rel)]
|
|
97
|
+
: [path.join(WORKSPACE, rel)];
|
|
98
|
+
throw new Error(
|
|
99
|
+
`${what} "${p}" is not in the VM (looked in ${looked.join(", ")}). Paths are ` +
|
|
100
|
+
`relative to your repo root. Check that the file is committed and not ` +
|
|
101
|
+
`excluded by .gitignore/.spectestignore.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
package/src/recorder.ts
CHANGED
|
@@ -445,6 +445,9 @@ export interface BrowserEvent extends BaseEvent {
|
|
|
445
445
|
key?: string;
|
|
446
446
|
/** Attribute name (getAttribute). */
|
|
447
447
|
attribute?: string;
|
|
448
|
+
/** File names given to an `<input type="file">` (setInputFiles). Names
|
|
449
|
+
* only — a built file's bytes stay out of the timeline. */
|
|
450
|
+
files?: string[];
|
|
448
451
|
/** Scroll/click coordinates. */
|
|
449
452
|
dx?: number;
|
|
450
453
|
dy?: number;
|