mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
feat(pi-hermes-memory): 导入上游 v0.9.10 源码快照
从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10 (df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。 本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略 均未改动。 - 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、 AGENTS 与 LICENSE;该快照共 156 个上游文件。 - 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、 覆盖率与构建产物。 - 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项: 在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts 对齐、strip-ansi 解析确认。 - 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至 24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为 对应一个已发布、可复现的版本。 尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载 Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。 验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份; 已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
This commit is contained in:
@@ -0,0 +1,986 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { DatabaseManager } from '../../src/store/db.js';
|
||||
import {
|
||||
addMemory,
|
||||
searchMemories,
|
||||
getMemories,
|
||||
removeMemory,
|
||||
touchMemory,
|
||||
getMemoryStats,
|
||||
syncMemoryEntry,
|
||||
replaceSyncedMemories,
|
||||
removeSyncedMemories,
|
||||
parseMarkdownMemoryEntry,
|
||||
formatFailureMemoryContent,
|
||||
reconcileMarkdownMemoryScope,
|
||||
reconcileMarkdownFailureScopes,
|
||||
} from '../../src/store/sqlite-memory-store.js';
|
||||
import { MemoryStore } from '../../src/store/memory-store.js';
|
||||
import { MDSYNC_METADATA_KEY_PREFIX } from '../../src/constants.js';
|
||||
|
||||
function mdsyncKey(target: string, project: string | null): string {
|
||||
return MDSYNC_METADATA_KEY_PREFIX + JSON.stringify([target, project]);
|
||||
}
|
||||
|
||||
function readMdsyncValue(dbManager: DatabaseManager, target: string, project: string | null): string | undefined {
|
||||
const row = dbManager.getDb().prepare(
|
||||
'SELECT value FROM extension_metadata WHERE key = ?',
|
||||
).get(mdsyncKey(target, project)) as { value: string } | undefined;
|
||||
return row?.value;
|
||||
}
|
||||
|
||||
function withBlockedMemoryWrites<T>(
|
||||
dbManager: DatabaseManager,
|
||||
fn: () => T,
|
||||
mode: 'insert' | 'insert-or-update' = 'insert-or-update',
|
||||
): T {
|
||||
const db = dbManager.getDb() as { prepare: (sql: string) => unknown };
|
||||
const originalPrepare = db.prepare.bind(db);
|
||||
db.prepare = (sql: string) => {
|
||||
const normalized = sql.replace(/\s+/g, ' ').trim();
|
||||
const isInsert = /^INSERT(?: OR REPLACE)? INTO memories\b/i.test(normalized);
|
||||
const isUpdate = /^UPDATE memories\b/i.test(normalized);
|
||||
if (isInsert || (mode === 'insert-or-update' && isUpdate)) {
|
||||
throw new Error(`unexpected memories write: ${normalized}`);
|
||||
}
|
||||
return originalPrepare(sql);
|
||||
};
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
db.prepare = originalPrepare;
|
||||
}
|
||||
}
|
||||
|
||||
describe('sqlite-memory-store', () => {
|
||||
let tmpDir: string;
|
||||
let dbManager: DatabaseManager;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-store-test-'));
|
||||
dbManager = new DatabaseManager(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dbManager.close();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('addMemory', () => {
|
||||
it('should add a memory entry', () => {
|
||||
const entry = addMemory(dbManager, 'prefers pnpm over npm');
|
||||
assert.ok(entry.id > 0);
|
||||
assert.strictEqual(entry.target, 'memory');
|
||||
assert.strictEqual(entry.content, 'prefers pnpm over npm');
|
||||
assert.ok(entry.created.length > 0);
|
||||
assert.ok(entry.lastReferenced.length > 0);
|
||||
});
|
||||
|
||||
it('should add a user entry', () => {
|
||||
const entry = addMemory(dbManager, 'name: Chandrateja', 'user');
|
||||
assert.strictEqual(entry.target, 'user');
|
||||
});
|
||||
|
||||
it('should add a project-specific entry', () => {
|
||||
const entry = addMemory(dbManager, 'uses Prisma', 'memory', 'my-project');
|
||||
assert.strictEqual(entry.project, 'my-project');
|
||||
});
|
||||
|
||||
it('should add a global entry (null project)', () => {
|
||||
const entry = addMemory(dbManager, 'timezone: AEST');
|
||||
assert.strictEqual(entry.project, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncMemoryEntry', () => {
|
||||
it('deduplicates exact logical entries', () => {
|
||||
const first = syncMemoryEntry(dbManager, {
|
||||
content: 'prefers pnpm over npm',
|
||||
target: 'memory',
|
||||
});
|
||||
const second = syncMemoryEntry(dbManager, {
|
||||
content: 'prefers pnpm over npm',
|
||||
target: 'memory',
|
||||
});
|
||||
|
||||
assert.strictEqual(first.action, 'inserted');
|
||||
assert.strictEqual(second.action, 'existing');
|
||||
assert.strictEqual(getMemories(dbManager).length, 1);
|
||||
});
|
||||
|
||||
it('stores project-scoped memory with project name', () => {
|
||||
syncMemoryEntry(dbManager, {
|
||||
content: 'uses Prisma',
|
||||
target: 'memory',
|
||||
project: 'project-a',
|
||||
});
|
||||
|
||||
const results = getMemories(dbManager, { project: 'project-a' });
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].project, 'project-a');
|
||||
assert.strictEqual(results[0].target, 'memory');
|
||||
});
|
||||
|
||||
it('preserves failure category metadata', () => {
|
||||
syncMemoryEntry(dbManager, {
|
||||
content: formatFailureMemoryContent('pnpm lockfile mismatch', {
|
||||
category: 'tool-quirk',
|
||||
failureReason: 'npm install rewrote lockfile',
|
||||
}),
|
||||
target: 'failure',
|
||||
category: 'tool-quirk',
|
||||
failureReason: 'npm install rewrote lockfile',
|
||||
});
|
||||
|
||||
const results = getMemories(dbManager, { target: 'failure', category: 'tool-quirk' });
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].category, 'tool-quirk');
|
||||
assert.strictEqual(results[0].failureReason, 'npm install rewrote lockfile');
|
||||
});
|
||||
|
||||
it('parses Markdown failure entries for backfill', () => {
|
||||
const parsed = parseMarkdownMemoryEntry(
|
||||
'[correction] use pnpm — Failed: npm install rewrote lockfile <!-- created=2026-05-08, last=2026-05-09 -->',
|
||||
'failure',
|
||||
);
|
||||
|
||||
assert.strictEqual(parsed.category, 'correction');
|
||||
assert.strictEqual(parsed.failureReason, 'npm install rewrote lockfile');
|
||||
assert.strictEqual(parsed.created, '2026-05-08');
|
||||
assert.strictEqual(parsed.lastReferenced, '2026-05-09');
|
||||
});
|
||||
|
||||
it('does not infer project scope from spoofable failure content', () => {
|
||||
const raw = '[correction] literal user text — Project: other-project <!-- created=2026-05-08, last=2026-05-09 -->';
|
||||
|
||||
reconcileMarkdownFailureScopes(dbManager, [raw]);
|
||||
|
||||
assert.strictEqual(getMemories(dbManager, { target: 'failure', project: 'other-project' }).length, 0);
|
||||
assert.strictEqual(getMemories(dbManager, { target: 'failure', project: null }).length, 1);
|
||||
});
|
||||
|
||||
it('round-trips project correction scope through authoritative Markdown metadata', async () => {
|
||||
const store = new MemoryStore({
|
||||
memoryDir: tmpDir,
|
||||
memoryCharLimit: 5_000,
|
||||
userCharLimit: 5_000,
|
||||
failureCharLimit: 5_000,
|
||||
} as any);
|
||||
await store.loadFromDisk();
|
||||
|
||||
await store.addFailure('use pnpm in this repo', {
|
||||
category: 'correction',
|
||||
failureReason: 'User corrected the agent',
|
||||
project: 'project-a',
|
||||
});
|
||||
const [raw] = store.getRawEntriesForSync('failure');
|
||||
reconcileMarkdownFailureScopes(dbManager, [raw]);
|
||||
|
||||
const entries = getMemories(dbManager, { target: 'failure', project: 'project-a' });
|
||||
assert.strictEqual(entries.length, 1);
|
||||
assert.strictEqual(entries[0].project, 'project-a');
|
||||
assert.doesNotMatch(entries[0].content, /Project: project-a/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileMarkdownMemoryScope', () => {
|
||||
it('keeps explicit global MEMORY and USER scope despite embedded project metadata', () => {
|
||||
const marker = 'c3Bvb2ZlZC1wcm9qZWN0';
|
||||
|
||||
reconcileMarkdownMemoryScope(
|
||||
dbManager,
|
||||
[`global memory <!-- created=2026-07-01, last=2026-07-02, project64=${marker} -->`],
|
||||
'memory',
|
||||
null,
|
||||
);
|
||||
reconcileMarkdownMemoryScope(
|
||||
dbManager,
|
||||
[`global user <!-- created=2026-07-01, last=2026-07-02, project64=${marker} -->`],
|
||||
'user',
|
||||
null,
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager).map((entry) => [entry.project, entry.target, entry.content]),
|
||||
[
|
||||
[null, 'memory', 'global memory'],
|
||||
[null, 'user', 'global user'],
|
||||
],
|
||||
);
|
||||
assert.deepStrictEqual(getMemories(dbManager, { project: 'spoofed-project' }), []);
|
||||
});
|
||||
|
||||
it('reports a degraded result (not a swallowed success) on FTS5 index errors', () => {
|
||||
// A genuine FTS5 search-index error must not look like a successful
|
||||
// zero-row sync: the result carries degraded + reason so the caller can
|
||||
// surface /memory-sync-markdown repair guidance.
|
||||
const failingDb = {
|
||||
prepare: (sql: string) => ({
|
||||
get: () => undefined,
|
||||
all: () => [],
|
||||
run: () => {
|
||||
throw new Error('fts5: table memory_fts is corrupted');
|
||||
},
|
||||
}),
|
||||
} as never;
|
||||
const stubDbManager = {
|
||||
getDb: () => failingDb,
|
||||
withCorruptionRecovery: <T>(operation: () => T): T => operation(),
|
||||
} as unknown as DatabaseManager;
|
||||
|
||||
const result = reconcileMarkdownMemoryScope(
|
||||
stubDbManager,
|
||||
['degraded reconcile test entry'],
|
||||
'memory',
|
||||
null,
|
||||
);
|
||||
assert.equal(result.degraded, true);
|
||||
assert.match(result.degradedReason ?? '', /fts5/);
|
||||
assert.equal(result.inserted, 0);
|
||||
});
|
||||
|
||||
it('propagates degraded from failure scopes (first reason wins)', () => {
|
||||
// When multiple failure scopes are degraded, the aggregate result must
|
||||
// be degraded with the first reason (not the last) so the user sees the
|
||||
// original failure, not a later duplicate.
|
||||
let callCount = 0;
|
||||
const failingDb = {
|
||||
prepare: (sql: string) => ({
|
||||
get: () => undefined,
|
||||
all: () => [],
|
||||
run: () => {
|
||||
callCount++;
|
||||
throw new Error(`fts5: error #${callCount}`);
|
||||
},
|
||||
}),
|
||||
} as never;
|
||||
const stubDbManager = {
|
||||
getDb: () => failingDb,
|
||||
withCorruptionRecovery: <T>(operation: () => T): T => operation(),
|
||||
} as unknown as DatabaseManager;
|
||||
|
||||
const result = reconcileMarkdownFailureScopes(
|
||||
stubDbManager,
|
||||
[
|
||||
'failure entry one — Project: project-a <!-- created=2026-01-01 -->',
|
||||
'failure entry two — Project: project-b <!-- created=2026-01-01 -->',
|
||||
],
|
||||
);
|
||||
assert.equal(result.degraded, true);
|
||||
assert.match(result.degradedReason ?? '', /fts5: error #1/);
|
||||
});
|
||||
|
||||
it('still propagates corruption errors instead of degrading (recovery must run)', () => {
|
||||
// Corruption signals are NOT FTS5 errors: they must escape so
|
||||
// DatabaseManager quarantines + rebuilds, never a degraded zero-result.
|
||||
const failingDb = {
|
||||
prepare: () => ({
|
||||
get: () => undefined,
|
||||
all: () => [],
|
||||
run: () => {
|
||||
throw new Error('database disk image is malformed');
|
||||
},
|
||||
}),
|
||||
} as never;
|
||||
const stubDbManager = {
|
||||
getDb: () => failingDb,
|
||||
withCorruptionRecovery: <T>(operation: () => T): T => operation(),
|
||||
} as unknown as DatabaseManager;
|
||||
|
||||
assert.throws(
|
||||
() => reconcileMarkdownMemoryScope(stubDbManager, ['corruption test entry'], 'memory', null),
|
||||
/malformed/,
|
||||
);
|
||||
});
|
||||
|
||||
it('prunes only absent rows in the exact target and project scope', () => {
|
||||
addMemory(dbManager, 'kept global memory', 'memory', null);
|
||||
addMemory(dbManager, 'orphaned global memory', 'memory', null);
|
||||
addMemory(dbManager, 'unrelated global user', 'user', null);
|
||||
addMemory(dbManager, 'unrelated project memory', 'memory', 'project-a');
|
||||
|
||||
const first = reconcileMarkdownMemoryScope(
|
||||
dbManager,
|
||||
['kept global memory <!-- created=2026-07-01, last=2026-07-02 -->'],
|
||||
'memory',
|
||||
null,
|
||||
);
|
||||
const second = reconcileMarkdownMemoryScope(
|
||||
dbManager,
|
||||
['kept global memory <!-- created=2026-07-01, last=2026-07-02 -->'],
|
||||
'memory',
|
||||
null,
|
||||
);
|
||||
|
||||
assert.strictEqual(first.removed, 1);
|
||||
assert.strictEqual(second.removed, 0);
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager).map((entry) => entry.content).sort(),
|
||||
['kept global memory', 'unrelated global user', 'unrelated project memory'].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes duplicate and stale-category rows by full Markdown identity', () => {
|
||||
const first = addMemory(
|
||||
dbManager,
|
||||
'[correction] use pnpm',
|
||||
'failure',
|
||||
'project-a',
|
||||
'correction',
|
||||
'original reason',
|
||||
null,
|
||||
'pnpm install',
|
||||
'2026-06-01',
|
||||
'2026-07-05',
|
||||
);
|
||||
addMemory(dbManager, '[correction] use pnpm', 'failure', 'project-a', 'correction');
|
||||
addMemory(dbManager, '[correction] use pnpm', 'failure', 'project-a', 'tool-quirk');
|
||||
|
||||
const result = reconcileMarkdownMemoryScope(
|
||||
dbManager,
|
||||
['[correction] use pnpm <!-- created=2026-07-01, last=2026-07-02, project64=cHJvamVjdC1h -->'],
|
||||
'failure',
|
||||
'project-a',
|
||||
);
|
||||
|
||||
const rows = dbManager.getDb().prepare(`
|
||||
SELECT id, category, failure_reason, corrected_to, created, last_referenced
|
||||
FROM memories
|
||||
WHERE project = 'project-a' AND target = 'failure'
|
||||
`).all() as Array<{
|
||||
id: number;
|
||||
category: string | null;
|
||||
failure_reason: string | null;
|
||||
corrected_to: string | null;
|
||||
created: string;
|
||||
last_referenced: string;
|
||||
}>;
|
||||
|
||||
assert.strictEqual(result.removed, 2);
|
||||
assert.deepStrictEqual(rows, [{
|
||||
id: first.id,
|
||||
category: 'correction',
|
||||
failure_reason: 'original reason',
|
||||
corrected_to: 'pnpm install',
|
||||
created: '2026-06-01',
|
||||
last_referenced: '2026-07-05',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('skips an unchanged scope without touching memories', () => {
|
||||
const entries = ['scope one', 'scope two', 'scope three'];
|
||||
const first = reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
|
||||
assert.strictEqual(first.inserted, 3);
|
||||
const ids = getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.id);
|
||||
const state = readMdsyncValue(dbManager, 'memory', null);
|
||||
assert.ok(state);
|
||||
const parsed = JSON.parse(state) as { sha256: string; entryCount: number };
|
||||
assert.strictEqual(parsed.entryCount, 3);
|
||||
assert.match(parsed.sha256, /^[0-9a-f]{64}$/i);
|
||||
|
||||
const second = withBlockedMemoryWrites(dbManager, () =>
|
||||
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null),
|
||||
);
|
||||
assert.deepStrictEqual(second, { inserted: 0, existing: 3, removed: 0 });
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.id),
|
||||
ids,
|
||||
);
|
||||
assert.strictEqual(readMdsyncValue(dbManager, 'memory', null), state);
|
||||
});
|
||||
|
||||
it('isolates a single-scope change from an untouched sibling scope', () => {
|
||||
const kept = ['kept project memory'];
|
||||
const edited = ['edited project original'];
|
||||
reconcileMarkdownMemoryScope(dbManager, kept, 'memory', 'kept-project');
|
||||
reconcileMarkdownMemoryScope(dbManager, edited, 'memory', 'edited-project');
|
||||
const keptIds = getMemories(dbManager, { target: 'memory', project: 'kept-project' }).map((entry) => entry.id);
|
||||
const keptState = readMdsyncValue(dbManager, 'memory', 'kept-project');
|
||||
|
||||
const editedResult = reconcileMarkdownMemoryScope(
|
||||
dbManager,
|
||||
['edited project replacement'],
|
||||
'memory',
|
||||
'edited-project',
|
||||
);
|
||||
const keptResult = withBlockedMemoryWrites(dbManager, () =>
|
||||
reconcileMarkdownMemoryScope(dbManager, kept, 'memory', 'kept-project'),
|
||||
);
|
||||
|
||||
assert.strictEqual(editedResult.inserted, 1);
|
||||
assert.strictEqual(editedResult.removed, 1);
|
||||
assert.deepStrictEqual(keptResult, { inserted: 0, existing: 1, removed: 0 });
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager, { target: 'memory', project: 'kept-project' }).map((entry) => entry.id),
|
||||
keptIds,
|
||||
);
|
||||
assert.strictEqual(readMdsyncValue(dbManager, 'memory', 'kept-project'), keptState);
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager, { target: 'memory', project: 'edited-project' }).map((entry) => entry.content),
|
||||
['edited project replacement'],
|
||||
);
|
||||
});
|
||||
|
||||
it('reimports when memories are emptied but the fingerprint survives', () => {
|
||||
const entries = ['mirror one', 'mirror two', 'mirror three'];
|
||||
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
|
||||
assert.ok(readMdsyncValue(dbManager, 'memory', null));
|
||||
dbManager.getDb().prepare('DELETE FROM memories').run();
|
||||
assert.strictEqual(getMemories(dbManager).length, 0);
|
||||
|
||||
const result = reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
|
||||
assert.strictEqual(result.inserted, 3);
|
||||
assert.strictEqual(getMemories(dbManager, { target: 'memory', project: null }).length, 3);
|
||||
});
|
||||
|
||||
it('repopulates a missing fingerprint without inserting existing rows', () => {
|
||||
const entries = ['fingerprint missing one', 'fingerprint missing two'];
|
||||
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
|
||||
const ids = getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.id);
|
||||
dbManager.getDb().prepare('DELETE FROM extension_metadata WHERE key = ?').run(mdsyncKey('memory', null));
|
||||
assert.equal(readMdsyncValue(dbManager, 'memory', null), undefined);
|
||||
|
||||
const result = withBlockedMemoryWrites(
|
||||
dbManager,
|
||||
() => reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null),
|
||||
'insert',
|
||||
);
|
||||
assert.strictEqual(result.inserted, 0);
|
||||
assert.ok(readMdsyncValue(dbManager, 'memory', null));
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.id),
|
||||
ids,
|
||||
);
|
||||
});
|
||||
|
||||
it('force-repairs COUNT-preserving sqlite-only drift', () => {
|
||||
const entries = ['canonical markdown content'];
|
||||
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
|
||||
dbManager.getDb().prepare(`
|
||||
UPDATE memories SET content = 'drifted sqlite content' WHERE target = 'memory' AND project IS NULL
|
||||
`).run();
|
||||
|
||||
const skipped = withBlockedMemoryWrites(dbManager, () =>
|
||||
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null),
|
||||
);
|
||||
assert.deepStrictEqual(skipped, { inserted: 0, existing: 1, removed: 0 });
|
||||
assert.strictEqual(getMemories(dbManager, { target: 'memory', project: null })[0].content, 'drifted sqlite content');
|
||||
|
||||
const forced = reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null, { force: true });
|
||||
assert.strictEqual(forced.inserted, 1);
|
||||
assert.strictEqual(forced.removed, 1);
|
||||
assert.deepStrictEqual(
|
||||
getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.content),
|
||||
['canonical markdown content'],
|
||||
);
|
||||
const rewritten = JSON.parse(readMdsyncValue(dbManager, 'memory', null)!) as { entryCount: number };
|
||||
assert.strictEqual(rewritten.entryCount, 1);
|
||||
});
|
||||
|
||||
it('drops the fingerprint when the force path empties a scope', () => {
|
||||
reconcileMarkdownMemoryScope(dbManager, ['force-emptied entry'], 'memory', 'force-emptied');
|
||||
assert.ok(readMdsyncValue(dbManager, 'memory', 'force-emptied'));
|
||||
|
||||
const forced = reconcileMarkdownMemoryScope(dbManager, [], 'memory', 'force-emptied', { force: true });
|
||||
|
||||
assert.strictEqual(forced.removed, 1);
|
||||
assert.equal(
|
||||
readMdsyncValue(dbManager, 'memory', 'force-emptied'),
|
||||
undefined,
|
||||
'the emptied scope must not leave a fingerprint behind',
|
||||
);
|
||||
});
|
||||
|
||||
it('restores search through the force path when the index lost a row', () => {
|
||||
const entries = ['indexed needle entry', 'indexed companion entry'];
|
||||
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
|
||||
assert.strictEqual(searchMemories(dbManager, 'needle').length, 1);
|
||||
|
||||
const lost = dbManager.getDb().prepare(
|
||||
'SELECT id, content FROM memories WHERE content = ?',
|
||||
).get('indexed needle entry') as { id: number; content: string };
|
||||
dbManager.getDb().prepare(
|
||||
"INSERT INTO memory_fts(memory_fts, rowid, content) VALUES ('delete', ?, ?)",
|
||||
).run(lost.id, lost.content);
|
||||
assert.strictEqual(searchMemories(dbManager, 'needle').length, 0, 'the index gap must hide the entry');
|
||||
|
||||
// The fingerprint binds markdown bytes, so an unchanged scope keeps
|
||||
// skipping even though search can no longer see the row. That is the
|
||||
// documented boundary; the forced repair is what has to close it.
|
||||
const skipped = reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
|
||||
assert.strictEqual(skipped.inserted, 0);
|
||||
assert.strictEqual(searchMemories(dbManager, 'needle').length, 0);
|
||||
|
||||
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null, { force: true });
|
||||
|
||||
assert.strictEqual(searchMemories(dbManager, 'needle').length, 1, 'force must re-index the scope');
|
||||
});
|
||||
|
||||
it('drops rows and the fingerprint for an emptied project scope', () => {
|
||||
reconcileMarkdownMemoryScope(dbManager, ['doomed project memory'], 'memory', 'doomed-project');
|
||||
assert.ok(readMdsyncValue(dbManager, 'memory', 'doomed-project'));
|
||||
|
||||
const firstEmpty = reconcileMarkdownMemoryScope(dbManager, [], 'memory', 'doomed-project');
|
||||
assert.strictEqual(firstEmpty.removed, 1);
|
||||
assert.deepStrictEqual(getMemories(dbManager, { target: 'memory', project: 'doomed-project' }), []);
|
||||
assert.equal(readMdsyncValue(dbManager, 'memory', 'doomed-project'), undefined);
|
||||
|
||||
const secondEmpty = withBlockedMemoryWrites(dbManager, () =>
|
||||
reconcileMarkdownMemoryScope(dbManager, [], 'memory', 'doomed-project'),
|
||||
);
|
||||
assert.deepStrictEqual(secondEmpty, { inserted: 0, existing: 0, removed: 0 });
|
||||
assert.equal(readMdsyncValue(dbManager, 'memory', 'doomed-project'), undefined);
|
||||
});
|
||||
|
||||
it('does not write a fingerprint when FTS5 degrades the reconcile', () => {
|
||||
const db = dbManager.getDb() as { prepare: (sql: string) => any };
|
||||
const originalPrepare = db.prepare.bind(db);
|
||||
db.prepare = (sql: string) => {
|
||||
const stmt = originalPrepare(sql);
|
||||
const normalized = sql.replace(/\s+/g, ' ').trim();
|
||||
if (/^INSERT(?: OR REPLACE)? INTO memories\b/i.test(normalized) || /^UPDATE memories\b/i.test(normalized)) {
|
||||
return {
|
||||
get: (...args: unknown[]) => stmt.get(...args),
|
||||
all: (...args: unknown[]) => stmt.all(...args),
|
||||
run: () => {
|
||||
throw new Error('fts5: table memory_fts is corrupted');
|
||||
},
|
||||
};
|
||||
}
|
||||
return stmt;
|
||||
};
|
||||
try {
|
||||
const result = reconcileMarkdownMemoryScope(dbManager, ['degraded fingerprint probe'], 'memory', null);
|
||||
assert.equal(result.degraded, true);
|
||||
assert.equal(readMdsyncValue(dbManager, 'memory', null), undefined);
|
||||
} finally {
|
||||
db.prepare = originalPrepare;
|
||||
}
|
||||
});
|
||||
|
||||
it('treats corrupt fingerprint JSON as a miss and rewrites valid state', () => {
|
||||
const entries = ['corrupt-state entry'];
|
||||
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
|
||||
dbManager.getDb().prepare(
|
||||
'UPDATE extension_metadata SET value = ? WHERE key = ?',
|
||||
).run('{not-json', mdsyncKey('memory', null));
|
||||
|
||||
const result = withBlockedMemoryWrites(
|
||||
dbManager,
|
||||
() => reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null),
|
||||
'insert',
|
||||
);
|
||||
assert.strictEqual(result.inserted, 0);
|
||||
const rewritten = JSON.parse(readMdsyncValue(dbManager, 'memory', null)!) as {
|
||||
sha256: string;
|
||||
entryCount: number;
|
||||
};
|
||||
assert.match(rewritten.sha256, /^[0-9a-f]{64}$/i);
|
||||
assert.strictEqual(rewritten.entryCount, 1);
|
||||
assert.deepStrictEqual(Object.keys(rewritten).sort(), ['entryCount', 'sha256']);
|
||||
});
|
||||
|
||||
it('treats extra fingerprint fields as a miss', () => {
|
||||
const entries = ['strict-state entry'];
|
||||
reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null);
|
||||
const current = JSON.parse(readMdsyncValue(dbManager, 'memory', null)!) as {
|
||||
sha256: string;
|
||||
entryCount: number;
|
||||
};
|
||||
dbManager.getDb().prepare(
|
||||
'UPDATE extension_metadata SET value = ? WHERE key = ?',
|
||||
).run(JSON.stringify({ ...current, extra: true }), mdsyncKey('memory', null));
|
||||
|
||||
withBlockedMemoryWrites(
|
||||
dbManager,
|
||||
() => reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null),
|
||||
'insert',
|
||||
);
|
||||
const rewritten = JSON.parse(readMdsyncValue(dbManager, 'memory', null)!) as Record<string, unknown>;
|
||||
assert.deepStrictEqual(Object.keys(rewritten).sort(), ['entryCount', 'sha256']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replace/remove synced memories', () => {
|
||||
it('escapes % and _ during replace matching', () => {
|
||||
addMemory(dbManager, 'token 100%_safe value');
|
||||
addMemory(dbManager, 'token 100XXsafe value');
|
||||
|
||||
const result = replaceSyncedMemories(dbManager, '100%_safe', {
|
||||
content: 'token updated literal value',
|
||||
target: 'memory',
|
||||
project: null,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.matched, 1);
|
||||
const all = getMemories(dbManager);
|
||||
assert.ok(all.some((entry) => entry.content === 'token updated literal value'));
|
||||
assert.ok(all.some((entry) => entry.content === 'token 100XXsafe value'));
|
||||
});
|
||||
|
||||
it('escapes % and _ during remove matching', () => {
|
||||
addMemory(dbManager, 'remove 50%_match literal');
|
||||
addMemory(dbManager, 'remove 50AAmatch literal');
|
||||
|
||||
const result = removeSyncedMemories(dbManager, '50%_match', {
|
||||
target: 'memory',
|
||||
project: null,
|
||||
});
|
||||
|
||||
assert.strictEqual(result.matched, 1);
|
||||
const all = getMemories(dbManager);
|
||||
assert.strictEqual(all.length, 1);
|
||||
assert.strictEqual(all[0].content, 'remove 50AAmatch literal');
|
||||
});
|
||||
|
||||
it('normalizes pasted memory_search lines during replace matching', () => {
|
||||
addMemory(dbManager, 'prefers pnpm over npm');
|
||||
|
||||
const result = replaceSyncedMemories(
|
||||
dbManager,
|
||||
'🧠 scope=global [target=memory] prefers pnpm over npm\n Created: 2026-05-27 | Last used: 2026-05-27',
|
||||
{
|
||||
content: 'prefers pnpm over npm and bun when needed',
|
||||
target: 'memory',
|
||||
project: null,
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(result.matched, 1);
|
||||
const all = getMemories(dbManager);
|
||||
assert.ok(all.some((entry) => entry.content === 'prefers pnpm over npm and bun when needed'));
|
||||
});
|
||||
|
||||
it('normalizes pasted memory_search lines during remove matching', () => {
|
||||
addMemory(dbManager, '[correction] use pnpm — Failed: npm rewrote the lockfile', 'failure');
|
||||
|
||||
const result = removeSyncedMemories(
|
||||
dbManager,
|
||||
'⚠️ [global] [correction] [correction] use pnpm\n Created: 2026-05-27 | Last used: 2026-05-27',
|
||||
{
|
||||
target: 'failure',
|
||||
project: null,
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(result.matched, 1);
|
||||
const all = getMemories(dbManager);
|
||||
assert.strictEqual(all.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchMemories', () => {
|
||||
beforeEach(() => {
|
||||
addMemory(dbManager, 'prefers pnpm over npm');
|
||||
addMemory(dbManager, 'uses Prisma with PostgreSQL', 'memory', 'project-a');
|
||||
addMemory(dbManager, 'debugged gpu timeout issue after driver update');
|
||||
addMemory(dbManager, 'memory search indexing notes');
|
||||
addMemory(dbManager, 'exact phrase memory search example');
|
||||
addMemory(dbManager, 'name: Chandrateja', 'user');
|
||||
addMemory(dbManager, 'timezone: AEST', 'user');
|
||||
});
|
||||
|
||||
it('degrades an all-stop-word query to the LIKE fallback instead of []', () => {
|
||||
// "the" is a stop word and "and"/"or" connectors, so FTS5 normalization
|
||||
// leaves nothing to match. The query must fall through to the scoped
|
||||
// literal fallback (which still searches the raw terms) rather than
|
||||
// hard-return an empty result set.
|
||||
addMemory(dbManager, 'the build passes after restart');
|
||||
|
||||
const results = searchMemories(dbManager, 'the and or');
|
||||
assert.ok(results.length > 0, 'all-stop-word query must reach the LIKE fallback, not []');
|
||||
assert.ok(results.some(r => r.content.includes('the')));
|
||||
});
|
||||
|
||||
it('should find memories by keyword', () => {
|
||||
const results = searchMemories(dbManager, 'pnpm');
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some(r => r.content.includes('pnpm')));
|
||||
});
|
||||
|
||||
it('should find memories by partial content', () => {
|
||||
const results = searchMemories(dbManager, 'Prisma');
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some(r => r.content.includes('Prisma')));
|
||||
});
|
||||
|
||||
it('returns only project-attributed memories for target "project"', () => {
|
||||
const results = searchMemories(dbManager, 'Prisma', { target: 'project' });
|
||||
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.every((r) => r.project === 'project-a'));
|
||||
assert.ok(results.every((r) => r.target === 'memory'));
|
||||
});
|
||||
|
||||
it('keeps target "memory" matching both global and project-attributed memories', () => {
|
||||
const global = searchMemories(dbManager, 'pnpm', { target: 'memory' });
|
||||
const project = searchMemories(dbManager, 'Prisma', { target: 'memory' });
|
||||
|
||||
assert.ok(global.some((r) => r.project === null));
|
||||
assert.ok(project.length > 0);
|
||||
assert.ok(project.every((r) => r.project === 'project-a'));
|
||||
});
|
||||
|
||||
it('combines target "project" with an explicit project filter', () => {
|
||||
addMemory(dbManager, 'uses Bun with PostgreSQL', 'memory', 'project-b');
|
||||
|
||||
const named = searchMemories(dbManager, 'PostgreSQL', { target: 'project', project: 'project-b' });
|
||||
const anyProject = searchMemories(dbManager, 'PostgreSQL', { target: 'project' });
|
||||
const globalOnly = searchMemories(dbManager, 'PostgreSQL', { target: 'project', project: null });
|
||||
|
||||
assert.ok(named.length > 0);
|
||||
assert.ok(named.every((r) => r.project === 'project-b'));
|
||||
assert.ok(anyProject.some((r) => r.project === 'project-a'));
|
||||
assert.strictEqual(globalOnly.length, 0);
|
||||
});
|
||||
it('finds pure CJK substrings with the trigram tokenizer', () => {
|
||||
addMemory(dbManager, '设备清单包含 NAS 和备份策略');
|
||||
|
||||
const results = searchMemories(dbManager, '设备清单');
|
||||
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some((result) => result.content.includes('设备清单')));
|
||||
});
|
||||
|
||||
it('retains English token searches in the trigram index', () => {
|
||||
addMemory(dbManager, '设备清单包含 NAS 和备份策略');
|
||||
|
||||
const results = searchMemories(dbManager, 'NAS');
|
||||
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some((result) => result.content.includes('NAS')));
|
||||
});
|
||||
|
||||
it('falls back to a scoped literal search for one- and two-character CJK queries', () => {
|
||||
addMemory(dbManager, '设备清单包含 NAS 和备份策略');
|
||||
|
||||
const results = searchMemories(dbManager, '设备');
|
||||
|
||||
assert.ok(results.some((result) => result.content.includes('设备清单')));
|
||||
});
|
||||
|
||||
it('applies target "project" in the CJK literal fallback path', () => {
|
||||
addMemory(dbManager, '项目约定使用 pnpm', 'memory', 'project-a');
|
||||
addMemory(dbManager, '项目全局笔记');
|
||||
|
||||
const results = searchMemories(dbManager, '项目', { target: 'project' });
|
||||
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.every((r) => r.project === 'project-a'));
|
||||
assert.ok(results.every((r) => r.target === 'memory'));
|
||||
});
|
||||
|
||||
|
||||
it('should match multi-word queries without requiring an exact phrase', () => {
|
||||
const results = searchMemories(dbManager, 'gpu issue');
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some((r) => r.content.includes('gpu timeout issue')));
|
||||
});
|
||||
|
||||
it('should ignore lowercase connector words in natural-language queries', () => {
|
||||
const results = searchMemories(dbManager, 'gpu and issue');
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some((r) => r.content.includes('gpu timeout issue')));
|
||||
});
|
||||
it('should fall back to broader natural-language matching when strict term matching misses', () => {
|
||||
addMemory(dbManager, "user's name is Naruto", 'user');
|
||||
|
||||
const results = searchMemories(dbManager, 'name identity Naruto', { target: 'user' });
|
||||
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some((r) => r.content.includes("Naruto")));
|
||||
});
|
||||
|
||||
it('should not broaden explicit operator queries', () => {
|
||||
const results = searchMemories(dbManager, 'pnpm AND nonexistent');
|
||||
|
||||
assert.strictEqual(results.length, 0);
|
||||
});
|
||||
|
||||
it('should preserve explicit quoted phrase searches', () => {
|
||||
const results = searchMemories(dbManager, '"memory search"');
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.every((r) => r.content.includes('memory search')));
|
||||
});
|
||||
|
||||
it('should recover natural-language queries with uppercase operator words and punctuation', () => {
|
||||
addMemory(dbManager, 'Never search whole filesystem from root. Do not run find /.', 'user');
|
||||
|
||||
const results = searchMemories(dbManager, 'DO NOT USE FIND /', { target: 'user' });
|
||||
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.some((r) => r.content.includes('find')));
|
||||
});
|
||||
|
||||
it('should preserve valid operator queries', () => {
|
||||
const results = searchMemories(dbManager, 'pnpm OR AEST');
|
||||
assert.ok(results.length >= 2);
|
||||
assert.ok(results.some((r) => r.content.includes('pnpm')));
|
||||
assert.ok(results.some((r) => r.content.includes('AEST')));
|
||||
});
|
||||
|
||||
it('should limit results', () => {
|
||||
const results = searchMemories(dbManager, 'pnpm OR Prisma OR AEST', { limit: 2 });
|
||||
assert.ok(results.length <= 2);
|
||||
});
|
||||
|
||||
it('should filter by project', () => {
|
||||
const results = searchMemories(dbManager, 'Prisma', { project: 'project-a' });
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.every(r => r.project === 'project-a'));
|
||||
});
|
||||
|
||||
it('should filter by target', () => {
|
||||
const results = searchMemories(dbManager, 'Chandrateja OR AEST', { target: 'user' });
|
||||
assert.ok(results.length > 0);
|
||||
assert.ok(results.every(r => r.target === 'user'));
|
||||
});
|
||||
|
||||
it('should return empty for no matches', () => {
|
||||
const results = searchMemories(dbManager, 'nonexistent-xyz');
|
||||
assert.strictEqual(results.length, 0);
|
||||
});
|
||||
|
||||
it('should return empty for blank queries', () => {
|
||||
assert.deepStrictEqual(searchMemories(dbManager, ' '), []);
|
||||
});
|
||||
|
||||
it('should not throw on unmatched quotes', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
const results = searchMemories(dbManager, 'issue "timeout');
|
||||
assert.ok(Array.isArray(results));
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty for malformed operator queries', () => {
|
||||
const results = searchMemories(dbManager, 'AND OR NOT');
|
||||
assert.strictEqual(results.length, 0);
|
||||
});
|
||||
|
||||
it('ranks a dense old match above a fresher, more diluted one (BM25 before recency)', () => {
|
||||
addMemory(dbManager, 'deploy target is the staging cluster');
|
||||
addMemory(
|
||||
dbManager,
|
||||
'unrelated retrospective notes that mention deploy once among a great many other words about hiring, budgets, roadmaps, vendor calls, office moves and quarterly planning for the staging of a conference'
|
||||
);
|
||||
const db = dbManager.getDb();
|
||||
db.prepare("UPDATE memories SET last_referenced = '2025-01-01' WHERE content LIKE 'deploy target%'").run();
|
||||
db.prepare("UPDATE memories SET last_referenced = '2026-08-30' WHERE content LIKE 'unrelated retrospective%'").run();
|
||||
|
||||
const results = searchMemories(dbManager, 'deploy staging');
|
||||
|
||||
// Both rows match; recency ordering would put the long fresh note first.
|
||||
assert.strictEqual(results.length, 2);
|
||||
assert.ok(results[0].content.startsWith('deploy target is the staging cluster'));
|
||||
});
|
||||
|
||||
it('still broadens a two-term natural-language query through the OR fallback', () => {
|
||||
// Neither "dark" nor "chocolate" co-occur, so the implicit-AND query
|
||||
// misses and only the OR fallback can recover this row. The existing
|
||||
// fallback test uses three terms; this pins the two-term case.
|
||||
addMemory(dbManager, 'user prefers dark mode UI theme', 'user');
|
||||
|
||||
const results = searchMemories(dbManager, 'dark chocolate', { target: 'user' });
|
||||
|
||||
assert.ok(results.some((r) => r.content.includes('dark mode')));
|
||||
});
|
||||
|
||||
it('recovers a natural-language query whose raw FTS5 form fails to parse', () => {
|
||||
// "DO NOT USE FIND /" passes through as raw FTS5 syntax and throws;
|
||||
// runSearch must catch that and let the natural-language retry run.
|
||||
addMemory(dbManager, 'Never search whole filesystem from root. Do not run find /.', 'user');
|
||||
|
||||
assert.doesNotThrow(() => searchMemories(dbManager, 'DO NOT USE FIND /', { target: 'user' }));
|
||||
const results = searchMemories(dbManager, 'DO NOT USE FIND /', { target: 'user' });
|
||||
|
||||
assert.ok(results.some((r) => r.content.includes('find /')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMemories', () => {
|
||||
beforeEach(() => {
|
||||
addMemory(dbManager, 'global memory 1');
|
||||
addMemory(dbManager, 'global memory 2');
|
||||
addMemory(dbManager, 'project memory', 'memory', 'project-a');
|
||||
addMemory(dbManager, 'user preference', 'user');
|
||||
});
|
||||
|
||||
it('should return all memories', () => {
|
||||
const results = getMemories(dbManager);
|
||||
assert.strictEqual(results.length, 4);
|
||||
});
|
||||
|
||||
it('should filter by project', () => {
|
||||
const results = getMemories(dbManager, { project: 'project-a' });
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].content, 'project memory');
|
||||
});
|
||||
|
||||
it('should filter by null project (global)', () => {
|
||||
const results = getMemories(dbManager, { project: null });
|
||||
assert.strictEqual(results.length, 3);
|
||||
});
|
||||
|
||||
it('should filter by target', () => {
|
||||
const results = getMemories(dbManager, { target: 'user' });
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].content, 'user preference');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeMemory', () => {
|
||||
it('should remove a memory by id', () => {
|
||||
const entry = addMemory(dbManager, 'to be removed');
|
||||
const removed = removeMemory(dbManager, entry.id);
|
||||
assert.strictEqual(removed, true);
|
||||
|
||||
const all = getMemories(dbManager);
|
||||
assert.strictEqual(all.length, 0);
|
||||
});
|
||||
|
||||
it('should return false for non-existent id', () => {
|
||||
const removed = removeMemory(dbManager, 99999);
|
||||
assert.strictEqual(removed, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('touchMemory', () => {
|
||||
it('should update last_referenced date', () => {
|
||||
const entry = addMemory(dbManager, 'old memory');
|
||||
// Manually set an old date
|
||||
const db = dbManager.getDb();
|
||||
db.prepare('UPDATE memories SET last_referenced = ? WHERE id = ?').run('2020-01-01', entry.id);
|
||||
|
||||
touchMemory(dbManager, entry.id);
|
||||
|
||||
const updated = db.prepare('SELECT last_referenced FROM memories WHERE id = ?').get(entry.id) as { last_referenced: string };
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
assert.strictEqual(updated.last_referenced, today);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMemoryStats', () => {
|
||||
it('should return zero stats for empty database', () => {
|
||||
const stats = getMemoryStats(dbManager);
|
||||
assert.strictEqual(stats.total, 0);
|
||||
assert.deepStrictEqual(stats.byProject, []);
|
||||
assert.deepStrictEqual(stats.byTarget, []);
|
||||
});
|
||||
|
||||
it('should return correct stats', () => {
|
||||
addMemory(dbManager, 'global 1');
|
||||
addMemory(dbManager, 'global 2');
|
||||
addMemory(dbManager, 'project memory', 'memory', 'project-a');
|
||||
addMemory(dbManager, 'user pref', 'user');
|
||||
|
||||
const stats = getMemoryStats(dbManager);
|
||||
assert.strictEqual(stats.total, 4);
|
||||
assert.strictEqual(stats.byTarget.length, 2);
|
||||
assert.ok(stats.byProject.length > 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user