@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 +21 -0
- package/README.md +75 -0
- package/dist/index.cjs +306 -0
- package/dist/index.d.cts +200 -0
- package/dist/index.d.ts +200 -0
- package/dist/index.js +278 -0
- package/package.json +62 -0
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
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|