Files
HologramAgent-DSH-Memory-Port/test/plugin.test.js
jianf c6595d2cf0 feat(memory-b): H 收尾 W1–W8 —— 信号转发、写侧校验、建中心入口
五条全部处理。第 5 条确认属实:saveSpaceCentroid 在 lib/ 里零调用点,
真实使用下中心表永久空着,票③ 的「dim unknown」会从边角变成常态。

W1/W5 信号转发(最硬的一条)
- execute(args, exec) → commit(input, {signal}) → embedText(text, signal)
- abort 时按「向量化失败」降级落盘:块写入、vector 留空
- 依据 dsh-tools/lib/types/index.d.ts:120-122

★ 一处我判错了,据实记录
W5 我原本断言「abort 时 isError === false」,实测是 true。查了实现:
dsh-tools/lib/index.js:3408 在工具成功后**再检查一次 signal**,
已 abort 就用 toolAbortedResult 覆盖成功结果。所以 isError 不归本插件控制,
断言它等于和 harness 对抗。改为断言**副作用**(块已写入、vector 为空),
并额外断言 error.info.code === 'ABORTED'。

W2 九个调用点补 await
scripts/checkpoints.js ×4、test/plugin.test.js ×5、test/loader.test.js ×2
(loader 两处保持并发语义用 Promise.all)。
原本绿是因为「无 embedder 时第一个 await 之前就写完 putBlock」——
这个隐式事实一旦被一行 await 打破,九处会静默变成「块没写」,
而且红会出现在离原因很远的地方(靠后面能否召回间接断言)。

W3 空指纹先判后嵌
原来顺序是 await embedText → putBlock 抛 → catch → 再 putBlock,
语义终点对但白付一次网络往返,且与 recall.js:430 的口径不一致。
改为 embed 之前判 space.fingerprint === ''。

W4 写侧维度校验
commit 现在检查 vector.length === space.dimension,不符按 embed failure 处理
(无向量落盘)。不落坏向量的理由:召回侧会按 skippedDim 静默跳过,
用户看到的是「记忆里有这条,但永远召不回来」。

W8 建中心入口(第 5 条)
新增 store.rebuildCentroid(fingerprint),移植 RebuildCentroid
(centroid_store.go:246-320):
  · 只取该空间的带向量行
  · 维度不符跳过不强制
  · 按 L2 归一化后累加均值
  · 均值范数 >= 0.6 才判 anomalous(centroid.go:87)
  · 计入 stats:meanLength / avgPairCosine / skippedRows
并新增 MEAN_THRESHOLD 常量。

★ W8 第一版判据是错的:我用完全共线的向量(meanLength = 1.0000)
  去测 0.6 阈值,把阈值改成 0.95 仍然通过。已改用 ±35° 锥
  (meanLength ≈ 0.879,落在 0.6 与 0.95 之间),并加断言
  「语料必须落在两阈值之间」,否则测试自身就失去区分力。

判据与变异(全部在 /tmp 干净副本上跑)
  W4  维度不符 → 块在、vector=''、fingerprint=''      | 删维度检查 → 红
  W5  abort → 块在、vector=''、elapsed<2s              | 不传 signal → 红(实测 5003ms)
  W6  无 embedder 时 {id,created} 与 putBlock 同形      | —
  W7  空指纹 → embedText 调用 0 次                      | 改为事后判 → 红
  W8  同空间重建、跨空间隔离、坏维度跳过、阈值判定      | 去空间过滤 → 3 条红
                                                        | 去维度过滤 → 1 条红
                                                        | 阈值 0.6→0.95 → 1 条红

未越界
- 四态计数(noVector/unknownSpace/spaceMismatch/embedFailure)留给 F/D
- recallBlocks 仍然同步、未接入 vectorScores(V 组的事)
- 票③「dim 来自中心元数据而非配置」继续有效;saveSpaceCentroid 仍在用
  dim ?? space.dimension,已在注释里标记为 C 组要区分「没建过」与
  「建过但维度取自配置」——声明不是证据

顺序按你的调整记下:H(完工) → V → C → F → D。
C 组准入条件已满足一半:机制(rebuildCentroid)有了,
「谁触发建中心」待 C 组定。

验证
- npm test 99/99(90 + W4/W5/W6/W7 六条 + W8 三条)
- npm run check 10/10
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
2026-10-30 10:35:41 +08:00

550 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Plugin-wiring tests against **real** dsh services.
*
* These are the assertions that would silently rot if dsh changed a hook, so they
* run the actual `SystemPrompt` and `ToolRuntime` rather than stubs. The
* checkpoint script covers the same ground with human-readable output; this file
* is what CI should run.
*
* Run: node --test test/
*
* @module dsh-memory/test/plugin
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import { apply as applyMemory, lastUserText, name as pluginName } from '../lib/index.js'
/**
* Build a real cordis app carrying the services this plugin injects.
* `ToolRuntime` declares `inject = ['systemPrompt']`, so prompt comes first.
* @returns {Promise<{ctx: Context, dir: string, done: () => void}>}
*/
async function app() {
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-plugin-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
return {
ctx,
dir,
done: async () => {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
},
}
}
/** A realistic dsh step batch: question, then two user-role injected contexts. */
function realBatch(question) {
return [
{
id: 'm1',
role: 'user',
source: { kind: 'user' },
content: [{ type: 'text', text: question }],
},
{
id: 'm2',
role: 'user',
source: { kind: 'runtime-context', form: 'snapshot' },
content: [{ type: 'text', text: '[Working directory]\n/home/agent/project' }],
},
{
id: 'm3',
role: 'user',
source: { kind: 'skill-catalog', form: 'catalog' },
content: [{ type: 'text', text: '[Skills] git-release-discipline, android-cli, shared-browser' }],
},
]
}
test('lastUserText picks the newest HUMAN prompt and joins only text blocks', () => {
// Every real message carries `source.kind`; the fixtures must too, or the test
// does not exercise the actual discrimination (see the REGRESSION block below).
const messages = [
{ role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: 'first' }] },
{ role: 'assistant', source: { kind: 'model' }, content: [{ type: 'text', text: 'reply' }] },
{
role: 'user',
source: { kind: 'user' },
content: [{ type: 'tool-result' }, { type: 'text', text: 'second' }],
},
]
assert.equal(lastUserText(messages), 'second')
assert.equal(lastUserText([]), '')
assert.equal(
lastUserText([{ role: 'assistant', source: { kind: 'model' }, content: [{ type: 'text', text: 'x' }] }]),
'',
)
})
// ── G6: the allowlist must be proven against REAL injected kinds ───────────
//
// Scope note (important): this table is **not** claimed to be exhaustive. It is
// "every user-role injected kind declared in the currently installed harness".
// The full `MessageSourceMap` surface is larger (25 source keys at the time of
// writing), and more will appear. That is fine — `HUMAN_PROMPT_KINDS` is an
// allowlist, so an unlisted future kind fails closed by construction, which is
// the property that actually matters at runtime.
//
// What this table DOES prove: the names we think are injected really are, and a
// rename in the harness breaks a test here instead of silently killing recall.
//
// Sources (each verified as a literal declaration, not inferred):
// agent-instructions dsh-agent-instructions/lib/types/state.d.ts:27
// agent-message dsh-subagent/lib/types/continuation-messages.d.ts:37
// compact-checkpoint dsh-compaction/lib/types/checkpoint.d.ts:18
// cordis-host-runner dsh-cordis-host-runner/lib/types/index.d.ts:13
// dsh-session-title-llm dsh-session-title-llm/lib/types/index.d.ts:14
// model-selection dsh-agent/lib/types/model-selection.d.ts:11
// plan-mode dsh-plan-mode/lib/types/index.d.ts:31
// ptc-mode dsh-tools/lib/types/ptc.d.ts:14
// repeat-tool-reminder dsh-repeat-tool-reminder/lib/types/index.d.ts:14
// runtime-context dsh-agent-loop/lib/types/runtime-context.d.ts:13
// schedule dsh-schedule/lib/types/runtime.d.ts:5
// session-reference dsh-session-reference/lib/types/types.d.ts:33
// skill-catalog dsh-tool-skill/lib/types/index.d.ts:29
// skill-invocation dsh-skill/lib/types/index.d.ts:131
// subagent-settled dsh-subagent/lib/types/continuation-messages.d.ts:38
// team-message dsh-experimental-agent-team/lib/types/types.d.ts:123
// time-context dsh-time-context/lib/types/index.d.ts:14
// tmux-context dsh-tmux-context/lib/types/index.d.ts
// tool-goal dsh-tool-goal/lib/types/index.d.ts:12
// tool-jobs dsh-tool-jobs/lib/types/index.d.ts:16
// tool-registry dsh-tools/lib/types/index.d.ts:20
// user-approval dsh-user-approval/lib/types/index.d.ts:14
// working-directory dsh-working-directory/lib/types/index.d.ts
const REAL_INJECTED_KINDS = [
'agent-instructions',
'agent-message',
'compact-checkpoint',
'cordis-host-runner',
'dsh-session-title-llm',
'model-selection',
'plan-mode',
'ptc-mode',
'repeat-tool-reminder',
'runtime-context',
'schedule',
'session-reference',
'skill-catalog',
'skill-invocation',
'subagent-settled',
'team-message',
'time-context',
'tmux-context',
'tool-goal',
'tool-jobs',
'tool-registry',
'user-approval',
'working-directory',
]
test('G6: every user-role injected kind in the installed harness is rejected as a query', () => {
for (const kind of REAL_INJECTED_KINDS) {
const batch = [
{ role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '真正的问题' }] },
{ role: 'user', source: { kind }, content: [{ type: 'text', text: `[${kind}] injected body` }] },
]
assert.equal(
lastUserText(batch),
'真正的问题',
`kind "${kind}" must be treated as injected, not as the human prompt`,
)
}
})
test('G6 mutation: "role is user ⇒ accept" must fail on every real injected kind', () => {
// The mutation is the exact first implementation: no kind filter at all.
const roleOnly = (messages) => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.role !== 'user') continue
const t = (messages[i].content ?? [])
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('\n')
.trim()
if (t !== '') return t
}
return ''
}
let mutatedFailures = 0
for (const kind of REAL_INJECTED_KINDS) {
const batch = [
{ role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: '真正的问题' }] },
{ role: 'user', source: { kind }, content: [{ type: 'text', text: `[${kind}] injected body` }] },
]
assert.equal(lastUserText(batch), '真正的问题')
if (roleOnly(batch) !== '真正的问题') mutatedFailures += 1
}
assert.equal(
mutatedFailures,
REAL_INJECTED_KINDS.length,
`the mutation must fail on ALL ${REAL_INJECTED_KINDS.length} kinds, failed on ${mutatedFailures}`,
)
})
// ── G6b: the allowlist must NOT exclude a real human prompt ───────────────
//
// This is the safety half of G6, and it is the reason a strict allowlist is
// acceptable at all. A remote prompt arrives with a DIFFERENT source key
// (`user-rpc`) but the SAME kind (`user`):
//
// dsh-api-session-controller/lib/types/types.d.ts:395-400
// 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string; principal?: Principal }
//
// So `kind: 'user'` covers both the local prompt and the browser/RPC prompt.
// Without this assertion, a future tightening of the allowlist (e.g. switching
// to key-based filtering) could silently stop recalling for web users only.
test('G6b: a user-rpc prompt (kind "user", extra fields) IS accepted', () => {
const rpc = {
role: 'user',
source: {
kind: 'user',
rpcId: 'rpc-123',
clientTimeZone: 'Asia/Shanghai',
principal: { id: 'other-caller' },
},
content: [{ type: 'text', text: '远程提问:值班室分机号是多少' }],
}
assert.equal(
lastUserText([rpc]),
'远程提问:值班室分机号是多少',
'user-rpc carries kind "user" and must be treated as a human prompt',
)
// And it still wins over an injected message that arrives after it.
const batch = [
rpc,
{ role: 'user', source: { kind: 'runtime-context' }, content: [{ type: 'text', text: '[wd]' }] },
]
assert.equal(lastUserText(batch), '远程提问:值班室分机号是多少')
})
// ── G7: a message with no source at all must yield '' ─────────────────────
//
// Pinned explicitly rather than left to inference. Reachability note: the
// current harness makes this state unreachable — `assertMessageEventShape`
// rejects `user/message` whose `source.kind` is not a non-empty string, and it
// runs on BOTH the append path (dsh-session/lib/index.js:1089) and the
// seed/restore path (:1165). So this is defense in depth against a future
// format/migration change, not a live bug. The assertion still earns its keep:
// it is the guard that keeps a missing `source` from silently degrading to
// "use whatever the last user-role message says".
test('G7: kind === undefined (and any non-string kind) yields ""', () => {
const cases = [
{ name: 'source missing entirely', message: { role: 'user', content: [{ type: 'text', text: 'q' }] } },
{ name: 'source is null', message: { role: 'user', source: null, content: [{ type: 'text', text: 'q' }] } },
{ name: 'source.kind missing', message: { role: 'user', source: {}, content: [{ type: 'text', text: 'q' }] } },
{
name: 'source.kind not a string',
message: { role: 'user', source: { kind: 42 }, content: [{ type: 'text', text: 'q' }] },
},
{
name: 'source.kind empty string',
message: { role: 'user', source: { kind: '' }, content: [{ type: 'text', text: 'q' }] },
},
]
for (const { name, message } of cases) {
assert.equal(lastUserText([message]), '', `${name} must yield "" (fail closed)`)
}
})
test('G7 mutation: dropping the typeof guard admits a sourceless message', () => {
// The mutation: `HUMAN_PROMPT_KINDS.has(undefined)` is false, so removing the
// typeof check would NOT by itself admit undefined — the real risk is a
// future refactor to `kind !== 'user'` negation or a role-only fallback.
// Pin the behaviour that the guard exists to produce:
const mutated = (messages) => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.role !== 'user') continue
// Mutation: no source check at all.
const t = (messages[i].content ?? [])
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('\n')
.trim()
if (t !== '') return t
}
return ''
}
const sourceless = [{ role: 'user', content: [{ type: 'text', text: 'q' }] }]
assert.equal(lastUserText(sourceless), '', 'guarded implementation rejects it')
assert.equal(mutated(sourceless), 'q', 'unguarded implementation admits it — G7 catches this')
})
test('V3: systemPrompt.context exposes agent but no step messages', async () => {
const { ctx, done } = await app()
try {
let keys = []
ctx.systemPrompt.context({
name: 'probe:v3',
order: 210,
text: (context) => {
keys = Object.keys(context).sort()
return 'probe'
},
})
await ctx.systemPrompt.assemble({ agent: { id: 'a', session: {} } })
assert.ok(keys.includes('agent'), `AssembleContext must carry agent; got ${keys.join(',')}`)
// The negative assertion is the load-bearing one: if dsh ever adds the step
// messages here, recall could move to this hook and this slice's whole
// design note would be wrong.
for (const forbidden of ['messages', 'input', 'text']) {
assert.ok(!keys.includes(forbidden), `AssembleContext must NOT expose ${forbidden}`)
}
} finally {
await done()
}
})
test('V3b: pre-step injection adds ONE attributed message after a real batch', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
// Uses realBatch, not a hand-built single message: the whole lesson of the
// regression above is that a 1- or 2-message batch is a fake batch.
const batch = realBatch('值班室分机号 4324 是谁在值班')
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: batch,
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: batch }),
)
assert.equal(decision.kind, 'enter')
assert.equal(decision.messages.length, batch.length + 1, 'batch + exactly one injected recall')
const injected = decision.messages[batch.length]
assert.equal(injected.role, 'user', 'injected context is a user message')
assert.equal(injected.source.kind, pluginName)
assert.equal(injected.source.form, 'snapshot')
assert.ok(injected.content.map((b) => b.text ?? '').join('').includes('4324'))
} finally {
await done()
}
})
test('V3b mutation: with recall silenced the batch stays un-augmented', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周' })
mem.contextForStep = () => ''
const input = createUserMessage({
content: [{ type: 'text', text: '值班室分机号 4324' }],
source: { kind: 'user' },
})
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [input],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.messages.length, 1, 'no injection when recall yields nothing')
} finally {
await done()
}
})
test('V3b: a rejected step is passed through untouched', async () => {
const { ctx, dir, done } = await app()
try {
applyMemory(ctx, { path: join(dir, 'm.db') })
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'reject' }),
)
assert.equal(decision.kind, 'reject', 'a rejection must not be converted into an enter')
} finally {
await done()
}
})
test('V4: stablemem_commit registers with a schema and executes end to end', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db') })
const schemas = ctx.tools.schemas()
const tool = schemas.find((entry) => entry.name === 'stablemem_commit')
assert.ok(tool, 'tool must be visible to the model')
assert.ok(tool.description.length > 0, 'tool must carry a description')
assert.ok(tool.parameters?.properties?.text, 'tool must declare its text parameter')
const result = await ctx.tools.execute({
callId: 'c1',
name: 'stablemem_commit',
arguments: { text: 'etcd 集群端口 2379', source: 'test' },
signal: new AbortController().signal,
})
assert.equal(result.isError, false, JSON.stringify(result))
assert.ok(String(result.value).includes('Committed'), result.value)
assert.equal(result.content[0].type, 'text', 'render must produce a text block')
assert.equal(mem.store.countBlocks(), 1)
} finally {
await done()
}
})
test('V5: disposing the fiber closes the store and releases the file', async () => {
const { ctx, dir, done } = await app()
const path = join(dir, 'm.db')
const mem = applyMemory(ctx, { path })
await mem.commit({ text: 'V5 句柄释放块' })
assert.equal(mem.store.isOpen, true)
await ctx.fiber.dispose()
assert.equal(mem.store.isOpen, false, 'fiber disposal must close the handle')
// Strongest proof: reopen the same file for writing.
const { BlockStore } = await import('../lib/store.js')
const reopened = new BlockStore({ path }).open()
assert.equal(reopened.countBlocks(), 1)
reopened.close()
rmSync(dir, { recursive: true, force: true })
void done
})
test('config.inject:false registers the tool but no pre-step listener', async () => {
const { ctx, dir, done } = await app()
try {
applyMemory(ctx, { path: join(dir, 'm.db'), inject: false })
const input = createUserMessage({
content: [{ type: 'text', text: 'anything' }],
source: { kind: 'user' },
})
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [input],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.messages.length, 1, 'injection must be off')
assert.ok(ctx.tools.schemas().some((s) => s.name === 'stablemem_commit'), 'tool still present')
} finally {
await done()
}
})
// ── REGRESSION: auto-recall died on a realistic dsh batch ──────────────────
//
// Found by running the plugin in a real headless profile, not by unit test.
// The unit test above passes because it builds a 2-message batch; a REAL step
// batch has additional user-role messages appended AFTER the question:
//
// [ user question ][ runtime-context snapshot ][ skill catalog reminder ]
//
// `lastUserText` used to return the last `role === 'user'` message, which is the
// skill catalog — so every real turn used a list of skill names as its recall
// query and matched nothing. Auto-recall was 100% dead in production while all
// checkpoints were green.
//
// The fix: filter by `source.kind`, not by role alone.
test('REGRESSION: lastUserText picks the real question out of a 3-part batch', () => {
const extracted = lastUserText(realBatch('值班室分机号是多少'))
assert.equal(
extracted,
'值班室分机号是多少',
`must return the human question, not the injected context; got ${JSON.stringify(extracted)}`,
)
})
test('REGRESSION: auto-recall hits on a realistic batch (the bug was 0 hits)', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: realBatch('值班室分机号是多少'),
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: realBatch('值班室分机号是多少') }),
)
assert.equal(decision.messages.length, 4, '3 batch messages + 1 injected recall')
const injected = decision.messages[3]
const text = injected.content.map((b) => b.text ?? '').join('')
assert.ok(
text.includes('4324'),
`recall must find the note; injected was ${JSON.stringify(text.slice(0, 100))}`,
)
} finally {
await done()
}
})
test('REGRESSION mutation: falling back to "last role=user" must drop recall to 0', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
// The mutation: reintroduce the old extraction rule.
const oldRule = (messages) => {
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i]?.role !== 'user') continue
const t = (messages[i].content ?? [])
.filter((b) => b?.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('\n')
.trim()
if (t !== '') return t
}
return ''
}
const batch = realBatch('值班室分机号是多少')
const mutatedQuery = oldRule(batch)
// `recall` became async in phase B group A. Await it: a bare Promise has no
// `.length`, and `undefined < undefined` is false, so this assertion would
// silently pass while measuring nothing.
const mutatedHits = await mem.recall(mutatedQuery)
const realHits = await mem.recall(lastUserText(batch))
// Numbers on the record: the old rule must lose every hit.
assert.ok(
mutatedHits.length < realHits.length,
`mutation must lose hits: old-rule=${mutatedHits.length}, fixed=${realHits.length}`,
)
assert.equal(mutatedHits.length, 0, `old rule must score 0 hits, got ${mutatedHits.length}`)
assert.ok(realHits.length >= 1, 'the fixed rule must find at least one')
} finally {
await done()
}
})