@specific.dev/spectest 0.16.0 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/browser.ts CHANGED
@@ -159,8 +159,10 @@ export interface Browser {
159
159
  /** Navigate to a URL; resolves when the main frame's load completes. */
160
160
  navigate(url: string): Promise<void>;
161
161
  /**
162
- * Evaluate a JS expression in the page and return the
163
- * JSON-deserialised result.
162
+ * Evaluate JS in the page and return the JSON-deserialised result.
163
+ * Accepts a single expression or a statement body (`const x = …;
164
+ * return x;`) — statement bodies are auto-wrapped in an async IIFE, so
165
+ * `return` and `await` work without manual wrapping.
164
166
  *
165
167
  * `description` is a short human-readable label for what the
166
168
  * snippet is doing ("read rendered todo list"); it surfaces in the
@@ -255,8 +257,9 @@ export interface MobileBackend extends Browser {
255
257
  * when the CDP override is unavailable). The daemon stamps these onto
256
258
  * the session record for the dashboard's replay. */
257
259
  readonly safeAreaInsets: SafeAreaInsets | null;
258
- /** Touch-tap at viewport CSS coordinates (touchStart→touchEnd). */
259
- tapAt(x: number, y: number): Promise<void>;
260
+ /** Touch-tap at viewport CSS coordinates (touchStart → short dwell →
261
+ * touchEnd; `durationMs` overrides the dwell). */
262
+ tapAt(x: number, y: number, opts?: { durationMs?: number }): Promise<void>;
260
263
  /** Touch-drag from (x,y) by (dx,dy) over a short move sequence. */
261
264
  swipeBy(x: number, y: number, dx: number, dy: number): Promise<void>;
262
265
  /**
@@ -289,6 +292,32 @@ const CHROME_ARGV = [
289
292
  "--dns-over-https-mode=off",
290
293
  ];
291
294
 
295
+ /** Default touchStart→touchEnd dwell for `tapAt` — see the comment there. */
296
+ const TAP_DWELL_MS = 60;
297
+
298
+ /**
299
+ * Make a user script evaluable by the page: Bun's `view.evaluate` accepts a
300
+ * single EXPRESSION (it wraps the source in `await (...)`), so a statement
301
+ * body (`const x = …; return x;`) is a syntax error. Rather than forcing
302
+ * authors to IIFE-wrap by hand, wrap it for them when it isn't an expression.
303
+ *
304
+ * The check is parse-only (`new Function` compiles without executing) and
305
+ * happens BEFORE the script runs — deciding by catching the page-side error
306
+ * and retrying would re-execute side-effecting expressions whose *runtime*
307
+ * error happens to look syntactic (`JSON.parse` throws SyntaxError too).
308
+ */
309
+ function toEvaluable(script: string): string {
310
+ try {
311
+ new Function(`return (${script}\n);`);
312
+ return script;
313
+ } catch {
314
+ // Statement body — an async IIFE makes `return`, declarations, and
315
+ // multi-statement snippets valid, with `await` still available. The
316
+ // newlines keep a trailing line comment from eating the wrapper.
317
+ return `(async () => {\n${script}\n})()`;
318
+ }
319
+ }
320
+
292
321
  // ────────────────────────────────────────────────────────────────────────
293
322
  // Device emulation (mobile frame)
294
323
  // ────────────────────────────────────────────────────────────────────────
@@ -1144,7 +1173,7 @@ function buildBackend(
1144
1173
  scriptTruncated: truncatedScript.truncated,
1145
1174
  },
1146
1175
  async () => {
1147
- const v = await holder.view.evaluate<T>(script);
1176
+ const v = await holder.view.evaluate<T>(toEvaluable(script));
1148
1177
  return v;
1149
1178
  },
1150
1179
  ) as Promise<Wrapped<T>>;
@@ -1183,6 +1212,8 @@ function buildBackend(
1183
1212
  scriptTruncated: truncatedScript.truncated,
1184
1213
  attempts: 0,
1185
1214
  };
1215
+ // Normalised once up front — see `toEvaluable` (statement bodies work).
1216
+ const evaluable = toEvaluable(expression);
1186
1217
  return instrumented<T>("waitFor", fields, async () => {
1187
1218
  const deadline = Date.now() + timeoutMs;
1188
1219
  // (return value wrapped by `instrumented`; cast below matches.)
@@ -1195,7 +1226,7 @@ function buildBackend(
1195
1226
  fields.attempts = (fields.attempts ?? 0) + 1;
1196
1227
  let v: unknown;
1197
1228
  try {
1198
- v = await holder.view.evaluate<unknown>(expression);
1229
+ v = await holder.view.evaluate<unknown>(evaluable);
1199
1230
  } catch (err) {
1200
1231
  if (Date.now() >= deadline) throw err;
1201
1232
  await new Promise((r) => setTimeout(r, intervalMs));
@@ -1272,7 +1303,7 @@ function buildBackend(
1272
1303
  }
1273
1304
  },
1274
1305
  // ── Mobile-only primitives ──────────────────────────────────────────
1275
- tapAt(x, y) {
1306
+ tapAt(x, y, opts) {
1276
1307
  // Recorded as a "click" (the event schema stays desktop-shaped; the
1277
1308
  // mobile facade owns the author-facing "tap" vocabulary). Dispatches
1278
1309
  // a real touch so RN-Web's responder system fires.
@@ -1281,6 +1312,13 @@ function buildBackend(
1281
1312
  type: "touchStart",
1282
1313
  touchPoints: [{ x, y, id: 0 }],
1283
1314
  });
1315
+ // Dwell between start and end, like a real finger. An instant
1316
+ // touchStart→touchEnd starves RN Pressables whose `onPressIn`
1317
+ // mutates state (optimistic label flips, scale animations): React
1318
+ // re-renders mid-gesture and the press never completes. The dwell
1319
+ // lets that commit land before release; well under any long-press
1320
+ // threshold (RN default 500ms).
1321
+ await new Promise((r) => setTimeout(r, opts?.durationMs ?? TAP_DWELL_MS));
1284
1322
  await holder.view.cdp("Input.dispatchTouchEvent", {
1285
1323
  type: "touchEnd",
1286
1324
  touchPoints: [],
@@ -28,7 +28,12 @@ export interface ExpoOptions {
28
28
  nodeVersion?: string;
29
29
  /** Output dir `expo export` writes to (relative to the app dir). Default `"dist"`. */
30
30
  outputDir?: string;
31
- /** Extra environment variables for the static server container. */
31
+ /**
32
+ * Extra environment variables. `EXPO_PUBLIC_*` keys are additionally baked
33
+ * into the image build so `expo export` inlines them into the bundle —
34
+ * Expo resolves them at export time, so runtime-only container env would be
35
+ * too late. Everything else applies to the static-server container only.
36
+ */
32
37
  env?: Record<string, string>;
33
38
  }
34
39
 
@@ -106,6 +111,23 @@ export function expo(opts: ExpoOptions = {}) {
106
111
  const nodeVersion = opts.nodeVersion ?? "20-bookworm-slim";
107
112
  const outputDir = opts.outputDir ?? "dist";
108
113
 
114
+ // Expo inlines EXPO_PUBLIC_* variables into the bundle at `expo export`
115
+ // time, so they must exist during the image build — runtime container env
116
+ // is too late. Bake them as ENV lines placed after `npm install` (an env
117
+ // edit re-exports without re-installing) and before the export RUN. They
118
+ // remain in `env` too, so server-side reads in the container agree.
119
+ const publicEnv = Object.entries(opts.env ?? {}).filter(([k]) =>
120
+ k.startsWith("EXPO_PUBLIC_"),
121
+ );
122
+ for (const [k, v] of publicEnv) {
123
+ if (/[\r\n]/.test(v)) {
124
+ throw new Error(`expo(): env ${k} must not contain newlines (baked into the Dockerfile)`);
125
+ }
126
+ }
127
+ const publicEnvLines = publicEnv
128
+ .map(([k, v]) => `ENV ${k}="${v.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`)
129
+ .join("\n");
130
+
109
131
  // CI=1 keeps `expo export` non-interactive. Static export resolves the web
110
132
  // bundle deterministically into `outputDir`.
111
133
  const dockerfile = `FROM node:${nodeVersion}
@@ -113,7 +135,7 @@ WORKDIR /app
113
135
  ENV CI=1
114
136
  COPY ${appDir}/ ./
115
137
  RUN npm install
116
- RUN npx expo export --platform web --output-dir ${outputDir}
138
+ ${publicEnvLines ? `${publicEnvLines}\n` : ""}RUN npx expo export --platform web --output-dir ${outputDir}
117
139
  `;
118
140
 
119
141
  return {
package/src/daemon.ts CHANGED
@@ -845,6 +845,19 @@ async function buildServiceImage(
845
845
  await fs.mkdir(dfDir, { recursive: true });
846
846
  const dfPath = path.join(dfDir, "Dockerfile");
847
847
  await fs.writeFile(dfPath, image.content);
848
+ // Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
849
+ // (next to the Dockerfile) in preference to the context root's
850
+ // `.dockerignore`, so this build sees the defaults plus ITS OWN
851
+ // `exclude` only — one service excluding `handhelds/**` no longer
852
+ // empties a sibling's build context. Verified on both the remote-buildx
853
+ // and DOCKER_BUILDKIT paths (client-side context filtering). The root
854
+ // union `.dockerignore` written at bootstrap stays as the fallback for
855
+ // the legacy non-BuildKit builder, which predates per-Dockerfile
856
+ // ignores.
857
+ await fs.writeFile(
858
+ `${dfPath}.dockerignore`,
859
+ [...DEFAULT_DOCKERIGNORE, ...(image.exclude ?? [])].join("\n") + "\n",
860
+ );
848
861
  const useRemote = await ensureRemoteBuilder();
849
862
  // Both the remote builder and a local buildx are BuildKit, so both emit
850
863
  // per-step timing on stderr under `--progress=plain` (parsed below). Only
@@ -2045,6 +2058,16 @@ async function proxyToService(
2045
2058
  // the public hostname. Lets origin servers that vhost by Host header
2046
2059
  // continue to find the right virtual host.
2047
2060
  fwdHeaders.set("host", `${service}:${port}`);
2061
+ // Fresh connection per upstream request — never reuse a pooled
2062
+ // keep-alive conn. Upstreams with short idle timeouts (uvicorn defaults
2063
+ // to 5s) close pooled connections under Bun's fetch, and the next
2064
+ // request on the dead socket fails with "socket closed unexpectedly"
2065
+ // even though the service is healthy. In-VM connects to a peer
2066
+ // container are sub-ms, so per-request connects cost nothing at test
2067
+ // scale. `connection: close` makes the upstream tear down immediately;
2068
+ // `keepalive: false` on the fetch below keeps Bun from pooling its end.
2069
+ // (Both verified effective on Bun 1.3.14.)
2070
+ fwdHeaders.set("connection", "close");
2048
2071
 
2049
2072
  // Buffer bounded request bodies so a transient upstream connect failure
2050
2073
  // can be retried (a ReadableStream body is consumed by the first
@@ -2085,8 +2108,13 @@ async function proxyToService(
2085
2108
  redirect: "manual",
2086
2109
  });
2087
2110
  // decompress:false → forward the encoded body untouched (see fn doc).
2111
+ // keepalive:false → fresh connection per request (see fwdHeaders above).
2088
2112
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2089
- const upstreamRes = await NATIVE_FETCH(upstreamReq, { decompress: false } as any);
2113
+ const upstreamRes = await NATIVE_FETCH(upstreamReq, {
2114
+ decompress: false,
2115
+ keepalive: false,
2116
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2117
+ } as any);
2090
2118
  // Strip hop-by-hop response headers; let Bun set content-length / TE.
2091
2119
  const respHeaders = new Headers();
2092
2120
  for (const [k, v] of upstreamRes.headers) {
@@ -2100,11 +2128,18 @@ async function proxyToService(
2100
2128
  });
2101
2129
  } catch (err) {
2102
2130
  lastErr = err;
2103
- // Only connect-class failures are safely retryable — if the request
2104
- // reached the upstream we must not replay it.
2131
+ // Retry connection-level failures: connect errors, plus a socket
2132
+ // that died before any response bytes ("socket closed unexpectedly",
2133
+ // ECONNRESET, hang-up) — under boot-time bursts an upstream accepts
2134
+ // and drops connections while still warming up. Pre-response
2135
+ // failures are the standard retry class for reverse proxies
2136
+ // (nginx's proxy_next_upstream error). Anything that produced a
2137
+ // response is never replayed.
2105
2138
  const msg = (err as Error)?.message ?? String(err);
2106
2139
  const connectFailure =
2107
- /unable to connect|connection refused|connect|typo in the url/i.test(msg);
2140
+ /unable to connect|connection refused|connect|typo in the url|socket closed|connection closed|econnreset|socket hang ?up|epipe/i.test(
2141
+ msg,
2142
+ );
2108
2143
  if (!connectFailure || attempt === attempts) break;
2109
2144
  // The cached IP may be stale (container recreated) — re-resolve.
2110
2145
  PROXY_IP_CACHE.delete(service);
package/src/mobile.ts CHANGED
@@ -69,12 +69,13 @@ interface LocatorDesc {
69
69
  * moment an action runs. Mirrors the select-then-act idiom shared by
70
70
  * Playwright, Detox, and RN Testing Library. */
71
71
  export interface MobileLocator {
72
- /** Wait for the element to be visible, then touch-tap its center. */
73
- tap(): Promise<void>;
72
+ /** Wait for the element to be visible (default 5s, `timeoutMs` overrides),
73
+ * then touch-tap its center. `durationMs` overrides the touch dwell. */
74
+ tap(opts?: { timeoutMs?: number; durationMs?: number }): Promise<void>;
74
75
  /** Tap to focus, then type `text` via real key events. */
75
- typeText(text: string): Promise<void>;
76
+ typeText(text: string, opts?: { timeoutMs?: number }): Promise<void>;
76
77
  /** Clear a text input's current value (RN-Web controlled input safe). */
77
- clearText(): Promise<void>;
78
+ clearText(opts?: { timeoutMs?: number }): Promise<void>;
78
79
  /** Scroll the element to the center of the viewport. */
79
80
  scrollIntoView(): Promise<void>;
80
81
  /** Wait until the element is attached and visible (throws on timeout). */
@@ -168,6 +169,11 @@ function descLabel(desc: LocatorDesc): string {
168
169
  return `${desc.kind} ${JSON.stringify(desc.value)}`;
169
170
  }
170
171
 
172
+ /** Default wait for a locator action's target to become visible. Override
173
+ * per action with `{ timeoutMs }` (animations that move elements mid-flight
174
+ * often need more than the default). */
175
+ const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
176
+
171
177
  function makeLocator(backend: MobileBackend, desc: LocatorDesc): MobileLocator {
172
178
  const label = descLabel(desc);
173
179
 
@@ -188,17 +194,17 @@ function makeLocator(backend: MobileBackend, desc: LocatorDesc): MobileLocator {
188
194
  }
189
195
 
190
196
  return {
191
- async tap() {
192
- const { x, y } = await waitCoords(5_000);
193
- await backend.tapAt(x, y);
197
+ async tap(opts) {
198
+ const { x, y } = await waitCoords(opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
199
+ await backend.tapAt(x, y, { durationMs: opts?.durationMs });
194
200
  },
195
- async typeText(text) {
196
- const { x, y } = await waitCoords(5_000);
201
+ async typeText(text, opts) {
202
+ const { x, y } = await waitCoords(opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
197
203
  await backend.tapAt(x, y);
198
204
  await backend.type(text);
199
205
  },
200
- async clearText() {
201
- await waitCoords(5_000);
206
+ async clearText(opts) {
207
+ await waitCoords(opts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS);
202
208
  // Native value-setter + input event so RN-Web's controlled TextInput
203
209
  // sees the change (a plain `.value = ""` is swallowed by React).
204
210
  await backend.evaluate(
@@ -269,6 +275,13 @@ export interface Mobile {
269
275
  getByLabel(text: string): MobileLocator;
270
276
  /** Escape hatch: select by a raw CSS selector. */
271
277
  locator(css: string): MobileLocator;
278
+ /**
279
+ * Touch-tap at viewport CSS coordinates — the escape hatch for targets no
280
+ * locator can select (unlabeled icon-only buttons, canvas hit areas). Same
281
+ * real-touch dispatch and dwell as a locator `tap()`. Prefer locators when
282
+ * the element has a testID/label; coordinates break on layout changes.
283
+ */
284
+ tapAt(x: number, y: number, opts?: { durationMs?: number }): Promise<void>;
272
285
  /** Swipe the screen in a direction (a touch drag from the center). */
273
286
  swipe(direction: "up" | "down" | "left" | "right", opts?: { distance?: number }): Promise<void>;
274
287
  /** Wheel-scroll the viewport by a pixel delta. */
@@ -334,6 +347,9 @@ function wrapMobile(backend: MobileBackend): Mobile {
334
347
  locator(css) {
335
348
  return makeLocator(backend, { kind: "css", value: css });
336
349
  },
350
+ tapAt(x, y, opts) {
351
+ return backend.tapAt(x, y, opts);
352
+ },
337
353
  async swipe(direction, opts) {
338
354
  const vp = await backend.probe<{ w: number; h: number }>(
339
355
  "({ w: window.innerWidth, h: window.innerHeight })",
package/src/recorder.ts CHANGED
@@ -115,8 +115,12 @@ export interface DbEvent extends BaseEvent {
115
115
  query: string;
116
116
  /** Parameter values. Best-effort JSON-safe; large/binary values stringified. */
117
117
  params?: unknown[];
118
- /** Rows returned, when known. */
118
+ /** Rows returned — or rows AFFECTED when `rowsAffected` is set. */
119
119
  rowCount?: number;
120
+ /** Set when `rowCount` counts affected rows (a non-RETURNING
121
+ * INSERT/UPDATE/DELETE) rather than a result set — rendered as
122
+ * "N affected" so an `UPDATE → 0 rows` can't read as "nothing updated". */
123
+ rowsAffected?: boolean;
120
124
  /** Captured rows for table rendering in the web UI. Capped at
121
125
  * MAX_DB_ROWS; `rowsTruncated` is set when there were more. */
122
126
  rows?: unknown[];
package/src/sql.ts CHANGED
@@ -124,6 +124,7 @@ export function instrumentSql(raw: RawSqlClient, label: string): SqlClient {
124
124
  query,
125
125
  params: params.length > 0 ? params.map(safeSerialize) : undefined,
126
126
  rowCount: rowCountOf(value),
127
+ rowsAffected: isAffectedCount(query, value) || undefined,
127
128
  rows,
128
129
  rowsTruncated,
129
130
  columns,
@@ -192,15 +193,34 @@ function reconstructSqlTemplate(
192
193
  }
193
194
 
194
195
  function rowCountOf(value: unknown): number | undefined {
195
- if (Array.isArray(value)) return value.length;
196
196
  if (value && typeof value === "object") {
197
197
  const obj = value as { count?: unknown; rowCount?: unknown };
198
+ // Bun.SQL (postgres.js semantics): `.count` is rows RETURNED for
199
+ // SELECT/RETURNING queries and rows AFFECTED for other writes — where
200
+ // the result array itself is empty. Check it before `.length`, or a
201
+ // non-RETURNING `UPDATE` that touched 3 rows records a misleading 0.
198
202
  if (typeof obj.count === "number") return obj.count;
199
203
  if (typeof obj.rowCount === "number") return obj.rowCount;
200
204
  }
205
+ if (Array.isArray(value)) return value.length;
201
206
  return undefined;
202
207
  }
203
208
 
209
+ /** True when the recorded count means "rows affected" rather than "rows
210
+ * returned" — a write with no result set. Lets the dashboard render
211
+ * `3 affected` instead of `3 rows` (and keeps `UPDATE … (0 rows)` from
212
+ * reading as "nothing updated"). */
213
+ function isAffectedCount(query: string, value: unknown): boolean {
214
+ if (Array.isArray(value) && value.length > 0) return false; // rows came back
215
+ const q = query.trimStart().slice(0, 8).toUpperCase();
216
+ return (
217
+ q.startsWith("INSERT") ||
218
+ q.startsWith("UPDATE") ||
219
+ q.startsWith("DELETE") ||
220
+ q.startsWith("MERGE")
221
+ );
222
+ }
223
+
204
224
  const MAX_DB_ROWS = 50;
205
225
 
206
226
  function captureRows(value: unknown): {