File size: 9,716 Bytes
e7c75bb b74c172 e7c75bb b74c172 e7c75bb da0d085 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 | 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);
});
|