@deepseek-ai/dsh-client-test-runtime 0.1.1-rc.2 → 0.1.2-alpha.3

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
@@ -1,11 +1,16 @@
1
1
  import { Context, Inject } from "@deepseek-ai/cordis";
2
2
  import { Fragment, createElement, useSyncExternalStore } from "react";
3
3
  import { act, render, within } from "@testing-library/react";
4
- import { ConversationEventRegistry, ConversationViewRegistry, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, SessionProvideChannel, SlotRegistry, createScope, createSnapshotStore, scopeOf } from "@deepseek-ai/dsh-client-runtime/client";
4
+ import { SlotRegistry } from "@deepseek-ai/dsh-client-ui-renderer/client";
5
5
  import { bindSnapshotSelector as bindSnapshotSelector$1 } from "@deepseek-ai/dsh-client-ui-renderer/src/client/bind.ts";
6
6
  import { createSlotRenderer as createSlotRenderer$1 } from "@deepseek-ai/dsh-client-ui-renderer/src/client/scoped-slots.tsx";
7
+ import { apply, inject } from "@deepseek-ai/dsh-client-ui-session/client";
7
8
  import { afterEach, beforeEach, expect, vi } from "vitest";
8
- import { SESSION_SEARCH_RESULT_LIMIT } from "@deepseek-ai/dsh-host-apiproxy/api";
9
+ import { MutableSessionEventSource, SESSION_SEARCH_RESULT_LIMIT, createScope, scopeOf } from "@deepseek-ai/dsh-api-session-controller/client";
10
+ import { createSnapshotStore } from "@deepseek-ai/dsh-client-store";
11
+ import { EMPTY_CONVERSATION_SNAPSHOT } from "@deepseek-ai/dsh-client-ui-conversation/client";
12
+ import { EMPTY_CHAT_SNAPSHOT } from "@deepseek-ai/dsh-client-ui-chat/client";
13
+ import { RemoteError } from "@deepseek-ai/dsh-typert-protocol";
9
14
  //#region lib/types/snapshot.js
10
15
  /**
11
16
  * DOM snapshot hygiene: a vitest snapshot serializer that keeps `.snap`
@@ -84,25 +89,17 @@ function registerDomSnapshotSerializer() {
84
89
  //#endregion
85
90
  //#region lib/types/fixtures.js
86
91
  /**
87
- * A complete quiescent conversation snapshot (open window, no traffic).
92
+ * A complete quiescent Session Controller snapshot.
88
93
  * @param sessionId - owning session id.
89
94
  * @returns the snapshot; spread fixture overrides on top.
90
95
  */
91
- function conversationSnapshot(sessionId) {
96
+ function sessionSnapshot(sessionId) {
92
97
  return {
93
98
  sessionId,
94
- views: EMPTY_CONVERSATION_VIEWS,
95
- chat: EMPTY_CHAT_SNAPSHOT,
96
- nodes: [],
97
- turnTimings: /* @__PURE__ */ new Map(),
98
- turnEnds: /* @__PURE__ */ new Map(),
99
- partial: null,
100
- runningCalls: [],
101
- pending: [],
102
99
  queue: [],
100
+ pendingSubmissions: [],
103
101
  running: false,
104
102
  subagent: null,
105
- composerPhase: "active",
106
103
  removed: false,
107
104
  openState: "open",
108
105
  openError: null,
@@ -110,30 +107,51 @@ function conversationSnapshot(sessionId) {
110
107
  loadingOlder: false,
111
108
  promptError: null,
112
109
  blank: false,
113
- lastAgentError: null
110
+ lastAgentError: null,
111
+ promptAttempted: false,
112
+ awaitingFirstTurn: false
114
113
  };
115
114
  }
116
115
  /**
117
- * A ready workspace list with no workspaces (the shape WorkspaceRuntime
118
- * projects after both baselines land).
119
- * @returns the initial state of the test workspaces store.
116
+ * A target-neutral Conversation snapshot.
117
+ * @param overrides - target roster or activity overrides.
118
+ * @returns an immutable fixture value.
120
119
  */
121
- function workspaceListState() {
120
+ function conversationSnapshot(overrides = {}) {
121
+ return {
122
+ ...EMPTY_CONVERSATION_SNAPSHOT,
123
+ ...overrides
124
+ };
125
+ }
126
+ /**
127
+ * A Chat target snapshot.
128
+ * @param overrides - Chat target overrides.
129
+ * @returns an immutable fixture value.
130
+ */
131
+ function chatSnapshot(overrides = {}) {
132
+ return {
133
+ ...EMPTY_CHAT_SNAPSHOT,
134
+ ...overrides
135
+ };
136
+ }
137
+ /**
138
+ * A ready Workspace Controller snapshot with no Workspace rows.
139
+ * @returns the initial state of the test Workspace source.
140
+ */
141
+ function workspaceSnapshot() {
122
142
  return {
123
143
  items: [],
124
144
  archivedSessionIds: [],
125
145
  state: "idle",
126
146
  phase: "ready",
127
- error: null,
128
- baselinesReady: true,
129
- recentWorkspaceId: void 0
147
+ error: null
130
148
  };
131
149
  }
132
150
  //#endregion
133
151
  //#region lib/types/sessions.js
134
152
  /**
135
- * The fixture-backed session face: conversation reads delegate to the
136
- * fixture's snapshot store; ISession verbs are fail-loud stubs unless the
153
+ * The fixture-backed session face: lifecycle reads delegate to the fixture's
154
+ * snapshot store; Session verbs are fail-loud stubs unless the
137
155
  * fixture supplies them (the runtime never fakes behavior a test did not
138
156
  * declare — an unstubbed call names itself instead of half-working). Extra
139
157
  * fixture methods are grafted verbatim for feature-side casts.
@@ -141,14 +159,15 @@ function workspaceListState() {
141
159
  var FixtureSession = class {
142
160
  sessionId;
143
161
  store;
162
+ /** Mutable event source consumed only by Conversation assembly. */
163
+ eventSource = new MutableSessionEventSource();
144
164
  /**
145
- * The useProjection seat: identity-stable per-key faces over the fixture's
146
- * projection values (set via {@link TestSessions.setProjection}).
165
+ * Identity-stable per-key faces over fixture-controlled projection values.
147
166
  */
148
167
  projections;
149
168
  /**
150
169
  * @param sessionId - host identity (branded view of the fixture id).
151
- * @param store - conversation snapshot store (updateSnapshot writes it).
170
+ * @param store - Session Controller snapshot store.
152
171
  * @param overrides - fixture-declared behavior face, grafted over the stubs.
153
172
  */
154
173
  constructor(sessionId, store, overrides) {
@@ -183,7 +202,7 @@ var FixtureSession = class {
183
202
  };
184
203
  Object.assign(this, overrides);
185
204
  }
186
- /** @returns the fixture conversation snapshot (useSession read side). */
205
+ /** @returns the fixture Session Controller snapshot (useSession read side). */
187
206
  getSnapshot() {
188
207
  return this.store.getSnapshot();
189
208
  }
@@ -203,6 +222,20 @@ var FixtureSession = class {
203
222
  throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`);
204
223
  }
205
224
  /**
225
+ * Minimal local-echo registration: mints an identity without touching the
226
+ * fixture snapshot (submission echoes are client-only presentation state).
227
+ * Supply `beginSubmission` on the fixture's session face to observe echoes.
228
+ * @returns a handle whose abandon is a no-op.
229
+ */
230
+ beginSubmission() {
231
+ this.submissionSeq += 1;
232
+ return {
233
+ requestId: `test-submission-${this.submissionSeq}`,
234
+ abandon: () => {}
235
+ };
236
+ }
237
+ submissionSeq = 0;
238
+ /**
206
239
  * Fail-loud stub; supply `readAttachment` on the fixture's session face to exercise it.
207
240
  * @param _attachmentId - opaque durable attachment id.
208
241
  * @returns never — always throws.
@@ -239,6 +272,13 @@ var FixtureSession = class {
239
272
  throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`);
240
273
  }
241
274
  /**
275
+ * Fail-loud stub; supply `loadThrough` on the fixture's session face to exercise it.
276
+ * @returns never — always throws.
277
+ */
278
+ loadThrough() {
279
+ throw new Error(`test session "${this.sessionId}": loadThrough is not stubbed — supply it on the fixture's session face`);
280
+ }
281
+ /**
242
282
  * Fail-loud stub; supply `rename` on the fixture's session face to exercise it.
243
283
  * @returns never — always throws.
244
284
  */
@@ -248,36 +288,28 @@ var FixtureSession = class {
248
288
  };
249
289
  /**
250
290
  * Sessions test double behind the renderer host and feature injects: owns the
251
- * list/current observable, the standard-props provide channel (the runtime's
252
- * `useSession` contribution included), scope minting through the production
253
- * `createScope`, and the session behavior face supplied per fixture.
291
+ * list/current observable, scope minting through the production `createScope`,
292
+ * stable Controller bindings, and the session behavior face supplied per
293
+ * fixture. `ui-session` owns standard-source materialization.
254
294
  *
255
295
  * Implements the same ISessions face features receive as `ctx.sessions`, so
256
296
  * a production face change breaks this double at compile time; the extra
257
- * members (add/updateSnapshot/setCurrent/remove/behavior/calls/stubSearch and
258
- * the legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
297
+ * members (add/updateSessionSnapshot/event-window drivers/setCurrent/remove/
298
+ * behavior/calls/stubs) are bench-only surface.
259
299
  */
260
300
  var TestSessions = class {
261
301
  stabilize;
262
302
  rootCtx;
263
303
  /** The useSessions standard feed (list rows + current selection). */
264
304
  list;
265
- /**
266
- * Atomic current-session provide projection (production SessionRuntime
267
- * mirror): selection changes and provider-roster changes publish through
268
- * this one source — the member the SlotRegistry host face hands the
269
- * renderer's SessionProvider.
270
- */
271
- currentProvideInfo;
272
305
  records = /* @__PURE__ */ new Map();
273
- /** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
274
- channel;
275
306
  /** Calls observed on the service-level face, newest last. */
276
307
  calls = [];
277
308
  /** The wire schema's `session.search` result bound (production parity). */
278
309
  searchResultLimit = SESSION_SEARCH_RESULT_LIMIT;
279
310
  /** Replaceable search behavior (see {@link TestSessions.stubSearch}). */
280
311
  searchStub;
312
+ createStub;
281
313
  /**
282
314
  * @param stabilize - the owning runtime's act wrapper.
283
315
  * @param rootCtx - the runtime's Cordis root; scope fibers mount under it.
@@ -294,16 +326,6 @@ var TestSessions = class {
294
326
  jobsBySession: {},
295
327
  currentAddress: void 0
296
328
  });
297
- this.channel = new SessionProvideChannel({
298
- rebuildBundles: () => {
299
- for (const record of this.records.values()) if (record.provideInfo !== void 0) record.provideInfo = this.channel.materializeInfo(this.bindingOf(record.session.sessionId, record));
300
- },
301
- resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current)
302
- });
303
- this.currentProvideInfo = this.channel.currentProvideInfo;
304
- this.list.subscribe(() => {
305
- this.channel.publishCurrent();
306
- });
307
329
  }
308
330
  /**
309
331
  * Add a session from a fixture and (by default) make it current.
@@ -323,16 +345,18 @@ var TestSessions = class {
323
345
  ...fixture.summary
324
346
  };
325
347
  const snapshot = createSnapshotStore({
326
- ...conversationSnapshot(id),
348
+ ...sessionSnapshot(id),
327
349
  ...fixture.snapshot
328
350
  });
351
+ const session = new FixtureSession(id, snapshot, fixture.session ?? {});
352
+ if (fixture.events !== void 0 || fixture.hasMore === true) session.eventSource.replace(fixture.events ?? [], fixture.hasMore ?? false);
329
353
  this.records.set(id, {
330
354
  summary,
331
355
  snapshot,
332
- session: new FixtureSession(id, snapshot, fixture.session ?? {}),
356
+ session,
333
357
  scope: void 0,
334
358
  scopeFiber: void 0,
335
- provideInfo: void 0
359
+ binding: void 0
336
360
  });
337
361
  await this.stabilize(() => {
338
362
  this.list.update((draft) => {
@@ -344,18 +368,49 @@ var TestSessions = class {
344
368
  return id;
345
369
  }
346
370
  /**
347
- * Update a session's conversation snapshot through an immer draft (the
348
- * live-stream stand-in: components subscribed via useSession re-render).
371
+ * Update Session Controller lifecycle state through an immer draft.
349
372
  * @param id - session id.
350
373
  * @param mutate - draft mutator.
351
374
  */
352
- async updateSnapshot(id, mutate) {
375
+ async updateSessionSnapshot(id, mutate) {
353
376
  const record = this.require(id);
354
377
  await this.stabilize(() => {
355
378
  record.snapshot.update(mutate);
356
379
  });
357
380
  }
358
381
  /**
382
+ * Replace a Session's complete contiguous event window.
383
+ * @param id - Session identity.
384
+ * @param entries - complete event window.
385
+ * @param hasMore - whether older history remains.
386
+ */
387
+ async replaceEvents(id, entries, hasMore = false) {
388
+ await this.stabilize(() => {
389
+ this.require(id).session.eventSource.replace(entries, hasMore);
390
+ });
391
+ }
392
+ /**
393
+ * Prepend one older contiguous event page.
394
+ * @param id - Session identity.
395
+ * @param entries - older entries.
396
+ * @param hasMore - whether another older page remains.
397
+ */
398
+ async prependEvents(id, entries, hasMore = false) {
399
+ await this.stabilize(() => {
400
+ this.require(id).session.eventSource.prepend(entries, hasMore);
401
+ });
402
+ }
403
+ /**
404
+ * Append one live event to a Session's contiguous window.
405
+ * @param id - Session identity.
406
+ * @param entry - live event entry.
407
+ */
408
+ async appendEvent(id, entry) {
409
+ await this.stabilize(() => {
410
+ this.require(id).session.eventSource.append(entry);
411
+ });
412
+ }
413
+ /**
359
414
  * Update a session's list row (the wire-echo stand-in: title settles,
360
415
  * running flips — components subscribed via useSessions re-render).
361
416
  * @param id - session id.
@@ -388,7 +443,7 @@ var TestSessions = class {
388
443
  /**
389
444
  * Remove a session: list row, scope fiber, and per-session store instances
390
445
  * (with persisted state) die together — the same single lifecycle axis the
391
- * production SessionRuntime drives on session death, minus staging.
446
+ * production Client Sessions service drives on session death, minus staging.
392
447
  * @param id - session id.
393
448
  */
394
449
  async remove(id) {
@@ -402,40 +457,9 @@ var TestSessions = class {
402
457
  if (draft.current === id) draft.current = void 0;
403
458
  });
404
459
  if (record.scopeFiber !== void 0) await record.scopeFiber.dispose();
405
- this.rootCtx.get("slots")?.pruneStoreScope(id);
406
460
  });
407
461
  }
408
462
  /**
409
- * Register a per-session standard-props provider (production `provide`
410
- * contract: hooks become `use<Name>` selector hooks on the render side,
411
- * props spread verbatim; duplicate names fail loud at materialization).
412
- * @param descriptor - static member roster plus per-session resolver.
413
- * @returns disposer removing the provider.
414
- */
415
- provide(descriptor) {
416
- return this.channel.provide(descriptor);
417
- }
418
- /**
419
- * Resolve the definite per-session standard-props bundle (host face member).
420
- * @param id - session id.
421
- * @returns the identity-stable bundle, or undefined for unknown sessions.
422
- */
423
- provideInfo(id) {
424
- const record = this.records.get(id);
425
- if (record === void 0) return void 0;
426
- record.provideInfo ??= this.channel.materializeInfo(this.bindingOf(id, record));
427
- return record.provideInfo;
428
- }
429
- /**
430
- * Resolve the current-session-optional standard kit (host face member):
431
- * unknown or absent ids return the static no-session projection.
432
- * @param id - current session id, when selected.
433
- * @returns a definite or no-session provide bundle.
434
- */
435
- maybeProvideInfo(id) {
436
- return (id === void 0 ? void 0 : this.provideInfo(id)) ?? this.channel.maybeInfo;
437
- }
438
- /**
439
463
  * Resolve (mint on first touch) the session-scoped Cordis context through
440
464
  * the production `createScope`, so real `scopeOf`/scope-addressed services
441
465
  * resolve it.
@@ -460,7 +484,8 @@ var TestSessions = class {
460
484
  binding(id) {
461
485
  const record = this.records.get(id);
462
486
  if (record === void 0) return void 0;
463
- return this.bindingOf(id, record);
487
+ record.binding ??= this.bindingOf(id, record);
488
+ return record.binding;
464
489
  }
465
490
  /**
466
491
  * Read the session scope tag off a context (service-method boundary mirror).
@@ -482,6 +507,24 @@ var TestSessions = class {
482
507
  return this.records.get(id)?.session;
483
508
  }
484
509
  /**
510
+ * Install Session creation behavior for navigation tests.
511
+ * @param impl - implementation that must return an already-added fixture id.
512
+ */
513
+ stubCreate(impl) {
514
+ this.createStub = impl;
515
+ }
516
+ /** Create through the installed test behavior and require an addressable binding. */
517
+ async create(opts) {
518
+ this.calls.push({
519
+ method: "create",
520
+ args: [opts]
521
+ });
522
+ if (this.createStub === void 0) throw new Error("test sessions: create is not stubbed — call stubCreate() first");
523
+ const id = await this.createStub(opts);
524
+ this.require(id);
525
+ return id;
526
+ }
527
+ /**
485
528
  * Service-level selection call (recorded, then applied to the list store
486
529
  * synchronously — inject callbacks call this outside any act window; the
487
530
  * store notify is microtask-batched so the next stabilized step observes it).
@@ -530,16 +573,6 @@ var TestSessions = class {
530
573
  });
531
574
  return Promise.resolve();
532
575
  }
533
- /** Apply a confirmed preset switch into the fixture list, as production does. */
534
- noteAgentPreset(sessionId, agentPreset) {
535
- this.list.update((draft) => {
536
- const summary = draft.byId[sessionId];
537
- if (summary !== void 0) draft.byId[sessionId] = {
538
- ...summary,
539
- agentPreset
540
- };
541
- });
542
- }
543
576
  /** Clear the current selection (recorded; the production no-session flow). */
544
577
  clear() {
545
578
  this.calls.push({
@@ -551,6 +584,14 @@ var TestSessions = class {
551
584
  draft.currentAddress = void 0;
552
585
  });
553
586
  }
587
+ /** Record a list refresh; fixture callers publish list state explicitly. */
588
+ refresh() {
589
+ this.calls.push({
590
+ method: "refresh",
591
+ args: []
592
+ });
593
+ return Promise.resolve();
594
+ }
554
595
  /**
555
596
  * Replace the sidebar-search result page (the call is still recorded).
556
597
  * @param impl - hits for a query, as the Host would rank them.
@@ -596,7 +637,7 @@ var TestSessions = class {
596
637
  * The session face of a fixture (typed view for assertions; fixture
597
638
  * behavior methods are grafted onto it).
598
639
  * @param id - session id.
599
- * @returns the FixtureSession the binding and provide channel carry.
640
+ * @returns the FixtureSession carried by the Controller binding.
600
641
  */
601
642
  behavior(id) {
602
643
  return this.require(id).session;
@@ -607,6 +648,7 @@ var TestSessions = class {
607
648
  await record.scopeFiber.dispose();
608
649
  record.scope = void 0;
609
650
  record.scopeFiber = void 0;
651
+ record.binding = void 0;
610
652
  }
611
653
  }
612
654
  bindingOf(id, record) {
@@ -617,6 +659,7 @@ var TestSessions = class {
617
659
  return {
618
660
  sessionId: id,
619
661
  session: record.session,
662
+ eventSource: record.session.eventSource,
620
663
  ctx
621
664
  };
622
665
  }
@@ -649,7 +692,7 @@ var TestWorkspaces = class {
649
692
  */
650
693
  constructor(stabilize) {
651
694
  this.stabilize = stabilize;
652
- this.list = createSnapshotStore(workspaceListState());
695
+ this.list = createSnapshotStore({ ...workspaceSnapshot() });
653
696
  }
654
697
  /**
655
698
  * Update the workspace list state through an immer draft.
@@ -662,40 +705,13 @@ var TestWorkspaces = class {
662
705
  }
663
706
  /**
664
707
  * Replace an action's behavior (the recorded call is still appended first).
665
- * @param method - action name (e.g. 'connectWorkspace').
708
+ * @param method - Controller action name (e.g. 'create').
666
709
  * @param impl - replacement behavior.
667
710
  */
668
711
  stub(method, impl) {
669
712
  this.stubs.set(method, impl);
670
713
  }
671
714
  /**
672
- * Connect a workspace to its reusable/new blank session (recorded). The
673
- * default resolves the workspace id back as the session id; stub for
674
- * cross-session flows.
675
- * @param workspaceId - target workspace.
676
- * @returns the connected session id.
677
- */
678
- async connectWorkspace(workspaceId) {
679
- this.calls.push({
680
- method: "connectWorkspace",
681
- args: [workspaceId]
682
- });
683
- const stub = this.stubs.get("connectWorkspace");
684
- if (stub !== void 0) return await stub(workspaceId);
685
- return `session-of-${workspaceId}`;
686
- }
687
- /**
688
- * New-session flow (recorded; stubbed behavior runs when installed).
689
- * @param workspaceId - optional explicit workspace target.
690
- */
691
- startSession(workspaceId) {
692
- this.calls.push({
693
- method: "startSession",
694
- args: [workspaceId]
695
- });
696
- this.stubs.get("startSession")?.(workspaceId);
697
- }
698
- /**
699
715
  * Create a Workspace (recorded). The default echoes a view derived from
700
716
  * the input; stub for failure or list-coupled flows.
701
717
  * @param input - the Host create payload.
@@ -716,82 +732,6 @@ var TestWorkspaces = class {
716
732
  };
717
733
  }
718
734
  /**
719
- * Open a path with the host OS default application (recorded; default no-op).
720
- * @param path - host-resolvable path.
721
- */
722
- async openPath(path) {
723
- this.calls.push({
724
- method: "openPath",
725
- args: [path]
726
- });
727
- await this.stubs.get("openPath")?.(path);
728
- }
729
- /**
730
- * Directory picker (recorded). The default cancels (null); stub to select.
731
- * @returns the picked path, or null.
732
- */
733
- async pickDirectory() {
734
- this.calls.push({
735
- method: "pickDirectory",
736
- args: []
737
- });
738
- const stub = this.stubs.get("pickDirectory");
739
- if (stub !== void 0) return await stub();
740
- return null;
741
- }
742
- /**
743
- * Browse listing (recorded). The default serves an empty home level; stub
744
- * to shape a tree.
745
- * @param path - absolute directory to list; absent lists the home level.
746
- * @returns the level's listing.
747
- */
748
- async listDirectory(path, signal) {
749
- this.calls.push({
750
- method: "listDirectory",
751
- args: [path, signal]
752
- });
753
- const stub = this.stubs.get("listDirectory");
754
- if (stub !== void 0) return await stub(path, signal);
755
- return {
756
- path: "/home/test",
757
- home: "/home/test",
758
- crumbs: [
759
- {
760
- name: "/",
761
- path: "/",
762
- hidden: false
763
- },
764
- {
765
- name: "home",
766
- path: "/home",
767
- hidden: false
768
- },
769
- {
770
- name: "test",
771
- path: "/home/test",
772
- hidden: false
773
- }
774
- ],
775
- entries: [],
776
- truncated: false
777
- };
778
- }
779
- /**
780
- * Browse child creation (recorded). The default joins parent and name.
781
- * @param path - absolute existing parent directory.
782
- * @param name - single path segment.
783
- * @returns the created directory's absolute path.
784
- */
785
- async createDirectory(path, name) {
786
- this.calls.push({
787
- method: "createDirectory",
788
- args: [path, name]
789
- });
790
- const stub = this.stubs.get("createDirectory");
791
- if (stub !== void 0) return await stub(path, name);
792
- return `${path}/${name}`;
793
- }
794
- /**
795
735
  * Rename a Workspace (recorded). The default echoes a minimal view.
796
736
  * @param workspaceId - target workspace.
797
737
  * @param title - new title.
@@ -899,6 +839,7 @@ function stubSettingsScope() {
899
839
  };
900
840
  const listeners = /* @__PURE__ */ new Set();
901
841
  const set = vi.fn(() => Promise.resolve());
842
+ const mutate = vi.fn(() => Promise.resolve());
902
843
  const unset = vi.fn(() => Promise.resolve());
903
844
  return {
904
845
  scope: {
@@ -909,10 +850,12 @@ function stubSettingsScope() {
909
850
  listeners.delete(listener);
910
851
  };
911
852
  },
853
+ mutate,
912
854
  set,
913
855
  unset
914
856
  },
915
857
  set,
858
+ mutate,
916
859
  unset,
917
860
  listenerCount: () => listeners.size,
918
861
  publish: (next) => {
@@ -925,34 +868,100 @@ function stubSettingsScope() {
925
868
  };
926
869
  }
927
870
  //#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
928
926
  //#region lib/types/remote.js
929
927
  /**
930
928
  * Remote service test double for the forwarded-event path. Feature specs need
931
929
  * `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded
932
- * host events to reach those subscribers, but not the generated namespaces or
933
- * the wire so this double implements subscription and dispatch only.
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
934
  *
935
- * Dispatch is driven the same way production drives it: `client/runtime` owns the
936
- * host frame sink and hands each decoded `host/remote-event` frame to
937
- * `$dispatch`. A spec therefore exercises its refresh chains by calling
938
- * `$dispatch(name, args)` on this double.
939
- *
940
- * `$mount` rejects: a spec that reaches a generated namespace through this
941
- * double has outgrown it and needs the real Client Remote service.
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.
942
938
  *
943
939
  * One deliberate asymmetry with production: a throwing listener propagates out
944
940
  * of the emit instead of being contained and logged, so a spec cannot lean on
945
941
  * this double for the containment guarantee `$on` documents — assert that
946
942
  * against the real service.
947
943
  */
948
- var TestRemote = class {
944
+ var TestRemote = class TestRemote {
949
945
  subscriptions = /* @__PURE__ */ new Map();
950
946
  /**
951
- * Register the double as `ctx.remote`.
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.
952
957
  * @param ctx - the spec's root Context.
958
+ * @param namespaces - scripted namespace faces reached as `ctx.remote.<name>`.
953
959
  */
954
- constructor(ctx) {
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);
955
963
  ctx.provide("remote", this);
964
+ for (const [name, face] of Object.entries(namespaces)) ctx.provide(`remote.${name}`, face);
956
965
  }
957
966
  /**
958
967
  * Deliver one forwarded host event to its subscribers, standing in for the
@@ -960,7 +969,7 @@ var TestRemote = class {
960
969
  * @param event - forwarded host event name.
961
970
  * @param args - the Host argument list, verbatim.
962
971
  */
963
- $dispatch(event, args) {
972
+ emit(event, args) {
964
973
  const listeners = this.subscriptions.get(event);
965
974
  if (listeners === void 0) return;
966
975
  for (const listener of [...listeners]) listener(...args);
@@ -1054,7 +1063,7 @@ function usePinnedBrowserLanguages(primary, ...rest) {
1054
1063
  //#region lib/types/index.js
1055
1064
  /**
1056
1065
  * jsdom slot test runtime: a real small runtime — Cordis `Context`, the
1057
- * runtime `SlotRegistry`, and the UI renderer — assembled around
1066
+ * renderer-owned `SlotRegistry`, the `ui-session` adapter, and the UI renderer — assembled around
1058
1067
  * test-owned session/workspace doubles, so feature specs exercise
1059
1068
  * declaration, registration, scope, store, inject, rendering, updates, and
1060
1069
  * disposal without hand-building the machinery per suite.
@@ -1163,7 +1172,7 @@ var TestRoot = class {
1163
1172
  * batching or React act themselves.
1164
1173
  */
1165
1174
  var SlotTestRuntime = class SlotTestRuntime {
1166
- /** The runtime's Cordis root (escape hatch: extra services via `ctx.provide`, raw `ctx.plugin` mounts). */
1175
+ /** The runtime's Cordis root for owner APIs and explicit test-only services. */
1167
1176
  ctx;
1168
1177
  /** The production SlotRegistry mounted on {@link SlotTestRuntime.ctx}. */
1169
1178
  slots;
@@ -1186,6 +1195,7 @@ var SlotTestRuntime = class SlotTestRuntime {
1186
1195
  ownerCell = new OwnerPropsCell();
1187
1196
  autoDeclared = /* @__PURE__ */ new Set();
1188
1197
  autoRootView;
1198
+ disposeWorkspaceSource;
1189
1199
  constructor(ctx, slots) {
1190
1200
  this.ctx = ctx;
1191
1201
  this.slots = slots;
@@ -1194,6 +1204,7 @@ var SlotTestRuntime = class SlotTestRuntime {
1194
1204
  this.workspaces = new TestWorkspaces(this.stabilizer);
1195
1205
  ctx.provide("sessions", this.sessions);
1196
1206
  ctx.provide("workspaces", this.workspaces);
1207
+ this.disposeWorkspaceSource = slots.provideRoot({ hooks: { workspaces: this.workspaces.list } });
1197
1208
  const renderer = createSlotRenderer();
1198
1209
  slots.install({ renderRoot: (host, ownerProps) => {
1199
1210
  this.host = host;
@@ -1209,22 +1220,12 @@ var SlotTestRuntime = class SlotTestRuntime {
1209
1220
  registerDomSnapshotSerializer();
1210
1221
  const ctx = new Context();
1211
1222
  await ctx.plugin(SlotRegistry).await();
1212
- await ctx.plugin(ConversationEventRegistry).await();
1213
- await ctx.plugin(ConversationViewRegistry).await();
1214
- return new SlotTestRuntime(ctx, ctx.get("slots"));
1215
- }
1216
- /**
1217
- * Provide an extra service the feature under test injects (e.g. a layout
1218
- * fake). Sugar over `ctx.provide`, typed against the Context declaration
1219
- * merge: for a declared service name the fake must be a subset of that
1220
- * service's outward face (Partial — supply only what the feature calls),
1221
- * so a production face change breaks the fake at compile time. Undeclared
1222
- * names stay unchecked (ad-hoc test services).
1223
- * @param name - service name.
1224
- * @param value - service implementation (test double).
1225
- */
1226
- provide(name, value) {
1227
- this.ctx.provide(name, value);
1223
+ const runtime = new SlotTestRuntime(ctx, ctx.get("slots"));
1224
+ await ctx.plugin({
1225
+ inject: [...inject],
1226
+ apply
1227
+ }).await();
1228
+ return runtime;
1228
1229
  }
1229
1230
  /**
1230
1231
  * Mount a feature plugin on a real fiber. Required services are prechecked
@@ -1252,6 +1253,10 @@ var SlotTestRuntime = class SlotTestRuntime {
1252
1253
  this.handles.push(handle);
1253
1254
  return handle;
1254
1255
  }
1256
+ /** Release the default Workspace hook before mounting its production owner. */
1257
+ releaseWorkspaceSource() {
1258
+ this.disposeWorkspaceSource();
1259
+ }
1255
1260
  /**
1256
1261
  * Render the root slot tree through the ctx-level entry (the shell's own
1257
1262
  * entry point): `ctx.slots.renderSlot('root', {})` under Testing Library.
@@ -1321,7 +1326,9 @@ var SlotTestRuntime = class SlotTestRuntime {
1321
1326
  if (this.host === void 0) throw new Error("storeOf before renderRoot() — the host face exists only inside the installed renderer");
1322
1327
  const entry = this.host.entriesOf(key)[0];
1323
1328
  if (entry === void 0) throw new Error(`storeOf('${key}'): no registration on the ledger`);
1324
- const instance = this.host.storeOf(entry, scopeKey);
1329
+ const scopeBinding = scopeKey === void 0 ? void 0 : this.host.scope("session")?.resolve(scopeKey);
1330
+ if (scopeKey !== void 0 && scopeBinding === void 0) throw new Error(`storeOf('${key}'): no live Session binding for '${scopeKey}'`);
1331
+ const instance = this.host.storeOf(entry, scopeBinding);
1325
1332
  if (instance === void 0) throw new Error(`storeOf('${key}'): the entry declares no store`);
1326
1333
  return instance;
1327
1334
  }
@@ -1351,4 +1358,4 @@ var SlotTestRuntime = class SlotTestRuntime {
1351
1358
  }
1352
1359
  };
1353
1360
  //#endregion
1354
- export { FixtureSession, SlotTestRuntime, TestRemote, TestRoot, TestSessions, TestWorkspaces, bindSnapshotSelector, conversationSnapshot, createSlotRenderer, domSnapshotSerializer, makeTranslate, registerDomSnapshotSerializer, stubSettingsScope, usePinnedBrowserLanguages, workspaceListState };
1361
+ export { FixtureSession, RemoteError, SlotTestRuntime, TestRemote, TestRoot, TestSessions, TestWorkspaces, bindSnapshotSelector, chatSnapshot, conversationSnapshot, createSlotRenderer, domSnapshotSerializer, makeTranslate, registerDomSnapshotSerializer, scriptedSettingsRemote, sessionSnapshot, stubSettingsScope, usePinnedBrowserLanguages, workspaceSnapshot };