@specific.dev/spectest 0.47.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 CHANGED
@@ -359,4 +359,5 @@ export interface RecordableFields {
359
359
  attempts: number;
360
360
  artifactId: string;
361
361
  attribute: string;
362
+ files: string[];
362
363
  }
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
- const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
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";
@@ -1028,7 +1030,7 @@ async function probeTcp(host, port) {
1028
1030
  return new Promise((resolve) => {
1029
1031
  const sock = net.createConnection({ host, port });
1030
1032
  let settled = false;
1031
- const finish = (v) => {
1033
+ const finish = (ok, detail) => {
1032
1034
  if (settled)
1033
1035
  return;
1034
1036
  settled = true;
@@ -1038,26 +1040,30 @@ async function probeTcp(host, port) {
1038
1040
  catch {
1039
1041
  /* ignore */
1040
1042
  }
1041
- resolve(v);
1043
+ resolve({ ok, detail });
1042
1044
  };
1043
1045
  sock.setTimeout(2000);
1044
- sock.once("connect", () => finish(true));
1045
- sock.once("error", () => finish(false));
1046
- 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`));
1047
1049
  });
1048
1050
  }
1049
1051
  async function probeHttp(host, port, urlPath, headers, expectStatus) {
1052
+ const url = `http://${host}:${port}${urlPath}`;
1050
1053
  const ctrl = new AbortController();
1051
1054
  const to = setTimeout(() => ctrl.abort(), 5000);
1052
1055
  try {
1053
- const res = await fetch(`http://${host}:${port}${urlPath}`, {
1054
- signal: ctrl.signal,
1055
- headers,
1056
- });
1057
- 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})` };
1058
1060
  }
1059
- catch {
1060
- return false;
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 };
1061
1067
  }
1062
1068
  finally {
1063
1069
  clearTimeout(to);
@@ -1065,28 +1071,64 @@ async function probeHttp(host, port, urlPath, headers, expectStatus) {
1065
1071
  }
1066
1072
  async function probeExec(name, command) {
1067
1073
  const r = await docker(["exec", name, "sh", "-c", command], 10_000);
1068
- return r.code === 0;
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;
1069
1093
  }
1070
1094
  async function waitForReady(svc) {
1071
1095
  const check = svc.readyCheck;
1072
1096
  if (!check)
1073
1097
  return;
1074
1098
  const timeoutSecs = check.timeoutSecs ?? 60;
1099
+ let last;
1075
1100
  const probe = async () => {
1076
1101
  if (check.type === "tcp")
1077
- return probeTcp(svc.name, check.port);
1078
- if (check.type === "http") {
1079
- return probeHttp(svc.name, check.port, check.path ?? "/", check.headers, check.expectStatus);
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);
1080
1105
  }
1081
- return probeExec(svc.name, check.command);
1106
+ else
1107
+ last = await probeExec(svc.name, check.command);
1108
+ return last.ok;
1082
1109
  };
1083
1110
  // Scheduling (the ramp, and not sleeping past the deadline) lives in
1084
1111
  // `harness/ready-poll.ts`; this supplies the probe and the diagnosis.
1085
- const { ready } = await pollUntilReady(probe, { kind: check.type, timeoutSecs });
1112
+ const { ready, attempts, elapsedMs } = await pollUntilReady(probe, {
1113
+ kind: check.type,
1114
+ timeoutSecs,
1115
+ });
1086
1116
  if (ready)
1087
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}`;
1088
1125
  const logs = await docker(["logs", "--tail=200", svc.name], 30_000);
1089
- throw new Error(`service ${svc.name} not ready within ${timeoutSecs}s. Recent container logs:\n${logs.stdout}\n${logs.stderr}`);
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);
1090
1132
  }
1091
1133
  /** Validate the `dependsOn` graph and return the name→service map used to
1092
1134
  * walk it. Rules live in `harness/service-graph.ts`. */
@@ -2926,7 +2968,8 @@ async function spectestContext(scope = {}) {
2926
2968
  const execTimeoutMs = scope.service ? COMPONENT_EXEC_DEFAULT_TIMEOUT_MS : undefined;
2927
2969
  return {
2928
2970
  projectRoot: WORKSPACE,
2929
- readProjectFile: (p) => fs.readFile(path.isAbsolute(p) ? p : path.join(WORKSPACE, p), "utf8"),
2971
+ readProjectFile: (p) => fs.readFile(resolveProjectPath(p), "utf8"),
2972
+ readProjectFileBytes: async (p) => new Uint8Array(await fs.readFile(resolveProjectPath(p))),
2930
2973
  exec: (service, command, opts) => componentExec(service, command, opts, execTimeoutMs),
2931
2974
  // Read `globalThis.fetch` at call time: the wrapper is installed for
2932
2975
  // the duration of a test / eval / project setup, so a context built
@@ -3342,22 +3385,31 @@ async function pollCall(description, fn, opts) {
3342
3385
  let value;
3343
3386
  let success = false;
3344
3387
  let predicateError;
3345
- // Record all iterations normally. Falsy iterations get truncated
3346
- // from the recorder so the timeline doesn't fill with polling
3347
- // noise; the LAST iteration's events stay, then get marked as
3348
- // children of the wait event so the UI can render them nested.
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".
3349
3400
  const beforePollIdx = recorderEventCount();
3350
3401
  let lastIterStartIdx = beforePollIdx;
3351
- let keptIterStartIdx = beforePollIdx;
3352
3402
  while (Date.now() - start < timeoutMs) {
3353
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);
3354
3407
  lastIterStartIdx = recorderEventCount();
3355
3408
  try {
3356
3409
  const v = await fn();
3357
3410
  if (v !== null && v !== undefined && v !== false) {
3358
3411
  value = v;
3359
3412
  success = true;
3360
- keptIterStartIdx = lastIterStartIdx;
3361
3413
  break;
3362
3414
  }
3363
3415
  }
@@ -3365,17 +3417,10 @@ async function pollCall(description, fn, opts) {
3365
3417
  predicateError = err;
3366
3418
  break;
3367
3419
  }
3368
- // Failed iteration — drop the events it emitted.
3369
- recorderTruncate(lastIterStartIdx);
3370
3420
  if (Date.now() - start + intervalMs > timeoutMs)
3371
3421
  break;
3372
3422
  await new Promise((r) => setTimeout(r, intervalMs));
3373
3423
  }
3374
- if (!success) {
3375
- // Timeout or predicate error: drop every attempt's events. The
3376
- // wait event we emit below is the only trace.
3377
- recorderTruncate(beforePollIdx);
3378
- }
3379
3424
  const errMsg = predicateError !== undefined
3380
3425
  ? (predicateError?.message ?? String(predicateError))
3381
3426
  : success
@@ -3388,11 +3433,11 @@ async function pollCall(description, fn, opts) {
3388
3433
  passed: success,
3389
3434
  ...(errMsg !== undefined ? { error: errMsg } : {}),
3390
3435
  }, resv);
3391
- if (success && seq !== undefined) {
3436
+ if (seq !== undefined) {
3392
3437
  // Group the kept iteration's events under the wait so the UI can
3393
3438
  // render them inside the wait card. The wait event itself is the
3394
3439
  // very last entry; markChildren skips it via the seq match.
3395
- recorderMarkChildren(keptIterStartIdx, seq);
3440
+ recorderMarkChildren(lastIterStartIdx, seq);
3396
3441
  }
3397
3442
  if (predicateError !== undefined)
3398
3443
  throw predicateError;
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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/browser.ts CHANGED
@@ -2092,4 +2092,5 @@ export interface RecordableFields {
2092
2092
  attempts: number;
2093
2093
  artifactId: string;
2094
2094
  attribute: string;
2095
+ files: string[];
2095
2096
  }
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
- const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
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";
@@ -1328,11 +1330,23 @@ async function runContainer(
1328
1330
  }
1329
1331
  }
1330
1332
 
1331
- async function probeTcp(host: string, port: number): Promise<boolean> {
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> {
1332
1346
  return new Promise((resolve) => {
1333
1347
  const sock = net.createConnection({ host, port });
1334
1348
  let settled = false;
1335
- const finish = (v: boolean) => {
1349
+ const finish = (ok: boolean, detail: string) => {
1336
1350
  if (settled) return;
1337
1351
  settled = true;
1338
1352
  try {
@@ -1340,12 +1354,16 @@ async function probeTcp(host: string, port: number): Promise<boolean> {
1340
1354
  } catch {
1341
1355
  /* ignore */
1342
1356
  }
1343
- resolve(v);
1357
+ resolve({ ok, detail });
1344
1358
  };
1345
1359
  sock.setTimeout(2000);
1346
- sock.once("connect", () => finish(true));
1347
- sock.once("error", () => finish(false));
1348
- sock.once("timeout", () => finish(false));
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
+ );
1349
1367
  });
1350
1368
  }
1351
1369
 
@@ -1355,52 +1373,92 @@ async function probeHttp(
1355
1373
  urlPath: string,
1356
1374
  headers?: Record<string, string>,
1357
1375
  expectStatus?: number,
1358
- ): Promise<boolean> {
1376
+ ): Promise<ProbeOutcome> {
1377
+ const url = `http://${host}:${port}${urlPath}`;
1359
1378
  const ctrl = new AbortController();
1360
1379
  const to = setTimeout(() => ctrl.abort(), 5000);
1361
1380
  try {
1362
- const res = await fetch(`http://${host}:${port}${urlPath}`, {
1363
- signal: ctrl.signal,
1364
- headers,
1365
- });
1366
- return expectStatus !== undefined ? res.status === expectStatus : res.ok;
1367
- } catch {
1368
- return false;
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 };
1369
1392
  } finally {
1370
1393
  clearTimeout(to);
1371
1394
  }
1372
1395
  }
1373
1396
 
1374
- async function probeExec(name: string, command: string): Promise<boolean> {
1397
+ async function probeExec(name: string, command: string): Promise<ProbeOutcome> {
1375
1398
  const r = await docker(["exec", name, "sh", "-c", command], 10_000);
1376
- return r.code === 0;
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;
1377
1419
  }
1378
1420
 
1379
1421
  async function waitForReady(svc: NamedService): Promise<void> {
1380
1422
  const check: ReadyCheck | undefined = svc.readyCheck;
1381
1423
  if (!check) return;
1382
1424
  const timeoutSecs = check.timeoutSecs ?? 60;
1425
+ let last: ProbeOutcome | undefined;
1383
1426
  const probe = async (): Promise<boolean> => {
1384
- if (check.type === "tcp") return probeTcp(svc.name, check.port);
1385
- if (check.type === "http") {
1386
- return probeHttp(
1427
+ if (check.type === "tcp") last = await probeTcp(svc.name, check.port);
1428
+ else if (check.type === "http") {
1429
+ last = await probeHttp(
1387
1430
  svc.name,
1388
1431
  check.port,
1389
1432
  check.path ?? "/",
1390
1433
  check.headers,
1391
1434
  check.expectStatus,
1392
1435
  );
1393
- }
1394
- return probeExec(svc.name, check.command);
1436
+ } else last = await probeExec(svc.name, check.command);
1437
+ return last.ok;
1395
1438
  };
1396
1439
  // Scheduling (the ramp, and not sleeping past the deadline) lives in
1397
1440
  // `harness/ready-poll.ts`; this supplies the probe and the diagnosis.
1398
- const { ready } = await pollUntilReady(probe, { kind: check.type, timeoutSecs });
1441
+ const { ready, attempts, elapsedMs } = await pollUntilReady(probe, {
1442
+ kind: check.type,
1443
+ timeoutSecs,
1444
+ });
1399
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}`;
1400
1455
  const logs = await docker(["logs", "--tail=200", svc.name], 30_000);
1401
- throw new Error(
1402
- `service ${svc.name} not ready within ${timeoutSecs}s. Recent container logs:\n${logs.stdout}\n${logs.stderr}`,
1403
- );
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);
1404
1462
  }
1405
1463
 
1406
1464
  /** Validate the `dependsOn` graph and return the name→service map used to
@@ -3634,8 +3692,9 @@ async function spectestContext(scope: ContextScope = {}): Promise<SpectestContex
3634
3692
  const execTimeoutMs = scope.service ? COMPONENT_EXEC_DEFAULT_TIMEOUT_MS : undefined;
3635
3693
  return {
3636
3694
  projectRoot: WORKSPACE,
3637
- readProjectFile: (p: string) =>
3638
- fs.readFile(path.isAbsolute(p) ? p : path.join(WORKSPACE, p), "utf8"),
3695
+ readProjectFile: (p: string) => fs.readFile(resolveProjectPath(p), "utf8"),
3696
+ readProjectFileBytes: async (p: string) =>
3697
+ new Uint8Array(await fs.readFile(resolveProjectPath(p))),
3639
3698
  exec: (service, command, opts) =>
3640
3699
  componentExec(service, command, opts, execTimeoutMs),
3641
3700
  // Read `globalThis.fetch` at call time: the wrapper is installed for
@@ -4117,41 +4176,42 @@ async function pollCall<T>(
4117
4176
  let success = false;
4118
4177
  let predicateError: unknown;
4119
4178
 
4120
- // Record all iterations normally. Falsy iterations get truncated
4121
- // from the recorder so the timeline doesn't fill with polling
4122
- // noise; the LAST iteration's events stay, then get marked as
4123
- // children of the wait event so the UI can render them nested.
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".
4124
4191
  const beforePollIdx = recorderEventCount();
4125
4192
  let lastIterStartIdx = beforePollIdx;
4126
- let keptIterStartIdx = beforePollIdx;
4127
4193
 
4128
4194
  while (Date.now() - start < timeoutMs) {
4129
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);
4130
4199
  lastIterStartIdx = recorderEventCount();
4131
4200
  try {
4132
4201
  const v = await fn();
4133
4202
  if (v !== null && v !== undefined && v !== false) {
4134
4203
  value = v as T;
4135
4204
  success = true;
4136
- keptIterStartIdx = lastIterStartIdx;
4137
4205
  break;
4138
4206
  }
4139
4207
  } catch (err) {
4140
4208
  predicateError = err;
4141
4209
  break;
4142
4210
  }
4143
- // Failed iteration — drop the events it emitted.
4144
- recorderTruncate(lastIterStartIdx);
4145
4211
  if (Date.now() - start + intervalMs > timeoutMs) break;
4146
4212
  await new Promise((r) => setTimeout(r, intervalMs));
4147
4213
  }
4148
4214
 
4149
- if (!success) {
4150
- // Timeout or predicate error: drop every attempt's events. The
4151
- // wait event we emit below is the only trace.
4152
- recorderTruncate(beforePollIdx);
4153
- }
4154
-
4155
4215
  const errMsg =
4156
4216
  predicateError !== undefined
4157
4217
  ? ((predicateError as Error)?.message ?? String(predicateError))
@@ -4166,11 +4226,11 @@ async function pollCall<T>(
4166
4226
  ...(errMsg !== undefined ? { error: errMsg } : {}),
4167
4227
  }, resv);
4168
4228
 
4169
- if (success && seq !== undefined) {
4229
+ if (seq !== undefined) {
4170
4230
  // Group the kept iteration's events under the wait so the UI can
4171
4231
  // render them inside the wait card. The wait event itself is the
4172
4232
  // very last entry; markChildren skips it via the seq match.
4173
- recorderMarkChildren(keptIterStartIdx, seq);
4233
+ recorderMarkChildren(lastIterStartIdx, seq);
4174
4234
  }
4175
4235
 
4176
4236
  if (predicateError !== undefined) throw predicateError;
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;