Files
Ncurator/tests/case/unit/knowledge/workspace-knowledge-cache.test.ts
huangyuan 508132adba perf(test): reuse knowledge computations and expand skill live evaluation
Share content-addressed parsing and embedding caches across isolated test runs, fingerprint relevant dependencies and model assets, and remove implicit UI warm-up. Add realistic two-round Skill delivery evaluation and cache regressions.
2026-10-23 18:50:49 +08:00

168 lines
7.3 KiB
TypeScript

import { mkdtemp, readFile, readdir, rm, writeFile, mkdir } from 'node:fs/promises'
import os from 'node:os'
import { spawnSync } from 'node:child_process'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createWorkspaceKnowledgeCache } from '../../../../src/main/services/workspace/WorkspaceKnowledgeCache'
import { fingerprintKnowledgePipeline } from '../../../../scripts/build/runtime/knowledge-cache-identity.mjs'
let root: string
const originalModels = process.env.NCURATOR_MODELS_PATH
const pipeline = { split: 'parser-a', embedding: 'embedding-a' }
const split = { contentHash: 'content-a', fileExt: '.md', chunks: [{ text: 'hello', page: 1 }] }
const key = { contentHash: 'content-a', fileExt: '.md', sourceName: 'notes.md' }
const vector = {
contentHash: 'content-a',
fileExt: '.md',
modelName: 'model-a',
vectors: [[1, 2, 3]]
}
const vectorKey = { ...key, modelName: 'model-a', texts: ['hello'] }
const cache = (identity = pipeline) =>
createWorkspaceKnowledgeCache({ root: path.join(root, 'cache'), pipeline: identity })!
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'ncurator-knowledge-cache-'))
process.env.NCURATOR_MODELS_PATH = path.join(root, 'models')
await mkdir(path.join(root, 'models', 'model-a'), { recursive: true })
await writeFile(path.join(root, 'models', 'model-a', 'model.onnx'), 'asset-a')
})
afterEach(async () => {
if (originalModels === undefined) delete process.env.NCURATOR_MODELS_PATH
else process.env.NCURATOR_MODELS_PATH = originalModels
await rm(root, { recursive: true, force: true })
})
describe('workspace knowledge cache', () => {
it('isolates split identity by contents, extension, name and parser while preserving same input hits', async () => {
const first = cache()
await expect(first.loadSplitSnapshot(key)).resolves.toBeNull()
await first.saveSplitSnapshot(split, key.sourceName)
await expect(cache().loadSplitSnapshot(key)).resolves.toEqual(split)
for (const change of [
{ contentHash: 'new' },
{ fileExt: '.txt' },
{ sourceName: 'renamed.md' }
]) {
await expect(first.loadSplitSnapshot({ ...key, ...change })).resolves.toBeNull()
}
await expect(
cache({ ...pipeline, split: 'parser-b' }).loadSplitSnapshot(key)
).resolves.toBeNull()
await expect(
cache({ ...pipeline, embedding: 'embedding-b' }).loadSplitSnapshot(key)
).resolves.toEqual(split)
})
it('keys vectors by actual ordered texts, model bytes and embedding code, then rebinds current source metadata', async () => {
await cache().saveVectorSnapshot(vector, vectorKey.texts)
await expect(cache().loadVectorSnapshot(vectorKey)).resolves.toEqual(vector)
await expect(
cache().loadVectorSnapshot({ ...vectorKey, contentHash: 'other-source', fileExt: '.txt' })
).resolves.toEqual({ ...vector, contentHash: 'other-source', fileExt: '.txt' })
await expect(
cache().loadVectorSnapshot({ ...vectorKey, texts: ['different'] })
).resolves.toBeNull()
await expect(
cache().loadVectorSnapshot({ ...vectorKey, texts: ['hello', 'second'] })
).resolves.toBeNull()
await expect(
cache({ ...pipeline, embedding: 'embedding-b' }).loadVectorSnapshot(vectorKey)
).resolves.toBeNull()
await expect(
cache({ ...pipeline, split: 'parser-b' }).loadVectorSnapshot(vectorKey)
).resolves.toEqual(vector)
await writeFile(path.join(root, 'models', 'model-a', 'model.onnx'), 'asset-b')
await expect(cache().loadVectorSnapshot(vectorKey)).resolves.toBeNull()
})
it('serializes missing work across consumers, retains completed entries on failure, and atomically rejects corruption', async () => {
const first = cache(),
second = cache()
let computations = 0
const warm = async (target: ReturnType<typeof cache>) =>
target.withLock('same-source', async () => {
if (await target.loadSplitSnapshot(key)) return
computations += 1
await target.saveSplitSnapshot(split, key.sourceName)
})
await Promise.all([warm(first), warm(second), warm(cache())])
expect(computations).toBe(1)
await expect(
first.withLock('same-source', async () => {
throw new Error('interrupted')
})
).rejects.toThrow('interrupted')
await warm(second)
expect(computations).toBe(1)
const [file] = await readdir(path.join(root, 'cache', 'splits'))
await writeFile(path.join(root, 'cache', 'splits', file), '{"incomplete":true}')
await warm(first)
expect(computations).toBe(2)
expect(await readdir(path.join(root, 'cache', 'locks'))).toEqual([])
const stats = JSON.parse(await readFile(path.join(root, 'cache', 'stats.json'), 'utf8'))
expect(stats.splitCacheHits).toBe(3)
expect(stats.splitCacheMisses).toBe(2)
})
it('cancels a waiting consumer without removing the active computation lock', async () => {
const target = cache()
const signal = new AbortController()
await target.withLock('busy-source', async () => {
const waiting = target.withLock(
'busy-source',
async () => {
throw new Error('must not start')
},
signal.signal
)
signal.abort()
await expect(waiting).rejects.toThrow('INDEX_PREPARE_CANCELLED')
expect(await readdir(path.join(root, 'cache', 'locks'))).toHaveLength(1)
})
expect(await readdir(path.join(root, 'cache', 'locks'))).toEqual([])
})
it('reclaims a terminated process lock without deleting a new owner', async () => {
const target = cache()
let directory = ''
await target.withLock('interrupted-source', async () => {
const [name] = await readdir(path.join(root, 'cache', 'locks'))
directory = path.join(root, 'cache', 'locks', name)
})
const child = spawnSync(process.execPath, ['-e', 'process.exit(0)'])
expect(child.status).toBe(0)
await mkdir(directory)
await writeFile(path.join(directory, `${child.pid}-interrupted`), '')
let recovered = false
await target.withLock('interrupted-source', async () => {
recovered = true
})
expect(recovered).toBe(true)
expect(await readdir(path.join(root, 'cache', 'locks'))).toEqual([])
})
it('automatically follows newly imported runtime dependencies while ignoring unrelated files and type-only imports', async () => {
await writeFile(
path.join(root, 'entry.ts'),
"import { parse } from './parser'; import type { Shape } from './types'; export const run = parse;"
)
await writeFile(path.join(root, 'parser.ts'), 'export const parse = () => 1')
await writeFile(path.join(root, 'types.ts'), 'export type Shape = string')
const fingerprint = () => fingerprintKnowledgePipeline(root, ['entry.ts'])
const initial = await fingerprint()
await writeFile(path.join(root, 'unrelated-ui.ts'), 'export const ui = 2')
await writeFile(path.join(root, 'types.ts'), 'export type Shape = number')
expect(await fingerprint()).toBe(initial)
await writeFile(
path.join(root, 'parser.ts'),
"import { value } from './new-dependency'; export const parse = () => value"
)
await writeFile(path.join(root, 'new-dependency.ts'), 'export const value = 1')
const imported = await fingerprint()
expect(imported).not.toBe(initial)
await writeFile(path.join(root, 'new-dependency.ts'), 'export const value = 2')
expect(await fingerprint()).not.toBe(imported)
})
})