refactor(e2b): group remote providers
This commit is contained in:
537
packages/e2b/fs-e2b/tests/filesystem.spec.ts
Normal file
537
packages/e2b/fs-e2b/tests/filesystem.spec.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { dirname, posix } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
CommandExitError,
|
||||
FileNotFoundError,
|
||||
FileType,
|
||||
type EntryInfo,
|
||||
type Sandbox,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
|
||||
import { FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
import E2BFileSystem from '@deepseek-ai/dsh-fs-e2b'
|
||||
import * as E2BFsInvariant from '../src/invariant.ts'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
interface RemoteNode {
|
||||
type: FileType
|
||||
data: Uint8Array
|
||||
mode: number
|
||||
modified: number
|
||||
metadata?: Record<string, string>
|
||||
symlinkTarget?: string
|
||||
}
|
||||
|
||||
function bytes(value: string | readonly number[]): Uint8Array {
|
||||
return typeof value === 'string' ? new TextEncoder().encode(value) : Uint8Array.from(value)
|
||||
}
|
||||
|
||||
function commandError(exitCode: number, stderr = ''): CommandExitError {
|
||||
return new CommandExitError({ exitCode, stdout: '', stderr, error: stderr })
|
||||
}
|
||||
|
||||
class FakeRemote {
|
||||
readonly nodes = new Map<string, RemoteNode>()
|
||||
readonly writes: Array<{ path: string; data: string; metadata?: Record<string, string> }> = []
|
||||
readonly renames: Array<{ from: string; to: string }> = []
|
||||
readonly removals: string[] = []
|
||||
readonly commands: string[] = []
|
||||
streamChunks: Uint8Array[] | undefined
|
||||
nextCommandError: unknown
|
||||
nextInfoError: unknown
|
||||
nextListError: unknown
|
||||
nextReadError: unknown
|
||||
nextRenameError: unknown
|
||||
nextRemoveError: unknown
|
||||
abortAfterRename: AbortController | undefined
|
||||
disappearOnInfo = new Set<string>()
|
||||
private clock = 1
|
||||
|
||||
constructor() {
|
||||
this.dir('/')
|
||||
this.dir('/workspace')
|
||||
}
|
||||
|
||||
dir(path: string): void {
|
||||
this.nodes.set(path, { type: FileType.DIR, data: bytes(''), mode: 0o755, modified: this.clock++ })
|
||||
}
|
||||
|
||||
file(path: string, data: string | readonly number[], mode = 0o644): void {
|
||||
this.nodes.set(path, { type: FileType.FILE, data: bytes(data), mode, modified: this.clock++ })
|
||||
}
|
||||
|
||||
other(path: string): void {
|
||||
this.nodes.set(path, { type: 'other' as FileType, data: bytes(''), mode: 0o600, modified: this.clock++ })
|
||||
}
|
||||
|
||||
symlink(path: string, target: string): void {
|
||||
this.nodes.set(path, {
|
||||
type: FileType.FILE,
|
||||
data: bytes(''),
|
||||
mode: 0o777,
|
||||
modified: this.clock++,
|
||||
symlinkTarget: target,
|
||||
})
|
||||
}
|
||||
|
||||
mutate(path: string, data: string): void {
|
||||
const node = this.required(path)
|
||||
node.data = bytes(data)
|
||||
node.modified = this.clock++
|
||||
}
|
||||
|
||||
private required(path: string): RemoteNode {
|
||||
const node = this.nodes.get(path)
|
||||
if (node === undefined) throw new FileNotFoundError(`missing: ${path}`)
|
||||
return node
|
||||
}
|
||||
|
||||
private followed(path: string): { path: string; node: RemoteNode; link?: RemoteNode } {
|
||||
const node = this.required(path)
|
||||
if (node.symlinkTarget === undefined) return { path, node }
|
||||
return { path: node.symlinkTarget, node: this.required(node.symlinkTarget), link: node }
|
||||
}
|
||||
|
||||
private info(path: string): EntryInfo {
|
||||
if (this.disappearOnInfo.delete(path)) throw new FileNotFoundError(`missing: ${path}`)
|
||||
return this.rawInfo(path)
|
||||
}
|
||||
|
||||
private rawInfo(path: string): EntryInfo {
|
||||
const followed = this.followed(path)
|
||||
const node = followed.node
|
||||
return {
|
||||
name: posix.basename(path),
|
||||
path,
|
||||
type: node.type,
|
||||
size: node.data.byteLength,
|
||||
mode: node.mode,
|
||||
permissions: 'rw-------',
|
||||
owner: 'user',
|
||||
group: 'user',
|
||||
modifiedTime: new Date(node.modified),
|
||||
...(node.metadata !== undefined ? { metadata: { ...node.metadata } } : {}),
|
||||
...(followed.link?.symlinkTarget !== undefined ? { symlinkTarget: followed.link.symlinkTarget } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
private checkAbort(options: { signal?: AbortSignal } | undefined): void {
|
||||
if (options?.signal?.aborted === true) throw new DOMException('aborted', 'AbortError')
|
||||
}
|
||||
|
||||
readonly sandbox = {
|
||||
sandboxId: 'fake',
|
||||
files: {
|
||||
makeDir: async (path: string): Promise<boolean> => {
|
||||
if (this.nodes.has(path)) return false
|
||||
this.dir(path)
|
||||
return true
|
||||
},
|
||||
getInfo: async (path: string, options?: { signal?: AbortSignal }): Promise<EntryInfo> => {
|
||||
this.checkAbort(options)
|
||||
if (this.nextInfoError !== undefined) {
|
||||
const error = this.nextInfoError
|
||||
this.nextInfoError = undefined
|
||||
throw error
|
||||
}
|
||||
return this.info(path)
|
||||
},
|
||||
read: async (path: string, options: { format: 'bytes' | 'stream'; signal?: AbortSignal }): Promise<Uint8Array | ReadableStream<Uint8Array>> => {
|
||||
this.checkAbort(options)
|
||||
if (this.nextReadError !== undefined) {
|
||||
const error = this.nextReadError
|
||||
this.nextReadError = undefined
|
||||
throw error
|
||||
}
|
||||
const data = this.followed(path).node.data
|
||||
if (options.format === 'bytes') return data.slice()
|
||||
const chunks = this.streamChunks ?? [data.slice()]
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(chunk)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
},
|
||||
list: async (path: string, options?: { depth?: number; signal?: AbortSignal }): Promise<EntryInfo[]> => {
|
||||
this.checkAbort(options)
|
||||
if (this.nextListError !== undefined) {
|
||||
const error = this.nextListError
|
||||
this.nextListError = undefined
|
||||
throw error
|
||||
}
|
||||
this.required(path)
|
||||
return [...this.nodes.keys()]
|
||||
.filter(candidate => candidate !== path && dirname(candidate) === path)
|
||||
.map(candidate => this.rawInfo(candidate))
|
||||
},
|
||||
write: async (path: string, data: string, options?: { metadata?: Record<string, string>; signal?: AbortSignal }): Promise<object> => {
|
||||
this.checkAbort(options)
|
||||
const parent = dirname(path)
|
||||
if (!this.nodes.has(parent)) this.dir(parent)
|
||||
this.nodes.set(path, {
|
||||
type: FileType.FILE,
|
||||
data: bytes(data),
|
||||
mode: 0o644,
|
||||
modified: this.clock++,
|
||||
...(options?.metadata !== undefined ? { metadata: { ...options.metadata } } : {}),
|
||||
})
|
||||
this.writes.push({ path, data, ...(options?.metadata !== undefined ? { metadata: options.metadata } : {}) })
|
||||
return {}
|
||||
},
|
||||
rename: async (from: string, to: string, options?: { signal?: AbortSignal }): Promise<EntryInfo> => {
|
||||
this.checkAbort(options)
|
||||
if (this.nextRenameError !== undefined) {
|
||||
const error = this.nextRenameError
|
||||
this.nextRenameError = undefined
|
||||
throw error
|
||||
}
|
||||
const node = this.required(from)
|
||||
this.nodes.delete(from)
|
||||
this.nodes.set(to, node)
|
||||
this.renames.push({ from, to })
|
||||
this.abortAfterRename?.abort('after commit')
|
||||
return this.info(to)
|
||||
},
|
||||
remove: async (path: string): Promise<void> => {
|
||||
this.removals.push(path)
|
||||
if (this.nextRemoveError !== undefined) {
|
||||
const error = this.nextRemoveError
|
||||
this.nextRemoveError = undefined
|
||||
throw error
|
||||
}
|
||||
this.nodes.delete(path)
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
run: async (command: string, options?: { signal?: AbortSignal }): Promise<{ exitCode: number; stdout: string; stderr: string }> => {
|
||||
this.checkAbort(options)
|
||||
this.commands.push(command)
|
||||
if (this.nextCommandError !== undefined) {
|
||||
const error = this.nextCommandError
|
||||
this.nextCommandError = undefined
|
||||
throw error
|
||||
}
|
||||
if (command.startsWith('realpath -m -- ')) {
|
||||
const input = command.slice('realpath -m -- '.length).slice(1, -1)
|
||||
const node = this.nodes.get(input)
|
||||
return { exitCode: 0, stdout: `${node?.symlinkTarget ?? input}\n`, stderr: '' }
|
||||
}
|
||||
const chmod = /^chmod ([0-7]+) -- '([^']+)'$/.exec(command)
|
||||
if (chmod !== null) this.required(chmod[2]!).mode = Number.parseInt(chmod[1]!, 8)
|
||||
const move = /^mv -f -- '([^']+)' '([^']+)'$/.exec(command)
|
||||
if (move !== null) {
|
||||
if (this.nextRenameError !== undefined) {
|
||||
const error = this.nextRenameError
|
||||
this.nextRenameError = undefined
|
||||
throw error
|
||||
}
|
||||
const node = this.required(move[1]!)
|
||||
this.nodes.delete(move[1]!)
|
||||
this.nodes.set(move[2]!, node)
|
||||
this.renames.push({ from: move[1]!, to: move[2]! })
|
||||
this.abortAfterRename?.abort('after commit')
|
||||
}
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
},
|
||||
},
|
||||
} as unknown as Sandbox
|
||||
}
|
||||
|
||||
async function setup(remote = new FakeRemote()): Promise<{ ctx: Context; fs: E2BFileSystem; remote: FakeRemote }> {
|
||||
const ctx = new Context()
|
||||
const runtime = {
|
||||
cwd: '/workspace',
|
||||
runtimeRoot: '/workspace/.dsh-e2b',
|
||||
disposeMode: 'kill',
|
||||
getSandbox: async () => remote.sandbox,
|
||||
} as unknown as E2BSandboxService
|
||||
ctx.provide('e2b', runtime)
|
||||
await ctx.plugin(E2BFileSystem)
|
||||
return { ctx, fs: ctx.fs as E2BFileSystem, remote }
|
||||
}
|
||||
|
||||
async function expectCode(promise: Promise<unknown>, code: string): Promise<void> {
|
||||
await expect(promise).rejects.toMatchObject({ code })
|
||||
}
|
||||
|
||||
describe('E2BFileSystem identity, metadata, and reads', () => {
|
||||
it('resolves remote paths, reports symlinks, and lists direct children in stable order', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/z.txt', 'z')
|
||||
remote.file('/workspace/a.txt', 'a')
|
||||
remote.dir('/workspace/dir')
|
||||
remote.other('/workspace/special')
|
||||
remote.file('/workspace/dir/nested.txt', 'nested')
|
||||
remote.symlink('/workspace/link.txt', '/workspace/a.txt')
|
||||
const { fs } = await setup(remote)
|
||||
|
||||
const link = await fs.resolve('link.txt')
|
||||
expect(link).toEqual({ targetKey: '/workspace/a.txt', displayPath: '/workspace/link.txt' })
|
||||
await expect(fs.lstat('link.txt')).resolves.toMatchObject({ type: 'symlink', size: 1 })
|
||||
await expect(fs.lstat('a.txt')).resolves.toMatchObject({ type: 'file', size: 1 })
|
||||
await expect(fs.lstat('dir')).resolves.toEqual(expect.objectContaining({ type: 'directory' }))
|
||||
await expect(fs.lstat('special')).resolves.toEqual(expect.objectContaining({ type: 'other' }))
|
||||
await expect(fs.lstat('missing')).resolves.toBeUndefined()
|
||||
await expect(fs.stat(link)).resolves.toMatchObject({ type: 'file', size: 1 })
|
||||
const directory = await fs.resolve('.')
|
||||
const listed = await fs.listDir(directory)
|
||||
expect(listed.map(entry => entry.name)).toEqual(['a.txt', 'dir', 'link.txt', 'special', 'z.txt'])
|
||||
expect(listed.find(entry => entry.name === 'dir')).toMatchObject({ type: 'directory' })
|
||||
expect(listed.find(entry => entry.name === 'link.txt')).toMatchObject({
|
||||
type: 'file',
|
||||
target: { targetKey: '/workspace/a.txt', displayPath: '/workspace/link.txt' },
|
||||
})
|
||||
expect(listed.some(entry => entry.name === 'nested.txt')).toBe(false)
|
||||
})
|
||||
|
||||
it('reads whole and streamed UTF-8 across chunk boundaries', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/text.txt', 'A€B')
|
||||
remote.streamChunks = [bytes([65, 0xe2]), bytes([0x82, 0xac, 66])]
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('text.txt')
|
||||
await expect(fs.readText(target)).resolves.toBe('A€B')
|
||||
let streamed = ''
|
||||
for await (const chunk of await fs.streamText(target)) streamed += chunk
|
||||
expect(streamed).toBe('A€B')
|
||||
|
||||
remote.streamChunks = [bytes([0xe2]), bytes([0x82, 0xac])]
|
||||
let initiallyBuffered = ''
|
||||
for await (const chunk of await fs.streamText(target)) initiallyBuffered += chunk
|
||||
expect(initiallyBuffered).toBe('€')
|
||||
})
|
||||
|
||||
it('matches local binary sampling while edits still reject any NUL byte', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/late-nul.txt', `${'a'.repeat(8192)}\0tail`)
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('late-nul.txt')
|
||||
await expect(fs.readText(target)).resolves.toContain('\0tail')
|
||||
remote.streamChunks = [bytes('a'.repeat(8192)), bytes([0, 116])]
|
||||
let streamed = ''
|
||||
for await (const chunk of await fs.streamText(target)) streamed += chunk
|
||||
expect(streamed).toBe(`${'a'.repeat(8192)}\0t`)
|
||||
await expectCode(fs.editText(target, { oldString: 'tail', newString: 'end', replaceAll: false }), 'FS_NOT_TEXT')
|
||||
})
|
||||
|
||||
it('maps binary, invalid UTF-8, missing, and non-regular read failures', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/binary', [0, 1])
|
||||
remote.file('/workspace/invalid', [0xff])
|
||||
remote.dir('/workspace/directory')
|
||||
const { fs } = await setup(remote)
|
||||
await expectCode(fs.readText(await fs.resolve('binary')), 'FS_NOT_TEXT')
|
||||
await expectCode(fs.readText(await fs.resolve('invalid')), 'FS_NOT_TEXT')
|
||||
await expectCode(fs.readText(await fs.resolve('missing')), 'FS_NOT_FOUND')
|
||||
await expectCode(fs.readText(await fs.resolve('directory')), 'FS_NOT_REGULAR_FILE')
|
||||
|
||||
remote.streamChunks = [bytes([0xff])]
|
||||
const invalid = await fs.streamText(await fs.resolve('invalid'))
|
||||
await expect((async () => { for await (const _chunk of invalid) void _chunk })()).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
remote.streamChunks = [bytes([0])]
|
||||
const binary = await fs.streamText(await fs.resolve('binary'))
|
||||
await expect((async () => { for await (const _chunk of binary) void _chunk })()).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
|
||||
remote.streamChunks = [bytes([0xe2])]
|
||||
const incomplete = await fs.streamText(await fs.resolve('invalid'))
|
||||
await expect((async () => { for await (const _chunk of incomplete) void _chunk })()).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
|
||||
|
||||
const raced = await fs.resolve('invalid')
|
||||
remote.nextReadError = new FileNotFoundError('gone after stat')
|
||||
await expectCode(fs.streamText(raced), 'FS_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('honors aborts before and during remote reads', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/a', 'a')
|
||||
const { fs } = await setup(remote)
|
||||
await expectCode(fs.resolve('a', { signal: AbortSignal.abort() }), 'FS_ABORTED')
|
||||
await expectCode(fs.lstat('a', undefined, AbortSignal.abort()), 'FS_ABORTED')
|
||||
await expectCode(fs.stat(await fs.resolve('a'), AbortSignal.abort()), 'FS_ABORTED')
|
||||
remote.nextReadError = new DOMException('aborted', 'AbortError')
|
||||
await expectCode(fs.readText(await fs.resolve('a')), 'FS_ABORTED')
|
||||
})
|
||||
|
||||
it('rejects empty paths and directory-listing type errors', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/file', 'x')
|
||||
const { fs } = await setup(remote)
|
||||
await expectCode(fs.resolve(' '), 'FS_NOT_FOUND')
|
||||
await expectCode(fs.lstat(''), 'FS_NOT_FOUND')
|
||||
await expectCode(fs.listDir(await fs.resolve('missing')), 'FS_NOT_FOUND')
|
||||
await expectCode(fs.listDir(await fs.resolve('/workspace/file')), 'FS_NOT_DIRECTORY')
|
||||
remote.nextListError = new Error('listing transport failed')
|
||||
await expectCode(fs.listDir(await fs.resolve('/workspace')), 'FS_IO_ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('E2BFileSystem atomic writes and edits', () => {
|
||||
it('creates owner-only files and returns metadata after the committed move', async () => {
|
||||
const { fs, remote } = await setup()
|
||||
const target = await fs.resolve('new.txt')
|
||||
const outcome = await fs.writeText(target, 'one\r\ntwo\rthree', { kind: 'createIfAbsent' })
|
||||
expect(outcome).toMatchObject({ operation: 'create', before: null, after: 'one\ntwo\rthree' })
|
||||
expect(remote.nodes.get('/workspace/new.txt')?.mode).toBe(0o600)
|
||||
expect(remote.nodes.get('/workspace/new.txt')?.metadata?.['dsh-version']).toBeDefined()
|
||||
await expect(fs.stat(target)).resolves.toMatchObject({ version: outcome.version, size: 14 })
|
||||
})
|
||||
|
||||
it('preserves replacement mode, normalizes only CRLF for diffs, and changes version on external writes', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/file.txt', 'old\r\nline\rlone', 0o640)
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('file.txt')
|
||||
const before = (await fs.stat(target))!.version
|
||||
const outcome = await fs.writeText(target, 'new', { kind: 'replaceIfVersion', version: before })
|
||||
expect(outcome).toMatchObject({ operation: 'update', before: 'old\nline\rlone', after: 'new' })
|
||||
expect(remote.nodes.get('/workspace/file.txt')?.mode).toBe(0o640)
|
||||
const committed = outcome.version
|
||||
remote.mutate('/workspace/file.txt', 'external')
|
||||
expect((await fs.stat(target))!.version).not.toBe(committed)
|
||||
})
|
||||
|
||||
it('returns null as the overwrite diff basis for binary or invalid prior content', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/file.txt', [0xff])
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('file.txt')
|
||||
await expect(fs.writeText(target, 'valid')).resolves.toMatchObject({ before: null, after: 'valid' })
|
||||
})
|
||||
|
||||
it('fails an overwrite when reading its text diff basis fails for another reason', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/file.txt', 'prior')
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('file.txt')
|
||||
remote.nextReadError = new Error('read transport failed')
|
||||
await expectCode(fs.writeText(target, 'replacement'), 'FS_IO_ERROR')
|
||||
expect(new TextDecoder().decode(remote.nodes.get('/workspace/file.txt')?.data)).toBe('prior')
|
||||
})
|
||||
|
||||
it('enforces create and version intents before publication', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/file.txt', 'v1')
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('file.txt')
|
||||
const version = (await fs.stat(target))!.version
|
||||
await expectCode(fs.writeText(target, 'blind', { kind: 'createIfAbsent' }), 'FS_NOT_OBSERVED')
|
||||
remote.mutate('/workspace/file.txt', 'v2')
|
||||
await expectCode(fs.writeText(target, 'stale', { kind: 'replaceIfVersion', version }), 'FS_STALE_VERSION')
|
||||
await expectCode(fs.writeText(await fs.resolve('missing'), 'stale', { kind: 'replaceIfVersion', version }), 'FS_STALE_VERSION')
|
||||
remote.dir('/workspace/dir')
|
||||
await expectCode(fs.writeText(await fs.resolve('dir'), 'x'), 'FS_NOT_REGULAR_FILE')
|
||||
})
|
||||
|
||||
it('does not turn an abort observed after a successful move into a failed write', async () => {
|
||||
const remote = new FakeRemote()
|
||||
const controller = new AbortController()
|
||||
remote.abortAfterRename = controller
|
||||
const { fs } = await setup(remote)
|
||||
await expect(fs.writeText(await fs.resolve('committed'), 'yes', undefined, controller.signal))
|
||||
.resolves.toMatchObject({ operation: 'create' })
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('cleans staging files and maps command, permission, and abort failures', async () => {
|
||||
const remote = new FakeRemote()
|
||||
const { fs } = await setup(remote)
|
||||
const commandTarget = await fs.resolve('command')
|
||||
remote.nextCommandError = commandError(1, 'chmod failed')
|
||||
await expectCode(fs.writeText(commandTarget, 'x'), 'FS_IO_ERROR')
|
||||
expect(remote.removals).toHaveLength(1)
|
||||
|
||||
remote.nextRenameError = new Error('permission denied')
|
||||
await expectCode(fs.writeText(await fs.resolve('permission'), 'x'), 'FS_PERMISSION_DENIED')
|
||||
remote.nextRemoveError = new Error('cleanup also failed')
|
||||
remote.nextRenameError = new DOMException('aborted', 'AbortError')
|
||||
await expectCode(fs.writeText(await fs.resolve('abort'), 'x'), 'FS_ABORTED')
|
||||
})
|
||||
|
||||
it('applies literal edits atomically and restores the detected CRLF style', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/file.txt', 'one\r\ntwo\r\nthree\n')
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('file.txt')
|
||||
const version = (await fs.stat(target))!.version
|
||||
const outcome = await fs.editText(
|
||||
target,
|
||||
{ oldString: 'two\r\n', newString: 'TWO\r\n', replaceAll: false },
|
||||
{ version },
|
||||
)
|
||||
expect(outcome).toMatchObject({ before: 'one\ntwo\nthree\n', after: 'one\nTWO\nthree\n' })
|
||||
expect(new TextDecoder().decode(remote.nodes.get('/workspace/file.txt')?.data)).toBe('one\r\nTWO\r\nthree\r\n')
|
||||
})
|
||||
|
||||
it('reports stale and literal-match failures with stable codes', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/file.txt', 'a a')
|
||||
remote.dir('/workspace/dir')
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('file.txt')
|
||||
await expectCode(fs.editText(target, { oldString: '', newString: 'x', replaceAll: false }), 'FS_EDIT_NOT_FOUND')
|
||||
await expectCode(fs.editText(target, { oldString: 'z', newString: 'x', replaceAll: false }), 'FS_EDIT_NOT_FOUND')
|
||||
await expectCode(fs.editText(target, { oldString: 'a', newString: 'x', replaceAll: false }), 'FS_AMBIGUOUS_EDIT')
|
||||
await expect(fs.editText(target, { oldString: 'a', newString: 'x', replaceAll: true }))
|
||||
.resolves.toMatchObject({ after: 'x x' })
|
||||
await expectCode(fs.editText(target, { oldString: 'x', newString: 'y', replaceAll: false }, { version: FsVersion('stale') }), 'FS_STALE_VERSION')
|
||||
await expectCode(fs.editText(await fs.resolve('missing'), { oldString: 'x', newString: 'y', replaceAll: false }), 'FS_STALE_VERSION')
|
||||
await expectCode(fs.editText(await fs.resolve('dir'), { oldString: 'x', newString: 'y', replaceAll: false }), 'FS_NOT_REGULAR_FILE')
|
||||
})
|
||||
|
||||
it('serializes guarded mutations so only one stale version can win', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/file.txt', 'base')
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('file.txt')
|
||||
const version = (await fs.stat(target))!.version
|
||||
const results = await Promise.allSettled([
|
||||
fs.writeText(target, 'one', { kind: 'replaceIfVersion', version }),
|
||||
fs.editText(target, { oldString: 'base', newString: 'two', replaceAll: false }, { version }),
|
||||
])
|
||||
expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1)
|
||||
expect(results.filter(result => result.status === 'rejected')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('E2B filesystem adapter integration edges', () => {
|
||||
it('maps canonicalization, permission, and generic provider failures', async () => {
|
||||
const remote = new FakeRemote()
|
||||
const { fs } = await setup(remote)
|
||||
remote.nextCommandError = commandError(1, 'not a directory')
|
||||
await expectCode(fs.resolve('bad'), 'FS_IO_ERROR')
|
||||
remote.nextCommandError = commandError(1)
|
||||
await expectCode(fs.resolve('bad-again'), 'FS_IO_ERROR')
|
||||
remote.nextCommandError = new Error('canonical transport failed')
|
||||
await expectCode(fs.resolve('bad-transport'), 'FS_IO_ERROR')
|
||||
remote.file('/workspace/a', 'a')
|
||||
const target = await fs.resolve('a')
|
||||
remote.nextInfoError = new Error('metadata transport failed')
|
||||
await expectCode(fs.stat(target), 'FS_IO_ERROR')
|
||||
remote.nextReadError = new Error('operation not permitted')
|
||||
await expectCode(fs.readText(target), 'FS_PERMISSION_DENIED')
|
||||
remote.nextReadError = 'transport vanished'
|
||||
await expectCode(fs.readText(target), 'FS_IO_ERROR')
|
||||
})
|
||||
|
||||
it('keeps a listed child whose metadata disappears as an other entry', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/a', 'a')
|
||||
remote.disappearOnInfo.add('/workspace/a')
|
||||
const { fs } = await setup(remote)
|
||||
const listed = await fs.listDir(await fs.resolve('/workspace'))
|
||||
expect(listed).toEqual([{
|
||||
name: 'a',
|
||||
type: 'other',
|
||||
target: { targetKey: '/workspace/a', displayPath: '/workspace/a' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('registers the package-owned empty invariant installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
const fiber = await ctx.plugin(E2BFsInvariant).await()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user