import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { LocalFolderFs, displayText } from '../src/local-folder.mjs'; import { createWorkspace } from '../src/workspace.mjs'; // OS-backed handle fixture for adapter semantics. Actual browser handle cloning, // permissions, picker UX, and IndexedDB are covered by the browser suite. async function fixture(t, initial = { 'README.md': '# Local project\n', 'src/app.js': 'export const answer = 1;\n' }) { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pi-folder-test-')); t.after(() => fs.rm(root, { recursive: true, force: true })); for (const [name, data] of Object.entries(initial)) { await fs.mkdir(path.dirname(path.join(root, name)), { recursive: true }); await fs.writeFile(path.join(root, name), data); } let reads = 0, failWrite = false; const handle = (p, kind) => ({ kind, name: path.basename(p), async *entries() { for (const item of await fs.readdir(p, { withFileTypes: true })) yield [item.name, handle(path.join(p, item.name), item.isDirectory() ? 'directory' : 'file')]; }, async getFile() { const stat = await fs.stat(p); return { size: stat.size, lastModified: stat.mtimeMs, async arrayBuffer() { reads++; return new Uint8Array(await fs.readFile(p)).buffer; } }; }, fixturePath: p, async isSameEntry(other) { return other.fixturePath === p; }, async createWritable() { if (failWrite) throw new DOMException('Write denied', 'NotAllowedError'); let bytes; return { async write(data) { bytes = data; }, async close() { await fs.writeFile(p, bytes); }, async abort() {} }; }, async removeEntry(name) { await fs.rm(path.join(p, name)); }, }); // Install traversal separately so each handle closes over its own path. function make(p, kind) { const h = handle(p, kind); const child = async (name, wanted, create) => { assert(!name.includes('/') && name !== '..'); const target = path.join(p, name); let stat; try { stat = await fs.stat(target); } catch (e) { if (e.code !== 'ENOENT') throw e; if (!create) throw new DOMException('Missing', 'NotFoundError'); if (wanted === 'directory') await fs.mkdir(target); else await fs.writeFile(target, ''); stat = await fs.stat(target); } if (stat.isDirectory() !== (wanted === 'directory')) throw new DOMException('Wrong kind', 'TypeMismatchError'); return make(target, wanted); }; h.getDirectoryHandle = (name, { create = false } = {}) => child(name, 'directory', create); h.getFileHandle = (name, { create = false } = {}) => child(name, 'file', create); h.entries = async function* () { for (const item of await fs.readdir(p, { withFileTypes: true })) yield [item.name, make(path.join(p, item.name), item.isDirectory() ? 'directory' : 'file')]; }; return h; } const directory = make(root, 'directory'); return { root, directory, get reads() { return reads; }, denyWrites() { failWrite = true; }, read: name => fs.readFile(path.join(root, name), 'utf8'), write: (name, text) => fs.writeFile(path.join(root, name), text) }; } test('Local folder: indexing/listing/stat do not read file bytes; workspace snapshots stay metadata-only', async t => { const disk = await fixture(t, { 'a.txt': 'hello', 'large.bin': Buffer.alloc(2 * 1024 ** 2), 'node_modules/ignore.js': 'ignored' }); const local = await LocalFolderFs.open(disk.directory), workspace = createWorkspace({}, undefined, local); await workspace.ready; assert.equal(disk.reads, 0); assert.deepEqual(Object.keys(await workspace.snapshot()), ['/workspace/a.txt', '/workspace/large.bin']); assert.equal(await workspace.serialize(), null); const listing = await workspace.bash.exec('ls -l /workspace'); assert.equal(listing.exitCode, 0, listing.stderr); assert.equal(disk.reads, 0); assert.equal(local.skipped, 1); assert.equal(await workspace.read('a.txt'), 'hello'); assert.equal(disk.reads, 1); await assert.rejects(workspace.read('large.bin'), { code: 'FILE_TOO_LARGE' }); }); test('Local folder: real Bash and agent tools stage edits, mkdir, copy, rename, delete; Apply writes only staged paths', async t => { const disk = await fixture(t); const local = await LocalFolderFs.open(disk.directory), workspace = createWorkspace({}, undefined, local); await workspace.ready; await workspace.tools.find(t => t.name === 'edit').execute('', { path: 'src/app.js', oldText: '= 1', newText: '= 2' }); const result = await workspace.bash.exec("mkdir -p reports/nested; printf 'ok\\n' > reports/nested/result.txt; cp README.md copy.md; mv copy.md moved.md; rm README.md"); assert.equal(result.exitCode, 0, result.stderr); assert.equal(await disk.read('src/app.js'), 'export const answer = 1;\n'); assert.equal(await disk.read('README.md'), '# Local project\n'); const patches = await local.patches(); assert.equal(patches.length, 6); assert.equal(await local.apply(), 6); assert.equal(await disk.read('src/app.js'), 'export const answer = 2;\n'); assert.equal(await disk.read('moved.md'), '# Local project\n'); assert.equal(await disk.read('reports/nested/result.txt'), 'ok\n'); await assert.rejects(disk.read('README.md'), { code: 'ENOENT' }); assert.equal((await local.patches()).length, 0); }); test('Local folder: conflicts preflight all writes, including same-size edits, creations and deletions', async t => { const disk = await fixture(t); const local = await LocalFolderFs.open(disk.directory); await local.writeFile('/README.md', 'changed'); await local.writeFile('/src/app.js', 'staged'); await disk.write('src/app.js', 'export const answer = 9;\n'); await assert.rejects(local.apply(), /Conflict/); assert.equal(await disk.read('README.md'), '# Local project\n'); assert.equal(await disk.read('src/app.js'), 'export const answer = 9;\n'); const fresh = await LocalFolderFs.open(disk.directory); await fresh.writeFile('/new.txt', 'staged'); await disk.write('new.txt', 'external'); await assert.rejects(fresh.apply(), /Conflict/); assert.equal(await disk.read('new.txt'), 'external'); const deleted = await LocalFolderFs.open(disk.directory); await deleted.rm('/README.md'); await disk.write('README.md', 'other editor'); await assert.rejects(deleted.apply(), /Conflict/); assert.equal(await disk.read('README.md'), 'other editor'); }); test('Local folder: staged patches restore with the original baseline, not a newly changed disk file', async t => { const disk = await fixture(t), local = await LocalFolderFs.open(disk.directory); await local.writeFile('/src/app.js', 'staged'); await local.mkdir('/new'); await local.writeFile('/new/file', 'new'); const patches = await local.patches(); await disk.write('src/app.js', 'external'); const restored = await LocalFolderFs.open(disk.directory, { patches }); assert.equal(await restored.readFile('/src/app.js'), 'staged'); assert.equal(await restored.readFile('/new/file'), 'new'); await assert.rejects(restored.apply(), /Conflict/); }); test('Local folder: failed or cancelled application retains pending edits', async t => { const disk = await fixture(t), local = await LocalFolderFs.open(disk.directory); await local.writeFile('/README.md', 'staged'); await assert.rejects(local.apply(AbortSignal.abort()), { name: 'AbortError' }); disk.denyWrites(); await assert.rejects(local.apply(), /0 of 1 changes applied/); assert.equal((await local.patches()).length, 1); assert.equal(await disk.read('README.md'), '# Local project\n'); const newFile = await LocalFolderFs.open(disk.directory); await newFile.writeFile('/new.txt', 'staged'); await assert.rejects(newFile.apply(), /0 of 1/); await assert.rejects(disk.read('new.txt'), { code: 'ENOENT' }); assert.equal((await newFile.patches()).length, 1); }); test('Local folder: unsupported operations, excludes, binary editor and traversal do not touch disk', async t => { const disk = await fixture(t), local = await LocalFolderFs.open(disk.directory), workspace = createWorkspace({}, undefined, local); await workspace.ready; for (const method of ['chmod', 'symlink', 'link', 'utimes']) assert.throws(() => local[method]('/README.md', 0o777), /ENOTSUP/); await assert.rejects(local.rm('/src', { recursive: true }), /not supported/); await assert.rejects(local.writeFile('/.git/config', 'oops'), /excluded/); assert.throws(() => displayText(new Uint8Array([0, 1])), { code: 'BINARY_FILE' }); assert.throws(() => displayText(new Uint8Array([0xff, 0xfe])), { code: 'BINARY_FILE' }); assert.equal(displayText(new TextEncoder().encode('Café 🐾')), 'Café 🐾'); await workspace.write('../outside.txt', 'virtual only'); assert.deepEqual(await local.patches(), []); await assert.rejects(disk.read('outside.txt'), { code: 'ENOENT' }); }); test('Local folder: an in-flight editor preview cannot overwrite a staged tool edit', async t => { const disk = await fixture(t), local = await LocalFolderFs.open(disk.directory); let unblock, started; const gate = new Promise(resolve => { unblock = resolve; }); const loading = new Promise(resolve => { started = resolve; }); const load = local.loadOriginal.bind(local); local.loadOriginal = async path => { started(); await gate; return load(path); }; const preview = local.readFile('/README.md'); await loading; const edit = local.writeFile('/README.md', 'new staged version'); unblock(); await Promise.all([preview, edit]); assert.equal(await local.readFile('/README.md'), 'new staged version'); assert.equal(await disk.read('README.md'), '# Local project\n'); assert.equal((await local.patches()).length, 1); });