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);
});