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