@specific.dev/spectest 0.31.0 → 0.32.1

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.js CHANGED
@@ -32,7 +32,7 @@ import { readFileSync } from "node:fs";
32
32
  import path from "node:path";
33
33
  import { fileURLToPath } from "node:url";
34
34
  import { generateId } from "./ids.js";
35
- import { recordBrowser, reserveEvent, truncateUtf8 } from "./recorder.js";
35
+ import { recordBrowser, reserveBackdated, reserveEvent, truncateUtf8 } from "./recorder.js";
36
36
  import { wrap } from "./inspect.js";
37
37
  import { describeUrlPattern, matchesUrl } from "./url-match.js";
38
38
  import { attachBrowserProbe, DEFAULT_ACTION_TIMEOUT_MS, desktopStrategy, makeLocator, mobileStrategy, } from "./locator.js";
@@ -1407,7 +1407,9 @@ function buildBackend(holder, recorder, buildOpts) {
1407
1407
  // No page work — the matcher already read the value via silentRead. We
1408
1408
  // only mint the timeline anchor: the seq the assertion nests under, plus
1409
1409
  // `sessionTimestamp` (post-settle wall clock) so the dashboard seeks the
1410
- // replay to the frame the assertion observed.
1410
+ // replay to the frame the assertion observed. The offset is backdated to
1411
+ // where the matcher started polling, since that's what `tOffsetMs` means
1412
+ // everywhere else (the ops that reserve up front stamp their start).
1411
1413
  const endT = Date.now();
1412
1414
  const seq = recordBrowser({
1413
1415
  action,
@@ -1417,7 +1419,7 @@ function buildBackend(holder, recorder, buildOpts) {
1417
1419
  : {}),
1418
1420
  durationMs: waitedMs,
1419
1421
  ...(error ? { error } : {}),
1420
- });
1422
+ }, reserveBackdated(waitedMs));
1421
1423
  // Drain the rrweb the page buffered while the matcher waited into this
1422
1424
  // step's chunk, so `settledTarget` has bounds to seek into.
1423
1425
  await drain(action);
@@ -1,7 +1,7 @@
1
1
  export { postgres, type PostgresOptions, type PostgresHelpers, } from "./postgres.js";
2
2
  export { s3, type S3Options, type S3Helpers, } from "./s3.js";
3
3
  export { SQL, type SqlClient, type SqlOptions } from "../sql.js";
4
- export { k3s, type K3sOptions, type K3sHelpers, type K3sClient, } from "./k3s.js";
4
+ export { k3s, type K3sOptions, type K3sHelpers, type K3sClient, type TaggedObjectApi, type KubernetesObjectRef, type ApplyOptions, type RolloutTarget, type WaitOptions, type LogsOptions, type KubernetesObject, type KubernetesListObject, } from "./k3s.js";
5
5
  export { expo, type ExpoOptions, type ExpoHelpers, } from "./expo.js";
6
6
  export { supabase, type SupabaseOptions, type SupabaseHelpers, type SupabaseStack, } from "./supabase.js";
7
7
  export { email, type EmailOptions, type EmailHelpers, type EmailMessage, type EmailSummary, type EmailMatch, type EmailAttachment, } from "./email.js";
@@ -1,6 +1,7 @@
1
- import { AppsV1Api, CoreV1Api, KubeConfig, KubernetesObjectApi, type KubernetesObject } from "@kubernetes/client-node";
1
+ import { AppsV1Api, BatchV1Api, CoreV1Api, KubeConfig, PatchStrategy, type KubernetesListObject, type KubernetesObject, type V1DeleteOptions, type V1Job, type V1Status } from "@kubernetes/client-node";
2
2
  import type { ServiceHelpersContext } from "../index.js";
3
3
  import type { Wrapped } from "../inspect.js";
4
+ export type { KubernetesObject, KubernetesListObject, } from "@kubernetes/client-node";
4
5
  export interface K3sOptions {
5
6
  /** Image tag for the official `rancher/k3s` image. Default `"v1.30.6-k3s1"`. */
6
7
  version?: string;
@@ -109,6 +110,50 @@ export interface K3sOptions {
109
110
  type Tagged<T> = {
110
111
  [K in keyof T]: T[K] extends (...args: infer A) => Promise<infer R> ? (...args: A) => Promise<Wrapped<R>> : T[K];
111
112
  };
113
+ /**
114
+ * `{ apiVersion, kind, metadata: { name } }` — enough to address an
115
+ * existing object. Redeclared here because `@kubernetes/client-node`
116
+ * keeps its equivalent (`KubernetesObjectHeader`) module-private.
117
+ */
118
+ export type KubernetesObjectRef<T extends KubernetesObject = KubernetesObject> = Pick<T, "apiVersion" | "kind"> & {
119
+ metadata: {
120
+ name: string;
121
+ namespace?: string;
122
+ };
123
+ };
124
+ /**
125
+ * The dynamic object API, hand-declared rather than derived through
126
+ * {@link Tagged}.
127
+ *
128
+ * `Tagged<T>` infers each method from its *erased* signature, and a mapped
129
+ * type can't reintroduce a type parameter (TypeScript has no higher-kinded
130
+ * types). For `CoreV1Api`/`AppsV1Api` that's invisible — their methods
131
+ * return concrete types — but `KubernetesObjectApi.read<T>`/`list<T>` are
132
+ * generic, so mapping them collapsed `read<MyCustomResource>(…)` to
133
+ * `Promise<Wrapped<KubernetesObject>>`: `Expected 0 type arguments, but got
134
+ * 1`, then `Property 'spec' does not exist on type 'KubernetesObject'` —
135
+ * precisely on the custom resources this API exists to reach. Worse, the
136
+ * degraded value made `expect(...)` resolve to the *locator* overload, so
137
+ * the reported error was a baffling `Property 'toBe' does not exist on type
138
+ * 'LocatorAssertion'` three lines further down.
139
+ *
140
+ * So the generic methods are written out. Arguments match the library's
141
+ * (positional, as it declares them); only the resolved value becomes
142
+ * {@link Wrapped}. See {@link K3sHelpers.list} for an options-object
143
+ * wrapper over the positional `list`.
144
+ */
145
+ export interface TaggedObjectApi {
146
+ create<T extends KubernetesObject>(spec: T, pretty?: string, dryRun?: string, fieldManager?: string): Promise<Wrapped<T>>;
147
+ patch<T extends KubernetesObject>(spec: T, pretty?: string, dryRun?: string, fieldManager?: string, force?: boolean, patchStrategy?: PatchStrategy): Promise<Wrapped<T>>;
148
+ /** Read one object. **Throws** on 404 — to assert something is *gone*,
149
+ * prefer {@link K3sHelpers.list} with a `metadata.name` field selector
150
+ * and assert the returned `items` are empty (a throw carries no value
151
+ * to assert on, and no provenance link). */
152
+ read<T extends KubernetesObject>(spec: KubernetesObjectRef<T>, pretty?: string, exact?: boolean, exportt?: boolean): Promise<Wrapped<T>>;
153
+ replace<T extends KubernetesObject>(spec: T, pretty?: string, dryRun?: string, fieldManager?: string): Promise<Wrapped<T>>;
154
+ delete(spec: KubernetesObject, pretty?: string, dryRun?: string, gracePeriodSeconds?: number, orphanDependents?: boolean, propagationPolicy?: string, body?: V1DeleteOptions): Promise<Wrapped<V1Status>>;
155
+ list<T extends KubernetesObject>(apiVersion: string, kind: string, namespace?: string, pretty?: string, exact?: boolean, exportt?: boolean, fieldSelector?: string, labelSelector?: string, limit?: number, continueToken?: string): Promise<Wrapped<KubernetesListObject<T>>>;
156
+ }
112
157
  /**
113
158
  * Pre-instantiated `@kubernetes/client-node` API clients sharing the
114
159
  * same recording HTTP transport. Every method call lands on the test
@@ -118,9 +163,55 @@ type Tagged<T> = {
118
163
  export interface K3sClient {
119
164
  core: Tagged<CoreV1Api>;
120
165
  apps: Tagged<AppsV1Api>;
121
- /** Generic object API — `create()`, `read()`, `patch()`, `delete()`
122
- * against any Kubernetes resource (custom resources included). */
123
- objects: Tagged<KubernetesObjectApi>;
166
+ batch: Tagged<BatchV1Api>;
167
+ /** Generic object API — `create()`, `read()`, `patch()`, `delete()`,
168
+ * `list()` against any Kubernetes resource, custom resources included.
169
+ * Generic in the resource type: `objects.read<MyCr>({ … })` resolves to
170
+ * `Wrapped<MyCr>`. */
171
+ objects: TaggedObjectApi;
172
+ }
173
+ /** Options for {@link K3sHelpers.apply}. */
174
+ export interface ApplyOptions {
175
+ /** Namespace for documents that don't declare one (cluster-scoped kinds
176
+ * are unaffected). Defaults to `default`, like `kubectl`. */
177
+ namespace?: string;
178
+ /** Server-side-apply field manager. Default `"spectest"`. Keep it stable
179
+ * across runs — that's what makes re-applying an *update* rather than a
180
+ * conflict. */
181
+ fieldManager?: string;
182
+ /** Take ownership of fields another manager owns, i.e. `kubectl apply
183
+ * --force-conflicts`. Default `true`: a test environment has one owner
184
+ * and a conflict is noise. */
185
+ force?: boolean;
186
+ /** Total budget for converging the whole manifest set, including retries
187
+ * of documents whose CRD/webhook isn't up yet. Default 120 s. */
188
+ timeoutMs?: number;
189
+ }
190
+ /** A workload to wait on: `"web"`, `"deployment/web"`,
191
+ * `"statefulset/db"`, `"daemonset/agent"`, or an object with
192
+ * `kind`/`metadata` (e.g. an element of {@link K3sHelpers.apply}'s
193
+ * result). Bare names are Deployments. */
194
+ export type RolloutTarget = string | KubernetesObject | KubernetesObjectRef;
195
+ /** Options for the wait helpers. */
196
+ export interface WaitOptions {
197
+ /** Namespace of the object. Default `default`, or the object's own. */
198
+ namespace?: string;
199
+ /** Give up after this long. Default 120 s. */
200
+ timeoutMs?: number;
201
+ /** Poll interval. Default 500 ms. */
202
+ intervalMs?: number;
203
+ }
204
+ /** Which pods to read logs from — see {@link K3sHelpers.logs}. */
205
+ export interface LogsOptions {
206
+ /** A single pod by name. */
207
+ pod?: string;
208
+ /** All pods matching a label selector (`"app=web"`). */
209
+ selector?: string;
210
+ namespace?: string;
211
+ /** Container within the pod (required only for multi-container pods). */
212
+ container?: string;
213
+ /** Tail this many lines per pod. Default 200. */
214
+ tailLines?: number;
124
215
  }
125
216
  /** Helpers a `k3s(...)` service exposes on `ctx.svc.<name>`. */
126
217
  export interface K3sHelpers {
@@ -136,13 +227,86 @@ export interface K3sHelpers {
136
227
  /** Pre-built API clients. */
137
228
  client: K3sClient;
138
229
  /**
139
- * Apply a (multi-document) YAML manifest. Each parsed document is
140
- * created via `KubernetesObjectApi.create`. Returns the API server's
141
- * response objects in input order — each element inspect-wrapped (the
142
- * array container itself is plain), so `expect(created[0]!.metadata.uid)`
143
- * links to its create call.
230
+ * Apply a (multi-document) YAML manifest with **server-side apply**
231
+ * semantics — the API equivalent of `kubectl apply --server-side
232
+ * --force-conflicts`. Each document is `PATCH`ed with
233
+ * `application/apply-patch+yaml`, so applying is create-or-update:
234
+ * re-applying an edited manifest converges instead of failing
235
+ * `AlreadyExists`, which is what makes it usable for real manifest sets
236
+ * (and for a `dependsOn` child re-applying over its parent's state).
237
+ *
238
+ * Documents that can't land *yet* are retried until `opts.timeoutMs`:
239
+ * a CR whose CRD is in the same manifest, or a resource an admission
240
+ * webhook rejects while its own pod is still coming up, both resolve on
241
+ * a later round instead of failing the run. A document that keeps
242
+ * failing throws with the API server's own message.
243
+ *
244
+ * Returns the API server's response objects in input order — each
245
+ * element inspect-wrapped (the array container itself is plain), so
246
+ * `expect(applied[0]!.metadata.uid)` links to its apply call.
247
+ *
248
+ * Nothing here shells out to `kubectl`; the cluster is driven over its
249
+ * API from the daemon.
250
+ */
251
+ apply(manifest: string, opts?: ApplyOptions): Promise<Wrapped<KubernetesObject>[]>;
252
+ /**
253
+ * Wait until a Deployment / StatefulSet / DaemonSet has actually rolled
254
+ * out: the controller has observed the current generation, every replica
255
+ * is updated, and the desired number are Ready (the checks `kubectl
256
+ * rollout status` makes). Throws on timeout with the last-seen status
257
+ * plus pod phases, container reasons and recent Warning events from the
258
+ * namespace, so a stuck image pull or CrashLoop is readable from the
259
+ * failure alone.
260
+ *
261
+ * ```ts
262
+ * await ctx.svc.k8s.apply(manifest);
263
+ * await ctx.svc.k8s.waitForRollout("web");
264
+ * await ctx.svc.k8s.waitForRollout("statefulset/db", { namespace: "data" });
265
+ * ```
266
+ */
267
+ waitForRollout(target: RolloutTarget, opts?: WaitOptions): Promise<void>;
268
+ /**
269
+ * Wait for a Job to finish. Resolves with the completed Job (Complete
270
+ * condition, or `succeeded` at the requested completion count) and
271
+ * throws if it fails — with the failed pods' logs attached, which is
272
+ * the thing you actually want when a migration or seed Job dies.
273
+ *
274
+ * ```ts
275
+ * await ctx.svc.k8s.apply(migrateJob);
276
+ * const job = await ctx.svc.k8s.waitForJob("migrate");
277
+ * expect(job.status?.succeeded).toBe(1);
278
+ * ```
279
+ */
280
+ waitForJob(name: string, opts?: WaitOptions): Promise<Wrapped<V1Job>>;
281
+ /**
282
+ * Read pod logs — one pod by `pod`, or every pod matching `selector`
283
+ * (concatenated, each preceded by a `==> <pod> <==` header). The
284
+ * diagnostic companion to the wait helpers.
285
+ */
286
+ logs(opts: LogsOptions): Promise<Wrapped<string>>;
287
+ /**
288
+ * List objects of any kind by options object rather than the library's
289
+ * ten positional parameters (`list(apiVersion, kind, namespace, pretty,
290
+ * exact, exportt, fieldSelector, …)`). Generic in the resource type, so
291
+ * custom resources keep their shape:
292
+ *
293
+ * ```ts
294
+ * // "the Branch is gone" — without a 404 throw to catch
295
+ * const left = await ctx.svc.k8s.list<Branch>({
296
+ * apiVersion: "xata.io/v1", kind: "Branch",
297
+ * fieldSelector: `metadata.name=${name}`,
298
+ * });
299
+ * expect(left.items).toHaveLength(0);
300
+ * ```
144
301
  */
145
- apply: (manifest: string) => Promise<Wrapped<KubernetesObject>[]>;
302
+ list<T extends KubernetesObject = KubernetesObject>(opts: {
303
+ apiVersion: string;
304
+ kind: string;
305
+ namespace?: string;
306
+ fieldSelector?: string;
307
+ labelSelector?: string;
308
+ limit?: number;
309
+ }): Promise<Wrapped<KubernetesListObject<T>>>;
146
310
  }
147
311
  export declare function k3s(opts?: K3sOptions): {
148
312
  readyCheck: {
@@ -154,7 +318,7 @@ export declare function k3s(opts?: K3sOptions): {
154
318
  name: string;
155
319
  helpers: K3sHelpers;
156
320
  }) => Promise<void>;
157
- helpers: ({ name, exec }: ServiceHelpersContext) => Promise<K3sHelpers>;
321
+ helpers: ({ name, exec, poll }: ServiceHelpersContext) => Promise<K3sHelpers>;
158
322
  files?: {
159
323
  path: string;
160
324
  content: string;
@@ -169,4 +333,3 @@ export declare function k3s(opts?: K3sOptions): {
169
333
  cgroupns: string;
170
334
  ports: number[];
171
335
  };
172
- export {};