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