@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 +5 -0
- package/README.md +28 -1
- package/package.json +12 -12
- package/src/errors.ts +18 -0
- package/src/index.ts +6 -0
- package/src/matcher-result.ts +7 -0
- package/src/matcher-surface.ts +8 -0
- package/src/matcher-visible.ts +68 -0
- package/src/matchers.ts +63 -16
- package/src/retry.ts +89 -0
- package/src/test-types.ts +16 -4
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": "
|
|
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": "
|
|
37
|
-
"@ultimat3/core": "
|
|
38
|
-
"@ultimat3/db": "
|
|
39
|
-
"@ultimat3/entity": "
|
|
40
|
-
"@ultimat3/i18n": "
|
|
41
|
-
"@ultimat3/jobs": "
|
|
42
|
-
"@ultimat3/mail": "
|
|
43
|
-
"@ultimat3/policy": "
|
|
44
|
-
"@ultimat3/query": "
|
|
45
|
-
"@ultimat3/realtime": "
|
|
46
|
-
"@ultimat3/time": "
|
|
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`.
|
package/src/matcher-surface.ts
CHANGED
|
@@ -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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
236
|
-
const
|
|
237
|
-
return
|
|
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
|
-
|
|
241
|
-
const
|
|
242
|
-
return
|
|
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.
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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;
|