371 lines
15 KiB
TypeScript
371 lines
15 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { promises as fs } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join } from 'node:path'
|
|
import {
|
|
artifactPathKey,
|
|
classifyArtifact,
|
|
extractArtifactCandidates,
|
|
indexArtifactSources,
|
|
type ArtifactSource
|
|
} from '../src/shared/artifact-index'
|
|
import type { LibraryArtifactEntry } from '../src/shared/library'
|
|
import type { ChatMessage, SessionMeta, ToolCallRecord } from '../src/shared/types'
|
|
import {
|
|
filterLibraryEntries,
|
|
formatLibraryBytes,
|
|
groupLibraryEntries,
|
|
listLibraryWorkspaces,
|
|
sortLibraryEntries
|
|
} from '../src/renderer/src/library/catalog'
|
|
import {
|
|
FileArtifactIndexRepository,
|
|
probeArtifactPath
|
|
} from '../src/main/library/artifact-repository'
|
|
import { ArtifactIndexService } from '../src/main/library/artifact-service'
|
|
import { LibraryService } from '../src/main/library/service'
|
|
import { JsonLibraryUserStateRepository } from '../src/main/library/user-state'
|
|
import { collectWorkbenchArtifacts } from '../src/renderer/src/workbuddy/workbench/workbench-state'
|
|
|
|
function tool(
|
|
id: string,
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
status: ToolCallRecord['status'] = 'ok'
|
|
): ToolCallRecord {
|
|
return { id, name, args, argsRaw: JSON.stringify(args), status }
|
|
}
|
|
|
|
function message(
|
|
id: string,
|
|
createdAt: number,
|
|
input: Partial<Pick<ChatMessage, 'role' | 'parts' | 'tools' | 'attachments' | 'resources'>> = {}
|
|
): ChatMessage {
|
|
return {
|
|
id,
|
|
role: 'assistant',
|
|
content: '',
|
|
createdAt,
|
|
...input
|
|
}
|
|
}
|
|
|
|
async function writeFixture(path: string, content: string): Promise<void> {
|
|
await fs.mkdir(dirname(path), { recursive: true })
|
|
await fs.writeFile(path, content, 'utf8')
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
const root = await fs.mkdtemp(join(tmpdir(), 'ycode-library-regression-'))
|
|
let outsideRoot = ''
|
|
try {
|
|
outsideRoot = await fs.mkdtemp(join(tmpdir(), 'ycode-library-outside-'))
|
|
const reportPath = join(root, 'report.md')
|
|
const tablePath = join(root, 'charts', 'table.csv')
|
|
const resultPath = join(root, 'notes', 'result.md')
|
|
const nativeWritePath = join(root, 'native', 'native-write.md')
|
|
const missingPath = join(root, 'missing.md')
|
|
await writeFixture(reportPath, '# Updated report\n')
|
|
await writeFixture(tablePath, 'name,value\na,1\n')
|
|
await writeFixture(resultPath, 'written result')
|
|
await writeFixture(nativeWritePath, 'native OMP write')
|
|
|
|
const messagesA: ChatMessage[] = [
|
|
message('user-presentation', 500, {
|
|
role: 'user',
|
|
parts: [{
|
|
type: 'artifact',
|
|
artifact: { id: 'user-artifact', name: '用户展示块', path: 'user-visible.md' }
|
|
}]
|
|
}),
|
|
message('m1', 1_000, {
|
|
parts: [{
|
|
type: 'artifact',
|
|
artifact: { id: 'report-artifact', name: '旧报告', path: 'report.md' }
|
|
}]
|
|
}),
|
|
message('m2', 2_000, {
|
|
tools: [
|
|
tool('present-ok', 'present_files', {
|
|
files: [{ path: 'charts/table.csv' }, { path: 'missing.md' }]
|
|
}),
|
|
tool('write-ok', 'write_to_file', {
|
|
file_path: 'notes/result.md',
|
|
content: 'written result'
|
|
}),
|
|
tool('read-ok', 'read_file', { path: 'notes/result.md' }),
|
|
tool('write-failed', 'write_to_file', {
|
|
file_path: 'failed.md',
|
|
content: 'must not index'
|
|
}, 'error')
|
|
],
|
|
attachments: [{
|
|
id: 'attachment',
|
|
name: 'attachment.md',
|
|
path: 'attachment.md',
|
|
mimeType: 'text/markdown',
|
|
size: 12
|
|
}],
|
|
resources: ['attachment.md', 'notes/result.md']
|
|
}),
|
|
message('m3', 3_000, {
|
|
tools: [
|
|
tool('write-newer', 'write_to_file', {
|
|
file_path: 'report.md',
|
|
content: '# Updated report\n'
|
|
})
|
|
]
|
|
})
|
|
]
|
|
|
|
const messagesB: ChatMessage[] = [
|
|
message('m4', 2_500, {
|
|
parts: [{
|
|
type: 'artifact',
|
|
artifact: { id: 'report-b', name: '任务 B 报告', path: 'report.md' }
|
|
}]
|
|
})
|
|
]
|
|
|
|
const sessionA: ArtifactSource = {
|
|
id: 'session-a',
|
|
title: '任务 A',
|
|
createdAt: 100,
|
|
updatedAt: 3_000,
|
|
workspaceId: 'workspace-a',
|
|
projectId: 'project-a',
|
|
cwd: root,
|
|
messages: messagesA
|
|
}
|
|
const sessionB: ArtifactSource = {
|
|
id: 'session-b',
|
|
title: '任务 B',
|
|
createdAt: 200,
|
|
updatedAt: 2_500,
|
|
workspaceId: 'workspace-a',
|
|
cwd: root,
|
|
messages: messagesB
|
|
}
|
|
|
|
const candidatesA = extractArtifactCandidates(messagesA, {
|
|
sessionId: sessionA.id,
|
|
cwd: root,
|
|
workspaceId: sessionA.workspaceId,
|
|
projectId: sessionA.projectId,
|
|
sourceSessionTitle: sessionA.title
|
|
})
|
|
assert.equal(candidatesA.length, 4, '只有显式 artifact、成功 present_files 和成功 write 应被提取')
|
|
const candidateReport = candidatesA.find((item) => artifactPathKey(item.path) === artifactPathKey(reportPath))
|
|
assert.equal(candidateReport?.sourceKind, 'write', '同任务同路径采用最新来源')
|
|
assert.equal(candidateReport?.messageId, 'm3')
|
|
assert.equal(candidateReport?.workspaceId, 'workspace-a')
|
|
assert.equal(candidateReport?.projectId, 'project-a')
|
|
assert.equal(candidatesA.some((item) => item.path.endsWith('failed.md')), false)
|
|
assert.equal(candidatesA.some((item) => item.path.endsWith('attachment.md')), false)
|
|
assert.equal(candidatesA.some((item) => item.path.endsWith('user-visible.md')), false)
|
|
const workbench = collectWorkbenchArtifacts(messagesA, root)
|
|
assert.equal(workbench.artifacts.length, 2, '工作台非改动产物应复用同一白名单')
|
|
assert.equal(workbench.changes.length, 2, '工作台写编辑产物应复用同一白名单')
|
|
|
|
const entries = await indexArtifactSources([sessionA, sessionB], probeArtifactPath)
|
|
assert.equal(entries.length, 5, '任务 A 四个产物与任务 B 同路径产物必须分开保留')
|
|
const reportEntries = entries.filter((entry) => artifactPathKey(entry.path) === artifactPathKey(reportPath))
|
|
assert.equal(reportEntries.length, 2, '跨任务同路径不能合并')
|
|
assert.deepEqual(new Set(reportEntries.map((entry) => entry.sessionId)), new Set(['session-a', 'session-b']))
|
|
assert.notEqual(reportEntries[0].id, reportEntries[1].id)
|
|
const reportA = entries.find((entry) => entry.sessionId === 'session-a'
|
|
&& artifactPathKey(entry.path) === artifactPathKey(reportPath))
|
|
assert.equal(reportA?.availability, 'available')
|
|
assert.equal(reportA?.size, Buffer.byteLength('# Updated report\n'))
|
|
assert.equal(reportA?.sourceSessionTitle, '任务 A')
|
|
assert.equal(reportA?.workspaceRoot, root)
|
|
const missing = entries.find((entry) => artifactPathKey(entry.path) === artifactPathKey(missingPath))
|
|
assert.equal(missing?.availability, 'unavailable', '无法访问文件必须保留索引并标记不可用')
|
|
assert.equal(classifyArtifact('table.csv'), 'spreadsheet')
|
|
assert.equal(classifyArtifact('README.md'), 'document')
|
|
assert.equal(classifyArtifact('app.ts'), 'code')
|
|
|
|
const nativeWriteMessages = [message('native-write-message', 3_500, {
|
|
tools: [tool('native-write', 'write', {
|
|
path: 'native/native-write.md',
|
|
content: 'native OMP write'
|
|
})]
|
|
})]
|
|
const nativeCandidates = extractArtifactCandidates(nativeWriteMessages, {
|
|
sessionId: 'native-write-session',
|
|
cwd: root,
|
|
sourceSessionTitle: '原生写入任务'
|
|
})
|
|
assert.equal(nativeCandidates.length, 1, '小写 OMP write 应经 Write 别名规范化后提取')
|
|
assert.equal(nativeCandidates[0].sourceKind, 'write')
|
|
assert.equal(nativeCandidates[0].sourceToolCallId, 'native-write')
|
|
assert.equal(artifactPathKey(nativeCandidates[0].path), artifactPathKey(nativeWritePath))
|
|
const nativeEntries = await indexArtifactSources([{
|
|
id: 'native-write-session',
|
|
title: '原生写入任务',
|
|
createdAt: 350,
|
|
updatedAt: 3_500,
|
|
cwd: root,
|
|
messages: nativeWriteMessages
|
|
}], probeArtifactPath)
|
|
assert.equal(nativeEntries.length, 1)
|
|
assert.equal(nativeEntries[0].availability, 'available')
|
|
|
|
const directOutsidePath = join(outsideRoot, 'direct.md')
|
|
const linkedOutsidePath = join(outsideRoot, 'linked.md')
|
|
const insideDirectory = join(root, 'safe-inside')
|
|
const insidePath = join(insideDirectory, 'inside.md')
|
|
await writeFixture(directOutsidePath, 'outside')
|
|
await writeFixture(linkedOutsidePath, 'must stay unavailable')
|
|
await writeFixture(insidePath, 'safe')
|
|
const escapeLink = join(root, 'escape-link')
|
|
const safeLink = join(root, 'safe-link')
|
|
await fs.symlink(outsideRoot, escapeLink, process.platform === 'win32' ? 'junction' : 'dir')
|
|
await fs.symlink(insideDirectory, safeLink, process.platform === 'win32' ? 'junction' : 'dir')
|
|
const unreferencedDirectory = join(root, 'large-unreferenced-tree')
|
|
await fs.mkdir(unreferencedDirectory, { recursive: true })
|
|
await Promise.all(Array.from({ length: 64 }, (_, index) =>
|
|
fs.writeFile(join(unreferencedDirectory, `source-${index}.ts`), `export const value = ${index}\n`, 'utf8')
|
|
))
|
|
|
|
const securitySession: ArtifactSource = {
|
|
id: 'security-session',
|
|
title: '边界检查任务',
|
|
createdAt: 400,
|
|
updatedAt: 4_000,
|
|
workspaceId: 'workspace-a',
|
|
cwd: root,
|
|
messages: [message('security-message', 4_000, {
|
|
tools: [tool('security-present', 'present_files', {
|
|
files: [
|
|
{ path: directOutsidePath },
|
|
{ path: join(escapeLink, 'linked.md') },
|
|
{ path: join(safeLink, 'inside.md') }
|
|
]
|
|
})]
|
|
})]
|
|
}
|
|
let securityProbeCalls = 0
|
|
const securityEntries = await indexArtifactSources([securitySession], async (artifactPath, context) => {
|
|
securityProbeCalls += 1
|
|
return probeArtifactPath(artifactPath, context)
|
|
})
|
|
assert.equal(securityEntries.length, 3, '大目录中未引用文件不能被扫描为产物')
|
|
assert.equal(securityProbeCalls, 3, '每个索引条目只探测自身路径')
|
|
assert.equal(
|
|
securityEntries.find((entry) => entry.path === directOutsidePath)?.availability,
|
|
'unavailable',
|
|
'来源 cwd 之外的显式路径必须不可用'
|
|
)
|
|
assert.equal(
|
|
securityEntries.find((entry) => entry.path === join(escapeLink, 'linked.md'))?.availability,
|
|
'unavailable',
|
|
'指向 cwd 之外的目录链接必须不可用'
|
|
)
|
|
assert.equal(
|
|
securityEntries.find((entry) => entry.path === join(safeLink, 'inside.md'))?.availability,
|
|
'available',
|
|
'仍位于来源 cwd realpath 内的链接应可用'
|
|
)
|
|
|
|
const userStatePath = join(root, 'state', 'library-state.json')
|
|
const userState = new JsonLibraryUserStateRepository(userStatePath)
|
|
const repository = new FileArtifactIndexRepository(probeArtifactPath)
|
|
const artifactIndex = new ArtifactIndexService({
|
|
repository,
|
|
listSourceSessions: async () => [sessionA, sessionB] satisfies SessionMeta[],
|
|
getMessages: async (sessionId) => sessionId === sessionA.id ? messagesA : messagesB
|
|
})
|
|
const library = new LibraryService({ artifactIndex, userState, now: () => 9_000 })
|
|
const snapshot = await library.getSnapshot()
|
|
assert.equal(snapshot.generatedAt, 9_000)
|
|
const snapshotReportA = snapshot.artifacts.find((entry) => entry.id === reportA?.id)
|
|
assert.ok(snapshotReportA)
|
|
await library.setFavorite(snapshotReportA.id, true)
|
|
await library.recordRecent(snapshotReportA.id, 9_100)
|
|
await assert.rejects(() => library.setFavorite('missing-artifact', true), /产物不存在/)
|
|
const updated = await library.getSnapshot()
|
|
const updatedReportA = updated.artifacts.find((entry) => entry.id === snapshotReportA.id)
|
|
assert.equal(updatedReportA?.favorite, true)
|
|
assert.equal(updatedReportA?.recentAt, 9_100)
|
|
|
|
const reloadedState = new JsonLibraryUserStateRepository(userStatePath)
|
|
const persisted = await reloadedState.get()
|
|
assert.deepEqual(persisted.favoriteArtifactIds, [snapshotReportA.id])
|
|
assert.deepEqual(persisted.recent, [{ artifactId: snapshotReportA.id, openedAt: 9_100 }])
|
|
assert.equal(
|
|
(await fs.readdir(dirname(userStatePath))).some((name) => name.endsWith('.tmp')),
|
|
false,
|
|
'原子写入不得遗留临时文件'
|
|
)
|
|
|
|
const catalogEntries: LibraryArtifactEntry[] = updated.artifacts.map((entry) => ({
|
|
...entry,
|
|
favorite: entry.id === snapshotReportA.id,
|
|
...(entry.id === snapshotReportA.id ? { recentAt: 9_100 } : {})
|
|
}))
|
|
const allFilters = { query: '', kind: 'all' as const, workspaceKey: 'all', favoritesOnly: false }
|
|
assert.equal(filterLibraryEntries(catalogEntries, 'recent', allFilters).length, 1)
|
|
assert.equal(filterLibraryEntries(catalogEntries, 'local', {
|
|
...allFilters,
|
|
query: '任务 B'
|
|
}).length, 1)
|
|
assert.equal(filterLibraryEntries(catalogEntries, 'local', {
|
|
...allFilters,
|
|
kind: 'spreadsheet'
|
|
}).length, 1)
|
|
assert.equal(filterLibraryEntries(catalogEntries, 'local', {
|
|
...allFilters,
|
|
favoritesOnly: true
|
|
}).length, 1)
|
|
const workspaceOptions = listLibraryWorkspaces(catalogEntries)
|
|
assert.equal(workspaceOptions.length, 1)
|
|
assert.equal(filterLibraryEntries(catalogEntries, 'local', {
|
|
...allFilters,
|
|
workspaceKey: workspaceOptions[0].key
|
|
}).length, 5)
|
|
const groups = groupLibraryEntries(catalogEntries)
|
|
assert.equal(groups.length, 1)
|
|
assert.equal(groups[0].tasks.length, 2, '同一空间内必须按任务分组')
|
|
assert.equal(groups[0].label, root.split(/[\\/]/).pop())
|
|
const sortedByName = sortLibraryEntries(catalogEntries, { key: 'name', direction: 'asc' }, 'local')
|
|
assert.ok(sortedByName[0].name.localeCompare(sortedByName.at(-1)!.name, 'zh-CN') <= 0)
|
|
assert.equal(formatLibraryBytes(1536), '1.5 KB')
|
|
assert.equal(formatLibraryBytes(1024), '1 KB')
|
|
assert.equal(formatLibraryBytes(undefined), '—')
|
|
|
|
const corruptPath = join(root, 'state', 'corrupt.json')
|
|
await fs.writeFile(corruptPath, '{not-json', 'utf8')
|
|
const corrupt = new JsonLibraryUserStateRepository(corruptPath)
|
|
await assert.rejects(() => corrupt.get(), /资料库收藏数据损坏|JSON|Unexpected|property name/i)
|
|
|
|
console.log('library-regression: ok')
|
|
console.log(JSON.stringify({
|
|
indexedEntries: entries.length,
|
|
crossTaskSamePathEntries: reportEntries.length,
|
|
unavailableEntries: entries.filter((entry) => entry.availability === 'unavailable').length,
|
|
workspaceGroups: groups.length,
|
|
taskGroups: groups[0].tasks.length,
|
|
persistedRecent: persisted.recent.length,
|
|
persistedFavorites: persisted.favoriteArtifactIds.length,
|
|
nativeWriteEntries: nativeEntries.length,
|
|
nativeWriteAvailability: nativeEntries[0]?.availability,
|
|
securityEntries: securityEntries.length,
|
|
securityProbeCalls,
|
|
crossRootUnavailable: securityEntries.find((entry) => entry.path === directOutsidePath)?.availability,
|
|
symlinkEscapeUnavailable: securityEntries.find((entry) => entry.path === join(escapeLink, 'linked.md'))?.availability,
|
|
safeSymlinkAvailable: securityEntries.find((entry) => entry.path === join(safeLink, 'inside.md'))?.availability
|
|
}, null, 2))
|
|
} finally {
|
|
await Promise.all([
|
|
fs.rm(root, { recursive: true, force: true }),
|
|
outsideRoot ? fs.rm(outsideRoot, { recursive: true, force: true }) : Promise.resolve()
|
|
])
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error)
|
|
process.exitCode = 1
|
|
})
|