@deepseek-ai/dsh-client-test-runtime 0.1.6-alpha.1 → 0.1.7-alpha.1

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,
@@ -142,6 +232,7 @@ function workspaceSnapshot() {
142
232
  return {
143
233
  items: [],
144
234
  archivedSessionIds: [],
235
+ pinnedSessionIds: [],
145
236
  state: "idle",
146
237
  phase: "ready",
147
238
  error: null
@@ -286,23 +377,94 @@ var FixtureSession = class {
286
377
  throw new Error(`test session "${this.sessionId}": rename is not stubbed — supply it on the fixture's session face`);
287
378
  }
288
379
  };
380
+ function freezeRetainedBy(counts) {
381
+ Object.setPrototypeOf(counts, null);
382
+ return Object.freeze(counts);
383
+ }
384
+ const EMPTY_RETAIN_INFO = Object.freeze({
385
+ referenceCount: 0,
386
+ retainedBy: freezeRetainedBy({})
387
+ });
388
+ async function waitForOpen(opening, signal) {
389
+ /* v8 ignore next -- TestSessionReference always supplies its release-composed signal. */
390
+ if (signal === void 0) return opening;
391
+ const aborted = Promise.withResolvers();
392
+ const onAbort = () => {
393
+ aborted.reject(signal.reason);
394
+ };
395
+ signal.addEventListener("abort", onAbort, { once: true });
396
+ try {
397
+ if (signal.aborted) onAbort();
398
+ await Promise.race([opening, aborted.promise]);
399
+ } finally {
400
+ signal.removeEventListener("abort", onAbort);
401
+ }
402
+ }
403
+ var TestSessionReference = class {
404
+ sessionId;
405
+ generation;
406
+ releaseReference;
407
+ released = new AbortController();
408
+ readiness = Promise.withResolvers();
409
+ ready = this.readiness.promise;
410
+ constructor(sessionId, generation, releaseReference) {
411
+ this.sessionId = sessionId;
412
+ this.generation = generation;
413
+ this.releaseReference = releaseReference;
414
+ this.ready.catch(() => {});
415
+ }
416
+ get binding() {
417
+ if (this.generation === void 0 || !this.generation.live) throw new Error(`Session reference "${this.sessionId}" is released`);
418
+ return this.generation.binding;
419
+ }
420
+ attachOpening(opening, signal) {
421
+ const waitSignal = signal === void 0 ? this.released.signal : AbortSignal.any([this.released.signal, signal]);
422
+ waitForOpen(opening, waitSignal).then(() => {
423
+ try {
424
+ waitSignal.throwIfAborted();
425
+ this.readiness.resolve(this.binding);
426
+ } catch (error) {
427
+ this.readiness.reject(error);
428
+ }
429
+ }, (error) => {
430
+ this.readiness.reject(error);
431
+ });
432
+ }
433
+ release() {
434
+ const reason = /* @__PURE__ */ new Error(`Session reference "${this.sessionId}" is released`);
435
+ const release = this.releaseReference;
436
+ this.released.abort(reason);
437
+ this.readiness.reject(reason);
438
+ this.generation = void 0;
439
+ this.releaseReference = void 0;
440
+ release?.();
441
+ }
442
+ [Symbol.dispose]() {
443
+ this.release();
444
+ }
445
+ };
289
446
  /**
290
447
  * Sessions test double behind the renderer host and feature injects: owns the
291
- * list/current observable, scope minting through the production `createScope`,
448
+ * catalog observable, scope minting through the production `createScope`,
292
449
  * stable Controller bindings, and the session behavior face supplied per
293
450
  * fixture. `ui-session` owns standard-source materialization.
294
451
  *
295
452
  * Implements the same ISessions face features receive as `ctx.sessions`, so
296
453
  * a production face change breaks this double at compile time; the extra
297
- * members (add/updateSessionSnapshot/event-window drivers/setCurrent/remove/
454
+ * members (add/updateSessionSnapshot/event-window drivers/remove/
298
455
  * behavior/calls/stubs) are bench-only surface.
299
456
  */
300
457
  var TestSessions = class {
301
458
  stabilize;
302
459
  rootCtx;
303
- /** The useSessions standard feed (list rows + current selection). */
460
+ /** The useSessions catalog feed, independent of view ownership. */
304
461
  list;
305
462
  records = /* @__PURE__ */ new Map();
463
+ generations = /* @__PURE__ */ new Map();
464
+ addresses = /* @__PURE__ */ new Map();
465
+ retentionStores = /* @__PURE__ */ new Map();
466
+ pendingDrops = /* @__PURE__ */ new Set();
467
+ closed = false;
306
468
  /** Calls observed on the service-level face, newest last. */
307
469
  calls = [];
308
470
  /** The wire schema's `session.search` result bound (production parity). */
@@ -320,20 +482,27 @@ var TestSessions = class {
320
482
  this.list = createSnapshotStore({
321
483
  ids: [],
322
484
  byId: {},
323
- current: void 0,
324
485
  phase: "ready",
325
- subagentsByParent: {},
326
- jobsBySession: {},
327
- currentAddress: void 0
486
+ projectionsBySession: {}
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,66 +739,27 @@ 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;
560
- }
561
- /** Record catalog consumption; fixture callers drive snapshots explicitly. */
562
- setSubagentCatalogOpen(parentSessionId, open) {
563
- this.calls.push({
564
- method: "setSubagentCatalogOpen",
565
- args: [parentSessionId, open]
566
- });
744
+ const retained = this.addresses.get(id);
745
+ if (retained !== void 0) return retained;
746
+ for (const [parentSessionId, projections] of Object.entries(this.list.getSnapshot().projectionsBySession)) {
747
+ const child = projections.values.subagentCatalog?.find((entry) => entry.id === id);
748
+ if (child !== void 0) return {
749
+ parentSessionId,
750
+ childSessionId: id,
751
+ mode: child.mode
752
+ };
753
+ }
567
754
  }
568
- /** Record a catalog refresh; fixture callers drive snapshots explicitly. */
569
- refreshSubagents(parentSessionId) {
755
+ /** Record a projection refresh; fixture callers drive snapshots explicitly. */
756
+ refreshProjections(sessionId) {
570
757
  this.calls.push({
571
- method: "refreshSubagents",
572
- args: [parentSessionId]
758
+ method: "refreshProjections",
759
+ args: [sessionId]
573
760
  });
574
761
  return Promise.resolve();
575
762
  }
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
763
  /** Record a list refresh; fixture callers publish list state explicitly. */
588
764
  refresh() {
589
765
  this.calls.push({
@@ -640,28 +816,138 @@ var TestSessions = class {
640
816
  * @returns the FixtureSession carried by the Controller binding.
641
817
  */
642
818
  behavior(id) {
643
- return this.require(id).session;
819
+ return this.generations.get(id)?.session ?? this.require(id).session;
644
820
  }
645
821
  /** Dispose minted scope fibers (runtime dispose path). */
646
822
  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
- }
823
+ this.closed = true;
824
+ for (const [id, generation] of this.generations) this.drop(id, generation);
825
+ await this.drainDrops();
653
826
  }
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
827
+ resolveTarget(target) {
828
+ const id = typeof target === "string" ? target : target.childSessionId;
829
+ if (typeof target !== "string") this.addresses.set(id, target);
830
+ this.require(id);
831
+ return id;
832
+ }
833
+ retainGeneration(id, generation, source) {
834
+ const previous = generation.retention;
835
+ generation.retention = Object.freeze({
836
+ referenceCount: previous.referenceCount + 1,
837
+ retainedBy: freezeRetainedBy({
838
+ ...previous.retainedBy,
839
+ [source]: (previous.retainedBy[source] ?? 0) + 1
840
+ })
841
+ });
842
+ const reference = new TestSessionReference(id, generation, () => {
843
+ if (!generation.live) return;
844
+ const count = generation.retention.referenceCount - 1;
845
+ const { [source]: sourceCount = 0, ...otherSources } = generation.retention.retainedBy;
846
+ const retainedBy = sourceCount > 1 ? {
847
+ ...otherSources,
848
+ [source]: sourceCount - 1
849
+ } : otherSources;
850
+ generation.retention = count === 0 ? EMPTY_RETAIN_INFO : Object.freeze({
851
+ referenceCount: count,
852
+ retainedBy: freezeRetainedBy(retainedBy)
853
+ });
854
+ if (count === 0) this.drop(id, generation);
855
+ else this.publishRetention(id);
856
+ });
857
+ this.publishRetention(id);
858
+ return reference;
859
+ }
860
+ retentionSnapshot(id) {
861
+ return this.generations.get(id)?.retention ?? EMPTY_RETAIN_INFO;
862
+ }
863
+ publishRetention(id) {
864
+ const retention = this.retentionSnapshot(id);
865
+ const store = this.retentionStores.get(id);
866
+ if (store !== void 0 && store.getSnapshot() !== retention) store.set(retention);
867
+ const state = this.list.getSnapshot();
868
+ const row = state.byId[id];
869
+ if (row === void 0 || row.retainedBy === retention.retainedBy) return;
870
+ const summary = {
871
+ ...row,
872
+ retainedBy: retention.retainedBy
664
873
  };
874
+ const record = this.records.get(id);
875
+ /* v8 ignore next -- a catalog row and its fixture record are inserted and removed together. */
876
+ if (record !== void 0) record.summary = summary;
877
+ this.list.set({
878
+ ...state,
879
+ byId: {
880
+ ...state.byId,
881
+ [id]: summary
882
+ }
883
+ });
884
+ }
885
+ materialize(id, fixture) {
886
+ const { ctx, fiber } = createScope(this.rootCtx, id);
887
+ const snapshot = createSnapshotStore(fixture.snapshot.getSnapshot());
888
+ const session = new FixtureSession(id, snapshot, fixture.overrides);
889
+ const window = fixture.session.eventSource.getSnapshot();
890
+ session.eventSource.replace(window.entries, window.hasMore);
891
+ for (const [key, value] of fixture.projections) session.projections.set(key, value);
892
+ const opening = Promise.withResolvers();
893
+ opening.promise.catch(() => {});
894
+ const lifetime = new AbortController();
895
+ const generation = {
896
+ binding: {
897
+ sessionId: id,
898
+ session,
899
+ eventSource: session.eventSource,
900
+ ctx
901
+ },
902
+ snapshot,
903
+ session,
904
+ fiber,
905
+ lifetime,
906
+ opening: opening.promise,
907
+ retention: EMPTY_RETAIN_INFO,
908
+ live: true
909
+ };
910
+ this.generations.set(id, generation);
911
+ ctx.effect(() => async () => {
912
+ if (generation.live) {
913
+ generation.live = false;
914
+ generation.retention = EMPTY_RETAIN_INFO;
915
+ if (this.generations.get(id) === generation) this.generations.delete(id);
916
+ this.publishRetention(id);
917
+ }
918
+ lifetime.abort(/* @__PURE__ */ new Error(`test Session generation "${id}" is disposed`));
919
+ await Promise.allSettled([generation.opening]);
920
+ }, "test sessions: exact generation");
921
+ this.startOpening(fixture.initialOpen, lifetime.signal, opening);
922
+ return generation;
923
+ }
924
+ startOpening(initialOpen, signal, opening) {
925
+ try {
926
+ Promise.resolve(initialOpen?.(signal)).then(opening.resolve, opening.reject);
927
+ } catch (error) {
928
+ opening.reject(error);
929
+ }
930
+ }
931
+ drop(id, generation) {
932
+ /* v8 ignore next -- only the live generation's retained callback can enter drop. */
933
+ if (!generation.live) return;
934
+ generation.live = false;
935
+ generation.retention = EMPTY_RETAIN_INFO;
936
+ /* v8 ignore next -- this synchronous path drops only the generation currently stored for id. */
937
+ if (this.generations.get(id) === generation) this.generations.delete(id);
938
+ generation.lifetime.abort(/* @__PURE__ */ new Error(`test Session generation "${id}" is released`));
939
+ this.publishRetention(id);
940
+ const disposal = generation.fiber.dispose();
941
+ this.pendingDrops.add(disposal);
942
+ disposal.then(() => {
943
+ this.pendingDrops.delete(disposal);
944
+ }, (error) => {
945
+ this.pendingDrops.delete(disposal);
946
+ this.rootCtx.logger.warn("test Session scope disposal failed:", error);
947
+ });
948
+ }
949
+ async drainDrops() {
950
+ while (this.pendingDrops.size !== 0) await Promise.all([...this.pendingDrops]);
665
951
  }
666
952
  require(id) {
667
953
  const record = this.records.get(id);
@@ -732,6 +1018,19 @@ var TestWorkspaces = class {
732
1018
  };
733
1019
  }
734
1020
  /**
1021
+ * Initialize the default Workspace through a test stub; defaults to an ineligible first use.
1022
+ * @param request - initial directory name and title.
1023
+ * @param signal - caller lifetime.
1024
+ * @returns the stubbed Workspace, or undefined when initialization is ineligible.
1025
+ */
1026
+ async initializeDefault(request, signal) {
1027
+ this.calls.push({
1028
+ method: "initializeDefault",
1029
+ args: [request, signal]
1030
+ });
1031
+ return await this.stubs.get("initializeDefault")?.(request, signal);
1032
+ }
1033
+ /**
735
1034
  * Rename a Workspace (recorded). The default echoes a minimal view.
736
1035
  * @param workspaceId - target workspace.
737
1036
  * @param title - new title.
@@ -837,16 +1136,54 @@ var TestWorkspaces = class {
837
1136
  draft.archivedSessionIds = draft.archivedSessionIds.filter((id) => id !== sessionId);
838
1137
  });
839
1138
  }
1139
+ /**
1140
+ * Pin a session (recorded). The default mirrors the production face's
1141
+ * observable effect: the id leads the list state's pin set.
1142
+ * @param sessionId - session to pin.
1143
+ */
1144
+ async pinSession(sessionId) {
1145
+ this.calls.push({
1146
+ method: "pinSession",
1147
+ args: [sessionId]
1148
+ });
1149
+ const stub = this.stubs.get("pinSession");
1150
+ if (stub !== void 0) {
1151
+ await stub(sessionId);
1152
+ return;
1153
+ }
1154
+ await this.update((draft) => {
1155
+ draft.pinnedSessionIds = [sessionId, ...draft.pinnedSessionIds.filter((id) => id !== sessionId)];
1156
+ });
1157
+ }
1158
+ /**
1159
+ * Unpin a session (recorded). The default mirrors the production face's
1160
+ * observable effect: the id leaves the list state's pin set.
1161
+ * @param sessionId - session to unpin.
1162
+ */
1163
+ async unpinSession(sessionId) {
1164
+ this.calls.push({
1165
+ method: "unpinSession",
1166
+ args: [sessionId]
1167
+ });
1168
+ const stub = this.stubs.get("unpinSession");
1169
+ if (stub !== void 0) {
1170
+ await stub(sessionId);
1171
+ return;
1172
+ }
1173
+ await this.update((draft) => {
1174
+ draft.pinnedSessionIds = draft.pinnedSessionIds.filter((id) => id !== sessionId);
1175
+ });
1176
+ }
840
1177
  };
841
1178
  //#endregion
842
- //#region lib/types/settings-scope.js
843
- /** Test double for the client settings-scope seam. */
1179
+ //#region lib/types/config-form.js
1180
+ /** Test doubles for settings transport. */
844
1181
  /**
845
1182
  * Build an in-memory settings scope for service specs: starts in the host
846
1183
  * loading state, records writes, and lets the test publish Host acceptances.
847
1184
  * @returns the stub handle.
848
1185
  */
849
- function stubSettingsScope() {
1186
+ function stubConfigForm() {
850
1187
  let snapshot = {
851
1188
  status: "loading",
852
1189
  value: void 0,
@@ -857,9 +1194,9 @@ function stubSettingsScope() {
857
1194
  mode: "host"
858
1195
  };
859
1196
  const listeners = /* @__PURE__ */ new Set();
860
- const set = vi.fn(() => Promise.resolve());
861
- const mutate = vi.fn(() => Promise.resolve());
862
- const unset = vi.fn(() => Promise.resolve());
1197
+ const set = vi.fn(() => Promise.resolve(true));
1198
+ const mutate = vi.fn(() => Promise.resolve(true));
1199
+ const unset = vi.fn(() => Promise.resolve(true));
863
1200
  return {
864
1201
  scope: {
865
1202
  getSnapshot: () => snapshot,
@@ -887,80 +1224,6 @@ function stubSettingsScope() {
887
1224
  };
888
1225
  }
889
1226
  //#endregion
890
- //#region lib/types/remote.js
891
- /**
892
- * Remote service test double for the forwarded-event path. Feature specs need
893
- * `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded
894
- * Host events to reach those subscribers, but not the wire — so this double
895
- * implements subscription plus an explicit `emit` driver available only on the
896
- * concrete test object. A spec that also calls one namespace scripts it through
897
- * the constructor rather than reaching the real Client Remote service.
898
- *
899
- * `$mount` rejects: a spec that needs a real generated contribution installed —
900
- * codecs, descriptors, and the wire — has outgrown this double and needs the
901
- * real Client Remote service.
902
- *
903
- * One deliberate asymmetry with production: a throwing listener propagates out
904
- * of the emit instead of being contained and logged, so a spec cannot lean on
905
- * this double for the containment guarantee `$on` documents — assert that
906
- * against the real service.
907
- */
908
- var TestRemote = class TestRemote {
909
- subscriptions = /* @__PURE__ */ new Map();
910
- /**
911
- * Fixed Host facts mirrored from the production `ctx.remote.$host`. Plain
912
- * mutable field: a spec assigns it to script a non-loopback or homed Host.
913
- */
914
- $host = {
915
- home: void 0,
916
- isLoopback: true
917
- };
918
- /**
919
- * Register the double as `ctx.remote`, plus one service per scripted
920
- * namespace so a plugin injecting `remote.<name>` also unparks.
921
- * @param ctx - the spec's root Context.
922
- * @param namespaces - scripted namespace faces reached as `ctx.remote.<name>`.
923
- */
924
- constructor(ctx, namespaces = {}) {
925
- 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`);
926
- Object.assign(this, namespaces);
927
- ctx.provide("remote", this);
928
- for (const [name, face] of Object.entries(namespaces)) ctx.provide(`remote.${name}`, face);
929
- }
930
- /**
931
- * Deliver one forwarded host event to its subscribers, standing in for the
932
- * carrier that owns the frame sink.
933
- * @param event - forwarded host event name.
934
- * @param args - the Host argument list, verbatim.
935
- */
936
- emit(event, args) {
937
- const listeners = this.subscriptions.get(event);
938
- if (listeners === void 0) return;
939
- for (const listener of [...listeners]) listener(...args);
940
- }
941
- /**
942
- * Subscribe to one forwarded host event.
943
- * @param event - forwarded host event name.
944
- * @param listener - receives the Host argument list verbatim.
945
- * @returns disposer removing this subscription.
946
- */
947
- $on(event, listener) {
948
- const listeners = this.subscriptions.get(event) ?? /* @__PURE__ */ new Set();
949
- this.subscriptions.set(event, listeners);
950
- listeners.add(listener);
951
- return () => {
952
- listeners.delete(listener);
953
- };
954
- }
955
- /**
956
- * Generated-namespace mount, unsupported by this double.
957
- * @returns never; always rejects.
958
- */
959
- $mount() {
960
- return Promise.reject(/* @__PURE__ */ new Error("TestRemote: $mount needs the real Client Remote service"));
961
- }
962
- };
963
- //#endregion
964
1227
  //#region lib/types/translate.js
965
1228
  /**
966
1229
  * Test double of the locale lookup chain: a translate stub over plain
@@ -1146,8 +1409,10 @@ var SlotTestRuntime = class SlotTestRuntime {
1146
1409
  slots;
1147
1410
  /** The test-owned 'root' occupant. */
1148
1411
  root;
1149
- /** Sessions double (list/current observable, cells, scopes, behavior faces). */
1412
+ /** Fixture catalog, explicit references, scoped contexts, and behavior faces. */
1150
1413
  sessions;
1414
+ /** One Remote double shared by every feature mounted in this runtime. */
1415
+ remote;
1151
1416
  /** Workspaces double (list observable, recorded intent actions). */
1152
1417
  workspaces;
1153
1418
  /** Test-owned panel selection used by the framework's usePanelInfo hook. */
@@ -1174,6 +1439,7 @@ var SlotTestRuntime = class SlotTestRuntime {
1174
1439
  this.slots = slots;
1175
1440
  this.root = new TestRoot(slots, this.stabilizer);
1176
1441
  this.sessions = new TestSessions(this.stabilizer, ctx);
1442
+ this.remote = new TestRemote(ctx);
1177
1443
  this.workspaces = new TestWorkspaces(this.stabilizer);
1178
1444
  this.fileUpload = { upload: () => Promise.reject(/* @__PURE__ */ new Error("client test runtime: file upload is not stubbed")) };
1179
1445
  ctx.provide("sessions", this.sessions);
@@ -1262,7 +1528,17 @@ var SlotTestRuntime = class SlotTestRuntime {
1262
1528
  const cell = this.ownerCell;
1263
1529
  const AutoFrame = (props) => {
1264
1530
  useSyncExternalStore(cell.subscribe, cell.getVersion);
1265
- return createElement(Fragment, null, cell.entries().map(([key, { owner, opts }]) => createElement(Fragment, { key }, props.renderSlot(key, owner, opts))));
1531
+ return createElement(Fragment, null, cell.entries().map(([key, { owner, opts }]) => {
1532
+ const body = props.renderSlot(key, owner, opts);
1533
+ const spec = children[key];
1534
+ if (spec.scope === "root") return createElement(Fragment, { key }, body);
1535
+ return createElement(props.SessionProvider, {
1536
+ key,
1537
+ session: opts?.session,
1538
+ children: body,
1539
+ ...spec.scope === "session-maybe" ? { empty: () => body } : {}
1540
+ });
1541
+ }));
1266
1542
  };
1267
1543
  await this.root.declare(children, AutoFrame);
1268
1544
  }
@@ -1274,14 +1550,16 @@ var SlotTestRuntime = class SlotTestRuntime {
1274
1550
  * slot of the same tree.
1275
1551
  * @param key - a key declared through {@link SlotTestRuntime.declare}.
1276
1552
  * @param owner - owner props share for the render site.
1277
- * @param opts - explicit keyed or list selection; retained by view updates.
1553
+ * @param opts - explicit entry selection and borrowed Session reference; retained by view updates.
1278
1554
  * @returns the slot-local view (snapshot container, scoped queries, owner updates).
1279
1555
  */
1280
1556
  renderSlot(key, owner, opts) {
1281
1557
  if (!this.autoDeclared.has(key)) throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`);
1282
- const install = (next) => {
1558
+ let options = opts;
1559
+ const install = (next, nextOptions = options) => {
1560
+ options = nextOptions;
1283
1561
  act(() => {
1284
- this.ownerCell.set(key, next, opts);
1562
+ this.ownerCell.set(key, next, options);
1285
1563
  });
1286
1564
  };
1287
1565
  install(owner);
@@ -1300,20 +1578,33 @@ var SlotTestRuntime = class SlotTestRuntime {
1300
1578
  * {@link SlotTestRuntime.renderRoot} — the host face exists only inside the
1301
1579
  * installed renderer, exactly as in production.
1302
1580
  * @param key - slot key whose first entry declares the store.
1303
- * @param scopeKey - session id for session-scope slots; omit for root scope.
1581
+ * @param session - retained Session reference for session-scope slots; omit for root scope.
1304
1582
  * @returns the live store instance.
1305
1583
  */
1306
- storeOf(key, scopeKey) {
1584
+ storeOf(key, session) {
1307
1585
  if (this.host === void 0) throw new Error("storeOf before renderRoot() — the host face exists only inside the installed renderer");
1308
1586
  const entry = this.host.entriesOf(key)[0];
1309
1587
  if (entry === void 0) throw new Error(`storeOf('${key}'): no registration on the ledger`);
1310
- const scopeBinding = scopeKey === void 0 ? void 0 : this.host.scope("session")?.resolve(scopeKey);
1311
- if (scopeKey !== void 0 && scopeBinding === void 0) throw new Error(`storeOf('${key}'): no live Session binding for '${scopeKey}'`);
1588
+ const adapter = session === void 0 ? void 0 : this.host.scope("session");
1589
+ const resolved = session === void 0 ? void 0 : adapter?.bindingSource(session).getSnapshot();
1590
+ const scopeBinding = resolved?.key === void 0 ? void 0 : resolved;
1591
+ if (session !== void 0 && scopeBinding === void 0) throw new Error(`storeOf('${key}'): no live Session binding for '${session.sessionId}'`);
1312
1592
  const instance = this.host.storeOf(entry, scopeBinding);
1313
1593
  if (instance === void 0) throw new Error(`storeOf('${key}'): the entry declares no store`);
1314
1594
  return instance;
1315
1595
  }
1316
1596
  /**
1597
+ * Read one registered Factory definition for direct contract assertions.
1598
+ * @param name - registered Factory name.
1599
+ * @returns the live Factory definition.
1600
+ */
1601
+ factoryOf(name) {
1602
+ if (this.host === void 0) throw new Error("factoryOf before renderRoot()");
1603
+ const definition = this.host.factoryOf(name);
1604
+ if (definition === void 0) throw new Error(`factoryOf('${name}'): no definition`);
1605
+ return definition;
1606
+ }
1607
+ /**
1317
1608
  * Flush pending ledger/store notifications inside act — for mutations made
1318
1609
  * outside the runtime's own methods (e.g. a direct `slots.register`).
1319
1610
  * @returns completion of the act pass.
@@ -1337,8 +1628,9 @@ var SlotTestRuntime = class SlotTestRuntime {
1337
1628
  this.disposeWorkspaceSource();
1338
1629
  this.disposePanelInfoSource();
1339
1630
  await this.sessions.disposeScopes();
1631
+ await this.stabilizer(() => this.ctx.fiber.dispose());
1340
1632
  localStorage.clear();
1341
1633
  }
1342
1634
  };
1343
1635
  //#endregion
1344
- export { FixtureSession, RemoteError, SlotTestRuntime, TestRemote, TestRoot, TestSessions, TestWorkspaces, bindSnapshotSelector, chatSnapshot, conversationSnapshot, createSlotRenderer, domSnapshotSerializer, makeTranslate, registerDomSnapshotSerializer, sessionSnapshot, stubSettingsScope, usePinnedBrowserLanguages, workspaceSnapshot };
1636
+ export { FixtureSession, RemoteError, SlotTestRuntime, TestRemote, TestRoot, TestSessions, TestWorkspaces, bindSnapshotSelector, chatSnapshot, conversationSnapshot, createSlotRenderer, domSnapshotSerializer, makeTranslate, registerDomSnapshotSerializer, sessionSnapshot, stubConfigForm, usePinnedBrowserLanguages, workspaceSnapshot };