test(workspace-context): make the merged suite's fixtures cross-platform

Path expectations derive from join() per the cross-platform fixtures
convention; the fake fs resolves against the host root so drive-letter
targets match seeded keys; the unreadable-candidate cases use the provider
throwOnRead fixture (host chmod 0 is a no-op for the owner on Windows),
keeping the read-failure branch covered there, with a narrow win32 skip only
for the host-chmod discovery case.
This commit is contained in:
Turtle
2026-07-22 15:54:01 +08:00
parent 06827eb868
commit ab32d3ec98

View File

@@ -58,6 +58,7 @@ async function write(path: string, content: string): Promise<void> {
class RecordingFileSystem extends FileSystem { class RecordingFileSystem extends FileSystem {
entries = new Map<string, { type: FsInfo['type']; content?: string; version?: FsVersion }>() entries = new Map<string, { type: FsInfo['type']; content?: string; version?: FsVersion }>()
throwOnStat = new Set<string>() throwOnStat = new Set<string>()
throwOnRead = new Set<string>()
omitSizes = new Set<string>() omitSizes = new Set<string>()
readTargets: string[] = [] readTargets: string[] = []
readTextTargets: string[] = [] readTextTargets: string[] = []
@@ -66,7 +67,9 @@ class RecordingFileSystem extends FileSystem {
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> { override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
if (opts?.signal !== undefined) this.signals.push(opts.signal) if (opts?.signal !== undefined) this.signals.push(opts.signal)
opts?.signal?.throwIfAborted() opts?.signal?.throwIfAborted()
const absolute = join(opts?.cwd ?? '/', path) // resolve(), not join(): entries are seeded with host join() keys, and on
// Windows a joined '/'-rooted prefix would not match a resolved drive path.
const absolute = resolve(opts?.cwd ?? '/', path)
return { targetKey: FsTargetKey(absolute), displayPath: absolute } return { targetKey: FsTargetKey(absolute), displayPath: absolute }
} }
@@ -108,6 +111,7 @@ class RecordingFileSystem extends FileSystem {
if (signal !== undefined) this.signals.push(signal) if (signal !== undefined) this.signals.push(signal)
signal?.throwIfAborted() signal?.throwIfAborted()
this.readTargets.push(target.targetKey) this.readTargets.push(target.targetKey)
if (this.throwOnRead.has(target.targetKey)) throw new Error(`read failed: ${target.displayPath}`)
const content = this.entries.get(target.targetKey)?.content ?? '' const content = this.entries.get(target.targetKey)?.content ?? ''
return (async function* () { return (async function* () {
const midpoint = Math.ceil(content.length / 2) const midpoint = Math.ceil(content.length / 2)
@@ -383,7 +387,8 @@ describe('workspace context instruction discovery', () => {
} }
}) })
it('skips a file that becomes unreadable after discovery without failing the request', async () => { // POSIX-only fixture: chmod 0 cannot make a file unreadable to its owner on Windows.
it.skipIf(process.platform === 'win32')('skips a file that becomes unreadable after discovery without failing the request', async () => {
const root = await tempRepo() const root = await tempRepo()
const home = await tempRepo() const home = await tempRepo()
try { try {
@@ -1818,7 +1823,7 @@ describe('dynamic nested workspace context injection', () => {
const text = blocksText(workspaceContextOf(result)?.content) const text = blocksText(workspaceContextOf(result)?.content)
expect(text).toBe([ expect(text).toBe([
'<system-reminder>', '<system-reminder>',
'Additional instructions from: pkg/AGENTS.md', `Additional instructions from: ${join('pkg', 'AGENTS.md')}`,
'', '',
'These instructions apply to work under `pkg`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.', 'These instructions apply to work under `pkg`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.',
'', '',
@@ -2115,7 +2120,7 @@ describe('dynamic nested workspace context injection', () => {
}) })
expect(blocksText(workspaceContextOf(changed)?.content)).toBe([ expect(blocksText(workspaceContextOf(changed)?.content)).toBe([
'<system-reminder>', '<system-reminder>',
'Updated instructions from: pkg/AGENTS.md', `Updated instructions from: ${join('pkg', 'AGENTS.md')}`,
'', '',
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.', 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
'', '',
@@ -2300,7 +2305,7 @@ describe('dynamic nested workspace context injection', () => {
}) })
expect(blocksText(workspaceContextOf(removed)?.content)).toBe([ expect(blocksText(workspaceContextOf(removed)?.content)).toBe([
'<system-reminder>', '<system-reminder>',
'Instructions removed: pkg/AGENTS.md', `Instructions removed: ${join('pkg', 'AGENTS.md')}`,
'', '',
'The previously loaded instructions from this file no longer apply.', 'The previously loaded instructions from this file no longer apply.',
'</system-reminder>', '</system-reminder>',
@@ -2755,16 +2760,23 @@ describe('dynamic nested workspace context injection', () => {
}) })
it('skips unreadable nested instruction files without attaching empty context', async () => { it('skips unreadable nested instruction files without attaching empty context', async () => {
// Cross-platform unreadable fixture: the provider read throws (chmod 0
// cannot make a file unreadable to its owner on Windows).
const root = await tempRepo() const root = await tempRepo()
const home = await tempRepo() const home = await tempRepo()
const ctx = new Context()
try { try {
await mkdir(join(root, '.git'), { recursive: true })
const nested = join(root, 'pkg/AGENTS.md') const nested = join(root, 'pkg/AGENTS.md')
await write(nested, 'nested package rule') await ctx.plugin(SystemPrompt)
await write(join(root, 'pkg/deep/file.txt'), 'hello') await ctx.plugin(ToolRegistry)
await chmod(nested, 0) await ctx.plugin(RecordingFileSystem)
const ctx = new Context() const fs = ctx.fs as RecordingFileSystem
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 }) fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(nested, { type: 'file', content: 'nested package rule' })
fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' })
fs.throwOnRead.add(nested)
await ctx.plugin(ToolFs)
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const result = await ctx.tools.execute({ const result = await ctx.tools.execute({
signal: testToolSignal, signal: testToolSignal,
@@ -2776,8 +2788,9 @@ describe('dynamic nested workspace context injection', () => {
expect(result.isError).toBe(false) expect(result.isError).toBe(false)
expect(result.additionalContexts).toBeUndefined() expect(result.additionalContexts).toBeUndefined()
await chmod(nested, 0o600) expect(fs.readTargets).toContain(nested)
} finally { } finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true }) await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true }) await rm(home, { recursive: true, force: true })
} }