@deepseek-ai/dsh-client-test-runtime 0.0.1-rc.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 ADDED
@@ -0,0 +1,1259 @@
1
+ import { Context, Inject } from "@deepseek-ai/cordis";
2
+ import { Fragment, createElement, useSyncExternalStore } from "react";
3
+ import { act, render, within } from "@testing-library/react";
4
+ import { ConversationEventRegistry, ConversationViewRegistry, EMPTY_CHAT_SNAPSHOT, SessionProvideChannel, SlotsService, createScope, createSnapshotStore, scopeOf } from "@deepseek-ai/dsh-client-runtime/client";
5
+ import { createSlotRenderer } from "@deepseek-ai/dsh-client-web-react";
6
+ import { afterEach, beforeEach, expect, vi } from "vitest";
7
+ import { SESSION_SEARCH_RESULT_LIMIT } from "@deepseek-ai/dsh-host-apiproxy/api";
8
+ //#region lib/types/snapshot.js
9
+ /**
10
+ * DOM snapshot hygiene: a vitest snapshot serializer that keeps `.snap`
11
+ * files structural. Two normalizations, both on a clone (the live DOM is
12
+ * untouched, so class/tag queries keep working):
13
+ *
14
+ * - CSS-module scoped class names (`_frame_334d2d`, this repo's
15
+ * `_[local]_[hash]` shape) fold back to their semantic local (`frame`), so
16
+ * CSS edits do not churn snapshots.
17
+ * - `<svg>` internals collapse to a `data-content` fingerprint on the svg
18
+ * element: path geometry is print noise, but the fingerprint still flips
19
+ * when an icon's artwork actually changes.
20
+ */
21
+ /** One scoped class token: `_<local>_<hash>` (local may itself contain underscores). */
22
+ const SCOPED_CLASS = /^_(.+)_[a-z0-9]+$/;
23
+ /** Fold scoped tokens in one class attribute value; foreign tokens pass through. */
24
+ function normalizeClassValue(value) {
25
+ return value.split(/\s+/).filter((token) => token !== "").map((token) => token.replace(SCOPED_CLASS, "$1")).join(" ");
26
+ }
27
+ /** FNV-1a 32-bit over the svg markup: deterministic, dependency-free fingerprint. */
28
+ function fingerprint(markup) {
29
+ let hash = 2166136261;
30
+ for (let i = 0; i < markup.length; i++) {
31
+ hash ^= markup.charCodeAt(i);
32
+ hash = Math.imul(hash, 16777619);
33
+ }
34
+ return (hash >>> 0).toString(16).padStart(8, "0");
35
+ }
36
+ /** svg elements of a subtree, the root included when it is one. */
37
+ function svgsOf(root) {
38
+ const svgs = [...root.querySelectorAll("svg")];
39
+ if (root.tagName.toLowerCase() === "svg") svgs.unshift(root);
40
+ return svgs;
41
+ }
42
+ /** Whether serializing this subtree needs a normalized clone. */
43
+ function needsNormalization(root) {
44
+ return [root, ...root.querySelectorAll("[class]")].some((el) => {
45
+ const value = el.getAttribute("class");
46
+ return value !== null && value.split(/\s+/).some((token) => SCOPED_CLASS.test(token));
47
+ }) || svgsOf(root).some((svg) => svg.childNodes.length > 0);
48
+ }
49
+ /**
50
+ * The serializer plugin. Matches DOM elements whose subtree carries a scoped
51
+ * class or svg internals; serializes a normalized clone, which no longer
52
+ * matches, so printing falls through to the built-in DOM element serializer.
53
+ */
54
+ const domSnapshotSerializer = {
55
+ test(value) {
56
+ return typeof Element !== "undefined" && value instanceof Element && needsNormalization(value);
57
+ },
58
+ serialize(value, config, indentation, depth, refs, printer) {
59
+ const clone = value.cloneNode(true);
60
+ for (const el of [clone, ...clone.querySelectorAll("[class]")]) {
61
+ const raw = el.getAttribute("class");
62
+ if (raw !== null) el.setAttribute("class", normalizeClassValue(raw));
63
+ }
64
+ for (const svg of svgsOf(clone)) {
65
+ if (svg.childNodes.length === 0) continue;
66
+ svg.setAttribute("data-content", fingerprint(svg.innerHTML));
67
+ svg.replaceChildren();
68
+ }
69
+ return printer(clone, config, indentation, depth, refs);
70
+ }
71
+ };
72
+ let registered = false;
73
+ /**
74
+ * Register {@link domSnapshotSerializer} with vitest's expect (idempotent).
75
+ * SlotTestRuntime.create() calls this; specs that snapshot DOM outside the
76
+ * runtime import and call it themselves.
77
+ */
78
+ function registerDomSnapshotSerializer() {
79
+ if (registered) return;
80
+ registered = true;
81
+ expect.addSnapshotSerializer(domSnapshotSerializer);
82
+ }
83
+ //#endregion
84
+ //#region lib/types/fixtures.js
85
+ /**
86
+ * A complete quiescent conversation snapshot (open window, no traffic).
87
+ * @param sessionId - owning session id.
88
+ * @returns the snapshot; spread fixture overrides on top.
89
+ */
90
+ function conversationSnapshot(sessionId) {
91
+ return {
92
+ sessionId,
93
+ chat: EMPTY_CHAT_SNAPSHOT,
94
+ nodes: [],
95
+ turnTimings: /* @__PURE__ */ new Map(),
96
+ turnEnds: /* @__PURE__ */ new Map(),
97
+ partial: null,
98
+ runningCalls: [],
99
+ pending: [],
100
+ queue: [],
101
+ running: false,
102
+ subagent: null,
103
+ composerPhase: "active",
104
+ removed: false,
105
+ openState: "open",
106
+ openError: null,
107
+ hasMore: false,
108
+ loadingOlder: false,
109
+ promptError: null,
110
+ blank: false,
111
+ lastAgentError: null
112
+ };
113
+ }
114
+ /**
115
+ * A ready workspace list with no workspaces (the shape WorkspacesService
116
+ * projects after both baselines land).
117
+ * @returns the initial state of the test workspaces store.
118
+ */
119
+ function workspaceListState() {
120
+ return {
121
+ items: [],
122
+ archivedSessionIds: [],
123
+ state: "idle",
124
+ phase: "ready",
125
+ error: null,
126
+ baselinesReady: true,
127
+ recentWorkspaceId: void 0
128
+ };
129
+ }
130
+ //#endregion
131
+ //#region lib/types/sessions.js
132
+ /**
133
+ * The fixture-backed session face: conversation reads delegate to the
134
+ * fixture's snapshot store; ISession verbs are fail-loud stubs unless the
135
+ * fixture supplies them (the runtime never fakes behavior a test did not
136
+ * declare — an unstubbed call names itself instead of half-working). Extra
137
+ * fixture methods are grafted verbatim for feature-side casts.
138
+ */
139
+ var FixtureSession = class {
140
+ sessionId;
141
+ store;
142
+ /**
143
+ * The useProjection seat: identity-stable per-key faces over the fixture's
144
+ * projection values (set via {@link TestSessions.setProjection}).
145
+ */
146
+ projections;
147
+ /**
148
+ * @param sessionId - host identity (branded view of the fixture id).
149
+ * @param store - conversation snapshot store (updateSnapshot writes it).
150
+ * @param overrides - fixture-declared behavior face, grafted over the stubs.
151
+ */
152
+ constructor(sessionId, store, overrides) {
153
+ this.sessionId = sessionId;
154
+ this.store = store;
155
+ const values = /* @__PURE__ */ new Map();
156
+ const listeners = /* @__PURE__ */ new Map();
157
+ const faces = /* @__PURE__ */ new Map();
158
+ this.projections = {
159
+ faceOf: (key) => {
160
+ let face = faces.get(key);
161
+ if (face === void 0) {
162
+ face = {
163
+ getSnapshot: () => values.get(key),
164
+ subscribe: (fn) => {
165
+ const set = listeners.get(key) ?? /* @__PURE__ */ new Set();
166
+ set.add(fn);
167
+ listeners.set(key, set);
168
+ return () => {
169
+ set.delete(fn);
170
+ };
171
+ }
172
+ };
173
+ faces.set(key, face);
174
+ }
175
+ return face;
176
+ },
177
+ set: (key, value) => {
178
+ values.set(key, value);
179
+ for (const fn of [...listeners.get(key) ?? []]) fn();
180
+ }
181
+ };
182
+ Object.assign(this, overrides);
183
+ }
184
+ /** @returns the fixture conversation snapshot (useSession read side). */
185
+ getSnapshot() {
186
+ return this.store.getSnapshot();
187
+ }
188
+ /**
189
+ * Subscribe to fixture snapshot changes.
190
+ * @param fn - change callback.
191
+ * @returns unsubscribe.
192
+ */
193
+ subscribe(fn) {
194
+ return this.store.subscribe(fn);
195
+ }
196
+ /**
197
+ * Fail-loud stub; supply `prompt` on the fixture's session face to exercise it.
198
+ * @returns never — always throws.
199
+ */
200
+ prompt() {
201
+ throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`);
202
+ }
203
+ /**
204
+ * Fail-loud stub; supply `readAttachment` on the fixture's session face to exercise it.
205
+ * @param _attachmentId - opaque durable attachment id.
206
+ * @returns never — always throws.
207
+ */
208
+ readAttachment(_attachmentId) {
209
+ throw new Error(`test session "${this.sessionId}": readAttachment is not stubbed — supply it on the fixture's session face`);
210
+ }
211
+ /**
212
+ * Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it.
213
+ * @returns never — always throws.
214
+ */
215
+ updateQueue() {
216
+ throw new Error(`test session "${this.sessionId}": updateQueue is not stubbed — supply it on the fixture's session face`);
217
+ }
218
+ /**
219
+ * Fail-loud stub; supply `cancel` on the fixture's session face to exercise it.
220
+ * @returns never — always throws.
221
+ */
222
+ cancel() {
223
+ throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`);
224
+ }
225
+ /**
226
+ * Fail-loud stub; supply `command` on the fixture's session face to exercise it.
227
+ * @returns never — always throws.
228
+ */
229
+ command() {
230
+ throw new Error(`test session "${this.sessionId}": command is not stubbed — supply it on the fixture's session face`);
231
+ }
232
+ /**
233
+ * Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it.
234
+ * @returns never — always throws.
235
+ */
236
+ loadOlder() {
237
+ throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`);
238
+ }
239
+ /**
240
+ * Fail-loud stub; supply `rename` on the fixture's session face to exercise it.
241
+ * @returns never — always throws.
242
+ */
243
+ rename() {
244
+ throw new Error(`test session "${this.sessionId}": rename is not stubbed — supply it on the fixture's session face`);
245
+ }
246
+ };
247
+ /**
248
+ * Sessions test double behind the renderer host and feature injects: owns the
249
+ * list/current observable, the standard-props provide channel (the runtime's
250
+ * `useSession` contribution included), scope minting through the production
251
+ * `createScope`, and the session behavior face supplied per fixture.
252
+ *
253
+ * Implements the same ISessions face features receive as `ctx.sessions`, so
254
+ * a production face change breaks this double at compile time; the extra
255
+ * members (add/updateSnapshot/setCurrent/remove/behavior/calls/stubSearch and
256
+ * the legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
257
+ */
258
+ var TestSessions = class {
259
+ stabilize;
260
+ rootCtx;
261
+ /** The useSessions standard feed (list rows + current selection). */
262
+ list;
263
+ /**
264
+ * Atomic current-session provide projection (production SessionsService
265
+ * mirror): selection changes and provider-roster changes publish through
266
+ * this one source — the member the SlotsService host face hands the
267
+ * renderer's SessionProvider.
268
+ */
269
+ currentProvideInfo;
270
+ records = /* @__PURE__ */ new Map();
271
+ /** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
272
+ channel;
273
+ /** Calls observed on the service-level face, newest last. */
274
+ calls = [];
275
+ /** The wire schema's `session.search` result bound (production parity). */
276
+ searchResultLimit = SESSION_SEARCH_RESULT_LIMIT;
277
+ /** Replaceable search behavior (see {@link TestSessions.stubSearch}). */
278
+ searchStub;
279
+ /**
280
+ * @param stabilize - the owning runtime's act wrapper.
281
+ * @param rootCtx - the runtime's Cordis root; scope fibers mount under it.
282
+ */
283
+ constructor(stabilize, rootCtx) {
284
+ this.stabilize = stabilize;
285
+ this.rootCtx = rootCtx;
286
+ this.list = createSnapshotStore({
287
+ ids: [],
288
+ byId: {},
289
+ current: void 0,
290
+ phase: "ready",
291
+ subagentsByParent: {},
292
+ currentAddress: void 0
293
+ });
294
+ this.channel = new SessionProvideChannel({
295
+ rebuildBundles: () => {
296
+ for (const record of this.records.values()) if (record.provideInfo !== void 0) record.provideInfo = this.channel.materializeInfo(this.bindingOf(record.session.sessionId, record));
297
+ },
298
+ resolveCurrent: () => this.maybeProvideInfo(this.list.getSnapshot().current)
299
+ });
300
+ this.currentProvideInfo = this.channel.currentProvideInfo;
301
+ this.list.subscribe(() => {
302
+ this.channel.publishCurrent();
303
+ });
304
+ }
305
+ /**
306
+ * Add a session from a fixture and (by default) make it current.
307
+ * @param fixture - identity + snapshot/summary overrides + behavior face.
308
+ * @param opts - pass `current: false` to add without selecting.
309
+ * @returns the stable session id (branded view of `fixture.id`).
310
+ */
311
+ async add(fixture, opts) {
312
+ const id = fixture.id;
313
+ if (this.records.has(id)) throw new Error(`test session "${id}" already added`);
314
+ const summary = {
315
+ id,
316
+ displayTitle: fixture.id,
317
+ running: false,
318
+ blank: false,
319
+ updatedAt: this.records.size + 1,
320
+ ...fixture.summary
321
+ };
322
+ const snapshot = createSnapshotStore({
323
+ ...conversationSnapshot(id),
324
+ ...fixture.snapshot
325
+ });
326
+ this.records.set(id, {
327
+ summary,
328
+ snapshot,
329
+ session: new FixtureSession(id, snapshot, fixture.session ?? {}),
330
+ scope: void 0,
331
+ scopeFiber: void 0,
332
+ provideInfo: void 0
333
+ });
334
+ await this.stabilize(() => {
335
+ this.list.update((draft) => {
336
+ draft.ids.push(id);
337
+ draft.byId[id] = summary;
338
+ if (opts?.current !== false) draft.current = id;
339
+ });
340
+ });
341
+ return id;
342
+ }
343
+ /**
344
+ * Update a session's conversation snapshot through an immer draft (the
345
+ * live-stream stand-in: components subscribed via useSession re-render).
346
+ * @param id - session id.
347
+ * @param mutate - draft mutator.
348
+ */
349
+ async updateSnapshot(id, mutate) {
350
+ const record = this.require(id);
351
+ await this.stabilize(() => {
352
+ record.snapshot.update(mutate);
353
+ });
354
+ }
355
+ /**
356
+ * Update a session's list row (the wire-echo stand-in: title settles,
357
+ * running flips — components subscribed via useSessions re-render).
358
+ * @param id - session id.
359
+ * @param patch - summary fields to merge over the row.
360
+ */
361
+ async updateSummary(id, patch) {
362
+ const record = this.require(id);
363
+ record.summary = {
364
+ ...record.summary,
365
+ ...patch
366
+ };
367
+ await this.stabilize(() => {
368
+ this.list.update((draft) => {
369
+ draft.byId[id] = record.summary;
370
+ });
371
+ });
372
+ }
373
+ /**
374
+ * Switch the current selection (undefined = the no-session empty state).
375
+ * @param id - session id to select, or undefined to clear.
376
+ */
377
+ async setCurrent(id) {
378
+ if (id !== void 0) this.require(id);
379
+ await this.stabilize(() => {
380
+ this.list.update((draft) => {
381
+ draft.current = id;
382
+ });
383
+ });
384
+ }
385
+ /**
386
+ * Remove a session: list row, scope fiber, and per-session store instances
387
+ * (with persisted state) die together — the same single lifecycle axis the
388
+ * production SessionsService drives on session death, minus staging.
389
+ * @param id - session id.
390
+ */
391
+ async remove(id) {
392
+ const record = this.require(id);
393
+ this.records.delete(id);
394
+ await this.stabilize(async () => {
395
+ this.list.update((draft) => {
396
+ draft.ids = draft.ids.filter((existing) => existing !== id);
397
+ const { [id]: _dead, ...rest } = draft.byId;
398
+ draft.byId = rest;
399
+ if (draft.current === id) draft.current = void 0;
400
+ });
401
+ if (record.scopeFiber !== void 0) await record.scopeFiber.dispose();
402
+ this.rootCtx.get("slots")?.pruneStoreScope(id);
403
+ });
404
+ }
405
+ /**
406
+ * Register a per-session standard-props provider (production `provide`
407
+ * contract: hooks become `use<Name>` selector hooks on the render side,
408
+ * props spread verbatim; duplicate names fail loud at materialization).
409
+ * @param descriptor - static member roster plus per-session resolver.
410
+ * @returns disposer removing the provider.
411
+ */
412
+ provide(descriptor) {
413
+ return this.channel.provide(descriptor);
414
+ }
415
+ /**
416
+ * Resolve the definite per-session standard-props bundle (host face member).
417
+ * @param id - session id.
418
+ * @returns the identity-stable bundle, or undefined for unknown sessions.
419
+ */
420
+ provideInfo(id) {
421
+ const record = this.records.get(id);
422
+ if (record === void 0) return void 0;
423
+ record.provideInfo ??= this.channel.materializeInfo(this.bindingOf(id, record));
424
+ return record.provideInfo;
425
+ }
426
+ /**
427
+ * Resolve the current-session-optional standard kit (host face member):
428
+ * unknown or absent ids return the static no-session projection.
429
+ * @param id - current session id, when selected.
430
+ * @returns a definite or no-session provide bundle.
431
+ */
432
+ maybeProvideInfo(id) {
433
+ return (id === void 0 ? void 0 : this.provideInfo(id)) ?? this.channel.maybeInfo;
434
+ }
435
+ /**
436
+ * Resolve (mint on first touch) the session-scoped Cordis context through
437
+ * the production `createScope`, so real `scopeOf`/scope-addressed services
438
+ * resolve it.
439
+ * @param id - session id.
440
+ * @returns the scoped context, or undefined for unknown sessions.
441
+ */
442
+ scope(id) {
443
+ const record = this.records.get(id);
444
+ if (record === void 0) return void 0;
445
+ if (record.scope === void 0) {
446
+ const handle = createScope(this.rootCtx, id);
447
+ record.scope = handle.ctx;
448
+ record.scopeFiber = handle.fiber;
449
+ }
450
+ return record.scope;
451
+ }
452
+ /**
453
+ * Session assembly binding (inject factories and provide resolvers receive it).
454
+ * @param id - session id.
455
+ * @returns sessionId + behavior face + scoped ctx, or undefined when unknown.
456
+ */
457
+ binding(id) {
458
+ const record = this.records.get(id);
459
+ if (record === void 0) return void 0;
460
+ return this.bindingOf(id, record);
461
+ }
462
+ /**
463
+ * Read the session scope tag off a context (service-method boundary mirror).
464
+ * @param ctx - any client context.
465
+ * @returns the session id, or undefined on root contexts.
466
+ */
467
+ scopeOf(ctx) {
468
+ return scopeOf(ctx);
469
+ }
470
+ /**
471
+ * Resolve the scoped session face off a context (production `sessionOf`
472
+ * mirror).
473
+ * @param ctx - any client context.
474
+ * @returns the fixture session face, or undefined off-scope.
475
+ */
476
+ sessionOf(ctx) {
477
+ const id = scopeOf(ctx);
478
+ if (id === void 0) return void 0;
479
+ return this.records.get(id)?.session;
480
+ }
481
+ /**
482
+ * Service-level selection call (recorded, then applied to the list store
483
+ * synchronously — inject callbacks call this outside any act window; the
484
+ * store notify is microtask-batched so the next stabilized step observes it).
485
+ * @param id - session id.
486
+ */
487
+ open(id) {
488
+ this.calls.push({
489
+ method: "open",
490
+ args: [id]
491
+ });
492
+ this.require(id);
493
+ this.list.update((draft) => {
494
+ draft.current = id;
495
+ draft.currentAddress = void 0;
496
+ });
497
+ }
498
+ /** Open an existing fixture through its catalog address. */
499
+ openSubagent(address) {
500
+ this.calls.push({
501
+ method: "openSubagent",
502
+ args: [address]
503
+ });
504
+ this.require(address.childSessionId);
505
+ this.list.update((draft) => {
506
+ draft.current = address.childSessionId;
507
+ draft.currentAddress = address;
508
+ });
509
+ }
510
+ /** Resolve the current fixture's retained catalog address. */
511
+ subagentAddress(id) {
512
+ const address = this.list.getSnapshot().currentAddress;
513
+ return address?.childSessionId === id ? address : void 0;
514
+ }
515
+ /** Record catalog consumption; fixture callers drive snapshots explicitly. */
516
+ setSubagentCatalogOpen(parentSessionId, open) {
517
+ this.calls.push({
518
+ method: "setSubagentCatalogOpen",
519
+ args: [parentSessionId, open]
520
+ });
521
+ }
522
+ /** Record a catalog refresh; fixture callers drive snapshots explicitly. */
523
+ refreshSubagents(parentSessionId) {
524
+ this.calls.push({
525
+ method: "refreshSubagents",
526
+ args: [parentSessionId]
527
+ });
528
+ return Promise.resolve();
529
+ }
530
+ /** Apply a confirmed preset switch into the fixture list, as production does. */
531
+ noteAgentPreset(sessionId, agentPreset) {
532
+ this.list.update((draft) => {
533
+ const summary = draft.byId[sessionId];
534
+ if (summary !== void 0) draft.byId[sessionId] = {
535
+ ...summary,
536
+ agentPreset
537
+ };
538
+ });
539
+ }
540
+ /** Clear the current selection (recorded; the production no-session flow). */
541
+ clear() {
542
+ this.calls.push({
543
+ method: "clear",
544
+ args: []
545
+ });
546
+ this.list.update((draft) => {
547
+ draft.current = void 0;
548
+ draft.currentAddress = void 0;
549
+ });
550
+ }
551
+ /**
552
+ * Replace the sidebar-search result page (the call is still recorded).
553
+ * @param impl - hits for a query, as the Host would rank them.
554
+ */
555
+ stubSearch(impl) {
556
+ this.searchStub = impl;
557
+ }
558
+ /**
559
+ * Content search over the fixture corpus (recorded). The default answers an
560
+ * empty page: content ranking is Host behavior, so a scenario that asserts
561
+ * hits declares them through {@link TestSessions.stubSearch}.
562
+ * @param query - non-blank literal phrase.
563
+ * @param signal - cancellation for a superseded search (recorded and forwarded).
564
+ * @returns the stubbed or empty result page.
565
+ */
566
+ search(query, signal) {
567
+ this.calls.push({
568
+ method: "search",
569
+ args: [query, signal]
570
+ });
571
+ return Promise.resolve({
572
+ ok: true,
573
+ value: this.searchStub?.(query, signal) ?? {
574
+ items: [],
575
+ hasMore: false
576
+ }
577
+ });
578
+ }
579
+ /**
580
+ * Recorded fork stub: no child materializes (benches asserting the full
581
+ * fork flow drive the production service; this face only proves the call).
582
+ * @param opts - source session id, optional cut anchor, and client title policy.
583
+ * @returns the source id (no child record is created).
584
+ */
585
+ fork(opts) {
586
+ this.calls.push({
587
+ method: "fork",
588
+ args: [opts]
589
+ });
590
+ return Promise.resolve(opts.sessionId);
591
+ }
592
+ /**
593
+ * The session face of a fixture (typed view for assertions; fixture
594
+ * behavior methods are grafted onto it).
595
+ * @param id - session id.
596
+ * @returns the FixtureSession the binding and provide channel carry.
597
+ */
598
+ behavior(id) {
599
+ return this.require(id).session;
600
+ }
601
+ /** Dispose minted scope fibers (runtime dispose path). */
602
+ async disposeScopes() {
603
+ for (const record of this.records.values()) if (record.scopeFiber !== void 0) {
604
+ await record.scopeFiber.dispose();
605
+ record.scope = void 0;
606
+ record.scopeFiber = void 0;
607
+ }
608
+ }
609
+ bindingOf(id, record) {
610
+ const ctx = this.scope(id);
611
+ /* v8 ignore next 2 -- bindingOf only runs for a live record, whose scope
612
+ * always resolves; kept so a future caller cannot mint a ctx-less binding. */
613
+ if (ctx === void 0) throw new Error(`test session "${id}" resolved no scope`);
614
+ return {
615
+ sessionId: id,
616
+ session: record.session,
617
+ ctx
618
+ };
619
+ }
620
+ require(id) {
621
+ const record = this.records.get(id);
622
+ if (record === void 0) throw new Error(`test session "${id}" is not added`);
623
+ return record;
624
+ }
625
+ };
626
+ //#endregion
627
+ //#region lib/types/workspaces.js
628
+ /** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
629
+ /**
630
+ * Workspaces test double. Implements the same IWorkspaces face features
631
+ * receive as `ctx.workspaces`, so a production face change breaks this
632
+ * double at compile time. Every action records into {@link
633
+ * TestWorkspaces.calls}; defaults are inert echoes — feature tests needing
634
+ * richer behavior replace them via {@link TestWorkspaces.stub}.
635
+ */
636
+ var TestWorkspaces = class {
637
+ stabilize;
638
+ /** The useWorkspaces standard feed. */
639
+ list;
640
+ /** Calls observed on the action face, newest last. */
641
+ calls = [];
642
+ /** Replaceable action seat: feature tests may stub richer behavior. */
643
+ stubs = /* @__PURE__ */ new Map();
644
+ /**
645
+ * @param stabilize - the owning runtime's act wrapper.
646
+ */
647
+ constructor(stabilize) {
648
+ this.stabilize = stabilize;
649
+ this.list = createSnapshotStore(workspaceListState());
650
+ }
651
+ /**
652
+ * Update the workspace list state through an immer draft.
653
+ * @param mutate - draft mutator.
654
+ */
655
+ async update(mutate) {
656
+ await this.stabilize(() => {
657
+ this.list.update(mutate);
658
+ });
659
+ }
660
+ /**
661
+ * Replace an action's behavior (the recorded call is still appended first).
662
+ * @param method - action name (e.g. 'connectWorkspace').
663
+ * @param impl - replacement behavior.
664
+ */
665
+ stub(method, impl) {
666
+ this.stubs.set(method, impl);
667
+ }
668
+ /**
669
+ * Connect a workspace to its reusable/new blank session (recorded). The
670
+ * default resolves the workspace id back as the session id; stub for
671
+ * cross-session flows.
672
+ * @param workspaceId - target workspace.
673
+ * @returns the connected session id.
674
+ */
675
+ async connectWorkspace(workspaceId) {
676
+ this.calls.push({
677
+ method: "connectWorkspace",
678
+ args: [workspaceId]
679
+ });
680
+ const stub = this.stubs.get("connectWorkspace");
681
+ if (stub !== void 0) return await stub(workspaceId);
682
+ return `session-of-${workspaceId}`;
683
+ }
684
+ /**
685
+ * New-session flow (recorded; stubbed behavior runs when installed).
686
+ * @param workspaceId - optional explicit workspace target.
687
+ */
688
+ startSession(workspaceId) {
689
+ this.calls.push({
690
+ method: "startSession",
691
+ args: [workspaceId]
692
+ });
693
+ this.stubs.get("startSession")?.(workspaceId);
694
+ }
695
+ /**
696
+ * Create a Workspace (recorded). The default echoes a view derived from
697
+ * the input; stub for failure or list-coupled flows.
698
+ * @param input - the Host create payload.
699
+ * @returns the created Workspace view.
700
+ */
701
+ async create(input) {
702
+ this.calls.push({
703
+ method: "create",
704
+ args: [input]
705
+ });
706
+ const stub = this.stubs.get("create");
707
+ if (stub !== void 0) return await stub(input);
708
+ return {
709
+ workspaceId: `ws-${input.path}`,
710
+ title: input.path,
711
+ path: input.path,
712
+ sessionIds: []
713
+ };
714
+ }
715
+ /**
716
+ * Open a path with the host OS default application (recorded; default no-op).
717
+ * @param path - host-resolvable path.
718
+ */
719
+ async openPath(path) {
720
+ this.calls.push({
721
+ method: "openPath",
722
+ args: [path]
723
+ });
724
+ await this.stubs.get("openPath")?.(path);
725
+ }
726
+ /**
727
+ * Directory picker (recorded). The default cancels (null); stub to select.
728
+ * @returns the picked path, or null.
729
+ */
730
+ async pickDirectory() {
731
+ this.calls.push({
732
+ method: "pickDirectory",
733
+ args: []
734
+ });
735
+ const stub = this.stubs.get("pickDirectory");
736
+ if (stub !== void 0) return await stub();
737
+ return null;
738
+ }
739
+ /**
740
+ * Browse listing (recorded). The default serves an empty home level; stub
741
+ * to shape a tree.
742
+ * @param path - absolute directory to list; absent lists the home level.
743
+ * @returns the level's listing.
744
+ */
745
+ async listDirectory(path, signal) {
746
+ this.calls.push({
747
+ method: "listDirectory",
748
+ args: [path, signal]
749
+ });
750
+ const stub = this.stubs.get("listDirectory");
751
+ if (stub !== void 0) return await stub(path, signal);
752
+ return {
753
+ path: "/home/test",
754
+ home: "/home/test",
755
+ crumbs: [
756
+ {
757
+ name: "/",
758
+ path: "/",
759
+ hidden: false
760
+ },
761
+ {
762
+ name: "home",
763
+ path: "/home",
764
+ hidden: false
765
+ },
766
+ {
767
+ name: "test",
768
+ path: "/home/test",
769
+ hidden: false
770
+ }
771
+ ],
772
+ entries: [],
773
+ truncated: false
774
+ };
775
+ }
776
+ /**
777
+ * Browse child creation (recorded). The default joins parent and name.
778
+ * @param path - absolute existing parent directory.
779
+ * @param name - single path segment.
780
+ * @returns the created directory's absolute path.
781
+ */
782
+ async createDirectory(path, name) {
783
+ this.calls.push({
784
+ method: "createDirectory",
785
+ args: [path, name]
786
+ });
787
+ const stub = this.stubs.get("createDirectory");
788
+ if (stub !== void 0) return await stub(path, name);
789
+ return `${path}/${name}`;
790
+ }
791
+ /**
792
+ * Rename a Workspace (recorded). The default echoes a minimal view.
793
+ * @param workspaceId - target workspace.
794
+ * @param title - new title.
795
+ * @returns the updated view.
796
+ */
797
+ async rename(workspaceId, title) {
798
+ this.calls.push({
799
+ method: "rename",
800
+ args: [workspaceId, title]
801
+ });
802
+ const stub = this.stubs.get("rename");
803
+ if (stub !== void 0) return await stub(workspaceId, title);
804
+ return {
805
+ workspaceId,
806
+ title,
807
+ path: `/${title}`,
808
+ sessionIds: []
809
+ };
810
+ }
811
+ /**
812
+ * Delete a Workspace (recorded; default no-op).
813
+ * @param workspaceId - target workspace.
814
+ */
815
+ async delete(workspaceId) {
816
+ this.calls.push({
817
+ method: "delete",
818
+ args: [workspaceId]
819
+ });
820
+ await this.stubs.get("delete")?.(workspaceId);
821
+ }
822
+ /**
823
+ * Move an accounted session (recorded). The default echoes a minimal view.
824
+ * @param workspaceId - target workspace.
825
+ * @param sessionId - session to move.
826
+ * @param beforeSessionId - anchor; omitted appends.
827
+ * @returns the updated view.
828
+ */
829
+ async insertSessionBefore(workspaceId, sessionId, beforeSessionId) {
830
+ this.calls.push({
831
+ method: "insertSessionBefore",
832
+ args: [
833
+ workspaceId,
834
+ sessionId,
835
+ beforeSessionId
836
+ ]
837
+ });
838
+ const stub = this.stubs.get("insertSessionBefore");
839
+ if (stub !== void 0) return await stub(workspaceId, sessionId, beforeSessionId);
840
+ return {
841
+ workspaceId,
842
+ title: "",
843
+ path: "",
844
+ sessionIds: [sessionId]
845
+ };
846
+ }
847
+ /**
848
+ * Archive a session (recorded). The default mirrors the production face's
849
+ * observable effect: the id joins the list state's archive set.
850
+ * @param sessionId - session to archive.
851
+ */
852
+ async archiveSession(sessionId) {
853
+ this.calls.push({
854
+ method: "archiveSession",
855
+ args: [sessionId]
856
+ });
857
+ const stub = this.stubs.get("archiveSession");
858
+ if (stub !== void 0) {
859
+ await stub(sessionId);
860
+ return;
861
+ }
862
+ await this.update((draft) => {
863
+ draft.archivedSessionIds = [...draft.archivedSessionIds, sessionId];
864
+ });
865
+ }
866
+ };
867
+ //#endregion
868
+ //#region lib/types/settings-scope.js
869
+ /** Test double for the client settings-scope seam. */
870
+ /**
871
+ * Build an in-memory settings scope for service specs: starts in the host
872
+ * loading state, records writes, and lets the test publish Host acceptances.
873
+ * @returns the stub handle.
874
+ */
875
+ function stubSettingsScope() {
876
+ let snapshot = {
877
+ status: "loading",
878
+ value: void 0,
879
+ revision: void 0,
880
+ writable: false,
881
+ mode: "host"
882
+ };
883
+ const listeners = /* @__PURE__ */ new Set();
884
+ const set = vi.fn(() => Promise.resolve());
885
+ return {
886
+ scope: {
887
+ getSnapshot: () => snapshot,
888
+ subscribe: (listener) => {
889
+ listeners.add(listener);
890
+ return () => {
891
+ listeners.delete(listener);
892
+ };
893
+ },
894
+ set
895
+ },
896
+ set,
897
+ listenerCount: () => listeners.size,
898
+ publish: (next) => {
899
+ snapshot = {
900
+ ...snapshot,
901
+ ...next
902
+ };
903
+ for (const listener of [...listeners]) listener();
904
+ }
905
+ };
906
+ }
907
+ //#endregion
908
+ //#region lib/types/translate.js
909
+ /**
910
+ * Test double of the locale lookup chain: a translate stub over plain
911
+ * dictionaries, mirroring LocaleService's resolution order (first dictionary
912
+ * that owns the key wins, then the key itself stays visible) and its
913
+ * `{name}` template interpolation. Specs stub the framework-injected `t`
914
+ * seat with `makeTranslate(zh, commonZh)` instead of re-implementing the
915
+ * chain per suite.
916
+ */
917
+ /**
918
+ * Build a translate stub resolving through `dicts` in order (namespace
919
+ * first, then the shared common vocabulary), falling back to the key.
920
+ * @param dicts - dictionaries consulted in order.
921
+ * @returns the translate function (assignable to any `XxxProps['t']` seat).
922
+ */
923
+ function makeTranslate(...dicts) {
924
+ return (key, params) => {
925
+ let template = key;
926
+ for (const dict of dicts) {
927
+ const hit = dict[key];
928
+ if (hit !== void 0) {
929
+ template = hit;
930
+ break;
931
+ }
932
+ }
933
+ if (!params) return template;
934
+ return template.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match);
935
+ };
936
+ }
937
+ //#endregion
938
+ //#region lib/types/locale-env.js
939
+ /**
940
+ * Browser-language pin for specs that assert localized copy. A fresh
941
+ * LocaleService with no stored preference opens in the language `navigator`
942
+ * asks for, and jsdom reports the runner's own (`en-US`) — so a spec asserting
943
+ * the product's Chinese copy states the browser it assumes instead of
944
+ * inheriting the machine's.
945
+ */
946
+ /**
947
+ * Pin `navigator.languages`/`navigator.language` for every test in the
948
+ * calling file (or describe block), restoring the environment's own values
949
+ * afterwards. Call at suite level, like the other vitest hooks.
950
+ * @param primary - most preferred BCP 47 tag; also becomes `navigator.language`.
951
+ * @param rest - further tags in preference order.
952
+ */
953
+ function usePinnedBrowserLanguages(primary, ...rest) {
954
+ beforeEach(() => {
955
+ Object.defineProperty(navigator, "languages", {
956
+ value: [primary, ...rest],
957
+ configurable: true
958
+ });
959
+ Object.defineProperty(navigator, "language", {
960
+ value: primary,
961
+ configurable: true
962
+ });
963
+ });
964
+ afterEach(() => {
965
+ const own = navigator;
966
+ delete own.languages;
967
+ delete own.language;
968
+ });
969
+ }
970
+ //#endregion
971
+ //#region lib/types/index.js
972
+ /**
973
+ * jsdom slot test runtime: a real small runtime — Cordis `Context`, the
974
+ * runtime `SlotsService`, and the web-react renderer — assembled around
975
+ * test-owned session/workspace doubles, so feature specs exercise
976
+ * declaration, registration, scope, store, inject, rendering, updates, and
977
+ * disposal without hand-building the machinery per suite.
978
+ *
979
+ * Not part of the product plugin graph (no `dsh.client`); feature packages
980
+ * depend on it in devDependencies only. It copies no SlotCore/renderer/store
981
+ * machinery — everything mounts the production implementations.
982
+ * @module @deepseek-ai/dsh-client-test-runtime
983
+ */
984
+ /**
985
+ * Owner-props cell behind the auto frame: one external store the frame
986
+ * subscribes to, so {@link SlotTestRuntime.renderSlot} and
987
+ * {@link SlotView.update} drive React through the standard uSES boundary.
988
+ */
989
+ var OwnerPropsCell = class {
990
+ owners = /* @__PURE__ */ new Map();
991
+ listeners = /* @__PURE__ */ new Set();
992
+ version = 0;
993
+ /** Snapshot version for uSES pairing (bumped on every set). */
994
+ getVersion = () => this.version;
995
+ /**
996
+ * Subscribe to owner-props changes.
997
+ * @param fn - change callback.
998
+ * @returns unsubscribe.
999
+ */
1000
+ subscribe = (fn) => {
1001
+ this.listeners.add(fn);
1002
+ return () => {
1003
+ this.listeners.delete(fn);
1004
+ };
1005
+ };
1006
+ /**
1007
+ * Install or replace one key's owner props and notify (synchronous; the
1008
+ * caller wraps in act).
1009
+ * @param key - slot key.
1010
+ * @param owner - owner props share.
1011
+ */
1012
+ set(key, owner) {
1013
+ this.owners.set(key, owner);
1014
+ this.version += 1;
1015
+ for (const fn of [...this.listeners]) fn();
1016
+ }
1017
+ /** Keys with supplied owner props, in first-supply order. */
1018
+ entries() {
1019
+ return [...this.owners.entries()];
1020
+ }
1021
+ };
1022
+ /**
1023
+ * The test-owned 'root' occupant: declares the child slots a suite needs
1024
+ * through the REAL `slots.register`, with a caller-supplied minimal frame —
1025
+ * the runtime never guesses a feature's page structure.
1026
+ */
1027
+ var TestRoot = class {
1028
+ slots;
1029
+ stabilize;
1030
+ disposeEntry;
1031
+ /**
1032
+ * @param slots - the runtime SlotsService.
1033
+ * @param stabilize - the owning runtime's act wrapper.
1034
+ */
1035
+ constructor(slots, stabilize) {
1036
+ this.slots = slots;
1037
+ this.stabilize = stabilize;
1038
+ }
1039
+ /**
1040
+ * Register the root frame, declaring (and thereby claiming) the child
1041
+ * slots. One declaration per runtime — a second call fails loud in the
1042
+ * core ('root' is a single slot).
1043
+ * @param children - child-slot declaration table (declaration + render authorization + runtime spec).
1044
+ * @param frame - minimal frame component; its props derive from the declared keys (composed-props contract).
1045
+ * @returns completion of the act-wrapped registration.
1046
+ */
1047
+ async declare(children, frame) {
1048
+ await this.stabilize(() => {
1049
+ this.disposeEntry = this.slots.register({
1050
+ name: "root",
1051
+ children
1052
+ }, frame);
1053
+ });
1054
+ }
1055
+ /** Remove the root registration and collapse its declarations (runtime dispose path). */
1056
+ release() {
1057
+ this.disposeEntry?.();
1058
+ this.disposeEntry = void 0;
1059
+ }
1060
+ };
1061
+ /**
1062
+ * The assembled test runtime. Obtain via {@link SlotTestRuntime.create};
1063
+ * dispose with {@link SlotTestRuntime.dispose} (afterEach). Public mutators
1064
+ * are act-wrapped throughout — tests never handle SlotCore microtask
1065
+ * batching or React act themselves.
1066
+ */
1067
+ var SlotTestRuntime = class SlotTestRuntime {
1068
+ /** The runtime's Cordis root (escape hatch: extra services via `ctx.provide`, raw `ctx.plugin` mounts). */
1069
+ ctx;
1070
+ /** The production SlotsService mounted on {@link SlotTestRuntime.ctx}. */
1071
+ slots;
1072
+ /** The test-owned 'root' occupant. */
1073
+ root;
1074
+ /** Sessions double (list/current observable, cells, scopes, behavior faces). */
1075
+ sessions;
1076
+ /** Workspaces double (list observable, recorded intent actions). */
1077
+ workspaces;
1078
+ stabilizer = async (fn) => {
1079
+ await act(async () => {
1080
+ await fn();
1081
+ });
1082
+ };
1083
+ host;
1084
+ views = [];
1085
+ handles = [];
1086
+ disposed = false;
1087
+ /** Auto-frame state ({@link SlotTestRuntime.declare} / {@link SlotTestRuntime.renderSlot}). */
1088
+ ownerCell = new OwnerPropsCell();
1089
+ autoDeclared = /* @__PURE__ */ new Set();
1090
+ autoRootView;
1091
+ constructor(ctx, slots) {
1092
+ this.ctx = ctx;
1093
+ this.slots = slots;
1094
+ this.root = new TestRoot(slots, this.stabilizer);
1095
+ this.sessions = new TestSessions(this.stabilizer, ctx);
1096
+ this.workspaces = new TestWorkspaces(this.stabilizer);
1097
+ ctx.provide("sessions", this.sessions);
1098
+ ctx.provide("workspaces", this.workspaces);
1099
+ const renderer = createSlotRenderer();
1100
+ slots.install({ renderRoot: (host, ownerProps) => {
1101
+ this.host = host;
1102
+ return renderer.renderRoot(host, ownerProps);
1103
+ } });
1104
+ }
1105
+ /**
1106
+ * Assemble a runtime: real Context, mounted SlotsService, installed
1107
+ * renderer, and the session/workspace doubles provided as services.
1108
+ * @returns the ready runtime.
1109
+ */
1110
+ static async create() {
1111
+ registerDomSnapshotSerializer();
1112
+ const ctx = new Context();
1113
+ await ctx.plugin(SlotsService).await();
1114
+ await ctx.plugin(ConversationEventRegistry).await();
1115
+ await ctx.plugin(ConversationViewRegistry).await();
1116
+ return new SlotTestRuntime(ctx, ctx.get("slots"));
1117
+ }
1118
+ /**
1119
+ * Provide an extra service the feature under test injects (e.g. a layout
1120
+ * fake). Sugar over `ctx.provide`, typed against the Context declaration
1121
+ * merge: for a declared service name the fake must be a subset of that
1122
+ * service's outward face (Partial — supply only what the feature calls),
1123
+ * so a production face change breaks the fake at compile time. Undeclared
1124
+ * names stay unchecked (ad-hoc test services).
1125
+ * @param name - service name.
1126
+ * @param value - service implementation (test double).
1127
+ */
1128
+ provide(name, value) {
1129
+ this.ctx.provide(name, value);
1130
+ }
1131
+ /**
1132
+ * Mount a feature plugin on a real fiber. Required services are prechecked
1133
+ * so a missing provider fails loud instead of suspending the fiber forever
1134
+ * (deliberate load-order suspension tests use `ctx.plugin` directly).
1135
+ * @param plugin - plugin value (function, class, or `{ inject, apply }` object).
1136
+ * @returns handle owning the fiber's explicit disposal.
1137
+ */
1138
+ async mount(plugin) {
1139
+ const missing = Object.keys(Inject.resolve(plugin.inject)).filter((name) => this.ctx.get(name) === void 0);
1140
+ if (missing.length > 0) throw new Error(`mount would suspend: missing service(s) ${missing.join(", ")} — provide() them first`);
1141
+ const fiber = this.ctx.plugin(plugin);
1142
+ await this.stabilizer(async () => {
1143
+ await fiber.await();
1144
+ });
1145
+ let disposed = false;
1146
+ const handle = {
1147
+ fiber,
1148
+ dispose: async () => {
1149
+ if (disposed) return;
1150
+ disposed = true;
1151
+ await this.stabilizer(() => fiber.dispose());
1152
+ }
1153
+ };
1154
+ this.handles.push(handle);
1155
+ return handle;
1156
+ }
1157
+ /**
1158
+ * Render the root slot tree through the ctx-level entry (the shell's own
1159
+ * entry point): `ctx.slots.renderSlot('root', {})` under Testing Library.
1160
+ * @returns the Testing Library view.
1161
+ */
1162
+ renderRoot() {
1163
+ const view = render(createElement(Fragment, null, this.slots.renderSlot("root", {})));
1164
+ this.views.push(view);
1165
+ return view;
1166
+ }
1167
+ /**
1168
+ * Declare child slots under an auto-generated root frame — the single-slot
1169
+ * mounting path for local DOM snapshots. Each key later supplied through
1170
+ * {@link SlotTestRuntime.renderSlot} renders inside its own
1171
+ * `<div data-slot="<key>">` wrapper (the snapshot root). Mutually exclusive
1172
+ * with {@link TestRoot.declare} ('root' is a single slot); one call per
1173
+ * runtime.
1174
+ * @param children - child-slot declaration table (same contract as TestRoot.declare).
1175
+ * @returns completion of the act-wrapped registration.
1176
+ */
1177
+ async declare(children) {
1178
+ for (const key of Object.keys(children)) this.autoDeclared.add(key);
1179
+ const cell = this.ownerCell;
1180
+ const AutoFrame = (props) => {
1181
+ useSyncExternalStore(cell.subscribe, cell.getVersion);
1182
+ return createElement(Fragment, null, cell.entries().map(([key, owner]) => createElement("div", {
1183
+ "data-slot": key,
1184
+ key
1185
+ }, props.renderSlot(key, owner))));
1186
+ };
1187
+ await this.root.declare(children, AutoFrame);
1188
+ }
1189
+ /**
1190
+ * Render one declared slot with its owner props and return the local view.
1191
+ * The whole root tree mounts through the production assembly path
1192
+ * (renderer, scope providers, store axis); only this key's output lands in
1193
+ * the returned container. Call again with another key to view a sibling
1194
+ * slot of the same tree.
1195
+ * @param key - a key declared through {@link SlotTestRuntime.declare}.
1196
+ * @param owner - owner props share for the render site.
1197
+ * @returns the slot-local view (snapshot container, scoped queries, owner updates).
1198
+ */
1199
+ renderSlot(key, owner) {
1200
+ if (!this.autoDeclared.has(key)) throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`);
1201
+ const install = (next) => {
1202
+ act(() => {
1203
+ this.ownerCell.set(key, next);
1204
+ });
1205
+ };
1206
+ install(owner);
1207
+ this.autoRootView ??= this.renderRoot();
1208
+ const container = this.autoRootView.container.querySelector(`[data-slot="${key}"]`);
1209
+ if (!(container instanceof HTMLElement)) throw new Error(`renderSlot('${key}'): the auto frame rendered no wrapper — was the runtime already disposed?`);
1210
+ return {
1211
+ container,
1212
+ view: within(container),
1213
+ update: install
1214
+ };
1215
+ }
1216
+ /**
1217
+ * Resolve the store instance the renderer would hand a slot's component
1218
+ * (identity assertions, action-driven writes). Requires a prior
1219
+ * {@link SlotTestRuntime.renderRoot} — the host face exists only inside the
1220
+ * installed renderer, exactly as in production.
1221
+ * @param key - slot key whose first entry declares the store.
1222
+ * @param scopeKey - session id for session-scope slots; omit for root scope.
1223
+ * @returns the live store instance.
1224
+ */
1225
+ storeOf(key, scopeKey) {
1226
+ if (this.host === void 0) throw new Error("storeOf before renderRoot() — the host face exists only inside the installed renderer");
1227
+ const entry = this.host.entriesOf(key)[0];
1228
+ if (entry === void 0) throw new Error(`storeOf('${key}'): no registration on the ledger`);
1229
+ const instance = this.host.storeOf(entry, scopeKey);
1230
+ if (instance === void 0) throw new Error(`storeOf('${key}'): the entry declares no store`);
1231
+ return instance;
1232
+ }
1233
+ /**
1234
+ * Flush pending ledger/store notifications inside act — for mutations made
1235
+ * outside the runtime's own methods (e.g. a direct `slots.register`).
1236
+ * @returns completion of the act pass.
1237
+ */
1238
+ async flush() {
1239
+ await this.stabilizer(() => {});
1240
+ }
1241
+ /**
1242
+ * Tear down: unmount React trees first, then dispose feature fibers, the
1243
+ * root registration, minted session scopes, and persisted test state.
1244
+ * Idempotent.
1245
+ * @returns completion of the teardown.
1246
+ */
1247
+ async dispose() {
1248
+ if (this.disposed) return;
1249
+ this.disposed = true;
1250
+ this.autoRootView = void 0;
1251
+ for (const view of this.views.splice(0)) view.unmount();
1252
+ for (const handle of this.handles.splice(0)) await handle.dispose();
1253
+ this.root.release();
1254
+ await this.sessions.disposeScopes();
1255
+ localStorage.clear();
1256
+ }
1257
+ };
1258
+ //#endregion
1259
+ export { FixtureSession, SlotTestRuntime, TestRoot, TestSessions, TestWorkspaces, conversationSnapshot, domSnapshotSerializer, makeTranslate, registerDomSnapshotSerializer, stubSettingsScope, usePinnedBrowserLanguages, workspaceListState };