Merge remote-tracking branch 'origin/master' into feat/read-image-context
# Conflicts: # docs/config-catalog.i18n.yaml # docs/config-catalog.md # docs/config-catalog.zh.md # packages/support/llm-replay/src/index.ts # packages/support/llm-replay/tests/llm-replay.spec.ts
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { constants as bufferConstants } from 'node:buffer'
|
||||
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -43,13 +44,39 @@ async function versionOf(target: FsTarget): Promise<FsVersion> {
|
||||
return info.version
|
||||
}
|
||||
|
||||
async function remountWithDiffLimit(diffBasisMaxBytes: number): Promise<void> {
|
||||
await fiber.dispose()
|
||||
fiber = await ctx.plugin(LocalFileSystem, { cwd: dir, diffBasisMaxBytes })
|
||||
fs = ctx.fs as LocalFileSystem
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers LocalFileSystem as ctx.fs with a default cwd', async () => {
|
||||
const bare = new Context()
|
||||
const bareFiber = await bare.plugin(LocalFileSystem)
|
||||
expect((bare.fs as LocalFileSystem).config.cwd).toBe(process.cwd())
|
||||
expect((bare.fs as LocalFileSystem).config.diffBasisMaxBytes).toBe(10 * 1024 * 1024)
|
||||
await bareFiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects non-positive, fractional, unsafe, or unallocatable diff-basis limits', async () => {
|
||||
const maxDiffBasisBytes = Math.min(
|
||||
bufferConstants.MAX_LENGTH,
|
||||
bufferConstants.MAX_STRING_LENGTH,
|
||||
)
|
||||
const valid = new Context()
|
||||
const validFiber = await valid.plugin(LocalFileSystem, { diffBasisMaxBytes: maxDiffBasisBytes })
|
||||
expect((valid.fs as LocalFileSystem).config.diffBasisMaxBytes).toBe(maxDiffBasisBytes)
|
||||
await validFiber.dispose()
|
||||
|
||||
for (const diffBasisMaxBytes of [0, -1, 1.5, maxDiffBasisBytes + 1, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
const invalid = new Context()
|
||||
await expect(invalid.plugin(LocalFileSystem, { diffBasisMaxBytes })).rejects.toThrow(
|
||||
`fs-local: diffBasisMaxBytes must be a positive safe integer no greater than ${maxDiffBasisBytes}`,
|
||||
)
|
||||
await invalid.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve', () => {
|
||||
@@ -481,6 +508,54 @@ describe('writeText', () => {
|
||||
expect(outcome.after).toBe('now valid')
|
||||
})
|
||||
|
||||
it('an overwrite of a prior file AT the whole-file bound reports before:null (undiffable), still succeeds', async () => {
|
||||
// The configured bound keeps the fixture small; 8 bytes at a bound of 8
|
||||
// pins the exclusive edge without coupling this provider to a read tool.
|
||||
await remountWithDiffLimit(8)
|
||||
await writeFile(join(dir, 'big.txt'), '12345678')
|
||||
const target = await fs.resolve('big.txt')
|
||||
const outcome = await fs.writeText(target, 'tiny')
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(outcome.before).toBeNull()
|
||||
expect(outcome.after).toBe('tiny')
|
||||
})
|
||||
|
||||
it('an overwrite whose NEW content is at the whole-file bound reports before:null (no huge contextual diff)', async () => {
|
||||
// The bound gates BOTH sides of the diff pair: a small prior file rewritten
|
||||
// with at/above-bound content yields no contextual-hunk basis either, since
|
||||
// a small-to-huge rewrite's hunk is as large as the new content — the
|
||||
// consumer must fall back to the whole-file diff card, exactly like a
|
||||
// create of the same size.
|
||||
await remountWithDiffLimit(8)
|
||||
await writeFile(join(dir, 'grow.txt'), 'tiny')
|
||||
const target = await fs.resolve('grow.txt')
|
||||
const outcome = await fs.writeText(target, '12345678')
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(outcome.before).toBeNull()
|
||||
expect(outcome.after).toBe('12345678')
|
||||
})
|
||||
|
||||
it('gates the NEW content by UTF-8 byte length, not character count', async () => {
|
||||
// Three CJK characters are 9 UTF-8 bytes: below an 8-byte bound by
|
||||
// characters but at/above it by bytes, so the basis must be declined.
|
||||
await remountWithDiffLimit(8)
|
||||
await writeFile(join(dir, 'cjk.txt'), 'tiny')
|
||||
const target = await fs.resolve('cjk.txt')
|
||||
const outcome = await fs.writeText(target, '你好吗')
|
||||
expect(outcome.operation).toBe('update')
|
||||
expect(outcome.before).toBeNull()
|
||||
expect(outcome.after).toBe('你好吗')
|
||||
})
|
||||
|
||||
it('an overwrite with BOTH sides below the whole-file bound keeps its contextual before basis', async () => {
|
||||
await remountWithDiffLimit(8)
|
||||
await writeFile(join(dir, 'small.txt'), '1234567')
|
||||
const target = await fs.resolve('small.txt')
|
||||
const outcome = await fs.writeText(target, 'new')
|
||||
expect(outcome.before).toBe('1234567')
|
||||
expect(outcome.after).toBe('new')
|
||||
})
|
||||
|
||||
it('releases per-target mutation locks after success and failure', async () => {
|
||||
const target = await fs.resolve('a.txt')
|
||||
await fs.writeText(target, 'created', { kind: 'createIfAbsent' })
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* policy and lives in `dsh-fs-policy`, so it is not tested here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
probe,
|
||||
probeNoFollow,
|
||||
readForEdit,
|
||||
readTextForDiff,
|
||||
readWholeText,
|
||||
resolveLocalTarget,
|
||||
restoreLineEndings,
|
||||
@@ -316,6 +317,261 @@ describe('readWholeText', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readTextForDiff', () => {
|
||||
it('returns normalized text only when the opened file is strictly below the limit', async () => {
|
||||
const file = join(dir, 'basis.txt')
|
||||
await writeFile(file, 'a\r\nb')
|
||||
expect(await readTextForDiff(file, 5)).toBe('a\nb')
|
||||
expect(await readTextForDiff(file, 4)).toBeNull()
|
||||
})
|
||||
|
||||
it('bounds the actual opened file rather than trusting an earlier path size', async () => {
|
||||
const file = join(dir, 'replaced.txt')
|
||||
await writeFile(file, 'tiny')
|
||||
const earlierSize = (await stat(file)).size
|
||||
await writeFile(file, '123456789')
|
||||
expect(earlierSize).toBeLessThan(8)
|
||||
expect(await readTextForDiff(file, 8)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the opened file shrinks after descriptor stat', async () => {
|
||||
const file = join(dir, 'shrinking.txt')
|
||||
await writeFile(file, 'abcdef')
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async open(...args: Parameters<typeof actual.open>) {
|
||||
const handle = await actual.open(...args)
|
||||
return {
|
||||
close: handle.close.bind(handle),
|
||||
read: handle.read.bind(handle),
|
||||
async stat(...statArgs: Parameters<typeof handle.stat>) {
|
||||
const info = await handle.stat(...statArgs)
|
||||
await writeFile(file, 'abc')
|
||||
return info
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
|
||||
expect(await isolatedReadTextForDiff(file, 8)).toBeNull()
|
||||
} finally {
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null when the opened file grows after descriptor stat', async () => {
|
||||
const file = join(dir, 'growing.txt')
|
||||
await writeFile(file, 'abcdef')
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async open(...args: Parameters<typeof actual.open>) {
|
||||
const handle = await actual.open(...args)
|
||||
return {
|
||||
close: handle.close.bind(handle),
|
||||
read: handle.read.bind(handle),
|
||||
async stat(...statArgs: Parameters<typeof handle.stat>) {
|
||||
const info = await handle.stat(...statArgs)
|
||||
await writeFile(file, 'abcdef-grown')
|
||||
return info
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
|
||||
expect(await isolatedReadTextForDiff(file, 32)).toBeNull()
|
||||
} finally {
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null when the file vanishes before the basis open (deletion race)', async () => {
|
||||
expect(await readTextForDiff(join(dir, 'deleted-after-preflight.txt'), 32)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the opened descriptor is no longer a regular file', async () => {
|
||||
const file = join(dir, 'swapped.txt')
|
||||
await writeFile(file, 'abcdef')
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async open(...args: Parameters<typeof actual.open>) {
|
||||
const handle = await actual.open(...args)
|
||||
return {
|
||||
close: handle.close.bind(handle),
|
||||
read: handle.read.bind(handle),
|
||||
async stat(...statArgs: Parameters<typeof handle.stat>) {
|
||||
const info = await handle.stat(...statArgs)
|
||||
return Object.assign(info, { isFile: () => false })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
|
||||
expect(await isolatedReadTextForDiff(file, 32)).toBeNull()
|
||||
} finally {
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates a non-errno fault instead of masking it as a null basis', async () => {
|
||||
const file = join(dir, 'faulted.txt')
|
||||
await writeFile(file, 'abcdef')
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async open() {
|
||||
throw new TypeError('forged programming fault')
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
|
||||
await expect(isolatedReadTextForDiff(file, 32)).rejects.toThrow('forged programming fault')
|
||||
} finally {
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for binary and invalid UTF-8 without blocking the caller write', async () => {
|
||||
await writeFile(join(dir, 'bin'), Buffer.from([0x68, 0x00, 0x69]))
|
||||
await writeFile(join(dir, 'bad'), Buffer.from([0x68, 0xff, 0x69]))
|
||||
expect(await readTextForDiff(join(dir, 'bin'), 8)).toBeNull()
|
||||
expect(await readTextForDiff(join(dir, 'bad'), 8)).toBeNull()
|
||||
})
|
||||
|
||||
it('honors a pre-aborted signal', async () => {
|
||||
const file = join(dir, 'basis.txt')
|
||||
await writeFile(file, 'text')
|
||||
await expect(readTextForDiff(file, 8, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it.each(['open', 'stat'] as const)('observes cancellation immediately after %s', async (stage) => {
|
||||
const file = join(dir, 'basis.txt')
|
||||
await writeFile(file, 'text')
|
||||
const reached = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
let statCalls = 0
|
||||
const allocate = vi.spyOn(Buffer, 'allocUnsafe')
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async open(...args: Parameters<typeof actual.open>) {
|
||||
const handle = await actual.open(...args)
|
||||
if (stage === 'open') {
|
||||
reached.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
return {
|
||||
close: handle.close.bind(handle),
|
||||
read: handle.read.bind(handle),
|
||||
async stat(...statArgs: Parameters<typeof handle.stat>) {
|
||||
statCalls += 1
|
||||
const info = await handle.stat(...statArgs)
|
||||
if (stage === 'stat') {
|
||||
reached.resolve(undefined)
|
||||
await release.promise
|
||||
}
|
||||
return info
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
|
||||
const controller = new AbortController()
|
||||
const pending = isolatedReadTextForDiff(file, 8, controller.signal)
|
||||
await reached.promise
|
||||
const allocationCalls = allocate.mock.calls.length
|
||||
controller.abort()
|
||||
release.resolve(undefined)
|
||||
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(statCalls).toBe(stage === 'open' ? 0 : 1)
|
||||
expect(allocate).toHaveBeenCalledTimes(allocationCalls)
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
allocate.mockRestore()
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds descriptor reads and observes cancellation before the next chunk', async () => {
|
||||
const file = join(dir, 'large-basis.txt')
|
||||
const fileBytes = 200 * 1024
|
||||
await writeFile(file, 'x'.repeat(fileBytes))
|
||||
const firstRead = Promise.withResolvers<undefined>()
|
||||
const releaseFirstRead = Promise.withResolvers<undefined>()
|
||||
const readLengths: number[] = []
|
||||
vi.resetModules()
|
||||
vi.doMock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return {
|
||||
...actual,
|
||||
async open(...args: Parameters<typeof actual.open>) {
|
||||
const handle = await actual.open(...args)
|
||||
return {
|
||||
stat: handle.stat.bind(handle),
|
||||
close: handle.close.bind(handle),
|
||||
async read(buffer: Buffer, offset: number, length: number, position: number | null) {
|
||||
readLengths.push(length)
|
||||
const result = await handle.read(buffer, offset, length, position)
|
||||
if (readLengths.length === 1) {
|
||||
firstRead.resolve(undefined)
|
||||
await releaseFirstRead.promise
|
||||
}
|
||||
return result
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const { readTextForDiff: isolatedReadTextForDiff } = await import('../src/fsio.ts')
|
||||
const controller = new AbortController()
|
||||
const pending = isolatedReadTextForDiff(file, fileBytes + 1, controller.signal)
|
||||
await firstRead.promise
|
||||
expect(readLengths).toEqual([64 * 1024])
|
||||
controller.abort()
|
||||
releaseFirstRead.resolve(undefined)
|
||||
await expect(pending).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
expect(readLengths).toHaveLength(1)
|
||||
} finally {
|
||||
releaseFirstRead.resolve(undefined)
|
||||
vi.doUnmock('node:fs/promises')
|
||||
vi.resetModules()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('streamWholeText', () => {
|
||||
it('streams the whole file as decoded text', async () => {
|
||||
const file = join(dir, 'a.txt')
|
||||
|
||||
Reference in New Issue
Block a user