@specific.dev/spectest 0.68.0 → 0.70.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.
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The :443 TLS terminator — one listener, for the life of the harness.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * Ingress certificates are minted *while tests run*: a fake that
7
+ * provisions a database hands the app a CA-trusted `https://<new-host>/`
8
+ * endpoint, and that hostname did not exist a second earlier. Bun fixes a
9
+ * server's TLS configuration at `Bun.serve` time — `reload({ tls })` is
10
+ * accepted and then ignored (re-measured on Bun 1.4.0: the added
11
+ * `serverName` still gets the first entry's leaf) — so for as long as
12
+ * :443 was a `Bun.serve`, every new certificate meant swapping the
13
+ * listener.
14
+ *
15
+ * **No listener swap can be made safe.** Binding the replacement first
16
+ * under SO_REUSEPORT and draining the original with `stop(false)` saves a
17
+ * request that is already *in flight*, and that is all it saves: measured
18
+ * on Bun 1.4.0, `stop(false)` sends FIN to an **idle kept-alive
19
+ * connection within 1–2 ms**. A client that writes a request into that
20
+ * socket in the same instant gets `SocketError: other side closed`
21
+ * (`UND_ERR_SOCKET` under undici), and Node's `fetch` does not retry it.
22
+ * That is a flake in whatever unrelated test happened to be talking
23
+ * through the ingress — never in the one that provisioned the service —
24
+ * and it is why the app under test would die far from any ingress code.
25
+ *
26
+ * So the listener must stop being swapped, which means the certificate
27
+ * must be chosen **per handshake** rather than baked into the socket.
28
+ * That is what every real server does, and under Bun it is reachable
29
+ * through `node:tls`'s `SNICallback` (verified working on Bun 1.4.0:
30
+ * adding an entry to the table is served on the next handshake, with no
31
+ * restart and no effect on any established connection).
32
+ *
33
+ * ## The shape, and why it is a byte pipe
34
+ *
35
+ * The terminator does TLS and *nothing else*: it accepts on :443, picks a
36
+ * leaf by SNI, and pipes the decrypted stream to a plaintext `Bun.serve`
37
+ * bound on loopback. Everything downstream — routing, fakes,
38
+ * interceptors, reverse-proxying, and `server.upgrade()` WebSocket
39
+ * bridging — is the same `Bun.serve` code that already serves :80, run
40
+ * unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
41
+ * would have meant reimplementing the WebSocket bridge against a
42
+ * different API, which is exactly the kind of trade this change exists to
43
+ * avoid: one flake removed, another introduced somewhere subtler.
44
+ *
45
+ * Measured cost of the extra hop on this host (Bun 1.4.0, undici client,
46
+ * 50 MB bodies): ~11 % fewer small requests per second, download
47
+ * throughput unchanged, upload ~500 MB/s against ~800 MB/s direct. Ingress
48
+ * traffic in a test VM is nowhere near either bound.
49
+ *
50
+ * ## Recovering the client's address
51
+ *
52
+ * The plaintext server's peer is the terminator, so `server.requestIP()`
53
+ * reports loopback and `x-forwarded-for` would lose the caller. The
54
+ * upstream socket's *source port* is unique among live connections, so the
55
+ * terminator records `source port → real client address` for the life of
56
+ * each connection and {@link TlsTerminator.clientIpFor} hands it back.
57
+ * Verified end to end: a client connecting from 127.0.0.2 is reported as
58
+ * 127.0.0.2 by the plaintext server behind the pipe.
59
+ */
60
+ import net from "node:net";
61
+ import tls from "node:tls";
62
+ /**
63
+ * Start the terminator. Resolves once :443 is accepting, so a caller can
64
+ * register the hostname in DNS immediately afterwards and know the port is
65
+ * live.
66
+ */
67
+ /**
68
+ * Is this the ordinary end of a connection rather than a fault? A peer
69
+ * that goes away mid-stream, or a half-finished handshake, is routine on a
70
+ * listener that fronts browsers.
71
+ */
72
+ function isRoutineDisconnect(err) {
73
+ const code = err?.code;
74
+ return (code === "ECONNRESET" ||
75
+ code === "EPIPE" ||
76
+ code === "ERR_STREAM_PREMATURE_CLOSE" ||
77
+ code === "ECONNABORTED");
78
+ }
79
+ export function startTlsTerminator(opts) {
80
+ // Parsing a PEM into a SecureContext is the expensive half of choosing a
81
+ // certificate, and the table is stable — cache by the certificate's own
82
+ // bytes so a handshake is a Map lookup. Keyed on the PEM rather than the
83
+ // hostname so a re-minted leaf for the same name cannot be served stale.
84
+ const contexts = new Map();
85
+ const contextFor = (leaf) => {
86
+ let ctx = contexts.get(leaf.cert);
87
+ if (!ctx) {
88
+ ctx = tls.createSecureContext({ cert: leaf.cert, key: leaf.key });
89
+ contexts.set(leaf.cert, ctx);
90
+ }
91
+ return ctx;
92
+ };
93
+ const warn = (message, err) => {
94
+ // A client hanging up is not a fault, and this listener carries every
95
+ // browser in the VM: Chromium opens speculative connections and drops
96
+ // them, a fetch is abandoned when its test ends, and each of those
97
+ // arrives here as a reset. Logging them would bury the boot log a user
98
+ // reads with `spectest env logs` in noise that means nothing.
99
+ if (isRoutineDisconnect(err))
100
+ return;
101
+ if (opts.onWarning)
102
+ opts.onWarning(message, err);
103
+ // eslint-disable-next-line no-console
104
+ else
105
+ console.warn(`[ingress] ${message}:`, err);
106
+ };
107
+ /** upstream source port → the address the TLS client connected from. */
108
+ const clientIpByUpstreamPort = new Map();
109
+ const live = new Set();
110
+ // A default leaf is only ever used for a client that sends no SNI at
111
+ // all; `SNICallback` decides every other handshake. This mirrors what
112
+ // Bun did with the first entry of its `tls` array.
113
+ const fallback = opts.certFor(undefined);
114
+ const server = tls.createServer({
115
+ ...(fallback ? { cert: fallback.cert, key: fallback.key } : {}),
116
+ SNICallback: (serverName, cb) => {
117
+ const leaf = opts.certFor(serverName) ?? fallback;
118
+ // No certificate at all is a handshake we cannot complete. Answer
119
+ // with the fallback context rather than an error so the client
120
+ // gets a certificate-mismatch alert it can report, which is the
121
+ // same thing it saw when Bun served the first entry.
122
+ cb(null, leaf ? contextFor(leaf) : undefined);
123
+ },
124
+ }, (client) => {
125
+ // No idle timeout anywhere on the ingress path: it fronts app
126
+ // endpoints that legitimately take minutes (a deploy), and a
127
+ // timeout here would surface as a truncated response with no
128
+ // explanation.
129
+ client.setTimeout(0);
130
+ live.add(client);
131
+ // Nothing reads from `client` until `pipe` below, so a socket that
132
+ // is written to before the upstream is connected stays paused and
133
+ // its bytes are buffered rather than dropped — which is the normal
134
+ // case, since a client sends its first request the instant the
135
+ // handshake completes.
136
+ const upstream = net.connect(opts.upstreamPort, "127.0.0.1");
137
+ upstream.setTimeout(0);
138
+ upstream.setNoDelay(true);
139
+ client.setNoDelay(true);
140
+ let recordedPort;
141
+ upstream.on("connect", () => {
142
+ recordedPort = upstream.localPort;
143
+ if (recordedPort !== undefined && client.remoteAddress) {
144
+ clientIpByUpstreamPort.set(recordedPort, client.remoteAddress);
145
+ }
146
+ // `pipe` carries backpressure and the half-close in both
147
+ // directions, which is what a WebSocket bridge and a streamed
148
+ // upload each need.
149
+ client.pipe(upstream);
150
+ upstream.pipe(client);
151
+ });
152
+ const teardown = () => {
153
+ if (recordedPort !== undefined)
154
+ clientIpByUpstreamPort.delete(recordedPort);
155
+ live.delete(client);
156
+ client.destroy();
157
+ upstream.destroy();
158
+ };
159
+ client.on("error", (err) => {
160
+ warn("tls client connection failed", err);
161
+ teardown();
162
+ });
163
+ upstream.on("error", (err) => {
164
+ warn(`ingress upstream :${opts.upstreamPort} failed`, err);
165
+ teardown();
166
+ });
167
+ client.on("close", teardown);
168
+ upstream.on("close", teardown);
169
+ });
170
+ // A client that aborts mid-handshake, or offers a protocol version we
171
+ // do not speak, emits this. It must be handled: an unhandled 'error' on
172
+ // a net server is thrown, and taking the daemon down over one bad
173
+ // handshake would be a far worse failure than the one being fixed.
174
+ server.on("tlsClientError", (err) => warn("tls handshake failed", err));
175
+ server.on("error", (err) => warn(`tls listener :${opts.port} failed`, err));
176
+ return new Promise((resolve, reject) => {
177
+ server.once("error", reject);
178
+ server.listen(opts.port, opts.hostname, () => {
179
+ server.removeListener("error", reject);
180
+ resolve({
181
+ port: opts.port,
182
+ clientIpFor: (upstreamPort) => upstreamPort === undefined ? undefined : clientIpByUpstreamPort.get(upstreamPort),
183
+ connectionCount: () => live.size,
184
+ close: () => {
185
+ try {
186
+ server.close();
187
+ }
188
+ catch (err) {
189
+ warn("closing the tls listener failed", err);
190
+ }
191
+ for (const sock of [...live])
192
+ sock.destroy();
193
+ live.clear();
194
+ clientIpByUpstreamPort.clear();
195
+ },
196
+ });
197
+ });
198
+ });
199
+ }
package/dist/index.js CHANGED
@@ -713,7 +713,7 @@ function buildLocatorMatchers(loc, negated, message) {
713
713
  // The last error a probe threw, kept for the failure message: a matcher
714
714
  // whose read waits for the element (textContent, inputValue, isEnabled …)
715
715
  // reports a missing element as a playwright timeout, and that — not
716
- // "got <error: Timeout 5000ms exceeded …>" — is the sentence to fail with.
716
+ // "got <error: Timeout 10000ms exceeded …>" — is the sentence to fail with.
717
717
  let probeError;
718
718
  for (;;) {
719
719
  let satisfied;
@@ -753,8 +753,8 @@ function buildLocatorMatchers(loc, negated, message) {
753
753
  }
754
754
  if (Date.now() >= deadline) {
755
755
  // The deadline, not the stopwatch: every one of these polled until it
756
- // ran out, and "(waited 5s)" is both what the author set and the same
757
- // number twice in a row — a measured 4_987ms is neither.
756
+ // ran out, and "(waited 10s)" is both what the author set and the same
757
+ // number twice in a row — a measured 9_987ms is neither.
758
758
  const msg = (await elementFailure(probeError)) ??
759
759
  `${describe(actual)} (waited ${formatWaited(budget)})`;
760
760
  const sourceSeq = await probe.settle(matcher, Date.now() - started, msg, settleOpts);
@@ -1,7 +1,7 @@
1
1
  // Readable failures for locator steps.
2
2
  //
3
3
  // Playwright reports every unmet actionability wait the same way: a
4
- // `TimeoutError` whose message is "Timeout 5000ms exceeded." with the real
4
+ // `TimeoutError` whose message is "Timeout 10000ms exceeded." with the real
5
5
  // story — did the element exist at all? was it disabled? did something cover
6
6
  // it? — buried in a call log below it. A test that clicks a button that is not
7
7
  // on the page therefore fails with a sentence about OUR deadline, which reads
package/dist/locator.d.ts CHANGED
@@ -3,11 +3,16 @@ import type { RecordableFields } from "./browser.js";
3
3
  import type { Wrapped } from "./inspect.js";
4
4
  import { type HintTarget } from "./locator-hints.js";
5
5
  /** Default deadline for a locator action/read's target to become actionable.
6
- * Playwright's own default is 30s — far too slow-failing for tests; 5s
7
- * matches the pre-Playwright behavior. A per-call `{ timeout }` overrides it;
8
- * `undefined` falls through to the context default (also set to this in
9
- * browser.ts's `newViewContext`). Navigations keep a longer deadline. */
10
- export declare const DEFAULT_ACTION_TIMEOUT_MS = 5000;
6
+ * Playwright's own default is 30s — far too slow-failing for tests. This was
7
+ * 5s (the pre-Playwright behavior) until 2026-09-03, and 5s turned out to be
8
+ * about one page load: a real app under a full VM fan-out took 1.8-2.0s for a
9
+ * full navigation plus its client data chain on a quiet box, and 4x that on
10
+ * the tail, so a step that was correct and merely late failed. 10s still
11
+ * fails fast enough to be useful and leaves room for the tail. A per-call
12
+ * `{ timeout }` overrides it; `undefined` falls through to the context
13
+ * default (also set to this in browser.ts's `newViewContext`). Navigations
14
+ * keep a longer deadline. */
15
+ export declare const DEFAULT_ACTION_TIMEOUT_MS = 10000;
11
16
  export interface GetByTextOptions {
12
17
  /** Whole-string, case-sensitive match instead of the default
13
18
  * case-insensitive substring. Ignored when the query is a RegExp. */
package/dist/locator.js CHANGED
@@ -27,11 +27,16 @@ import { formatHints, nearMissHints } from "./locator-hints.js";
27
27
  import { resolveExistingProjectPath } from "./project-files.js";
28
28
  import { truncateUtf8 } from "./recorder.js";
29
29
  /** Default deadline for a locator action/read's target to become actionable.
30
- * Playwright's own default is 30s — far too slow-failing for tests; 5s
31
- * matches the pre-Playwright behavior. A per-call `{ timeout }` overrides it;
32
- * `undefined` falls through to the context default (also set to this in
33
- * browser.ts's `newViewContext`). Navigations keep a longer deadline. */
34
- export const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
30
+ * Playwright's own default is 30s — far too slow-failing for tests. This was
31
+ * 5s (the pre-Playwright behavior) until 2026-09-03, and 5s turned out to be
32
+ * about one page load: a real app under a full VM fan-out took 1.8-2.0s for a
33
+ * full navigation plus its client data chain on a quiet box, and 4x that on
34
+ * the tail, so a step that was correct and merely late failed. 10s still
35
+ * fails fast enough to be useful and leaves room for the tail. A per-call
36
+ * `{ timeout }` overrides it; `undefined` falls through to the context
37
+ * default (also set to this in browser.ts's `newViewContext`). Navigations
38
+ * keep a longer deadline. */
39
+ export const DEFAULT_ACTION_TIMEOUT_MS = 10_000;
35
40
  // Brand + chain carrier. Both are `Symbol.for` keys so `JSON.stringify` drops
36
41
  // them (locators are never serialized) while runtime code can still detect a
37
42
  // locator and read a nested one's chain during lowering.
@@ -401,7 +406,7 @@ export function makeLocator(backend, strategy, chain) {
401
406
  return formatHints(await nearMissHints(page, target));
402
407
  };
403
408
  // Every terminal op runs through this: playwright's actionability timeouts
404
- // say "Timeout 5000ms exceeded" and hide what actually went wrong in a call
409
+ // say "Timeout 10000ms exceeded" and hide what actually went wrong in a call
405
410
  // log, so they are rewritten into a sentence naming the element and its
406
411
  // state (see locator-errors.ts), and a failure that found NO element asks
407
412
  // the page what it does hold. Applied INSIDE `pageOp`, so the recorded step
package/dist/terminal.js CHANGED
@@ -28,6 +28,9 @@
28
28
  import { Terminal as XtermHeadless } from "@xterm/headless";
29
29
  import { recordTerminalStep, reserveEvent, truncateUtf8 } from "./recorder.js";
30
30
  import { wrap } from "./inspect.js";
31
+ // One default deadline for "wait until the app shows it", whichever surface
32
+ // the author is watching — see the constant for why it is what it is.
33
+ import { DEFAULT_ACTION_TIMEOUT_MS } from "./locator.js";
31
34
  // Cap on cumulative output we keep in memory. The asciicast frames
32
35
  // grow separately (they live on the session record) and aren't bounded
33
36
  // here. Matches `TERMINAL_OUTPUT_CAP_BYTES` in daemon.ts.
@@ -421,7 +424,7 @@ export async function openTerminal(args) {
421
424
  return renderScreen();
422
425
  },
423
426
  async waitFor(description, matcher, waitOpts) {
424
- const timeoutMs = waitOpts?.timeoutMs ?? 5_000;
427
+ const timeoutMs = waitOpts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS;
425
428
  const intervalMs = waitOpts?.intervalMs ?? 100;
426
429
  const t = Date.now();
427
430
  const resv = reserveEvent();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.68.0",
3
+ "version": "0.70.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
@@ -1943,7 +1943,7 @@ function buildBackend(
1943
1943
  arg?: unknown,
1944
1944
  options: { timeout?: number; polling?: number } = {},
1945
1945
  ): Promise<Wrapped<T>> {
1946
- const timeoutMs = options.timeout ?? 5_000;
1946
+ const timeoutMs = options.timeout ?? DEFAULT_ACTION_TIMEOUT_MS;
1947
1947
  const intervalMs = options.polling ?? 100;
1948
1948
  const src = typeof fn === "string" ? fn : fn.toString();
1949
1949
  const t = truncateUtf8(src);