@use-everywhere/test-utils 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jonatan Kruszewski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # @use-everywhere/test-utils
2
+
3
+ Test seams for [use-everywhere](https://github.com/rxova/use-everywhere): run
4
+ several simulated tabs in one process, with no browser and no globals.
5
+
6
+ ```sh
7
+ pnpm add -D @use-everywhere/test-utils
8
+ ```
9
+
10
+ ```ts
11
+ import { createScenario } from '@use-everywhere/test-utils';
12
+
13
+ it('two tabs converge', async () => {
14
+ const browser = createScenario();
15
+ const cartA = browser.tab().store('cart', { items: 0 });
16
+ const cartB = browser.tab().store('cart', { items: 0 });
17
+
18
+ cartA.set('items', 3);
19
+ await browser.settle();
20
+
21
+ expect(cartB.getSnapshot().items).toBe(3);
22
+ browser.dispose();
23
+ });
24
+ ```
25
+
26
+ ## Close is not crash
27
+
28
+ The reason multi-tab code is hard is that a tab can leave in two ways, and only
29
+ one of them says goodbye. Both are one call here:
30
+
31
+ ```ts
32
+ const browser = createScenario();
33
+ const a = browser.tab();
34
+ const b = browser.tab();
35
+ const first = a.leader('app');
36
+ const second = b.leader('app');
37
+
38
+ a.crash(); // the wire is cut mid-sentence: no goodbye, no resignation
39
+ await browser.settle();
40
+
41
+ expect(second.getSnapshot().isLeader).toBe(true); // the platform reclaimed the seat
42
+ ```
43
+
44
+ `close()` closes the primitives and then the wire, so peers are _told_.
45
+ `crash()` closes the wire first and reclaims the tab's Web Locks, so peers have
46
+ to _notice_ — which is the path that finds the bugs.
47
+
48
+ ## What's in the box
49
+
50
+ | Export | What it is |
51
+ | ------------------------------ | -------------------------------------------------------------------------------- |
52
+ | `createScenario(options?)` | One simulated browser: a shared hub, shared Web Locks, and tabs |
53
+ | `FakeLockManager` | `navigator.locks` with FIFO queueing and reclamation on crash |
54
+ | `FakeWindow`, `fakeWindowPair` | Enough `Window` for `openWindow` / `connectToOpener`, including hostile messages |
55
+ | `tick`, `snapshotWindow` | The two waits that matter: delivery, and the late-joiner snapshot window |
56
+ | `MemoryHub`, `MemoryTransport` | Re-exported from `@use-everywhere/core/testing` — same classes, one import |
57
+
58
+ `createScenario({ election: 'heartbeat' })` runs the election that plain-http
59
+ origins get, where Web Locks does not exist. Test both if you ship to one.
60
+
61
+ ## One primitive per name per tab
62
+
63
+ A `Tab` is a lifecycle group. Each primitive created through it gets its own
64
+ connection to the hub — the same shape a real tab has when it uses several bus
65
+ names — so a presence and a store created on _one_ name in one tab announce
66
+ themselves as two clients. Keep one primitive per name per tab and the
67
+ simulation matches a browser exactly.
68
+
69
+ ## Docs
70
+
71
+ [Testing guide](https://rxova.org/packages/use-everywhere/guides/testing/)
72
+
73
+ ## License
74
+
75
+ MIT © [Jonatan Kruszewski](https://github.com/rxova)
package/dist/index.cjs ADDED
@@ -0,0 +1,306 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var src_exports = {};
22
+ __export(src_exports, {
23
+ FakeLockManager: () => FakeLockManager,
24
+ FakeWindow: () => FakeWindow,
25
+ MemoryHub: () => import_testing2.MemoryHub,
26
+ MemoryTransport: () => import_testing2.MemoryTransport,
27
+ createScenario: () => createScenario,
28
+ fakeWindowPair: () => fakeWindowPair,
29
+ snapshotWindow: () => snapshotWindow,
30
+ tick: () => tick
31
+ });
32
+ module.exports = __toCommonJS(src_exports);
33
+
34
+ // src/scenario.ts
35
+ var import_core = require("@use-everywhere/core");
36
+ var import_testing = require("@use-everywhere/core/testing");
37
+
38
+ // src/fake-locks.ts
39
+ var abortError = () => new DOMException("The operation was aborted.", "AbortError");
40
+ var FakeLockManager = class {
41
+ constructor() {
42
+ this.holds = /* @__PURE__ */ new Map();
43
+ this.queues = /* @__PURE__ */ new Map();
44
+ this.nextHoldId = 1;
45
+ }
46
+ request(name, options, callback) {
47
+ return this.requestAs(void 0, name, options, callback);
48
+ }
49
+ /**
50
+ * A view of this manager tagged with an owner, so `reclaim(owner)` can take
51
+ * the lock back the way a browser does when the tab holding it disappears.
52
+ */
53
+ forOwner(owner) {
54
+ return {
55
+ request: (name, options, callback) => this.requestAs(owner, name, options, callback)
56
+ };
57
+ }
58
+ /**
59
+ * The tab named `owner` is gone: free every lock it holds and forget every
60
+ * lock it was waiting on.
61
+ *
62
+ * Its callbacks are never settled — that is the point. A crashed tab's code
63
+ * does not get to run again, and a test that asserts the *next* tab took the
64
+ * seat is asserting exactly what the browser guarantees.
65
+ */
66
+ reclaim(owner) {
67
+ for (const [name, hold] of [...this.holds]) {
68
+ if (hold.owner !== owner) continue;
69
+ this.holds.delete(name);
70
+ this.queues.get(name)?.shift()?.grant();
71
+ }
72
+ for (const queue of this.queues.values()) {
73
+ for (let at = queue.length - 1; at >= 0; at -= 1) {
74
+ if (queue[at].owner === owner) queue.splice(at, 1);
75
+ }
76
+ }
77
+ }
78
+ /** Is this lock held by anyone? */
79
+ isHeld(name) {
80
+ return this.holds.has(name);
81
+ }
82
+ /** Which owner holds it, if the request was tagged with one. */
83
+ holder(name) {
84
+ return this.holds.get(name)?.owner;
85
+ }
86
+ /** How many callers are waiting behind the holder. */
87
+ queued(name) {
88
+ return this.queues.get(name)?.length ?? 0;
89
+ }
90
+ requestAs(owner, name, options, callback) {
91
+ return new Promise((resolve, reject) => {
92
+ const grant = () => {
93
+ const hold = { owner, id: this.nextHoldId++ };
94
+ this.holds.set(name, hold);
95
+ void callback().catch(() => {
96
+ }).then(() => {
97
+ if (this.holds.get(name)?.id !== hold.id) {
98
+ resolve();
99
+ return;
100
+ }
101
+ this.holds.delete(name);
102
+ const next = this.queues.get(name)?.shift();
103
+ resolve();
104
+ next?.grant();
105
+ });
106
+ };
107
+ if (options.signal?.aborted) {
108
+ reject(abortError());
109
+ return;
110
+ }
111
+ if (!this.holds.has(name)) {
112
+ grant();
113
+ return;
114
+ }
115
+ const waiter = { owner, grant };
116
+ const queue = this.queues.get(name) ?? [];
117
+ queue.push(waiter);
118
+ this.queues.set(name, queue);
119
+ options.signal?.addEventListener("abort", () => {
120
+ const at = queue.indexOf(waiter);
121
+ if (at >= 0) {
122
+ queue.splice(at, 1);
123
+ reject(abortError());
124
+ }
125
+ });
126
+ });
127
+ }
128
+ };
129
+
130
+ // src/timing.ts
131
+ var tick = () => new Promise((resolve) => setTimeout(resolve, 0));
132
+ var snapshotWindow = (ms = 80) => new Promise((resolve) => setTimeout(resolve, ms));
133
+
134
+ // src/scenario.ts
135
+ var SimulatedTab = class {
136
+ constructor(id, hub, locks, election, kind) {
137
+ this.id = id;
138
+ this.hub = hub;
139
+ this.locks = locks;
140
+ this.election = election;
141
+ this.kind = kind;
142
+ this.wires = [];
143
+ this.created = [];
144
+ this.state = "open";
145
+ }
146
+ get gone() {
147
+ return this.state !== "open";
148
+ }
149
+ store(name, initial, options = {}) {
150
+ return this.track((0, import_core.createSharedStore)(name, initial, { ...this.common(), ...options }));
151
+ }
152
+ reducer(name, reducer, initial, options = {}) {
153
+ return this.track(
154
+ (0, import_core.createSharedReducer)(name, reducer, initial, { ...this.common(), ...options })
155
+ );
156
+ }
157
+ channel(name, options = {}) {
158
+ return this.track((0, import_core.createChannel)(name, { ...this.common(), ...options }));
159
+ }
160
+ presence(name, options = {}) {
161
+ return this.track((0, import_core.createPresence)(name, { ...this.common(), ...options }));
162
+ }
163
+ leader(name, options = {}) {
164
+ const election = this.election === "web-locks" ? { strategy: "web-locks", locks: this.locks.forOwner(this.id) } : { strategy: "heartbeat" };
165
+ return this.track((0, import_core.createLeader)(name, { ...this.common(), ...election, ...options }));
166
+ }
167
+ /**
168
+ * The difference between closing and crashing is the *order* these two lines
169
+ * run in, which is exactly the difference in a browser: a tab that closes
170
+ * gets its goodbye out before the wire goes, and a tab that crashes does not.
171
+ */
172
+ close() {
173
+ if (this.gone) return;
174
+ this.state = "closed";
175
+ for (const closeable of this.created) closeable.close();
176
+ for (const wire of this.wires) wire.close();
177
+ }
178
+ crash() {
179
+ if (this.gone) return;
180
+ this.state = "crashed";
181
+ for (const wire of this.wires) wire.close();
182
+ this.locks.reclaim(this.id);
183
+ }
184
+ /** Options every primitive in this tab shares: its own wire, its own kind. */
185
+ common() {
186
+ return {
187
+ transport: () => {
188
+ const wire = this.hub.connect();
189
+ this.wires.push(wire);
190
+ return wire;
191
+ },
192
+ // Spread-friendly: exactOptionalPropertyTypes rejects an explicit undefined.
193
+ ...this.kind ? { kind: this.kind } : {}
194
+ };
195
+ }
196
+ track(primitive) {
197
+ this.created.push(primitive);
198
+ return primitive;
199
+ }
200
+ };
201
+ function createScenario(options = {}) {
202
+ const hub = new import_testing.MemoryHub();
203
+ const locks = new FakeLockManager();
204
+ const election = options.election ?? "web-locks";
205
+ const tabs = [];
206
+ return {
207
+ hub,
208
+ locks,
209
+ tabs,
210
+ tab(tabOptions = {}) {
211
+ const tab = new SimulatedTab(
212
+ tabOptions.id ?? `tab-${tabs.length + 1}`,
213
+ hub,
214
+ locks,
215
+ election,
216
+ tabOptions.kind
217
+ );
218
+ tabs.push(tab);
219
+ return tab;
220
+ },
221
+ settle(ms) {
222
+ return ms === void 0 ? tick() : new Promise((resolve) => setTimeout(resolve, ms));
223
+ },
224
+ dispose() {
225
+ for (const tab of tabs) tab.close();
226
+ }
227
+ };
228
+ }
229
+
230
+ // src/fake-window.ts
231
+ var FakeWindow = class {
232
+ constructor(origin) {
233
+ this.closed = false;
234
+ this.peer = null;
235
+ /** Messages wait here until `flush()` — a child that has not loaded yet. */
236
+ this.pending = [];
237
+ this.autoFlush = true;
238
+ this.listeners = /* @__PURE__ */ new Map();
239
+ this.origin = origin;
240
+ }
241
+ addEventListener(type, listener) {
242
+ let set = this.listeners.get(type);
243
+ if (!set) {
244
+ set = /* @__PURE__ */ new Set();
245
+ this.listeners.set(type, set);
246
+ }
247
+ set.add(listener);
248
+ }
249
+ removeEventListener(type, listener) {
250
+ this.listeners.get(type)?.delete(listener);
251
+ }
252
+ /** Called by the peer: deliver a message event to this window's listeners. */
253
+ postMessage(data, targetOrigin) {
254
+ if (targetOrigin !== "*" && targetOrigin !== this.origin) return;
255
+ const from = this.peer;
256
+ const deliver = () => {
257
+ if (this.closed) return;
258
+ for (const fn of this.listeners.get("message") ?? []) {
259
+ fn({ data, origin: from?.origin ?? this.origin, source: from });
260
+ }
261
+ };
262
+ if (this.autoFlush) queueMicrotask(deliver);
263
+ else this.pending.push(deliver);
264
+ }
265
+ /**
266
+ * Deliver a message that did not come from the peer — an attacker page, or an
267
+ * unrelated widget on the same origin. The handshake must ignore it.
268
+ */
269
+ injectMessage(data, origin, source = {}) {
270
+ for (const fn of this.listeners.get("message") ?? []) {
271
+ fn({ data, origin, source });
272
+ }
273
+ }
274
+ /** Deliver everything held back while `autoFlush` was off. */
275
+ flush() {
276
+ for (const deliver of this.pending.splice(0)) deliver();
277
+ }
278
+ /** Close this window, firing `pagehide` the way a real one does. */
279
+ close() {
280
+ this.closed = true;
281
+ for (const fn of this.listeners.get("pagehide") ?? []) {
282
+ fn({ data: void 0, origin: this.origin, source: this });
283
+ }
284
+ }
285
+ };
286
+ function fakeWindowPair(openerOrigin, childOrigin) {
287
+ const opener = new FakeWindow(openerOrigin);
288
+ const child = new FakeWindow(childOrigin);
289
+ opener.peer = child;
290
+ child.peer = opener;
291
+ return { opener, child };
292
+ }
293
+
294
+ // src/index.ts
295
+ var import_testing2 = require("@use-everywhere/core/testing");
296
+ // Annotate the CommonJS export names for ESM import in node:
297
+ 0 && (module.exports = {
298
+ FakeLockManager,
299
+ FakeWindow,
300
+ MemoryHub,
301
+ MemoryTransport,
302
+ createScenario,
303
+ fakeWindowPair,
304
+ snapshotWindow,
305
+ tick
306
+ });
@@ -0,0 +1,200 @@
1
+ import { LockManagerLike, SharedStoreOptions, SharedStore, SharedReducerOptions, SharedReducer, MessageMap, ChannelOptions, Channel, PresenceOptions, Presence, LeaderOptions, Leader, PeerKind, WindowEventTarget, WindowLike } from '@use-everywhere/core';
2
+ import { MemoryHub } from '@use-everywhere/core/testing';
3
+ export { MemoryHub, MemoryTransport } from '@use-everywhere/core/testing';
4
+
5
+ /**
6
+ * A stand-in for `navigator.locks` with the three behaviours leadership rests
7
+ * on: exactly one holder per name, a FIFO queue that hands the lock on the
8
+ * instant the holder lets go, and — the one a fake usually misses — reclamation
9
+ * when the holder dies without releasing.
10
+ *
11
+ * One instance stands for "the browser", so every simulated tab queues against
12
+ * the others exactly as real ones would.
13
+ *
14
+ * ```ts
15
+ * const locks = new FakeLockManager();
16
+ * const leader = createLeader('cart', { locks, transport: () => hub.connect() });
17
+ * ```
18
+ */
19
+ declare class FakeLockManager {
20
+ private holds;
21
+ private queues;
22
+ private nextHoldId;
23
+ request(name: string, options: {
24
+ signal?: AbortSignal;
25
+ }, callback: () => Promise<void>): Promise<void>;
26
+ /**
27
+ * A view of this manager tagged with an owner, so `reclaim(owner)` can take
28
+ * the lock back the way a browser does when the tab holding it disappears.
29
+ */
30
+ forOwner(owner: string): LockManagerLike;
31
+ /**
32
+ * The tab named `owner` is gone: free every lock it holds and forget every
33
+ * lock it was waiting on.
34
+ *
35
+ * Its callbacks are never settled — that is the point. A crashed tab's code
36
+ * does not get to run again, and a test that asserts the *next* tab took the
37
+ * seat is asserting exactly what the browser guarantees.
38
+ */
39
+ reclaim(owner: string): void;
40
+ /** Is this lock held by anyone? */
41
+ isHeld(name: string): boolean;
42
+ /** Which owner holds it, if the request was tagged with one. */
43
+ holder(name: string): string | undefined;
44
+ /** How many callers are waiting behind the holder. */
45
+ queued(name: string): number;
46
+ private requestAs;
47
+ }
48
+
49
+ interface ScenarioOptions {
50
+ /**
51
+ * How leadership is arbitrated in this simulated browser. Default
52
+ * `'web-locks'`, matching every browser that has the API — and the only
53
+ * strategy where a crashed tab's seat is reclaimed by the platform rather
54
+ * than by a lease expiring.
55
+ *
56
+ * `'heartbeat'` runs the election that plain-http origins get. Test both if
57
+ * your app ships to one.
58
+ */
59
+ election?: 'web-locks' | 'heartbeat';
60
+ }
61
+ interface TabOptions {
62
+ /** A label for this tab. Defaults to `tab-1`, `tab-2`, … */
63
+ id?: string;
64
+ /** What this client announces itself as. Defaults to `'tab'`. */
65
+ kind?: PeerKind;
66
+ }
67
+ /**
68
+ * A simulated tab: a lifecycle group over the primitives created through it.
69
+ *
70
+ * Each primitive gets its own connection to the hub — the same shape a real tab
71
+ * has when it uses several bus names — so a presence and a store created on
72
+ * *one* name in one tab announce themselves as two clients. One primitive per
73
+ * name per tab, and the simulation matches a browser exactly.
74
+ */
75
+ interface Tab {
76
+ readonly id: string;
77
+ /** True once this tab has been closed or crashed. */
78
+ readonly gone: boolean;
79
+ store<S extends Record<string, unknown>>(name: string, initial: S, options?: SharedStoreOptions<S>): SharedStore<S>;
80
+ reducer<S, A>(name: string, reducer: (state: S, action: A) => S, initial: S, options?: SharedReducerOptions): SharedReducer<S, A>;
81
+ channel<M extends MessageMap>(name: string, options?: ChannelOptions<M>): Channel<M>;
82
+ presence(name: string, options?: PresenceOptions): Presence;
83
+ leader(name: string, options?: LeaderOptions): Leader;
84
+ /**
85
+ * Close this tab the way a user closes one: every primitive says goodbye,
86
+ * peers drop it from the roster at once, and any lock it held is released.
87
+ */
88
+ close(): void;
89
+ /**
90
+ * Kill this tab the way a crash does: the wire is cut mid-sentence, no
91
+ * goodbye is sent, and the locks it held are reclaimed by the platform.
92
+ *
93
+ * The difference from `close()` is the whole reason multi-tab code is hard —
94
+ * peers have to *notice*, rather than being told.
95
+ */
96
+ crash(): void;
97
+ }
98
+ interface Scenario {
99
+ /** The in-memory bus every tab in this scenario is connected to. */
100
+ readonly hub: MemoryHub;
101
+ /** The Web Locks stand-in shared by every tab. */
102
+ readonly locks: FakeLockManager;
103
+ /** Every tab created, in order, including the ones that are gone. */
104
+ readonly tabs: readonly Tab[];
105
+ /** Open another tab. */
106
+ tab(options?: TabOptions): Tab;
107
+ /**
108
+ * Let the wire catch up: drains microtasks, or waits `ms` when the thing you
109
+ * are waiting for is on a timer (a snapshot window, a lease, a probe).
110
+ */
111
+ settle(ms?: number): Promise<void>;
112
+ /** Close every tab that is still open. Safe to call twice. */
113
+ dispose(): void;
114
+ }
115
+
116
+ /**
117
+ * One simulated browser: a hub every tab shares, a Web Locks stand-in every tab
118
+ * queues on, and tabs that can be closed *or* crashed.
119
+ *
120
+ * ```ts
121
+ * const browser = createScenario();
122
+ * const a = browser.tab();
123
+ * const b = browser.tab();
124
+ *
125
+ * const cartA = a.store('cart', { items: 0 });
126
+ * const cartB = b.store('cart', { items: 0 });
127
+ *
128
+ * cartA.set('items', 3);
129
+ * await browser.settle();
130
+ * expect(cartB.getSnapshot().items).toBe(3);
131
+ * ```
132
+ *
133
+ * Nothing here touches globals: no `BroadcastChannel`, no `navigator.locks`, no
134
+ * timers you did not ask for. Several scenarios can run in one file, in
135
+ * parallel, without seeing each other.
136
+ */
137
+ declare function createScenario(options?: ScenarioOptions): Scenario;
138
+
139
+ type Listener = (event: {
140
+ data: unknown;
141
+ origin: string;
142
+ source: unknown;
143
+ }) => void;
144
+ /**
145
+ * One side of a fake window pair: listens like a `Window`, posts to its peer.
146
+ *
147
+ * Enough of the object model for `openWindow` and `connectToOpener` to run in a
148
+ * plain test process — including the parts the cross-origin handshake exists to
149
+ * defend: a message from the wrong origin, a message from an unrelated source,
150
+ * a child that closes mid-flow, and a child that loads too late to hear the
151
+ * first hello.
152
+ */
153
+ declare class FakeWindow implements WindowEventTarget, WindowLike {
154
+ closed: boolean;
155
+ origin: string;
156
+ peer: FakeWindow | null;
157
+ /** Messages wait here until `flush()` — a child that has not loaded yet. */
158
+ pending: Array<() => void>;
159
+ autoFlush: boolean;
160
+ private listeners;
161
+ constructor(origin: string);
162
+ addEventListener(type: string, listener: Listener): void;
163
+ removeEventListener(type: string, listener: Listener): void;
164
+ /** Called by the peer: deliver a message event to this window's listeners. */
165
+ postMessage(data: unknown, targetOrigin: string): void;
166
+ /**
167
+ * Deliver a message that did not come from the peer — an attacker page, or an
168
+ * unrelated widget on the same origin. The handshake must ignore it.
169
+ */
170
+ injectMessage(data: unknown, origin: string, source?: unknown): void;
171
+ /** Deliver everything held back while `autoFlush` was off. */
172
+ flush(): void;
173
+ /** Close this window, firing `pagehide` the way a real one does. */
174
+ close(): void;
175
+ }
176
+ /** Two fake windows wired to each other: the opener, and the window it opened. */
177
+ declare function fakeWindowPair(openerOrigin: string, childOrigin: string): {
178
+ opener: FakeWindow;
179
+ child: FakeWindow;
180
+ };
181
+
182
+ /**
183
+ * Drain pending microtasks — and the microtasks they queue.
184
+ *
185
+ * Delivery on a `BroadcastChannel`, and on the MemoryHub that stands in for it,
186
+ * is asynchronous. `await tick()` is the line between "this tab wrote" and
187
+ * "every other tab has seen it".
188
+ */
189
+ declare const tick: () => Promise<void>;
190
+ /**
191
+ * Wait out the late-joiner snapshot window.
192
+ *
193
+ * A peer answers a newcomer's `hello` after a jittered pause, and only if
194
+ * nobody else already did — which is what turns N replies into one. Hydration
195
+ * is therefore not a microtask away: it costs up to `snapshotDelayMs`, 40 by
196
+ * default. One `tick()` is not enough, on purpose.
197
+ */
198
+ declare const snapshotWindow: (ms?: number) => Promise<void>;
199
+
200
+ export { FakeLockManager, FakeWindow, type Scenario, type ScenarioOptions, type Tab, type TabOptions, createScenario, fakeWindowPair, snapshotWindow, tick };
@@ -0,0 +1,200 @@
1
+ import { LockManagerLike, SharedStoreOptions, SharedStore, SharedReducerOptions, SharedReducer, MessageMap, ChannelOptions, Channel, PresenceOptions, Presence, LeaderOptions, Leader, PeerKind, WindowEventTarget, WindowLike } from '@use-everywhere/core';
2
+ import { MemoryHub } from '@use-everywhere/core/testing';
3
+ export { MemoryHub, MemoryTransport } from '@use-everywhere/core/testing';
4
+
5
+ /**
6
+ * A stand-in for `navigator.locks` with the three behaviours leadership rests
7
+ * on: exactly one holder per name, a FIFO queue that hands the lock on the
8
+ * instant the holder lets go, and — the one a fake usually misses — reclamation
9
+ * when the holder dies without releasing.
10
+ *
11
+ * One instance stands for "the browser", so every simulated tab queues against
12
+ * the others exactly as real ones would.
13
+ *
14
+ * ```ts
15
+ * const locks = new FakeLockManager();
16
+ * const leader = createLeader('cart', { locks, transport: () => hub.connect() });
17
+ * ```
18
+ */
19
+ declare class FakeLockManager {
20
+ private holds;
21
+ private queues;
22
+ private nextHoldId;
23
+ request(name: string, options: {
24
+ signal?: AbortSignal;
25
+ }, callback: () => Promise<void>): Promise<void>;
26
+ /**
27
+ * A view of this manager tagged with an owner, so `reclaim(owner)` can take
28
+ * the lock back the way a browser does when the tab holding it disappears.
29
+ */
30
+ forOwner(owner: string): LockManagerLike;
31
+ /**
32
+ * The tab named `owner` is gone: free every lock it holds and forget every
33
+ * lock it was waiting on.
34
+ *
35
+ * Its callbacks are never settled — that is the point. A crashed tab's code
36
+ * does not get to run again, and a test that asserts the *next* tab took the
37
+ * seat is asserting exactly what the browser guarantees.
38
+ */
39
+ reclaim(owner: string): void;
40
+ /** Is this lock held by anyone? */
41
+ isHeld(name: string): boolean;
42
+ /** Which owner holds it, if the request was tagged with one. */
43
+ holder(name: string): string | undefined;
44
+ /** How many callers are waiting behind the holder. */
45
+ queued(name: string): number;
46
+ private requestAs;
47
+ }
48
+
49
+ interface ScenarioOptions {
50
+ /**
51
+ * How leadership is arbitrated in this simulated browser. Default
52
+ * `'web-locks'`, matching every browser that has the API — and the only
53
+ * strategy where a crashed tab's seat is reclaimed by the platform rather
54
+ * than by a lease expiring.
55
+ *
56
+ * `'heartbeat'` runs the election that plain-http origins get. Test both if
57
+ * your app ships to one.
58
+ */
59
+ election?: 'web-locks' | 'heartbeat';
60
+ }
61
+ interface TabOptions {
62
+ /** A label for this tab. Defaults to `tab-1`, `tab-2`, … */
63
+ id?: string;
64
+ /** What this client announces itself as. Defaults to `'tab'`. */
65
+ kind?: PeerKind;
66
+ }
67
+ /**
68
+ * A simulated tab: a lifecycle group over the primitives created through it.
69
+ *
70
+ * Each primitive gets its own connection to the hub — the same shape a real tab
71
+ * has when it uses several bus names — so a presence and a store created on
72
+ * *one* name in one tab announce themselves as two clients. One primitive per
73
+ * name per tab, and the simulation matches a browser exactly.
74
+ */
75
+ interface Tab {
76
+ readonly id: string;
77
+ /** True once this tab has been closed or crashed. */
78
+ readonly gone: boolean;
79
+ store<S extends Record<string, unknown>>(name: string, initial: S, options?: SharedStoreOptions<S>): SharedStore<S>;
80
+ reducer<S, A>(name: string, reducer: (state: S, action: A) => S, initial: S, options?: SharedReducerOptions): SharedReducer<S, A>;
81
+ channel<M extends MessageMap>(name: string, options?: ChannelOptions<M>): Channel<M>;
82
+ presence(name: string, options?: PresenceOptions): Presence;
83
+ leader(name: string, options?: LeaderOptions): Leader;
84
+ /**
85
+ * Close this tab the way a user closes one: every primitive says goodbye,
86
+ * peers drop it from the roster at once, and any lock it held is released.
87
+ */
88
+ close(): void;
89
+ /**
90
+ * Kill this tab the way a crash does: the wire is cut mid-sentence, no
91
+ * goodbye is sent, and the locks it held are reclaimed by the platform.
92
+ *
93
+ * The difference from `close()` is the whole reason multi-tab code is hard —
94
+ * peers have to *notice*, rather than being told.
95
+ */
96
+ crash(): void;
97
+ }
98
+ interface Scenario {
99
+ /** The in-memory bus every tab in this scenario is connected to. */
100
+ readonly hub: MemoryHub;
101
+ /** The Web Locks stand-in shared by every tab. */
102
+ readonly locks: FakeLockManager;
103
+ /** Every tab created, in order, including the ones that are gone. */
104
+ readonly tabs: readonly Tab[];
105
+ /** Open another tab. */
106
+ tab(options?: TabOptions): Tab;
107
+ /**
108
+ * Let the wire catch up: drains microtasks, or waits `ms` when the thing you
109
+ * are waiting for is on a timer (a snapshot window, a lease, a probe).
110
+ */
111
+ settle(ms?: number): Promise<void>;
112
+ /** Close every tab that is still open. Safe to call twice. */
113
+ dispose(): void;
114
+ }
115
+
116
+ /**
117
+ * One simulated browser: a hub every tab shares, a Web Locks stand-in every tab
118
+ * queues on, and tabs that can be closed *or* crashed.
119
+ *
120
+ * ```ts
121
+ * const browser = createScenario();
122
+ * const a = browser.tab();
123
+ * const b = browser.tab();
124
+ *
125
+ * const cartA = a.store('cart', { items: 0 });
126
+ * const cartB = b.store('cart', { items: 0 });
127
+ *
128
+ * cartA.set('items', 3);
129
+ * await browser.settle();
130
+ * expect(cartB.getSnapshot().items).toBe(3);
131
+ * ```
132
+ *
133
+ * Nothing here touches globals: no `BroadcastChannel`, no `navigator.locks`, no
134
+ * timers you did not ask for. Several scenarios can run in one file, in
135
+ * parallel, without seeing each other.
136
+ */
137
+ declare function createScenario(options?: ScenarioOptions): Scenario;
138
+
139
+ type Listener = (event: {
140
+ data: unknown;
141
+ origin: string;
142
+ source: unknown;
143
+ }) => void;
144
+ /**
145
+ * One side of a fake window pair: listens like a `Window`, posts to its peer.
146
+ *
147
+ * Enough of the object model for `openWindow` and `connectToOpener` to run in a
148
+ * plain test process — including the parts the cross-origin handshake exists to
149
+ * defend: a message from the wrong origin, a message from an unrelated source,
150
+ * a child that closes mid-flow, and a child that loads too late to hear the
151
+ * first hello.
152
+ */
153
+ declare class FakeWindow implements WindowEventTarget, WindowLike {
154
+ closed: boolean;
155
+ origin: string;
156
+ peer: FakeWindow | null;
157
+ /** Messages wait here until `flush()` — a child that has not loaded yet. */
158
+ pending: Array<() => void>;
159
+ autoFlush: boolean;
160
+ private listeners;
161
+ constructor(origin: string);
162
+ addEventListener(type: string, listener: Listener): void;
163
+ removeEventListener(type: string, listener: Listener): void;
164
+ /** Called by the peer: deliver a message event to this window's listeners. */
165
+ postMessage(data: unknown, targetOrigin: string): void;
166
+ /**
167
+ * Deliver a message that did not come from the peer — an attacker page, or an
168
+ * unrelated widget on the same origin. The handshake must ignore it.
169
+ */
170
+ injectMessage(data: unknown, origin: string, source?: unknown): void;
171
+ /** Deliver everything held back while `autoFlush` was off. */
172
+ flush(): void;
173
+ /** Close this window, firing `pagehide` the way a real one does. */
174
+ close(): void;
175
+ }
176
+ /** Two fake windows wired to each other: the opener, and the window it opened. */
177
+ declare function fakeWindowPair(openerOrigin: string, childOrigin: string): {
178
+ opener: FakeWindow;
179
+ child: FakeWindow;
180
+ };
181
+
182
+ /**
183
+ * Drain pending microtasks — and the microtasks they queue.
184
+ *
185
+ * Delivery on a `BroadcastChannel`, and on the MemoryHub that stands in for it,
186
+ * is asynchronous. `await tick()` is the line between "this tab wrote" and
187
+ * "every other tab has seen it".
188
+ */
189
+ declare const tick: () => Promise<void>;
190
+ /**
191
+ * Wait out the late-joiner snapshot window.
192
+ *
193
+ * A peer answers a newcomer's `hello` after a jittered pause, and only if
194
+ * nobody else already did — which is what turns N replies into one. Hydration
195
+ * is therefore not a microtask away: it costs up to `snapshotDelayMs`, 40 by
196
+ * default. One `tick()` is not enough, on purpose.
197
+ */
198
+ declare const snapshotWindow: (ms?: number) => Promise<void>;
199
+
200
+ export { FakeLockManager, FakeWindow, type Scenario, type ScenarioOptions, type Tab, type TabOptions, createScenario, fakeWindowPair, snapshotWindow, tick };
package/dist/index.js ADDED
@@ -0,0 +1,278 @@
1
+ // src/scenario.ts
2
+ import {
3
+ createChannel,
4
+ createLeader,
5
+ createPresence,
6
+ createSharedReducer,
7
+ createSharedStore
8
+ } from "@use-everywhere/core";
9
+ import { MemoryHub } from "@use-everywhere/core/testing";
10
+
11
+ // src/fake-locks.ts
12
+ var abortError = () => new DOMException("The operation was aborted.", "AbortError");
13
+ var FakeLockManager = class {
14
+ constructor() {
15
+ this.holds = /* @__PURE__ */ new Map();
16
+ this.queues = /* @__PURE__ */ new Map();
17
+ this.nextHoldId = 1;
18
+ }
19
+ request(name, options, callback) {
20
+ return this.requestAs(void 0, name, options, callback);
21
+ }
22
+ /**
23
+ * A view of this manager tagged with an owner, so `reclaim(owner)` can take
24
+ * the lock back the way a browser does when the tab holding it disappears.
25
+ */
26
+ forOwner(owner) {
27
+ return {
28
+ request: (name, options, callback) => this.requestAs(owner, name, options, callback)
29
+ };
30
+ }
31
+ /**
32
+ * The tab named `owner` is gone: free every lock it holds and forget every
33
+ * lock it was waiting on.
34
+ *
35
+ * Its callbacks are never settled — that is the point. A crashed tab's code
36
+ * does not get to run again, and a test that asserts the *next* tab took the
37
+ * seat is asserting exactly what the browser guarantees.
38
+ */
39
+ reclaim(owner) {
40
+ for (const [name, hold] of [...this.holds]) {
41
+ if (hold.owner !== owner) continue;
42
+ this.holds.delete(name);
43
+ this.queues.get(name)?.shift()?.grant();
44
+ }
45
+ for (const queue of this.queues.values()) {
46
+ for (let at = queue.length - 1; at >= 0; at -= 1) {
47
+ if (queue[at].owner === owner) queue.splice(at, 1);
48
+ }
49
+ }
50
+ }
51
+ /** Is this lock held by anyone? */
52
+ isHeld(name) {
53
+ return this.holds.has(name);
54
+ }
55
+ /** Which owner holds it, if the request was tagged with one. */
56
+ holder(name) {
57
+ return this.holds.get(name)?.owner;
58
+ }
59
+ /** How many callers are waiting behind the holder. */
60
+ queued(name) {
61
+ return this.queues.get(name)?.length ?? 0;
62
+ }
63
+ requestAs(owner, name, options, callback) {
64
+ return new Promise((resolve, reject) => {
65
+ const grant = () => {
66
+ const hold = { owner, id: this.nextHoldId++ };
67
+ this.holds.set(name, hold);
68
+ void callback().catch(() => {
69
+ }).then(() => {
70
+ if (this.holds.get(name)?.id !== hold.id) {
71
+ resolve();
72
+ return;
73
+ }
74
+ this.holds.delete(name);
75
+ const next = this.queues.get(name)?.shift();
76
+ resolve();
77
+ next?.grant();
78
+ });
79
+ };
80
+ if (options.signal?.aborted) {
81
+ reject(abortError());
82
+ return;
83
+ }
84
+ if (!this.holds.has(name)) {
85
+ grant();
86
+ return;
87
+ }
88
+ const waiter = { owner, grant };
89
+ const queue = this.queues.get(name) ?? [];
90
+ queue.push(waiter);
91
+ this.queues.set(name, queue);
92
+ options.signal?.addEventListener("abort", () => {
93
+ const at = queue.indexOf(waiter);
94
+ if (at >= 0) {
95
+ queue.splice(at, 1);
96
+ reject(abortError());
97
+ }
98
+ });
99
+ });
100
+ }
101
+ };
102
+
103
+ // src/timing.ts
104
+ var tick = () => new Promise((resolve) => setTimeout(resolve, 0));
105
+ var snapshotWindow = (ms = 80) => new Promise((resolve) => setTimeout(resolve, ms));
106
+
107
+ // src/scenario.ts
108
+ var SimulatedTab = class {
109
+ constructor(id, hub, locks, election, kind) {
110
+ this.id = id;
111
+ this.hub = hub;
112
+ this.locks = locks;
113
+ this.election = election;
114
+ this.kind = kind;
115
+ this.wires = [];
116
+ this.created = [];
117
+ this.state = "open";
118
+ }
119
+ get gone() {
120
+ return this.state !== "open";
121
+ }
122
+ store(name, initial, options = {}) {
123
+ return this.track(createSharedStore(name, initial, { ...this.common(), ...options }));
124
+ }
125
+ reducer(name, reducer, initial, options = {}) {
126
+ return this.track(
127
+ createSharedReducer(name, reducer, initial, { ...this.common(), ...options })
128
+ );
129
+ }
130
+ channel(name, options = {}) {
131
+ return this.track(createChannel(name, { ...this.common(), ...options }));
132
+ }
133
+ presence(name, options = {}) {
134
+ return this.track(createPresence(name, { ...this.common(), ...options }));
135
+ }
136
+ leader(name, options = {}) {
137
+ const election = this.election === "web-locks" ? { strategy: "web-locks", locks: this.locks.forOwner(this.id) } : { strategy: "heartbeat" };
138
+ return this.track(createLeader(name, { ...this.common(), ...election, ...options }));
139
+ }
140
+ /**
141
+ * The difference between closing and crashing is the *order* these two lines
142
+ * run in, which is exactly the difference in a browser: a tab that closes
143
+ * gets its goodbye out before the wire goes, and a tab that crashes does not.
144
+ */
145
+ close() {
146
+ if (this.gone) return;
147
+ this.state = "closed";
148
+ for (const closeable of this.created) closeable.close();
149
+ for (const wire of this.wires) wire.close();
150
+ }
151
+ crash() {
152
+ if (this.gone) return;
153
+ this.state = "crashed";
154
+ for (const wire of this.wires) wire.close();
155
+ this.locks.reclaim(this.id);
156
+ }
157
+ /** Options every primitive in this tab shares: its own wire, its own kind. */
158
+ common() {
159
+ return {
160
+ transport: () => {
161
+ const wire = this.hub.connect();
162
+ this.wires.push(wire);
163
+ return wire;
164
+ },
165
+ // Spread-friendly: exactOptionalPropertyTypes rejects an explicit undefined.
166
+ ...this.kind ? { kind: this.kind } : {}
167
+ };
168
+ }
169
+ track(primitive) {
170
+ this.created.push(primitive);
171
+ return primitive;
172
+ }
173
+ };
174
+ function createScenario(options = {}) {
175
+ const hub = new MemoryHub();
176
+ const locks = new FakeLockManager();
177
+ const election = options.election ?? "web-locks";
178
+ const tabs = [];
179
+ return {
180
+ hub,
181
+ locks,
182
+ tabs,
183
+ tab(tabOptions = {}) {
184
+ const tab = new SimulatedTab(
185
+ tabOptions.id ?? `tab-${tabs.length + 1}`,
186
+ hub,
187
+ locks,
188
+ election,
189
+ tabOptions.kind
190
+ );
191
+ tabs.push(tab);
192
+ return tab;
193
+ },
194
+ settle(ms) {
195
+ return ms === void 0 ? tick() : new Promise((resolve) => setTimeout(resolve, ms));
196
+ },
197
+ dispose() {
198
+ for (const tab of tabs) tab.close();
199
+ }
200
+ };
201
+ }
202
+
203
+ // src/fake-window.ts
204
+ var FakeWindow = class {
205
+ constructor(origin) {
206
+ this.closed = false;
207
+ this.peer = null;
208
+ /** Messages wait here until `flush()` — a child that has not loaded yet. */
209
+ this.pending = [];
210
+ this.autoFlush = true;
211
+ this.listeners = /* @__PURE__ */ new Map();
212
+ this.origin = origin;
213
+ }
214
+ addEventListener(type, listener) {
215
+ let set = this.listeners.get(type);
216
+ if (!set) {
217
+ set = /* @__PURE__ */ new Set();
218
+ this.listeners.set(type, set);
219
+ }
220
+ set.add(listener);
221
+ }
222
+ removeEventListener(type, listener) {
223
+ this.listeners.get(type)?.delete(listener);
224
+ }
225
+ /** Called by the peer: deliver a message event to this window's listeners. */
226
+ postMessage(data, targetOrigin) {
227
+ if (targetOrigin !== "*" && targetOrigin !== this.origin) return;
228
+ const from = this.peer;
229
+ const deliver = () => {
230
+ if (this.closed) return;
231
+ for (const fn of this.listeners.get("message") ?? []) {
232
+ fn({ data, origin: from?.origin ?? this.origin, source: from });
233
+ }
234
+ };
235
+ if (this.autoFlush) queueMicrotask(deliver);
236
+ else this.pending.push(deliver);
237
+ }
238
+ /**
239
+ * Deliver a message that did not come from the peer — an attacker page, or an
240
+ * unrelated widget on the same origin. The handshake must ignore it.
241
+ */
242
+ injectMessage(data, origin, source = {}) {
243
+ for (const fn of this.listeners.get("message") ?? []) {
244
+ fn({ data, origin, source });
245
+ }
246
+ }
247
+ /** Deliver everything held back while `autoFlush` was off. */
248
+ flush() {
249
+ for (const deliver of this.pending.splice(0)) deliver();
250
+ }
251
+ /** Close this window, firing `pagehide` the way a real one does. */
252
+ close() {
253
+ this.closed = true;
254
+ for (const fn of this.listeners.get("pagehide") ?? []) {
255
+ fn({ data: void 0, origin: this.origin, source: this });
256
+ }
257
+ }
258
+ };
259
+ function fakeWindowPair(openerOrigin, childOrigin) {
260
+ const opener = new FakeWindow(openerOrigin);
261
+ const child = new FakeWindow(childOrigin);
262
+ opener.peer = child;
263
+ child.peer = opener;
264
+ return { opener, child };
265
+ }
266
+
267
+ // src/index.ts
268
+ import { MemoryHub as MemoryHub2, MemoryTransport } from "@use-everywhere/core/testing";
269
+ export {
270
+ FakeLockManager,
271
+ FakeWindow,
272
+ MemoryHub2 as MemoryHub,
273
+ MemoryTransport,
274
+ createScenario,
275
+ fakeWindowPair,
276
+ snapshotWindow,
277
+ tick
278
+ };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@use-everywhere/test-utils",
3
+ "version": "0.0.0",
4
+ "description": "Simulate several tabs in one process: a scenario DSL, an in-memory bus, fake windows and fake Web Locks",
5
+ "license": "MIT",
6
+ "author": "Jonatan Kruszewski <jonakrusze@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/rxova/use-everywhere.git",
10
+ "directory": "packages/test-utils"
11
+ },
12
+ "homepage": "https://github.com/rxova/use-everywhere#readme",
13
+ "bugs": "https://github.com/rxova/use-everywhere/issues",
14
+ "keywords": [
15
+ "testing",
16
+ "cross-tab",
17
+ "broadcastchannel",
18
+ "shared-state",
19
+ "use-everywhere"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "main": "./dist/index.cjs",
27
+ "module": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "import": {
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./dist/index.js"
34
+ },
35
+ "require": {
36
+ "types": "./dist/index.d.cts",
37
+ "default": "./dist/index.cjs"
38
+ }
39
+ }
40
+ },
41
+ "files": [
42
+ "dist"
43
+ ],
44
+ "dependencies": {
45
+ "@use-everywhere/core": "0.8.0"
46
+ },
47
+ "devDependencies": {
48
+ "@arethetypeswrong/cli": "^0.18.5",
49
+ "@vitest/coverage-v8": "^4.1.10",
50
+ "publint": "^0.3.21",
51
+ "tsup": "^8.5.1",
52
+ "typescript": "^6.0.3",
53
+ "vitest": "^4.1.10"
54
+ },
55
+ "scripts": {
56
+ "build": "tsup",
57
+ "test": "vitest run --coverage",
58
+ "typecheck": "tsc --noEmit",
59
+ "check:exports": "publint --strict && attw --pack .",
60
+ "pack:smoke": "node --import tsx ../tooling/pack-smoke.ts"
61
+ }
62
+ }