UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

400 lines • 18.9 kB
import { RAMIndexCacheStore } from "../cache/index/ram.js"; import { getExtension } from "../utils/filetype.js"; import { isEnoent, isStaleWrite } from "../errors/fs.js"; import { runWithWriteContext } from "../cache/context.js"; import { OwnRead } from "../cache/types.js"; import { DirListing, FileType, PathSpec } from "../types.js"; import { mountPrefixOf } from "../utils/key_prefix.js"; import { concat } from "../utils/bytes.js"; import { lstripSlash, rstripSlash, stripSlash } from "../utils/slash.js"; function check(condition, message) { if (!condition) throw new Error(message); } function sameBytes(actual, expected) { return actual.length === expected.length && actual.every((byte, i) => byte === expected[i]); } // The renderer a read of `path` runs, found by the extension the mount // resolves it by, or undefined where the VFS renders none. Mirrors // Python's `_renderer`. function renderer(vfs, path) { const name = vfs.renderers[getExtension(path.virtual) ?? '']; if (name === undefined) return undefined; const render = vfs[name]; check(render !== undefined, `renderer ${name} is not a method`); return render.bind(vfs); } // What a VFS function is handed beside its path, as the mount hands it: the // store, for a function that takes one, what is at its path, and its // folder's row. Mirrors Python's `_taken`. function taken(store, found, parent = null) { return { index: store, entry: found, parent }; } // What is at `path` and at its folder, found by listing each directory from // the mount root down, each handed what its own parent's listing named, as // the mount finds it. Null for the root, which no listing names, and for a // path nothing is at. Mirrors Python's `_rows_at`. async function rowsAt(vfs, path) { const prefix = mountPrefixOf(path.virtual, path.vfsPath); const names = path.vfsPath.split('/').filter((name) => name !== ''); let found = null; let above = null; for (const [depth, name] of names.entries()) { const key = names.slice(0, depth).join('/'); const virtual = key !== '' ? `${prefix}/${key}` : prefix || '/'; const folder = new PathSpec({ virtual, directory: virtual, vfsPath: key }); const listed = await vfs.readdir(folder, { entry: found, parent: above }); const rows = listed instanceof DirListing ? listed.entries : listed; above = found; found = rows.find((row) => row.name === name) ?? null; if (found === null) return [null, above]; } return [found, above]; } /** * What a `read` answered, a stream collected and each of its chunks held to * `maxChunkSize`. Mirrors Python's `_bytes_of`. */ async function bytesOf(answer, maxChunkSize) { const found = await answer; if (found instanceof Uint8Array) return found; const chunks = []; for await (const chunk of found) { check(maxChunkSize === undefined || chunk.byteLength <= maxChunkSize, 'read streamed a chunk over maxChunkSize'); chunks.push(chunk); } return concat(chunks); } // Check that `read` answers a byte window of `data`, the whole file as // `read` answers it. Mirrors Python's `_check_windows`. async function checkWindows(read, path, slot, data) { if (data.length === 0) return; const offset = Math.min(1, data.length - 1); for (const size of [Math.min(3, data.length - offset), null]) { const actual = await bytesOf(read(path, slot, offset, size)); check(sameBytes(actual, data.slice(offset, size === null ? undefined : offset + size)), 'read must use offset and byte count'); } } /** * Verify a VFS's reads against a caller-owned fixture, mutating nothing. * * A read runs the renderer of the file's filetype where the VFS renders * one, else `read`; a read that answers a stream is collected, each chunk * held to the fixture's bound, and a missing file may fail when it is called * or at its first pull. An existence check is probed only where the VFS * defines one. A renderer is always handed a byte window, `read` only where * it takes one natively: the caller reads whole and slices otherwise. `index` is the store every function is * handed, a RAM store at the VFS's `indexTtl` by default. Mirrors Python's * `check_read_contract`. */ export async function checkReadContract(vfs, fixture, index) { const store = index ?? new RAMIndexCacheStore({ ttl: vfs.indexTtl }); const [entry, folderRow] = await rowsAt(vfs, fixture.file); const [, above] = await rowsAt(vfs, fixture.directory); const file = taken(store, entry, folderRow); const folder = taken(store, folderRow, above); const render = renderer(vfs, fixture.file); const data = await bytesOf((render ?? vfs.read.bind(vfs))(fixture.file, file), fixture.maxChunkSize); check(sameBytes(data, fixture.content), 'read differs from fixture content'); const info = await vfs.stat(fixture.file, file); check(info.type === FileType.FILE, 'fixture must stat as a file'); check(info.size === null || info.size === data.length, 'stat size must be rendered byte length or null'); const parent = await vfs.stat(fixture.directory, folder); check(parent.type === FileType.DIRECTORY, 'parent must stat as a directory'); const listed = await vfs.readdir(fixture.directory, folder); const rows = listed instanceof DirListing ? listed.entries : listed; const child = rstripSlash(fixture.file.virtual).split('/').pop() ?? ''; check(rows.some((row) => row.name === child), 'readdir must include the child'); const stored = render !== undefined && vfs.supports('read') ? await bytesOf(vfs.read(fixture.file, file), fixture.maxChunkSize) : data; if (render !== undefined) await checkWindows(render, fixture.file, file, data); if (vfs.readsRanges && vfs.supports('read')) { await checkWindows(vfs.read.bind(vfs), fixture.file, file, stored); } const missing = taken(store, null, folderRow); if (vfs.supports('exists')) { check(await vfs.exists(fixture.file, file), 'exists rejected the fixture file'); check(!(await vfs.exists(fixture.missing, missing)), 'exists accepted a missing file'); } const readMissing = renderer(vfs, fixture.missing) ?? vfs.read.bind(vfs); for (const call of [ (p) => vfs.stat(p, missing), (p) => bytesOf(readMissing(p, missing)), ]) { try { await call(fixture.missing); } catch (error) { if (isEnoent(error)) continue; throw error; } throw new Error('missing paths must raise ENOENT'); } } const OTHER = new TextEncoder().encode('replaced\n'); const LOST = '0'.repeat(32); // The file `name` in `directory`, keyed below the same mount. Mirrors // Python's `_child`. function child(directory, name) { const key = lstripSlash(`${stripSlash(directory.vfsPath)}/${name}`); return new PathSpec({ virtual: `${rstripSlash(directory.virtual)}/${name}`, directory: directory.virtual, vfsPath: key, }); } // Check that `call` rejects with what `wanted` accepts, letting any other // rejection through. Mirrors Python's `_rejects`. async function rejects(call, wanted, message) { try { await call(); } catch (error) { if (wanted(error)) return; throw error; } throw new Error(message); } /** * Run `fn` under the write context a `write: conditional` mount of `vfs` * binds, its cache holding `versions`. * * Each pair is a path and the version a session read there, null for a * path it found no file at. A write, copy or delete of a listed path then * carries the condition `conditionSupport` declares for it: `match` on the * version, `absent` on null; an unlisted path goes out unconditioned. * Mirrors Python's `run_with_versions`. */ export function runWithVersions(vfs, versions, fn) { const held = new Map(versions.map(([path, version]) => [path.virtual, version ?? OwnRead.ABSENT])); const support = Object.entries(vfs.conditionSupport); const context = { vfs: vfs.name, conditions: support.filter(([, modes]) => modes.includes('match')).map(([kind]) => kind), creates: support.filter(([, modes]) => modes.includes('absent')).map(([kind]) => kind), readVersion: (path) => Promise.resolve(held.get(path.virtual) ?? null), readVersions: (paths) => Promise.resolve(paths.map((path) => held.get(path.virtual) ?? null)), drop: (path) => { held.delete(path.virtual); return Promise.resolve(); }, keep: (path, version) => { held.set(path.virtual, version); return Promise.resolve(); }, }; const [first] = versions; const prefix = first === undefined ? '' : mountPrefixOf(first[0].virtual, first[0].vfsPath); return runWithWriteContext(prefix, context, fn); } // One mutation-contract run: the VFS, its store, and the files it wrote in // the scratch directory. Mirrors Python's `_Checker`. class Checker { vfs; fixture; store; content; other; written = new Map(); constructor(vfs, fixture, store) { this.vfs = vfs; this.fixture = fixture; this.store = store; this.content = fixture.content ?? new TextEncoder().encode('mutation contract\n'); this.other = concat([this.content, OTHER]); } path(name) { const path = child(this.fixture.directory, name); this.written.set(path.virtual, path); return path; } // What `op` is handed for its path, and a destination for a two-path op: // only the rows its VFS declares it takes, each read fresh, as the mount // hands them. async handed(op, path, dst) { const wanted = this.vfs.handed[op] ?? []; let rows = { index: this.store }; if (wanted.includes('entry') || wanted.includes('parent')) { const [entry, parent] = await rowsAt(this.vfs, path); rows = { ...rows, entry, parent }; } if (dst !== undefined && (wanted.includes('dstEntry') || wanted.includes('dstParent'))) { const [dstEntry, dstParent] = await rowsAt(this.vfs, dst); rows = { ...rows, dstEntry, dstParent }; } return rows; } async put(path, data) { await this.vfs.write(path, data, undefined, await this.handed('write', path)); } async bytesAt(path) { return bytesOf(this.vfs.read(path, await this.handed('read', path))); } async gone(path) { const handed = await this.handed('stat', path); try { await this.vfs.stat(path, handed); } catch (error) { if (isEnoent(error)) return true; throw error; } return false; } async version(path) { const info = await this.vfs.stat(path, await this.handed('stat', path)); check(info.fingerprint !== null && info.fingerprint !== '', 'a VFS that declares a match condition must stat a fingerprint'); return info.fingerprint; } async call(op, path, dst) { const handed = await this.handed(op, path, dst); if (op === 'unlink') return this.vfs.unlink(path, handed); check(dst !== undefined, `${op} needs a destination`); if (op === 'rename') return this.vfs.rename(path, dst, handed); return this.vfs.copy(path, dst, undefined, handed); } // Unlink every file the run named that is still there, trying each. A // cleanup failure after a failed run is logged, so the run's own failure is // the one thrown; after a passed run the first is thrown once all are tried. async clean(failed) { let first = null; for (const path of this.written.values()) { try { await this.call('unlink', path); } catch (error) { if (isEnoent(error)) continue; console.debug(`cleanup of ${path.virtual} failed: ${String(error)}`); first ??= { error }; } } if (first !== null && !failed) throw first.error; } // Check `rename` or `copy` of one file: onto a fresh name, onto an // existing file, and from a missing source. async checkMoved(op) { const src = this.path(`${op}-src`); const dst = this.path(`${op}-dst`); for (const existing of [false, true]) { await this.put(src, this.content); if (existing) await this.put(dst, this.other); await this.call(op, src, dst); check(sameBytes(await this.bytesAt(dst), this.content), `${op} must land the source's bytes${existing ? ' over an existing file' : ''}`); if (op === 'rename') check(await this.gone(src), 'rename must remove the source'); else check(sameBytes(await this.bytesAt(src), this.content), 'copy must keep the source'); } await rejects(() => this.call(op, this.path(`${op}-missing`), dst), isEnoent, `${op} of a missing source must raise ENOENT`); check(sameBytes(await this.bytesAt(dst), this.content), `a refused ${op} must leave the destination`); } // Check a declared conditional write, honoured and refused. async checkPut(mode) { const path = this.path(`put-${mode}`); if (mode === 'match') await this.put(path, this.other); const held = mode === 'absent' ? null : await this.version(path); await runWithVersions(this.vfs, [[path, held]], () => this.put(path, this.content)); check(sameBytes(await this.bytesAt(path), this.content), `a held put (${mode}) must land`); const lost = mode === 'absent' ? null : LOST; await rejects(() => runWithVersions(this.vfs, [[path, lost]], () => this.put(path, this.other)), isStaleWrite, `a lost put (${mode}) must raise a stale write`); check(sameBytes(await this.bytesAt(path), this.content), `a lost put (${mode}) must write nothing`); } // Check a declared conditional copy, honoured and refused. async checkCopy(mode) { const src = this.path(`copy-${mode}-src`); const dst = this.path(`copy-${mode}`); await this.put(src, this.content); if (mode === 'match') await this.put(dst, this.other); const held = mode === 'absent' ? null : await this.version(dst); await runWithVersions(this.vfs, [[dst, held]], () => this.call('copy', src, dst)); check(sameBytes(await this.bytesAt(dst), this.content), `a held copy (${mode}) must land`); await this.put(src, this.other); const lost = mode === 'absent' ? null : LOST; await rejects(() => runWithVersions(this.vfs, [[dst, lost]], () => this.call('copy', src, dst)), isStaleWrite, `a lost copy (${mode}) must raise a stale write`); check(sameBytes(await this.bytesAt(dst), this.content), `a lost copy (${mode}) must write nothing`); } // Check a declared conditional delete (match), honoured and refused. async checkDelete() { const path = this.path('delete-match'); await this.put(path, this.content); await rejects(() => runWithVersions(this.vfs, [[path, LOST]], () => this.call('unlink', path)), isStaleWrite, 'a lost delete (match) must raise a stale write'); check(sameBytes(await this.bytesAt(path), this.content), 'a lost delete (match) must keep the file'); const held = await this.version(path); await runWithVersions(this.vfs, [[path, held]], () => this.call('unlink', path)); check(await this.gone(path), 'a held delete (match) must remove'); } // Check every declared condition, then `rename` and `copy`. async run(unenforced) { const support = this.vfs.conditionSupport; const skipped = (kind, mode) => unenforced.some(([k, m]) => k === kind && m === mode); for (const [kind, mode] of unenforced) { check((support[kind] ?? []).includes(mode), `unenforced names ${kind} (${mode}), which the VFS does not declare`); console.warn(`mutation contract: ${kind} (${mode}) skipped as unenforced`); } for (const kind of ['put', 'copy', 'delete']) { for (const mode of ['absent', 'match']) { if (skipped(kind, mode) || !(support[kind] ?? []).includes(mode)) continue; if (kind === 'put') await this.checkPut(mode); else if (kind === 'copy' && this.vfs.supports('copy')) await this.checkCopy(mode); else if (kind === 'delete' && mode === 'match') await this.checkDelete(); } } for (const op of ['rename', 'copy']) { if (this.vfs.supports(op)) await this.checkMoved(op); } } } /** * Verify a VFS's single-file mutations in an empty, caller-owned scratch * directory, unlinking the files it wrote there, pass or fail. * * Each op is handed the rows a mount reads for it, fresh, as * `checkReadContract` hands them. Write preconditions are checked exactly as * `conditionSupport` declares them, each op run through `runWithVersions` as * a `write: conditional` mount runs it: a declared condition lands when it * holds and rejects as a stale write, writing nothing, when it does not. An * undeclared condition is refused by the mount before the VFS sees it, so it * is not probed. `rename` and `copy` are checked where the VFS defines them: * onto a fresh name and over an existing file the destination holds the * source's bytes, `rename` removes the source and `copy` keeps it, and a * missing source rejects ENOENT and leaves the destination. `index` is the * store every function is handed, a RAM store at the VFS's `indexTtl` by * default. `unenforced` names declared conditions the backend under test * cannot enforce (a fake), each skipped with a warning; naming an undeclared * one fails. Mirrors Python's `check_mutation_contract`. */ export async function checkMutationContract(vfs, fixture, index, unenforced = []) { check(vfs.supports('write'), 'the mutation contract seeds through write'); check(vfs.supports('unlink'), 'the mutation contract cleans up through unlink'); const checker = new Checker(vfs, fixture, index ?? new RAMIndexCacheStore({ ttl: vfs.indexTtl })); try { await checker.run(unenforced); } catch (error) { await checker.clean(true); throw error; } await checker.clean(false); } //# sourceMappingURL=testing.js.map