@ultimat3/testing 12.0.0 → 14.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 +10 -1
- package/README.md +45 -2
- package/package.json +12 -12
- package/src/errors.ts +18 -0
- package/src/fixture-island.ts +14 -2
- package/src/index.ts +7 -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 +26 -4
package/CLAUDE.md
CHANGED
|
@@ -22,12 +22,20 @@ 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 |
|
|
28
33
|
| Fixtures | the preload registers the whole framework bag — an app registers only what the framework cannot know (`seed`, `actorFor`) |
|
|
29
|
-
| e2e without a driver | `e2eTest` becomes `test.skip`, and the gate reports the step GREEN over it — `bun test` exits 0 on a skip and the exit code is the only channel between the step and the child that registers the driver. `hasE2eDriver()` is what a harness asks instead of reading an all-skipped run as a pass. Zero drivers are registered `
|
|
34
|
+
| e2e without a driver | `e2eTest` becomes `test.skip`, and the gate reports the step GREEN over it — `bun test` exits 0 on a skip and the exit code is the only channel between the step and the child that registers the driver. `hasE2eDriver()` is what a harness asks instead of reading an all-skipped run as a pass. Zero drivers are registered BY DEFAULT and that is the design, not a gap: `@ultimat3/cli`'s `installE2eDriver()` is the one that exists (`packages/cli/src/e2e-driver.ts`), an app's test preload is what calls it, and CI has no browser |
|
|
35
|
+
| The seam has an inverse, `As of 2026-08-25` | `resetE2eDriver()`. `useE2eDriver` writes MODULE scope and `bun test` is one process, so a file that installed a browser handed every later file in the run an `e2eTest` that opened a page nobody asked for — `test-types.test.ts` was itself doing it, at module scope, with nothing to undo it. Same shape and same reason as `@ultimat3/scraping`'s `resetScrapeDriver()` |
|
|
30
36
|
| Built vs declared | `clock` `mail` `network` `runJobs` `statements` `subscribe` are built in-process; `page` `budget` `signIn` `deploy` are declared and wait for a driver (`X_TEST_FIXTURE_UNAVAILABLE`). The four left all need a browser or a second build — things the framework genuinely cannot bundle |
|
|
37
|
+
| `page` HAS a driver now, and the other three still do not, `As of 2026-08-25` | `installE2eDriver({ page, baseUrl })` registers `page` over its declaration and leaves `budget`, `signIn` and `deploy` refusing, on purpose: byte counts come off a built `dist/`, a sign-in route is the APP's, and a new build id is a fact about the SERVER — a page port can answer for none of the three, and a fixture that silently no-opped would make the assertion after it read as proof |
|
|
38
|
+
| `network` is THIS process's fetch, and an e2e page is not in this process | `sealed-network.ts` patches `globalThis.fetch` here; a browser's requests never pass through it. So `network.offline()` in a test that also destructures `page` is a **no-op on the browser** — the app's online page passing an offline test. `E2eFixtures.offline()` is the browser-side spelling and the CDP driver REFUSES it (`X_TEST_FIXTURE_UNAVAILABLE`), because `CdpPageLike` declares no `setOfflineMode`. Two words, two mechanisms, and only one of them can put a browser offline |
|
|
31
39
|
| `subscribe` is a whole `sync` node | `live-node.ts` assembles what `x dev --role sync` assembles minus the listener — real `LiveQueryRegistry`, real `liveQueryDefinition` bridge, real per-subscriber gate, real cursor — over a socket that is two objects handing each other the JSON a WebSocket would. `live-replicator.ts` feeds it from `@ultimat3/entity`'s `setRowObserver`, which is the change SOURCE a test process never had: PGlite has no walsender and the memory driver no log, so `InMemoryChangeFeed` had nothing upstream of it. The WAL decoder is the only thing substituted; everything downstream of it is production code |
|
|
32
40
|
| What `subscribe` does NOT hold | a client store, an offline queue or a rebase log — so `feed.local()` answers `undefined` rather than the server row. A twin reported as applied whether or not a mutator ran is coverage that reads as proof, which is worse than none. That half is `useMutation` / `useMutationQueue` and an e2e |
|
|
33
41
|
| Draining is a macrotask yield | the node dispatches `message` into a floating async task, so `settled()` yields with `setImmediate` until the frame count stops moving. Counting microtask turns is a number that is right until someone adds an `await` — the first version drained 32 and read `examples/dummy`'s deeper feed read as "the node answered nothing" |
|
|
@@ -60,6 +68,7 @@ is its own entry point and not part of the barrel.
|
|
|
60
68
|
| Factory seeds | derived from the table name unless given, so two entities never draw the same uuid stream. `reset()` cascades into associated parents — a half-reset row is worse than none |
|
|
61
69
|
| Shared examples | `behavesLike` calls `describe`, so it goes at declaration scope; bun rejects a `describe` inside a test body |
|
|
62
70
|
| An island needs a BUILDER, not an import | `buildIslands` is `@ultimat3/cli`'s and both packages are tier 5; the one declared edge is `cli → testing`, so the reverse is a `bun run boundaries` failure. `mountIsland({ build, root, file })` takes the function as a parameter and declares only the two fields it reads — `IslandChunkLike` is `{ file, code }`, so a CSS artifact, a source map or a dev/production flag the bundler grows is invisible here. Moving the bundler down a tier was the alternative and it drags `@babel/core` and `babel-preset-solid` with it, into a package whose whole point is being importable from tier 0 |
|
|
71
|
+
| `mountIsland` AWAITS `mount` | `IslandEntry['mount']` returns `unknown`, not `void`, and the call is awaited — the shipped runtime chains `import(e).then((m) => m.mount(el, props))` (`packages/render/src/hydrate.ts`) and only marks the element mounted when that settles, so an `async` mount is an ordinary island and `like.island.tsx` already is one. Typed `=> void` and called bare, the fixture returned before an island that opens a queue or a socket had rendered anything, so every assertion after it read an empty wrapper — and worse, the mount RESUMED after `restore()` had taken the fake `document` back out, failing with `document is not defined` inside whichever later test happened to be running. Not a breaking change: `IslandEntry` is module-private and an island module is matched structurally off an `unknown` import, so nothing implements it. |
|
|
63
72
|
| The micro-DOM is the fixture's, once **for islands** | `island-dom.ts`. **"Once" is scoped to this job, and said so only from 2026-08-23**: `packages/ui/src/fake-dom.ts` is a second micro-DOM (201 lines, test-only, off that package's barrel) for a different one — focus, `activeElement`, `contains` and a `:not()` selector grammar for keyboard code, none of which parses a `<template>`. It is not a copy to collapse: `ui` is tier 4 and this package is tier 5, so `ui -> testing` is an upward import the boundary check refuses, and the merge would need the shared half moved down to a tier neither grammar belongs in. `bun test` has no DOM and no DOM library may be added; `generate: 'dom'` builds every element from `_$template("<label …>")`, so a stub without a parsed `<template>` cannot run one line of a compiled island. It lived twice, ~200 lines each, in `packages/cli/src/island-bundle.test.ts` and the reference app's island test |
|
|
64
73
|
| `style` and `classList` RECORD, `As of 2026-08` | `FakeStyle` is one declaration map behind all four spellings compiled Solid uses on one element: a STATIC entry baked into the template's `style=` attribute, a dynamic one through `setStyleProperty` → `style.setProperty`, a whole-object or string prop through `style()` → `cssText`, and a cleared one through `removeAttribute`. It was `style = {}` until 2026-08-21 — `<Form>`, `<Stack>`, `<Grid>` and `<Container>` each set a CSS custom property, so every one of them died inside `mount` with `e.style.setProperty is not a function`, and `x g resource` emitted a plain `<form>` rather than the design system's. A design-system component kept out of generated code by the limits of a TEST DOUBLE. A no-op `setProperty` would have stopped the crash and left "the component set `--form-gap`" unassertable, which is the same hole one layer down |
|
|
65
74
|
| `classList` is the class attribute | not a list of its own, so `classList.toggle` — what the compiler emits INLINE for `classList={{ … }}`, with no runtime helper in front of it — and `className` can never answer one element two ways. `add` was the only method the stand-in had and is the one Solid never calls |
|
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` |
|
|
@@ -135,9 +136,19 @@ there is no flag on the fixture and no code to silence.
|
|
|
135
136
|
| `contractTest` | OpenAPI diff vs the committed spec, MCP exposure | `contract` |
|
|
136
137
|
| `liveTest` | exactly what each subscriber receives | `live` |
|
|
137
138
|
| `jobTest` | step sequence, retries, idempotency | `job` |
|
|
138
|
-
| `e2eTest` | a browser driver
|
|
139
|
+
| `e2eTest` | a browser driver; with none registered it SKIPS, and the gate's `e2e` step passes over the skip — ask `hasE2eDriver()` rather than reading that as a pass | `e2e` |
|
|
139
140
|
| `evalTest` | LLM output scoring against a threshold | `eval` |
|
|
140
141
|
|
|
142
|
+
**Registering one, `As of 2026-08-25`.** `@ultimat3/cli`'s `installE2eDriver({ page, baseUrl })` is
|
|
143
|
+
the driver that exists — a `PageLike` over `@ultimat3/scraping`'s browser — and an app's test preload
|
|
144
|
+
is what calls it. It returns the undo, and `resetE2eDriver()` is the seam's inverse for anything that
|
|
145
|
+
installs one by hand: `bun test` is one process, so a driver left registered reaches every later file.
|
|
146
|
+
|
|
147
|
+
It registers `page` and nothing else. `budget`, `signIn` and `deploy` keep refusing with
|
|
148
|
+
`X_TEST_FIXTURE_UNAVAILABLE`, and so do `E2eFixtures`' `offline()` / `online()` / `update()` — the
|
|
149
|
+
browser's own network state and a second build id are not things a page port can answer for, and a
|
|
150
|
+
fixture that silently no-opped would make the assertion after it read as proof.
|
|
151
|
+
|
|
141
152
|
Each helper prefixes the test name with its type (`job · onboards an org`), which is what
|
|
142
153
|
`bun test --test-name-pattern "job · "` selects — the six lines of `x verify` come from the tests
|
|
143
154
|
themselves, not from a directory convention.
|
|
@@ -280,6 +291,12 @@ line and makes the direction visible instead of hidden.
|
|
|
280
291
|
**`fire` answers whether a handler RAN.** A selector that matches nothing and an island that
|
|
281
292
|
attached no handler are the same silence; the second is a bug and the first is a typo in the test.
|
|
282
293
|
|
|
294
|
+
**An `async` mount is awaited**, as the shipped hydration runtime awaits it — an island that opens
|
|
295
|
+
a queue or a socket before its first render (`like.island.tsx` does) needs no `settle()` helper in
|
|
296
|
+
the test. Until `As of 2026-08-25` the call was bare, so the fixture answered before such an island
|
|
297
|
+
had rendered anything and the mount later resumed against a `document` the teardown had already
|
|
298
|
+
removed.
|
|
299
|
+
|
|
283
300
|
**A mount installs process-global state**, so `MountedIsland` is `Disposable` — `using`, or
|
|
284
301
|
`island[Symbol.dispose]()` in an `afterAll`. Left installed it hands a fake `document` to every
|
|
285
302
|
later FILE in the run.
|
|
@@ -356,6 +373,32 @@ changed when only the reviewer moved.
|
|
|
356
373
|
**The command that takes the pictures is not here yet.** `As of 2026-08-23` this package ships the
|
|
357
374
|
vocabulary, the expansion and the refusals; the browser half is `x shot`'s.
|
|
358
375
|
|
|
376
|
+
## The one assertion that waits
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
import { e2eTest, expect } from '@ultimat3/testing';
|
|
380
|
+
|
|
381
|
+
e2eTest('the feed paints, and says so when the socket drops', async ({ page }) => {
|
|
382
|
+
// Retries isVisible() to a budget — 5000ms every 100ms unless you narrow it.
|
|
383
|
+
await expect(page.getByRole('heading', { name: 'Feed' })).toBeVisible();
|
|
384
|
+
await expect(page.getByText('Reconnecting')).toBeVisible({ timeout: 10_000 });
|
|
385
|
+
|
|
386
|
+
// `.not` waits for it to GO, not for one look to come back false.
|
|
387
|
+
await expect(page.getByText('Offline')).not.toBeVisible();
|
|
388
|
+
});
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
| Fact | Why |
|
|
392
|
+
|---|---|
|
|
393
|
+
| 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)` |
|
|
394
|
+
| 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 |
|
|
395
|
+
| `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 |
|
|
396
|
+
| 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" |
|
|
397
|
+
| `timeout: 0` is the non-retrying spelling | one look, no wait — and you have to ask for it |
|
|
398
|
+
|
|
399
|
+
`expect(await locator.isVisible()).toBe(true)` still works and is a **different** assertion: it
|
|
400
|
+
fails on a page that simply has not painted yet.
|
|
401
|
+
|
|
359
402
|
## Errors
|
|
360
403
|
|
|
361
404
|
`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": "14.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": "14.0.0",
|
|
37
|
+
"@ultimat3/core": "14.0.0",
|
|
38
|
+
"@ultimat3/db": "14.0.0",
|
|
39
|
+
"@ultimat3/entity": "14.0.0",
|
|
40
|
+
"@ultimat3/i18n": "14.0.0",
|
|
41
|
+
"@ultimat3/jobs": "14.0.0",
|
|
42
|
+
"@ultimat3/mail": "14.0.0",
|
|
43
|
+
"@ultimat3/policy": "14.0.0",
|
|
44
|
+
"@ultimat3/query": "14.0.0",
|
|
45
|
+
"@ultimat3/realtime": "14.0.0",
|
|
46
|
+
"@ultimat3/time": "14.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/fixture-island.ts
CHANGED
|
@@ -62,8 +62,16 @@ export interface MountedIsland extends Disposable {
|
|
|
62
62
|
): boolean;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Module-private, and its `mount` returns `unknown` rather than `void` — the shipped hydration
|
|
67
|
+
* runtime chains `import(e).then((m) => m.mount(el, props))` (`packages/render/src/hydrate.ts`),
|
|
68
|
+
* so a browser AWAITS whatever `mount` answers before it marks the element mounted. An island that
|
|
69
|
+
* opens a queue or a socket first is therefore an ordinary island (`like.island.tsx` is `async`),
|
|
70
|
+
* and a fixture typed `=> void` could only ever call it and walk away. Widening, not narrowing: an
|
|
71
|
+
* island module is matched structurally off an `unknown` import, so nothing implements this type.
|
|
72
|
+
*/
|
|
65
73
|
interface IslandEntry {
|
|
66
|
-
readonly mount: (el: unknown, props: unknown) =>
|
|
74
|
+
readonly mount: (el: unknown, props: unknown) => unknown;
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
/** `unknown` + a check, not a cast: a chunk whose `mount` was renamed is a real authoring mistake
|
|
@@ -180,7 +188,11 @@ export async function mountIsland(options: MountIslandOptions): Promise<MountedI
|
|
|
180
188
|
if (options.shell !== undefined) {
|
|
181
189
|
for (const child of [...parseHtml(options.shell).children]) el.appendChild(child);
|
|
182
190
|
}
|
|
183
|
-
|
|
191
|
+
// AWAITED, because the runtime this fixture stands in for awaits it. Left unawaited, an async
|
|
192
|
+
// island's `mount` resumed AFTER the `restore()` below had taken the fake `document` back out
|
|
193
|
+
// — so it failed with `document is not defined` inside whichever later test happened to be
|
|
194
|
+
// running, with no thread back here.
|
|
195
|
+
await entry.mount(el, options.props);
|
|
184
196
|
return {
|
|
185
197
|
code: chunk.code,
|
|
186
198
|
el,
|
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`.
|
|
@@ -246,6 +252,7 @@ export {
|
|
|
246
252
|
hasE2eDriver,
|
|
247
253
|
jobTest,
|
|
248
254
|
liveTest,
|
|
255
|
+
resetE2eDriver,
|
|
249
256
|
SEPARATOR,
|
|
250
257
|
TEST_TYPES,
|
|
251
258
|
testName,
|
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;
|
|
@@ -100,6 +112,16 @@ export function useE2eDriver(driver: (name: string, body: E2eBody) => void): voi
|
|
|
100
112
|
* instead of reading an all-skipped run as a green one. */
|
|
101
113
|
export const hasE2eDriver = (): boolean => e2eDriver !== undefined;
|
|
102
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Put the seam back. The counterpart `useE2eDriver` shipped without, and the module scope it
|
|
117
|
+
* writes to is process-global: `bun test` shares one process across files, so a file that installs
|
|
118
|
+
* a browser and does not undo it hands every later file an `e2eTest` that opens a page nobody
|
|
119
|
+
* asked for. Same shape and same reason as `@ultimat3/scraping`'s `resetScrapeDriver()`.
|
|
120
|
+
*/
|
|
121
|
+
export const resetE2eDriver = (): void => {
|
|
122
|
+
e2eDriver = undefined;
|
|
123
|
+
};
|
|
124
|
+
|
|
103
125
|
export const e2eTest = (name: string, body: E2eBody): void => {
|
|
104
126
|
if (e2eDriver === undefined) {
|
|
105
127
|
test.skip(
|