@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/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
- * list/current observable, scope minting through the production `createScope`,
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/setCurrent/remove/
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 standard feed (list rows + current selection). */
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 session from a fixture and (by default) make it current.
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, opts) {
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
- scope: void 0,
358
- scopeFiber: void 0,
359
- binding: void 0
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
- * Switch the current selection (undefined = the no-session empty state).
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
- const record = this.require(id);
624
+ this.require(id);
451
625
  this.records.delete(id);
452
- await this.stabilize(async () => {
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
- if (record.scopeFiber !== void 0) await record.scopeFiber.dispose();
632
+ this.generations.get(id)?.snapshot.update((draft) => {
633
+ draft.removed = true;
634
+ });
460
635
  });
461
636
  }
462
637
  /**
463
- * Resolve (mint on first touch) the session-scoped Cordis context through
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 for unknown sessions.
640
+ * @returns the scoped context, or undefined without a live reference.
468
641
  */
469
642
  scope(id) {
470
- const record = this.records.get(id);
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 sessionId + behavior face + scoped ctx, or undefined when unknown.
648
+ * @returns the live generation's binding, or undefined without a reference.
483
649
  */
484
650
  binding(id) {
485
- const record = this.records.get(id);
486
- if (record === void 0) return void 0;
487
- record.binding ??= this.bindingOf(id, record);
488
- return record.binding;
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
- return this.records.get(id)?.session;
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 an addressable binding. */
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 address = this.list.getSnapshot().currentAddress;
559
- return address?.childSessionId === id ? address : void 0;
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
- for (const record of this.records.values()) if (record.scopeFiber !== void 0) {
648
- await record.scopeFiber.dispose();
649
- record.scope = void 0;
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
- bindingOf(id, record) {
655
- const ctx = this.scope(id);
656
- /* v8 ignore next 2 -- bindingOf only runs for a live record, whose scope
657
- * always resolves; kept so a future caller cannot mint a ctx-less binding. */
658
- if (ctx === void 0) throw new Error(`test session "${id}" resolved no scope`);
659
- return {
660
- sessionId: id,
661
- session: record.session,
662
- eventSource: record.session.eventSource,
663
- ctx
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
- /** Sessions double (list/current observable, cells, scopes, behavior faces). */
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 }]) => createElement(Fragment, { key }, props.renderSlot(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 keyed or list selection; retained by view updates.
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
- const install = (next) => {
1514
+ let options = opts;
1515
+ const install = (next, nextOptions = options) => {
1516
+ options = nextOptions;
1322
1517
  act(() => {
1323
- this.ownerCell.set(key, next, opts);
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 scopeKey - session id for session-scope slots; omit for root scope.
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, scopeKey) {
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 scopeBinding = scopeKey === void 0 ? void 0 : this.host.scope("session")?.resolve(scopeKey);
1350
- if (scopeKey !== void 0 && scopeBinding === void 0) throw new Error(`storeOf('${key}'): no live Session binding for '${scopeKey}'`);
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, scriptedSettingsRemote, sessionSnapshot, stubSettingsScope, usePinnedBrowserLanguages, workspaceSnapshot };
1592
+ export { FixtureSession, RemoteError, SlotTestRuntime, TestRemote, TestRoot, TestSessions, TestWorkspaces, bindSnapshotSelector, chatSnapshot, conversationSnapshot, createSlotRenderer, domSnapshotSerializer, makeTranslate, registerDomSnapshotSerializer, sessionSnapshot, stubSettingsScope, usePinnedBrowserLanguages, workspaceSnapshot };