@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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +150 -0
- package/README.zh.md +150 -0
- package/lib/index.js +1377 -0
- package/lib/types/fixtures.d.ts +71 -0
- package/lib/types/index.d.ts +212 -0
- package/lib/types/locale-env.d.ts +9 -0
- package/lib/types/remote.d.ts +58 -0
- package/lib/types/sessions.d.ts +276 -0
- package/lib/types/settings-remote.d.ts +83 -0
- package/lib/types/settings-scope.d.ts +29 -0
- package/lib/types/snapshot.d.ts +14 -0
- package/lib/types/translate.d.ts +16 -0
- package/lib/types/workspaces.d.ts +89 -0
- package/package.json +74 -0
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 };
|