@deepseek-ai/dsh-client-test-runtime 0.1.5-rc.2 → 0.1.6-alpha.2
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/README.i18n.yaml +2 -2
- package/README.md +39 -4
- package/README.zh.md +47 -12
- package/lib/index.js +467 -258
- package/lib/types/assembly/bundle-roster.d.ts +15 -0
- package/lib/types/assembly/index.d.ts +15 -0
- package/lib/types/assembly/modules.d.ts +25 -0
- package/lib/types/assembly/remote-default-responses.d.ts +12 -0
- package/lib/types/assembly/remote-proxies.d.ts +20 -0
- package/lib/types/assembly/roster.d.ts +80 -0
- package/lib/types/assembly/test-client.d.ts +83 -0
- package/lib/types/assembly/vitest.d.ts +24 -0
- package/lib/types/fixtures.d.ts +2 -0
- package/lib/types/index.d.ts +22 -11
- package/lib/types/remote.d.ts +8 -0
- package/lib/types/sessions.d.ts +52 -38
- package/lib/types/workspaces.d.ts +6 -0
- package/package.json +54 -33
- package/lib/types/settings-remote.d.ts +0 -83
package/lib/index.js
CHANGED
|
@@ -7,10 +7,11 @@ import { createSlotRenderer as createSlotRenderer$1 } from "@deepseek-ai/dsh-cli
|
|
|
7
7
|
import { apply, inject } from "@deepseek-ai/dsh-client-ui-session/client";
|
|
8
8
|
import { createSnapshotStore } from "@deepseek-ai/dsh-client-store";
|
|
9
9
|
import { afterEach, beforeEach, expect, vi } from "vitest";
|
|
10
|
+
import { RemoteError } from "@deepseek-ai/dsh-typert-protocol";
|
|
10
11
|
import { MutableSessionEventSource, SESSION_SEARCH_RESULT_LIMIT, createScope, scopeOf } from "@deepseek-ai/dsh-api-session-controller/client";
|
|
12
|
+
import { scopeIdentityOf } from "@deepseek-ai/dsh-api-session-controller/src/client/scope.ts";
|
|
11
13
|
import { EMPTY_CONVERSATION_SNAPSHOT } from "@deepseek-ai/dsh-client-ui-conversation/client";
|
|
12
14
|
import { EMPTY_CHAT_SNAPSHOT } from "@deepseek-ai/dsh-client-ui-chat/client";
|
|
13
|
-
import { RemoteError } from "@deepseek-ai/dsh-typert-protocol";
|
|
14
15
|
//#region lib/types/snapshot.js
|
|
15
16
|
/**
|
|
16
17
|
* DOM snapshot hygiene: a vitest snapshot serializer that keeps `.snap`
|
|
@@ -87,6 +88,96 @@ function registerDomSnapshotSerializer() {
|
|
|
87
88
|
expect.addSnapshotSerializer(domSnapshotSerializer);
|
|
88
89
|
}
|
|
89
90
|
//#endregion
|
|
91
|
+
//#region lib/types/remote.js
|
|
92
|
+
/**
|
|
93
|
+
* Remote service test double for the forwarded-event path. Feature specs need
|
|
94
|
+
* `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded
|
|
95
|
+
* Host events to reach those subscribers, but not the wire — so this double
|
|
96
|
+
* implements subscription plus an explicit `emit` driver available only on the
|
|
97
|
+
* concrete test object. A spec that also calls one namespace scripts it through
|
|
98
|
+
* the constructor rather than reaching the real Client Remote service.
|
|
99
|
+
*
|
|
100
|
+
* `$mount` rejects: a spec that needs a real generated contribution installed —
|
|
101
|
+
* codecs, descriptors, and the wire — has outgrown this double and needs the
|
|
102
|
+
* real Client Remote service.
|
|
103
|
+
*
|
|
104
|
+
* One deliberate asymmetry with production: a throwing listener propagates out
|
|
105
|
+
* of the emit instead of being contained and logged, so a spec cannot lean on
|
|
106
|
+
* this double for the containment guarantee `$on` documents — assert that
|
|
107
|
+
* against the real service.
|
|
108
|
+
*/
|
|
109
|
+
var TestRemote = class {
|
|
110
|
+
ctx;
|
|
111
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
112
|
+
/**
|
|
113
|
+
* Fixed Host facts mirrored from the production `ctx.remote.$host`. Plain
|
|
114
|
+
* mutable field: a spec assigns it to script a non-loopback or homed Host.
|
|
115
|
+
*/
|
|
116
|
+
$host = {
|
|
117
|
+
home: void 0,
|
|
118
|
+
isLoopback: true
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Register the double as `ctx.remote`, plus one service per scripted
|
|
122
|
+
* namespace so a plugin injecting `remote.<name>` also unparks.
|
|
123
|
+
* @param ctx - the spec's root Context.
|
|
124
|
+
* @param namespaces - scripted namespace faces reached as `ctx.remote.<name>`.
|
|
125
|
+
*/
|
|
126
|
+
constructor(ctx, namespaces = {}) {
|
|
127
|
+
this.ctx = ctx;
|
|
128
|
+
this.validateNamespaces(namespaces);
|
|
129
|
+
ctx.provide("remote", this);
|
|
130
|
+
this.installNamespaces(namespaces);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Add scripted namespace faces to this Remote service.
|
|
134
|
+
* @param namespaces - scripted namespace faces reached as `ctx.remote.<name>`.
|
|
135
|
+
*/
|
|
136
|
+
provideNamespaces(namespaces) {
|
|
137
|
+
this.validateNamespaces(namespaces);
|
|
138
|
+
this.installNamespaces(namespaces);
|
|
139
|
+
}
|
|
140
|
+
validateNamespaces(namespaces) {
|
|
141
|
+
for (const name of Object.keys(namespaces)) if (name in this) throw new TypeError(`TestRemote: scripted namespace "${name}" would shadow the double's own member`);
|
|
142
|
+
}
|
|
143
|
+
installNamespaces(namespaces) {
|
|
144
|
+
Object.assign(this, namespaces);
|
|
145
|
+
for (const [name, face] of Object.entries(namespaces)) this.ctx.provide(`remote.${name}`, face);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Deliver one forwarded host event to its subscribers, standing in for the
|
|
149
|
+
* carrier that owns the frame sink.
|
|
150
|
+
* @param event - forwarded host event name.
|
|
151
|
+
* @param args - the Host argument list, verbatim.
|
|
152
|
+
*/
|
|
153
|
+
emit(event, args) {
|
|
154
|
+
const listeners = this.subscriptions.get(event);
|
|
155
|
+
if (listeners === void 0) return;
|
|
156
|
+
for (const listener of [...listeners]) listener(...args);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Subscribe to one forwarded host event.
|
|
160
|
+
* @param event - forwarded host event name.
|
|
161
|
+
* @param listener - receives the Host argument list verbatim.
|
|
162
|
+
* @returns disposer removing this subscription.
|
|
163
|
+
*/
|
|
164
|
+
$on(event, listener) {
|
|
165
|
+
const listeners = this.subscriptions.get(event) ?? /* @__PURE__ */ new Set();
|
|
166
|
+
this.subscriptions.set(event, listeners);
|
|
167
|
+
listeners.add(listener);
|
|
168
|
+
return () => {
|
|
169
|
+
listeners.delete(listener);
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Generated-namespace mount, unsupported by this double.
|
|
174
|
+
* @returns never; always rejects.
|
|
175
|
+
*/
|
|
176
|
+
$mount() {
|
|
177
|
+
return Promise.reject(/* @__PURE__ */ new Error("TestRemote: $mount needs the real Client Remote service"));
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
//#endregion
|
|
90
181
|
//#region lib/types/fixtures.js
|
|
91
182
|
/**
|
|
92
183
|
* A complete quiescent Session Controller snapshot.
|
|
@@ -96,7 +187,6 @@ function registerDomSnapshotSerializer() {
|
|
|
96
187
|
function sessionSnapshot(sessionId) {
|
|
97
188
|
return {
|
|
98
189
|
sessionId,
|
|
99
|
-
queue: [],
|
|
100
190
|
pendingSubmissions: [],
|
|
101
191
|
running: false,
|
|
102
192
|
subagent: null,
|
|
@@ -286,23 +376,94 @@ var FixtureSession = class {
|
|
|
286
376
|
throw new Error(`test session "${this.sessionId}": rename is not stubbed — supply it on the fixture's session face`);
|
|
287
377
|
}
|
|
288
378
|
};
|
|
379
|
+
function freezeRetainedBy(counts) {
|
|
380
|
+
Object.setPrototypeOf(counts, null);
|
|
381
|
+
return Object.freeze(counts);
|
|
382
|
+
}
|
|
383
|
+
const EMPTY_RETAIN_INFO = Object.freeze({
|
|
384
|
+
referenceCount: 0,
|
|
385
|
+
retainedBy: freezeRetainedBy({})
|
|
386
|
+
});
|
|
387
|
+
async function waitForOpen(opening, signal) {
|
|
388
|
+
/* v8 ignore next -- TestSessionReference always supplies its release-composed signal. */
|
|
389
|
+
if (signal === void 0) return opening;
|
|
390
|
+
const aborted = Promise.withResolvers();
|
|
391
|
+
const onAbort = () => {
|
|
392
|
+
aborted.reject(signal.reason);
|
|
393
|
+
};
|
|
394
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
395
|
+
try {
|
|
396
|
+
if (signal.aborted) onAbort();
|
|
397
|
+
await Promise.race([opening, aborted.promise]);
|
|
398
|
+
} finally {
|
|
399
|
+
signal.removeEventListener("abort", onAbort);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
var TestSessionReference = class {
|
|
403
|
+
sessionId;
|
|
404
|
+
generation;
|
|
405
|
+
releaseReference;
|
|
406
|
+
released = new AbortController();
|
|
407
|
+
readiness = Promise.withResolvers();
|
|
408
|
+
ready = this.readiness.promise;
|
|
409
|
+
constructor(sessionId, generation, releaseReference) {
|
|
410
|
+
this.sessionId = sessionId;
|
|
411
|
+
this.generation = generation;
|
|
412
|
+
this.releaseReference = releaseReference;
|
|
413
|
+
this.ready.catch(() => {});
|
|
414
|
+
}
|
|
415
|
+
get binding() {
|
|
416
|
+
if (this.generation === void 0 || !this.generation.live) throw new Error(`Session reference "${this.sessionId}" is released`);
|
|
417
|
+
return this.generation.binding;
|
|
418
|
+
}
|
|
419
|
+
attachOpening(opening, signal) {
|
|
420
|
+
const waitSignal = signal === void 0 ? this.released.signal : AbortSignal.any([this.released.signal, signal]);
|
|
421
|
+
waitForOpen(opening, waitSignal).then(() => {
|
|
422
|
+
try {
|
|
423
|
+
waitSignal.throwIfAborted();
|
|
424
|
+
this.readiness.resolve(this.binding);
|
|
425
|
+
} catch (error) {
|
|
426
|
+
this.readiness.reject(error);
|
|
427
|
+
}
|
|
428
|
+
}, (error) => {
|
|
429
|
+
this.readiness.reject(error);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
release() {
|
|
433
|
+
const reason = /* @__PURE__ */ new Error(`Session reference "${this.sessionId}" is released`);
|
|
434
|
+
const release = this.releaseReference;
|
|
435
|
+
this.released.abort(reason);
|
|
436
|
+
this.readiness.reject(reason);
|
|
437
|
+
this.generation = void 0;
|
|
438
|
+
this.releaseReference = void 0;
|
|
439
|
+
release?.();
|
|
440
|
+
}
|
|
441
|
+
[Symbol.dispose]() {
|
|
442
|
+
this.release();
|
|
443
|
+
}
|
|
444
|
+
};
|
|
289
445
|
/**
|
|
290
446
|
* Sessions test double behind the renderer host and feature injects: owns the
|
|
291
|
-
*
|
|
447
|
+
* catalog observable, scope minting through the production `createScope`,
|
|
292
448
|
* stable Controller bindings, and the session behavior face supplied per
|
|
293
449
|
* fixture. `ui-session` owns standard-source materialization.
|
|
294
450
|
*
|
|
295
451
|
* Implements the same ISessions face features receive as `ctx.sessions`, so
|
|
296
452
|
* a production face change breaks this double at compile time; the extra
|
|
297
|
-
* members (add/updateSessionSnapshot/event-window drivers/
|
|
453
|
+
* members (add/updateSessionSnapshot/event-window drivers/remove/
|
|
298
454
|
* behavior/calls/stubs) are bench-only surface.
|
|
299
455
|
*/
|
|
300
456
|
var TestSessions = class {
|
|
301
457
|
stabilize;
|
|
302
458
|
rootCtx;
|
|
303
|
-
/** The useSessions
|
|
459
|
+
/** The useSessions catalog feed, independent of view ownership. */
|
|
304
460
|
list;
|
|
305
461
|
records = /* @__PURE__ */ new Map();
|
|
462
|
+
generations = /* @__PURE__ */ new Map();
|
|
463
|
+
addresses = /* @__PURE__ */ new Map();
|
|
464
|
+
retentionStores = /* @__PURE__ */ new Map();
|
|
465
|
+
pendingDrops = /* @__PURE__ */ new Set();
|
|
466
|
+
closed = false;
|
|
306
467
|
/** Calls observed on the service-level face, newest last. */
|
|
307
468
|
calls = [];
|
|
308
469
|
/** The wire schema's `session.search` result bound (production parity). */
|
|
@@ -320,20 +481,28 @@ var TestSessions = class {
|
|
|
320
481
|
this.list = createSnapshotStore({
|
|
321
482
|
ids: [],
|
|
322
483
|
byId: {},
|
|
323
|
-
current: void 0,
|
|
324
484
|
phase: "ready",
|
|
325
485
|
subagentsByParent: {},
|
|
326
|
-
jobsBySession: {}
|
|
327
|
-
currentAddress: void 0
|
|
486
|
+
jobsBySession: {}
|
|
328
487
|
});
|
|
488
|
+
rootCtx.effect(() => async () => {
|
|
489
|
+
this.closed = true;
|
|
490
|
+
for (const [id, generation] of this.generations) {
|
|
491
|
+
generation.live = false;
|
|
492
|
+
generation.retention = EMPTY_RETAIN_INFO;
|
|
493
|
+
generation.lifetime.abort(/* @__PURE__ */ new Error("test Session Controller is disposed"));
|
|
494
|
+
this.publishRetention(id);
|
|
495
|
+
}
|
|
496
|
+
this.generations.clear();
|
|
497
|
+
await this.drainDrops();
|
|
498
|
+
}, "test sessions: Client generations");
|
|
329
499
|
}
|
|
330
500
|
/**
|
|
331
|
-
* Add a
|
|
501
|
+
* Add a Session fixture to the catalog without retaining a generation.
|
|
332
502
|
* @param fixture - identity + snapshot/summary overrides + behavior face.
|
|
333
|
-
* @param opts - pass `current: false` to add without selecting.
|
|
334
503
|
* @returns the stable session id (branded view of `fixture.id`).
|
|
335
504
|
*/
|
|
336
|
-
async add(fixture
|
|
505
|
+
async add(fixture) {
|
|
337
506
|
const id = fixture.id;
|
|
338
507
|
if (this.records.has(id)) throw new Error(`test session "${id}" already added`);
|
|
339
508
|
const summary = {
|
|
@@ -342,7 +511,8 @@ var TestSessions = class {
|
|
|
342
511
|
running: false,
|
|
343
512
|
blank: false,
|
|
344
513
|
updatedAt: this.records.size + 1,
|
|
345
|
-
...fixture.summary
|
|
514
|
+
...fixture.summary,
|
|
515
|
+
retainedBy: this.retentionSnapshot(id).retainedBy
|
|
346
516
|
};
|
|
347
517
|
const snapshot = createSnapshotStore({
|
|
348
518
|
...sessionSnapshot(id),
|
|
@@ -354,15 +524,14 @@ var TestSessions = class {
|
|
|
354
524
|
summary,
|
|
355
525
|
snapshot,
|
|
356
526
|
session,
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
527
|
+
overrides: fixture.session ?? {},
|
|
528
|
+
projections: /* @__PURE__ */ new Map(),
|
|
529
|
+
initialOpen: fixture.initialOpen
|
|
360
530
|
});
|
|
361
531
|
await this.stabilize(() => {
|
|
362
532
|
this.list.update((draft) => {
|
|
363
533
|
draft.ids.push(id);
|
|
364
534
|
draft.byId[id] = summary;
|
|
365
|
-
if (opts?.current !== false) draft.current = id;
|
|
366
535
|
});
|
|
367
536
|
});
|
|
368
537
|
return id;
|
|
@@ -376,6 +545,21 @@ var TestSessions = class {
|
|
|
376
545
|
const record = this.require(id);
|
|
377
546
|
await this.stabilize(() => {
|
|
378
547
|
record.snapshot.update(mutate);
|
|
548
|
+
this.generations.get(id)?.snapshot.set(record.snapshot.getSnapshot());
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Publish one complete projection value through the fixture Session face.
|
|
553
|
+
* @param id - session id.
|
|
554
|
+
* @param key - registered projection key.
|
|
555
|
+
* @param value - complete value for that key.
|
|
556
|
+
*/
|
|
557
|
+
async setProjection(id, key, value) {
|
|
558
|
+
const record = this.require(id);
|
|
559
|
+
record.projections.set(key, value);
|
|
560
|
+
await this.stabilize(() => {
|
|
561
|
+
record.session.projections.set(key, value);
|
|
562
|
+
this.generations.get(id)?.session.projections.set(key, value);
|
|
379
563
|
});
|
|
380
564
|
}
|
|
381
565
|
/**
|
|
@@ -387,6 +571,7 @@ var TestSessions = class {
|
|
|
387
571
|
async replaceEvents(id, entries, hasMore = false) {
|
|
388
572
|
await this.stabilize(() => {
|
|
389
573
|
this.require(id).session.eventSource.replace(entries, hasMore);
|
|
574
|
+
this.generations.get(id)?.session.eventSource.replace(entries, hasMore);
|
|
390
575
|
});
|
|
391
576
|
}
|
|
392
577
|
/**
|
|
@@ -398,6 +583,7 @@ var TestSessions = class {
|
|
|
398
583
|
async prependEvents(id, entries, hasMore = false) {
|
|
399
584
|
await this.stabilize(() => {
|
|
400
585
|
this.require(id).session.eventSource.prepend(entries, hasMore);
|
|
586
|
+
this.generations.get(id)?.session.eventSource.prepend(entries, hasMore);
|
|
401
587
|
});
|
|
402
588
|
}
|
|
403
589
|
/**
|
|
@@ -408,6 +594,7 @@ var TestSessions = class {
|
|
|
408
594
|
async appendEvent(id, entry) {
|
|
409
595
|
await this.stabilize(() => {
|
|
410
596
|
this.require(id).session.eventSource.append(entry);
|
|
597
|
+
this.generations.get(id)?.session.eventSource.append(entry);
|
|
411
598
|
});
|
|
412
599
|
}
|
|
413
600
|
/**
|
|
@@ -420,7 +607,8 @@ var TestSessions = class {
|
|
|
420
607
|
const record = this.require(id);
|
|
421
608
|
record.summary = {
|
|
422
609
|
...record.summary,
|
|
423
|
-
...patch
|
|
610
|
+
...patch,
|
|
611
|
+
retainedBy: this.retentionSnapshot(id).retainedBy
|
|
424
612
|
};
|
|
425
613
|
await this.stabilize(() => {
|
|
426
614
|
this.list.update((draft) => {
|
|
@@ -429,63 +617,89 @@ var TestSessions = class {
|
|
|
429
617
|
});
|
|
430
618
|
}
|
|
431
619
|
/**
|
|
432
|
-
*
|
|
433
|
-
* @param id - session id to select, or undefined to clear.
|
|
434
|
-
*/
|
|
435
|
-
async setCurrent(id) {
|
|
436
|
-
if (id !== void 0) this.require(id);
|
|
437
|
-
await this.stabilize(() => {
|
|
438
|
-
this.list.update((draft) => {
|
|
439
|
-
draft.current = id;
|
|
440
|
-
});
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
|
-
/**
|
|
444
|
-
* Remove a session: list row, scope fiber, and per-session store instances
|
|
445
|
-
* (with persisted state) die together — the same single lifecycle axis the
|
|
446
|
-
* production Client Sessions service drives on session death, minus staging.
|
|
620
|
+
* Remove a catalog row and mark its retained Session removed without releasing owners.
|
|
447
621
|
* @param id - session id.
|
|
448
622
|
*/
|
|
449
623
|
async remove(id) {
|
|
450
|
-
|
|
624
|
+
this.require(id);
|
|
451
625
|
this.records.delete(id);
|
|
452
|
-
await this.stabilize(
|
|
626
|
+
await this.stabilize(() => {
|
|
453
627
|
this.list.update((draft) => {
|
|
454
628
|
draft.ids = draft.ids.filter((existing) => existing !== id);
|
|
455
629
|
const { [id]: _dead, ...rest } = draft.byId;
|
|
456
630
|
draft.byId = rest;
|
|
457
|
-
if (draft.current === id) draft.current = void 0;
|
|
458
631
|
});
|
|
459
|
-
|
|
632
|
+
this.generations.get(id)?.snapshot.update((draft) => {
|
|
633
|
+
draft.removed = true;
|
|
634
|
+
});
|
|
460
635
|
});
|
|
461
636
|
}
|
|
462
637
|
/**
|
|
463
|
-
*
|
|
464
|
-
* the production `createScope`, so real `scopeOf`/scope-addressed services
|
|
465
|
-
* resolve it.
|
|
638
|
+
* Borrow the already-retained session-scoped Cordis context.
|
|
466
639
|
* @param id - session id.
|
|
467
|
-
* @returns the scoped context, or undefined
|
|
640
|
+
* @returns the scoped context, or undefined without a live reference.
|
|
468
641
|
*/
|
|
469
642
|
scope(id) {
|
|
470
|
-
|
|
471
|
-
if (record === void 0) return void 0;
|
|
472
|
-
if (record.scope === void 0) {
|
|
473
|
-
const handle = createScope(this.rootCtx, id);
|
|
474
|
-
record.scope = handle.ctx;
|
|
475
|
-
record.scopeFiber = handle.fiber;
|
|
476
|
-
}
|
|
477
|
-
return record.scope;
|
|
643
|
+
return this.generations.get(id)?.binding.ctx;
|
|
478
644
|
}
|
|
479
645
|
/**
|
|
480
646
|
* Session assembly binding (inject factories and provide resolvers receive it).
|
|
481
647
|
* @param id - session id.
|
|
482
|
-
* @returns
|
|
648
|
+
* @returns the live generation's binding, or undefined without a reference.
|
|
483
649
|
*/
|
|
484
650
|
binding(id) {
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
651
|
+
return this.generations.get(id)?.binding;
|
|
652
|
+
}
|
|
653
|
+
retain(target, options = { source: "testFixture" }) {
|
|
654
|
+
const { source, signal } = options;
|
|
655
|
+
signal?.throwIfAborted();
|
|
656
|
+
if (this.closed) throw new Error("test Session Controller is disposed");
|
|
657
|
+
const id = this.resolveTarget(target);
|
|
658
|
+
const generation = this.generations.get(id) ?? this.materialize(id, this.require(id));
|
|
659
|
+
const reference = this.retainGeneration(id, generation, source);
|
|
660
|
+
try {
|
|
661
|
+
reference.attachOpening(generation.opening, signal);
|
|
662
|
+
return reference;
|
|
663
|
+
} catch (error) {
|
|
664
|
+
reference.release();
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
async using(target, options, operation) {
|
|
669
|
+
const reference = this.retain(target, options);
|
|
670
|
+
try {
|
|
671
|
+
await reference.ready;
|
|
672
|
+
return await operation(reference);
|
|
673
|
+
} finally {
|
|
674
|
+
reference.release();
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
retainInfo(id) {
|
|
678
|
+
let store = this.retentionStores.get(id);
|
|
679
|
+
if (store === void 0) {
|
|
680
|
+
store = createSnapshotStore(this.retentionSnapshot(id));
|
|
681
|
+
this.retentionStores.set(id, store);
|
|
682
|
+
}
|
|
683
|
+
return store;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Retain one fixture Session until the supplied Cordis owner stops.
|
|
687
|
+
* @param ownerCtx - context whose disposal releases the reference.
|
|
688
|
+
* @param target - fixture Session identity or subagent address.
|
|
689
|
+
* @param options - reference source and optional readiness cancellation.
|
|
690
|
+
* @returns the owned reference immediately.
|
|
691
|
+
*/
|
|
692
|
+
retainFor(ownerCtx, target, options = { source: "testFixture" }) {
|
|
693
|
+
const reference = this.retain(target, options);
|
|
694
|
+
try {
|
|
695
|
+
ownerCtx.effect(() => () => {
|
|
696
|
+
reference.release();
|
|
697
|
+
}, "test sessions: owned reference");
|
|
698
|
+
return reference;
|
|
699
|
+
} catch (error) {
|
|
700
|
+
reference.release();
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
489
703
|
}
|
|
490
704
|
/**
|
|
491
705
|
* Read the session scope tag off a context (service-method boundary mirror).
|
|
@@ -504,7 +718,8 @@ var TestSessions = class {
|
|
|
504
718
|
sessionOf(ctx) {
|
|
505
719
|
const id = scopeOf(ctx);
|
|
506
720
|
if (id === void 0) return void 0;
|
|
507
|
-
|
|
721
|
+
const generation = this.generations.get(id);
|
|
722
|
+
return generation !== void 0 && scopeIdentityOf(generation.binding.ctx) === scopeIdentityOf(ctx) ? generation.session : void 0;
|
|
508
723
|
}
|
|
509
724
|
/**
|
|
510
725
|
* Install Session creation behavior for navigation tests.
|
|
@@ -513,7 +728,7 @@ var TestSessions = class {
|
|
|
513
728
|
stubCreate(impl) {
|
|
514
729
|
this.createStub = impl;
|
|
515
730
|
}
|
|
516
|
-
/** Create through the installed test behavior and require
|
|
731
|
+
/** Create through the installed test behavior and require a catalogued fixture. */
|
|
517
732
|
async create(opts) {
|
|
518
733
|
this.calls.push({
|
|
519
734
|
method: "create",
|
|
@@ -524,39 +739,18 @@ var TestSessions = class {
|
|
|
524
739
|
this.require(id);
|
|
525
740
|
return id;
|
|
526
741
|
}
|
|
527
|
-
/**
|
|
528
|
-
* Service-level selection call (recorded, then applied to the list store
|
|
529
|
-
* synchronously — inject callbacks call this outside any act window; the
|
|
530
|
-
* store notify is microtask-batched so the next stabilized step observes it).
|
|
531
|
-
* @param id - session id.
|
|
532
|
-
*/
|
|
533
|
-
open(id) {
|
|
534
|
-
this.calls.push({
|
|
535
|
-
method: "open",
|
|
536
|
-
args: [id]
|
|
537
|
-
});
|
|
538
|
-
this.require(id);
|
|
539
|
-
this.list.update((draft) => {
|
|
540
|
-
draft.current = id;
|
|
541
|
-
draft.currentAddress = void 0;
|
|
542
|
-
});
|
|
543
|
-
}
|
|
544
|
-
/** Open an existing fixture through its catalog address. */
|
|
545
|
-
openSubagent(address) {
|
|
546
|
-
this.calls.push({
|
|
547
|
-
method: "openSubagent",
|
|
548
|
-
args: [address]
|
|
549
|
-
});
|
|
550
|
-
this.require(address.childSessionId);
|
|
551
|
-
this.list.update((draft) => {
|
|
552
|
-
draft.current = address.childSessionId;
|
|
553
|
-
draft.currentAddress = address;
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
/** Resolve the current fixture's retained catalog address. */
|
|
742
|
+
/** Resolve a retained or catalog-derived address independently of a view. */
|
|
557
743
|
subagentAddress(id) {
|
|
558
|
-
const
|
|
559
|
-
|
|
744
|
+
const retained = this.addresses.get(id);
|
|
745
|
+
if (retained !== void 0) return retained;
|
|
746
|
+
for (const [parentSessionId, catalog] of Object.entries(this.list.getSnapshot().subagentsByParent)) {
|
|
747
|
+
const child = catalog.entries.find((entry) => entry.kind === "child" && entry.id === id);
|
|
748
|
+
if (child?.kind === "child") return {
|
|
749
|
+
parentSessionId,
|
|
750
|
+
childSessionId: id,
|
|
751
|
+
mode: child.mode
|
|
752
|
+
};
|
|
753
|
+
}
|
|
560
754
|
}
|
|
561
755
|
/** Record catalog consumption; fixture callers drive snapshots explicitly. */
|
|
562
756
|
setSubagentCatalogOpen(parentSessionId, open) {
|
|
@@ -573,17 +767,6 @@ var TestSessions = class {
|
|
|
573
767
|
});
|
|
574
768
|
return Promise.resolve();
|
|
575
769
|
}
|
|
576
|
-
/** Clear the current selection (recorded; the production no-session flow). */
|
|
577
|
-
clear() {
|
|
578
|
-
this.calls.push({
|
|
579
|
-
method: "clear",
|
|
580
|
-
args: []
|
|
581
|
-
});
|
|
582
|
-
this.list.update((draft) => {
|
|
583
|
-
draft.current = void 0;
|
|
584
|
-
draft.currentAddress = void 0;
|
|
585
|
-
});
|
|
586
|
-
}
|
|
587
770
|
/** Record a list refresh; fixture callers publish list state explicitly. */
|
|
588
771
|
refresh() {
|
|
589
772
|
this.calls.push({
|
|
@@ -640,28 +823,138 @@ var TestSessions = class {
|
|
|
640
823
|
* @returns the FixtureSession carried by the Controller binding.
|
|
641
824
|
*/
|
|
642
825
|
behavior(id) {
|
|
643
|
-
return this.require(id).session;
|
|
826
|
+
return this.generations.get(id)?.session ?? this.require(id).session;
|
|
644
827
|
}
|
|
645
828
|
/** Dispose minted scope fibers (runtime dispose path). */
|
|
646
829
|
async disposeScopes() {
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
record.scopeFiber = void 0;
|
|
651
|
-
record.binding = void 0;
|
|
652
|
-
}
|
|
830
|
+
this.closed = true;
|
|
831
|
+
for (const [id, generation] of this.generations) this.drop(id, generation);
|
|
832
|
+
await this.drainDrops();
|
|
653
833
|
}
|
|
654
|
-
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
834
|
+
resolveTarget(target) {
|
|
835
|
+
const id = typeof target === "string" ? target : target.childSessionId;
|
|
836
|
+
if (typeof target !== "string") this.addresses.set(id, target);
|
|
837
|
+
this.require(id);
|
|
838
|
+
return id;
|
|
839
|
+
}
|
|
840
|
+
retainGeneration(id, generation, source) {
|
|
841
|
+
const previous = generation.retention;
|
|
842
|
+
generation.retention = Object.freeze({
|
|
843
|
+
referenceCount: previous.referenceCount + 1,
|
|
844
|
+
retainedBy: freezeRetainedBy({
|
|
845
|
+
...previous.retainedBy,
|
|
846
|
+
[source]: (previous.retainedBy[source] ?? 0) + 1
|
|
847
|
+
})
|
|
848
|
+
});
|
|
849
|
+
const reference = new TestSessionReference(id, generation, () => {
|
|
850
|
+
if (!generation.live) return;
|
|
851
|
+
const count = generation.retention.referenceCount - 1;
|
|
852
|
+
const { [source]: sourceCount = 0, ...otherSources } = generation.retention.retainedBy;
|
|
853
|
+
const retainedBy = sourceCount > 1 ? {
|
|
854
|
+
...otherSources,
|
|
855
|
+
[source]: sourceCount - 1
|
|
856
|
+
} : otherSources;
|
|
857
|
+
generation.retention = count === 0 ? EMPTY_RETAIN_INFO : Object.freeze({
|
|
858
|
+
referenceCount: count,
|
|
859
|
+
retainedBy: freezeRetainedBy(retainedBy)
|
|
860
|
+
});
|
|
861
|
+
if (count === 0) this.drop(id, generation);
|
|
862
|
+
else this.publishRetention(id);
|
|
863
|
+
});
|
|
864
|
+
this.publishRetention(id);
|
|
865
|
+
return reference;
|
|
866
|
+
}
|
|
867
|
+
retentionSnapshot(id) {
|
|
868
|
+
return this.generations.get(id)?.retention ?? EMPTY_RETAIN_INFO;
|
|
869
|
+
}
|
|
870
|
+
publishRetention(id) {
|
|
871
|
+
const retention = this.retentionSnapshot(id);
|
|
872
|
+
const store = this.retentionStores.get(id);
|
|
873
|
+
if (store !== void 0 && store.getSnapshot() !== retention) store.set(retention);
|
|
874
|
+
const state = this.list.getSnapshot();
|
|
875
|
+
const row = state.byId[id];
|
|
876
|
+
if (row === void 0 || row.retainedBy === retention.retainedBy) return;
|
|
877
|
+
const summary = {
|
|
878
|
+
...row,
|
|
879
|
+
retainedBy: retention.retainedBy
|
|
664
880
|
};
|
|
881
|
+
const record = this.records.get(id);
|
|
882
|
+
/* v8 ignore next -- a catalog row and its fixture record are inserted and removed together. */
|
|
883
|
+
if (record !== void 0) record.summary = summary;
|
|
884
|
+
this.list.set({
|
|
885
|
+
...state,
|
|
886
|
+
byId: {
|
|
887
|
+
...state.byId,
|
|
888
|
+
[id]: summary
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
materialize(id, fixture) {
|
|
893
|
+
const { ctx, fiber } = createScope(this.rootCtx, id);
|
|
894
|
+
const snapshot = createSnapshotStore(fixture.snapshot.getSnapshot());
|
|
895
|
+
const session = new FixtureSession(id, snapshot, fixture.overrides);
|
|
896
|
+
const window = fixture.session.eventSource.getSnapshot();
|
|
897
|
+
session.eventSource.replace(window.entries, window.hasMore);
|
|
898
|
+
for (const [key, value] of fixture.projections) session.projections.set(key, value);
|
|
899
|
+
const opening = Promise.withResolvers();
|
|
900
|
+
opening.promise.catch(() => {});
|
|
901
|
+
const lifetime = new AbortController();
|
|
902
|
+
const generation = {
|
|
903
|
+
binding: {
|
|
904
|
+
sessionId: id,
|
|
905
|
+
session,
|
|
906
|
+
eventSource: session.eventSource,
|
|
907
|
+
ctx
|
|
908
|
+
},
|
|
909
|
+
snapshot,
|
|
910
|
+
session,
|
|
911
|
+
fiber,
|
|
912
|
+
lifetime,
|
|
913
|
+
opening: opening.promise,
|
|
914
|
+
retention: EMPTY_RETAIN_INFO,
|
|
915
|
+
live: true
|
|
916
|
+
};
|
|
917
|
+
this.generations.set(id, generation);
|
|
918
|
+
ctx.effect(() => async () => {
|
|
919
|
+
if (generation.live) {
|
|
920
|
+
generation.live = false;
|
|
921
|
+
generation.retention = EMPTY_RETAIN_INFO;
|
|
922
|
+
if (this.generations.get(id) === generation) this.generations.delete(id);
|
|
923
|
+
this.publishRetention(id);
|
|
924
|
+
}
|
|
925
|
+
lifetime.abort(/* @__PURE__ */ new Error(`test Session generation "${id}" is disposed`));
|
|
926
|
+
await Promise.allSettled([generation.opening]);
|
|
927
|
+
}, "test sessions: exact generation");
|
|
928
|
+
this.startOpening(fixture.initialOpen, lifetime.signal, opening);
|
|
929
|
+
return generation;
|
|
930
|
+
}
|
|
931
|
+
startOpening(initialOpen, signal, opening) {
|
|
932
|
+
try {
|
|
933
|
+
Promise.resolve(initialOpen?.(signal)).then(opening.resolve, opening.reject);
|
|
934
|
+
} catch (error) {
|
|
935
|
+
opening.reject(error);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
drop(id, generation) {
|
|
939
|
+
/* v8 ignore next -- only the live generation's retained callback can enter drop. */
|
|
940
|
+
if (!generation.live) return;
|
|
941
|
+
generation.live = false;
|
|
942
|
+
generation.retention = EMPTY_RETAIN_INFO;
|
|
943
|
+
/* v8 ignore next -- this synchronous path drops only the generation currently stored for id. */
|
|
944
|
+
if (this.generations.get(id) === generation) this.generations.delete(id);
|
|
945
|
+
generation.lifetime.abort(/* @__PURE__ */ new Error(`test Session generation "${id}" is released`));
|
|
946
|
+
this.publishRetention(id);
|
|
947
|
+
const disposal = generation.fiber.dispose();
|
|
948
|
+
this.pendingDrops.add(disposal);
|
|
949
|
+
disposal.then(() => {
|
|
950
|
+
this.pendingDrops.delete(disposal);
|
|
951
|
+
}, (error) => {
|
|
952
|
+
this.pendingDrops.delete(disposal);
|
|
953
|
+
this.rootCtx.logger.warn("test Session scope disposal failed:", error);
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
async drainDrops() {
|
|
957
|
+
while (this.pendingDrops.size !== 0) await Promise.all([...this.pendingDrops]);
|
|
665
958
|
}
|
|
666
959
|
require(id) {
|
|
667
960
|
const record = this.records.get(id);
|
|
@@ -818,6 +1111,25 @@ var TestWorkspaces = class {
|
|
|
818
1111
|
draft.archivedSessionIds = [...draft.archivedSessionIds, sessionId];
|
|
819
1112
|
});
|
|
820
1113
|
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Unarchive a session (recorded). The default mirrors the production face's
|
|
1116
|
+
* observable effect: the id leaves the list state's archive set.
|
|
1117
|
+
* @param sessionId - session to unarchive.
|
|
1118
|
+
*/
|
|
1119
|
+
async unarchiveSession(sessionId) {
|
|
1120
|
+
this.calls.push({
|
|
1121
|
+
method: "unarchiveSession",
|
|
1122
|
+
args: [sessionId]
|
|
1123
|
+
});
|
|
1124
|
+
const stub = this.stubs.get("unarchiveSession");
|
|
1125
|
+
if (stub !== void 0) {
|
|
1126
|
+
await stub(sessionId);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
await this.update((draft) => {
|
|
1130
|
+
draft.archivedSessionIds = draft.archivedSessionIds.filter((id) => id !== sessionId);
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
821
1133
|
};
|
|
822
1134
|
//#endregion
|
|
823
1135
|
//#region lib/types/settings-scope.js
|
|
@@ -868,135 +1180,6 @@ function stubSettingsScope() {
|
|
|
868
1180
|
};
|
|
869
1181
|
}
|
|
870
1182
|
//#endregion
|
|
871
|
-
//#region lib/types/settings-remote.js
|
|
872
|
-
/** Test double for the `settings` Remote namespace a bench's plugins inject. */
|
|
873
|
-
/**
|
|
874
|
-
* Build a scripted `settings` Remote namespace for a bench. Each write answers
|
|
875
|
-
* with the addressed namespace unchanged, so a bench that only needs its
|
|
876
|
-
* plugins to activate scripts nothing; one asserting a write reads the
|
|
877
|
-
* corresponding spy or replaces the face.
|
|
878
|
-
* @param namespaces - namespace views the first describe answers with.
|
|
879
|
-
* @param options - deployment facts the describe answer reports.
|
|
880
|
-
* @returns the face and its controls.
|
|
881
|
-
*/
|
|
882
|
-
function scriptedSettingsRemote(namespaces = [], options = {}) {
|
|
883
|
-
let served = namespaces;
|
|
884
|
-
const writable = options.writable ?? true;
|
|
885
|
-
const hasDocument = options.hasDocument ?? false;
|
|
886
|
-
const answer = (ns) => {
|
|
887
|
-
const view = served.find((candidate) => candidate.ns === ns);
|
|
888
|
-
return Promise.resolve(view === void 0 ? {
|
|
889
|
-
ok: false,
|
|
890
|
-
error: {
|
|
891
|
-
code: "settings/rejected",
|
|
892
|
-
message: `no scripted namespace "${ns}"`,
|
|
893
|
-
details: { ns }
|
|
894
|
-
}
|
|
895
|
-
} : {
|
|
896
|
-
ok: true,
|
|
897
|
-
value: view
|
|
898
|
-
});
|
|
899
|
-
};
|
|
900
|
-
const update = vi.fn((ns, _patch, _expectedRevision) => answer(ns));
|
|
901
|
-
const replace = vi.fn((ns, _section, _expectedRevision) => answer(ns));
|
|
902
|
-
const mutate = vi.fn((ns, _ops, _expectedRevision) => answer(ns));
|
|
903
|
-
return {
|
|
904
|
-
settings: {
|
|
905
|
-
describe: () => Promise.resolve({
|
|
906
|
-
ok: true,
|
|
907
|
-
value: {
|
|
908
|
-
writable,
|
|
909
|
-
hasDocument,
|
|
910
|
-
namespaces: served
|
|
911
|
-
}
|
|
912
|
-
}),
|
|
913
|
-
update: (ns, patch, expectedRevision) => update(ns, patch, expectedRevision),
|
|
914
|
-
replace: (ns, section, expectedRevision) => replace(ns, section, expectedRevision),
|
|
915
|
-
mutate: (ns, ops, expectedRevision) => mutate(ns, ops, expectedRevision)
|
|
916
|
-
},
|
|
917
|
-
update,
|
|
918
|
-
replace,
|
|
919
|
-
mutate,
|
|
920
|
-
publish(next) {
|
|
921
|
-
served = next;
|
|
922
|
-
}
|
|
923
|
-
};
|
|
924
|
-
}
|
|
925
|
-
//#endregion
|
|
926
|
-
//#region lib/types/remote.js
|
|
927
|
-
/**
|
|
928
|
-
* Remote service test double for the forwarded-event path. Feature specs need
|
|
929
|
-
* `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded
|
|
930
|
-
* Host events to reach those subscribers, but not the wire — so this double
|
|
931
|
-
* implements subscription plus an explicit `emit` driver available only on the
|
|
932
|
-
* concrete test object. A spec that also calls one namespace scripts it through
|
|
933
|
-
* the constructor rather than reaching the real Client Remote service.
|
|
934
|
-
*
|
|
935
|
-
* `$mount` rejects: a spec that needs a real generated contribution installed —
|
|
936
|
-
* codecs, descriptors, and the wire — has outgrown this double and needs the
|
|
937
|
-
* real Client Remote service.
|
|
938
|
-
*
|
|
939
|
-
* One deliberate asymmetry with production: a throwing listener propagates out
|
|
940
|
-
* of the emit instead of being contained and logged, so a spec cannot lean on
|
|
941
|
-
* this double for the containment guarantee `$on` documents — assert that
|
|
942
|
-
* against the real service.
|
|
943
|
-
*/
|
|
944
|
-
var TestRemote = class TestRemote {
|
|
945
|
-
subscriptions = /* @__PURE__ */ new Map();
|
|
946
|
-
/**
|
|
947
|
-
* Fixed Host facts mirrored from the production `ctx.remote.$host`. Plain
|
|
948
|
-
* mutable field: a spec assigns it to script a non-loopback or homed Host.
|
|
949
|
-
*/
|
|
950
|
-
$host = {
|
|
951
|
-
home: void 0,
|
|
952
|
-
isLoopback: true
|
|
953
|
-
};
|
|
954
|
-
/**
|
|
955
|
-
* Register the double as `ctx.remote`, plus one service per scripted
|
|
956
|
-
* namespace so a plugin injecting `remote.<name>` also unparks.
|
|
957
|
-
* @param ctx - the spec's root Context.
|
|
958
|
-
* @param namespaces - scripted namespace faces reached as `ctx.remote.<name>`.
|
|
959
|
-
*/
|
|
960
|
-
constructor(ctx, namespaces = {}) {
|
|
961
|
-
for (const name of Object.keys(namespaces)) if (name in TestRemote.prototype || name === "subscriptions" || name === "$host") throw new TypeError(`TestRemote: scripted namespace "${name}" would shadow the double's own member`);
|
|
962
|
-
Object.assign(this, namespaces);
|
|
963
|
-
ctx.provide("remote", this);
|
|
964
|
-
for (const [name, face] of Object.entries(namespaces)) ctx.provide(`remote.${name}`, face);
|
|
965
|
-
}
|
|
966
|
-
/**
|
|
967
|
-
* Deliver one forwarded host event to its subscribers, standing in for the
|
|
968
|
-
* carrier that owns the frame sink.
|
|
969
|
-
* @param event - forwarded host event name.
|
|
970
|
-
* @param args - the Host argument list, verbatim.
|
|
971
|
-
*/
|
|
972
|
-
emit(event, args) {
|
|
973
|
-
const listeners = this.subscriptions.get(event);
|
|
974
|
-
if (listeners === void 0) return;
|
|
975
|
-
for (const listener of [...listeners]) listener(...args);
|
|
976
|
-
}
|
|
977
|
-
/**
|
|
978
|
-
* Subscribe to one forwarded host event.
|
|
979
|
-
* @param event - forwarded host event name.
|
|
980
|
-
* @param listener - receives the Host argument list verbatim.
|
|
981
|
-
* @returns disposer removing this subscription.
|
|
982
|
-
*/
|
|
983
|
-
$on(event, listener) {
|
|
984
|
-
const listeners = this.subscriptions.get(event) ?? /* @__PURE__ */ new Set();
|
|
985
|
-
this.subscriptions.set(event, listeners);
|
|
986
|
-
listeners.add(listener);
|
|
987
|
-
return () => {
|
|
988
|
-
listeners.delete(listener);
|
|
989
|
-
};
|
|
990
|
-
}
|
|
991
|
-
/**
|
|
992
|
-
* Generated-namespace mount, unsupported by this double.
|
|
993
|
-
* @returns never; always rejects.
|
|
994
|
-
*/
|
|
995
|
-
$mount() {
|
|
996
|
-
return Promise.reject(/* @__PURE__ */ new Error("TestRemote: $mount needs the real Client Remote service"));
|
|
997
|
-
}
|
|
998
|
-
};
|
|
999
|
-
//#endregion
|
|
1000
1183
|
//#region lib/types/translate.js
|
|
1001
1184
|
/**
|
|
1002
1185
|
* Test double of the locale lookup chain: a translate stub over plain
|
|
@@ -1182,8 +1365,10 @@ var SlotTestRuntime = class SlotTestRuntime {
|
|
|
1182
1365
|
slots;
|
|
1183
1366
|
/** The test-owned 'root' occupant. */
|
|
1184
1367
|
root;
|
|
1185
|
-
/**
|
|
1368
|
+
/** Fixture catalog, explicit references, scoped contexts, and behavior faces. */
|
|
1186
1369
|
sessions;
|
|
1370
|
+
/** One Remote double shared by every feature mounted in this runtime. */
|
|
1371
|
+
remote;
|
|
1187
1372
|
/** Workspaces double (list observable, recorded intent actions). */
|
|
1188
1373
|
workspaces;
|
|
1189
1374
|
/** Test-owned panel selection used by the framework's usePanelInfo hook. */
|
|
@@ -1210,11 +1395,9 @@ var SlotTestRuntime = class SlotTestRuntime {
|
|
|
1210
1395
|
this.slots = slots;
|
|
1211
1396
|
this.root = new TestRoot(slots, this.stabilizer);
|
|
1212
1397
|
this.sessions = new TestSessions(this.stabilizer, ctx);
|
|
1398
|
+
this.remote = new TestRemote(ctx);
|
|
1213
1399
|
this.workspaces = new TestWorkspaces(this.stabilizer);
|
|
1214
|
-
this.fileUpload = {
|
|
1215
|
-
available: false,
|
|
1216
|
-
upload: () => Promise.reject(/* @__PURE__ */ new Error("client test runtime: file upload is not stubbed"))
|
|
1217
|
-
};
|
|
1400
|
+
this.fileUpload = { upload: () => Promise.reject(/* @__PURE__ */ new Error("client test runtime: file upload is not stubbed")) };
|
|
1218
1401
|
ctx.provide("sessions", this.sessions);
|
|
1219
1402
|
ctx.provide("workspaces", this.workspaces);
|
|
1220
1403
|
ctx.provide("fileUpload", this.fileUpload);
|
|
@@ -1301,7 +1484,17 @@ var SlotTestRuntime = class SlotTestRuntime {
|
|
|
1301
1484
|
const cell = this.ownerCell;
|
|
1302
1485
|
const AutoFrame = (props) => {
|
|
1303
1486
|
useSyncExternalStore(cell.subscribe, cell.getVersion);
|
|
1304
|
-
return createElement(Fragment, null, cell.entries().map(([key, { owner, opts }]) =>
|
|
1487
|
+
return createElement(Fragment, null, cell.entries().map(([key, { owner, opts }]) => {
|
|
1488
|
+
const body = props.renderSlot(key, owner, opts);
|
|
1489
|
+
const spec = children[key];
|
|
1490
|
+
if (spec.scope === "root") return createElement(Fragment, { key }, body);
|
|
1491
|
+
return createElement(props.SessionProvider, {
|
|
1492
|
+
key,
|
|
1493
|
+
session: opts?.session,
|
|
1494
|
+
children: body,
|
|
1495
|
+
...spec.scope === "session-maybe" ? { empty: () => body } : {}
|
|
1496
|
+
});
|
|
1497
|
+
}));
|
|
1305
1498
|
};
|
|
1306
1499
|
await this.root.declare(children, AutoFrame);
|
|
1307
1500
|
}
|
|
@@ -1313,14 +1506,16 @@ var SlotTestRuntime = class SlotTestRuntime {
|
|
|
1313
1506
|
* slot of the same tree.
|
|
1314
1507
|
* @param key - a key declared through {@link SlotTestRuntime.declare}.
|
|
1315
1508
|
* @param owner - owner props share for the render site.
|
|
1316
|
-
* @param opts - explicit
|
|
1509
|
+
* @param opts - explicit entry selection and borrowed Session reference; retained by view updates.
|
|
1317
1510
|
* @returns the slot-local view (snapshot container, scoped queries, owner updates).
|
|
1318
1511
|
*/
|
|
1319
1512
|
renderSlot(key, owner, opts) {
|
|
1320
1513
|
if (!this.autoDeclared.has(key)) throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`);
|
|
1321
|
-
|
|
1514
|
+
let options = opts;
|
|
1515
|
+
const install = (next, nextOptions = options) => {
|
|
1516
|
+
options = nextOptions;
|
|
1322
1517
|
act(() => {
|
|
1323
|
-
this.ownerCell.set(key, next,
|
|
1518
|
+
this.ownerCell.set(key, next, options);
|
|
1324
1519
|
});
|
|
1325
1520
|
};
|
|
1326
1521
|
install(owner);
|
|
@@ -1339,20 +1534,33 @@ var SlotTestRuntime = class SlotTestRuntime {
|
|
|
1339
1534
|
* {@link SlotTestRuntime.renderRoot} — the host face exists only inside the
|
|
1340
1535
|
* installed renderer, exactly as in production.
|
|
1341
1536
|
* @param key - slot key whose first entry declares the store.
|
|
1342
|
-
* @param
|
|
1537
|
+
* @param session - retained Session reference for session-scope slots; omit for root scope.
|
|
1343
1538
|
* @returns the live store instance.
|
|
1344
1539
|
*/
|
|
1345
|
-
storeOf(key,
|
|
1540
|
+
storeOf(key, session) {
|
|
1346
1541
|
if (this.host === void 0) throw new Error("storeOf before renderRoot() — the host face exists only inside the installed renderer");
|
|
1347
1542
|
const entry = this.host.entriesOf(key)[0];
|
|
1348
1543
|
if (entry === void 0) throw new Error(`storeOf('${key}'): no registration on the ledger`);
|
|
1349
|
-
const
|
|
1350
|
-
|
|
1544
|
+
const adapter = session === void 0 ? void 0 : this.host.scope("session");
|
|
1545
|
+
const resolved = session === void 0 ? void 0 : adapter?.bindingSource(session).getSnapshot();
|
|
1546
|
+
const scopeBinding = resolved?.key === void 0 ? void 0 : resolved;
|
|
1547
|
+
if (session !== void 0 && scopeBinding === void 0) throw new Error(`storeOf('${key}'): no live Session binding for '${session.sessionId}'`);
|
|
1351
1548
|
const instance = this.host.storeOf(entry, scopeBinding);
|
|
1352
1549
|
if (instance === void 0) throw new Error(`storeOf('${key}'): the entry declares no store`);
|
|
1353
1550
|
return instance;
|
|
1354
1551
|
}
|
|
1355
1552
|
/**
|
|
1553
|
+
* Read one registered Factory definition for direct contract assertions.
|
|
1554
|
+
* @param name - registered Factory name.
|
|
1555
|
+
* @returns the live Factory definition.
|
|
1556
|
+
*/
|
|
1557
|
+
factoryOf(name) {
|
|
1558
|
+
if (this.host === void 0) throw new Error("factoryOf before renderRoot()");
|
|
1559
|
+
const definition = this.host.factoryOf(name);
|
|
1560
|
+
if (definition === void 0) throw new Error(`factoryOf('${name}'): no definition`);
|
|
1561
|
+
return definition;
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1356
1564
|
* Flush pending ledger/store notifications inside act — for mutations made
|
|
1357
1565
|
* outside the runtime's own methods (e.g. a direct `slots.register`).
|
|
1358
1566
|
* @returns completion of the act pass.
|
|
@@ -1376,8 +1584,9 @@ var SlotTestRuntime = class SlotTestRuntime {
|
|
|
1376
1584
|
this.disposeWorkspaceSource();
|
|
1377
1585
|
this.disposePanelInfoSource();
|
|
1378
1586
|
await this.sessions.disposeScopes();
|
|
1587
|
+
await this.stabilizer(() => this.ctx.fiber.dispose());
|
|
1379
1588
|
localStorage.clear();
|
|
1380
1589
|
}
|
|
1381
1590
|
};
|
|
1382
1591
|
//#endregion
|
|
1383
|
-
export { FixtureSession, RemoteError, SlotTestRuntime, TestRemote, TestRoot, TestSessions, TestWorkspaces, bindSnapshotSelector, chatSnapshot, conversationSnapshot, createSlotRenderer, domSnapshotSerializer, makeTranslate, registerDomSnapshotSerializer,
|
|
1592
|
+
export { FixtureSession, RemoteError, SlotTestRuntime, TestRemote, TestRoot, TestSessions, TestWorkspaces, bindSnapshotSelector, chatSnapshot, conversationSnapshot, createSlotRenderer, domSnapshotSerializer, makeTranslate, registerDomSnapshotSerializer, sessionSnapshot, stubSettingsScope, usePinnedBrowserLanguages, workspaceSnapshot };
|