@qilin-agent/client-test-runtime 0.0.0-stage → 3.1.4
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 +67 -0
- package/README.md +185 -2
- package/README.zh.md +186 -0
- package/lib/index.js +1716 -0
- package/lib/types/assembly/bundle-roster.d.ts +15 -0
- package/lib/types/assembly/index.d.ts +15 -0
- package/lib/types/assembly/modules.d.ts +25 -0
- package/lib/types/assembly/remote-default-responses.d.ts +12 -0
- package/lib/types/assembly/remote-proxies.d.ts +20 -0
- package/lib/types/assembly/roster.d.ts +80 -0
- package/lib/types/assembly/test-client.d.ts +83 -0
- package/lib/types/assembly/vitest.d.ts +24 -0
- package/lib/types/config-form.d.ts +29 -0
- package/lib/types/fixtures.d.ts +73 -0
- package/lib/types/index.d.ts +236 -0
- package/lib/types/locale-env.d.ts +9 -0
- package/lib/types/remote.d.ts +66 -0
- package/lib/types/sessions.d.ts +283 -0
- package/lib/types/settings-remote.d.ts +83 -0
- package/lib/types/snapshot.d.ts +14 -0
- package/lib/types/translate.d.ts +16 -0
- package/lib/types/workspaces.d.ts +107 -0
- package/package.json +147 -3
package/lib/index.js
ADDED
|
@@ -0,0 +1,1716 @@
|
|
|
1
|
+
import { Context, Inject } from "@qilin-agent/kylin";
|
|
2
|
+
import { Fragment, createElement, useSyncExternalStore } from "react";
|
|
3
|
+
import { act, render, within } from "@testing-library/react";
|
|
4
|
+
import { SlotRegistry } from "@qilin-agent/client-ui-renderer/client";
|
|
5
|
+
import { bindSnapshotSelector as bindSnapshotSelector$1 } from "@qilin-agent/client-ui-renderer/src/client/bind.ts";
|
|
6
|
+
import { createSlotRenderer as createSlotRenderer$1 } from "@qilin-agent/client-ui-renderer/src/client/scoped-slots.tsx";
|
|
7
|
+
import { apply, inject } from "@qilin-agent/client-ui-session/client";
|
|
8
|
+
import { createSnapshotStore } from "@qilin-agent/client-store";
|
|
9
|
+
import { afterEach, beforeEach, expect, vi } from "vitest";
|
|
10
|
+
import { RemoteError } from "@qilin-agent/typert-protocol";
|
|
11
|
+
import { MutableSessionEventSource, SESSION_SEARCH_RESULT_LIMIT, createScope, scopeOf } from "@qilin-agent/api-session-controller/client";
|
|
12
|
+
import { scopeIdentityOf } from "@qilin-agent/api-session-controller/src/client/scope.ts";
|
|
13
|
+
import { EMPTY_CONVERSATION_SNAPSHOT } from "@qilin-agent/client-ui-conversation/client";
|
|
14
|
+
import { EMPTY_CHAT_SNAPSHOT } from "@qilin-agent/client-ui-chat/client";
|
|
15
|
+
//#region lib/types/snapshot.js
|
|
16
|
+
/**
|
|
17
|
+
* DOM snapshot hygiene: a vitest snapshot serializer that keeps `.snap`
|
|
18
|
+
* files structural. Two normalizations, both on a clone (the live DOM is
|
|
19
|
+
* untouched, so class/tag queries keep working):
|
|
20
|
+
*
|
|
21
|
+
* - CSS-module scoped class names (`_frame_334d2d`, this repo's
|
|
22
|
+
* `_[local]_[hash]` shape) fold back to their semantic local (`frame`), so
|
|
23
|
+
* CSS edits do not churn snapshots.
|
|
24
|
+
* - `<svg>` internals collapse to a `data-content` fingerprint on the svg
|
|
25
|
+
* element: path geometry is print noise, but the fingerprint still flips
|
|
26
|
+
* when an icon's artwork actually changes.
|
|
27
|
+
*/
|
|
28
|
+
/** One scoped class token: `_<local>_<hash>` (local may itself contain underscores). */
|
|
29
|
+
const SCOPED_CLASS = /^_(.+)_[a-z0-9]+$/;
|
|
30
|
+
/** Fold scoped tokens in one class attribute value; foreign tokens pass through. */
|
|
31
|
+
function normalizeClassValue(value) {
|
|
32
|
+
return value.split(/\s+/).filter((token) => token !== "").map((token) => token.replace(SCOPED_CLASS, "$1")).join(" ");
|
|
33
|
+
}
|
|
34
|
+
/** FNV-1a 32-bit over the svg markup: deterministic, dependency-free fingerprint. */
|
|
35
|
+
function fingerprint(markup) {
|
|
36
|
+
let hash = 2166136261;
|
|
37
|
+
for (let i = 0; i < markup.length; i++) {
|
|
38
|
+
hash ^= markup.charCodeAt(i);
|
|
39
|
+
hash = Math.imul(hash, 16777619);
|
|
40
|
+
}
|
|
41
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
42
|
+
}
|
|
43
|
+
/** svg elements of a subtree, the root included when it is one. */
|
|
44
|
+
function svgsOf(root) {
|
|
45
|
+
const svgs = [...root.querySelectorAll("svg")];
|
|
46
|
+
if (root.tagName.toLowerCase() === "svg") svgs.unshift(root);
|
|
47
|
+
return svgs;
|
|
48
|
+
}
|
|
49
|
+
/** Whether serializing this subtree needs a normalized clone. */
|
|
50
|
+
function needsNormalization(root) {
|
|
51
|
+
return [root, ...root.querySelectorAll("[class]")].some((el) => {
|
|
52
|
+
const value = el.getAttribute("class");
|
|
53
|
+
return value !== null && value.split(/\s+/).some((token) => SCOPED_CLASS.test(token));
|
|
54
|
+
}) || svgsOf(root).some((svg) => svg.childNodes.length > 0);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The serializer plugin. Matches DOM elements whose subtree carries a scoped
|
|
58
|
+
* class or svg internals; serializes a normalized clone, which no longer
|
|
59
|
+
* matches, so printing falls through to the built-in DOM element serializer.
|
|
60
|
+
*/
|
|
61
|
+
const domSnapshotSerializer = {
|
|
62
|
+
test(value) {
|
|
63
|
+
return typeof Element !== "undefined" && value instanceof Element && needsNormalization(value);
|
|
64
|
+
},
|
|
65
|
+
serialize(value, config, indentation, depth, refs, printer) {
|
|
66
|
+
const clone = value.cloneNode(true);
|
|
67
|
+
for (const el of [clone, ...clone.querySelectorAll("[class]")]) {
|
|
68
|
+
const raw = el.getAttribute("class");
|
|
69
|
+
if (raw !== null) el.setAttribute("class", normalizeClassValue(raw));
|
|
70
|
+
}
|
|
71
|
+
for (const svg of svgsOf(clone)) {
|
|
72
|
+
if (svg.childNodes.length === 0) continue;
|
|
73
|
+
svg.setAttribute("data-content", fingerprint(svg.innerHTML));
|
|
74
|
+
svg.replaceChildren();
|
|
75
|
+
}
|
|
76
|
+
return printer(clone, config, indentation, depth, refs);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
let registered = false;
|
|
80
|
+
/**
|
|
81
|
+
* Register {@link domSnapshotSerializer} with vitest's expect (idempotent).
|
|
82
|
+
* SlotTestRuntime.create() calls this; specs that snapshot DOM outside the
|
|
83
|
+
* runtime import and call it themselves.
|
|
84
|
+
*/
|
|
85
|
+
function registerDomSnapshotSerializer() {
|
|
86
|
+
if (registered) return;
|
|
87
|
+
registered = true;
|
|
88
|
+
expect.addSnapshotSerializer(domSnapshotSerializer);
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region lib/types/remote.js
|
|
92
|
+
/**
|
|
93
|
+
* Remote service test double for the forwarded-event path. Feature specs need
|
|
94
|
+
* `ctx.remote.$on` to exist (their plugins inject `remote`) and need forwarded
|
|
95
|
+
* Host events to reach those subscribers, but not the wire — so this double
|
|
96
|
+
* implements subscription plus an explicit `emit` driver available only on the
|
|
97
|
+
* concrete test object. A spec that also calls one namespace scripts it through
|
|
98
|
+
* the constructor rather than reaching the real Client Remote service.
|
|
99
|
+
*
|
|
100
|
+
* `$mount` rejects: a spec that needs a real generated contribution installed —
|
|
101
|
+
* codecs, descriptors, and the wire — has outgrown this double and needs the
|
|
102
|
+
* real Client Remote service.
|
|
103
|
+
*
|
|
104
|
+
* One deliberate asymmetry with production: a throwing listener propagates out
|
|
105
|
+
* of the emit instead of being contained and logged, so a spec cannot lean on
|
|
106
|
+
* this double for the containment guarantee `$on` documents — assert that
|
|
107
|
+
* against the real service.
|
|
108
|
+
*/
|
|
109
|
+
var TestRemote = class {
|
|
110
|
+
ctx;
|
|
111
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
112
|
+
/**
|
|
113
|
+
* Fixed Host facts mirrored from the production `ctx.remote.$host`. Plain
|
|
114
|
+
* mutable field: a spec assigns it to script a non-loopback or homed Host.
|
|
115
|
+
*/
|
|
116
|
+
$host = {
|
|
117
|
+
home: void 0,
|
|
118
|
+
isLoopback: true
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Register the double as `ctx.remote`, plus one service per scripted
|
|
122
|
+
* namespace so a plugin injecting `remote.<name>` also unparks.
|
|
123
|
+
* @param ctx - the spec's root Context.
|
|
124
|
+
* @param namespaces - scripted namespace faces reached as `ctx.remote.<name>`.
|
|
125
|
+
*/
|
|
126
|
+
constructor(ctx, namespaces = {}) {
|
|
127
|
+
this.ctx = ctx;
|
|
128
|
+
this.validateNamespaces(namespaces);
|
|
129
|
+
ctx.provide("remote", this);
|
|
130
|
+
this.installNamespaces(namespaces);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Add scripted namespace faces to this Remote service.
|
|
134
|
+
* @param namespaces - scripted namespace faces reached as `ctx.remote.<name>`.
|
|
135
|
+
*/
|
|
136
|
+
provideNamespaces(namespaces) {
|
|
137
|
+
this.validateNamespaces(namespaces);
|
|
138
|
+
this.installNamespaces(namespaces);
|
|
139
|
+
}
|
|
140
|
+
validateNamespaces(namespaces) {
|
|
141
|
+
for (const name of Object.keys(namespaces)) if (name in this) throw new TypeError(`TestRemote: scripted namespace "${name}" would shadow the double's own member`);
|
|
142
|
+
}
|
|
143
|
+
installNamespaces(namespaces) {
|
|
144
|
+
Object.assign(this, namespaces);
|
|
145
|
+
for (const [name, face] of Object.entries(namespaces)) this.ctx.provide(`remote.${name}`, face);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Deliver one forwarded host event to its subscribers, standing in for the
|
|
149
|
+
* carrier that owns the frame sink.
|
|
150
|
+
* @param event - forwarded host event name.
|
|
151
|
+
* @param args - the Host argument list, verbatim.
|
|
152
|
+
*/
|
|
153
|
+
emit(event, args) {
|
|
154
|
+
const listeners = this.subscriptions.get(event);
|
|
155
|
+
if (listeners === void 0) return;
|
|
156
|
+
for (const listener of [...listeners]) listener(...args);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Subscribe to one forwarded host event.
|
|
160
|
+
* @param event - forwarded host event name.
|
|
161
|
+
* @param listener - receives the Host argument list verbatim.
|
|
162
|
+
* @returns disposer removing this subscription.
|
|
163
|
+
*/
|
|
164
|
+
$on(event, listener) {
|
|
165
|
+
const listeners = this.subscriptions.get(event) ?? /* @__PURE__ */ new Set();
|
|
166
|
+
this.subscriptions.set(event, listeners);
|
|
167
|
+
listeners.add(listener);
|
|
168
|
+
return () => {
|
|
169
|
+
listeners.delete(listener);
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Generated-namespace mount, unsupported by this double.
|
|
174
|
+
* @returns never; always rejects.
|
|
175
|
+
*/
|
|
176
|
+
$mount() {
|
|
177
|
+
return Promise.reject(/* @__PURE__ */ new Error("TestRemote: $mount needs the real Client Remote service"));
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region lib/types/fixtures.js
|
|
182
|
+
/**
|
|
183
|
+
* A complete quiescent Session Controller snapshot.
|
|
184
|
+
* @param sessionId - owning session id.
|
|
185
|
+
* @returns the snapshot; spread fixture overrides on top.
|
|
186
|
+
*/
|
|
187
|
+
function sessionSnapshot(sessionId) {
|
|
188
|
+
return {
|
|
189
|
+
sessionId,
|
|
190
|
+
pendingSubmissions: [],
|
|
191
|
+
running: false,
|
|
192
|
+
subagent: null,
|
|
193
|
+
removed: false,
|
|
194
|
+
openState: "open",
|
|
195
|
+
openError: null,
|
|
196
|
+
hasMore: false,
|
|
197
|
+
loadingOlder: false,
|
|
198
|
+
promptError: null,
|
|
199
|
+
blank: false,
|
|
200
|
+
lastAgentError: null,
|
|
201
|
+
promptAttempted: false,
|
|
202
|
+
awaitingFirstTurn: false
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* A target-neutral Conversation snapshot.
|
|
207
|
+
* @param overrides - target roster or activity overrides.
|
|
208
|
+
* @returns an immutable fixture value.
|
|
209
|
+
*/
|
|
210
|
+
function conversationSnapshot(overrides = {}) {
|
|
211
|
+
return {
|
|
212
|
+
...EMPTY_CONVERSATION_SNAPSHOT,
|
|
213
|
+
...overrides
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* A Chat target snapshot.
|
|
218
|
+
* @param overrides - Chat target overrides.
|
|
219
|
+
* @returns an immutable fixture value.
|
|
220
|
+
*/
|
|
221
|
+
function chatSnapshot(overrides = {}) {
|
|
222
|
+
return {
|
|
223
|
+
...EMPTY_CHAT_SNAPSHOT,
|
|
224
|
+
...overrides
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* A ready Workspace Controller snapshot with no Workspace rows.
|
|
229
|
+
* @returns the initial state of the test Workspace source.
|
|
230
|
+
*/
|
|
231
|
+
function workspaceSnapshot() {
|
|
232
|
+
return {
|
|
233
|
+
items: [],
|
|
234
|
+
archivedSessionIds: [],
|
|
235
|
+
pinnedSessionIds: [],
|
|
236
|
+
state: "idle",
|
|
237
|
+
phase: "ready",
|
|
238
|
+
error: null
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region lib/types/sessions.js
|
|
243
|
+
/**
|
|
244
|
+
* The fixture-backed session face: lifecycle reads delegate to the fixture's
|
|
245
|
+
* snapshot store; Session verbs are fail-loud stubs unless the
|
|
246
|
+
* fixture supplies them (the runtime never fakes behavior a test did not
|
|
247
|
+
* declare — an unstubbed call names itself instead of half-working). Extra
|
|
248
|
+
* fixture methods are grafted verbatim for feature-side casts.
|
|
249
|
+
*/
|
|
250
|
+
var FixtureSession = class {
|
|
251
|
+
sessionId;
|
|
252
|
+
store;
|
|
253
|
+
/** Mutable event source consumed only by Conversation assembly. */
|
|
254
|
+
eventSource = new MutableSessionEventSource();
|
|
255
|
+
/**
|
|
256
|
+
* Identity-stable per-key faces over fixture-controlled projection values.
|
|
257
|
+
*/
|
|
258
|
+
projections;
|
|
259
|
+
/**
|
|
260
|
+
* @param sessionId - host identity (branded view of the fixture id).
|
|
261
|
+
* @param store - Session Controller snapshot store.
|
|
262
|
+
* @param overrides - fixture-declared behavior face, grafted over the stubs.
|
|
263
|
+
*/
|
|
264
|
+
constructor(sessionId, store, overrides) {
|
|
265
|
+
this.sessionId = sessionId;
|
|
266
|
+
this.store = store;
|
|
267
|
+
const values = /* @__PURE__ */ new Map();
|
|
268
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
269
|
+
const faces = /* @__PURE__ */ new Map();
|
|
270
|
+
this.projections = {
|
|
271
|
+
faceOf: (key) => {
|
|
272
|
+
let face = faces.get(key);
|
|
273
|
+
if (face === void 0) {
|
|
274
|
+
face = {
|
|
275
|
+
getSnapshot: () => values.get(key),
|
|
276
|
+
subscribe: (fn) => {
|
|
277
|
+
const set = listeners.get(key) ?? /* @__PURE__ */ new Set();
|
|
278
|
+
set.add(fn);
|
|
279
|
+
listeners.set(key, set);
|
|
280
|
+
return () => {
|
|
281
|
+
set.delete(fn);
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
faces.set(key, face);
|
|
286
|
+
}
|
|
287
|
+
return face;
|
|
288
|
+
},
|
|
289
|
+
set: (key, value) => {
|
|
290
|
+
values.set(key, value);
|
|
291
|
+
for (const fn of [...listeners.get(key) ?? []]) fn();
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
Object.assign(this, overrides);
|
|
295
|
+
}
|
|
296
|
+
/** @returns the fixture Session Controller snapshot (useSession read side). */
|
|
297
|
+
getSnapshot() {
|
|
298
|
+
return this.store.getSnapshot();
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Subscribe to fixture snapshot changes.
|
|
302
|
+
* @param fn - change callback.
|
|
303
|
+
* @returns unsubscribe.
|
|
304
|
+
*/
|
|
305
|
+
subscribe(fn) {
|
|
306
|
+
return this.store.subscribe(fn);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Fail-loud stub; supply `prompt` on the fixture's session face to exercise it.
|
|
310
|
+
* @returns never — always throws.
|
|
311
|
+
*/
|
|
312
|
+
prompt() {
|
|
313
|
+
throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Minimal local-echo registration: mints an identity without touching the
|
|
317
|
+
* fixture snapshot (submission echoes are client-only presentation state).
|
|
318
|
+
* Supply `beginSubmission` on the fixture's session face to observe echoes.
|
|
319
|
+
* @returns a handle whose abandon is a no-op.
|
|
320
|
+
*/
|
|
321
|
+
beginSubmission() {
|
|
322
|
+
this.submissionSeq += 1;
|
|
323
|
+
return {
|
|
324
|
+
requestId: `test-submission-${this.submissionSeq}`,
|
|
325
|
+
abandon: () => {}
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
submissionSeq = 0;
|
|
329
|
+
/**
|
|
330
|
+
* Fail-loud stub; supply `readAttachment` on the fixture's session face to exercise it.
|
|
331
|
+
* @param _attachmentId - opaque durable attachment id.
|
|
332
|
+
* @returns never — always throws.
|
|
333
|
+
*/
|
|
334
|
+
readAttachment(_attachmentId) {
|
|
335
|
+
throw new Error(`test session "${this.sessionId}": readAttachment is not stubbed — supply it on the fixture's session face`);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it.
|
|
339
|
+
* @returns never — always throws.
|
|
340
|
+
*/
|
|
341
|
+
updateQueue() {
|
|
342
|
+
throw new Error(`test session "${this.sessionId}": updateQueue is not stubbed — supply it on the fixture's session face`);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Fail-loud stub; supply `cancel` on the fixture's session face to exercise it.
|
|
346
|
+
* @returns never — always throws.
|
|
347
|
+
*/
|
|
348
|
+
cancel() {
|
|
349
|
+
throw new Error(`test session "${this.sessionId}": cancel is not stubbed — supply it on the fixture's session face`);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Fail-loud stub; supply `command` on the fixture's session face to exercise it.
|
|
353
|
+
* @returns never — always throws.
|
|
354
|
+
*/
|
|
355
|
+
command() {
|
|
356
|
+
throw new Error(`test session "${this.sessionId}": command is not stubbed — supply it on the fixture's session face`);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Fail-loud stub; supply `loadOlder` on the fixture's session face to exercise it.
|
|
360
|
+
* @returns never — always throws.
|
|
361
|
+
*/
|
|
362
|
+
loadOlder() {
|
|
363
|
+
throw new Error(`test session "${this.sessionId}": loadOlder is not stubbed — supply it on the fixture's session face`);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Fail-loud stub; supply `loadThrough` on the fixture's session face to exercise it.
|
|
367
|
+
* @returns never — always throws.
|
|
368
|
+
*/
|
|
369
|
+
loadThrough() {
|
|
370
|
+
throw new Error(`test session "${this.sessionId}": loadThrough is not stubbed — supply it on the fixture's session face`);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Fail-loud stub; supply `rename` on the fixture's session face to exercise it.
|
|
374
|
+
* @returns never — always throws.
|
|
375
|
+
*/
|
|
376
|
+
rename() {
|
|
377
|
+
throw new Error(`test session "${this.sessionId}": rename is not stubbed — supply it on the fixture's session face`);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
function freezeRetainedBy(counts) {
|
|
381
|
+
Object.setPrototypeOf(counts, null);
|
|
382
|
+
return Object.freeze(counts);
|
|
383
|
+
}
|
|
384
|
+
const EMPTY_RETAIN_INFO = Object.freeze({
|
|
385
|
+
referenceCount: 0,
|
|
386
|
+
retainedBy: freezeRetainedBy({})
|
|
387
|
+
});
|
|
388
|
+
async function waitForOpen(opening, signal) {
|
|
389
|
+
/* v8 ignore next -- TestSessionReference always supplies its release-composed signal. */
|
|
390
|
+
if (signal === void 0) return opening;
|
|
391
|
+
const aborted = Promise.withResolvers();
|
|
392
|
+
const onAbort = () => {
|
|
393
|
+
aborted.reject(signal.reason);
|
|
394
|
+
};
|
|
395
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
396
|
+
try {
|
|
397
|
+
if (signal.aborted) onAbort();
|
|
398
|
+
await Promise.race([opening, aborted.promise]);
|
|
399
|
+
} finally {
|
|
400
|
+
signal.removeEventListener("abort", onAbort);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
var TestSessionReference = class {
|
|
404
|
+
sessionId;
|
|
405
|
+
generation;
|
|
406
|
+
releaseReference;
|
|
407
|
+
released = new AbortController();
|
|
408
|
+
readiness = Promise.withResolvers();
|
|
409
|
+
ready = this.readiness.promise;
|
|
410
|
+
constructor(sessionId, generation, releaseReference) {
|
|
411
|
+
this.sessionId = sessionId;
|
|
412
|
+
this.generation = generation;
|
|
413
|
+
this.releaseReference = releaseReference;
|
|
414
|
+
this.ready.catch(() => {});
|
|
415
|
+
}
|
|
416
|
+
get binding() {
|
|
417
|
+
if (this.generation === void 0 || !this.generation.live) throw new Error(`Session reference "${this.sessionId}" is released`);
|
|
418
|
+
return this.generation.binding;
|
|
419
|
+
}
|
|
420
|
+
attachOpening(opening, signal) {
|
|
421
|
+
const waitSignal = signal === void 0 ? this.released.signal : AbortSignal.any([this.released.signal, signal]);
|
|
422
|
+
waitForOpen(opening, waitSignal).then(() => {
|
|
423
|
+
try {
|
|
424
|
+
waitSignal.throwIfAborted();
|
|
425
|
+
this.readiness.resolve(this.binding);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
this.readiness.reject(error);
|
|
428
|
+
}
|
|
429
|
+
}, (error) => {
|
|
430
|
+
this.readiness.reject(error);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
release() {
|
|
434
|
+
const reason = /* @__PURE__ */ new Error(`Session reference "${this.sessionId}" is released`);
|
|
435
|
+
const release = this.releaseReference;
|
|
436
|
+
this.released.abort(reason);
|
|
437
|
+
this.readiness.reject(reason);
|
|
438
|
+
this.generation = void 0;
|
|
439
|
+
this.releaseReference = void 0;
|
|
440
|
+
release?.();
|
|
441
|
+
}
|
|
442
|
+
[Symbol.dispose]() {
|
|
443
|
+
this.release();
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
/**
|
|
447
|
+
* Sessions test double behind the renderer host and feature injects: owns the
|
|
448
|
+
* catalog observable, scope minting through the production `createScope`,
|
|
449
|
+
* stable Controller bindings, and the session behavior face supplied per
|
|
450
|
+
* fixture. `ui-session` owns standard-source materialization.
|
|
451
|
+
*
|
|
452
|
+
* Implements the same ISessions face features receive as `ctx.sessions`, so
|
|
453
|
+
* a production face change breaks this double at compile time; the extra
|
|
454
|
+
* members (add/updateSessionSnapshot/event-window drivers/remove/
|
|
455
|
+
* behavior/calls/stubs) are bench-only surface.
|
|
456
|
+
*/
|
|
457
|
+
var TestSessions = class {
|
|
458
|
+
stabilize;
|
|
459
|
+
rootCtx;
|
|
460
|
+
/** The useSessions catalog feed, independent of view ownership. */
|
|
461
|
+
list;
|
|
462
|
+
records = /* @__PURE__ */ new Map();
|
|
463
|
+
generations = /* @__PURE__ */ new Map();
|
|
464
|
+
addresses = /* @__PURE__ */ new Map();
|
|
465
|
+
retentionStores = /* @__PURE__ */ new Map();
|
|
466
|
+
pendingDrops = /* @__PURE__ */ new Set();
|
|
467
|
+
closed = false;
|
|
468
|
+
/** Calls observed on the service-level face, newest last. */
|
|
469
|
+
calls = [];
|
|
470
|
+
/** The wire schema's `session.search` result bound (production parity). */
|
|
471
|
+
searchResultLimit = SESSION_SEARCH_RESULT_LIMIT;
|
|
472
|
+
/** Replaceable search behavior (see {@link TestSessions.stubSearch}). */
|
|
473
|
+
searchStub;
|
|
474
|
+
createStub;
|
|
475
|
+
/**
|
|
476
|
+
* @param stabilize - the owning runtime's act wrapper.
|
|
477
|
+
* @param rootCtx - the runtime's Cordis root; scope fibers mount under it.
|
|
478
|
+
*/
|
|
479
|
+
constructor(stabilize, rootCtx) {
|
|
480
|
+
this.stabilize = stabilize;
|
|
481
|
+
this.rootCtx = rootCtx;
|
|
482
|
+
this.list = createSnapshotStore({
|
|
483
|
+
ids: [],
|
|
484
|
+
byId: {},
|
|
485
|
+
phase: "ready",
|
|
486
|
+
projectionsBySession: {}
|
|
487
|
+
});
|
|
488
|
+
rootCtx.effect(() => async () => {
|
|
489
|
+
this.closed = true;
|
|
490
|
+
for (const [id, generation] of this.generations) {
|
|
491
|
+
generation.live = false;
|
|
492
|
+
generation.retention = EMPTY_RETAIN_INFO;
|
|
493
|
+
generation.lifetime.abort(/* @__PURE__ */ new Error("test Session Controller is disposed"));
|
|
494
|
+
this.publishRetention(id);
|
|
495
|
+
}
|
|
496
|
+
this.generations.clear();
|
|
497
|
+
await this.drainDrops();
|
|
498
|
+
}, "test sessions: Client generations");
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Add a Session fixture to the catalog without retaining a generation.
|
|
502
|
+
* @param fixture - identity + snapshot/summary overrides + behavior face.
|
|
503
|
+
* @returns the stable session id (branded view of `fixture.id`).
|
|
504
|
+
*/
|
|
505
|
+
async add(fixture) {
|
|
506
|
+
const id = fixture.id;
|
|
507
|
+
if (this.records.has(id)) throw new Error(`test session "${id}" already added`);
|
|
508
|
+
const summary = {
|
|
509
|
+
id,
|
|
510
|
+
displayTitle: fixture.id,
|
|
511
|
+
running: false,
|
|
512
|
+
blank: false,
|
|
513
|
+
updatedAt: this.records.size + 1,
|
|
514
|
+
...fixture.summary,
|
|
515
|
+
retainedBy: this.retentionSnapshot(id).retainedBy
|
|
516
|
+
};
|
|
517
|
+
const snapshot = createSnapshotStore({
|
|
518
|
+
...sessionSnapshot(id),
|
|
519
|
+
...fixture.snapshot
|
|
520
|
+
});
|
|
521
|
+
const session = new FixtureSession(id, snapshot, fixture.session ?? {});
|
|
522
|
+
if (fixture.events !== void 0 || fixture.hasMore === true) session.eventSource.replace(fixture.events ?? [], fixture.hasMore ?? false);
|
|
523
|
+
this.records.set(id, {
|
|
524
|
+
summary,
|
|
525
|
+
snapshot,
|
|
526
|
+
session,
|
|
527
|
+
overrides: fixture.session ?? {},
|
|
528
|
+
projections: /* @__PURE__ */ new Map(),
|
|
529
|
+
initialOpen: fixture.initialOpen
|
|
530
|
+
});
|
|
531
|
+
await this.stabilize(() => {
|
|
532
|
+
this.list.update((draft) => {
|
|
533
|
+
draft.ids.push(id);
|
|
534
|
+
draft.byId[id] = summary;
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
return id;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Update Session Controller lifecycle state through an immer draft.
|
|
541
|
+
* @param id - session id.
|
|
542
|
+
* @param mutate - draft mutator.
|
|
543
|
+
*/
|
|
544
|
+
async updateSessionSnapshot(id, mutate) {
|
|
545
|
+
const record = this.require(id);
|
|
546
|
+
await this.stabilize(() => {
|
|
547
|
+
record.snapshot.update(mutate);
|
|
548
|
+
this.generations.get(id)?.snapshot.set(record.snapshot.getSnapshot());
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Publish one complete projection value through the fixture Session face.
|
|
553
|
+
* @param id - session id.
|
|
554
|
+
* @param key - registered projection key.
|
|
555
|
+
* @param value - complete value for that key.
|
|
556
|
+
*/
|
|
557
|
+
async setProjection(id, key, value) {
|
|
558
|
+
const record = this.require(id);
|
|
559
|
+
record.projections.set(key, value);
|
|
560
|
+
await this.stabilize(() => {
|
|
561
|
+
record.session.projections.set(key, value);
|
|
562
|
+
this.generations.get(id)?.session.projections.set(key, value);
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Replace a Session's complete contiguous event window.
|
|
567
|
+
* @param id - Session identity.
|
|
568
|
+
* @param entries - complete event window.
|
|
569
|
+
* @param hasMore - whether older history remains.
|
|
570
|
+
*/
|
|
571
|
+
async replaceEvents(id, entries, hasMore = false) {
|
|
572
|
+
await this.stabilize(() => {
|
|
573
|
+
this.require(id).session.eventSource.replace(entries, hasMore);
|
|
574
|
+
this.generations.get(id)?.session.eventSource.replace(entries, hasMore);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Prepend one older contiguous event page.
|
|
579
|
+
* @param id - Session identity.
|
|
580
|
+
* @param entries - older entries.
|
|
581
|
+
* @param hasMore - whether another older page remains.
|
|
582
|
+
*/
|
|
583
|
+
async prependEvents(id, entries, hasMore = false) {
|
|
584
|
+
await this.stabilize(() => {
|
|
585
|
+
this.require(id).session.eventSource.prepend(entries, hasMore);
|
|
586
|
+
this.generations.get(id)?.session.eventSource.prepend(entries, hasMore);
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Append one live event to a Session's contiguous window.
|
|
591
|
+
* @param id - Session identity.
|
|
592
|
+
* @param entry - live event entry.
|
|
593
|
+
*/
|
|
594
|
+
async appendEvent(id, entry) {
|
|
595
|
+
await this.stabilize(() => {
|
|
596
|
+
this.require(id).session.eventSource.append(entry);
|
|
597
|
+
this.generations.get(id)?.session.eventSource.append(entry);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Update a session's list row (the wire-echo stand-in: title settles,
|
|
602
|
+
* running flips — components subscribed via useSessions re-render).
|
|
603
|
+
* @param id - session id.
|
|
604
|
+
* @param patch - summary fields to merge over the row.
|
|
605
|
+
*/
|
|
606
|
+
async updateSummary(id, patch) {
|
|
607
|
+
const record = this.require(id);
|
|
608
|
+
record.summary = {
|
|
609
|
+
...record.summary,
|
|
610
|
+
...patch,
|
|
611
|
+
retainedBy: this.retentionSnapshot(id).retainedBy
|
|
612
|
+
};
|
|
613
|
+
await this.stabilize(() => {
|
|
614
|
+
this.list.update((draft) => {
|
|
615
|
+
draft.byId[id] = record.summary;
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Remove a catalog row and mark its retained Session removed without releasing owners.
|
|
621
|
+
* @param id - session id.
|
|
622
|
+
*/
|
|
623
|
+
async remove(id) {
|
|
624
|
+
this.require(id);
|
|
625
|
+
this.records.delete(id);
|
|
626
|
+
await this.stabilize(() => {
|
|
627
|
+
this.list.update((draft) => {
|
|
628
|
+
draft.ids = draft.ids.filter((existing) => existing !== id);
|
|
629
|
+
const { [id]: _dead, ...rest } = draft.byId;
|
|
630
|
+
draft.byId = rest;
|
|
631
|
+
});
|
|
632
|
+
this.generations.get(id)?.snapshot.update((draft) => {
|
|
633
|
+
draft.removed = true;
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Borrow the already-retained session-scoped Cordis context.
|
|
639
|
+
* @param id - session id.
|
|
640
|
+
* @returns the scoped context, or undefined without a live reference.
|
|
641
|
+
*/
|
|
642
|
+
scope(id) {
|
|
643
|
+
return this.generations.get(id)?.binding.ctx;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Session assembly binding (inject factories and provide resolvers receive it).
|
|
647
|
+
* @param id - session id.
|
|
648
|
+
* @returns the live generation's binding, or undefined without a reference.
|
|
649
|
+
*/
|
|
650
|
+
binding(id) {
|
|
651
|
+
return this.generations.get(id)?.binding;
|
|
652
|
+
}
|
|
653
|
+
retain(target, options = { source: "testFixture" }) {
|
|
654
|
+
const { source, signal } = options;
|
|
655
|
+
signal?.throwIfAborted();
|
|
656
|
+
if (this.closed) throw new Error("test Session Controller is disposed");
|
|
657
|
+
const id = this.resolveTarget(target);
|
|
658
|
+
const generation = this.generations.get(id) ?? this.materialize(id, this.require(id));
|
|
659
|
+
const reference = this.retainGeneration(id, generation, source);
|
|
660
|
+
try {
|
|
661
|
+
reference.attachOpening(generation.opening, signal);
|
|
662
|
+
return reference;
|
|
663
|
+
} catch (error) {
|
|
664
|
+
reference.release();
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
async using(target, options, operation) {
|
|
669
|
+
const reference = this.retain(target, options);
|
|
670
|
+
try {
|
|
671
|
+
await reference.ready;
|
|
672
|
+
return await operation(reference);
|
|
673
|
+
} finally {
|
|
674
|
+
reference.release();
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
retainInfo(id) {
|
|
678
|
+
let store = this.retentionStores.get(id);
|
|
679
|
+
if (store === void 0) {
|
|
680
|
+
store = createSnapshotStore(this.retentionSnapshot(id));
|
|
681
|
+
this.retentionStores.set(id, store);
|
|
682
|
+
}
|
|
683
|
+
return store;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Retain one fixture Session until the supplied Cordis owner stops.
|
|
687
|
+
* @param ownerCtx - context whose disposal releases the reference.
|
|
688
|
+
* @param target - fixture Session identity or subagent address.
|
|
689
|
+
* @param options - reference source and optional readiness cancellation.
|
|
690
|
+
* @returns the owned reference immediately.
|
|
691
|
+
*/
|
|
692
|
+
retainFor(ownerCtx, target, options = { source: "testFixture" }) {
|
|
693
|
+
const reference = this.retain(target, options);
|
|
694
|
+
try {
|
|
695
|
+
ownerCtx.effect(() => () => {
|
|
696
|
+
reference.release();
|
|
697
|
+
}, "test sessions: owned reference");
|
|
698
|
+
return reference;
|
|
699
|
+
} catch (error) {
|
|
700
|
+
reference.release();
|
|
701
|
+
throw error;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Read the session scope tag off a context (service-method boundary mirror).
|
|
706
|
+
* @param ctx - any client context.
|
|
707
|
+
* @returns the session id, or undefined on root contexts.
|
|
708
|
+
*/
|
|
709
|
+
scopeOf(ctx) {
|
|
710
|
+
return scopeOf(ctx);
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Resolve the scoped session face off a context (production `sessionOf`
|
|
714
|
+
* mirror).
|
|
715
|
+
* @param ctx - any client context.
|
|
716
|
+
* @returns the fixture session face, or undefined off-scope.
|
|
717
|
+
*/
|
|
718
|
+
sessionOf(ctx) {
|
|
719
|
+
const id = scopeOf(ctx);
|
|
720
|
+
if (id === void 0) return void 0;
|
|
721
|
+
const generation = this.generations.get(id);
|
|
722
|
+
return generation !== void 0 && scopeIdentityOf(generation.binding.ctx) === scopeIdentityOf(ctx) ? generation.session : void 0;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Install Session creation behavior for navigation tests.
|
|
726
|
+
* @param impl - implementation that must return an already-added fixture id.
|
|
727
|
+
*/
|
|
728
|
+
stubCreate(impl) {
|
|
729
|
+
this.createStub = impl;
|
|
730
|
+
}
|
|
731
|
+
/** Create through the installed test behavior and require a catalogued fixture. */
|
|
732
|
+
async create(opts) {
|
|
733
|
+
this.calls.push({
|
|
734
|
+
method: "create",
|
|
735
|
+
args: [opts]
|
|
736
|
+
});
|
|
737
|
+
if (this.createStub === void 0) throw new Error("test sessions: create is not stubbed — call stubCreate() first");
|
|
738
|
+
const id = await this.createStub(opts);
|
|
739
|
+
this.require(id);
|
|
740
|
+
return id;
|
|
741
|
+
}
|
|
742
|
+
/** Resolve a retained or catalog-derived address independently of a view. */
|
|
743
|
+
subagentAddress(id) {
|
|
744
|
+
const retained = this.addresses.get(id);
|
|
745
|
+
if (retained !== void 0) return retained;
|
|
746
|
+
for (const [parentSessionId, projections] of Object.entries(this.list.getSnapshot().projectionsBySession)) {
|
|
747
|
+
const child = projections.values.subagentCatalog?.find((entry) => entry.id === id);
|
|
748
|
+
if (child !== void 0) return {
|
|
749
|
+
parentSessionId,
|
|
750
|
+
childSessionId: id,
|
|
751
|
+
mode: child.mode
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
/** Record a projection refresh; fixture callers drive snapshots explicitly. */
|
|
756
|
+
refreshProjections(sessionId) {
|
|
757
|
+
this.calls.push({
|
|
758
|
+
method: "refreshProjections",
|
|
759
|
+
args: [sessionId]
|
|
760
|
+
});
|
|
761
|
+
return Promise.resolve();
|
|
762
|
+
}
|
|
763
|
+
/** Record a list refresh; fixture callers publish list state explicitly. */
|
|
764
|
+
refresh() {
|
|
765
|
+
this.calls.push({
|
|
766
|
+
method: "refresh",
|
|
767
|
+
args: []
|
|
768
|
+
});
|
|
769
|
+
return Promise.resolve();
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Replace the sidebar-search result page (the call is still recorded).
|
|
773
|
+
* @param impl - hits for a query, as the Host would rank them.
|
|
774
|
+
*/
|
|
775
|
+
stubSearch(impl) {
|
|
776
|
+
this.searchStub = impl;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Content search over the fixture corpus (recorded). The default answers an
|
|
780
|
+
* empty page: content ranking is Host behavior, so a scenario that asserts
|
|
781
|
+
* hits declares them through {@link TestSessions.stubSearch}.
|
|
782
|
+
* @param query - non-blank literal phrase.
|
|
783
|
+
* @param signal - cancellation for a superseded search (recorded and forwarded).
|
|
784
|
+
* @returns the stubbed or empty result page.
|
|
785
|
+
*/
|
|
786
|
+
search(query, signal) {
|
|
787
|
+
this.calls.push({
|
|
788
|
+
method: "search",
|
|
789
|
+
args: [query, signal]
|
|
790
|
+
});
|
|
791
|
+
return Promise.resolve({
|
|
792
|
+
ok: true,
|
|
793
|
+
value: this.searchStub?.(query, signal) ?? {
|
|
794
|
+
items: [],
|
|
795
|
+
hasMore: false
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Recorded fork stub: no child materializes (benches asserting the full
|
|
801
|
+
* fork flow drive the production service; this face only proves the call).
|
|
802
|
+
* @param opts - source session id, optional cut anchor, and client title policy.
|
|
803
|
+
* @returns the source id (no child record is created).
|
|
804
|
+
*/
|
|
805
|
+
fork(opts) {
|
|
806
|
+
this.calls.push({
|
|
807
|
+
method: "fork",
|
|
808
|
+
args: [opts]
|
|
809
|
+
});
|
|
810
|
+
return Promise.resolve(opts.sessionId);
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* The session face of a fixture (typed view for assertions; fixture
|
|
814
|
+
* behavior methods are grafted onto it).
|
|
815
|
+
* @param id - session id.
|
|
816
|
+
* @returns the FixtureSession carried by the Controller binding.
|
|
817
|
+
*/
|
|
818
|
+
behavior(id) {
|
|
819
|
+
return this.generations.get(id)?.session ?? this.require(id).session;
|
|
820
|
+
}
|
|
821
|
+
/** Dispose minted scope fibers (runtime dispose path). */
|
|
822
|
+
async disposeScopes() {
|
|
823
|
+
this.closed = true;
|
|
824
|
+
for (const [id, generation] of this.generations) this.drop(id, generation);
|
|
825
|
+
await this.drainDrops();
|
|
826
|
+
}
|
|
827
|
+
resolveTarget(target) {
|
|
828
|
+
const id = typeof target === "string" ? target : target.childSessionId;
|
|
829
|
+
if (typeof target !== "string") this.addresses.set(id, target);
|
|
830
|
+
this.require(id);
|
|
831
|
+
return id;
|
|
832
|
+
}
|
|
833
|
+
retainGeneration(id, generation, source) {
|
|
834
|
+
const previous = generation.retention;
|
|
835
|
+
generation.retention = Object.freeze({
|
|
836
|
+
referenceCount: previous.referenceCount + 1,
|
|
837
|
+
retainedBy: freezeRetainedBy({
|
|
838
|
+
...previous.retainedBy,
|
|
839
|
+
[source]: (previous.retainedBy[source] ?? 0) + 1
|
|
840
|
+
})
|
|
841
|
+
});
|
|
842
|
+
const reference = new TestSessionReference(id, generation, () => {
|
|
843
|
+
if (!generation.live) return;
|
|
844
|
+
const count = generation.retention.referenceCount - 1;
|
|
845
|
+
const { [source]: sourceCount = 0, ...otherSources } = generation.retention.retainedBy;
|
|
846
|
+
const retainedBy = sourceCount > 1 ? {
|
|
847
|
+
...otherSources,
|
|
848
|
+
[source]: sourceCount - 1
|
|
849
|
+
} : otherSources;
|
|
850
|
+
generation.retention = count === 0 ? EMPTY_RETAIN_INFO : Object.freeze({
|
|
851
|
+
referenceCount: count,
|
|
852
|
+
retainedBy: freezeRetainedBy(retainedBy)
|
|
853
|
+
});
|
|
854
|
+
if (count === 0) this.drop(id, generation);
|
|
855
|
+
else this.publishRetention(id);
|
|
856
|
+
});
|
|
857
|
+
this.publishRetention(id);
|
|
858
|
+
return reference;
|
|
859
|
+
}
|
|
860
|
+
retentionSnapshot(id) {
|
|
861
|
+
return this.generations.get(id)?.retention ?? EMPTY_RETAIN_INFO;
|
|
862
|
+
}
|
|
863
|
+
publishRetention(id) {
|
|
864
|
+
const retention = this.retentionSnapshot(id);
|
|
865
|
+
const store = this.retentionStores.get(id);
|
|
866
|
+
if (store !== void 0 && store.getSnapshot() !== retention) store.set(retention);
|
|
867
|
+
const state = this.list.getSnapshot();
|
|
868
|
+
const row = state.byId[id];
|
|
869
|
+
if (row === void 0 || row.retainedBy === retention.retainedBy) return;
|
|
870
|
+
const summary = {
|
|
871
|
+
...row,
|
|
872
|
+
retainedBy: retention.retainedBy
|
|
873
|
+
};
|
|
874
|
+
const record = this.records.get(id);
|
|
875
|
+
/* v8 ignore next -- a catalog row and its fixture record are inserted and removed together. */
|
|
876
|
+
if (record !== void 0) record.summary = summary;
|
|
877
|
+
this.list.set({
|
|
878
|
+
...state,
|
|
879
|
+
byId: {
|
|
880
|
+
...state.byId,
|
|
881
|
+
[id]: summary
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
materialize(id, fixture) {
|
|
886
|
+
const { ctx, fiber } = createScope(this.rootCtx, id);
|
|
887
|
+
const snapshot = createSnapshotStore(fixture.snapshot.getSnapshot());
|
|
888
|
+
const session = new FixtureSession(id, snapshot, fixture.overrides);
|
|
889
|
+
const window = fixture.session.eventSource.getSnapshot();
|
|
890
|
+
session.eventSource.replace(window.entries, window.hasMore);
|
|
891
|
+
for (const [key, value] of fixture.projections) session.projections.set(key, value);
|
|
892
|
+
const opening = Promise.withResolvers();
|
|
893
|
+
opening.promise.catch(() => {});
|
|
894
|
+
const lifetime = new AbortController();
|
|
895
|
+
const generation = {
|
|
896
|
+
binding: {
|
|
897
|
+
sessionId: id,
|
|
898
|
+
session,
|
|
899
|
+
eventSource: session.eventSource,
|
|
900
|
+
ctx
|
|
901
|
+
},
|
|
902
|
+
snapshot,
|
|
903
|
+
session,
|
|
904
|
+
fiber,
|
|
905
|
+
lifetime,
|
|
906
|
+
opening: opening.promise,
|
|
907
|
+
retention: EMPTY_RETAIN_INFO,
|
|
908
|
+
live: true
|
|
909
|
+
};
|
|
910
|
+
this.generations.set(id, generation);
|
|
911
|
+
ctx.effect(() => async () => {
|
|
912
|
+
if (generation.live) {
|
|
913
|
+
generation.live = false;
|
|
914
|
+
generation.retention = EMPTY_RETAIN_INFO;
|
|
915
|
+
if (this.generations.get(id) === generation) this.generations.delete(id);
|
|
916
|
+
this.publishRetention(id);
|
|
917
|
+
}
|
|
918
|
+
lifetime.abort(/* @__PURE__ */ new Error(`test Session generation "${id}" is disposed`));
|
|
919
|
+
await Promise.allSettled([generation.opening]);
|
|
920
|
+
}, "test sessions: exact generation");
|
|
921
|
+
this.startOpening(fixture.initialOpen, lifetime.signal, opening);
|
|
922
|
+
return generation;
|
|
923
|
+
}
|
|
924
|
+
startOpening(initialOpen, signal, opening) {
|
|
925
|
+
try {
|
|
926
|
+
Promise.resolve(initialOpen?.(signal)).then(opening.resolve, opening.reject);
|
|
927
|
+
} catch (error) {
|
|
928
|
+
opening.reject(error);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
drop(id, generation) {
|
|
932
|
+
/* v8 ignore next -- only the live generation's retained callback can enter drop. */
|
|
933
|
+
if (!generation.live) return;
|
|
934
|
+
generation.live = false;
|
|
935
|
+
generation.retention = EMPTY_RETAIN_INFO;
|
|
936
|
+
/* v8 ignore next -- this synchronous path drops only the generation currently stored for id. */
|
|
937
|
+
if (this.generations.get(id) === generation) this.generations.delete(id);
|
|
938
|
+
generation.lifetime.abort(/* @__PURE__ */ new Error(`test Session generation "${id}" is released`));
|
|
939
|
+
this.publishRetention(id);
|
|
940
|
+
const disposal = generation.fiber.dispose();
|
|
941
|
+
this.pendingDrops.add(disposal);
|
|
942
|
+
disposal.then(() => {
|
|
943
|
+
this.pendingDrops.delete(disposal);
|
|
944
|
+
}, (error) => {
|
|
945
|
+
this.pendingDrops.delete(disposal);
|
|
946
|
+
this.rootCtx.logger.warn("test Session scope disposal failed:", error);
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
async drainDrops() {
|
|
950
|
+
while (this.pendingDrops.size !== 0) await Promise.all([...this.pendingDrops]);
|
|
951
|
+
}
|
|
952
|
+
require(id) {
|
|
953
|
+
const record = this.records.get(id);
|
|
954
|
+
if (record === void 0) throw new Error(`test session "${id}" is not added`);
|
|
955
|
+
return record;
|
|
956
|
+
}
|
|
957
|
+
};
|
|
958
|
+
//#endregion
|
|
959
|
+
//#region lib/types/workspaces.js
|
|
960
|
+
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
|
|
961
|
+
/**
|
|
962
|
+
* Workspaces test double. Implements the same IWorkspaces face features
|
|
963
|
+
* receive as `ctx.workspaces`, so a production face change breaks this
|
|
964
|
+
* double at compile time. Every action records into {@link
|
|
965
|
+
* TestWorkspaces.calls}; defaults are inert echoes — feature tests needing
|
|
966
|
+
* richer behavior replace them via {@link TestWorkspaces.stub}.
|
|
967
|
+
*/
|
|
968
|
+
var TestWorkspaces = class {
|
|
969
|
+
stabilize;
|
|
970
|
+
/** The useWorkspaces standard feed. */
|
|
971
|
+
list;
|
|
972
|
+
/** Calls observed on the action face, newest last. */
|
|
973
|
+
calls = [];
|
|
974
|
+
/** Replaceable action seat: feature tests may stub richer behavior. */
|
|
975
|
+
stubs = /* @__PURE__ */ new Map();
|
|
976
|
+
/**
|
|
977
|
+
* @param stabilize - the owning runtime's act wrapper.
|
|
978
|
+
*/
|
|
979
|
+
constructor(stabilize) {
|
|
980
|
+
this.stabilize = stabilize;
|
|
981
|
+
this.list = createSnapshotStore({ ...workspaceSnapshot() });
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Update the workspace list state through an immer draft.
|
|
985
|
+
* @param mutate - draft mutator.
|
|
986
|
+
*/
|
|
987
|
+
async update(mutate) {
|
|
988
|
+
await this.stabilize(() => {
|
|
989
|
+
this.list.update(mutate);
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Replace an action's behavior (the recorded call is still appended first).
|
|
994
|
+
* @param method - Controller action name (e.g. 'create').
|
|
995
|
+
* @param impl - replacement behavior.
|
|
996
|
+
*/
|
|
997
|
+
stub(method, impl) {
|
|
998
|
+
this.stubs.set(method, impl);
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Create a Workspace (recorded). The default echoes a view derived from
|
|
1002
|
+
* the input; stub for failure or list-coupled flows.
|
|
1003
|
+
* @param input - the Host create payload.
|
|
1004
|
+
* @returns the created Workspace view.
|
|
1005
|
+
*/
|
|
1006
|
+
async create(input) {
|
|
1007
|
+
this.calls.push({
|
|
1008
|
+
method: "create",
|
|
1009
|
+
args: [input]
|
|
1010
|
+
});
|
|
1011
|
+
const stub = this.stubs.get("create");
|
|
1012
|
+
if (stub !== void 0) return await stub(input);
|
|
1013
|
+
return {
|
|
1014
|
+
workspaceId: `ws-${input.path}`,
|
|
1015
|
+
title: input.path,
|
|
1016
|
+
path: input.path,
|
|
1017
|
+
sessionIds: []
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Rename a Workspace (recorded). The default echoes a minimal view.
|
|
1022
|
+
* @param workspaceId - target workspace.
|
|
1023
|
+
* @param title - new title.
|
|
1024
|
+
* @returns the updated view.
|
|
1025
|
+
*/
|
|
1026
|
+
async rename(workspaceId, title) {
|
|
1027
|
+
this.calls.push({
|
|
1028
|
+
method: "rename",
|
|
1029
|
+
args: [workspaceId, title]
|
|
1030
|
+
});
|
|
1031
|
+
const stub = this.stubs.get("rename");
|
|
1032
|
+
if (stub !== void 0) return await stub(workspaceId, title);
|
|
1033
|
+
return {
|
|
1034
|
+
workspaceId,
|
|
1035
|
+
title,
|
|
1036
|
+
path: `/${title}`,
|
|
1037
|
+
sessionIds: []
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Delete a Workspace (recorded; default no-op).
|
|
1042
|
+
* @param workspaceId - target workspace.
|
|
1043
|
+
*/
|
|
1044
|
+
async delete(workspaceId) {
|
|
1045
|
+
this.calls.push({
|
|
1046
|
+
method: "delete",
|
|
1047
|
+
args: [workspaceId]
|
|
1048
|
+
});
|
|
1049
|
+
await this.stubs.get("delete")?.(workspaceId);
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Move a Workspace in display order (recorded; default no-op).
|
|
1053
|
+
* @param workspaceId - Workspace to move.
|
|
1054
|
+
* @param beforeWorkspaceId - Anchor; omitted appends.
|
|
1055
|
+
*/
|
|
1056
|
+
async insertBefore(workspaceId, beforeWorkspaceId) {
|
|
1057
|
+
this.calls.push({
|
|
1058
|
+
method: "insertBefore",
|
|
1059
|
+
args: [workspaceId, beforeWorkspaceId]
|
|
1060
|
+
});
|
|
1061
|
+
await this.stubs.get("insertBefore")?.(workspaceId, beforeWorkspaceId);
|
|
1062
|
+
}
|
|
1063
|
+
/**
|
|
1064
|
+
* Move an accounted session (recorded). The default echoes a minimal view.
|
|
1065
|
+
* @param workspaceId - target workspace.
|
|
1066
|
+
* @param sessionId - session to move.
|
|
1067
|
+
* @param beforeSessionId - anchor; omitted appends.
|
|
1068
|
+
* @returns the updated view.
|
|
1069
|
+
*/
|
|
1070
|
+
async insertSessionBefore(workspaceId, sessionId, beforeSessionId) {
|
|
1071
|
+
this.calls.push({
|
|
1072
|
+
method: "insertSessionBefore",
|
|
1073
|
+
args: [
|
|
1074
|
+
workspaceId,
|
|
1075
|
+
sessionId,
|
|
1076
|
+
beforeSessionId
|
|
1077
|
+
]
|
|
1078
|
+
});
|
|
1079
|
+
const stub = this.stubs.get("insertSessionBefore");
|
|
1080
|
+
if (stub !== void 0) return await stub(workspaceId, sessionId, beforeSessionId);
|
|
1081
|
+
return {
|
|
1082
|
+
workspaceId,
|
|
1083
|
+
title: "",
|
|
1084
|
+
path: "",
|
|
1085
|
+
sessionIds: [sessionId]
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Archive a session (recorded). The default mirrors the production face's
|
|
1090
|
+
* observable effect: the id joins the list state's archive set.
|
|
1091
|
+
* @param sessionId - session to archive.
|
|
1092
|
+
*/
|
|
1093
|
+
async archiveSession(sessionId) {
|
|
1094
|
+
this.calls.push({
|
|
1095
|
+
method: "archiveSession",
|
|
1096
|
+
args: [sessionId]
|
|
1097
|
+
});
|
|
1098
|
+
const stub = this.stubs.get("archiveSession");
|
|
1099
|
+
if (stub !== void 0) {
|
|
1100
|
+
await stub(sessionId);
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
await this.update((draft) => {
|
|
1104
|
+
draft.archivedSessionIds = [...draft.archivedSessionIds, sessionId];
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Unarchive a session (recorded). The default mirrors the production face's
|
|
1109
|
+
* observable effect: the id leaves the list state's archive set.
|
|
1110
|
+
* @param sessionId - session to unarchive.
|
|
1111
|
+
*/
|
|
1112
|
+
async unarchiveSession(sessionId) {
|
|
1113
|
+
this.calls.push({
|
|
1114
|
+
method: "unarchiveSession",
|
|
1115
|
+
args: [sessionId]
|
|
1116
|
+
});
|
|
1117
|
+
const stub = this.stubs.get("unarchiveSession");
|
|
1118
|
+
if (stub !== void 0) {
|
|
1119
|
+
await stub(sessionId);
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
await this.update((draft) => {
|
|
1123
|
+
draft.archivedSessionIds = draft.archivedSessionIds.filter((id) => id !== sessionId);
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Pin a session (recorded). The default mirrors the production face's
|
|
1128
|
+
* observable effect: the id leads the list state's pin set.
|
|
1129
|
+
* @param sessionId - session to pin.
|
|
1130
|
+
*/
|
|
1131
|
+
async pinSession(sessionId) {
|
|
1132
|
+
this.calls.push({
|
|
1133
|
+
method: "pinSession",
|
|
1134
|
+
args: [sessionId]
|
|
1135
|
+
});
|
|
1136
|
+
const stub = this.stubs.get("pinSession");
|
|
1137
|
+
if (stub !== void 0) {
|
|
1138
|
+
await stub(sessionId);
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
await this.update((draft) => {
|
|
1142
|
+
draft.pinnedSessionIds = [sessionId, ...draft.pinnedSessionIds.filter((id) => id !== sessionId)];
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Unpin a session (recorded). The default mirrors the production face's
|
|
1147
|
+
* observable effect: the id leaves the list state's pin set.
|
|
1148
|
+
* @param sessionId - session to unpin.
|
|
1149
|
+
*/
|
|
1150
|
+
async unpinSession(sessionId) {
|
|
1151
|
+
this.calls.push({
|
|
1152
|
+
method: "unpinSession",
|
|
1153
|
+
args: [sessionId]
|
|
1154
|
+
});
|
|
1155
|
+
const stub = this.stubs.get("unpinSession");
|
|
1156
|
+
if (stub !== void 0) {
|
|
1157
|
+
await stub(sessionId);
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
await this.update((draft) => {
|
|
1161
|
+
draft.pinnedSessionIds = draft.pinnedSessionIds.filter((id) => id !== sessionId);
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
};
|
|
1165
|
+
//#endregion
|
|
1166
|
+
//#region lib/types/config-form.js
|
|
1167
|
+
/** Test doubles for settings transport. */
|
|
1168
|
+
/**
|
|
1169
|
+
* Build an in-memory settings scope for service specs: starts in the host
|
|
1170
|
+
* loading state, records writes, and lets the test publish Host acceptances.
|
|
1171
|
+
* @returns the stub handle.
|
|
1172
|
+
*/
|
|
1173
|
+
function stubConfigForm() {
|
|
1174
|
+
let snapshot = {
|
|
1175
|
+
status: "loading",
|
|
1176
|
+
value: void 0,
|
|
1177
|
+
base: void 0,
|
|
1178
|
+
user: void 0,
|
|
1179
|
+
revision: void 0,
|
|
1180
|
+
writable: false,
|
|
1181
|
+
mode: "host"
|
|
1182
|
+
};
|
|
1183
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1184
|
+
const set = vi.fn(() => Promise.resolve(true));
|
|
1185
|
+
const mutate = vi.fn(() => Promise.resolve(true));
|
|
1186
|
+
const unset = vi.fn(() => Promise.resolve(true));
|
|
1187
|
+
return {
|
|
1188
|
+
scope: {
|
|
1189
|
+
getSnapshot: () => snapshot,
|
|
1190
|
+
subscribe: (listener) => {
|
|
1191
|
+
listeners.add(listener);
|
|
1192
|
+
return () => {
|
|
1193
|
+
listeners.delete(listener);
|
|
1194
|
+
};
|
|
1195
|
+
},
|
|
1196
|
+
mutate,
|
|
1197
|
+
set,
|
|
1198
|
+
unset
|
|
1199
|
+
},
|
|
1200
|
+
set,
|
|
1201
|
+
mutate,
|
|
1202
|
+
unset,
|
|
1203
|
+
listenerCount: () => listeners.size,
|
|
1204
|
+
publish: (next) => {
|
|
1205
|
+
snapshot = {
|
|
1206
|
+
...snapshot,
|
|
1207
|
+
...next
|
|
1208
|
+
};
|
|
1209
|
+
for (const listener of [...listeners]) listener();
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
//#endregion
|
|
1214
|
+
//#region lib/types/settings-remote.js
|
|
1215
|
+
/** Test double for the `settings` Remote namespace a bench's plugins inject. */
|
|
1216
|
+
/**
|
|
1217
|
+
* Build a scripted `settings` Remote namespace for a bench. Each write answers
|
|
1218
|
+
* with the addressed namespace unchanged, so a bench that only needs its
|
|
1219
|
+
* plugins to activate scripts nothing; one asserting a write reads the
|
|
1220
|
+
* corresponding spy or replaces the face.
|
|
1221
|
+
* @param namespaces - namespace views the first describe answers with.
|
|
1222
|
+
* @param options - deployment facts the describe answer reports.
|
|
1223
|
+
* @returns the face and its controls.
|
|
1224
|
+
*/
|
|
1225
|
+
function scriptedSettingsRemote(namespaces = [], options = {}) {
|
|
1226
|
+
let served = namespaces;
|
|
1227
|
+
const writable = options.writable ?? true;
|
|
1228
|
+
const hasDocument = options.hasDocument ?? false;
|
|
1229
|
+
const answer = (ns) => {
|
|
1230
|
+
const view = served.find((candidate) => candidate.ns === ns);
|
|
1231
|
+
return Promise.resolve(view === void 0 ? {
|
|
1232
|
+
ok: false,
|
|
1233
|
+
error: {
|
|
1234
|
+
code: "settings/rejected",
|
|
1235
|
+
message: `no scripted namespace "${ns}"`,
|
|
1236
|
+
details: { ns }
|
|
1237
|
+
}
|
|
1238
|
+
} : {
|
|
1239
|
+
ok: true,
|
|
1240
|
+
value: view
|
|
1241
|
+
});
|
|
1242
|
+
};
|
|
1243
|
+
const update = vi.fn((ns, _patch, _expectedRevision) => answer(ns));
|
|
1244
|
+
const replace = vi.fn((ns, _section, _expectedRevision) => answer(ns));
|
|
1245
|
+
const mutate = vi.fn((ns, _ops, _expectedRevision) => answer(ns));
|
|
1246
|
+
return {
|
|
1247
|
+
settings: {
|
|
1248
|
+
describe: () => Promise.resolve({
|
|
1249
|
+
ok: true,
|
|
1250
|
+
value: {
|
|
1251
|
+
writable,
|
|
1252
|
+
hasDocument,
|
|
1253
|
+
namespaces: served
|
|
1254
|
+
}
|
|
1255
|
+
}),
|
|
1256
|
+
update: (ns, patch, expectedRevision) => update(ns, patch, expectedRevision),
|
|
1257
|
+
replace: (ns, section, expectedRevision) => replace(ns, section, expectedRevision),
|
|
1258
|
+
mutate: (ns, ops, expectedRevision) => mutate(ns, ops, expectedRevision)
|
|
1259
|
+
},
|
|
1260
|
+
update,
|
|
1261
|
+
replace,
|
|
1262
|
+
mutate,
|
|
1263
|
+
publish(next) {
|
|
1264
|
+
served = next;
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
//#endregion
|
|
1269
|
+
//#region lib/types/translate.js
|
|
1270
|
+
/**
|
|
1271
|
+
* Test double of the locale lookup chain: a translate stub over plain
|
|
1272
|
+
* dictionaries, mirroring LocaleRuntime's resolution order (first dictionary
|
|
1273
|
+
* that owns the key wins, then the key itself stays visible) and its
|
|
1274
|
+
* `{name}` template interpolation. Specs stub the framework-injected `t`
|
|
1275
|
+
* seat with `makeTranslate(zh, commonZh)` instead of re-implementing the
|
|
1276
|
+
* chain per suite.
|
|
1277
|
+
*/
|
|
1278
|
+
/**
|
|
1279
|
+
* Build a translate stub resolving through `dicts` in order (namespace
|
|
1280
|
+
* first, then the shared common vocabulary), falling back to the key.
|
|
1281
|
+
* @param dicts - dictionaries consulted in order.
|
|
1282
|
+
* @returns the translate function (assignable to any `XxxProps['t']` seat).
|
|
1283
|
+
*/
|
|
1284
|
+
function makeTranslate(...dicts) {
|
|
1285
|
+
return (key, params) => {
|
|
1286
|
+
let template = key;
|
|
1287
|
+
for (const dict of dicts) {
|
|
1288
|
+
const hit = dict[key];
|
|
1289
|
+
if (hit !== void 0) {
|
|
1290
|
+
template = hit;
|
|
1291
|
+
break;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
if (!params) return template;
|
|
1295
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => name in params ? String(params[name]) : match);
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
//#endregion
|
|
1299
|
+
//#region lib/types/locale-env.js
|
|
1300
|
+
/**
|
|
1301
|
+
* Browser-language pin for specs that assert localized copy. A fresh
|
|
1302
|
+
* LocaleRuntime with no stored preference opens in the language `navigator`
|
|
1303
|
+
* asks for, and jsdom reports the runner's own (`en-US`) — so a spec asserting
|
|
1304
|
+
* the product's Chinese copy states the browser it assumes instead of
|
|
1305
|
+
* inheriting the machine's.
|
|
1306
|
+
*/
|
|
1307
|
+
/**
|
|
1308
|
+
* Pin `navigator.languages`/`navigator.language` for every test in the
|
|
1309
|
+
* calling file (or describe block), restoring the environment's own values
|
|
1310
|
+
* afterwards. Call at suite level, like the other vitest hooks.
|
|
1311
|
+
* @param primary - most preferred BCP 47 tag; also becomes `navigator.language`.
|
|
1312
|
+
* @param rest - further tags in preference order.
|
|
1313
|
+
*/
|
|
1314
|
+
function usePinnedBrowserLanguages(primary, ...rest) {
|
|
1315
|
+
beforeEach(() => {
|
|
1316
|
+
Object.defineProperty(navigator, "languages", {
|
|
1317
|
+
value: [primary, ...rest],
|
|
1318
|
+
configurable: true
|
|
1319
|
+
});
|
|
1320
|
+
Object.defineProperty(navigator, "language", {
|
|
1321
|
+
value: primary,
|
|
1322
|
+
configurable: true
|
|
1323
|
+
});
|
|
1324
|
+
});
|
|
1325
|
+
afterEach(() => {
|
|
1326
|
+
const own = navigator;
|
|
1327
|
+
delete own.languages;
|
|
1328
|
+
delete own.language;
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
//#endregion
|
|
1332
|
+
//#region lib/types/index.js
|
|
1333
|
+
/**
|
|
1334
|
+
* jsdom slot test runtime: a real small runtime — Cordis `Context`, the
|
|
1335
|
+
* renderer-owned `SlotRegistry`, the `ui-session` adapter, and the UI renderer — assembled around
|
|
1336
|
+
* test-owned session/workspace doubles and a fail-loud file-upload stub, so feature specs exercise
|
|
1337
|
+
* declaration, registration, scope, store, inject, rendering, updates, and
|
|
1338
|
+
* disposal without hand-building the machinery per suite.
|
|
1339
|
+
*
|
|
1340
|
+
* Not part of the product plugin graph (no `qilin.client`); feature packages
|
|
1341
|
+
* depend on it in devDependencies only. It copies no SlotCore/renderer/store
|
|
1342
|
+
* machinery — everything mounts the production implementations.
|
|
1343
|
+
* @module @qilin-agent/client-test-runtime
|
|
1344
|
+
*/
|
|
1345
|
+
/**
|
|
1346
|
+
* Bind an observable source to the production renderer's selector hook.
|
|
1347
|
+
* @param source - Observable snapshot source.
|
|
1348
|
+
* @returns Typed React selector hook.
|
|
1349
|
+
*/
|
|
1350
|
+
function bindSnapshotSelector(source) {
|
|
1351
|
+
return bindSnapshotSelector$1(source);
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Create the production slot renderer used by client feature tests.
|
|
1355
|
+
* @returns Slot renderer instance.
|
|
1356
|
+
*/
|
|
1357
|
+
function createSlotRenderer() {
|
|
1358
|
+
return createSlotRenderer$1();
|
|
1359
|
+
}
|
|
1360
|
+
/** The empty keycap catalog, one frozen reference (see the shortcuts stub below). */
|
|
1361
|
+
const NO_SHORTCUTS = [];
|
|
1362
|
+
/** The idle shortcut-config reading, one frozen reference for the same reason. */
|
|
1363
|
+
const DEFAULT_SHORTCUTS_CONFIG = {
|
|
1364
|
+
sequence: 0,
|
|
1365
|
+
status: "ready"
|
|
1366
|
+
};
|
|
1367
|
+
/**
|
|
1368
|
+
* Owner-props cell behind the auto frame: one external store the frame
|
|
1369
|
+
* subscribes to, so {@link SlotTestRuntime.renderSlot} and
|
|
1370
|
+
* {@link SlotView.update} drive React through the standard uSES boundary.
|
|
1371
|
+
*/
|
|
1372
|
+
var OwnerPropsCell = class {
|
|
1373
|
+
owners = /* @__PURE__ */ new Map();
|
|
1374
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1375
|
+
version = 0;
|
|
1376
|
+
/** Snapshot version for uSES pairing (bumped on every set). */
|
|
1377
|
+
getVersion = () => this.version;
|
|
1378
|
+
/**
|
|
1379
|
+
* Subscribe to owner-props changes.
|
|
1380
|
+
* @param fn - change callback.
|
|
1381
|
+
* @returns unsubscribe.
|
|
1382
|
+
*/
|
|
1383
|
+
subscribe = (fn) => {
|
|
1384
|
+
this.listeners.add(fn);
|
|
1385
|
+
return () => {
|
|
1386
|
+
this.listeners.delete(fn);
|
|
1387
|
+
};
|
|
1388
|
+
};
|
|
1389
|
+
/**
|
|
1390
|
+
* Install or replace one key's owner props and notify (synchronous; the
|
|
1391
|
+
* caller wraps in act).
|
|
1392
|
+
* @param key - slot key.
|
|
1393
|
+
* @param owner - owner props share.
|
|
1394
|
+
* @param opts - explicit keyed or list selection for the render site.
|
|
1395
|
+
*/
|
|
1396
|
+
set(key, owner, opts) {
|
|
1397
|
+
this.owners.set(key, {
|
|
1398
|
+
owner,
|
|
1399
|
+
opts
|
|
1400
|
+
});
|
|
1401
|
+
this.version += 1;
|
|
1402
|
+
for (const fn of [...this.listeners]) fn();
|
|
1403
|
+
}
|
|
1404
|
+
/** Keys with supplied owner props, in first-supply order. */
|
|
1405
|
+
entries() {
|
|
1406
|
+
return [...this.owners.entries()];
|
|
1407
|
+
}
|
|
1408
|
+
};
|
|
1409
|
+
/**
|
|
1410
|
+
* The test-owned 'root' occupant: declares the child slots a suite needs
|
|
1411
|
+
* through the REAL `slots.register`, with a caller-supplied minimal frame —
|
|
1412
|
+
* the runtime never guesses a feature's page structure.
|
|
1413
|
+
*/
|
|
1414
|
+
var TestRoot = class {
|
|
1415
|
+
slots;
|
|
1416
|
+
stabilize;
|
|
1417
|
+
disposeEntry;
|
|
1418
|
+
/**
|
|
1419
|
+
* @param slots - the runtime SlotRegistry.
|
|
1420
|
+
* @param stabilize - the owning runtime's act wrapper.
|
|
1421
|
+
*/
|
|
1422
|
+
constructor(slots, stabilize) {
|
|
1423
|
+
this.slots = slots;
|
|
1424
|
+
this.stabilize = stabilize;
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Register the root frame, declaring (and thereby claiming) the child
|
|
1428
|
+
* slots. One declaration per runtime — a second call fails loud in the
|
|
1429
|
+
* core ('root' is a single slot).
|
|
1430
|
+
* @param children - child-slot declaration table (declaration + render authorization + runtime spec).
|
|
1431
|
+
* @param frame - minimal frame component; its props derive from the declared keys (composed-props contract).
|
|
1432
|
+
* @returns completion of the act-wrapped registration.
|
|
1433
|
+
*/
|
|
1434
|
+
async declare(children, frame) {
|
|
1435
|
+
await this.stabilize(() => {
|
|
1436
|
+
this.disposeEntry = this.slots.register({
|
|
1437
|
+
name: "root",
|
|
1438
|
+
children
|
|
1439
|
+
}, frame);
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
/** Remove the root registration and collapse its declarations (runtime dispose path). */
|
|
1443
|
+
release() {
|
|
1444
|
+
this.disposeEntry?.();
|
|
1445
|
+
this.disposeEntry = void 0;
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
/**
|
|
1449
|
+
* The assembled test runtime. Obtain via {@link SlotTestRuntime.create};
|
|
1450
|
+
* dispose with {@link SlotTestRuntime.dispose} (afterEach). Public mutators
|
|
1451
|
+
* are act-wrapped throughout — tests never handle SlotCore microtask
|
|
1452
|
+
* batching or React act themselves.
|
|
1453
|
+
*/
|
|
1454
|
+
var SlotTestRuntime = class SlotTestRuntime {
|
|
1455
|
+
/** The runtime's Cordis root for owner APIs and explicit test-only services. */
|
|
1456
|
+
ctx;
|
|
1457
|
+
/** The production SlotRegistry mounted on {@link SlotTestRuntime.ctx}. */
|
|
1458
|
+
slots;
|
|
1459
|
+
/** The test-owned 'root' occupant. */
|
|
1460
|
+
root;
|
|
1461
|
+
/** Fixture catalog, explicit references, scoped contexts, and behavior faces. */
|
|
1462
|
+
sessions;
|
|
1463
|
+
/** One Remote double shared by every feature mounted in this runtime. */
|
|
1464
|
+
remote;
|
|
1465
|
+
/** Workspaces double (list observable, recorded intent actions). */
|
|
1466
|
+
workspaces;
|
|
1467
|
+
/** Test-owned panel selection used by the framework's usePanelInfo hook. */
|
|
1468
|
+
panelInfo = createSnapshotStore({ activePanelId: null });
|
|
1469
|
+
/** Mutable file-upload stub; replace `upload` in suites that exercise the capability. */
|
|
1470
|
+
fileUpload;
|
|
1471
|
+
stabilizer = async (fn) => {
|
|
1472
|
+
await act(async () => {
|
|
1473
|
+
await fn();
|
|
1474
|
+
});
|
|
1475
|
+
};
|
|
1476
|
+
host;
|
|
1477
|
+
views = [];
|
|
1478
|
+
handles = [];
|
|
1479
|
+
disposed = false;
|
|
1480
|
+
/** Auto-frame state ({@link SlotTestRuntime.declare} / {@link SlotTestRuntime.renderSlot}). */
|
|
1481
|
+
ownerCell = new OwnerPropsCell();
|
|
1482
|
+
autoDeclared = /* @__PURE__ */ new Set();
|
|
1483
|
+
autoRootView;
|
|
1484
|
+
disposeWorkspaceSource;
|
|
1485
|
+
disposePanelInfoSource;
|
|
1486
|
+
constructor(ctx, slots, options = {}) {
|
|
1487
|
+
this.ctx = ctx;
|
|
1488
|
+
this.slots = slots;
|
|
1489
|
+
this.root = new TestRoot(slots, this.stabilizer);
|
|
1490
|
+
this.sessions = new TestSessions(this.stabilizer, ctx);
|
|
1491
|
+
this.remote = new TestRemote(ctx);
|
|
1492
|
+
this.workspaces = new TestWorkspaces(this.stabilizer);
|
|
1493
|
+
this.fileUpload = { upload: () => Promise.reject(/* @__PURE__ */ new Error("client test runtime: file upload is not stubbed")) };
|
|
1494
|
+
ctx.provide("sessions", this.sessions);
|
|
1495
|
+
ctx.provide("workspaces", this.workspaces);
|
|
1496
|
+
ctx.provide("fileUpload", this.fileUpload);
|
|
1497
|
+
if (options.shortcuts !== false) ctx.provide("shortcuts", {
|
|
1498
|
+
runtime: "web",
|
|
1499
|
+
platform: "macos",
|
|
1500
|
+
stopSequenceMs: 500,
|
|
1501
|
+
catalog: {
|
|
1502
|
+
getSnapshot: () => NO_SHORTCUTS,
|
|
1503
|
+
subscribe: () => () => {}
|
|
1504
|
+
},
|
|
1505
|
+
fixedCatalog: {
|
|
1506
|
+
getSnapshot: () => NO_SHORTCUTS,
|
|
1507
|
+
subscribe: () => () => {}
|
|
1508
|
+
},
|
|
1509
|
+
config: {
|
|
1510
|
+
getSnapshot: () => DEFAULT_SHORTCUTS_CONFIG,
|
|
1511
|
+
subscribe: () => () => {}
|
|
1512
|
+
},
|
|
1513
|
+
register: () => () => {},
|
|
1514
|
+
registerFixed: () => () => {},
|
|
1515
|
+
observeFixedInput: () => () => {},
|
|
1516
|
+
describeBinding: () => ({
|
|
1517
|
+
binding: null,
|
|
1518
|
+
keys: [],
|
|
1519
|
+
issue: null,
|
|
1520
|
+
conflicts: []
|
|
1521
|
+
}),
|
|
1522
|
+
edit: () => Promise.resolve({ status: "saved" }),
|
|
1523
|
+
recording: () => Promise.resolve(),
|
|
1524
|
+
closeWindow: () => Promise.resolve()
|
|
1525
|
+
});
|
|
1526
|
+
this.disposeWorkspaceSource = slots.provideRoot({ hooks: { workspaces: this.workspaces.list } });
|
|
1527
|
+
this.disposePanelInfoSource = slots.provideRoot({ hooks: { panelInfo: this.panelInfo } });
|
|
1528
|
+
const renderer = createSlotRenderer();
|
|
1529
|
+
slots.install({ renderRoot: (host, ownerProps) => {
|
|
1530
|
+
this.host = host;
|
|
1531
|
+
return renderer.renderRoot(host, ownerProps);
|
|
1532
|
+
} });
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Assemble a runtime: real Context, mounted SlotRegistry, installed
|
|
1536
|
+
* renderer, and the session/workspace doubles provided as services.
|
|
1537
|
+
* @param options - the shortcuts stub is mounted unless `shortcuts: false`,
|
|
1538
|
+
* which lets a spec provide the real shortcuts service instead.
|
|
1539
|
+
* @returns the ready runtime.
|
|
1540
|
+
*/
|
|
1541
|
+
static async create(options = {}) {
|
|
1542
|
+
registerDomSnapshotSerializer();
|
|
1543
|
+
const ctx = new Context();
|
|
1544
|
+
await ctx.plugin(SlotRegistry).await();
|
|
1545
|
+
const runtime = new SlotTestRuntime(ctx, ctx.get("slots"), options);
|
|
1546
|
+
await ctx.plugin({
|
|
1547
|
+
inject: [...inject],
|
|
1548
|
+
apply
|
|
1549
|
+
}).await();
|
|
1550
|
+
return runtime;
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Mount a feature plugin on a real fiber. Required services are prechecked
|
|
1554
|
+
* so a missing provider fails loud instead of suspending the fiber forever
|
|
1555
|
+
* (deliberate load-order suspension tests use `ctx.plugin` directly).
|
|
1556
|
+
* @param plugin - plugin value (function, class, or `{ inject, apply }` object).
|
|
1557
|
+
* @returns handle owning the fiber's explicit disposal.
|
|
1558
|
+
*/
|
|
1559
|
+
async mount(plugin) {
|
|
1560
|
+
const missing = Object.keys(Inject.resolve(plugin.inject)).filter((name) => this.ctx.get(name) === void 0);
|
|
1561
|
+
if (missing.length > 0) throw new Error(`mount would suspend: missing service(s) ${missing.join(", ")} — provide() them first`);
|
|
1562
|
+
const fiber = this.ctx.plugin(plugin);
|
|
1563
|
+
await this.stabilizer(async () => {
|
|
1564
|
+
await fiber.await();
|
|
1565
|
+
});
|
|
1566
|
+
let disposed = false;
|
|
1567
|
+
const handle = {
|
|
1568
|
+
fiber,
|
|
1569
|
+
dispose: async () => {
|
|
1570
|
+
if (disposed) return;
|
|
1571
|
+
disposed = true;
|
|
1572
|
+
await this.stabilizer(() => fiber.dispose());
|
|
1573
|
+
}
|
|
1574
|
+
};
|
|
1575
|
+
this.handles.push(handle);
|
|
1576
|
+
return handle;
|
|
1577
|
+
}
|
|
1578
|
+
/** Release the default Workspace hook before mounting its production owner. */
|
|
1579
|
+
releaseWorkspaceSource() {
|
|
1580
|
+
this.disposeWorkspaceSource();
|
|
1581
|
+
}
|
|
1582
|
+
/** Release the default panel hook before mounting the production Layout owner. */
|
|
1583
|
+
releasePanelInfoSource() {
|
|
1584
|
+
this.disposePanelInfoSource();
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* Render the root slot tree through the ctx-level entry (the shell's own
|
|
1588
|
+
* entry point): `ctx.slots.renderSlot('root', {})` under Testing Library.
|
|
1589
|
+
* @returns the Testing Library view.
|
|
1590
|
+
*/
|
|
1591
|
+
renderRoot() {
|
|
1592
|
+
const view = render(createElement(Fragment, null, this.slots.renderSlot("root", {})));
|
|
1593
|
+
this.views.push(view);
|
|
1594
|
+
return view;
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Declare child slots under an auto-generated root frame — the single-slot
|
|
1598
|
+
* mounting path for local DOM snapshots. Each key later supplied through
|
|
1599
|
+
* {@link SlotTestRuntime.renderSlot} renders inside the renderer's own
|
|
1600
|
+
* `<div data-slot="<key>">` outlet anchor (the snapshot root — the frame
|
|
1601
|
+
* adds no wrapper of its own). Mutually exclusive with
|
|
1602
|
+
* {@link TestRoot.declare} ('root' is a single slot); one call per runtime.
|
|
1603
|
+
* @param children - child-slot declaration table (same contract as TestRoot.declare).
|
|
1604
|
+
* @returns completion of the act-wrapped registration.
|
|
1605
|
+
*/
|
|
1606
|
+
async declare(children) {
|
|
1607
|
+
for (const key of Object.keys(children)) this.autoDeclared.add(key);
|
|
1608
|
+
const cell = this.ownerCell;
|
|
1609
|
+
const AutoFrame = (props) => {
|
|
1610
|
+
useSyncExternalStore(cell.subscribe, cell.getVersion);
|
|
1611
|
+
return createElement(Fragment, null, cell.entries().map(([key, { owner, opts }]) => {
|
|
1612
|
+
const body = props.renderSlot(key, owner, opts);
|
|
1613
|
+
const spec = children[key];
|
|
1614
|
+
if (spec.scope === "root") return createElement(Fragment, { key }, body);
|
|
1615
|
+
return createElement(props.SessionProvider, {
|
|
1616
|
+
key,
|
|
1617
|
+
session: opts?.session,
|
|
1618
|
+
children: body,
|
|
1619
|
+
...spec.scope === "session-maybe" ? { empty: () => body } : {}
|
|
1620
|
+
});
|
|
1621
|
+
}));
|
|
1622
|
+
};
|
|
1623
|
+
await this.root.declare(children, AutoFrame);
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Render one declared slot with its owner props and return the local view.
|
|
1627
|
+
* The whole root tree mounts through the production assembly path
|
|
1628
|
+
* (renderer, scope providers, store axis); only this key's output lands in
|
|
1629
|
+
* the returned container. Call again with another key to view a sibling
|
|
1630
|
+
* slot of the same tree.
|
|
1631
|
+
* @param key - a key declared through {@link SlotTestRuntime.declare}.
|
|
1632
|
+
* @param owner - owner props share for the render site.
|
|
1633
|
+
* @param opts - explicit entry selection and borrowed Session reference; retained by view updates.
|
|
1634
|
+
* @returns the slot-local view (snapshot container, scoped queries, owner updates).
|
|
1635
|
+
*/
|
|
1636
|
+
renderSlot(key, owner, opts) {
|
|
1637
|
+
if (!this.autoDeclared.has(key)) throw new Error(`renderSlot('${key}') without declare() — declare the key first (or use root.declare for a custom frame)`);
|
|
1638
|
+
let options = opts;
|
|
1639
|
+
const install = (next, nextOptions = options) => {
|
|
1640
|
+
options = nextOptions;
|
|
1641
|
+
act(() => {
|
|
1642
|
+
this.ownerCell.set(key, next, options);
|
|
1643
|
+
});
|
|
1644
|
+
};
|
|
1645
|
+
install(owner);
|
|
1646
|
+
this.autoRootView ??= this.renderRoot();
|
|
1647
|
+
const container = this.autoRootView.container.querySelector(`[data-slot="${key}"]`);
|
|
1648
|
+
if (!(container instanceof HTMLElement)) throw new Error(`renderSlot('${key}'): the auto frame rendered no wrapper — was the runtime already disposed?`);
|
|
1649
|
+
return {
|
|
1650
|
+
container,
|
|
1651
|
+
view: within(container),
|
|
1652
|
+
update: install
|
|
1653
|
+
};
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Resolve the store instance the renderer would hand a slot's component
|
|
1657
|
+
* (identity assertions, action-driven writes). Requires a prior
|
|
1658
|
+
* {@link SlotTestRuntime.renderRoot} — the host face exists only inside the
|
|
1659
|
+
* installed renderer, exactly as in production.
|
|
1660
|
+
* @param key - slot key whose first entry declares the store.
|
|
1661
|
+
* @param session - retained Session reference for session-scope slots; omit for root scope.
|
|
1662
|
+
* @returns the live store instance.
|
|
1663
|
+
*/
|
|
1664
|
+
storeOf(key, session) {
|
|
1665
|
+
if (this.host === void 0) throw new Error("storeOf before renderRoot() — the host face exists only inside the installed renderer");
|
|
1666
|
+
const entry = this.host.entriesOf(key)[0];
|
|
1667
|
+
if (entry === void 0) throw new Error(`storeOf('${key}'): no registration on the ledger`);
|
|
1668
|
+
const adapter = session === void 0 ? void 0 : this.host.scope("session");
|
|
1669
|
+
const resolved = session === void 0 ? void 0 : adapter?.bindingSource(session).getSnapshot();
|
|
1670
|
+
const scopeBinding = resolved?.key === void 0 ? void 0 : resolved;
|
|
1671
|
+
if (session !== void 0 && scopeBinding === void 0) throw new Error(`storeOf('${key}'): no live Session binding for '${session.sessionId}'`);
|
|
1672
|
+
const instance = this.host.storeOf(entry, scopeBinding);
|
|
1673
|
+
if (instance === void 0) throw new Error(`storeOf('${key}'): the entry declares no store`);
|
|
1674
|
+
return instance;
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Read one registered Factory definition for direct contract assertions.
|
|
1678
|
+
* @param name - registered Factory name.
|
|
1679
|
+
* @returns the live Factory definition.
|
|
1680
|
+
*/
|
|
1681
|
+
factoryOf(name) {
|
|
1682
|
+
if (this.host === void 0) throw new Error("factoryOf before renderRoot()");
|
|
1683
|
+
const definition = this.host.factoryOf(name);
|
|
1684
|
+
if (definition === void 0) throw new Error(`factoryOf('${name}'): no definition`);
|
|
1685
|
+
return definition;
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Flush pending ledger/store notifications inside act — for mutations made
|
|
1689
|
+
* outside the runtime's own methods (e.g. a direct `slots.register`).
|
|
1690
|
+
* @returns completion of the act pass.
|
|
1691
|
+
*/
|
|
1692
|
+
async flush() {
|
|
1693
|
+
await this.stabilizer(() => {});
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Tear down: unmount React trees first, then dispose feature fibers, the
|
|
1697
|
+
* root registration and standard sources, minted session scopes, and persisted test state.
|
|
1698
|
+
* Idempotent.
|
|
1699
|
+
* @returns completion of the teardown.
|
|
1700
|
+
*/
|
|
1701
|
+
async dispose() {
|
|
1702
|
+
if (this.disposed) return;
|
|
1703
|
+
this.disposed = true;
|
|
1704
|
+
this.autoRootView = void 0;
|
|
1705
|
+
for (const view of this.views.splice(0)) view.unmount();
|
|
1706
|
+
for (const handle of this.handles.splice(0)) await handle.dispose();
|
|
1707
|
+
this.root.release();
|
|
1708
|
+
this.disposeWorkspaceSource();
|
|
1709
|
+
this.disposePanelInfoSource();
|
|
1710
|
+
await this.sessions.disposeScopes();
|
|
1711
|
+
await this.stabilizer(() => this.ctx.fiber.dispose());
|
|
1712
|
+
localStorage.clear();
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
//#endregion
|
|
1716
|
+
export { FixtureSession, RemoteError, SlotTestRuntime, TestRemote, TestRoot, TestSessions, TestWorkspaces, bindSnapshotSelector, chatSnapshot, conversationSnapshot, createSlotRenderer, domSnapshotSerializer, makeTranslate, registerDomSnapshotSerializer, scriptedSettingsRemote, sessionSnapshot, stubConfigForm, usePinnedBrowserLanguages, workspaceSnapshot };
|