dsh-session-rewind 0.0.0-stage → 1.2.7

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.
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Browser-half smoke test: loads `lib/client.js` exactly as a classic script
3
+ * with a stub module loader, a stub React, a stub slot registry and a stub
4
+ * `fetch`, then drives the rendered control through plan -> fork and checks the
5
+ * side effects that matter (the fork boundary, the pre-seeded draft, the opened
6
+ * session and the restore request).
7
+ */
8
+
9
+ import assert from 'node:assert/strict';
10
+ import { readFile } from 'node:fs/promises';
11
+
12
+ //#region stubs
13
+ const listeners = new Map();
14
+ globalThis.document = {
15
+ addEventListener: (type, handler) => listeners.set(type, handler),
16
+ removeEventListener: (type) => listeners.delete(type),
17
+ querySelector: () => null,
18
+ createElement: () => ({ dataset: {}, textContent: '' }),
19
+ head: { appendChild: () => {} },
20
+ documentElement: { appendChild: () => {} },
21
+ };
22
+ const store = new Map();
23
+ globalThis.localStorage = {
24
+ getItem: (key) => (store.has(key) ? store.get(key) : null),
25
+ setItem: (key, value) => store.set(key, String(value)),
26
+ removeItem: (key) => store.delete(key),
27
+ };
28
+ globalThis.window = globalThis;
29
+
30
+ async function installReact() {
31
+ const { createRequire } = await import('node:module');
32
+ // No React in this environment: a minimal hook store per component function is
33
+ // enough (hook order never changes inside either component).
34
+ const stores = new Map();
35
+ let hooks = [];
36
+ let cursor = 0;
37
+ const ReactStub = {
38
+ useState: (initial) => {
39
+ // Capture this render's store: setters outlive the render that made them.
40
+ const store = hooks;
41
+ const index = cursor;
42
+ cursor += 1;
43
+ if (!(index in store)) store[index] = typeof initial === 'function' ? initial() : initial;
44
+ return [store[index], (next) => {
45
+ store[index] = typeof next === 'function' ? next(store[index]) : next;
46
+ }];
47
+ },
48
+ useRef: (initial) => {
49
+ const store = hooks;
50
+ const index = cursor;
51
+ cursor += 1;
52
+ if (!(index in store)) store[index] = { current: initial };
53
+ return store[index];
54
+ },
55
+ useEffect: () => {
56
+ cursor += 1;
57
+ return undefined;
58
+ },
59
+ };
60
+ const runtime = {
61
+ jsx: (type, props, key) => ({ type, props: props ?? {}, key }),
62
+ jsxs: (type, props, key) => ({ type, props: props ?? {}, key }),
63
+ };
64
+ return {
65
+ require: (id) => {
66
+ if (id === 'react') return ReactStub;
67
+ if (id === 'react/jsx-runtime') return runtime;
68
+ throw new Error(`unexpected require: ${id}`);
69
+ },
70
+ /** One render pass of one component: its own hook store, cursor at 0. */
71
+ beginRender: (component) => {
72
+ if (!stores.has(component)) stores.set(component, []);
73
+ hooks = stores.get(component);
74
+ cursor = 0;
75
+ },
76
+ resetCursor: () => {
77
+ cursor = 0;
78
+ },
79
+ getHooks: () => hooks,
80
+ createRequire,
81
+ };
82
+ }
83
+
84
+ const walk = (node, visit) => {
85
+ if (node === null || node === undefined || typeof node === 'boolean') return;
86
+ if (Array.isArray(node)) {
87
+ for (const child of node) walk(child, visit);
88
+ return;
89
+ }
90
+ if (typeof node === 'string' || typeof node === 'number') {
91
+ visit({ type: '#text', props: { children: node } });
92
+ return;
93
+ }
94
+ if (typeof node.type !== 'object' && typeof node.type !== 'function' && typeof node.type !== 'string') return;
95
+ if (typeof node.type === 'function') {
96
+ // Every invocation is one render pass of that component.
97
+ react.beginRender(node.type);
98
+ walk(node.type({ ...node.props, key: node.key }), visit);
99
+ return;
100
+ }
101
+ visit(node);
102
+ if (node.props !== undefined && node.props.children !== undefined) walk(node.props.children, visit);
103
+ };
104
+
105
+ const findAll = (tree, predicate) => {
106
+ const found = [];
107
+ walk(tree, (node) => {
108
+ if (predicate(node)) found.push(node);
109
+ });
110
+ return found;
111
+ };
112
+
113
+ const textOf = (tree) => {
114
+ const parts = [];
115
+ walk(tree, (node) => {
116
+ if (node.type === '#text') parts.push(String(node.props.children));
117
+ });
118
+ return parts.join('');
119
+ };
120
+
121
+ const flush = async (times = 6) => {
122
+ for (let index = 0; index < times; index += 1) {
123
+ await new Promise((resolve) => setTimeout(resolve, 5));
124
+ }
125
+ };
126
+ //#endregion
127
+
128
+ const react = await installReact();
129
+ let factory;
130
+ let moduleId;
131
+ globalThis.__ModuleLoader__ = {
132
+ load: (entry) => {
133
+ moduleId = entry.id;
134
+ factory = entry.factory;
135
+ },
136
+ };
137
+ const source = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8');
138
+ // eslint-disable-next-line no-new-func
139
+ new Function(source)();
140
+ assert.equal(moduleId, 'dsh-session-rewind');
141
+ assert.equal(typeof factory, 'function');
142
+
143
+ const clientModule = factory(react.require);
144
+ assert.equal(typeof clientModule.apply, 'function');
145
+ assert.deepEqual(clientModule.inject, []);
146
+
147
+ //#region registries
148
+ const registrations = [];
149
+ const localeEntries = [];
150
+ /** Services the Client context resolves; filled before the render block. */
151
+ const services = {};
152
+ const context = {
153
+ get: (name) => services[name],
154
+ inject: (names, callback) => {
155
+ const scope = {
156
+ get: (name) => (name === 'slots' || name === 'locale' ? scopeServices[name] : undefined),
157
+ effect: (fn) => {
158
+ const disposer = fn();
159
+ return typeof disposer === 'function' ? disposer : () => {};
160
+ },
161
+ slots: {
162
+ inject: (name, register) => {
163
+ register();
164
+ },
165
+ register: (options, component) => {
166
+ registrations.push({ options, component });
167
+ return () => {};
168
+ },
169
+ },
170
+ locale: {
171
+ register: (namespace, dictionaries) => {
172
+ localeEntries.push({ namespace, dictionaries });
173
+ return () => {};
174
+ },
175
+ bind: (namespace) => (key) => `${namespace}.${key}`,
176
+ },
177
+ };
178
+ const scopeServices = { slots: scope.slots, locale: scope.locale };
179
+ callback(scope);
180
+ return scope;
181
+ },
182
+ };
183
+ clientModule.apply(context);
184
+ assert.equal(registrations.length, 2);
185
+ const registration = registrations.find((entry) => entry.options.name === 'conversation.chat.turnTail');
186
+ const headerRegistration = registrations.find((entry) => entry.options.name === 'conversation.session.header.actions');
187
+ assert.ok(registration !== undefined, 'turn-tail registration');
188
+ assert.ok(headerRegistration !== undefined, 'header registration');
189
+ assert.equal(headerRegistration.options.id, 'session-rewind');
190
+ assert.equal(headerRegistration.options.order, -1);
191
+ assert.equal(registration.options.name, 'conversation.chat.turnTail');
192
+ assert.equal(registration.options.id, 'session-rewind');
193
+ assert.equal(registration.options.order, -1);
194
+ assert.equal(registration.options.inject().tr('button.label'), 'session-rewind.button.label');
195
+ assert.equal(localeEntries.length, 1);
196
+ assert.equal(localeEntries[0].namespace, 'session-rewind');
197
+ assert.equal(typeof localeEntries[0].dictionaries.zh, 'object');
198
+ assert.equal(typeof localeEntries[0].dictionaries.en, 'object');
199
+ //#endregion
200
+
201
+ //#region render, plan, fork
202
+ const childId = 'session-child-9';
203
+ const fetchCalls = [];
204
+ globalThis.fetch = async (route, init) => {
205
+ const body = JSON.parse(init.body);
206
+ fetchCalls.push({ route, body });
207
+ if (route === '/api/session-rewind.plan') {
208
+ return {
209
+ ok: true,
210
+ status: 200,
211
+ json: async () => ({
212
+ ok: true,
213
+ turn: 3,
214
+ seq: 12,
215
+ prompt: '把登录页改成深色主题',
216
+ files: [
217
+ { path: 'E:/work/app/login.tsx', displayPath: 'app/login.tsx', action: 'restore', size: 42 },
218
+ { path: 'E:/work/app/new.css', displayPath: 'app/new.css', action: 'delete', size: 8 },
219
+ ],
220
+ }),
221
+ };
222
+ }
223
+ if (route === '/api/session-rewind.restore') {
224
+ return {
225
+ ok: true,
226
+ status: 200,
227
+ json: async () => ({
228
+ ok: true,
229
+ turn: 3,
230
+ results: (body.paths ?? []).map((path) => ({ path, status: 'restored' })),
231
+ }),
232
+ };
233
+ }
234
+ if (route === '/api/session-rewind.candidates') {
235
+ return {
236
+ ok: true,
237
+ status: 200,
238
+ json: async () => ({
239
+ ok: true,
240
+ turns: [
241
+ { turn: 2, seq: 28, userSeq: 21, prompt: '第二条提问:把登录页改成深色主题', files: { count: 2, restorable: 2 } },
242
+ { turn: 1, seq: 12, userSeq: 5, prompt: '第一条提问:初始化项目', files: { count: 0, restorable: 0 } },
243
+ ],
244
+ }),
245
+ };
246
+ }
247
+ throw new Error(`unexpected route ${route}`);
248
+ };
249
+
250
+ const forkCalls = [];
251
+ const opened = [];
252
+ const sessionsService = {
253
+ fork: async (request) => {
254
+ forkCalls.push(request);
255
+ return childId;
256
+ },
257
+ };
258
+ const workspaceService = {
259
+ openSession: (target) => opened.push(target),
260
+ };
261
+ services.sessions = sessionsService;
262
+ services.uiWorkspace = workspaceService;
263
+ const props = {
264
+ sessionId: 'session-parent-1',
265
+ turn: { turn: 3, end: { seq: 12 } },
266
+ seq: 12,
267
+ useAccess: () => true,
268
+ ctx: {
269
+ get: (name) => {
270
+ if (name === 'sessions') return sessionsService;
271
+ if (name === 'uiWorkspace') return workspaceService;
272
+ return undefined;
273
+ },
274
+ },
275
+ };
276
+
277
+ // No target -> nothing renders.
278
+ react.resetCursor();
279
+ const emptyTree = registration.component({ ...props, turn: undefined, seq: undefined });
280
+ assert.deepEqual(findAll(emptyTree, (node) => node.type === 'button'), []);
281
+
282
+ react.resetCursor();
283
+ let tree = registration.component(props);
284
+ let triggers = findAll(tree, (node) => node.type === 'button'
285
+ && String(node.props.className ?? '').includes('dshSessionRewindTrigger'));
286
+ assert.equal(triggers.length, 1);
287
+ assert.match(textOf(tree), /session-rewind\.button\.label/u);
288
+
289
+ // Open the panel: it must ask the Host for a plan first.
290
+ triggers[0].props.onClick();
291
+ await flush();
292
+ assert.equal(fetchCalls.length, 1);
293
+ assert.deepEqual(fetchCalls[0].body, { sessionId: 'session-parent-1', turn: 3 });
294
+ assert.equal(fetchCalls[0].route, '/api/session-rewind.plan');
295
+
296
+ react.resetCursor();
297
+ tree = registration.component(props);
298
+ assert.match(textOf(tree), /session-rewind\.panel\.title/u);
299
+ assert.match(textOf(tree), /app\/login\.tsx/u);
300
+ assert.match(textOf(tree), /app\/new\.css/u);
301
+ assert.match(textOf(tree), /session-rewind\.panel\.deleteOne/u);
302
+
303
+ // Rewind: restore the checked files, fork at the Turn boundary, seed the draft
304
+ // and open the child session.
305
+ const primary = findAll(tree, (node) => node.type === 'button'
306
+ && String(node.props.className ?? '').includes('dshSessionRewindPrimary'));
307
+ assert.equal(primary.length, 1);
308
+ primary[0].props.onClick();
309
+ await flush(12);
310
+
311
+ assert.equal(fetchCalls.length, 2);
312
+ assert.equal(fetchCalls[1].route, '/api/session-rewind.restore');
313
+ assert.deepEqual(fetchCalls[1].body.paths, ['E:/work/app/login.tsx', 'E:/work/app/new.css']);
314
+ assert.deepEqual(forkCalls, [{ sessionId: 'session-parent-1', atSeq: 12, increaseTitle: true }]);
315
+ assert.deepEqual(opened, [childId]);
316
+ const draft = JSON.parse(store.get(`dsh.conversation.${childId}`));
317
+ assert.deepEqual(draft.draft, { text: '把登录页改成深色主题', references: [] });
318
+
319
+ // Unchecking a file keeps it out of the restore request.
320
+ react.resetCursor();
321
+ tree = registration.component(props);
322
+ const boxes = findAll(tree, (node) => node.type === 'input');
323
+ assert.equal(boxes.length, 2);
324
+ boxes[0].props.onChange();
325
+ react.resetCursor();
326
+ tree = registration.component(props);
327
+ const restoreOnly = findAll(tree, (node) => node.type === 'button'
328
+ && String(node.props.children ?? '') === 'session-rewind.action.restore');
329
+ assert.equal(restoreOnly.length, 1);
330
+ fetchCalls.length = 0;
331
+ restoreOnly[0].props.onClick();
332
+ await flush(10);
333
+ assert.deepEqual(fetchCalls[0].body.paths, ['E:/work/app/new.css']);
334
+ assert.equal(forkCalls.length, 1);
335
+ //#endregion
336
+
337
+ //#region failure paths
338
+ // A session with no fork boundary renders nothing at all.
339
+ react.resetCursor();
340
+ const noBoundary = registration.component({ ...props, turn: { turn: 3 }, seq: undefined });
341
+ assert.deepEqual(findAll(noBoundary, (node) => node.type === 'button'), []);
342
+ //#endregion
343
+
344
+ //#region session-header entry: candidate list -> mode -> fork
345
+ const headerChildId = 'session-child-header';
346
+ fetchCalls.length = 0;
347
+ forkCalls.length = 0;
348
+ opened.length = 0;
349
+ sessionsService.fork = async (request) => {
350
+ forkCalls.push(request);
351
+ return headerChildId;
352
+ };
353
+
354
+ const headerProps = {
355
+ sessionId: 'session-parent-1',
356
+ useAccess: () => true,
357
+ ctx: {
358
+ get: (name) => {
359
+ if (name === 'sessions') return sessionsService;
360
+ if (name === 'uiWorkspace') return workspaceService;
361
+ return undefined;
362
+ },
363
+ },
364
+ };
365
+
366
+ // The trigger renders even with nothing loaded yet.
367
+ // `renderHeader()` forces one full render pass (the tree is built lazily).
368
+ const renderHeader = () => {
369
+ const tree = headerRegistration.component(headerProps);
370
+ findAll(tree, () => false);
371
+ return tree;
372
+ };
373
+ let headerTree = renderHeader();
374
+ let headerTrigger = findAll(headerTree, (node) => node.type === 'button'
375
+ && String(node.props.className ?? '').includes('dshSessionRewindHeaderBtn'));
376
+ assert.equal(headerTrigger.length, 1);
377
+
378
+ // Opening it asks the Host for the finished Turns.
379
+ headerTrigger[0].props.onClick();
380
+ await flush();
381
+ assert.equal(fetchCalls.length, 1);
382
+ assert.equal(fetchCalls[0].route, '/api/session-rewind.candidates');
383
+ assert.deepEqual(fetchCalls[0].body, { sessionId: 'session-parent-1' });
384
+ headerTree = renderHeader();
385
+
386
+ let headerRounds = findAll(headerTree, (node) => node.type === 'button'
387
+ && String(node.props.className ?? '').includes('dshSessionRewindRound'));
388
+ assert.equal(headerRounds.length, 2, 'two candidate rounds');
389
+ assert.match(textOf(headerTree), /session-rewind\.list\.round/u);
390
+ assert.match(textOf(headerTree), /第二条提问/u);
391
+ assert.match(textOf(headerTree), /session-rewind\.list\.files/u);
392
+
393
+ // Choosing a round switches to the mode step: in-place is the primary action
394
+ // and branching is still offered.
395
+ headerRounds[0].props.onClick();
396
+ assert.match(textOf(headerTree), /session-rewind\.mode\.inplace/u);
397
+ assert.match(textOf(headerTree), /session-rewind\.mode\.inplaceFiles/u);
398
+ assert.match(textOf(headerTree), /session-rewind\.mode\.fork/u);
399
+ assert.match(textOf(headerTree), /session-rewind\.mode\.back/u);
400
+
401
+ // Branch mode forks at that round's committed turn end, with no file write.
402
+ const headerFork = findAll(headerTree, (node) => node.type === 'button'
403
+ && String(node.props.children ?? '') === 'session-rewind.mode.fork');
404
+ assert.equal(headerFork.length, 1);
405
+ headerFork[0].props.onClick();
406
+ await flush(12);
407
+ assert.deepEqual(forkCalls, [{ sessionId: 'session-parent-1', atSeq: 28, increaseTitle: true }]);
408
+ assert.deepEqual(opened, [headerChildId]);
409
+ assert.deepEqual(
410
+ JSON.parse(store.get(`dsh.conversation.${headerChildId}`)).draft,
411
+ { text: '第二条提问:把登录页改成深色主题', references: [] },
412
+ );
413
+ assert.equal(fetchCalls.filter((call) => call.route === '/api/session-rewind.restore').length, 0);
414
+
415
+ // "Rewind and restore" reaches the restore route first, then forks.
416
+ fetchCalls.length = 0;
417
+ forkCalls.length = 0;
418
+ opened.length = 0;
419
+ headerTrigger = findAll(renderHeader(), (node) => node.type === 'button'
420
+ && String(node.props.className ?? '').includes('dshSessionRewindHeaderBtn'));
421
+ headerTrigger[0].props.onClick();
422
+ await flush();
423
+ headerTree = renderHeader();
424
+ headerRounds = findAll(headerTree, (node) => node.type === 'button'
425
+ && String(node.props.className ?? '').includes('dshSessionRewindRound'));
426
+ assert.equal(headerRounds.length, 2);
427
+ headerRounds[0].props.onClick();
428
+ headerTree = renderHeader();
429
+ const restoreBoth = findAll(headerTree, (node) => node.type === 'button'
430
+ && String(node.props.children ?? '') === 'session-rewind.mode.inplaceFiles');
431
+ assert.equal(restoreBoth.length, 1);
432
+ // In-place mode without a mount-less bridge must degrade to a visible error
433
+ // instead of throwing (the DOM bridge only exists in a real page).
434
+ restoreBoth[0].props.onClick();
435
+ await flush(12);
436
+ assert.match(textOf(headerTree), /session-rewind\.notice\.unavailable/u);
437
+ assert.equal(forkCalls.length, 0);
438
+ //#endregion
439
+
440
+ console.log('client smoke: all assertions passed');