@modusensus/dsh-mneme 0.8.15 → 0.8.16
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/README.md +8 -3
- package/bin/cli.mjs +4 -2
- package/docs/CONFIGURATION.md +14 -2
- package/docs/DISTILL.md +162 -0
- package/docs/STORAGE.md +11 -2
- package/lib/api-standalone.js +22 -39
- package/lib/api.js +171 -3
- package/lib/client.js +1065 -156
- package/lib/config.js +68 -1
- package/lib/dream/decisions.js +20 -0
- package/lib/dream.js +2 -0
- package/lib/hot-memory.js +62 -0
- package/lib/index.js +24 -7
- package/lib/inject.js +27 -63
- package/lib/maintenance.js +25 -5
- package/lib/recall-stats.js +22 -7
- package/lib/reclaim-http.js +150 -0
- package/lib/safe-slice.js +18 -0
- package/lib/service.js +27 -2
- package/lib/settings.js +13 -2
- package/lib/store.js +196 -7
- package/lib/summarize.js +208 -60
- package/package.json +1 -1
- package/src/api-standalone.js +22 -39
- package/src/api.js +171 -3
- package/src/config.js +68 -1
- package/src/dream/decisions.js +20 -0
- package/src/dream.js +2 -0
- package/src/hot-memory.js +62 -0
- package/src/index.js +24 -7
- package/src/inject.js +27 -63
- package/src/maintenance.js +25 -5
- package/src/recall-stats.js +22 -7
- package/src/reclaim-http.js +150 -0
- package/src/safe-slice.js +18 -0
- package/src/service.js +27 -2
- package/src/settings.js +13 -2
- package/src/store.js +196 -7
- package/src/summarize.js +208 -60
- package/test/api-routes-gaps.test.js +681 -0
- package/test/api.test.js +25 -2
- package/test/client.test.js +354 -2
- package/test/contrast.test.js +314 -0
- package/test/dream-merge-guardrail.test.js +219 -0
- package/test/hot-memory.test.js +67 -1
- package/test/inject-recall.test.js +39 -0
- package/test/inject.test.js +23 -0
- package/test/llm-audit.test.js +98 -0
- package/test/maintenance.test.js +112 -2
- package/test/pre-inject-gate.test.js +10 -0
- package/test/recall-stats.test.js +89 -4
- package/test/reclaim-http.test.js +209 -0
- package/test/service.test.js +7 -2
- package/test/settings.test.js +8 -0
- package/test/store.test.js +108 -0
- package/test/summarize.test.js +174 -0
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { EventEmitter } from "node:events";
|
|
4
|
+
import { readFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
4
8
|
import { createStore } from "../src/store.js";
|
|
5
9
|
import { createService } from "../src/service.js";
|
|
6
10
|
import { createApi } from "../src/api.js";
|
|
7
11
|
import { createSettings } from "../src/settings.js";
|
|
12
|
+
import { createMaintenance } from "../src/maintenance.js";
|
|
13
|
+
|
|
14
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
15
|
|
|
9
16
|
// 覆盖 PR#73 删测后遗留的三个存活 API 空白(v0.7.16 回归补测):
|
|
10
17
|
// /api/dsh-mneme/delete 面板删记忆(POST + auth + 存在性 404)
|
|
@@ -216,3 +223,677 @@ test("external-api PUT applies valid config and echoes it", async () => {
|
|
|
216
223
|
assert.equal(cfg.host, "127.0.0.1");
|
|
217
224
|
assert.ok(typeof cfg.token === "string");
|
|
218
225
|
});
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// /api/dsh-mneme/create(#386 第 5 条:面板手动新增记忆)
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// 这些用例锁的是「面板建行」与「工具建行」必须同语义:同一条 saveWithDedupe
|
|
231
|
+
// 路径、同一套去重键。历史缺口是面板只有看/改/删/导入,用户想手写一条必须开
|
|
232
|
+
// 终端或调外部 API——两条旁路都绕开了面板的去重提示。
|
|
233
|
+
test("create is auth-gated and rejects non-POST", async () => {
|
|
234
|
+
const { routes } = setup("secret-token");
|
|
235
|
+
const route = handler(routes, "/api/dsh-mneme/create");
|
|
236
|
+
assert.ok(route, "route must be registered");
|
|
237
|
+
|
|
238
|
+
// 未带 token → 401(写路由与 /update、/delete 同口径)
|
|
239
|
+
let res = new FakeRes();
|
|
240
|
+
await route.handler(req("/api/dsh-mneme/create", "POST", { type: "project", title: "t", content: "c" }), res);
|
|
241
|
+
assert.equal(res.statusCode, 401);
|
|
242
|
+
assert.equal(JSON.parse(res.body).error, "unauthorized");
|
|
243
|
+
|
|
244
|
+
// 带 token 但 GET → 404(不是 405:与既有写路由一致,避免探测路由存在性)
|
|
245
|
+
res = new FakeRes();
|
|
246
|
+
const ok = req("/api/dsh-mneme/create", "GET");
|
|
247
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
248
|
+
await route.handler(ok, res);
|
|
249
|
+
assert.equal(res.statusCode, 404);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("create validates type/title/content and refuses document rows", async () => {
|
|
253
|
+
const { routes, service } = setup("secret-token");
|
|
254
|
+
const route = handler(routes, "/api/dsh-mneme/create");
|
|
255
|
+
const post = async (body) => {
|
|
256
|
+
const r = req("/api/dsh-mneme/create", "POST", body);
|
|
257
|
+
r.headers = { authorization: "Bearer secret-token" };
|
|
258
|
+
const res = new FakeRes();
|
|
259
|
+
await route.handler(r, res);
|
|
260
|
+
return res;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// 未知类型 → 400(TYPES 是唯一真相源,面板不得自己维护第二份白名单)
|
|
264
|
+
let res = await post({ type: "not-a-type", title: "t", content: "c" });
|
|
265
|
+
assert.equal(res.statusCode, 400);
|
|
266
|
+
assert.equal(JSON.parse(res.body).error, "invalid-type");
|
|
267
|
+
|
|
268
|
+
// document 是 registerDocument 的专属铸造口(#230)→ 干净 400,不落成 500
|
|
269
|
+
res = await post({ type: "document", title: "t", content: "c" });
|
|
270
|
+
assert.equal(res.statusCode, 400);
|
|
271
|
+
assert.equal(JSON.parse(res.body).error, "document-requires-register");
|
|
272
|
+
|
|
273
|
+
// 空白标题 / 空白正文 → 400(面板是人工输入,空正文只可能是误操作)
|
|
274
|
+
res = await post({ type: "project", title: " ", content: "c" });
|
|
275
|
+
assert.equal(res.statusCode, 400);
|
|
276
|
+
assert.equal(JSON.parse(res.body).error, "invalid-title");
|
|
277
|
+
res = await post({ type: "project", title: "t", content: " \n " });
|
|
278
|
+
assert.equal(res.statusCode, 400);
|
|
279
|
+
assert.equal(JSON.parse(res.body).error, "invalid-content");
|
|
280
|
+
|
|
281
|
+
// importance 越界 / tags 非字符串数组 → 400,宁拒绝不静默纠正
|
|
282
|
+
res = await post({ type: "project", title: "t", content: "c", importance: 9 });
|
|
283
|
+
assert.equal(res.statusCode, 400);
|
|
284
|
+
assert.equal(JSON.parse(res.body).error, "invalid-importance");
|
|
285
|
+
res = await post({ type: "project", title: "t", content: "c", tags: [1, 2] });
|
|
286
|
+
assert.equal(res.statusCode, 400);
|
|
287
|
+
assert.equal(JSON.parse(res.body).error, "invalid-tags");
|
|
288
|
+
|
|
289
|
+
// 全部被拒后一条都没落库
|
|
290
|
+
assert.equal(service.count(), 0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("create saves a memory and reports action=created with 201", async () => {
|
|
294
|
+
const { routes, service } = setup("secret-token");
|
|
295
|
+
const route = handler(routes, "/api/dsh-mneme/create");
|
|
296
|
+
|
|
297
|
+
const r = req("/api/dsh-mneme/create", "POST", {
|
|
298
|
+
type: "constraint",
|
|
299
|
+
title: "面板手写一条",
|
|
300
|
+
content: "正文",
|
|
301
|
+
importance: 4,
|
|
302
|
+
tags: ["panel"]
|
|
303
|
+
});
|
|
304
|
+
r.headers = { authorization: "Bearer secret-token" };
|
|
305
|
+
const res = new FakeRes();
|
|
306
|
+
await route.handler(r, res);
|
|
307
|
+
|
|
308
|
+
assert.equal(res.statusCode, 201);
|
|
309
|
+
const body = JSON.parse(res.body);
|
|
310
|
+
assert.equal(body.action, "created");
|
|
311
|
+
assert.equal(body.type, "constraint");
|
|
312
|
+
assert.equal(body.title, "面板手写一条");
|
|
313
|
+
assert.equal(body.importance, 4);
|
|
314
|
+
assert.deepEqual(body.tags, ["panel"]);
|
|
315
|
+
// source 必须能一眼认出是面板写的——详情抽屉把它当「来源」显示
|
|
316
|
+
assert.equal(body.source, "panel");
|
|
317
|
+
assert.ok(service.getById(body.id), "row really exists in the store");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("create merges into an existing row with the same (type, title) and says so", async () => {
|
|
321
|
+
const { routes, service } = setup("secret-token");
|
|
322
|
+
const route = handler(routes, "/api/dsh-mneme/create");
|
|
323
|
+
service.saveWithDedupe({ type: "project", title: "同标题", content: "原来的正文" });
|
|
324
|
+
|
|
325
|
+
const r = req("/api/dsh-mneme/create", "POST", { type: "project", title: "同标题", content: "追加的正文" });
|
|
326
|
+
r.headers = { authorization: "Bearer secret-token" };
|
|
327
|
+
const res = new FakeRes();
|
|
328
|
+
await route.handler(r, res);
|
|
329
|
+
|
|
330
|
+
// 200 而非 201:并入了既有行,没有新行诞生。面板据此提示「已并入」而不是
|
|
331
|
+
// 「新建成功」——两者对用户是不同的事实。
|
|
332
|
+
assert.equal(res.statusCode, 200);
|
|
333
|
+
const body = JSON.parse(res.body);
|
|
334
|
+
assert.equal(body.action, "merged");
|
|
335
|
+
assert.equal(service.count(), 1, "no second row was minted");
|
|
336
|
+
assert.ok(service.getById(body.id).content.includes("追加的正文"));
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("create maps a denied write to 422 instead of leaking a 500", async () => {
|
|
340
|
+
// #254 写入准入今天在无会话身份时放行(write-admission.js 首行早退),面板路由
|
|
341
|
+
// 恰好不带 _sessionKey,所以这条分支当前到不了。仍然要锁:一旦有人把会话身份接
|
|
342
|
+
// 进面板,落回 toApiList([null]) 会把「被拒」伪装成「服务器出错」——用户看到的
|
|
343
|
+
// 是「新建失败」,实际是准入拒绝,两者的处置完全不同。
|
|
344
|
+
const { routes, service } = setup("secret-token");
|
|
345
|
+
const route = handler(routes, "/api/dsh-mneme/create");
|
|
346
|
+
const original = service.saveWithDedupe;
|
|
347
|
+
service.saveWithDedupe = () => ({ action: "denied", reason: "too-many-writes", memory: null });
|
|
348
|
+
try {
|
|
349
|
+
const r = req("/api/dsh-mneme/create", "POST", { type: "project", title: "t", content: "c" });
|
|
350
|
+
r.headers = { authorization: "Bearer secret-token" };
|
|
351
|
+
const res = new FakeRes();
|
|
352
|
+
await route.handler(r, res);
|
|
353
|
+
assert.equal(res.statusCode, 422);
|
|
354
|
+
const body = JSON.parse(res.body);
|
|
355
|
+
assert.equal(body.error, "write-rejected");
|
|
356
|
+
assert.equal(body.reason, "too-many-writes", "the reason must survive: it is what makes the rejection actionable");
|
|
357
|
+
} finally {
|
|
358
|
+
service.saveWithDedupe = original;
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("create returns 500 (not a leaked throw) when the store rejects the write", async () => {
|
|
363
|
+
const { routes, service } = setup("secret-token");
|
|
364
|
+
const route = handler(routes, "/api/dsh-mneme/create");
|
|
365
|
+
// 绕过路由校验直达 saveWithDedupe 的守卫:document 行会 throw。这里用打桩
|
|
366
|
+
// 模拟「路由认为合法、存储层仍然拒绝」的那类情况(类型白名单漂移等)。
|
|
367
|
+
const original = service.saveWithDedupe;
|
|
368
|
+
service.saveWithDedupe = () => { throw new Error("invalid memory type: whatever"); };
|
|
369
|
+
try {
|
|
370
|
+
const r = req("/api/dsh-mneme/create", "POST", { type: "project", title: "t", content: "c" });
|
|
371
|
+
r.headers = { authorization: "Bearer secret-token" };
|
|
372
|
+
const res = new FakeRes();
|
|
373
|
+
await route.handler(r, res);
|
|
374
|
+
assert.equal(res.statusCode, 500);
|
|
375
|
+
assert.equal(JSON.parse(res.body).error, "internal");
|
|
376
|
+
} finally {
|
|
377
|
+
service.saveWithDedupe = original;
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
// /api/dsh-mneme/maintenance/reclaim(#386 第 4 条:存储回收的面板入口)
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// 这条路由与 standalone 数据面的 POST /maintenance/reclaim 是同一个能力的两张皮,
|
|
385
|
+
// 底下是同一份 src/maintenance.js。锁盯的是两类回归:
|
|
386
|
+
// a) 口径漂移——状态码、错误码字符串、默认值必须与 src/api-standalone.js:485-535
|
|
387
|
+
// 逐字一致,否则同一个动作在两个入口上给出不同的答复,客户端只能各写一套;
|
|
388
|
+
// b) 最坏解释——dry-run 路径必须一个字节都不改;畸形输入绝不能被静默读成
|
|
389
|
+
// olderThanDays=0(0 在这里的含义是「清掉全部快照」)。
|
|
390
|
+
const RECLAIM_PATH = "/api/dsh-mneme/maintenance/reclaim";
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* 回收测试台。必须落在**文件库**上:`:memory:` 库的 storageStats().fileBytes 是
|
|
394
|
+
* null(store.js:2134),而报告的口径就是「按 VACUUM 前后体积量」——用内存库测,
|
|
395
|
+
* 体积那几格永远测不到数字。
|
|
396
|
+
*/
|
|
397
|
+
function reclaimSetup(apiToken = "secret-token", { logger = null } = {}) {
|
|
398
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-reclaim-api-"));
|
|
399
|
+
const store = createStore(join(dir, "memory.db"));
|
|
400
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
401
|
+
const settings = createSettings(store.db);
|
|
402
|
+
const commands = { add: () => {}, remove: () => {}, list: () => [] };
|
|
403
|
+
const routes = [];
|
|
404
|
+
const ctx = { logger, webServer: { register(route) { routes.push(route); return () => {}; } } };
|
|
405
|
+
const maintenance = createMaintenance({ store, config: {}, logger });
|
|
406
|
+
createApi(ctx, service, settings, commands, null, undefined, apiToken, null, maintenance);
|
|
407
|
+
return {
|
|
408
|
+
store, service, routes, maintenance,
|
|
409
|
+
// 闭包拿 store,不用 this:裸函数调用下 this 是 undefined,异常被吞掉就会变成
|
|
410
|
+
// 「库没关就删目录」的 EPERM(清理失败还说不清原因)。
|
|
411
|
+
close() {
|
|
412
|
+
try { store.close(); } catch { /* already closed */ }
|
|
413
|
+
rmSync(dir, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** 一条窗口外(30 天前)的 run 输入快照 + 一行带向量的归档行:让 planned/cleared
|
|
419
|
+
* 都有非零数字,否则「cleared 非零」这类锁在恒零的报告上也能通过。 */
|
|
420
|
+
function seedReclaim(store) {
|
|
421
|
+
store.saveDreamRun({
|
|
422
|
+
id: "api-old-run",
|
|
423
|
+
created_at: new Date(Date.now() - 30 * 86400000).toISOString(),
|
|
424
|
+
status: "ok",
|
|
425
|
+
snapshot_hash: "h-api-old",
|
|
426
|
+
input_count: 1,
|
|
427
|
+
input: JSON.stringify([{ id: "m1", content: "x".repeat(600) }]),
|
|
428
|
+
decisions: JSON.stringify([{ action: "keep", ids: ["m1"] }]),
|
|
429
|
+
receipt: "dsh-mneme:run:api-old-run:ok:h-api-old"
|
|
430
|
+
});
|
|
431
|
+
const archived = store.save({ type: "project", title: "归档行", content: "归档内容" });
|
|
432
|
+
store.setEmbedding(archived.id, [0.5, 0.25, 0.125]);
|
|
433
|
+
store.setArchived(archived.id, true);
|
|
434
|
+
return archived;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* 把三个「真会改数据」的原语换成计数器 + 透传。dry-run 的锁要说的都是同一句话:
|
|
439
|
+
* 「这条路径上一行都没改」。只看返回数字不够——返回的 0 也可能是「改了但没报」。
|
|
440
|
+
*/
|
|
441
|
+
function spyWrites(store) {
|
|
442
|
+
const calls = { clearDreamRunInputs: 0, clearArchivedEmbeddings: 0, vacuum: 0 };
|
|
443
|
+
const orig = {
|
|
444
|
+
clearDreamRunInputs: store.clearDreamRunInputs,
|
|
445
|
+
clearArchivedEmbeddings: store.clearArchivedEmbeddings,
|
|
446
|
+
vacuum: store.vacuum
|
|
447
|
+
};
|
|
448
|
+
store.clearDreamRunInputs = (...args) => { calls.clearDreamRunInputs += 1; return orig.clearDreamRunInputs(...args); };
|
|
449
|
+
store.clearArchivedEmbeddings = (...args) => { calls.clearArchivedEmbeddings += 1; return orig.clearArchivedEmbeddings(...args); };
|
|
450
|
+
store.vacuum = (...args) => { calls.vacuum += 1; return orig.vacuum(...args); };
|
|
451
|
+
return {
|
|
452
|
+
calls,
|
|
453
|
+
restore() {
|
|
454
|
+
store.clearDreamRunInputs = orig.clearDreamRunInputs;
|
|
455
|
+
store.clearArchivedEmbeddings = orig.clearArchivedEmbeddings;
|
|
456
|
+
store.vacuum = orig.vacuum;
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** 原始 body 版请求:畸形 JSON 锁要发的是**不能**被 JSON.stringify 出来的文本。 */
|
|
462
|
+
function reqRaw(path, method, text, headers = {}) {
|
|
463
|
+
const r = new EventEmitter();
|
|
464
|
+
r.url = path;
|
|
465
|
+
r.method = method;
|
|
466
|
+
r.headers = headers;
|
|
467
|
+
process.nextTick(() => {
|
|
468
|
+
r.emit("data", Buffer.from(text));
|
|
469
|
+
r.emit("end");
|
|
470
|
+
});
|
|
471
|
+
return r;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function callReclaim(route, {
|
|
475
|
+
path = RECLAIM_PATH, method = "GET", body = null, raw = false, token = "secret-token"
|
|
476
|
+
} = {}) {
|
|
477
|
+
const sendBody = body !== null || raw;
|
|
478
|
+
const r = sendBody
|
|
479
|
+
? reqRaw(path, method, raw ? body : JSON.stringify(body))
|
|
480
|
+
: req(path, method);
|
|
481
|
+
if (token !== null) r.headers = { authorization: `Bearer ${token}` };
|
|
482
|
+
const res = new FakeRes();
|
|
483
|
+
await route.handler(r, res);
|
|
484
|
+
return { status: res.statusCode, body: res.body ? JSON.parse(res.body) : null };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** 从 `(` 起做括号配平扫描,返回整段调用的文本。路由调用里嵌着对象字面量与箭头
|
|
488
|
+
* 函数,用 indexOf(");") 会先停在内层 `throw new Error(...)` 上。 */
|
|
489
|
+
function callText(src, start) {
|
|
490
|
+
const open = src.indexOf("(", start);
|
|
491
|
+
let depth = 0;
|
|
492
|
+
for (let i = open; i < src.length; i += 1) {
|
|
493
|
+
if (src[i] === "(") depth += 1;
|
|
494
|
+
else if (src[i] === ")") {
|
|
495
|
+
depth -= 1;
|
|
496
|
+
if (depth === 0) return src.slice(start, i + 1);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return src.slice(start);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
test("reclaim is auth-gated on both methods and GET returns a dry-run", async () => {
|
|
503
|
+
const bench = reclaimSetup();
|
|
504
|
+
try {
|
|
505
|
+
seedReclaim(bench.store);
|
|
506
|
+
const route = handler(bench.routes, RECLAIM_PATH);
|
|
507
|
+
assert.ok(route, "route must be registered on the panel API");
|
|
508
|
+
|
|
509
|
+
// 不可逆内容丢弃的入口,两个方法都不能有例外。
|
|
510
|
+
let out = await callReclaim(route, { token: null });
|
|
511
|
+
assert.equal(out.status, 401);
|
|
512
|
+
assert.equal(out.body.error, "unauthorized");
|
|
513
|
+
|
|
514
|
+
out = await callReclaim(route, { method: "POST", body: {}, token: null });
|
|
515
|
+
assert.equal(out.status, 401);
|
|
516
|
+
|
|
517
|
+
out = await callReclaim(route);
|
|
518
|
+
assert.equal(out.status, 200);
|
|
519
|
+
assert.equal(out.body.dryRun, true, "GET 是只读预估,恒为 dry-run");
|
|
520
|
+
assert.equal(out.body.olderThanDays, 7, "缺省用 DEFAULT_INPUT_RETENTION_DAYS");
|
|
521
|
+
} finally {
|
|
522
|
+
bench.close();
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("reclaim GET honours olderThanDays and rejects malformed windows", async () => {
|
|
527
|
+
const bench = reclaimSetup();
|
|
528
|
+
try {
|
|
529
|
+
seedReclaim(bench.store);
|
|
530
|
+
const route = handler(bench.routes, RECLAIM_PATH);
|
|
531
|
+
|
|
532
|
+
let out = await callReclaim(route, { path: `${RECLAIM_PATH}?olderThanDays=3` });
|
|
533
|
+
assert.equal(out.status, 200);
|
|
534
|
+
assert.equal(out.body.olderThanDays, 3);
|
|
535
|
+
|
|
536
|
+
// 只收十进制整数字面量。`Number()` 会把 ""/[]/" " 化成 0,而 0 在这里是
|
|
537
|
+
// 「清掉全部快照」——最坏解释绝不能从一个畸形输入静默拿到。
|
|
538
|
+
for (const bad of ["abc", "-1", "", " ", "3.5", "0x2", "1e3", "[]", "null", "3d"]) {
|
|
539
|
+
out = await callReclaim(route, { path: `${RECLAIM_PATH}?olderThanDays=${encodeURIComponent(bad)}` });
|
|
540
|
+
assert.equal(out.status, 400, `olderThanDays=${JSON.stringify(bad)} 必须被拒`);
|
|
541
|
+
assert.equal(out.body.error, "invalid-older-than-days");
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// 边界值 0 是**合法**的(含义就是清全部),不能被上面那条锁顺手误伤。
|
|
545
|
+
out = await callReclaim(route, { path: `${RECLAIM_PATH}?olderThanDays=0` });
|
|
546
|
+
assert.equal(out.status, 200);
|
|
547
|
+
assert.equal(out.body.olderThanDays, 0);
|
|
548
|
+
|
|
549
|
+
// 量级也要挡:十进制字面量不保证是能算日期的数,400 位数字会化成 Infinity,
|
|
550
|
+
// 而 maintenance 会拿它算 `now() - days * 86400000` 再 toISOString() 抛 RangeError。
|
|
551
|
+
for (const huge of ["1" + "0".repeat(400), "100000000000000000000", "3651"]) {
|
|
552
|
+
out = await callReclaim(route, { path: `${RECLAIM_PATH}?olderThanDays=${huge}` });
|
|
553
|
+
assert.equal(out.status, 400, `olderThanDays=${huge} 必须被拒`);
|
|
554
|
+
assert.equal(out.body.error, "invalid-older-than-days");
|
|
555
|
+
}
|
|
556
|
+
out = await callReclaim(route, { path: `${RECLAIM_PATH}?olderThanDays=3650` });
|
|
557
|
+
assert.equal(out.status, 200, "上界本身是合法的");
|
|
558
|
+
assert.equal(out.body.olderThanDays, 3650);
|
|
559
|
+
} finally {
|
|
560
|
+
bench.close();
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("reclaim POST defaults to dry-run and writes nothing", async () => {
|
|
565
|
+
const bench = reclaimSetup();
|
|
566
|
+
try {
|
|
567
|
+
seedReclaim(bench.store);
|
|
568
|
+
const route = handler(bench.routes, RECLAIM_PATH);
|
|
569
|
+
const spy = spyWrites(bench.store);
|
|
570
|
+
try {
|
|
571
|
+
const out = await callReclaim(route, { method: "POST", body: {} });
|
|
572
|
+
assert.equal(out.status, 200);
|
|
573
|
+
assert.equal(out.body.dryRun, true, "不显式要求就不执行");
|
|
574
|
+
assert.equal(out.body.planned.dream_run_inputs, 1);
|
|
575
|
+
assert.equal(spy.calls.clearDreamRunInputs, 0);
|
|
576
|
+
assert.equal(spy.calls.clearArchivedEmbeddings, 0);
|
|
577
|
+
assert.equal(spy.calls.vacuum, 0);
|
|
578
|
+
assert.equal(bench.store.listLlmAudits().length, 0, "dry-run 不落回执");
|
|
579
|
+
} finally {
|
|
580
|
+
spy.restore();
|
|
581
|
+
}
|
|
582
|
+
} finally {
|
|
583
|
+
bench.close();
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("reclaim POST without confirm refuses to execute but hands back the dry-run report", async () => {
|
|
588
|
+
const bench = reclaimSetup();
|
|
589
|
+
try {
|
|
590
|
+
seedReclaim(bench.store);
|
|
591
|
+
const route = handler(bench.routes, RECLAIM_PATH);
|
|
592
|
+
const spy = spyWrites(bench.store);
|
|
593
|
+
try {
|
|
594
|
+
const out = await callReclaim(route, { method: "POST", body: { dryRun: false, vacuum: true } });
|
|
595
|
+
assert.equal(out.status, 400);
|
|
596
|
+
assert.equal(out.body.error, "confirm-required");
|
|
597
|
+
assert.match(out.body.hint, /dryRun/);
|
|
598
|
+
assert.ok(out.body.report, "确认的依据就是这份数字,必须一并回给调用方");
|
|
599
|
+
// 报告描述的必须是**将要执行的那个动作**:这里带了 vacuum 去执行,看到的
|
|
600
|
+
// 数字就得是「会 VACUUM」的那一份,否则调用方照着「不 VACUUM」的估算按下去。
|
|
601
|
+
assert.equal(out.body.report.vacuum.requested, true);
|
|
602
|
+
assert.equal(out.body.report.dryRun, true);
|
|
603
|
+
assert.equal(spy.calls.clearDreamRunInputs, 0, "拒绝执行就是真的一行都没改");
|
|
604
|
+
assert.equal(spy.calls.clearArchivedEmbeddings, 0);
|
|
605
|
+
assert.equal(spy.calls.vacuum, 0);
|
|
606
|
+
} finally {
|
|
607
|
+
spy.restore();
|
|
608
|
+
}
|
|
609
|
+
} finally {
|
|
610
|
+
bench.close();
|
|
611
|
+
}
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("reclaim POST with confirm executes and reports what it cleared", async () => {
|
|
615
|
+
const bench = reclaimSetup();
|
|
616
|
+
try {
|
|
617
|
+
const archived = seedReclaim(bench.store);
|
|
618
|
+
const route = handler(bench.routes, RECLAIM_PATH);
|
|
619
|
+
|
|
620
|
+
const out = await callReclaim(route, {
|
|
621
|
+
method: "POST",
|
|
622
|
+
body: { dryRun: false, confirm: true, olderThanDays: 7, vacuum: false }
|
|
623
|
+
});
|
|
624
|
+
assert.equal(out.status, 200);
|
|
625
|
+
assert.equal(out.body.dryRun, false);
|
|
626
|
+
assert.equal(out.body.olderThanDays, 7);
|
|
627
|
+
assert.equal(out.body.cleared.dream_run_inputs, 1, "窗口外的输入快照真的被清了");
|
|
628
|
+
assert.equal(out.body.cleared.archived_embeddings, 1);
|
|
629
|
+
// 行数不变(这项回收只置列,不删行)——它「无损」的全部含义就在这里。
|
|
630
|
+
assert.equal(bench.store.db.prepare("SELECT count(*) AS c FROM dream_runs").get().c, 1);
|
|
631
|
+
assert.equal(bench.store.db.prepare("SELECT input FROM dream_runs WHERE id = 'api-old-run'").get().input, null);
|
|
632
|
+
assert.equal(bench.store.db.prepare("SELECT embedding FROM memories WHERE id = ?").get(archived.id).embedding, null);
|
|
633
|
+
} finally {
|
|
634
|
+
bench.close();
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
test("reclaim POST rejects a non-boolean vacuum", async () => {
|
|
639
|
+
const bench = reclaimSetup();
|
|
640
|
+
try {
|
|
641
|
+
seedReclaim(bench.store);
|
|
642
|
+
const route = handler(bench.routes, RECLAIM_PATH);
|
|
643
|
+
|
|
644
|
+
for (const bad of ["yes", 1, 0, {}, []]) {
|
|
645
|
+
const out = await callReclaim(route, { method: "POST", body: { vacuum: bad } });
|
|
646
|
+
assert.equal(out.status, 400, `vacuum=${JSON.stringify(bad)} 必须被拒`);
|
|
647
|
+
assert.equal(out.body.error, "invalid-vacuum");
|
|
648
|
+
}
|
|
649
|
+
// 显式 false 是合法的:面板的 checkbox 默认关,传的就是它。
|
|
650
|
+
const ok = await callReclaim(route, { method: "POST", body: { vacuum: false } });
|
|
651
|
+
assert.equal(ok.status, 200);
|
|
652
|
+
} finally {
|
|
653
|
+
bench.close();
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("reclaim POST separates invalid-json from invalid-body", async () => {
|
|
658
|
+
const bench = reclaimSetup();
|
|
659
|
+
try {
|
|
660
|
+
const route = handler(bench.routes, RECLAIM_PATH);
|
|
661
|
+
|
|
662
|
+
// 发坏了(解析不了)与发成了非对象(null/数组)是两种不同的错,处置也不同:
|
|
663
|
+
// 前者查客户端序列化,后者查调用方把数组当 body 传了。
|
|
664
|
+
let out = await callReclaim(route, { method: "POST", raw: true, body: "{not json" });
|
|
665
|
+
assert.equal(out.status, 400);
|
|
666
|
+
assert.equal(out.body.error, "invalid-json");
|
|
667
|
+
|
|
668
|
+
out = await callReclaim(route, { method: "POST", body: [] });
|
|
669
|
+
assert.equal(out.status, 400);
|
|
670
|
+
assert.equal(out.body.error, "invalid-body");
|
|
671
|
+
|
|
672
|
+
out = await callReclaim(route, { method: "POST", raw: true, body: "null" });
|
|
673
|
+
assert.equal(out.status, 400);
|
|
674
|
+
assert.equal(out.body.error, "invalid-body");
|
|
675
|
+
|
|
676
|
+
out = await callReclaim(route, { method: "POST", raw: true, body: "\"str\"" });
|
|
677
|
+
assert.equal(out.status, 400);
|
|
678
|
+
assert.equal(out.body.error, "invalid-body");
|
|
679
|
+
} finally {
|
|
680
|
+
bench.close();
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("reclaim POST validates olderThanDays as a JSON number, not a string", async () => {
|
|
685
|
+
const bench = reclaimSetup();
|
|
686
|
+
try {
|
|
687
|
+
seedReclaim(bench.store);
|
|
688
|
+
const route = handler(bench.routes, RECLAIM_PATH);
|
|
689
|
+
|
|
690
|
+
// POST 侧收的是 JSON 数字域:字符串 "3" 不是数字,必须拒——与 GET 的 query
|
|
691
|
+
// 域口径不同(那边只有字符串可收)。两边都放宽等于把「畸形」重新放进来。
|
|
692
|
+
// NaN 不在此列表里:JSON 表达不了它,stringify 会把它变成 null,而 null 在这里
|
|
693
|
+
// 与缺省同义(落默认窗口)——拿它当坏输入只会测到 null 那条规则。
|
|
694
|
+
for (const bad of ["3", -1, 3.5, true, {}, []]) {
|
|
695
|
+
const out = await callReclaim(route, { method: "POST", body: { olderThanDays: bad } });
|
|
696
|
+
assert.equal(out.status, 400, `olderThanDays=${JSON.stringify(bad)} 必须被拒`);
|
|
697
|
+
assert.equal(out.body.error, "invalid-older-than-days");
|
|
698
|
+
}
|
|
699
|
+
// 量级也是「坏输入」:1e20 是整数,却会让 maintenance 算日期时抛 RangeError
|
|
700
|
+
// (#410 评审)。400 位数字不在此列——它化成 Infinity,而 JSON.stringify 把
|
|
701
|
+
// Infinity 写成 null,落进「与缺省同义」那条规则(GET 侧才测得到它)。
|
|
702
|
+
for (const huge of [1e20, 1e21, 3651]) {
|
|
703
|
+
const out = await callReclaim(route, { method: "POST", body: { olderThanDays: huge } });
|
|
704
|
+
assert.equal(out.status, 400, `olderThanDays=${String(huge)} 必须被拒`);
|
|
705
|
+
assert.equal(out.body.error, "invalid-older-than-days");
|
|
706
|
+
}
|
|
707
|
+
let ok = await callReclaim(route, { method: "POST", body: { olderThanDays: 0 } });
|
|
708
|
+
assert.equal(ok.status, 200);
|
|
709
|
+
assert.equal(ok.body.olderThanDays, 0);
|
|
710
|
+
ok = await callReclaim(route, { method: "POST", body: { olderThanDays: 3650 } });
|
|
711
|
+
assert.equal(ok.status, 200, "上界本身是合法的");
|
|
712
|
+
assert.equal(ok.body.olderThanDays, 3650);
|
|
713
|
+
ok = await callReclaim(route, { method: "POST", body: { olderThanDays: null } });
|
|
714
|
+
assert.equal(ok.status, 200);
|
|
715
|
+
assert.equal(ok.body.olderThanDays, 7, "null 与缺省同义:落默认窗口,不是 0");
|
|
716
|
+
} finally {
|
|
717
|
+
bench.close();
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
test("reclaim without the maintenance dependency answers 503 instead of crashing", async () => {
|
|
722
|
+
// 既有 14 处 createApi(...) 调用全是位置传参、不传第 9 个。它们必须继续工作:
|
|
723
|
+
// 「没接线」要如实报 unavailable,而不是把 undefined.reclaim 落成崩溃。
|
|
724
|
+
const { routes } = setup("secret-token");
|
|
725
|
+
const route = handler(routes, RECLAIM_PATH);
|
|
726
|
+
assert.ok(route, "路由注册与接线无关,少传依赖也要注册出来");
|
|
727
|
+
|
|
728
|
+
let out = await callReclaim(route);
|
|
729
|
+
assert.equal(out.status, 503);
|
|
730
|
+
assert.equal(out.body.error, "maintenance-unavailable");
|
|
731
|
+
|
|
732
|
+
out = await callReclaim(route, { method: "POST", body: {} });
|
|
733
|
+
assert.equal(out.status, 503);
|
|
734
|
+
assert.equal(out.body.error, "maintenance-unavailable");
|
|
735
|
+
|
|
736
|
+
// 顺序也是契约:端点整个没接线时,报「窗口不合法」是误导——调用方会去改窗口,
|
|
737
|
+
// 改完照样 503。所以 503 必须赢过字段校验(数据面同一条,见 maintenance.test.js)。
|
|
738
|
+
out = await callReclaim(route, { method: "POST", body: { olderThanDays: 1e20 } });
|
|
739
|
+
assert.equal(out.status, 503, "没接线要赢过字段校验");
|
|
740
|
+
assert.equal(out.body.error, "maintenance-unavailable");
|
|
741
|
+
|
|
742
|
+
out = await callReclaim(route, { path: `${RECLAIM_PATH}?olderThanDays=abc` });
|
|
743
|
+
assert.equal(out.status, 503, "GET 侧同样:没接线要赢过窗口校验");
|
|
744
|
+
assert.equal(out.body.error, "maintenance-unavailable");
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
test("reclaim maps a throwing maintenance.reclaim to 500 instead of leaking", async () => {
|
|
748
|
+
// 带 logger 跑:契约里 500 与 logger.warn 是一起要求的(没有日志的 500 在现场
|
|
749
|
+
// 只剩一个「内部错误」,查不出是哪次回收炸的)。GET 那侧尤其要注意——外层 catch
|
|
750
|
+
// 也能兜成 500,所以只断言状态码抓不住内层 catch 的消失,必须看日志。
|
|
751
|
+
const warnings = [];
|
|
752
|
+
const bench = reclaimSetup("secret-token", { logger: { warn: (m) => warnings.push(String(m)) } });
|
|
753
|
+
try {
|
|
754
|
+
const route = handler(bench.routes, RECLAIM_PATH);
|
|
755
|
+
const original = bench.maintenance.reclaim;
|
|
756
|
+
bench.maintenance.reclaim = () => { throw new Error("database is locked"); };
|
|
757
|
+
try {
|
|
758
|
+
let out = await callReclaim(route);
|
|
759
|
+
assert.equal(out.status, 500);
|
|
760
|
+
assert.equal(out.body.error, "internal");
|
|
761
|
+
|
|
762
|
+
out = await callReclaim(route, { method: "POST", body: { dryRun: false, confirm: true } });
|
|
763
|
+
assert.equal(out.status, 500);
|
|
764
|
+
assert.equal(out.body.error, "internal");
|
|
765
|
+
|
|
766
|
+
// confirm-required 那条也要算上:它在 readBody 的回调里算 dry-run 报告,外层 try
|
|
767
|
+
// 那时已经退栈,漏了这道守卫就变成一个没人接的 rejection——客户端拿到的是「一个
|
|
768
|
+
// 字节都没回」而不是 500(#410 评审指出的正是这条)。
|
|
769
|
+
out = await callReclaim(route, { method: "POST", body: { dryRun: false } });
|
|
770
|
+
assert.equal(out.status, 500, "confirm-required 的报告抛错必须回 500,不能无响应");
|
|
771
|
+
assert.equal(out.body.error, "internal");
|
|
772
|
+
|
|
773
|
+
assert.equal(warnings.length, 3, "三条会调 reclaim 的路径各留一条 warn");
|
|
774
|
+
for (const w of warnings) {
|
|
775
|
+
assert.match(w, /storage reclaim failed/);
|
|
776
|
+
assert.match(w, /database is locked/, "原始错误文本要留住,否则现场无从下手");
|
|
777
|
+
}
|
|
778
|
+
} finally {
|
|
779
|
+
bench.maintenance.reclaim = original;
|
|
780
|
+
}
|
|
781
|
+
} finally {
|
|
782
|
+
bench.close();
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
test("reclaim rejects non-GET/POST methods with 404", async () => {
|
|
787
|
+
const bench = reclaimSetup();
|
|
788
|
+
try {
|
|
789
|
+
seedReclaim(bench.store);
|
|
790
|
+
const route = handler(bench.routes, RECLAIM_PATH);
|
|
791
|
+
const spy = spyWrites(bench.store);
|
|
792
|
+
try {
|
|
793
|
+
for (const method of ["PUT", "DELETE", "PATCH", "HEAD"]) {
|
|
794
|
+
const out = await callReclaim(route, { method, body: { dryRun: false, confirm: true } });
|
|
795
|
+
assert.equal(out.status, 404, `${method} 不该在这个路由上有语义`);
|
|
796
|
+
assert.equal(out.body.error, "not-found");
|
|
797
|
+
}
|
|
798
|
+
assert.equal(spy.calls.clearDreamRunInputs, 0, "被 404 挡掉的方法一个字节都不能改");
|
|
799
|
+
} finally {
|
|
800
|
+
spy.restore();
|
|
801
|
+
}
|
|
802
|
+
} finally {
|
|
803
|
+
bench.close();
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("reclaim report has no wrapper layer: fields sit at the top level", async () => {
|
|
808
|
+
const bench = reclaimSetup();
|
|
809
|
+
try {
|
|
810
|
+
seedReclaim(bench.store);
|
|
811
|
+
const route = handler(bench.routes, RECLAIM_PATH);
|
|
812
|
+
|
|
813
|
+
const out = await callReclaim(route);
|
|
814
|
+
assert.equal(out.status, 200);
|
|
815
|
+
// 报告形状由 src/maintenance.js 决定,路由只做搬运:加一层 { report } 会让
|
|
816
|
+
// 面板与 standalone 两个客户端对不上同一个字段路径。
|
|
817
|
+
assert.equal(typeof out.body.size.before_bytes, "number", "文件库必须给出真实体积");
|
|
818
|
+
assert.ok(out.body.size.before_bytes > 0);
|
|
819
|
+
assert.equal(typeof out.body.size.freelist_pages, "number");
|
|
820
|
+
assert.equal(typeof out.body.duration_ms, "number");
|
|
821
|
+
// #399:蒸馏原文记录按 distilled_at 到期纳入回收范围后,planned 的键随回收面
|
|
822
|
+
// 演进——这份锁防的是路由层悄悄包一层 { report }(键路径变深,面板与 standalone
|
|
823
|
+
// 会对不上字段),不是冻结回收范围本身;新增回收项时同步扩这份清单,并配
|
|
824
|
+
// maintenance.test.js 的同款断言。
|
|
825
|
+
assert.deepEqual(Object.keys(out.body.planned).sort(), ["archived_embeddings", "distill_records", "dream_run_inputs"]);
|
|
826
|
+
assert.deepEqual(Object.keys(out.body.vacuum).sort(), ["duration_ms", "error", "ran", "requested"]);
|
|
827
|
+
assert.ok("receipt" in out.body);
|
|
828
|
+
assert.ok("cutoff" in out.body);
|
|
829
|
+
} finally {
|
|
830
|
+
bench.close();
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
test("reclaim POST executes VACUUM when asked, and reports it ran", async () => {
|
|
836
|
+
const bench = reclaimSetup();
|
|
837
|
+
try {
|
|
838
|
+
const archived = seedReclaim(bench.store);
|
|
839
|
+
const route = handler(bench.routes, RECLAIM_PATH);
|
|
840
|
+
|
|
841
|
+
// vacuum 是**执行时**才有的动作:dry-run 只报「会 VACUUM」,真执行才跑。这条锁住
|
|
842
|
+
// 「勾了 VACUUM 却静默不跑」——请求体里带 true,报告里 requested 与 ran 都得是
|
|
843
|
+
// true,否则用户以为文件缩回去了,其实只是清了列。
|
|
844
|
+
const out = await callReclaim(route, {
|
|
845
|
+
method: "POST",
|
|
846
|
+
body: { dryRun: false, confirm: true, olderThanDays: 7, vacuum: true }
|
|
847
|
+
});
|
|
848
|
+
assert.equal(out.status, 200);
|
|
849
|
+
assert.equal(out.body.dryRun, false);
|
|
850
|
+
assert.equal(out.body.vacuum.requested, true, "请求里的 vacuum:true 必须传到实现层");
|
|
851
|
+
assert.equal(out.body.vacuum.ran, true, "确认执行时 VACUUM 必须真的跑");
|
|
852
|
+
assert.equal(out.body.vacuum.error, null);
|
|
853
|
+
assert.equal(out.body.cleared.dream_run_inputs, 1);
|
|
854
|
+
assert.equal(bench.store.db.prepare("SELECT input FROM dream_runs WHERE id = 'api-old-run'").get().input, null);
|
|
855
|
+
assert.equal(bench.store.db.prepare("SELECT embedding FROM memories WHERE id = ?").get(archived.id).embedding, null);
|
|
856
|
+
} finally {
|
|
857
|
+
bench.close();
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test("reclaim confirm-required hands back a report for the same window it was asked about", async () => {
|
|
862
|
+
const bench = reclaimSetup();
|
|
863
|
+
try {
|
|
864
|
+
seedReclaim(bench.store);
|
|
865
|
+
const route = handler(bench.routes, RECLAIM_PATH);
|
|
866
|
+
|
|
867
|
+
// 确认的依据就是这份报告里的数字。窗口传了 30 却回一份 7 天的估算,用户就是照着
|
|
868
|
+
// 错的数字按下去——对不可逆动作来说这是最坏的一种错位。
|
|
869
|
+
const out = await callReclaim(route, {
|
|
870
|
+
method: "POST",
|
|
871
|
+
body: { dryRun: false, olderThanDays: 30, vacuum: true }
|
|
872
|
+
});
|
|
873
|
+
assert.equal(out.status, 400);
|
|
874
|
+
assert.equal(out.body.error, "confirm-required");
|
|
875
|
+
assert.equal(out.body.report.olderThanDays, 30, "报告必须复述调用方要的那个窗口");
|
|
876
|
+
assert.equal(out.body.report.vacuum.requested, true, "报告必须描述将要执行的那个动作");
|
|
877
|
+
assert.equal(out.body.report.dryRun, true, "拒绝执行时给出的仍是 dry-run 报告");
|
|
878
|
+
} finally {
|
|
879
|
+
bench.close();
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
test("wiring: maintenance is built before the panel createApi call and passed into it", () => {
|
|
884
|
+
// 接线锁。webServer 块在 index.js 里先于原来的构造点执行,实例一旦建晚了,面板
|
|
885
|
+
// 路由拿到的就是 null——功能「看起来加了」但每次都回 503。
|
|
886
|
+
const src = readFileSync(join(root, "src", "index.js"), "utf8");
|
|
887
|
+
const buildAt = src.indexOf("createMaintenance({");
|
|
888
|
+
const callAt = src.indexOf("createApi(ctx, service, settings");
|
|
889
|
+
assert.ok(buildAt >= 0, "src/index.js 里找不到 createMaintenance 构造");
|
|
890
|
+
assert.ok(callAt >= 0, "src/index.js 里找不到面板 createApi 调用");
|
|
891
|
+
assert.ok(buildAt < callAt, "维护实例必须在面板 createApi 之前构造");
|
|
892
|
+
assert.match(callText(src, callAt), /maintenance/, "面板 createApi 调用必须把维护实例传进去");
|
|
893
|
+
|
|
894
|
+
// 另一头:standalone 那条路(既有功能)不能因为上移而丢掉。
|
|
895
|
+
const standaloneAt = src.indexOf("createStandaloneApi({");
|
|
896
|
+
assert.ok(standaloneAt >= 0);
|
|
897
|
+
assert.match(callText(src, standaloneAt), /maintenance/);
|
|
898
|
+
});
|
|
899
|
+
|