@sanqianx/project-knowledge 5.0.26 → 5.0.28

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,531 @@
1
+ 'use strict';
2
+ // _site/_test/helpers/qa-r91-harness.cjs — P-MEMORY-DELETE-R91
3
+ //
4
+ // Copy of helpers/parallel-memory-shell.js (the P-MEMORY-01 lane harness)
5
+ // with exactly one product difference: the in-process knowledge engine is
6
+ // loaded from the R91 SOURCE CHECKOUT (sibling knowledge-engine repository,
7
+ // override with QA_R91_ENGINE_SRC) instead of the root's _modules pin, so
8
+ // the browser exercises the lane's own delete implementation. The installed
9
+ // core modules and the _modules pin are never touched.
10
+ //
11
+ // Everything else keeps the original lane discipline: own port/data dir/
12
+ // knowledge root/bridge journal home, declared stand-in analyzer, token
13
+ // pass-through proxy in front of the engine, real HTTP module services,
14
+ // real Chrome in its own profile, and no contact with the live
15
+ // 5757/5790/8787/8790 endpoints, the real HOME or the real memories.
16
+ //
17
+ // R99 (P-MEMORY-RELEASE-PREP-R99): startHarness({ productRoot }) — or the
18
+ // QA_R91_PRODUCT_ROOT env — runs the SAME isolated acceptance against an
19
+ // explicitly chosen OFFICIAL installed prefix: the spawned product server,
20
+ // its cwd and the static UI come from <productRoot>/_site/lib/server-app.js,
21
+ // and the in-process engine defaults to the prefix's vendored
22
+ // _modules/knowledge-engine (QA_R91_ENGINE_SRC still overrides). Helpers,
23
+ // isolation and stand-in analyzers stay canonical. Invalid roots are
24
+ // rejected by resolveProductRoot BEFORE any spawn; the default source-root
25
+ // behaviour is unchanged.
26
+
27
+ const assert = require('node:assert/strict');
28
+ const crypto = require('node:crypto');
29
+ const fs = require('node:fs');
30
+ const path = require('node:path');
31
+ const { spawnSync } = require('node:child_process');
32
+
33
+ const { spawnServer } = require('../helpers/spawn-server');
34
+ const { findChrome } = require('../helpers/find-chrome');
35
+ const { launchCdpBrowser, waitFor } = require('../helpers/cdp-browser');
36
+ const { attachNetworkCapture } = require('../helpers/cdp-network-capture');
37
+ const { createModuleFixture, createUpstreamProxy } = require('../helpers/module-fixture');
38
+ const freePort = require('../helpers/free-port');
39
+ const { createMemoryStandinAnalyzer } = require('./parallel-memory-analyzer');
40
+ const { createEventBridgeStandin } = require('./parallel-memory-event-bridge');
41
+ const { applyDriverIsolation } = require('./parallel-qa-engine-endpoint-isolate');
42
+ const { assertOwnedEndpoint, createEngineVectorStandin } = require('./parallel-qa-engine-endpoint-guard');
43
+
44
+ const ROOT = path.resolve(__dirname, '../../..');
45
+ const LIVE_PORTS = new Set([5757, 5760, 5790, 8787, 8790]);
46
+ const ENGINE_SRC_ENV = process.env.QA_R91_ENGINE_SRC;
47
+ const ENGINE_SOURCE_ROOT = path.resolve(ENGINE_SRC_ENV || path.join(ROOT, '..', 'knowledge-engine'));
48
+ const ENGINE_SOURCE_SERVER = path.join(ENGINE_SOURCE_ROOT, 'src', 'server.js');
49
+ // R99: captured at MODULE LOAD, before applyDriverIsolation can replace the
50
+ // process environment (the isolate helper wipes process.env). These are the
51
+ // REAL system locations a product root must never live in: the live npm
52
+ // global prefix, per-user program installs, Program Files, and any prefix
53
+ // surfaced through NPM_CONFIG_PREFIX. Reading them later would see the
54
+ // ISOLATED values and the protection would silently vanish.
55
+ const REAL_ENV_MARKERS = {
56
+ appdata: process.env.APPDATA,
57
+ localAppdata: process.env.LOCALAPPDATA,
58
+ programFiles: process.env.ProgramFiles,
59
+ programFilesX86: process.env['ProgramFiles(x86)'],
60
+ npmConfigPrefix: process.env.NPM_CONFIG_PREFIX,
61
+ };
62
+ const PRODUCT_ROOT_ENV = process.env.QA_R91_PRODUCT_ROOT;
63
+ const DENY_ROOTS_ENV = process.env.QA_R99_DENY_ROOTS;
64
+
65
+ const PRODUCT_SITE_ENTRY = path.join('_site', 'lib', 'server-app.js');
66
+
67
+ function invalidProductRoot(reason, detail) {
68
+ return Object.assign(new Error(`QA_R91_PRODUCT_ROOT ${reason}: ${detail}`), { code: 'INVALID_PRODUCT_ROOT', reason });
69
+ }
70
+
71
+ function pathInside(parent, child) {
72
+ const rel = path.relative(path.resolve(parent), path.resolve(child));
73
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
74
+ }
75
+
76
+ // R99: validates an explicitly chosen product root for official-artifact
77
+ // acceptance. A product root is where a REAL installed prefix lives: the
78
+ // spawned server, its cwd and its static UI all come from
79
+ // <root>/_site/lib/server-app.js. Location rules run BEFORE the existence
80
+ // check, so a protected location is rejected deterministically even when the
81
+ // path does not exist yet. The canonical source checkout stays reachable
82
+ // only through the DEFAULT behaviour (no productRoot).
83
+ function resolveProductRoot(candidate) {
84
+ if (typeof candidate !== 'string' || candidate.trim() === '') {
85
+ throw invalidProductRoot('NOT_ABSOLUTE', `the product root must be a non-empty absolute path, got ${JSON.stringify(candidate)}`);
86
+ }
87
+ if (!path.isAbsolute(candidate)) {
88
+ throw invalidProductRoot('NOT_ABSOLUTE', `relative product root ${JSON.stringify(candidate)} — the official prefix must be given as an absolute path`);
89
+ }
90
+ const root = path.resolve(candidate);
91
+
92
+ const protectedLocations = [];
93
+ if (REAL_ENV_MARKERS.appdata) protectedLocations.push({ label: 'the live npm global prefix', dir: path.join(REAL_ENV_MARKERS.appdata, 'npm') });
94
+ if (REAL_ENV_MARKERS.localAppdata) protectedLocations.push({ label: 'per-user program installs', dir: path.join(REAL_ENV_MARKERS.localAppdata, 'Programs') });
95
+ if (REAL_ENV_MARKERS.programFiles) protectedLocations.push({ label: 'Program Files', dir: REAL_ENV_MARKERS.programFiles });
96
+ if (REAL_ENV_MARKERS.programFilesX86) protectedLocations.push({ label: 'Program Files (x86)', dir: REAL_ENV_MARKERS.programFilesX86 });
97
+ if (REAL_ENV_MARKERS.npmConfigPrefix) protectedLocations.push({ label: 'NPM_CONFIG_PREFIX', dir: REAL_ENV_MARKERS.npmConfigPrefix });
98
+ for (const deny of (DENY_ROOTS_ENV || '').split(path.delimiter).map(entry => entry.trim()).filter(Boolean)) {
99
+ protectedLocations.push({ label: 'QA_R99_DENY_ROOTS', dir: deny });
100
+ }
101
+ for (const spot of protectedLocations) {
102
+ if (pathInside(spot.dir, root)) throw invalidProductRoot('PROTECTED_LOCATION', `${root} is inside ${spot.label} ${spot.dir} — the real installed prefix must never be a test root`);
103
+ }
104
+ // A product root must be a separate installed location: the canonical
105
+ // source checkout and the engine source checkout are not official prefixes
106
+ // (and overlapping them in either direction would blur source/default runs).
107
+ for (const spot of [{ label: 'the canonical test repository', dir: ROOT }, { label: 'the engine source checkout', dir: ENGINE_SOURCE_ROOT }]) {
108
+ if (pathInside(spot.dir, root) || pathInside(root, spot.dir)) throw invalidProductRoot('PROTECTED_SOURCE_ROOT', `${root} overlaps ${spot.label} ${spot.dir}`);
109
+ }
110
+
111
+ if (!fs.existsSync(root)) throw invalidProductRoot('MISSING_ROOT', `${root} does not exist`);
112
+ const siteEntry = path.join(root, PRODUCT_SITE_ENTRY);
113
+ if (!fs.existsSync(siteEntry)) throw invalidProductRoot('MISSING_SITE_ENTRY', `${siteEntry} not found — not a product prefix`);
114
+ let packageJson;
115
+ try {
116
+ packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
117
+ } catch (error) {
118
+ throw invalidProductRoot('INVALID_PACKAGE', `package.json unreadable: ${error.message}`);
119
+ }
120
+ if (!packageJson || typeof packageJson.name !== 'string' || !packageJson.name || typeof packageJson.version !== 'string' || !packageJson.version) {
121
+ throw invalidProductRoot('INVALID_PACKAGE', `package.json must carry name and version, got ${JSON.stringify({ name: packageJson && packageJson.name, version: packageJson && packageJson.version })}`);
122
+ }
123
+ return { root, packageJson, siteEntry, engineRoot: path.join(root, '_modules', 'knowledge-engine') };
124
+ }
125
+
126
+ function r91CreateEngineServer(options = {}, engineSourceRoot = ENGINE_SOURCE_ROOT) {
127
+ // Same fail-closed endpoint discipline as the R5 guard, but the module is
128
+ // the R91 source checkout under review (or, under a product root, the
129
+ // prefix's vendored engine).
130
+ const engineServerPath = path.join(engineSourceRoot, 'src', 'server.js');
131
+ if (!fs.existsSync(engineServerPath)) throw new Error(`R91 engine source not found: ${engineServerPath}`);
132
+ assertOwnedEndpoint(options.vectorUrl, { role: 'vectorUrl' });
133
+ if (options.bridge === undefined) assertOwnedEndpoint(options.bridgeUrl, { role: 'bridgeUrl' });
134
+ const { createEngineServer } = require(engineServerPath);
135
+ return createEngineServer(options);
136
+ }
137
+
138
+ function git(cwd, args) {
139
+ const result = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', windowsHide: true, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
140
+ assert.equal(result.status, 0, `git ${args.join(' ')} failed: ${result.stderr}`);
141
+ return String(result.stdout || '').trim();
142
+ }
143
+
144
+ /** A real Git repository with real commits: the product imports it for real. */
145
+ function makeRepo(root, name, files = { 'README.md': `# ${name}\n` }) {
146
+ const repo = path.join(root, name);
147
+ fs.mkdirSync(repo, { recursive: true });
148
+ git(repo, ['init', '-q', '-b', 'main']);
149
+ git(repo, ['config', 'user.email', 'qa-r91@local.invalid']);
150
+ git(repo, ['config', 'user.name', 'QA R91 Regression']);
151
+ git(repo, ['config', 'commit.gpgsign', 'false']);
152
+ for (const [file, content] of Object.entries(files)) fs.writeFileSync(path.join(repo, file), content, 'utf8');
153
+ git(repo, ['add', '.']);
154
+ git(repo, ['commit', '-qm', 'baseline']);
155
+ return { path: repo, headCommit: git(repo, ['rev-parse', 'HEAD']) };
156
+ }
157
+
158
+ function commitFile(repo, file, content, message = 'follow-up') {
159
+ const full = path.join(repo.path, file);
160
+ fs.mkdirSync(path.dirname(full), { recursive: true });
161
+ // R94: every fixture commit must be MEANINGFUL. Re-committing identical
162
+ // bytes leaves nothing to stage and fails the git assert; instead the file
163
+ // gains a distinct revision line so the repository always moves forward
164
+ // with a real, distinct commit.
165
+ let revision = 0;
166
+ do {
167
+ fs.writeFileSync(full, revision === 0 ? content : `${content}\n// qa-revision-${revision}\n`, 'utf8');
168
+ revision += 1;
169
+ } while (git(repo.path, ['status', '--porcelain']).length === 0 && revision < 50);
170
+ git(repo.path, ['add', '.']);
171
+ git(repo.path, ['commit', '-qm', message]);
172
+ repo.headCommit = git(repo.path, ['rev-parse', 'HEAD']);
173
+ return repo.headCommit;
174
+ }
175
+
176
+ async function startHarness({ tag, evidenceDir, productRoot, viewport = { width: 1600, height: 900 } } = {}) {
177
+ // R99: validate an explicit product root BEFORE isolation and BEFORE any
178
+ // spawn — a bad root fails synchronously here, no child process starts.
179
+ // Without productRoot (option or QA_R91_PRODUCT_ROOT env) the default
180
+ // source-root behaviour is byte-for-byte unchanged.
181
+ const requestedProductRoot = productRoot || PRODUCT_ROOT_ENV || null;
182
+ const product = requestedProductRoot ? resolveProductRoot(requestedProductRoot) : null;
183
+ // Engine source: an explicit QA_R91_ENGINE_SRC always wins; under a product
184
+ // root the default becomes the prefix's vendored engine; otherwise the
185
+ // sibling source checkout (unchanged default).
186
+ const harnessEngineSource = ENGINE_SRC_ENV ? ENGINE_SOURCE_ROOT : (product ? product.engineRoot : ENGINE_SOURCE_ROOT);
187
+ if (product && !fs.existsSync(path.join(harnessEngineSource, 'src', 'server.js'))) {
188
+ throw invalidProductRoot('MISSING_PRODUCT_ENGINE', `no vendored engine under the official prefix: ${path.join(harnessEngineSource, 'src', 'server.js')} (set QA_R91_ENGINE_SRC to override)`);
189
+ }
190
+
191
+ const applied = applyDriverIsolation({ prefix: `pk-qa-r91-${tag}`, captureChrome: true });
192
+ const tempRoot = applied.layout.temp;
193
+
194
+ const fixture = fs.mkdtempSync(path.join(tempRoot, `pk-qa-r91-${tag}-`));
195
+ const evidence = evidenceDir || path.join(fixture, 'evidence');
196
+ fs.mkdirSync(evidence, { recursive: true });
197
+ const dataDir = path.join(fixture, 'data');
198
+ const knowledgeRoot = path.join(fixture, 'knowledge');
199
+ fs.mkdirSync(knowledgeRoot, { recursive: true });
200
+
201
+ const plan = { document: '', memoryCandidates: [] };
202
+ const engineToken = crypto.randomBytes(24).toString('hex');
203
+ const engineVector = createEngineVectorStandin({ label: 'r91-engine-vector' });
204
+ await engineVector.ready;
205
+ const terminal = createModuleFixture({ kind: 'terminal', tag: `${tag}-terminal` });
206
+ const vectorHub = createModuleFixture({ kind: 'vectorHub', tag: `${tag}-vectorhub` });
207
+ const eventBridge = await createEventBridgeStandin({ tag: `${tag}-eventbridge` });
208
+ await terminal.start();
209
+ await vectorHub.start();
210
+ await eventBridge.start();
211
+
212
+ const engineDataDir = path.join(fixture, 'engine');
213
+ // R94: `let`, not `const` — restartEngine reassigns this handle so cleanup
214
+ // and every in-process helper always act on the LIVE engine (closing the
215
+ // closed original left the restarted server handle open and hung Node).
216
+ let engine = r91CreateEngineServer({
217
+ dataDir: engineDataDir,
218
+ runtimeToken: engineToken,
219
+ debug: true,
220
+ consume: false,
221
+ analyzer: createMemoryStandinAnalyzer({ plan }),
222
+ vectorUrl: engineVector.url,
223
+ bridgeUrl: eventBridge.url,
224
+ }, harnessEngineSource);
225
+ await engine.ready;
226
+ await new Promise(resolve => engine.server.listen(0, '127.0.0.1', resolve));
227
+ const enginePort = engine.server.address().port;
228
+ assert.equal(LIVE_PORTS.has(enginePort), false, 'the lane engine must never use a live port');
229
+ const engineUrl = `http://127.0.0.1:${enginePort}`;
230
+ const engineProxy = createUpstreamProxy({ target: engineUrl, token: engineToken, tag: 'engine' });
231
+ await engineProxy.start();
232
+
233
+ const port = await freePort();
234
+ const base = `http://127.0.0.1:${port}`;
235
+ const server = spawnServer({
236
+ // R99: under a product root the SERVER, its cwd and the static UI come
237
+ // from the official prefix; helpers and fixtures stay canonical.
238
+ root: product ? product.root : ROOT,
239
+ port,
240
+ dataDir,
241
+ tag: `qa-r91-${tag}`,
242
+ analysisOwner: 'engine',
243
+ extraEnv: {
244
+ KB_MODULES_REGISTER: '1',
245
+ KB_MODULES_AUTOSTART: '0',
246
+ KB_TERMINAL_URL: terminal.url,
247
+ KB_VECTORHUB_URL: vectorHub.url,
248
+ KB_EVENTBRIDGE_URL: eventBridge.url,
249
+ KB_KNOWLEDGE_ENGINE_URL: engineProxy.url,
250
+ },
251
+ });
252
+
253
+ const productApi = async (method, route, body) => {
254
+ const response = await fetch(base + route, {
255
+ method,
256
+ headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
257
+ body: body === undefined ? undefined : JSON.stringify(body),
258
+ signal: AbortSignal.timeout(20000),
259
+ });
260
+ return { status: response.status, body: await response.json().catch(() => null) };
261
+ };
262
+
263
+ const engineApi = async (method, route, body) => {
264
+ const response = await fetch(engineUrl + route, {
265
+ method,
266
+ headers: {
267
+ 'Content-Type': 'application/json',
268
+ ...(engineToken ? { Authorization: `Bearer ${engineToken}` } : {}),
269
+ },
270
+ body: body === undefined ? undefined : JSON.stringify(body),
271
+ signal: AbortSignal.timeout(20000),
272
+ });
273
+ return { status: response.status, body: await response.json().catch(() => null) };
274
+ };
275
+
276
+ await waitFor(async () => {
277
+ try { return (await productApi('GET', '/api/health')).status === 200; } catch { return false; }
278
+ }, 'the isolated product instance', 30000);
279
+ const settings = await productApi('PATCH', '/api/settings', { knowledge: { rootPath: knowledgeRoot } });
280
+ assert.equal(settings.status, 200, `the isolated instance accepts its own knowledge root: ${JSON.stringify(settings.body)}`);
281
+
282
+ let browser = null;
283
+ let network = null;
284
+ try {
285
+ browser = await launchCdpBrowser({
286
+ chrome: findChrome(),
287
+ profileDir: path.join(fixture, 'browser'),
288
+ url: `${base}/`,
289
+ width: viewport.width,
290
+ height: viewport.height,
291
+ });
292
+ await browser.setViewport(viewport.width, viewport.height);
293
+ await browser.send('Network.enable');
294
+ network = attachNetworkCapture(browser);
295
+ } catch (error) {
296
+ await server.cleanup();
297
+ await engineProxy.close();
298
+ await terminal.close();
299
+ await vectorHub.close();
300
+ await eventBridge.close();
301
+ await engine.close().catch(() => {});
302
+ fs.rmSync(fixture, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
303
+ throw error;
304
+ }
305
+
306
+ const run = expression => browser.evaluate(expression);
307
+ const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
308
+
309
+ const reload = async () => {
310
+ const startedAt = Date.now();
311
+ await run('window.__pkShellReloadMark = Date.now(), true');
312
+ await browser.send('Page.reload', {});
313
+ await waitFor(
314
+ () => run("document.readyState === 'complete' && typeof window.__pkShellReloadMark === 'undefined' && document.querySelector('.view.active') !== null"),
315
+ 'the reloaded page is the fresh document and its boot finished',
316
+ 20000,
317
+ );
318
+ return Date.now() - startedAt;
319
+ };
320
+
321
+ const selectOption = async (selector, value) => {
322
+ const changed = await run(`(() => {
323
+ const el = document.querySelector(${JSON.stringify(selector)});
324
+ if (!el) return { missing: true };
325
+ el.value = ${JSON.stringify(value)};
326
+ el.dispatchEvent(new Event('change', { bubbles: true }));
327
+ return { value: el.value };
328
+ })()`);
329
+ assert.ok(changed && !changed.missing, `the select ${selector} must exist`);
330
+ assert.equal(changed.value, value, `the select ${selector} must accept the value ${value}`);
331
+ };
332
+
333
+ const nativeClick = async selector => {
334
+ const probe = await run(`(() => {
335
+ const el = document.querySelector(${JSON.stringify(selector)});
336
+ if (!el) return { missing: true };
337
+ const rect = el.getBoundingClientRect();
338
+ if (rect.width <= 0 || rect.height <= 0) return { invisible: true };
339
+ const x = rect.left + rect.width / 2;
340
+ const y = rect.top + rect.height / 2;
341
+ const hit = document.elementFromPoint(x, y);
342
+ return { x, y, hitIsTarget: Boolean(hit && (hit === el || el.contains(hit))), label: (el.textContent || '').trim().slice(0, 40) };
343
+ })()`);
344
+ assert.ok(probe && !probe.missing, `the control ${selector} must exist in the page`);
345
+ assert.ok(!probe.invisible, `the control ${selector} must be visible`);
346
+ assert.ok(probe.hitIsTarget, `the control ${selector} must be the hit target; something else is on top`);
347
+ const common = { x: probe.x, y: probe.y, button: 'left', clickCount: 1 };
348
+ await browser.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: probe.x, y: probe.y, button: 'none', clickCount: 0 });
349
+ await browser.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...common });
350
+ await browser.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...common });
351
+ return probe;
352
+ };
353
+
354
+ const typeText = async text => {
355
+ for (const character of String(text)) {
356
+ const upper = character.toUpperCase();
357
+ const isLower = character >= 'a' && character <= 'z';
358
+ const isUpper = character >= 'A' && character <= 'Z';
359
+ const isDigit = character >= '0' && character <= '9';
360
+ const code = isLower ? `Key${upper}` : isUpper ? `Key${character}` : isDigit ? `Digit${character}` : '';
361
+ const keyCode = isLower ? upper.charCodeAt(0) : isUpper || isDigit ? character.charCodeAt(0) : 0;
362
+ const base = { key: character, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode };
363
+ await browser.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base, text: character, unmodifiedText: character });
364
+ await browser.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base });
365
+ }
366
+ };
367
+
368
+ const importProject = async (repo, displayName, { domainId } = {}) => {
369
+ const knowledgePath = path.join(knowledgeRoot, displayName);
370
+ fs.mkdirSync(knowledgePath, { recursive: true });
371
+ const result = await productApi('POST', '/api/projects/import', { localPath: repo.path, knowledgePath, displayName });
372
+ assert.equal(result.status, 201, `the project import must succeed: ${JSON.stringify(result.body)}`);
373
+ const projectId = result.body.projectId;
374
+ if (domainId) {
375
+ const patched = await productApi('PATCH', `/api/projects/${encodeURIComponent(projectId)}`, { domainId });
376
+ assert.equal(patched.status, 200, `the domain binding must succeed: ${JSON.stringify(patched.body)}`);
377
+ }
378
+ return { projectId, project: result.body.project, knowledgePath, modules: result.body.modules };
379
+ };
380
+
381
+ const addDomain = async name => {
382
+ const result = await productApi('POST', '/api/domains', { name });
383
+ assert.equal(result.status, 201, `the brain domain must be accepted: ${JSON.stringify(result.body)}`);
384
+ return result.body.domain;
385
+ };
386
+
387
+ const brainScopePath = () => {
388
+ const scope = engine.store.state.sharedScopes.find(entry => entry.kind === 'brain');
389
+ assert.ok(scope, 'the engine must know the brain scope');
390
+ return scope.knowledgePath;
391
+ };
392
+
393
+ const productMemories = async () => {
394
+ const snapshot = await productApi('GET', '/api/engine/snapshot?summary=1&limit=50');
395
+ assert.equal(snapshot.status, 200, `the product snapshot must be readable: ${JSON.stringify(snapshot.body)}`);
396
+ return snapshot.body.memories || [];
397
+ };
398
+
399
+ const injectedMemories = async projectId => {
400
+ const context = await engineApi('POST', '/api/context', { projectId, query: '长期记忆', task: 'qa-r91' });
401
+ assert.equal(context.status, 200, `the engine context must be readable: ${JSON.stringify(context.body)}`);
402
+ return context.body.memories || [];
403
+ };
404
+
405
+ const memoryCandidate = ({ text, event, scopeType = 'global' }) => ({
406
+ text,
407
+ quote: text,
408
+ scopeType,
409
+ sourceEventId: event.eventId,
410
+ sourceHash: event.contentHash,
411
+ eligibility: {
412
+ durable: true,
413
+ crossProject: true,
414
+ reason: '人工明确表达的长期偏好',
415
+ scopeReason: scopeType === 'global' ? '适用于所有领域与项目' : '适用于指定领域',
416
+ },
417
+ });
418
+
419
+ const ingestUserEvent = async ({ projectId, commitSha, content, inputOrigin = 'human' }) => {
420
+ const id = `event-${crypto.randomUUID()}`;
421
+ const turn = `turn-${crypto.randomUUID()}`;
422
+ const result = await engine.coordinator.conversations.appendEvent(projectId, {
423
+ eventId: id,
424
+ sequence: 1,
425
+ source: 'claude-code',
426
+ eventType: 'user_prompt',
427
+ role: 'user',
428
+ inputOrigin,
429
+ content,
430
+ sessionId: `session-${projectId}`,
431
+ turnId: turn,
432
+ capturedAt: new Date().toISOString(),
433
+ identityConfidence: 'exact',
434
+ explicitCommitSha: commitSha,
435
+ });
436
+ return { eventId: id, turnId: turn, contentHash: result.event.contentHash, inputOrigin: result.event.inputOrigin };
437
+ };
438
+
439
+ const triggerRun = async (projectId, commitSha, timeoutMs = 60000) => {
440
+ const started = await engineApi('POST', '/api/runs', { projectId, commitSha });
441
+ assert.equal(started.status, 200, `the run must be accepted: ${JSON.stringify(started.body)}`);
442
+ const runId = started.body.run.runId;
443
+ const deadline = Date.now() + timeoutMs;
444
+ let last = null;
445
+ while (Date.now() < deadline) {
446
+ const current = await engineApi('GET', `/api/runs/${encodeURIComponent(runId)}`);
447
+ last = current.body && current.body.run;
448
+ if (last && ['succeeded', 'failed'].includes(last.state)) {
449
+ if (last.state === 'failed') throw new Error(`the run failed: ${JSON.stringify(last.error)}`);
450
+ return last;
451
+ }
452
+ if (last && last.promotedAt && last.memoryCapture) return last;
453
+ await wait(150);
454
+ }
455
+ throw new Error(`the run did not finish in time: ${JSON.stringify(last)}`);
456
+ };
457
+
458
+ const produceCandidate = async ({ repo, projectId, text, scopeType = 'global', file = 'src/candidate.js' }) => {
459
+ const head = commitFile(repo, file, `// ${text}\n`, 'candidate commit');
460
+ const event = await ingestUserEvent({ projectId, commitSha: head, content: `人工要求:${text}` });
461
+ plan.document = `# 冻结证据\n\n${text}\n`;
462
+ plan.memoryCandidates = [memoryCandidate({ text, event, scopeType })];
463
+ const run = await triggerRun(projectId, head);
464
+ return { run, event, commitSha: head, capture: run.memoryCapture };
465
+ };
466
+
467
+ // Restart the in-process engine on its own data dir and port: the shell's
468
+ // proxy keeps its target, so the browser sees a real engine restart.
469
+ // R94: the handle is REASSIGNED, so cleanup()/brainScopePath() always act
470
+ // on the live restarted engine, never on the already-closed original.
471
+ const restartEngine = async () => {
472
+ await engine.close();
473
+ engine = r91CreateEngineServer({
474
+ dataDir: engineDataDir,
475
+ runtimeToken: engineToken,
476
+ debug: true,
477
+ consume: false,
478
+ analyzer: createMemoryStandinAnalyzer({ plan }),
479
+ vectorUrl: engineVector.url,
480
+ bridgeUrl: eventBridge.url,
481
+ }, harnessEngineSource);
482
+ await engine.ready;
483
+ await new Promise(resolve => engine.server.listen(enginePort, '127.0.0.1', resolve));
484
+ return engine;
485
+ };
486
+
487
+ // R94: every cleanup step is bounded. A wedged child or an uncooperative
488
+ // close used to turn the whole driver into an unkillable lane process (the
489
+ // Node 17840 hang was exactly this); each step now gets a deadline and a
490
+ // failure is reported instead of blocking the teardown forever.
491
+ const cleanupStep = async (label, task, deadlineMs = 15000) => {
492
+ let timer = null;
493
+ try {
494
+ await Promise.race([
495
+ Promise.resolve().then(task).catch(error => { console.error(`[qa-r91-harness] cleanup step ${label} failed:`, error && error.message); }),
496
+ new Promise(resolve => { timer = setTimeout(resolve, deadlineMs); if (typeof timer.unref === 'function') timer.unref(); }),
497
+ ]);
498
+ } finally {
499
+ clearTimeout(timer);
500
+ }
501
+ };
502
+
503
+ const cleanup = async () => {
504
+ await cleanupStep('browser', () => browser?.close() || Promise.resolve());
505
+ await cleanupStep('server', () => server.cleanup());
506
+ await cleanupStep('engineProxy', () => engineProxy.close());
507
+ await cleanupStep('terminal', () => terminal.close());
508
+ await cleanupStep('vectorHub', () => vectorHub.close());
509
+ await cleanupStep('eventBridge', () => eventBridge.close());
510
+ await cleanupStep('engine', () => engine.close());
511
+ await cleanupStep('engineVector', () => engineVector.close());
512
+ fs.rmSync(fixture, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
513
+ };
514
+
515
+ return {
516
+ fixture, evidence, dataDir, knowledgeRoot, tempRoot, plan,
517
+ base, server, productApi, engineApi, engineUrl, enginePort, engineProxy, engineDataDir,
518
+ terminal, vectorHub, eventBridge, engineVector,
519
+ // R94: a getter, not a snapshot — harness.engine always resolves to the
520
+ // LIVE engine handle after restartEngine().
521
+ get engine() { return engine; },
522
+ browser, run, wait, waitFor, network, reload,
523
+ selectOption, nativeClick, typeText,
524
+ importProject, addDomain, ingestUserEvent, triggerRun,
525
+ brainScopePath, productMemories, injectedMemories,
526
+ memoryCandidate, produceCandidate, restartEngine,
527
+ cleanup,
528
+ };
529
+ }
530
+
531
+ module.exports = { startHarness, makeRepo, commitFile, resolveProductRoot, ROOT, ENGINE_SOURCE_ROOT, REAL_ENV_MARKERS, PRODUCT_SITE_ENTRY };