@ultimat3/testing 21.0.0 → 22.1.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 +123 -85
- package/README.md +17 -1
- package/package.json +15 -13
- package/src/cdp-browser.ts +94 -0
- package/src/cdp-connection.ts +260 -0
- package/src/cdp-e2e-page.ts +180 -0
- package/src/cdp-e2e-session.ts +199 -0
- package/src/cdp-errors.ts +58 -0
- package/src/cdp-launch.ts +192 -0
- package/src/cdp-offline-script.ts +76 -0
- package/src/cdp-pipe.ts +77 -0
- package/src/e2e-app.ts +106 -0
- package/src/e2e-browser-handle.ts +55 -0
- package/src/e2e-dom-fixture.ts +122 -0
- package/src/e2e-driver.ts +114 -0
- package/src/e2e-error-codes.ts +42 -0
- package/src/e2e-errors.ts +126 -0
- package/src/e2e-evaluate.ts +157 -0
- package/src/e2e-locator.ts +86 -0
- package/src/e2e-page.ts +153 -0
- package/src/e2e-preload.ts +22 -0
- package/src/e2e-probe.ts +23 -0
- package/src/e2e-run.ts +87 -0
- package/src/e2e-selection.ts +192 -0
- package/src/e2e-spawn.ts +195 -0
- package/src/errors.ts +6 -0
- package/src/fixture-subscribe.ts +2 -2
- package/src/index.ts +68 -2
- package/src/island-dom.ts +18 -1
- package/src/island-observers.ts +3 -0
- package/src/matcher-receiver-errors.ts +39 -0
- package/src/matchers.ts +20 -20
- package/src/live-replicator.ts +0 -159
package/CLAUDE.md
CHANGED
|
@@ -14,114 +14,98 @@ is its own entry point and not part of the barrel.
|
|
|
14
14
|
|---|---|
|
|
15
15
|
| No mocks of the DB | clone a template database; `template-db.ts` is the only DB path |
|
|
16
16
|
| No wall clock | `frozenClock` / `advanceClock`; `Date.now()` is frozen by the preload |
|
|
17
|
-
| Frozen ≠ different | `globalThis.Date` becomes a subclass, so `FrozenDate[Symbol.hasInstance]` brands on the `[[DateValue]]` slot — `Date.prototype.getTime.call(value)` throws or it doesn't.
|
|
17
|
+
| Frozen ≠ different | `globalThis.Date` becomes a subclass, so `FrozenDate[Symbol.hasInstance]` brands on the `[[DateValue]]` slot — `Date.prototype.getTime.call(value)` throws or it doesn't. |
|
|
18
18
|
| Slot, not prototype | the brand is cross-realm on purpose: `instanceof RealDate` misses a `node:vm` or worker Date, and `Object.prototype.toString` is spoofable by `Symbol.toStringTag: 'Date'`. Only the slot is both |
|
|
19
19
|
| No unmocked egress | `sealed-network.ts` patches fetch; a miss is `X_TEST_NETWORK_SEALED` |
|
|
20
20
|
| Self is not egress | a port core's `markListening()` announced passes through — a socket test never unseals |
|
|
21
21
|
| Offline is a state, not a mock | `network.offline()` / `.drop()` fail every request as `X_TEST_NETWORK_OFFLINE`, ahead of the mocks — the app's own offline path runs |
|
|
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
|
-
| `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`
|
|
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.
|
|
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)`
|
|
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.
|
|
28
|
-
| A matcher that may throw a coded error MUST NOT be `async` |
|
|
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
|
|
30
|
-
| A matcher MESSAGE is a thunk, and never `JSON.stringify` | `result(pass, () => …)` — `expect.extend` reads `message()` only on the wrong verdict, and the string used to be built EAGERLY.
|
|
31
|
-
| Every message thunk needs a test that PROVOKES it | a thunk is a function, so an unread message is an UNCOVERED function and `bun run scripts/coverage-gate.ts --package testing` is what says so
|
|
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` |
|
|
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. |
|
|
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)` |
|
|
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. |
|
|
28
|
+
| A matcher that may throw a coded error MUST NOT be `async` | bun REPLACES an `async` matcher's thrown error with `returned a promise that rejected`, so code, cause and fix are gone. A synchronous prologue validates the receiver, then a promise is returned; `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 |
|
|
30
|
+
| A matcher MESSAGE is a thunk, and never `JSON.stringify` | `result(pass, () => …)` — `expect.extend` reads `message()` only on the wrong verdict, and the string used to be built EAGERLY. |
|
|
31
|
+
| Every message thunk needs a test that PROVOKES it | a thunk is a function, so an unread message is an UNCOVERED function and `bun run scripts/coverage-gate.ts --package testing` is what says so |
|
|
32
32
|
| 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 |
|
|
33
|
-
| `test` here is `fixtureTest`, and it takes no timeout | `(name, body)`, nothing else (`fixtures.ts:248`, exported as `test` by `index.ts:107`)
|
|
33
|
+
| `test` here is `fixtureTest`, and it takes no timeout | `(name, body)`, nothing else (`fixtures.ts:248`, exported as `test` by `index.ts:107`) |
|
|
34
34
|
| Injection | `SqlRunner` and `connect` are parameters, so unit tests need no server |
|
|
35
35
|
| Fixtures | the preload registers the whole framework bag — an app registers only what the framework cannot know (`seed`, `actorFor`) |
|
|
36
|
-
| 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.
|
|
37
|
-
| The seam has an inverse
|
|
38
|
-
| 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`).
|
|
39
|
-
| `page` HAS a driver
|
|
40
|
-
| `network` is THIS process's fetch, and an e2e page is not in this process | `
|
|
41
|
-
| `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
|
|
42
|
-
| 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.
|
|
43
|
-
| 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.
|
|
44
|
-
| A lone subscriber cannot resume | the retained window is the registry's ENTRY and the entry is dropped when its last subscriber goes, so a reconnect with nobody else holding it re-snapshots — correctly.
|
|
45
|
-
| Strict is opt-in by destructuring | `statements` installs the N+1 detector in throw mode for one test. A fixture nobody names is a fixture nobody built, so there is no `strict: true` and no suite-wide switch
|
|
36
|
+
| 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. |
|
|
37
|
+
| The seam has an inverse | `resetE2eDriver()`. `useE2eDriver` writes MODULE scope and `bun test` is one process, so a file that installs a browser must undo it |
|
|
38
|
+
| 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`). |
|
|
39
|
+
| `page` HAS a driver; `budget`, `signIn`, `deploy` do not | `installE2eDriver({ page, baseUrl })` registers `page` only. Byte counts, a sign-in route and a new build id are facts no page port can answer; a no-op would read as proof |
|
|
40
|
+
| `network` is THIS process's fetch, and an e2e page is not in this process | `network.offline()` beside `page` is a no-op on the browser. `E2eFixtures.offline()` is the browser-side spelling and forwards to `E2eBrowserPage.offline()`; a page port with none refuses by name |
|
|
41
|
+
| `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 |
|
|
42
|
+
| 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. |
|
|
43
|
+
| 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. |
|
|
44
|
+
| A lone subscriber cannot resume | the retained window is the registry's ENTRY and the entry is dropped when its last subscriber goes, so a reconnect with nobody else holding it re-snapshots — correctly. |
|
|
45
|
+
| Strict is opt-in by destructuring | `statements` installs the N+1 detector in throw mode for one test. A fixture nobody names is a fixture nobody built, so there is no `strict: true` and no suite-wide switch |
|
|
46
46
|
| One threshold, one error | `N_PLUS_ONE_THRESHOLD` and `nPlusOne()` are `@ultimat3/entity`'s. A number or a message written here would make a loop that fails a test a different loop from the one `x dev` warns about |
|
|
47
|
-
| The unit of work is the test | `x dev`'s ledger tallies per `Ctx` and ignores a statement issued outside a request
|
|
48
|
-
| Throws once per shape | the failing line is the loop's own statement (the seam lets `onStatement` throw for this reason alone).
|
|
47
|
+
| The unit of work is the test | `x dev`'s ledger tallies per `Ctx` and ignores a statement issued outside a request |
|
|
48
|
+
| Throws once per shape | the failing line is the loop's own statement (the seam lets `onStatement` throw for this reason alone). |
|
|
49
49
|
| Measure vs judge | `all()` `count()` `shapes()` count `expectedQueryLoop` statements too; only the verdict honours the suppression, so "this page issues two statements" never depends on who declared what |
|
|
50
50
|
| One seam for drivers | a driver registers over a declaration with `defineFixtures` — merges, last wins. Never a second registration mechanism |
|
|
51
51
|
| A driver arrives whole | `defineFixtures` holds every name `Fixtures` declares to its declared type, so a half-built `page` is a compile error at the registration, not a missing method three awaits later |
|
|
52
52
|
| Registry hygiene | the fixture registry is process-global; a test that clears it snapshots with `fixtureSnapshot()` and hands it back in `afterAll` |
|
|
53
|
-
| Leaks are the file's, not the next file's | `installRegistryLeakGuard()` runs from the preload and fails the run naming the FILE that left cache tags declared or a cache tier registered after its last test (`X_TEST_REGISTRY_LEAK`).
|
|
54
|
-
| The baseline is not a hook | measured on Bun 1.3.14 the order is onLoad → module eval → file `beforeAll` → describe `beforeAll` → preload `beforeEach`, so a preload hook cannot sample before the file's own `beforeAll`
|
|
55
|
-
| Reported and restored are different sets | the guard also RESTORES, at the same file boundary, the registries whose module-scope declarations a neighbour's cleanup destroys
|
|
56
|
-
| A catalog restore is a MERGE, never a replace, `As of 2026-08-23` | the other three registries are replaced with the snapshot; the catalogs are not. `registerCatalog` has no inverse, so the only thing a file can cost its neighbour is a `resetCatalogs()`
|
|
57
|
-
|
|
|
58
|
-
| Guarded state is boot state | only the two registries whose honest invariant is "clean when the file ends" — `declareTags` and `registerTier` are boot installs.
|
|
59
|
-
| Filled and CLEARED are different questions | and the row above answers only the first. "Idiomatic to leave filled" is about the leak REPORT
|
|
60
|
-
| An empty registry is a premise you state | a test whose subject is "nothing is declared" — `x db gen` with nothing to generate — calls `isolateEntityRegistry()` and restores in a `finally`.
|
|
61
|
-
| That one helper is off the barrel | `@ultimat3/testing/registry-isolation`, its own entry point. It is the only module here that value-imports `@ultimat3/entity`
|
|
62
|
-
| Teardown restores, never uninstalls | `describeApp`/`testApp` capture the seal and the determinism snapshot before booting and put those back
|
|
63
|
-
| Teardown is a `finally` | an `app.close()` that rejects still reaches `db.drop()` and still restores the process state; the first failure is what the caller sees.
|
|
64
|
-
| A boot that rejects is its own teardown | `acquireWorkerDatabase`, `seed` or `boot` throwing returns no `BootedHarness`, so no caller can ever reach `close()`
|
|
65
|
-
| A found template is not a migrated one | `template-db.ts` tolerates "already exists" for the `CREATE DATABASE` alone. `config.migrate` runs unconditionally and un-swallowed
|
|
66
|
-
| Fixture teardown | a fixture that installs process-global state (the ambient job or mail driver) implements `Symbol.dispose` / `Symbol.asyncDispose` and restores what was there
|
|
53
|
+
| Leaks are the file's, not the next file's | `installRegistryLeakGuard()` runs from the preload and fails the run naming the FILE that left cache tags declared or a cache tier registered after its last test (`X_TEST_REGISTRY_LEAK`). |
|
|
54
|
+
| The baseline is not a hook | measured on Bun 1.3.14 the order is onLoad → module eval → file `beforeAll` → describe `beforeAll` → preload `beforeEach`, so a preload hook cannot sample before the file's own `beforeAll` |
|
|
55
|
+
| Reported and restored are different sets | the guard also RESTORES, at the same file boundary, the registries whose module-scope declarations a neighbour's cleanup destroys |
|
|
56
|
+
| A catalog restore is a MERGE, never a replace, `As of 2026-08-23` | the other three registries are replaced with the snapshot; the catalogs are not. `registerCatalog` has no inverse, so the only thing a file can cost its neighbour is a `resetCatalogs()` |
|
|
57
|
+
| Permissions are still REPLACED at a file boundary, and it is measured | an app's `definePermissions()` reached only by a dynamic `loadApp()` is dropped at that file's boundary exactly as catalogs were; the union rule would leak `permissions.test.ts`'s declarations into every later file. Open, its own piece of work (history) |
|
|
58
|
+
| Guarded state is boot state | only the two registries whose honest invariant is "clean when the file ends" — `declareTags` and `registerTier` are boot installs. |
|
|
59
|
+
| Filled and CLEARED are different questions | and the row above answers only the first. "Idiomatic to leave filled" is about the leak REPORT |
|
|
60
|
+
| An empty registry is a premise you state | a test whose subject is "nothing is declared" — `x db gen` with nothing to generate — calls `isolateEntityRegistry()` and restores in a `finally`. |
|
|
61
|
+
| That one helper is off the barrel | `@ultimat3/testing/registry-isolation`, its own entry point. It is the only module here that value-imports `@ultimat3/entity` |
|
|
62
|
+
| Teardown restores, never uninstalls | `describeApp`/`testApp` capture the seal and the determinism snapshot before booting and put those back |
|
|
63
|
+
| Teardown is a `finally` | an `app.close()` that rejects still reaches `db.drop()` and still restores the process state; the first failure is what the caller sees. |
|
|
64
|
+
| A boot that rejects is its own teardown | `acquireWorkerDatabase`, `seed` or `boot` throwing returns no `BootedHarness`, so no caller can ever reach `close()` |
|
|
65
|
+
| A found template is not a migrated one | `template-db.ts` tolerates "already exists" for the `CREATE DATABASE` alone. `config.migrate` runs unconditionally and un-swallowed |
|
|
66
|
+
| Fixture teardown | a fixture that installs process-global state (the ambient job or mail driver) implements `Symbol.dispose` / `Symbol.asyncDispose` and restores what was there |
|
|
67
67
|
| Building one by hand | `createRunJobs()` outside `fixtureTest` is not disposed for you — reset the driver in `afterEach`, or the next file in the process inherits your queue |
|
|
68
68
|
| Factory strategy | an association is built with the strategy that asked for it: `build()` never reaches a database, `create()` writes the parent first. Never a third strategy |
|
|
69
69
|
| One write seam | `usePersister` is the only place `create()` writes. A factory that took a repo argument would put the seam at every call site |
|
|
70
70
|
| 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 |
|
|
71
71
|
| Shared examples | `behavesLike` calls `describe`, so it goes at declaration scope; bun rejects a `describe` inside a test body |
|
|
72
|
-
| 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.
|
|
73
|
-
| `mountIsland` AWAITS `mount` | `IslandEntry['mount']` returns `unknown`, not `void`, and the call is awaited
|
|
74
|
-
| Dispose STOPS the island, then restores the globals | `mountIsland` keeps what `mount` resolved to and, when it is a function, calls it in `[Symbol.dispose]` BEFORE `restore()`
|
|
75
|
-
| The micro-DOM is the fixture's, once **for islands** | `island-dom.ts`.
|
|
76
|
-
| `style` and `classList` RECORD
|
|
77
|
-
| `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.
|
|
78
|
-
| A `document` listener is the documentElement's |
|
|
72
|
+
| 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. |
|
|
73
|
+
| `mountIsland` AWAITS `mount` | `IslandEntry['mount']` returns `unknown`, not `void`, and the call is awaited |
|
|
74
|
+
| Dispose STOPS the island, then restores the globals | `mountIsland` keeps what `mount` resolved to and, when it is a function, calls it in `[Symbol.dispose]` BEFORE `restore()` |
|
|
75
|
+
| The micro-DOM is the fixture's, once **for islands** | `island-dom.ts`. `packages/ui/src/fake-dom.ts` is a second one for keyboard code, and `ui -> testing` is upward, so it is not a copy to collapse. `bun test` has no DOM and no DOM library may be added |
|
|
76
|
+
| `style` and `classList` RECORD | `FakeStyle` is one declaration map behind all four spellings compiled Solid uses (static attribute, `setProperty`, `cssText`, `removeAttribute`), so a test can assert the component set `--form-gap` |
|
|
77
|
+
| `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. |
|
|
78
|
+
| A `document` listener is the documentElement's | no bubbling: `document.addEventListener` registers on `documentElement`, and `fire(mounted.documentElement, 'keydown', …)` drives it. One handler per type, last wins |
|
|
79
79
|
| `querySelector` skips `this` | descendants only, as the DOM's does. Matching the element it is called on made a host `<div>` answer `find('div')` with the container the test built rather than the markup the island rendered |
|
|
80
|
-
| The selector grammar is SMALL and REFUSES
|
|
81
|
-
| The box is 0 until a test writes it | `clientHeight`, `scrollTop`, `scrollHeight` and the rest are plain writable numbers, default 0, and `getBoundingClientRect()` derives from the offset box.
|
|
82
|
-
| `ResizeObserver` records and never fires on its own | `island-observers.ts`, one registry PER DOCUMENT.
|
|
80
|
+
| The selector grammar is SMALL and REFUSES | `island-selector.ts`: compounds of tag, `#id`, `.class`, `[attr]`, `[attr="value"]`, joined by space or `>`. Anything else is `X_TEST_ISLAND_SELECTOR_UNSUPPORTED` with the offset, never an empty answer |
|
|
81
|
+
| The box is 0 until a test writes it | `clientHeight`, `scrollTop`, `scrollHeight` and the rest are plain writable numbers, default 0, and `getBoundingClientRect()` derives from the offset box. |
|
|
82
|
+
| `ResizeObserver` records and never fires on its own | `island-observers.ts`, one registry PER DOCUMENT. |
|
|
83
83
|
| A mount installs process globals | so `MountedIsland` is `Disposable` and a `mount` that THROWS restores before it rethrows. A fake `document` left installed reaches every later FILE in the run, and fails somewhere with no thread back |
|
|
84
|
-
| `fire` answers whether a handler ran | a selector matching nothing and an island that attached no handler are the same silence otherwise — the second is a bug, the first a typo.
|
|
85
|
-
| A states file is PURE DATA, and it is enforced | `defineIslandStates` declares the states an island can be photographed in — error, empty, over-quota, read-only
|
|
86
|
-
| The rule is the RELATIVENESS, not the extension, `As of 2026-08-23` | the scan refused `solid-js` and a specifier ENDING in `.tsx`/`.jsx`, and `import { X } from './settings.island'` resolves to `./settings.island.tsx` under Bun
|
|
87
|
-
| `import type` is not an import, and it is the one way to name the component | `verbatimModuleSyntax` erases a statement that BEGINS `import type` / `export type`
|
|
88
|
-
| Unreadable is not pure | a computed specifier — ``import(`./${name}.island`)``, `require(SPEC)` — is refused as `IslandStatesOpaqueImportError` rather than passed.
|
|
89
|
-
| What the scan does NOT follow, stated rather than silent | a BARE specifier other than `solid-js
|
|
90
|
-
| Props are JSON or they are refused | they ride `data-x-props`, which `@ultimat3/render`'s `emitIslandProps` `JSON.stringify`s, so anything else is a prop the component never receives.
|
|
91
|
-
| The clock is pinned in the vocabulary, zone included | `timeZone` defaults to `ISLAND_SHOT_TIME_ZONE` (`UTC`) and `now` to this package's own `DEFAULT_NOW`, and both ride onto every `IslandShotTarget`.
|
|
92
|
-
| Loose in, strict out | `findIslandStates` resolves `Settings`, `settings`, `settings.island.tsx` and the full path to one manifest, and refuses a name nothing answers to by listing EVERY valid one
|
|
93
|
-
| The disk check is not in `defineIslandStates` | a declaration evaluates wherever it is imported from, so a rule that reads the filesystem at import time fails on the cwd rather than on the path.
|
|
94
|
-
| The island EXTENSION is not restated here | `.island.tsx` is `@ultimat3/render`'s `ISLAND_EXTENSION` and `render` is not a dependency of this package.
|
|
95
|
-
| The chunk is imported from a temp FILE
|
|
96
|
-
| The scratch directory is lazy and removed, `As of 2026-08-22` | `mkdtempSync` ran at MODULE scope and nothing removed it, so every process importing `@ultimat3/testing` at all — this module is on the `.` barrel, so `expect` alone did it
|
|
97
|
-
| Attaching a node MOVES it | `appendChild`, `insertBefore` and `replaceChild` detach the node from its old parent first, and `removeChild` clears `parentNode`
|
|
98
|
-
| Globals install all-or-nothing | `installGlobals` saves DESCRIPTORS, not values — a saved value cannot tell "no such global" from "a global holding `undefined`", and the teardown deleted both
|
|
99
|
-
| Which command shards | `bun test` is one process on one database, and that is still what a scaffolded app's `test` script runs.
|
|
100
|
-
|
|
101
|
-
## The frozen instant is screened, and so is the seed — `As of 2026-08-26`
|
|
102
|
-
|
|
103
|
-
`installDeterminism({ now })`, `setFrozenClock`, `frozenClock` and `advanceClock` all write ONE
|
|
104
|
-
module-level number, and `bun test` is one process: `new Date('yesterday').getTime()` is `NaN`, so
|
|
105
|
-
one unreadable instant makes `Date.now()` answer `NaN` and `new Date()` answer `Invalid Date` for
|
|
106
|
-
every file after it, where every `expiresAt > Date.now()` in the framework reads false and no
|
|
107
|
-
assertion anywhere names the clock. Nothing repairs it either — `NaN + ms` is `NaN`, so
|
|
108
|
-
`advanceClock` only carries it forward, which is why that required parameter is screened too. All
|
|
109
|
-
four go through one `instantMs`, and `defineIslandStates` already refused an unpinned `now` at
|
|
110
|
-
declaration (`isPinnedInstant`) — this is that rule where the clock is actually set.
|
|
111
|
-
|
|
112
|
-
**The seed is NOT a bound, and it is screened anyway — measured before deciding.**
|
|
113
|
-
`seededRandom` starts at `seed >>> 0`, so `NaN`, `±Infinity`, `0.5`, `-1` and `2 ** 32` all produce
|
|
114
|
-
the SAME sequence as `seed: 0`. A non-finite seed therefore does not make a run non-deterministic;
|
|
115
|
-
it silently makes it the seed-0 run, and the record of which seed produced it is false — in the
|
|
116
|
-
package whose promise is reproducibility. `preload.ts` already screened its own environment read by
|
|
117
|
-
hand (`Number.isFinite(seed) ? { seed } : {}`) while `harness.ts` passed an app's `seedValue`
|
|
118
|
-
straight through, which is the repair-in-another-file shape. What the screen does NOT claim: `>>>`
|
|
119
|
-
is modulo `2 ** 32`, so a seed above that still wraps onto another one.
|
|
84
|
+
| `fire` answers whether a handler ran | a selector matching nothing and an island that attached no handler are the same silence otherwise — the second is a bug, the first a typo. |
|
|
85
|
+
| A states file is PURE DATA, and it is enforced | `defineIslandStates` declares the states an island can be photographed in — error, empty, over-quota, read-only |
|
|
86
|
+
| The rule is the RELATIVENESS, not the extension, `As of 2026-08-23` | the scan refused `solid-js` and a specifier ENDING in `.tsx`/`.jsx`, and `import { X } from './settings.island'` resolves to `./settings.island.tsx` under Bun |
|
|
87
|
+
| `import type` is not an import, and it is the one way to name the component | `verbatimModuleSyntax` erases a statement that BEGINS `import type` / `export type` |
|
|
88
|
+
| Unreadable is not pure | a computed specifier — ``import(`./${name}.island`)``, `require(SPEC)` — is refused as `IslandStatesOpaqueImportError` rather than passed. |
|
|
89
|
+
| What the scan does NOT follow, stated rather than silent | a BARE specifier other than `solid-js`, an ABSOLUTE path specifier, and a specifier inside a string LITERAL (read as an import). The first two are holes; the third is a false refusal, the safe direction |
|
|
90
|
+
| Props are JSON or they are refused | they ride `data-x-props`, which `@ultimat3/render`'s `emitIslandProps` `JSON.stringify`s, so anything else is a prop the component never receives. |
|
|
91
|
+
| The clock is pinned in the vocabulary, zone included | `timeZone` defaults to `ISLAND_SHOT_TIME_ZONE` (`UTC`) and `now` to this package's own `DEFAULT_NOW`, and both ride onto every `IslandShotTarget`. |
|
|
92
|
+
| Loose in, strict out | `findIslandStates` resolves `Settings`, `settings`, `settings.island.tsx` and the full path to one manifest, and refuses a name nothing answers to by listing EVERY valid one |
|
|
93
|
+
| The disk check is not in `defineIslandStates` | a declaration evaluates wherever it is imported from, so a rule that reads the filesystem at import time fails on the cwd rather than on the path. |
|
|
94
|
+
| The island EXTENSION is not restated here | `.island.tsx` is `@ultimat3/render`'s `ISLAND_EXTENSION` and `render` is not a dependency of this package. |
|
|
95
|
+
| The chunk is imported from a temp FILE | named by its SHA-256, so an edit is a new module and nothing is left in the app. Never a `data:` URL: `bun test --coverage` panics importing one past ~4 kB (Bun 1.4.0); `fixture-island.test.ts` pins it |
|
|
96
|
+
| The scratch directory is lazy and removed, `As of 2026-08-22` | `mkdtempSync` ran at MODULE scope and nothing removed it, so every process importing `@ultimat3/testing` at all — this module is on the `.` barrel, so `expect` alone did it |
|
|
97
|
+
| Attaching a node MOVES it | `appendChild`, `insertBefore` and `replaceChild` detach the node from its old parent first, and `removeChild` clears `parentNode` |
|
|
98
|
+
| Globals install all-or-nothing | `installGlobals` saves DESCRIPTORS, not values — a saved value cannot tell "no such global" from "a global holding `undefined`", and the teardown deleted both |
|
|
99
|
+
| Which command shards | `bun test` is one process on one database, and that is still what a scaffolded app's `test` script runs. |
|
|
120
100
|
|
|
101
|
+
|
|
102
|
+
## The frozen instant and the seed are screened
|
|
103
|
+
|
|
104
|
+
`installDeterminism({ now })`, `setFrozenClock`, `frozenClock` and `advanceClock` go through one
|
|
105
|
+
`instantMs`: an unreadable instant would make `Date.now()` answer `NaN` for every later file. The
|
|
106
|
+
seed is screened too — `seed >>> 0` maps `NaN`, `0.5` and `2 ** 32` onto seed 0 silently.
|
|
121
107
|
`finiteOption`/`finiteCount` from `@ultimat3/core` are the one form; `determinism-bounds.test.ts`
|
|
122
|
-
holds both sides
|
|
123
|
-
`retry.ts`'s budget was already screened, and `matcher-visible.ts` passes its `timeout`/`interval`
|
|
124
|
-
into that screen rather than growing a second one.
|
|
108
|
+
holds both sides.
|
|
125
109
|
|
|
126
110
|
Commands: `bun test`, `bunx tsc --noEmit -p tsconfig.json`.
|
|
127
111
|
|
|
@@ -133,3 +117,57 @@ import `@ultimat3/core` and nothing else, so they cost a tier-0 test nothing. Th
|
|
|
133
117
|
is `examples/dummy/apps/web/app/settings/settings.island.test.ts`; `fixture-island.test.ts` pins
|
|
134
118
|
this package's own contract against modules written in the idiom `babel-preset-solid` emits, with
|
|
135
119
|
no bundler, because a test here cannot import one.
|
|
120
|
+
|
|
121
|
+
## The browser-backed e2e driver (moved here from `@ultimat3/cli` in 22.0.0)
|
|
122
|
+
|
|
123
|
+
It was in `@ultimat3/cli`, on the argument that the adapter joined `testing`'s `PageLike` to
|
|
124
|
+
`@ultimat3/scraping`'s browser and only `cli` could import both. That stopped being true when the
|
|
125
|
+
browser became RAW CDP (below): the driver needs `@ultimat3/core` and Bun, nothing else, so it
|
|
126
|
+
lives beside the `PageLike` it implements. `cli` imports it over the declared `cli -> testing` edge.
|
|
127
|
+
|
|
128
|
+
| File | Job |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `e2e-driver.ts` | `installE2eDriver({ page, baseUrl })` — the ONE call an app's test preload makes. Registers `page` over its declaration and installs the `e2eTest` seam; returns the undo |
|
|
131
|
+
| `e2e-page.ts` | `PageLike` over four members of `ScrapePage`, declared structurally so a test stands one up in six lines |
|
|
132
|
+
| `e2e-locator.ts` | `LocatorLike` — a handle that resolves nothing until asked, one round trip per question |
|
|
133
|
+
| `e2e-selection.ts` | what a locator SELECTS, as data, and the one in-page expression that resolves it |
|
|
134
|
+
| `e2e-evaluate.ts` | the closure→string crossing, which is the only lossy edge in the adapter |
|
|
135
|
+
| `e2e-errors.ts` | one constructor per refusal |
|
|
136
|
+
| `e2e-dom-fixture.ts` | a document small enough to hold in a test and real enough to RUN the expressions above |
|
|
137
|
+
| `cdp-browser.ts` | the two doors: `openE2eBrowserIfAvailable()` (undefined when there is no browser) and `openE2eBrowser()` (refuses by name), and the close that undoes both halves |
|
|
138
|
+
| `cdp-launch.ts` | which Chrome, and starting it — the candidate list, the flags, and the endpoint read off its stderr |
|
|
139
|
+
| `cdp-connection.ts` | CDP over Bun's own `WebSocket`: request framing, reply correlation by `id`, one-shot event waiters, the per-call deadline |
|
|
140
|
+
| `cdp-e2e-session.ts` | the BROWSER half, `E2eSession`: every target auto-attached at browser level and PAUSED until its Network domain is on (a SharedWorker opens its socket at start-up) |
|
|
141
|
+
| `cdp-e2e-page.ts` | one TAB, `E2eTab`: `E2eBrowserPage`'s five methods plus `reload`, `waitFor`, `indexedDbNames`, `close`. `offline()` forwards to the session — the switch is browser-wide |
|
|
142
|
+
| `e2e-app.ts` | `startE2eApp({ root, mode, seed })`: reset + seed + spawn on a THROWAWAY `ULTIMATE_STATE_DIR` and free ports, `/readyz`-gated, spawned through the app's own `@ultimat3/cli` bin (`xBin`); `stop()` removes the directory |
|
|
143
|
+
| `e2e-preload.ts` + `e2e-browser-handle.ts` | the `e2e` step's preload (`@ultimat3/testing/e2e-preload`): with `ULTIMATE_E2E_ROOT` set it spawns the app, opens ONE browser, installs its first tab, and publishes `e2eBrowser()`, `e2eApp()`, `e2eBaseUrl()` |
|
|
144
|
+
| `packages/cli/src/verify-e2e.ts` | `withE2eApp` (cli's): in an app on a machine with Chrome, the `e2e` step runs the suite with that preload; otherwise exactly as before |
|
|
145
|
+
| `cdp-errors.ts` | one constructor per way the browser half refuses |
|
|
146
|
+
|
|
147
|
+
**Absent by default.** Nothing here runs until `installE2eDriver` is called, so `hasE2eDriver()`
|
|
148
|
+
answers `false` and the gate's `e2e` step refuses rather than passing over a browser it lacks.
|
|
149
|
+
GitHub-hosted `ubuntu-latest` ships Chrome at `/usr/bin/google-chrome`.
|
|
150
|
+
|
|
151
|
+
**Raw CDP over Bun, no dependency.** A launched Chrome is driven over its debugging PIPE
|
|
152
|
+
(`cdp-pipe.ts`); a remote one over Bun's `WebSocket` (`cdpConnect`). `x shot` and the dev MCP
|
|
153
|
+
server's `ui.*` tools launch on the same `launchChrome` since 22.0.0
|
|
154
|
+
(`packages/cli/src/cdp-shot-driver.ts`) — one launcher, one wire. A CDP event listener is handed
|
|
155
|
+
the event's `sessionId` (`CdpEventListener`), so a subscriber owning one page ignores the rest.
|
|
156
|
+
|
|
157
|
+
**The load EVENT is the completion signal, never `Page.navigate`'s reply** — Chrome drops the reply
|
|
158
|
+
when a navigation swaps the render process. The waiter goes up before the send; the reply is read
|
|
159
|
+
only for `errorText`.
|
|
160
|
+
|
|
161
|
+
**Every call is deadlined, and a close settles every call in flight.** Four codes, four repairs:
|
|
162
|
+
`X_CDP_BROWSER_MISSING` (install one), `X_CDP_LAUNCH_FAILED` (read its stderr, in the cause),
|
|
163
|
+
`X_CDP_CALL_FAILED` (look at the page), `X_CDP_TIMEOUT` (raise the deadline).
|
|
164
|
+
|
|
165
|
+
**`evaluate` is the lossy edge.** Only `Function.prototype.toString()` crosses: a zero-parameter
|
|
166
|
+
closure over page globals works; native, bound, parameterised and method-shorthand closures are
|
|
167
|
+
refused statically; a missing binding comes back named from the page's own `ReferenceError`.
|
|
168
|
+
|
|
169
|
+
**`update()` still refuses** — a second build under a new build id is a SERVER fact no page port
|
|
170
|
+
can speak for. `offline()`/`online()` forward to `E2eBrowserPage.offline`.
|
|
171
|
+
|
|
172
|
+
The reasoning behind every rule above, verbatim, is [`docs/history/testing.md`](../../docs/history/testing.md).
|
|
173
|
+
|
package/README.md
CHANGED
|
@@ -190,7 +190,9 @@ test does not care about; `defineFactory` is for the rows it does.
|
|
|
190
190
|
```ts
|
|
191
191
|
const anAuthenticatedAction = sharedExamples<Action>('an authenticated action', (subject) => {
|
|
192
192
|
test('denies an anonymous actor', async () => {
|
|
193
|
-
|
|
193
|
+
// The POLICY is the receiver and the context is the argument — `toDenyPolicy` evaluates it.
|
|
194
|
+
// Awaited: the decision is async, and an un-awaited assertion can never fail the test.
|
|
195
|
+
await expect(subject().policy).toDenyPolicy({ actor: null, input: {} });
|
|
194
196
|
});
|
|
195
197
|
});
|
|
196
198
|
|
|
@@ -490,6 +492,20 @@ fails on a page that simply has not painted yet.
|
|
|
490
492
|
`X_TEST_ISLAND_STATE_JSON_INVALID` `X_TEST_ISLAND_STATE_CLOCK_INVALID`
|
|
491
493
|
`X_TEST_ISLAND_STATE_STUB_INVALID`
|
|
492
494
|
|
|
495
|
+
### Error classes
|
|
496
|
+
|
|
497
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
498
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
499
|
+
|
|
500
|
+
| Class | Code | Declared in |
|
|
501
|
+
|---|---|---|
|
|
502
|
+
| `FixtureUnavailableError` | `X_TEST_FIXTURE_UNAVAILABLE` | `src/errors.ts` |
|
|
503
|
+
| `NetworkOfflineError` | `X_TEST_NETWORK_OFFLINE` | `src/errors.ts` |
|
|
504
|
+
| `NetworkSealedError` | `X_TEST_NETWORK_SEALED` | `src/errors.ts` |
|
|
505
|
+
| `NondeterministicError` | `X_TEST_NONDETERMINISTIC` | `src/errors.ts` |
|
|
506
|
+
| `RegistryLeakError` | `X_TEST_REGISTRY_LEAK` | `src/errors.ts` |
|
|
507
|
+
| `TestDatabaseUnavailableError` | `X_TEST_DB_UNAVAILABLE` | `src/errors.ts` |
|
|
508
|
+
|
|
493
509
|
## One process, one registry
|
|
494
510
|
|
|
495
511
|
`bun test` runs every file of one invocation in the same process — only `x verify`'s shards pass
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/testing",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.1.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",
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"exports": {
|
|
17
17
|
".": "./src/index.ts",
|
|
18
18
|
"./preload": "./src/preload.ts",
|
|
19
|
-
"./
|
|
19
|
+
"./e2e-preload": "./src/e2e-preload.ts",
|
|
20
|
+
"./registry-isolation": "./src/registry-isolation.ts",
|
|
21
|
+
"./test-types": "./src/test-types.ts"
|
|
20
22
|
},
|
|
21
23
|
"files": [
|
|
22
24
|
"src",
|
|
@@ -33,16 +35,16 @@
|
|
|
33
35
|
"test": "bun test"
|
|
34
36
|
},
|
|
35
37
|
"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": "
|
|
38
|
+
"@ultimat3/cache": "22.1.0",
|
|
39
|
+
"@ultimat3/core": "22.1.0",
|
|
40
|
+
"@ultimat3/db": "22.1.0",
|
|
41
|
+
"@ultimat3/entity": "22.1.0",
|
|
42
|
+
"@ultimat3/i18n": "22.1.0",
|
|
43
|
+
"@ultimat3/jobs": "22.1.0",
|
|
44
|
+
"@ultimat3/mail": "22.1.0",
|
|
45
|
+
"@ultimat3/policy": "22.1.0",
|
|
46
|
+
"@ultimat3/query": "22.1.0",
|
|
47
|
+
"@ultimat3/realtime": "22.1.0",
|
|
48
|
+
"@ultimat3/time": "22.1.0"
|
|
47
49
|
}
|
|
48
50
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// One responsibility: compose the halves — find a browser, launch it over its pipe, attach a page —
|
|
2
|
+
// into the one object `installE2eDriver({ page })` takes, plus the way to shut it down.
|
|
3
|
+
//
|
|
4
|
+
// **Absent is a SKIP, never a failure, and that is a requirement rather than a state.** A CI box
|
|
5
|
+
// with no Chrome must not turn the `e2e` step red for a reason unrelated to the change, which is
|
|
6
|
+
// the rule `packages/cli/CLAUDE.md` already states about `x shot`, `x pr` and `x ci`.
|
|
7
|
+
// `openE2eBrowserIfAvailable` is that door; `openE2eBrowser` refuses by name for a caller that has
|
|
8
|
+
// already decided a browser is required.
|
|
9
|
+
|
|
10
|
+
import { finiteCount } from '@ultimat3/core';
|
|
11
|
+
import type { E2eTab } from './cdp-e2e-page';
|
|
12
|
+
import type { E2eSession } from './cdp-e2e-session';
|
|
13
|
+
import { cdpE2eSession } from './cdp-e2e-session';
|
|
14
|
+
import { CdpBrowserMissingError } from './cdp-errors';
|
|
15
|
+
import type { LaunchedBrowser } from './cdp-launch';
|
|
16
|
+
import { CHROME_CANDIDATES, findChrome, launchChrome } from './cdp-launch';
|
|
17
|
+
|
|
18
|
+
/** How long a launch, a connect or a single CDP call may take. One number, three deadlines. */
|
|
19
|
+
export const DEFAULT_CDP_TIMEOUT_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
export interface E2eBrowser {
|
|
22
|
+
/** The first tab — what `installE2eDriver({ page })` drives. */
|
|
23
|
+
readonly page: E2eTab;
|
|
24
|
+
/**
|
|
25
|
+
* The browser itself: more tabs in the same profile, init scripts, the offline switch for every
|
|
26
|
+
* page and worker, and the log of every socket and request. What a multi-tab acceptance suite
|
|
27
|
+
* drives, on the same launch as `page` — one harness, never a second one beside the driver.
|
|
28
|
+
*/
|
|
29
|
+
readonly session: E2eSession;
|
|
30
|
+
/** Idempotent, and it closes both halves: the CDP socket, then the process and its profile. */
|
|
31
|
+
close(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OpenE2eBrowserOptions {
|
|
35
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
36
|
+
readonly timeoutMs?: number | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Screened HERE, before a browser exists, and not where it lands. It becomes three deadlines — the
|
|
41
|
+
* launch, the handshake and every CDP call — and `Number(process.env.E2E_TIMEOUT ?? '')` is `NaN`
|
|
42
|
+
* for an unset variable and is not nullish, so `??` keeps it: a `setTimeout` given `NaN` fires at
|
|
43
|
+
* 1ms in this Bun, which makes every call report `X_CDP_TIMEOUT` against a browser that was
|
|
44
|
+
* answering. A misdiagnosis reported as a test failure is worse than the failure.
|
|
45
|
+
*/
|
|
46
|
+
const budget = (options: OpenE2eBrowserOptions): number =>
|
|
47
|
+
finiteCount('openE2eBrowser', 'timeoutMs', options.timeoutMs ?? DEFAULT_CDP_TIMEOUT_MS);
|
|
48
|
+
|
|
49
|
+
const compose = (launched: LaunchedBrowser, session: E2eSession, page: E2eTab): E2eBrowser => ({
|
|
50
|
+
page,
|
|
51
|
+
session,
|
|
52
|
+
// The connection first, then the process — `launched.close()` does both, in that order: closing
|
|
53
|
+
// the process out from under an open connection makes every in-flight call report "the browser
|
|
54
|
+
// closed the CDP connection", which is true and useless.
|
|
55
|
+
close: () => launched.close(),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Launch a browser and attach one page to it. Refuses with `X_CDP_BROWSER_MISSING` when there is
|
|
60
|
+
* nothing to launch — the caller that wants a skip asks `openE2eBrowserIfAvailable` instead.
|
|
61
|
+
*/
|
|
62
|
+
export async function openE2eBrowser(options: OpenE2eBrowserOptions = {}): Promise<E2eBrowser> {
|
|
63
|
+
const timeoutMs = budget(options);
|
|
64
|
+
const executable = await findChrome(options.env ?? process.env);
|
|
65
|
+
if (executable === undefined) throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
|
|
66
|
+
return openLaunched(executable, timeoutMs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `undefined` when this machine has no browser. Every other failure still throws. */
|
|
70
|
+
export async function openE2eBrowserIfAvailable(
|
|
71
|
+
options: OpenE2eBrowserOptions = {},
|
|
72
|
+
): Promise<E2eBrowser | undefined> {
|
|
73
|
+
const timeoutMs = budget(options);
|
|
74
|
+
const executable = await findChrome(options.env ?? process.env);
|
|
75
|
+
if (executable === undefined) return undefined;
|
|
76
|
+
return openLaunched(executable, timeoutMs);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The half both doors share. Each step undoes the ones before it on the way out: a Chrome that
|
|
81
|
+
* launched and then refused the CDP handshake would otherwise be left running, holding its profile
|
|
82
|
+
* directory, for the rest of the test process — one leaked browser per failing suite.
|
|
83
|
+
*/
|
|
84
|
+
async function openLaunched(executable: string, timeoutMs: number): Promise<E2eBrowser> {
|
|
85
|
+
const launched = await launchChrome({ executable, timeoutMs });
|
|
86
|
+
const { connection } = launched;
|
|
87
|
+
try {
|
|
88
|
+
const session = await cdpE2eSession({ connection, loadTimeoutMs: timeoutMs });
|
|
89
|
+
return compose(launched, session, await session.newTab());
|
|
90
|
+
} catch (error) {
|
|
91
|
+
launched.close();
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|