@deepseek-ai/dsh-client-test-runtime 0.1.1-rc.1 → 0.1.2-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
@@ -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.
@@ -248,36 +281,28 @@ var FixtureSession = class {
248
281
  };
249
282
  /**
250
283
  * 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.
284
+ * list/current observable, scope minting through the production `createScope`,
285
+ * stable Controller bindings, and the session behavior face supplied per
286
+ * fixture. `ui-session` owns standard-source materialization.
254
287
  *
255
288
  * Implements the same ISessions face features receive as `ctx.sessions`, so
256
289
  * 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.
290
+ * members (add/updateSessionSnapshot/event-window drivers/setCurrent/remove/
291
+ * behavior/calls/stubs) are bench-only surface.
259
292
  */
260
293
  var TestSessions = class {
261
294
  stabilize;
262
295
  rootCtx;
263
296
  /** The useSessions standard feed (list rows + current selection). */
264
297
  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
298
  records = /* @__PURE__ */ new Map();
273
- /** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
274
- channel;
275
299
  /** Calls observed on the service-level face, newest last. */
276
300
  calls = [];
277
301
  /** The wire schema's `session.search` result bound (production parity). */
278
302
  searchResultLimit = SESSION_SEARCH_RESULT_LIMIT;
279
303
  /** Replaceable search behavior (see {@link TestSessions.stubSearch}). */
280
304
  searchStub;
305
+ createStub;
281
306
  /**
282
307
  * @param stabilize - the owning runtime's act wrapper.
283
308
  * @param rootCtx - the runtime's Cordis root; scope fibers mount under it.
@@ -294,16 +319,6 @@ var TestSessions = class {
294
319
  jobsBySession: {},
295
320
  currentAddress: void 0
296
321
  });
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
322
  }
308
323
  /**
309
324
  * Add a session from a fixture and (by default) make it current.
@@ -323,16 +338,18 @@ var TestSessions = class {
323
338
  ...fixture.summary
324
339
  };
325
340
  const snapshot = createSnapshotStore({
326
- ...conversationSnapshot(id),
341
+ ...sessionSnapshot(id),
327
342
  ...fixture.snapshot
328
343
  });
344
+ const session = new FixtureSession(id, snapshot, fixture.session ?? {});
345
+ if (fixture.events !== void 0 || fixture.hasMore === true) session.eventSource.replace(fixture.events ?? [], fixture.hasMore ?? false);
329
346
  this.records.set(id, {
330
347
  summary,
331
348
  snapshot,
332
- session: new FixtureSession(id, snapshot, fixture.session ?? {}),
349
+ session,
333
350
  scope: void 0,
334
351
  scopeFiber: void 0,
335
- provideInfo: void 0
352
+ binding: void 0
336
353
  });
337
354
  await this.stabilize(() => {
338
355
  this.list.update((draft) => {
@@ -344,18 +361,49 @@ var TestSessions = class {
344
361
  return id;
345
362
  }
346
363
  /**
347
- * Update a session's conversation snapshot through an immer draft (the
348
- * live-stream stand-in: components subscribed via useSession re-render).
364
+ * Update Session Controller lifecycle state through an immer draft.
349
365
  * @param id - session id.
350
366
  * @param mutate - draft mutator.
351
367
  */
352
- async updateSnapshot(id, mutate) {
368
+ async updateSessionSnapshot(id, mutate) {
353
369
  const record = this.require(id);
354
370
  await this.stabilize(() => {
355
371
  record.snapshot.update(mutate);
356
372
  });
357
373
  }
358
374
  /**
375
+ * Replace a Session's complete contiguous event window.
376
+ * @param id - Session identity.
377
+ * @param entries - complete event window.
378
+ * @param hasMore - whether older history remains.
379
+ */
380
+ async replaceEvents(id, entries, hasMore = false) {
381
+ await this.stabilize(() => {
382
+ this.require(id).session.eventSource.replace(entries, hasMore);
383
+ });
384
+ }
385
+ /**
386
+ * Prepend one older contiguous event page.
387
+ * @param id - Session identity.
388
+ * @param entries - older entries.
389
+ * @param hasMore - whether another older page remains.
390
+ */
391
+ async prependEvents(id, entries, hasMore = false) {
392
+ await this.stabilize(() => {
393
+ this.require(id).session.eventSource.prepend(entries, hasMore);
394
+ });
395
+ }
396
+ /**
397
+ * Append one live event to a Session's contiguous window.
398
+ * @param id - Session identity.
399
+ * @param entry - live event entry.
400
+ */
401
+ async appendEvent(id, entry) {
402
+ await this.stabilize(() => {
403
+ this.require(id).session.eventSource.append(entry);
404
+ });
405
+ }
406
+ /**
359
407
  * Update a session's list row (the wire-echo stand-in: title settles,
360
408
  * running flips — components subscribed via useSessions re-render).
361
409
  * @param id - session id.
@@ -388,7 +436,7 @@ var TestSessions = class {
388
436
  /**
389
437
  * Remove a session: list row, scope fiber, and per-session store instances
390
438
  * (with persisted state) die together — the same single lifecycle axis the
391
- * production SessionRuntime drives on session death, minus staging.
439
+ * production Client Sessions service drives on session death, minus staging.
392
440
  * @param id - session id.
393
441
  */
394
442
  async remove(id) {
@@ -402,40 +450,9 @@ var TestSessions = class {
402
450
  if (draft.current === id) draft.current = void 0;
403
451
  });
404
452
  if (record.scopeFiber !== void 0) await record.scopeFiber.dispose();
405
- this.rootCtx.get("slots")?.pruneStoreScope(id);
406
453
  });
407
454
  }
408
455
  /**
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
456
  * Resolve (mint on first touch) the session-scoped Cordis context through
440
457
  * the production `createScope`, so real `scopeOf`/scope-addressed services
441
458
  * resolve it.
@@ -460,7 +477,8 @@ var TestSessions = class {
460
477
  binding(id) {
461
478
  const record = this.records.get(id);
462
479
  if (record === void 0) return void 0;
463
- return this.bindingOf(id, record);
480
+ record.binding ??= this.bindingOf(id, record);
481
+ return record.binding;
464
482
  }
465
483
  /**
466
484
  * Read the session scope tag off a context (service-method boundary mirror).
@@ -482,6 +500,24 @@ var TestSessions = class {
482
500
  return this.records.get(id)?.session;
483
501
  }
484
502
  /**
503
+ * Install Session creation behavior for navigation tests.
504
+ * @param impl - implementation that must return an already-added fixture id.
505
+ */
506
+ stubCreate(impl) {
507
+ this.createStub = impl;
508
+ }
509
+ /** Create through the installed test behavior and require an addressable binding. */
510
+ async create(opts) {
511
+ this.calls.push({
512
+ method: "create",
513
+ args: [opts]
514
+ });
515
+ if (this.createStub === void 0) throw new Error("test sessions: create is not stubbed — call stubCreate() first");
516
+ const id = await this.createStub(opts);
517
+ this.require(id);
518
+ return id;
519
+ }
520
+ /**
485
521
  * Service-level selection call (recorded, then applied to the list store
486
522
  * synchronously — inject callbacks call this outside any act window; the
487
523
  * store notify is microtask-batched so the next stabilized step observes it).
@@ -530,16 +566,6 @@ var TestSessions = class {
530
566
  });
531
567
  return Promise.resolve();
532
568
  }
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
569
  /** Clear the current selection (recorded; the production no-session flow). */
544
570
  clear() {
545
571
  this.calls.push({
@@ -551,6 +577,14 @@ var TestSessions = class {
551
577
  draft.currentAddress = void 0;
552
578
  });
553
579
  }
580
+ /** Record a list refresh; fixture callers publish list state explicitly. */
581
+ refresh() {
582
+ this.calls.push({
583
+ method: "refresh",
584
+ args: []
585
+ });
586
+ return Promise.resolve();
587
+ }
554
588
  /**
555
589
  * Replace the sidebar-search result page (the call is still recorded).
556
590
  * @param impl - hits for a query, as the Host would rank them.
@@ -596,7 +630,7 @@ var TestSessions = class {
596
630
  * The session face of a fixture (typed view for assertions; fixture
597
631
  * behavior methods are grafted onto it).
598
632
  * @param id - session id.
599
- * @returns the FixtureSession the binding and provide channel carry.
633
+ * @returns the FixtureSession carried by the Controller binding.
600
634
  */
601
635
  behavior(id) {
602
636
  return this.require(id).session;
@@ -607,6 +641,7 @@ var TestSessions = class {
607
641
  await record.scopeFiber.dispose();
608
642
  record.scope = void 0;
609
643
  record.scopeFiber = void 0;
644
+ record.binding = void 0;
610
645
  }
611
646
  }
612
647
  bindingOf(id, record) {
@@ -617,6 +652,7 @@ var TestSessions = class {
617
652
  return {
618
653
  sessionId: id,
619
654
  session: record.session,
655
+ eventSource: record.session.eventSource,
620
656
  ctx
621
657
  };
622
658
  }
@@ -649,7 +685,7 @@ var TestWorkspaces = class {
649
685
  */
650
686
  constructor(stabilize) {
651
687
  this.stabilize = stabilize;
652
- this.list = createSnapshotStore(workspaceListState());
688
+ this.list = createSnapshotStore({ ...workspaceSnapshot() });
653
689
  }
654
690
  /**
655
691
  * Update the workspace list state through an immer draft.
@@ -662,40 +698,13 @@ var TestWorkspaces = class {
662
698
  }
663
699
  /**
664
700
  * Replace an action's behavior (the recorded call is still appended first).
665
- * @param method - action name (e.g. 'connectWorkspace').
701
+ * @param method - Controller action name (e.g. 'create').
666
702
  * @param impl - replacement behavior.
667
703
  */
668
704
  stub(method, impl) {
669
705
  this.stubs.set(method, impl);
670
706
  }
671
707
  /**
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
708
  * Create a Workspace (recorded). The default echoes a view derived from
700
709
  * the input; stub for failure or list-coupled flows.
701
710
  * @param input - the Host create payload.
@@ -716,82 +725,6 @@ var TestWorkspaces = class {
716
725
  };
717
726
  }
718
727
  /**
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
728
  * Rename a Workspace (recorded). The default echoes a minimal view.
796
729
  * @param workspaceId - target workspace.
797
730
  * @param title - new title.
@@ -899,6 +832,7 @@ function stubSettingsScope() {
899
832
  };
900
833
  const listeners = /* @__PURE__ */ new Set();
901
834
  const set = vi.fn(() => Promise.resolve());
835
+ const mutate = vi.fn(() => Promise.resolve());
902
836
  const unset = vi.fn(() => Promise.resolve());
903
837
  return {
904
838
  scope: {
@@ -909,10 +843,12 @@ function stubSettingsScope() {
909
843
  listeners.delete(listener);
910
844
  };
911
845
  },
846
+ mutate,
912
847
  set,
913
848
  unset
914
849
  },
915
850
  set,
851
+ mutate,
916
852
  unset,
917
853
  listenerCount: () => listeners.size,
918
854
  publish: (next) => {
@@ -925,34 +861,100 @@ function stubSettingsScope() {
925
861
  };
926
862
  }
927
863
  //#endregion
864
+ //#region lib/types/settings-remote.js
865
+ /** Test double for the `settings` Remote namespace a bench's plugins inject. */
866
+ /**
867
+ * Build a scripted `settings` Remote namespace for a bench. Each write answers
868
+ * with the addressed namespace unchanged, so a bench that only needs its
869
+ * plugins to activate scripts nothing; one asserting a write reads the
870
+ * corresponding spy or replaces the face.
871
+ * @param namespaces - namespace views the first describe answers with.
872
+ * @param options - deployment facts the describe answer reports.
873
+ * @returns the face and its controls.
874
+ */
875
+ function scriptedSettingsRemote(namespaces = [], options = {}) {
876
+ let served = namespaces;
877
+ const writable = options.writable ?? true;
878
+ const hasDocument = options.hasDocument ?? false;
879
+ const answer = (ns) => {
880
+ const view = served.find((candidate) => candidate.ns === ns);
881
+ return Promise.resolve(view === void 0 ? {
882
+ ok: false,
883
+ error: {
884
+ code: "settings/rejected",
885
+ message: `no scripted namespace "${ns}"`,
886
+ details: { ns }
887
+ }
888
+ } : {
889
+ ok: true,
890
+ value: view
891
+ });
892
+ };
893
+ const update = vi.fn((ns, _patch, _expectedRevision) => answer(ns));
894
+ const replace = vi.fn((ns, _section, _expectedRevision) => answer(ns));
895
+ const mutate = vi.fn((ns, _ops, _expectedRevision) => answer(ns));
896
+ return {
897
+ settings: {
898
+ describe: () => Promise.resolve({
899
+ ok: true,
900
+ value: {
901
+ writable,
902
+ hasDocument,
903
+ namespaces: served
904
+ }
905
+ }),
906
+ update: (ns, patch, expectedRevision) => update(ns, patch, expectedRevision),
907
+ replace: (ns, section, expectedRevision) => replace(ns, section, expectedRevision),
908
+ mutate: (ns, ops, expectedRevision) => mutate(ns, ops, expectedRevision)
909
+ },
910
+ update,
911
+ replace,
912
+ mutate,
913
+ publish(next) {
914
+ served = next;
915
+ }
916
+ };
917
+ }
918
+ //#endregion
928
919
  //#region lib/types/remote.js
929
920
  /**
930
921
  * Remote service test double for the forwarded-event path. Feature specs need
931
922
  * `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.
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.
923
+ * Host events to reach those subscribers, but not the wire so this double
924
+ * implements subscription plus an explicit `emit` driver available only on the
925
+ * concrete test object. A spec that also calls one namespace scripts it through
926
+ * the constructor rather than reaching the real Client Remote service.
939
927
  *
940
- * `$mount` rejects: a spec that reaches a generated namespace through this
941
- * double has outgrown it and needs the real Client Remote service.
928
+ * `$mount` rejects: a spec that needs a real generated contribution installed
929
+ * codecs, descriptors, and the wire — has outgrown this double and needs the
930
+ * real Client Remote service.
942
931
  *
943
932
  * One deliberate asymmetry with production: a throwing listener propagates out
944
933
  * of the emit instead of being contained and logged, so a spec cannot lean on
945
934
  * this double for the containment guarantee `$on` documents — assert that
946
935
  * against the real service.
947
936
  */
948
- var TestRemote = class {
937
+ var TestRemote = class TestRemote {
949
938
  subscriptions = /* @__PURE__ */ new Map();
950
939
  /**
951
- * Register the double as `ctx.remote`.
940
+ * Fixed Host facts mirrored from the production `ctx.remote.$host`. Plain
941
+ * mutable field: a spec assigns it to script a non-loopback or homed Host.
942
+ */
943
+ $host = {
944
+ home: void 0,
945
+ isLoopback: true
946
+ };
947
+ /**
948
+ * Register the double as `ctx.remote`, plus one service per scripted
949
+ * namespace so a plugin injecting `remote.<name>` also unparks.
952
950
  * @param ctx - the spec's root Context.
951
+ * @param namespaces - scripted namespace faces reached as `ctx.remote.<name>`.
953
952
  */
954
- constructor(ctx) {
953
+ constructor(ctx, namespaces = {}) {
954
+ 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`);
955
+ Object.assign(this, namespaces);
955
956
  ctx.provide("remote", this);
957
+ for (const [name, face] of Object.entries(namespaces)) ctx.provide(`remote.${name}`, face);
956
958
  }
957
959
  /**
958
960
  * Deliver one forwarded host event to its subscribers, standing in for the
@@ -960,7 +962,7 @@ var TestRemote = class {
960
962
  * @param event - forwarded host event name.
961
963
  * @param args - the Host argument list, verbatim.
962
964
  */
963
- $dispatch(event, args) {
965
+ emit(event, args) {
964
966
  const listeners = this.subscriptions.get(event);
965
967
  if (listeners === void 0) return;
966
968
  for (const listener of [...listeners]) listener(...args);
@@ -1054,7 +1056,7 @@ function usePinnedBrowserLanguages(primary, ...rest) {
1054
1056
  //#region lib/types/index.js
1055
1057
  /**
1056
1058
  * jsdom slot test runtime: a real small runtime — Cordis `Context`, the
1057
- * runtime `SlotRegistry`, and the UI renderer — assembled around
1059
+ * renderer-owned `SlotRegistry`, the `ui-session` adapter, and the UI renderer — assembled around
1058
1060
  * test-owned session/workspace doubles, so feature specs exercise
1059
1061
  * declaration, registration, scope, store, inject, rendering, updates, and
1060
1062
  * disposal without hand-building the machinery per suite.
@@ -1163,7 +1165,7 @@ var TestRoot = class {
1163
1165
  * batching or React act themselves.
1164
1166
  */
1165
1167
  var SlotTestRuntime = class SlotTestRuntime {
1166
- /** The runtime's Cordis root (escape hatch: extra services via `ctx.provide`, raw `ctx.plugin` mounts). */
1168
+ /** The runtime's Cordis root for owner APIs and explicit test-only services. */
1167
1169
  ctx;
1168
1170
  /** The production SlotRegistry mounted on {@link SlotTestRuntime.ctx}. */
1169
1171
  slots;
@@ -1186,6 +1188,7 @@ var SlotTestRuntime = class SlotTestRuntime {
1186
1188
  ownerCell = new OwnerPropsCell();
1187
1189
  autoDeclared = /* @__PURE__ */ new Set();
1188
1190
  autoRootView;
1191
+ disposeWorkspaceSource;
1189
1192
  constructor(ctx, slots) {
1190
1193
  this.ctx = ctx;
1191
1194
  this.slots = slots;
@@ -1194,6 +1197,7 @@ var SlotTestRuntime = class SlotTestRuntime {
1194
1197
  this.workspaces = new TestWorkspaces(this.stabilizer);
1195
1198
  ctx.provide("sessions", this.sessions);
1196
1199
  ctx.provide("workspaces", this.workspaces);
1200
+ this.disposeWorkspaceSource = slots.provideRoot({ hooks: { workspaces: this.workspaces.list } });
1197
1201
  const renderer = createSlotRenderer();
1198
1202
  slots.install({ renderRoot: (host, ownerProps) => {
1199
1203
  this.host = host;
@@ -1209,22 +1213,12 @@ var SlotTestRuntime = class SlotTestRuntime {
1209
1213
  registerDomSnapshotSerializer();
1210
1214
  const ctx = new Context();
1211
1215
  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);
1216
+ const runtime = new SlotTestRuntime(ctx, ctx.get("slots"));
1217
+ await ctx.plugin({
1218
+ inject: [...inject],
1219
+ apply
1220
+ }).await();
1221
+ return runtime;
1228
1222
  }
1229
1223
  /**
1230
1224
  * Mount a feature plugin on a real fiber. Required services are prechecked
@@ -1252,6 +1246,10 @@ var SlotTestRuntime = class SlotTestRuntime {
1252
1246
  this.handles.push(handle);
1253
1247
  return handle;
1254
1248
  }
1249
+ /** Release the default Workspace hook before mounting its production owner. */
1250
+ releaseWorkspaceSource() {
1251
+ this.disposeWorkspaceSource();
1252
+ }
1255
1253
  /**
1256
1254
  * Render the root slot tree through the ctx-level entry (the shell's own
1257
1255
  * entry point): `ctx.slots.renderSlot('root', {})` under Testing Library.
@@ -1321,7 +1319,9 @@ var SlotTestRuntime = class SlotTestRuntime {
1321
1319
  if (this.host === void 0) throw new Error("storeOf before renderRoot() — the host face exists only inside the installed renderer");
1322
1320
  const entry = this.host.entriesOf(key)[0];
1323
1321
  if (entry === void 0) throw new Error(`storeOf('${key}'): no registration on the ledger`);
1324
- const instance = this.host.storeOf(entry, scopeKey);
1322
+ const scopeBinding = scopeKey === void 0 ? void 0 : this.host.scope("session")?.resolve(scopeKey);
1323
+ if (scopeKey !== void 0 && scopeBinding === void 0) throw new Error(`storeOf('${key}'): no live Session binding for '${scopeKey}'`);
1324
+ const instance = this.host.storeOf(entry, scopeBinding);
1325
1325
  if (instance === void 0) throw new Error(`storeOf('${key}'): the entry declares no store`);
1326
1326
  return instance;
1327
1327
  }
@@ -1351,4 +1351,4 @@ var SlotTestRuntime = class SlotTestRuntime {
1351
1351
  }
1352
1352
  };
1353
1353
  //#endregion
1354
- export { FixtureSession, SlotTestRuntime, TestRemote, TestRoot, TestSessions, TestWorkspaces, bindSnapshotSelector, conversationSnapshot, createSlotRenderer, domSnapshotSerializer, makeTranslate, registerDomSnapshotSerializer, stubSettingsScope, usePinnedBrowserLanguages, workspaceListState };
1354
+ export { FixtureSession, RemoteError, SlotTestRuntime, TestRemote, TestRoot, TestSessions, TestWorkspaces, bindSnapshotSelector, chatSnapshot, conversationSnapshot, createSlotRenderer, domSnapshotSerializer, makeTranslate, registerDomSnapshotSerializer, scriptedSettingsRemote, sessionSnapshot, stubSettingsScope, usePinnedBrowserLanguages, workspaceSnapshot };