@ultimat3/testing 12.0.0 → 13.0.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/CLAUDE.md CHANGED
@@ -22,6 +22,11 @@ is its own entry point and not part of the barrel.
22
22
  | One way offline | the `network` fixture. `setNetworkState` is the gate's only writer and is not exported — setting it from a test body skips the fixture's disposal and leaves every later file offline |
23
23
  | No retries | a flake is fixed or deleted the day it flakes; there is no `retry: 3` |
24
24
  | `toBeUltimateError` reads THREE fields | an `X_` code plus a `cause` plus a `fix`, all through core's `stringField`. `typeof value.code === 'string'` alone passed a Node `ENOENT` — so a suite pinning "never throw a bare `Error`" stayed green through exactly the regression it guards (`matchers.test.ts`). Same discriminator `packages/cli/src/output.ts` uses to decide what the terminal shows, and a hostile getter answers `undefined` instead of raising inside the assertion |
25
+ | One matcher WAITS, and it is `toBeVisible` | `await expect(locator).toBeVisible()` retries `isVisible()` to a budget — 5000ms every 100ms, Playwright's own default, narrowable per call. **`.not` waits for the element to GO**: it reads bun's `isNot` (measured — the flag is real but is not in bun's published types) and inverts what it waits FOR, never just the answer. Inverting a single look is a no-op that passes on a page which has not painted yet, which is the failure a retrying assertion exists to remove. `expect(await locator.isVisible()).toBe(true)` is the point-in-time spelling and stays a different assertion |
26
+ | The budget is counted in LOOKS, not milliseconds | this package freezes `Date.now()`, so a deadline computed from the clock never expires and the loop spins forever. `attemptsFor(budget)` is `1 + floor(timeout / interval)` — one free look plus one per whole interval — and it is what a test asserts, because elapsed time is the one thing a frozen clock cannot give it. `retryUntil`'s `sleep` is a DEFAULT PARAMETER for the same reason a `random = Math.random` default is: a test injects a counting stub and measures how many times it looked and slept |
27
+ | A FIXED interval, and never a curve | doubling the gap makes the last look land long after the state changed, and the caller's deadline is the contract. There is exactly one backoff curve in the framework (`@ultimat3/core`'s `backoff.ts`) and this is deliberately not a second one. A zero or negative interval is REFUSED: the failure mode is a test that never fails — it hangs, and CI reports a runner timeout with no assertion in it |
28
+ | A matcher that may throw a coded error MUST NOT be `async` | measured against Bun 1.4.0: an `async` matcher has any error it throws REPLACED by bun's own `Matcher \`x\` returned a promise that rejected`, so the code, the cause and the fix are gone before a reader sees them. `X_TEST_SCHEMA_EXPECTED` and `X_TEST_JOB_EXPECTED` were declared, registered, titled — and unreachable by any caller for their whole life, because nothing asserted them. The shape is a SYNCHRONOUS prologue that validates the receiver (`assertStandardSchema`, `assertJobDeclaration`, `assertVisibilityProbe`) and a promise returned after it. `matcher-receiver.test.ts` pins all four |
29
+ | Wrong receiver throws, wrong value returns | a matcher handed a page where a locator belongs is not FALSE, it is unanswerable — `pass: false` would read as "the element was hidden". A wrong-shaped receiver is a coded throw; a wrong value is `result(false, …)` |
25
30
  | Test names | the filename picks the step; `testName(type, name)` on the outer `describe` puts that type on every failure line under it. Never on the inner `test` too — the prefix would print twice |
26
31
  | `test` here is `fixtureTest`, and it takes no timeout | `(name, body)`, nothing else (`fixtures.ts:248`, exported as `test` by `index.ts:107`) — `bunTest` is called with two arguments, so bun's own third one is unreachable and no case can ask for more than the default 5s. Slow work goes in `beforeAll(fn, 60_000)`, which is `bun:test`'s own re-export and does take one; that is where both generated island tests and `examples/dummy/apps/web/app/settings/settings.island.test.ts` build their chunk, once, for every case to share — a Babel pass plus a browser bundle is seconds. A case that needs 60s of its own is work sitting in the wrong place |
27
32
  | Injection | `SqlRunner` and `connect` are parameters, so unit tests need no server |
package/README.md CHANGED
@@ -16,7 +16,8 @@ frozen clock. Never let a test reach the network unmocked — it fails by design
16
16
  | `factory-persist.ts` | `usePersister` — the one seam `create()` writes through |
17
17
  | `shared-examples.ts` | `sharedExamples` / `behavesLike` — one rule, many subjects |
18
18
  | `test-types.ts` | the six test types and their helpers |
19
- | `matchers.ts` | `toBeUltimateError` `toDenyPolicy` `toEmitSteps` `toMatchOpenApi` `toBeWithinBudget` `toRejectInput` |
19
+ | `matchers.ts` | `toBeUltimateError` `toDenyPolicy` `toEmitSteps` `toMatchOpenApi` `toBeWithinBudget` `toRejectInput` `toAcceptInput` `toBeVisible` |
20
+ | `retry.ts` | the one retry loop every waiting assertion is built from — a budget, a fixed interval, and an injectable sleep |
20
21
  | `fixtures.ts` | the registry + `test('…', ({ clock }) => …)` injection |
21
22
  | `fixture-{clock,mail,jobs,network,statements}.ts` | the five fixtures the framework builds in-process |
22
23
  | `fixture-drivers.ts` | the five it declares but a driver must build — `page` `budget` `signIn` `deploy` `subscribe` |
@@ -356,6 +357,32 @@ changed when only the reviewer moved.
356
357
  **The command that takes the pictures is not here yet.** `As of 2026-08-23` this package ships the
357
358
  vocabulary, the expansion and the refusals; the browser half is `x shot`'s.
358
359
 
360
+ ## The one assertion that waits
361
+
362
+ ```ts
363
+ import { e2eTest, expect } from '@ultimat3/testing';
364
+
365
+ e2eTest('the feed paints, and says so when the socket drops', async ({ page }) => {
366
+ // Retries isVisible() to a budget — 5000ms every 100ms unless you narrow it.
367
+ await expect(page.getByRole('heading', { name: 'Feed' })).toBeVisible();
368
+ await expect(page.getByText('Reconnecting')).toBeVisible({ timeout: 10_000 });
369
+
370
+ // `.not` waits for it to GO, not for one look to come back false.
371
+ await expect(page.getByText('Offline')).not.toBeVisible();
372
+ });
373
+ ```
374
+
375
+ | Fact | Why |
376
+ |---|---|
377
+ | the budget is counted in **looks**, not elapsed ms | this package freezes `Date.now()`, so a deadline read off the clock never expires. `1 + floor(timeout / interval)` |
378
+ | a fixed interval, never a curve | the caller's deadline is the contract; a curve spends most of it waiting. The framework's one backoff curve is `@ultimat3/core`'s |
379
+ | `interval: 0` is refused | an unbounded spin is a test that never fails — it hangs, and CI reports a runner timeout with no assertion in it |
380
+ | a receiver with no `isVisible()` is `X_TEST_LOCATOR_EXPECTED` | the assertion is unanswerable, not false; `pass: false` would read as "the element was hidden" |
381
+ | `timeout: 0` is the non-retrying spelling | one look, no wait — and you have to ask for it |
382
+
383
+ `expect(await locator.isVisible()).toBe(true)` still works and is a **different** assertion: it
384
+ fails on a page that simply has not painted yet.
385
+
359
386
  ## Errors
360
387
 
361
388
  `X_TEST_NETWORK_SEALED` `X_TEST_DB_UNAVAILABLE` `X_TEST_NONDETERMINISTIC` `X_TEST_FIXTURE_UNKNOWN`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/testing",
3
- "version": "12.0.0",
3
+ "version": "13.0.0",
4
4
  "description": "Test harness: cloned template DBs per worker, frozen clock, sealed network, 6 test types",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,16 +33,16 @@
33
33
  "test": "bun test"
34
34
  },
35
35
  "dependencies": {
36
- "@ultimat3/cache": "12.0.0",
37
- "@ultimat3/core": "12.0.0",
38
- "@ultimat3/db": "12.0.0",
39
- "@ultimat3/entity": "12.0.0",
40
- "@ultimat3/i18n": "12.0.0",
41
- "@ultimat3/jobs": "12.0.0",
42
- "@ultimat3/mail": "12.0.0",
43
- "@ultimat3/policy": "12.0.0",
44
- "@ultimat3/query": "12.0.0",
45
- "@ultimat3/realtime": "12.0.0",
46
- "@ultimat3/time": "12.0.0"
36
+ "@ultimat3/cache": "13.0.0",
37
+ "@ultimat3/core": "13.0.0",
38
+ "@ultimat3/db": "13.0.0",
39
+ "@ultimat3/entity": "13.0.0",
40
+ "@ultimat3/i18n": "13.0.0",
41
+ "@ultimat3/jobs": "13.0.0",
42
+ "@ultimat3/mail": "13.0.0",
43
+ "@ultimat3/policy": "13.0.0",
44
+ "@ultimat3/query": "13.0.0",
45
+ "@ultimat3/realtime": "13.0.0",
46
+ "@ultimat3/time": "13.0.0"
47
47
  }
48
48
  }
package/src/errors.ts CHANGED
@@ -21,6 +21,7 @@ export const TESTING_ERROR_CODES = [
21
21
  'X_TEST_EVAL_THRESHOLD',
22
22
  'X_TEST_SCHEMA_EXPECTED',
23
23
  'X_TEST_JOB_EXPECTED',
24
+ 'X_TEST_LOCATOR_EXPECTED',
24
25
  'X_TEST_NETWORK_RACE',
25
26
  'X_TEST_FACTORY_TRAIT_UNKNOWN',
26
27
  'X_TEST_FACTORY_NOT_PERSISTED',
@@ -55,6 +56,7 @@ export const TESTING_ERROR_TITLES: Readonly<Record<TestingErrorCode, string>> =
55
56
  X_TEST_EVAL_THRESHOLD: 'an evalTest() score fell below its threshold',
56
57
  X_TEST_SCHEMA_EXPECTED: 'a matcher expected a Standard Schema and got something else',
57
58
  X_TEST_JOB_EXPECTED: 'a matcher expected a job declaration and got something else',
59
+ X_TEST_LOCATOR_EXPECTED: 'a matcher expected a locator and got something else',
58
60
  X_TEST_NETWORK_RACE: 'a request raced unsealNetwork() and lost the patched fetch',
59
61
  X_TEST_FACTORY_TRAIT_UNKNOWN: 'a factory was asked for a trait it does not declare',
60
62
  X_TEST_FACTORY_NOT_PERSISTED: 'a factory create() had nowhere to write the row',
@@ -257,6 +259,22 @@ export class TestSchemaExpectedError extends UltimateError {
257
259
  }
258
260
  }
259
261
 
262
+ /**
263
+ * `toBeVisible` was handed something with no `isVisible()` — a page, a selector string, or the
264
+ * result of awaiting the locator. Thrown rather than returned as a failing result, exactly as
265
+ * `TestSchemaExpectedError` is: the assertion is not false, it is unanswerable, and a `pass: false`
266
+ * would read as "the element was hidden".
267
+ */
268
+ export class TestLocatorExpectedError extends UltimateError {
269
+ constructor() {
270
+ super({
271
+ code: 'X_TEST_LOCATOR_EXPECTED',
272
+ cause: 'toBeVisible expects a locator — an object with isVisible() — not a page or a string',
273
+ fix: 'expect(page.getByRole("heading", { name: "Feed" })).toBeVisible() # the locator itself, unawaited',
274
+ });
275
+ }
276
+ }
277
+
260
278
  /** `toEmitSteps`/`recordSteps` were handed something other than a job declaration. */
261
279
  export class TestJobExpectedError extends UltimateError {
262
280
  constructor() {
package/src/index.ts CHANGED
@@ -185,6 +185,12 @@ export type { LiveConnection, LiveNodeHandle, LiveNodeOptions } from './live-nod
185
185
  export { createLiveNode } from './live-node';
186
186
  export type { LiveReplicator, LiveReplicatorOptions } from './live-replicator';
187
187
  export { startLiveReplicator } from './live-replicator';
188
+ /**
189
+ * The budget `toBeVisible(options?)` takes. Exported because it is in a public matcher's signature;
190
+ * `retryUntil` and `RetryBudget` deliberately are NOT — nothing outside this package calls them,
191
+ * and an export nothing calls is the unwired declaration this matcher exists to have closed.
192
+ */
193
+ export type { VisibleOptions } from './matcher-visible';
188
194
  export type { MatcherResult } from './matchers';
189
195
  export { matchersInstalled, recordSteps } from './matchers';
190
196
  // `isolateEntityRegistry` is deliberately NOT here — it is `@ultimat3/testing/registry-isolation`.
@@ -0,0 +1,7 @@
1
+ // What every matcher in this package hands back. Its own module so `matcher-visible.ts` can carry
2
+ // the shape without importing `matchers.ts`, which imports it.
3
+
4
+ export interface MatcherResult {
5
+ readonly pass: boolean;
6
+ message(): string;
7
+ }
@@ -3,6 +3,7 @@
3
3
  // program in the repo has to READ this declaration — `tsconfig.tests.json` names this file — and a
4
4
  // tier-0 test cannot reach it by importing tier-5 `@ultimat3/testing`.
5
5
 
6
+ import type { VisibleOptions } from './matcher-visible';
6
7
  import type { OpenApiLike } from './test-types';
7
8
 
8
9
  /**
@@ -23,6 +24,13 @@ export interface UltimateMatchers<T> {
23
24
  toBeWithinBudget(limit: number): T;
24
25
  toRejectInput(input: unknown): Promise<T>;
25
26
  toAcceptInput(input: unknown): Promise<T>;
27
+ /**
28
+ * The one matcher that WAITS. Retries `isVisible()` to a budget — 5000ms every 100ms unless
29
+ * narrowed — and `.not.toBeVisible()` waits for the element to GO rather than inverting one look.
30
+ * Point-in-time is `expect(await locator.isVisible()).toBe(true)`, and it is a different
31
+ * assertion: it fails on a page that simply has not painted yet.
32
+ */
33
+ toBeVisible(options?: VisibleOptions): Promise<T>;
26
34
  }
27
35
 
28
36
  declare module 'bun:test' {
@@ -0,0 +1,68 @@
1
+ // The one assertion in this package that WAITS. Split from `matchers.ts` because everything about
2
+ // it is different from the others: it has a budget, it has a direction (`.not` waits for the
3
+ // element to go), and its message has to say what it waited for and what it saw instead.
4
+
5
+ import { TestLocatorExpectedError } from './errors';
6
+ import type { MatcherResult } from './matcher-result';
7
+ import { DEFAULT_RETRY_BUDGET, type RetryBudget, retryUntil } from './retry';
8
+
9
+ /** What a caller may narrow. Both halves default to `DEFAULT_RETRY_BUDGET`. */
10
+ export type VisibleOptions = Partial<RetryBudget>;
11
+
12
+ /**
13
+ * Structural, and the ONE member it insists on is the one it calls. `LocatorLike` declares four,
14
+ * but a matcher demanding all four would refuse a driver's element handle over members this
15
+ * assertion never touches — and the receiver here is whatever a test passed to `expect()`.
16
+ */
17
+ export interface VisibilityProbe {
18
+ isVisible(): Promise<boolean>;
19
+ }
20
+
21
+ /**
22
+ * SYNCHRONOUS, and that is the whole reason it is a function of its own.
23
+ *
24
+ * Measured against Bun 1.4.0: a matcher declared `async` that throws has its error replaced by
25
+ * bun's own `Matcher \`x\` returned a promise that rejected` — the code, the cause and the fix are
26
+ * all gone, and the reader is told nothing. A matcher that is NOT async and throws before it
27
+ * returns a promise keeps the error intact. So the receiver check runs here, in the synchronous
28
+ * prologue, and the waiting happens in the promise that follows it.
29
+ */
30
+ export const assertVisibilityProbe = (received: unknown): VisibilityProbe => {
31
+ const probe = received as { isVisible?: unknown };
32
+ if (typeof received !== 'object' || received === null || typeof probe.isVisible !== 'function') {
33
+ throw new TestLocatorExpectedError();
34
+ }
35
+ return received as VisibilityProbe;
36
+ };
37
+
38
+ /**
39
+ * `pass` is the RAW fact — whether the element was visible on the last look — because `.not`
40
+ * inverts it and a matcher that pre-inverted would report the wrong direction. What `isNot` decides
41
+ * is what this WAITS FOR: `.not.toBeVisible()` must wait for the element to disappear, not check
42
+ * once and invert. Inverting a single look is a no-op that passes on a page which has not painted
43
+ * yet, which is the exact failure a retrying assertion exists to remove.
44
+ */
45
+ export const visibilityResult = async (
46
+ probe: VisibilityProbe,
47
+ isNot: boolean,
48
+ options: VisibleOptions = {},
49
+ ): Promise<MatcherResult> => {
50
+ const budget: RetryBudget = {
51
+ timeout: options.timeout ?? DEFAULT_RETRY_BUDGET.timeout,
52
+ interval: options.interval ?? DEFAULT_RETRY_BUDGET.interval,
53
+ };
54
+ const wanted = !isNot;
55
+ const outcome = await retryUntil(
56
+ () => probe.isVisible(),
57
+ (visible) => visible === wanted,
58
+ budget,
59
+ );
60
+ const looks = `${outcome.attempts} look${outcome.attempts === 1 ? '' : 's'}, ${budget.interval}ms apart`;
61
+ // The BUDGET is reported, never an elapsed measurement: this package freezes `Date.now()`, so an
62
+ // "after 4993ms" would be a number nothing in the process could have produced.
63
+ const waited = `within ${budget.timeout}ms (${looks})`;
64
+ const message = isNot
65
+ ? `expected the locator to stop being visible ${waited}, and it was visible every time`
66
+ : `expected the locator to be visible ${waited}, and it was hidden every time`;
67
+ return { pass: outcome.last, message: () => message };
68
+ };
package/src/matchers.ts CHANGED
@@ -6,20 +6,19 @@ import type { ExpectExtendMatchers } from 'bun:test';
6
6
  import { expect } from 'bun:test';
7
7
  import { describeValue, isUltimateError, stringField } from '@ultimat3/core';
8
8
  import { TestJobExpectedError, TestSchemaExpectedError } from './errors';
9
+ import type { MatcherResult } from './matcher-result';
9
10
  import type { UltimateMatchers } from './matcher-surface';
11
+ import type { VisibleOptions } from './matcher-visible';
12
+ import { assertVisibilityProbe, visibilityResult } from './matcher-visible';
10
13
  import type { OpenApiLike } from './test-types';
11
14
 
15
+ export type { MatcherResult } from './matcher-result';
12
16
  // Re-exported so the EMITTED `matchers.d.ts` still names `./matcher-surface`. A type-only import
13
17
  // used by a non-exported const is elided from the declaration output, and with it goes the
14
18
  // `bun:test` augmentation for anyone consuming this package through `dist/` — which is every
15
19
  // package that reaches it across a project reference.
16
20
  export type { UltimateMatchers } from './matcher-surface';
17
21
 
18
- export interface MatcherResult {
19
- readonly pass: boolean;
20
- message(): string;
21
- }
22
-
23
22
  const isRecord = (value: unknown): value is Record<string, unknown> =>
24
23
  typeof value === 'object' && value !== null;
25
24
 
@@ -56,6 +55,25 @@ interface StandardSchema {
56
55
  const isStandardSchema = (value: unknown): value is StandardSchema =>
57
56
  isRecord(value) && isRecord(value['~standard']);
58
57
 
58
+ /**
59
+ * The receiver check, SYNCHRONOUS and separate from the work — measured against Bun 1.4.0: a
60
+ * matcher declared `async` has any error it throws replaced by bun's own `Matcher \`x\` returned a
61
+ * promise that rejected`, so the code, the cause and the fix are all gone by the time a reader sees
62
+ * it. `X_TEST_SCHEMA_EXPECTED` and `X_TEST_JOB_EXPECTED` were declared, registered and titled, and
63
+ * no caller could ever observe either (`matcher-receiver.test.ts`). The guard runs before the
64
+ * matcher returns a promise; the work happens inside it.
65
+ */
66
+ function assertStandardSchema(schema: unknown): StandardSchema {
67
+ if (!isStandardSchema(schema)) throw new TestSchemaExpectedError();
68
+ return schema;
69
+ }
70
+
71
+ /** The same rule for a job declaration. `recordSteps` is public, so it keeps its own check too. */
72
+ function assertJobDeclaration(job: unknown): JobLike {
73
+ if (!isJob(job)) throw new TestJobExpectedError();
74
+ return job;
75
+ }
76
+
59
77
  async function hasIssues(schema: unknown, input: unknown): Promise<boolean> {
60
78
  if (!isStandardSchema(schema)) {
61
79
  throw new TestSchemaExpectedError();
@@ -176,7 +194,28 @@ function breakingChanges(before: OpenApiLike, after: OpenApiLike): readonly stri
176
194
  * of the three is visible, and a declared-but-unimplemented matcher fails at runtime in whichever
177
195
  * test calls it first.
178
196
  */
197
+ /**
198
+ * What `expect.extend` binds `this` to. Bun supplies `isNot`, and it is the only member any matcher
199
+ * here reads — measured, because the shape is not in bun's published types and a matcher that
200
+ * guessed would silently wait for the wrong thing under `.not`.
201
+ */
202
+ interface MatcherContext {
203
+ readonly isNot: boolean;
204
+ }
205
+
179
206
  const implementations: ExpectExtendMatchers<UltimateMatchers<unknown>> = {
207
+ // NOT `async`, deliberately — see `assertVisibilityProbe`. A matcher declared `async` has any
208
+ // error it throws replaced by bun's own "returned a promise that rejected", which is how the
209
+ // three matchers below it lost their codes. The guard runs synchronously; the wait is the
210
+ // promise this returns.
211
+ toBeVisible(received: unknown, options?: VisibleOptions) {
212
+ const probe = assertVisibilityProbe(received);
213
+ // `this` and not a parameter: the direction is bun's to tell us, and `.not` has to change what
214
+ // this WAITS for, not just how the answer is read.
215
+ const context = this as unknown as MatcherContext;
216
+ return visibilityResult(probe, context.isNot === true, options);
217
+ },
218
+
180
219
  toBeUltimateError(received: unknown, code?: string) {
181
220
  const actual = codeOf(received);
182
221
  if (actual === undefined) {
@@ -200,11 +239,15 @@ const implementations: ExpectExtendMatchers<UltimateMatchers<unknown>> = {
200
239
  return result(!allowed, `expected the policy to deny ${JSON.stringify(context)}`);
201
240
  },
202
241
 
203
- async toEmitSteps(received: unknown, expected: readonly string[]) {
204
- const names = await recordSteps(received);
205
- return result(
206
- JSON.stringify(names) === JSON.stringify(expected),
207
- `expected steps ${expected.join(' -> ')}, ran ${names.join(' -> ')}`,
242
+ // Not `async` — see `assertStandardSchema`. The guard is the synchronous prologue; the wait is
243
+ // the promise this returns.
244
+ toEmitSteps(received: unknown, expected: readonly string[]) {
245
+ const job = assertJobDeclaration(received);
246
+ return recordSteps(job).then((names) =>
247
+ result(
248
+ JSON.stringify(names) === JSON.stringify(expected),
249
+ `expected steps ${expected.join(' -> ')}, ran ${names.join(' -> ')}`,
250
+ ),
208
251
  );
209
252
  },
210
253
 
@@ -232,14 +275,18 @@ const implementations: ExpectExtendMatchers<UltimateMatchers<unknown>> = {
232
275
  return result(received <= limit, `expected ${received} to be within the budget of ${limit}`);
233
276
  },
234
277
 
235
- async toRejectInput(received: unknown, input: unknown) {
236
- const rejected = await hasIssues(received, input);
237
- return result(rejected, `expected the schema to reject ${JSON.stringify(input)}`);
278
+ toRejectInput(received: unknown, input: unknown) {
279
+ const schema = assertStandardSchema(received);
280
+ return hasIssues(schema, input).then((rejected) =>
281
+ result(rejected, `expected the schema to reject ${JSON.stringify(input)}`),
282
+ );
238
283
  },
239
284
 
240
- async toAcceptInput(received: unknown, input: unknown) {
241
- const rejected = await hasIssues(received, input);
242
- return result(!rejected, `expected the schema to accept ${JSON.stringify(input)}`);
285
+ toAcceptInput(received: unknown, input: unknown) {
286
+ const schema = assertStandardSchema(received);
287
+ return hasIssues(schema, input).then((rejected) =>
288
+ result(!rejected, `expected the schema to accept ${JSON.stringify(input)}`),
289
+ );
243
290
  },
244
291
  };
245
292
 
package/src/retry.ts ADDED
@@ -0,0 +1,89 @@
1
+ // Poll an observation until it matches, or until a declared budget runs out. The MECHANISM behind
2
+ // every retrying assertion in this package, owned here so a second one cannot arrive with its own
3
+ // deadline, its own interval and its own idea of what to report — the rule `backoff.ts` already
4
+ // holds for retries that back off.
5
+
6
+ import { assert } from '@ultimat3/core';
7
+
8
+ /**
9
+ * What a retrying assertion is allowed to spend. A FIXED interval and not a curve: an assertion
10
+ * about a page is asked "is it there yet", and doubling the gap makes the last look land long
11
+ * after the state changed — the caller's deadline is the contract, and a curve would quietly
12
+ * spend most of it waiting. There is exactly one backoff curve in this framework
13
+ * (`@ultimat3/core`'s `backoff.ts`) and this is deliberately not a second one.
14
+ */
15
+ export interface RetryBudget {
16
+ /** Total time the assertion may wait, in milliseconds. `0` is one look and no wait. */
17
+ readonly timeout: number;
18
+ /** Gap between looks, in milliseconds. */
19
+ readonly interval: number;
20
+ }
21
+
22
+ export interface RetryOutcome<T> {
23
+ readonly matched: boolean;
24
+ /** How many times the observation ran. `attemptsFor(budget)` when it never matched. */
25
+ readonly attempts: number;
26
+ /** What the last look answered — what a failure message reports as "saw" instead. */
27
+ readonly last: T;
28
+ }
29
+
30
+ /** Playwright's own default, which is what a reader of `toBeVisible()` already expects. */
31
+ export const DEFAULT_RETRY_BUDGET: RetryBudget = { timeout: 5_000, interval: 100 };
32
+
33
+ /**
34
+ * How many times the observation will run at most: one free look, plus one per whole interval in
35
+ * the budget. Exported because it is what makes a retry TESTABLE — a test asserts this number
36
+ * rather than measuring elapsed time, which is the one thing a frozen clock cannot give it.
37
+ */
38
+ export const attemptsFor = (budget: RetryBudget): number =>
39
+ 1 + Math.floor(budget.timeout / budget.interval);
40
+
41
+ /** Real time passes here and nowhere else in this module, which is what makes `sleep` injectable. */
42
+ const wait = (ms: number): Promise<void> =>
43
+ new Promise((resolve) => {
44
+ setTimeout(resolve, ms);
45
+ });
46
+
47
+ /**
48
+ * Look, test, sleep, repeat — stopping on the look that matches or on the last one the budget buys.
49
+ *
50
+ * `sleep` is a DEFAULT PARAMETER and not a module-level call, the same seam a `random = Math.random`
51
+ * default is: a test injects a counting stub and asserts how many times this slept and how many
52
+ * times it looked, so "it retried, and it stopped when it said it would" is a measurement rather
53
+ * than a wall-clock hope. This package freezes `Date.now()`, so a deadline computed from the clock
54
+ * would never expire and the loop would spin forever — the budget is counted in LOOKS for that
55
+ * reason, not in elapsed milliseconds.
56
+ *
57
+ * Never sleeps after the look that answered: a passing assertion costs one observation and no time
58
+ * at all, which is what keeps a retrying matcher usable in a suite of hundreds.
59
+ */
60
+ export const retryUntil = async <T>(
61
+ observe: () => Promise<T>,
62
+ matches: (value: T) => boolean,
63
+ budget: RetryBudget = DEFAULT_RETRY_BUDGET,
64
+ sleep: (ms: number) => Promise<void> = wait,
65
+ ): Promise<RetryOutcome<T>> => {
66
+ // Refused rather than attempted: a zero or negative interval is an unbounded spin, and the
67
+ // failure mode is a test that never fails — it hangs, and CI reports a runner timeout with no
68
+ // assertion anywhere in it.
69
+ assert(
70
+ Number.isFinite(budget.interval) && budget.interval > 0,
71
+ `a retry interval must be a positive number of milliseconds, got ${String(budget.interval)}`,
72
+ 'toBeVisible({ interval: 100 }) # the gap between looks; the default budget is 5000ms every 100ms',
73
+ );
74
+ assert(
75
+ Number.isFinite(budget.timeout) && budget.timeout >= 0,
76
+ `a retry timeout must be zero or more milliseconds, got ${String(budget.timeout)}`,
77
+ 'toBeVisible({ timeout: 5000 }) # the whole wait; 0 is one look and no wait',
78
+ );
79
+ const limit = attemptsFor(budget);
80
+ let attempts = 0;
81
+ let last = await observe();
82
+ attempts += 1;
83
+ while (!matches(last) && attempts < limit) {
84
+ await sleep(budget.interval);
85
+ last = await observe();
86
+ attempts += 1;
87
+ }
88
+ return { matched: matches(last), attempts, last };
89
+ };
package/src/test-types.ts CHANGED
@@ -44,19 +44,31 @@ export interface LocatorLike {
44
44
  }
45
45
 
46
46
  /**
47
- * The browser surface an e2e test drives. Every member is one the reference app's e2e suite
48
- * already calls — this is the observed contract, not a wish list, and the driver that implements
49
- * it (a browser, at milestone 11) is the only thing that may add to it.
47
+ * The browser surface an e2e test drives. Intended as the OBSERVED contract rather than a wish
48
+ * list, and the driver that implements it (a browser, at milestone 11) is the only thing that may
49
+ * add to it.
50
+ *
51
+ * This comment claimed every member was one the reference app's e2e suite already calls, and an
52
+ * audit on 2026-08-24 found that false for three of eleven: `content()` had ZERO call sites
53
+ * anywhere in the repository, and `title()` / `reload()` are named only by `x g route`'s generated
54
+ * template — a string the CLI writes, which nothing executes. `content()` is deleted; the other
55
+ * two keep their line WITH the caveat, because a generated template is a real contract the moment
56
+ * a driver lands and a scaffolded app runs it.
57
+ *
58
+ * The claim was checkable and nobody checked it, which is the class this tree keeps re-shipping.
59
+ * Nothing enforces this one either: `PageLike` has no driver, so an unwired member cannot fail —
60
+ * see `hasE2eDriver()`. Do not restore the absolute wording without a rule that reads it.
50
61
  */
51
62
  export interface PageLike {
52
63
  goto(url: string): Promise<unknown>;
53
64
  /** The first flush of a streamed response — the shell, before the holes resolve. */
54
65
  gotoStreamed(url: string): Promise<{ readonly html: string }>;
66
+ /** Called only by `x g route`'s generated e2e template — no driver runs it yet. */
55
67
  reload(): Promise<unknown>;
56
68
  /** Resolves once the service worker controls the page, so the offline assertions are not racy. */
57
69
  waitForServiceWorker(): Promise<void>;
70
+ /** Called only by `x g route`'s generated e2e template — no driver runs it yet. */
58
71
  title(): Promise<string>;
59
- content(): Promise<string>;
60
72
  url(): string;
61
73
  evaluate<T>(fn: () => T): Promise<T>;
62
74
  locator(selector: string): LocatorLike;