@langchain/sandbox-standard-tests 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/dist/index.cjs +5 -0
- package/dist/index.d.cts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/sandbox-BX7bEJLz.d.cts +168 -0
- package/dist/sandbox-C1ApHibz.js +1157 -0
- package/dist/sandbox-C1ApHibz.js.map +1 -0
- package/dist/sandbox-C22TOwhI.cjs +1169 -0
- package/dist/sandbox-C22TOwhI.cjs.map +1 -0
- package/dist/sandbox-CWDp3zl_.d.ts +168 -0
- package/dist/vitest.cjs +43 -0
- package/dist/vitest.cjs.map +1 -0
- package/dist/vitest.d.cts +19 -0
- package/dist/vitest.d.ts +19 -0
- package/dist/vitest.js +41 -0
- package/dist/vitest.js.map +1 -0
- package/package.json +77 -0
|
@@ -0,0 +1,1169 @@
|
|
|
1
|
+
|
|
2
|
+
//#region src/tests/lifecycle.ts
|
|
3
|
+
/**
|
|
4
|
+
* Register sandbox lifecycle tests (create, isRunning, close, two-step init).
|
|
5
|
+
*
|
|
6
|
+
* These tests use both the shared sandbox (for id/isRunning checks) and
|
|
7
|
+
* temporary sandboxes (for close and two-step initialization).
|
|
8
|
+
*/
|
|
9
|
+
function registerLifecycleTests(getShared, config, timeout) {
|
|
10
|
+
const { describe, it, expect, beforeAll, afterAll } = config.runner;
|
|
11
|
+
const describeSkipIf = describe.skipIf ?? ((condition) => condition ? describe.skip ?? (() => {}) : describe);
|
|
12
|
+
const itSkipIf = it.skipIf ?? ((condition) => condition ? () => {} : it);
|
|
13
|
+
describe("sandbox lifecycle", () => {
|
|
14
|
+
it("should create sandbox and have a valid id", () => {
|
|
15
|
+
const shared = getShared();
|
|
16
|
+
expect(shared).toBeDefined();
|
|
17
|
+
expect(shared.id).toBeDefined();
|
|
18
|
+
expect(typeof shared.id).toBe("string");
|
|
19
|
+
expect(shared.id.length).toBeGreaterThan(0);
|
|
20
|
+
}, timeout);
|
|
21
|
+
it("should have isRunning as true after creation", () => {
|
|
22
|
+
expect(getShared().isRunning).toBe(true);
|
|
23
|
+
}, timeout);
|
|
24
|
+
describeSkipIf(!(typeof config.closeSandbox === "function"))("close", () => {
|
|
25
|
+
let tmp;
|
|
26
|
+
beforeAll(async () => {
|
|
27
|
+
tmp = await withRetry(() => config.createSandbox());
|
|
28
|
+
}, timeout);
|
|
29
|
+
afterAll(async () => {
|
|
30
|
+
try {
|
|
31
|
+
await config.closeSandbox?.(tmp);
|
|
32
|
+
} catch {}
|
|
33
|
+
}, timeout);
|
|
34
|
+
it("should close sandbox successfully", async () => {
|
|
35
|
+
expect(tmp.isRunning).toBe(true);
|
|
36
|
+
await config.closeSandbox?.(tmp);
|
|
37
|
+
expect(tmp.isRunning).toBe(false);
|
|
38
|
+
}, timeout);
|
|
39
|
+
});
|
|
40
|
+
describe("two-step initialization", () => {
|
|
41
|
+
let tmp;
|
|
42
|
+
afterAll(async () => {
|
|
43
|
+
try {
|
|
44
|
+
if (tmp) await config.closeSandbox?.(tmp);
|
|
45
|
+
} catch {}
|
|
46
|
+
}, timeout);
|
|
47
|
+
itSkipIf(!config.createUninitializedSandbox)("should work with two-step initialization", async () => {
|
|
48
|
+
tmp = config.createUninitializedSandbox();
|
|
49
|
+
expect(tmp.isRunning).toBe(false);
|
|
50
|
+
await withRetry(() => tmp.initialize());
|
|
51
|
+
expect(tmp.isRunning).toBe(true);
|
|
52
|
+
expect(tmp.id).toBeDefined();
|
|
53
|
+
}, timeout);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/tests/command-execution.ts
|
|
60
|
+
/**
|
|
61
|
+
* Register command execution tests (echo, exit codes, multiline, stderr, env vars).
|
|
62
|
+
*/
|
|
63
|
+
function registerCommandExecutionTests(getShared, config, timeout) {
|
|
64
|
+
const { describe, it, expect } = config.runner;
|
|
65
|
+
describe("command execution", () => {
|
|
66
|
+
it("should run a simple echo command", async () => {
|
|
67
|
+
const result = await getShared().execute("echo \"hello\"");
|
|
68
|
+
expect(result.exitCode).toBe(0);
|
|
69
|
+
expect(result.output.trim()).toBe("hello");
|
|
70
|
+
expect(result.truncated).toBe(false);
|
|
71
|
+
}, timeout);
|
|
72
|
+
it("should capture non-zero exit code", async () => {
|
|
73
|
+
expect((await getShared().execute("exit 42")).exitCode).toBe(42);
|
|
74
|
+
}, timeout);
|
|
75
|
+
it("should capture multiline output", async () => {
|
|
76
|
+
const result = await getShared().execute("echo \"line1\" && echo \"line2\" && echo \"line3\"");
|
|
77
|
+
expect(result.exitCode).toBe(0);
|
|
78
|
+
expect(result.output).toContain("line1");
|
|
79
|
+
expect(result.output).toContain("line2");
|
|
80
|
+
expect(result.output).toContain("line3");
|
|
81
|
+
}, timeout);
|
|
82
|
+
it("should capture stderr output", async () => {
|
|
83
|
+
expect((await getShared().execute("echo \"error message\" >&2")).output).toContain("error message");
|
|
84
|
+
}, timeout);
|
|
85
|
+
it("should handle command with environment variables", async () => {
|
|
86
|
+
const result = await getShared().execute("export MY_VAR=\"test_value\" && echo $MY_VAR");
|
|
87
|
+
expect(result.exitCode).toBe(0);
|
|
88
|
+
expect(result.output.trim()).toBe("test_value");
|
|
89
|
+
}, timeout);
|
|
90
|
+
it("should handle non-existent command", async () => {
|
|
91
|
+
expect((await getShared().execute("nonexistent_command_12345")).exitCode).not.toBe(0);
|
|
92
|
+
}, timeout);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/tests/file-operations.ts
|
|
98
|
+
/**
|
|
99
|
+
* Register basic file operation tests (upload, download, read, write, edit, multiple files).
|
|
100
|
+
*/
|
|
101
|
+
function registerFileOperationTests(getShared, config, timeout) {
|
|
102
|
+
const { describe, it, expect } = config.runner;
|
|
103
|
+
describe("file operations", () => {
|
|
104
|
+
it("should upload files to sandbox", async () => {
|
|
105
|
+
const shared = getShared();
|
|
106
|
+
const filePath = config.resolvePath("test-upload.txt");
|
|
107
|
+
const content = new TextEncoder().encode("Hello from test file!");
|
|
108
|
+
const results = await shared.uploadFiles([[filePath, content]]);
|
|
109
|
+
expect(results.length).toBe(1);
|
|
110
|
+
expect(results[0].path).toBe(filePath);
|
|
111
|
+
expect(results[0].error).toBeNull();
|
|
112
|
+
expect((await shared.execute(`cat ${filePath}`)).output.trim()).toBe("Hello from test file!");
|
|
113
|
+
}, timeout);
|
|
114
|
+
it("should download files from sandbox", async () => {
|
|
115
|
+
const shared = getShared();
|
|
116
|
+
const filePath = config.resolvePath("test-download.txt");
|
|
117
|
+
const encoder = new TextEncoder();
|
|
118
|
+
await shared.uploadFiles([[filePath, encoder.encode("Download test content")]]);
|
|
119
|
+
const results = await shared.downloadFiles([filePath]);
|
|
120
|
+
expect(results.length).toBe(1);
|
|
121
|
+
expect(results[0].error).toBeNull();
|
|
122
|
+
expect(results[0].content).not.toBeNull();
|
|
123
|
+
expect(new TextDecoder().decode(results[0].content).trim()).toBe("Download test content");
|
|
124
|
+
}, timeout);
|
|
125
|
+
it("should handle file not found on download", async () => {
|
|
126
|
+
const filePath = config.resolvePath("nonexistent-file-12345.txt");
|
|
127
|
+
const results = await getShared().downloadFiles([filePath]);
|
|
128
|
+
expect(results.length).toBe(1);
|
|
129
|
+
expect(results[0].content).toBeNull();
|
|
130
|
+
expect(results[0].error).toBe("file_not_found");
|
|
131
|
+
}, timeout);
|
|
132
|
+
it("should use inherited read method from BaseSandbox", async () => {
|
|
133
|
+
const shared = getShared();
|
|
134
|
+
const filePath = config.resolvePath("read-test.txt");
|
|
135
|
+
const encoder = new TextEncoder();
|
|
136
|
+
await shared.uploadFiles([[filePath, encoder.encode("Read test content")]]);
|
|
137
|
+
expect(await shared.read(filePath)).toContain("Read test content");
|
|
138
|
+
}, timeout);
|
|
139
|
+
it("should use inherited write method from BaseSandbox", async () => {
|
|
140
|
+
const shared = getShared();
|
|
141
|
+
const filePath = config.resolvePath("write-test.txt");
|
|
142
|
+
await shared.write(filePath, "Written via BaseSandbox");
|
|
143
|
+
expect((await shared.execute(`cat ${filePath}`)).output.trim()).toBe("Written via BaseSandbox");
|
|
144
|
+
}, timeout);
|
|
145
|
+
it("should use inherited edit method from BaseSandbox", async () => {
|
|
146
|
+
const shared = getShared();
|
|
147
|
+
const filePath = config.resolvePath("edit-test.txt");
|
|
148
|
+
await shared.write(filePath, "Hello World");
|
|
149
|
+
await shared.edit(filePath, "Hello World", "Hello Edited World");
|
|
150
|
+
expect((await shared.execute(`cat ${filePath}`)).output.trim()).toBe("Hello Edited World");
|
|
151
|
+
}, timeout);
|
|
152
|
+
it("should upload multiple files at once", async () => {
|
|
153
|
+
const shared = getShared();
|
|
154
|
+
const path1 = config.resolvePath("multi1.txt");
|
|
155
|
+
const path2 = config.resolvePath("multi2.txt");
|
|
156
|
+
const path3 = config.resolvePath("multi3.txt");
|
|
157
|
+
const encoder = new TextEncoder();
|
|
158
|
+
const results = await shared.uploadFiles([
|
|
159
|
+
[path1, encoder.encode("Content 1")],
|
|
160
|
+
[path2, encoder.encode("Content 2")],
|
|
161
|
+
[path3, encoder.encode("Content 3")]
|
|
162
|
+
]);
|
|
163
|
+
expect(results.length).toBe(3);
|
|
164
|
+
expect(results.every((r) => r.error === null)).toBe(true);
|
|
165
|
+
const checkResult = await shared.execute(`cat ${path1} ${path2} ${path3}`);
|
|
166
|
+
expect(checkResult.output).toContain("Content 1");
|
|
167
|
+
expect(checkResult.output).toContain("Content 2");
|
|
168
|
+
expect(checkResult.output).toContain("Content 3");
|
|
169
|
+
}, timeout);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/tests/write.ts
|
|
175
|
+
/**
|
|
176
|
+
* Register detailed write() tests (new file, parent dirs, existing file,
|
|
177
|
+
* special chars, empty, spaces, unicode, slashes, long content, newlines).
|
|
178
|
+
*/
|
|
179
|
+
function registerWriteTests(getShared, config, timeout) {
|
|
180
|
+
const { describe, it, expect } = config.runner;
|
|
181
|
+
describe("write", () => {
|
|
182
|
+
it("should write a new file with basic content", async () => {
|
|
183
|
+
const shared = getShared();
|
|
184
|
+
const filePath = config.resolvePath("wt-new.txt");
|
|
185
|
+
const content = "Hello, sandbox!\nLine 2\nLine 3";
|
|
186
|
+
const result = await shared.write(filePath, content);
|
|
187
|
+
expect(result.error).toBeUndefined();
|
|
188
|
+
expect(result.path).toBe(filePath);
|
|
189
|
+
expect((await shared.execute(`cat ${filePath}`)).output.trim()).toBe(content);
|
|
190
|
+
}, timeout);
|
|
191
|
+
it("should create parent directories automatically", async () => {
|
|
192
|
+
const shared = getShared();
|
|
193
|
+
const filePath = config.resolvePath("wt-parents/deep/nested/dir/file.txt");
|
|
194
|
+
const content = "Nested file content";
|
|
195
|
+
expect((await shared.write(filePath, content)).error).toBeUndefined();
|
|
196
|
+
expect((await shared.execute(`cat ${filePath}`)).output.trim()).toBe(content);
|
|
197
|
+
}, timeout);
|
|
198
|
+
it("should fail when writing to an existing file", async () => {
|
|
199
|
+
const shared = getShared();
|
|
200
|
+
const filePath = config.resolvePath("wt-existing.txt");
|
|
201
|
+
await shared.write(filePath, "First content");
|
|
202
|
+
const result = await shared.write(filePath, "Second content");
|
|
203
|
+
expect(result.error).toBeDefined();
|
|
204
|
+
expect(result.error.toLowerCase()).toContain("already exists");
|
|
205
|
+
expect((await shared.execute(`cat ${filePath}`)).output.trim()).toBe("First content");
|
|
206
|
+
}, timeout);
|
|
207
|
+
it("should handle special characters and escape sequences", async () => {
|
|
208
|
+
const shared = getShared();
|
|
209
|
+
const filePath = config.resolvePath("wt-special.txt");
|
|
210
|
+
const content = "Special chars: $VAR, `command`, $(subshell)\nTab here\nBackslash: \\";
|
|
211
|
+
expect((await shared.write(filePath, content)).error).toBeUndefined();
|
|
212
|
+
expect((await shared.execute(`cat ${filePath}`)).output.trim()).toBe(content);
|
|
213
|
+
}, timeout);
|
|
214
|
+
it("should write an empty file", async () => {
|
|
215
|
+
const shared = getShared();
|
|
216
|
+
const filePath = config.resolvePath("wt-empty.txt");
|
|
217
|
+
expect((await shared.write(filePath, "")).error).toBeUndefined();
|
|
218
|
+
expect((await shared.execute(`[ -f ${filePath} ] && echo 'exists' || echo 'missing'`)).output).toContain("exists");
|
|
219
|
+
}, timeout);
|
|
220
|
+
it("should write a file with spaces in the path", async () => {
|
|
221
|
+
const shared = getShared();
|
|
222
|
+
const filePath = config.resolvePath("wt-spaces/dir with spaces/file name.txt");
|
|
223
|
+
const content = "Content in file with spaces";
|
|
224
|
+
expect((await shared.write(filePath, content)).error).toBeUndefined();
|
|
225
|
+
expect((await shared.execute(`cat '${filePath}'`)).output.trim()).toBe(content);
|
|
226
|
+
}, timeout);
|
|
227
|
+
it("should write unicode content", async () => {
|
|
228
|
+
const shared = getShared();
|
|
229
|
+
const filePath = config.resolvePath("wt-unicode.txt");
|
|
230
|
+
const content = "Hello 👋 世界 مرحبا Привет 🌍\nLine with émojis 🎉";
|
|
231
|
+
expect((await shared.write(filePath, content)).error).toBeUndefined();
|
|
232
|
+
expect((await shared.execute(`cat ${filePath}`)).output.trim()).toBe(content);
|
|
233
|
+
}, timeout);
|
|
234
|
+
it("should handle consecutive slashes in path", async () => {
|
|
235
|
+
const shared = getShared();
|
|
236
|
+
const basePath = config.resolvePath("wt-slashes");
|
|
237
|
+
const filePath = `${basePath}//subdir///file.txt`;
|
|
238
|
+
const content = "Content";
|
|
239
|
+
expect((await shared.write(filePath, content)).error).toBeUndefined();
|
|
240
|
+
expect((await shared.execute(`cat ${basePath}/subdir/file.txt`)).output.trim()).toBe(content);
|
|
241
|
+
}, timeout);
|
|
242
|
+
it("should write very long content (1000 lines)", async () => {
|
|
243
|
+
const shared = getShared();
|
|
244
|
+
const filePath = config.resolvePath("wt-long.txt");
|
|
245
|
+
const content = Array.from({ length: 1e3 }, (_, i) => `Line ${i} with some content here`).join("\n");
|
|
246
|
+
expect((await shared.write(filePath, content)).error).toBeUndefined();
|
|
247
|
+
expect((await shared.execute(`wc -l < ${filePath}`)).output.trim()).toMatch(/^(999|1000)$/);
|
|
248
|
+
}, timeout);
|
|
249
|
+
it("should write content with only newlines", async () => {
|
|
250
|
+
const shared = getShared();
|
|
251
|
+
const filePath = config.resolvePath("wt-newlines.txt");
|
|
252
|
+
expect((await shared.write(filePath, "\n\n\n\n\n")).error).toBeUndefined();
|
|
253
|
+
expect((await shared.execute(`wc -l < ${filePath}`)).output.trim()).toBe("5");
|
|
254
|
+
}, timeout);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/tests/read.ts
|
|
260
|
+
/**
|
|
261
|
+
* Register detailed read() tests (basic, nonexistent, empty, offset, limit,
|
|
262
|
+
* offset+limit, unicode, long lines, zero limit, offset beyond, chunked).
|
|
263
|
+
*/
|
|
264
|
+
function registerReadTests(getShared, config, timeout) {
|
|
265
|
+
const { describe, it, expect } = config.runner;
|
|
266
|
+
describe("read", () => {
|
|
267
|
+
it("should read a file with line numbers", async () => {
|
|
268
|
+
const shared = getShared();
|
|
269
|
+
const filePath = config.resolvePath("rd-basic.txt");
|
|
270
|
+
await shared.write(filePath, "Line 1\nLine 2\nLine 3");
|
|
271
|
+
const result = await shared.read(filePath);
|
|
272
|
+
expect(result).not.toContain("Error:");
|
|
273
|
+
expect(result).toContain("Line 1");
|
|
274
|
+
expect(result).toContain("Line 2");
|
|
275
|
+
expect(result).toContain("Line 3");
|
|
276
|
+
}, timeout);
|
|
277
|
+
it("should return error for nonexistent file", async () => {
|
|
278
|
+
const filePath = config.resolvePath("rd-nonexistent-xyz.txt");
|
|
279
|
+
const result = await getShared().read(filePath);
|
|
280
|
+
expect(result).toContain("Error:");
|
|
281
|
+
expect(result.toLowerCase()).toContain("not found");
|
|
282
|
+
}, timeout);
|
|
283
|
+
it("should handle reading an empty file", async () => {
|
|
284
|
+
const shared = getShared();
|
|
285
|
+
const filePath = config.resolvePath("rd-empty.txt");
|
|
286
|
+
await shared.write(filePath, "");
|
|
287
|
+
expect((await shared.read(filePath)).toLowerCase()).not.toContain("error:");
|
|
288
|
+
}, timeout);
|
|
289
|
+
it("should read with offset parameter", async () => {
|
|
290
|
+
const shared = getShared();
|
|
291
|
+
const filePath = config.resolvePath("rd-offset.txt");
|
|
292
|
+
const content = Array.from({ length: 10 }, (_, i) => `Row_${i + 1}_content`).join("\n");
|
|
293
|
+
await shared.write(filePath, content);
|
|
294
|
+
const result = await shared.read(filePath, 5);
|
|
295
|
+
expect(result).toContain("Row_6_content");
|
|
296
|
+
expect(result).not.toContain("Row_1_content");
|
|
297
|
+
}, timeout);
|
|
298
|
+
it("should read with limit parameter", async () => {
|
|
299
|
+
const shared = getShared();
|
|
300
|
+
const filePath = config.resolvePath("rd-limit.txt");
|
|
301
|
+
const content = Array.from({ length: 100 }, (_, i) => `Row_${i + 1}_content`).join("\n");
|
|
302
|
+
await shared.write(filePath, content);
|
|
303
|
+
const result = await shared.read(filePath, 0, 5);
|
|
304
|
+
expect(result).toContain("Row_1_content");
|
|
305
|
+
expect(result).toContain("Row_5_content");
|
|
306
|
+
expect(result).not.toContain("Row_6_content");
|
|
307
|
+
}, timeout);
|
|
308
|
+
it("should read with both offset and limit", async () => {
|
|
309
|
+
const shared = getShared();
|
|
310
|
+
const filePath = config.resolvePath("rd-offset-limit.txt");
|
|
311
|
+
const content = Array.from({ length: 20 }, (_, i) => `Row_${i + 1}_content`).join("\n");
|
|
312
|
+
await shared.write(filePath, content);
|
|
313
|
+
const result = await shared.read(filePath, 10, 5);
|
|
314
|
+
expect(result).toContain("Row_11_content");
|
|
315
|
+
expect(result).toContain("Row_15_content");
|
|
316
|
+
expect(result).not.toContain("Row_10_content");
|
|
317
|
+
expect(result).not.toContain("Row_16_content");
|
|
318
|
+
}, timeout);
|
|
319
|
+
it("should read unicode content", async () => {
|
|
320
|
+
const shared = getShared();
|
|
321
|
+
const filePath = config.resolvePath("rd-unicode.txt");
|
|
322
|
+
await shared.write(filePath, "Hello 👋 世界\nПривет мир\nمرحبا العالم");
|
|
323
|
+
const result = await shared.read(filePath);
|
|
324
|
+
expect(result).not.toContain("Error:");
|
|
325
|
+
expect(result).toContain("👋");
|
|
326
|
+
expect(result).toContain("世界");
|
|
327
|
+
expect(result).toContain("Привет");
|
|
328
|
+
}, timeout);
|
|
329
|
+
it("should handle files with very long lines", async () => {
|
|
330
|
+
const shared = getShared();
|
|
331
|
+
const filePath = config.resolvePath("rd-long-lines.txt");
|
|
332
|
+
const content = `Short line\n${"x".repeat(3e3)}\nAnother short line`;
|
|
333
|
+
await shared.write(filePath, content);
|
|
334
|
+
const result = await shared.read(filePath);
|
|
335
|
+
expect(result).not.toContain("Error:");
|
|
336
|
+
expect(result).toContain("Short line");
|
|
337
|
+
}, timeout);
|
|
338
|
+
it("should return nothing with limit=0", async () => {
|
|
339
|
+
const shared = getShared();
|
|
340
|
+
const filePath = config.resolvePath("rd-zero-limit.txt");
|
|
341
|
+
await shared.write(filePath, "Line 1\nLine 2\nLine 3");
|
|
342
|
+
expect(await shared.read(filePath, 0, 0)).not.toContain("Line 1");
|
|
343
|
+
}, timeout);
|
|
344
|
+
it("should handle offset beyond file length", async () => {
|
|
345
|
+
const shared = getShared();
|
|
346
|
+
const filePath = config.resolvePath("rd-offset-beyond.txt");
|
|
347
|
+
await shared.write(filePath, "Line 1\nLine 2\nLine 3");
|
|
348
|
+
const result = await shared.read(filePath, 100, 10);
|
|
349
|
+
expect(result).not.toContain("Line 1");
|
|
350
|
+
expect(result).not.toContain("Line 2");
|
|
351
|
+
expect(result).not.toContain("Line 3");
|
|
352
|
+
}, timeout);
|
|
353
|
+
it("should handle offset exactly at file length", async () => {
|
|
354
|
+
const shared = getShared();
|
|
355
|
+
const filePath = config.resolvePath("rd-offset-exact.txt");
|
|
356
|
+
const content = Array.from({ length: 5 }, (_, i) => `Line ${i + 1}`).join("\n");
|
|
357
|
+
await shared.write(filePath, content);
|
|
358
|
+
const result = await shared.read(filePath, 5, 10);
|
|
359
|
+
expect(result).not.toContain("Line 1");
|
|
360
|
+
expect(result).not.toContain("Line 5");
|
|
361
|
+
}, timeout);
|
|
362
|
+
it("should read a large file in chunks", async () => {
|
|
363
|
+
const shared = getShared();
|
|
364
|
+
const filePath = config.resolvePath("rd-chunked.txt");
|
|
365
|
+
const content = Array.from({ length: 1e3 }, (_, i) => `Line_${String(i).padStart(4, "0")}_content`).join("\n");
|
|
366
|
+
await shared.write(filePath, content);
|
|
367
|
+
const chunk1 = await shared.read(filePath, 0, 100);
|
|
368
|
+
expect(chunk1).toContain("Line_0000_content");
|
|
369
|
+
expect(chunk1).toContain("Line_0099_content");
|
|
370
|
+
expect(chunk1).not.toContain("Line_0100_content");
|
|
371
|
+
const chunk2 = await shared.read(filePath, 500, 100);
|
|
372
|
+
expect(chunk2).toContain("Line_0500_content");
|
|
373
|
+
expect(chunk2).toContain("Line_0599_content");
|
|
374
|
+
expect(chunk2).not.toContain("Line_0499_content");
|
|
375
|
+
const chunk3 = await shared.read(filePath, 900, 100);
|
|
376
|
+
expect(chunk3).toContain("Line_0900_content");
|
|
377
|
+
expect(chunk3).toContain("Line_0999_content");
|
|
378
|
+
}, timeout);
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region src/tests/edit.ts
|
|
384
|
+
/**
|
|
385
|
+
* Register detailed edit() tests (single/multi occurrence, replaceAll,
|
|
386
|
+
* not found, special chars, multiline, delete, identical, unicode,
|
|
387
|
+
* whitespace, long strings, line endings, partial match).
|
|
388
|
+
*/
|
|
389
|
+
function registerEditTests(getShared, config, timeout) {
|
|
390
|
+
const { describe, it, expect } = config.runner;
|
|
391
|
+
describe("edit", () => {
|
|
392
|
+
it("should edit a single occurrence", async () => {
|
|
393
|
+
const shared = getShared();
|
|
394
|
+
const filePath = config.resolvePath("ed-single.txt");
|
|
395
|
+
await shared.write(filePath, "Hello world\nGoodbye world\nHello again");
|
|
396
|
+
const result = await shared.edit(filePath, "Goodbye", "Farewell");
|
|
397
|
+
expect(result.error).toBeUndefined();
|
|
398
|
+
expect(result.occurrences).toBe(1);
|
|
399
|
+
const content = await shared.read(filePath);
|
|
400
|
+
expect(content).toContain("Farewell world");
|
|
401
|
+
expect(content).not.toContain("Goodbye");
|
|
402
|
+
}, timeout);
|
|
403
|
+
it("should fail with multiple occurrences without replaceAll", async () => {
|
|
404
|
+
const shared = getShared();
|
|
405
|
+
const filePath = config.resolvePath("ed-multi-fail.txt");
|
|
406
|
+
await shared.write(filePath, "apple\nbanana\napple\norange\napple");
|
|
407
|
+
const result = await shared.edit(filePath, "apple", "pear", false);
|
|
408
|
+
expect(result.error).toBeDefined();
|
|
409
|
+
expect(result.error.toLowerCase()).toContain("multiple");
|
|
410
|
+
const content = await shared.read(filePath);
|
|
411
|
+
expect(content).toContain("apple");
|
|
412
|
+
expect(content).not.toContain("pear");
|
|
413
|
+
}, timeout);
|
|
414
|
+
it("should replace all occurrences with replaceAll=true", async () => {
|
|
415
|
+
const shared = getShared();
|
|
416
|
+
const filePath = config.resolvePath("ed-replace-all.txt");
|
|
417
|
+
await shared.write(filePath, "apple\nbanana\napple\norange\napple");
|
|
418
|
+
const result = await shared.edit(filePath, "apple", "pear", true);
|
|
419
|
+
expect(result.error).toBeUndefined();
|
|
420
|
+
expect(result.occurrences).toBe(3);
|
|
421
|
+
const execResult = await shared.execute(`cat ${filePath}`);
|
|
422
|
+
expect(execResult.output).not.toContain("apple");
|
|
423
|
+
expect(execResult.output.split("pear").length - 1).toBe(3);
|
|
424
|
+
}, timeout);
|
|
425
|
+
it("should return error when string is not found", async () => {
|
|
426
|
+
const shared = getShared();
|
|
427
|
+
const filePath = config.resolvePath("ed-not-found.txt");
|
|
428
|
+
await shared.write(filePath, "Hello world");
|
|
429
|
+
const result = await shared.edit(filePath, "nonexistent", "replacement");
|
|
430
|
+
expect(result.error).toBeDefined();
|
|
431
|
+
expect(result.error.toLowerCase()).toContain("not found");
|
|
432
|
+
}, timeout);
|
|
433
|
+
it("should return error for nonexistent file", async () => {
|
|
434
|
+
const filePath = config.resolvePath("ed-nonexistent-xyz.txt");
|
|
435
|
+
const result = await getShared().edit(filePath, "old", "new");
|
|
436
|
+
expect(result.error).toBeDefined();
|
|
437
|
+
expect(result.error.toLowerCase()).toContain("not found");
|
|
438
|
+
}, timeout);
|
|
439
|
+
it("should handle special characters and regex metacharacters", async () => {
|
|
440
|
+
const shared = getShared();
|
|
441
|
+
const filePath = config.resolvePath("ed-special.txt");
|
|
442
|
+
await shared.write(filePath, "Price: $100.00\nPattern: [a-z]*\nPath: /usr/bin");
|
|
443
|
+
expect((await shared.edit(filePath, "$100.00", "$200.00")).error).toBeUndefined();
|
|
444
|
+
expect((await shared.edit(filePath, "[a-z]*", "[0-9]+")).error).toBeUndefined();
|
|
445
|
+
const content = await shared.read(filePath);
|
|
446
|
+
expect(content).toContain("$200.00");
|
|
447
|
+
expect(content).toContain("[0-9]+");
|
|
448
|
+
}, timeout);
|
|
449
|
+
it("should handle multiline string replacement", async () => {
|
|
450
|
+
const shared = getShared();
|
|
451
|
+
const filePath = config.resolvePath("ed-multiline.txt");
|
|
452
|
+
await shared.write(filePath, "Line 1\nLine 2\nLine 3");
|
|
453
|
+
const result = await shared.edit(filePath, "Line 1\nLine 2", "Combined");
|
|
454
|
+
expect(result.error).toBeUndefined();
|
|
455
|
+
expect(result.occurrences).toBe(1);
|
|
456
|
+
const content = await shared.read(filePath);
|
|
457
|
+
expect(content).toContain("Combined");
|
|
458
|
+
expect(content).toContain("Line 3");
|
|
459
|
+
expect(content).not.toContain("Line 1");
|
|
460
|
+
}, timeout);
|
|
461
|
+
it("should delete content by replacing with empty string", async () => {
|
|
462
|
+
const shared = getShared();
|
|
463
|
+
const filePath = config.resolvePath("ed-delete.txt");
|
|
464
|
+
await shared.write(filePath, "Keep this\nDelete this part\nKeep this too");
|
|
465
|
+
const result = await shared.edit(filePath, "Delete this part\n", "");
|
|
466
|
+
expect(result.error).toBeUndefined();
|
|
467
|
+
expect(result.occurrences).toBe(1);
|
|
468
|
+
const content = await shared.read(filePath);
|
|
469
|
+
expect(content).toContain("Keep this");
|
|
470
|
+
expect(content).toContain("Keep this too");
|
|
471
|
+
expect(content).not.toContain("Delete this part");
|
|
472
|
+
}, timeout);
|
|
473
|
+
it("should handle identical old and new strings", async () => {
|
|
474
|
+
const shared = getShared();
|
|
475
|
+
const filePath = config.resolvePath("ed-identical.txt");
|
|
476
|
+
await shared.write(filePath, "Same text");
|
|
477
|
+
const result = await shared.edit(filePath, "Same text", "Same text");
|
|
478
|
+
expect(result.error).toBeUndefined();
|
|
479
|
+
expect(result.occurrences).toBe(1);
|
|
480
|
+
expect(await shared.read(filePath)).toContain("Same text");
|
|
481
|
+
}, timeout);
|
|
482
|
+
it("should handle unicode content", async () => {
|
|
483
|
+
const shared = getShared();
|
|
484
|
+
const filePath = config.resolvePath("ed-unicode.txt");
|
|
485
|
+
await shared.write(filePath, "Hello 👋 world\n世界 is beautiful");
|
|
486
|
+
const result = await shared.edit(filePath, "👋", "🌍");
|
|
487
|
+
expect(result.error).toBeUndefined();
|
|
488
|
+
expect(result.occurrences).toBe(1);
|
|
489
|
+
const content = await shared.read(filePath);
|
|
490
|
+
expect(content).toContain("🌍");
|
|
491
|
+
expect(content).not.toContain("👋");
|
|
492
|
+
}, timeout);
|
|
493
|
+
it("should handle whitespace-only strings", async () => {
|
|
494
|
+
const shared = getShared();
|
|
495
|
+
const filePath = config.resolvePath("ed-whitespace.txt");
|
|
496
|
+
await shared.write(filePath, "Line1 Line2");
|
|
497
|
+
const result = await shared.edit(filePath, " ", " ");
|
|
498
|
+
expect(result.error).toBeUndefined();
|
|
499
|
+
expect(result.occurrences).toBe(1);
|
|
500
|
+
expect(await shared.read(filePath)).toContain("Line1 Line2");
|
|
501
|
+
}, timeout);
|
|
502
|
+
it("should handle very long old and new strings", async () => {
|
|
503
|
+
const shared = getShared();
|
|
504
|
+
const filePath = config.resolvePath("ed-long.txt");
|
|
505
|
+
const oldString = "x".repeat(1e3);
|
|
506
|
+
const newString = "y".repeat(1e3);
|
|
507
|
+
await shared.write(filePath, `Start\n${oldString}\nEnd`);
|
|
508
|
+
const result = await shared.edit(filePath, oldString, newString);
|
|
509
|
+
expect(result.error).toBeUndefined();
|
|
510
|
+
expect(result.occurrences).toBe(1);
|
|
511
|
+
const content = await shared.read(filePath);
|
|
512
|
+
expect(content).toContain("y".repeat(100));
|
|
513
|
+
expect(content).not.toContain("x".repeat(100));
|
|
514
|
+
}, timeout);
|
|
515
|
+
it("should preserve line endings correctly", async () => {
|
|
516
|
+
const shared = getShared();
|
|
517
|
+
const filePath = config.resolvePath("ed-line-endings.txt");
|
|
518
|
+
await shared.write(filePath, "Line 1\nLine 2\nLine 3\n");
|
|
519
|
+
expect((await shared.edit(filePath, "Line 2", "Modified Line 2")).error).toBeUndefined();
|
|
520
|
+
const content = await shared.read(filePath);
|
|
521
|
+
expect(content).toContain("Line 1");
|
|
522
|
+
expect(content).toContain("Modified Line 2");
|
|
523
|
+
expect(content).toContain("Line 3");
|
|
524
|
+
}, timeout);
|
|
525
|
+
it("should edit a substring within a line", async () => {
|
|
526
|
+
const shared = getShared();
|
|
527
|
+
const filePath = config.resolvePath("ed-partial.txt");
|
|
528
|
+
await shared.write(filePath, "The quick brown fox jumps over the lazy dog");
|
|
529
|
+
const result = await shared.edit(filePath, "brown fox", "red cat");
|
|
530
|
+
expect(result.error).toBeUndefined();
|
|
531
|
+
expect(result.occurrences).toBe(1);
|
|
532
|
+
expect((await shared.execute(`cat ${filePath}`)).output).toContain("The quick red cat jumps");
|
|
533
|
+
}, timeout);
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
//#endregion
|
|
538
|
+
//#region src/tests/ls-info.ts
|
|
539
|
+
/**
|
|
540
|
+
* Register lsInfo() tests (absolute paths, files + subdirs, empty dir,
|
|
541
|
+
* nonexistent dir, hidden files, spaces, unicode, large dir, trailing slash,
|
|
542
|
+
* special characters).
|
|
543
|
+
*/
|
|
544
|
+
function registerLsInfoTests(getShared, config, timeout) {
|
|
545
|
+
const { describe, it, expect } = config.runner;
|
|
546
|
+
describe("lsInfo", () => {
|
|
547
|
+
it("should return absolute paths", async () => {
|
|
548
|
+
const shared = getShared();
|
|
549
|
+
const baseDir = config.resolvePath("li-absolute");
|
|
550
|
+
await shared.write(`${baseDir}/file.txt`, "content");
|
|
551
|
+
const result = await shared.lsInfo(baseDir);
|
|
552
|
+
expect(result.length).toBe(1);
|
|
553
|
+
expect(result[0].path).toBe(`${baseDir}/file.txt`);
|
|
554
|
+
}, timeout);
|
|
555
|
+
it("should list files and subdirectories", async () => {
|
|
556
|
+
const shared = getShared();
|
|
557
|
+
const baseDir = config.resolvePath("li-basic");
|
|
558
|
+
await shared.write(`${baseDir}/file1.txt`, "content1");
|
|
559
|
+
await shared.write(`${baseDir}/file2.txt`, "content2");
|
|
560
|
+
await shared.execute(`mkdir -p '${baseDir}/subdir'`);
|
|
561
|
+
const result = await shared.lsInfo(baseDir);
|
|
562
|
+
expect(result.length).toBe(3);
|
|
563
|
+
const paths = result.map((info) => info.path.replace(/\/$/, ""));
|
|
564
|
+
expect(paths).toContain(`${baseDir}/file1.txt`);
|
|
565
|
+
expect(paths).toContain(`${baseDir}/file2.txt`);
|
|
566
|
+
expect(paths).toContain(`${baseDir}/subdir`);
|
|
567
|
+
for (const info of result) if (info.path.replace(/\/$/, "").endsWith("/subdir")) expect(info.is_dir).toBe(true);
|
|
568
|
+
else expect(info.is_dir).toBe(false);
|
|
569
|
+
}, timeout);
|
|
570
|
+
it("should return empty list for empty directory", async () => {
|
|
571
|
+
const shared = getShared();
|
|
572
|
+
const emptyDir = config.resolvePath("li-empty-dir");
|
|
573
|
+
await shared.execute(`mkdir -p '${emptyDir}'`);
|
|
574
|
+
expect(await shared.lsInfo(emptyDir)).toEqual([]);
|
|
575
|
+
}, timeout);
|
|
576
|
+
it("should return empty list for nonexistent directory", async () => {
|
|
577
|
+
const nonexistentDir = config.resolvePath("li-does-not-exist-12345");
|
|
578
|
+
expect(await getShared().lsInfo(nonexistentDir)).toEqual([]);
|
|
579
|
+
}, timeout);
|
|
580
|
+
it("should include hidden files", async () => {
|
|
581
|
+
const shared = getShared();
|
|
582
|
+
const baseDir = config.resolvePath("li-hidden");
|
|
583
|
+
await shared.write(`${baseDir}/.hidden`, "hidden content");
|
|
584
|
+
await shared.write(`${baseDir}/visible.txt`, "visible content");
|
|
585
|
+
const paths = (await shared.lsInfo(baseDir)).map((info) => info.path);
|
|
586
|
+
expect(paths).toContain(`${baseDir}/.hidden`);
|
|
587
|
+
expect(paths).toContain(`${baseDir}/visible.txt`);
|
|
588
|
+
}, timeout);
|
|
589
|
+
it("should handle directories with spaces in names", async () => {
|
|
590
|
+
const shared = getShared();
|
|
591
|
+
const baseDir = config.resolvePath("li-spaces");
|
|
592
|
+
await shared.write(`${baseDir}/file with spaces.txt`, "content");
|
|
593
|
+
await shared.execute(`mkdir -p '${baseDir}/dir with spaces'`);
|
|
594
|
+
const paths = (await shared.lsInfo(baseDir)).map((info) => info.path.replace(/\/$/, ""));
|
|
595
|
+
expect(paths).toContain(`${baseDir}/file with spaces.txt`);
|
|
596
|
+
expect(paths).toContain(`${baseDir}/dir with spaces`);
|
|
597
|
+
}, timeout);
|
|
598
|
+
it("should handle unicode filenames", async () => {
|
|
599
|
+
const shared = getShared();
|
|
600
|
+
const baseDir = config.resolvePath("li-unicode");
|
|
601
|
+
await shared.write(`${baseDir}/\u6D4B\u8BD5\u6587\u4EF6.txt`, "content");
|
|
602
|
+
await shared.write(`${baseDir}/\u0444\u0430\u0439\u043B.txt`, "content");
|
|
603
|
+
expect((await shared.lsInfo(baseDir)).length).toBe(2);
|
|
604
|
+
}, timeout);
|
|
605
|
+
it("should handle large directories", async () => {
|
|
606
|
+
const shared = getShared();
|
|
607
|
+
const baseDir = config.resolvePath("li-large");
|
|
608
|
+
await shared.execute(`mkdir -p '${baseDir}' && cd '${baseDir}' && for i in \$(seq 0 49); do echo 'content' > file_\$(printf '%03d' \$i).txt; done`);
|
|
609
|
+
const result = await shared.lsInfo(baseDir);
|
|
610
|
+
expect(result.length).toBe(50);
|
|
611
|
+
const paths = result.map((info) => info.path);
|
|
612
|
+
expect(paths).toContain(`${baseDir}/file_000.txt`);
|
|
613
|
+
expect(paths).toContain(`${baseDir}/file_049.txt`);
|
|
614
|
+
}, timeout);
|
|
615
|
+
it("should handle trailing slash in path", async () => {
|
|
616
|
+
const shared = getShared();
|
|
617
|
+
const baseDir = config.resolvePath("li-trailing");
|
|
618
|
+
await shared.write(`${baseDir}/file.txt`, "content");
|
|
619
|
+
expect((await shared.lsInfo(`${baseDir}/`)).length).toBeGreaterThanOrEqual(1);
|
|
620
|
+
}, timeout);
|
|
621
|
+
it("should handle special characters in filenames", async () => {
|
|
622
|
+
const shared = getShared();
|
|
623
|
+
const baseDir = config.resolvePath("li-special-chars");
|
|
624
|
+
await shared.write(`${baseDir}/file(1).txt`, "content");
|
|
625
|
+
await shared.write(`${baseDir}/file-3.txt`, "content");
|
|
626
|
+
const paths = (await shared.lsInfo(baseDir)).map((info) => info.path);
|
|
627
|
+
expect(paths).toContain(`${baseDir}/file(1).txt`);
|
|
628
|
+
expect(paths).toContain(`${baseDir}/file-3.txt`);
|
|
629
|
+
}, timeout);
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region src/tests/grep-raw.ts
|
|
635
|
+
/**
|
|
636
|
+
* Register grepRaw() tests (basic search, glob filter, no matches,
|
|
637
|
+
* multi matches, literal matching, unicode, case sensitivity, special chars,
|
|
638
|
+
* empty dir, nested dirs, line numbers).
|
|
639
|
+
*/
|
|
640
|
+
function registerGrepRawTests(getShared, config, timeout) {
|
|
641
|
+
const { describe, it, expect } = config.runner;
|
|
642
|
+
describe("grepRaw", () => {
|
|
643
|
+
it("should find basic literal pattern matches", async () => {
|
|
644
|
+
const shared = getShared();
|
|
645
|
+
const baseDir = config.resolvePath("gr-basic");
|
|
646
|
+
await shared.write(`${baseDir}/file1.txt`, "Hello world\nGoodbye world");
|
|
647
|
+
await shared.write(`${baseDir}/file2.txt`, "Hello there\nGoodbye friend");
|
|
648
|
+
const result = await shared.grepRaw("Hello", baseDir);
|
|
649
|
+
expect(Array.isArray(result)).toBe(true);
|
|
650
|
+
const matches = result;
|
|
651
|
+
expect(matches.length).toBe(2);
|
|
652
|
+
const paths = matches.map((m) => m.path);
|
|
653
|
+
expect(paths.some((p) => p.includes("file1.txt"))).toBe(true);
|
|
654
|
+
expect(paths.some((p) => p.includes("file2.txt"))).toBe(true);
|
|
655
|
+
for (const match of matches) {
|
|
656
|
+
expect(match.line).toBe(1);
|
|
657
|
+
expect(match.text).toContain("Hello");
|
|
658
|
+
}
|
|
659
|
+
}, timeout);
|
|
660
|
+
it("should filter files with glob pattern", async () => {
|
|
661
|
+
const shared = getShared();
|
|
662
|
+
const baseDir = config.resolvePath("gr-glob");
|
|
663
|
+
await shared.write(`${baseDir}/test.txt`, "pattern_match");
|
|
664
|
+
await shared.write(`${baseDir}/test.py`, "pattern_match");
|
|
665
|
+
await shared.write(`${baseDir}/test.md`, "pattern_match");
|
|
666
|
+
const result = await shared.grepRaw("pattern_match", baseDir, "*.py");
|
|
667
|
+
expect(Array.isArray(result)).toBe(true);
|
|
668
|
+
const matches = result;
|
|
669
|
+
expect(matches.length).toBe(1);
|
|
670
|
+
expect(matches[0].path).toContain("test.py");
|
|
671
|
+
}, timeout);
|
|
672
|
+
it("should return empty array when no matches found", async () => {
|
|
673
|
+
const shared = getShared();
|
|
674
|
+
const baseDir = config.resolvePath("gr-no-match");
|
|
675
|
+
await shared.write(`${baseDir}/file.txt`, "Hello world");
|
|
676
|
+
const result = await shared.grepRaw("nonexistent_str", baseDir);
|
|
677
|
+
expect(Array.isArray(result)).toBe(true);
|
|
678
|
+
expect(result.length).toBe(0);
|
|
679
|
+
}, timeout);
|
|
680
|
+
it("should find multiple matches in a single file", async () => {
|
|
681
|
+
const shared = getShared();
|
|
682
|
+
const baseDir = config.resolvePath("gr-multi");
|
|
683
|
+
await shared.write(`${baseDir}/fruits.txt`, "apple\nbanana\napple\norange\napple");
|
|
684
|
+
const result = await shared.grepRaw("apple", baseDir);
|
|
685
|
+
expect(Array.isArray(result)).toBe(true);
|
|
686
|
+
const matches = result;
|
|
687
|
+
expect(matches.length).toBe(3);
|
|
688
|
+
expect(matches.map((m) => m.line)).toEqual([
|
|
689
|
+
1,
|
|
690
|
+
3,
|
|
691
|
+
5
|
|
692
|
+
]);
|
|
693
|
+
}, timeout);
|
|
694
|
+
it("should match literal strings not regex", async () => {
|
|
695
|
+
const shared = getShared();
|
|
696
|
+
const baseDir = config.resolvePath("gr-literal");
|
|
697
|
+
await shared.write(`${baseDir}/numbers.txt`, "test123\ntest456\nabcdef");
|
|
698
|
+
const result = await shared.grepRaw("test123", baseDir);
|
|
699
|
+
expect(Array.isArray(result)).toBe(true);
|
|
700
|
+
const matches = result;
|
|
701
|
+
expect(matches.length).toBe(1);
|
|
702
|
+
expect(matches[0].text).toContain("test123");
|
|
703
|
+
}, timeout);
|
|
704
|
+
it("should find unicode patterns", async () => {
|
|
705
|
+
const shared = getShared();
|
|
706
|
+
const baseDir = config.resolvePath("gr-unicode");
|
|
707
|
+
await shared.write(`${baseDir}/unicode.txt`, "Hello 世界\nПривет мир\n测试 pattern");
|
|
708
|
+
const result = await shared.grepRaw("世界", baseDir);
|
|
709
|
+
expect(Array.isArray(result)).toBe(true);
|
|
710
|
+
const matches = result;
|
|
711
|
+
expect(matches.length).toBe(1);
|
|
712
|
+
expect(matches[0].text).toContain("世界");
|
|
713
|
+
}, timeout);
|
|
714
|
+
it("should be case-sensitive by default", async () => {
|
|
715
|
+
const shared = getShared();
|
|
716
|
+
const baseDir = config.resolvePath("gr-case");
|
|
717
|
+
await shared.write(`${baseDir}/case.txt`, "Hello\nhello\nHELLO");
|
|
718
|
+
const result = await shared.grepRaw("Hello", baseDir);
|
|
719
|
+
expect(Array.isArray(result)).toBe(true);
|
|
720
|
+
const matches = result;
|
|
721
|
+
expect(matches.length).toBe(1);
|
|
722
|
+
expect(matches[0].text).toContain("Hello");
|
|
723
|
+
}, timeout);
|
|
724
|
+
it("should handle special characters as literals", async () => {
|
|
725
|
+
const shared = getShared();
|
|
726
|
+
const baseDir = config.resolvePath("gr-special");
|
|
727
|
+
await shared.write(`${baseDir}/special.txt`, "Price: $100\nPath: /usr/bin\nPattern: [a-z]*");
|
|
728
|
+
const result1 = await shared.grepRaw("$100", baseDir);
|
|
729
|
+
expect(Array.isArray(result1)).toBe(true);
|
|
730
|
+
const matches1 = result1;
|
|
731
|
+
expect(matches1.length).toBe(1);
|
|
732
|
+
expect(matches1[0].text).toContain("$100");
|
|
733
|
+
const result2 = await shared.grepRaw("[a-z]*", baseDir);
|
|
734
|
+
expect(Array.isArray(result2)).toBe(true);
|
|
735
|
+
const matches2 = result2;
|
|
736
|
+
expect(matches2.length).toBe(1);
|
|
737
|
+
expect(matches2[0].text).toContain("[a-z]*");
|
|
738
|
+
}, timeout);
|
|
739
|
+
it("should return empty array for empty directory", async () => {
|
|
740
|
+
const shared = getShared();
|
|
741
|
+
const baseDir = config.resolvePath("gr-empty-dir");
|
|
742
|
+
await shared.execute(`mkdir -p '${baseDir}'`);
|
|
743
|
+
const result = await shared.grepRaw("anything", baseDir);
|
|
744
|
+
expect(Array.isArray(result)).toBe(true);
|
|
745
|
+
expect(result.length).toBe(0);
|
|
746
|
+
}, timeout);
|
|
747
|
+
it("should search recursively across nested directories", async () => {
|
|
748
|
+
const shared = getShared();
|
|
749
|
+
const baseDir = config.resolvePath("gr-nested");
|
|
750
|
+
await shared.write(`${baseDir}/root.txt`, "target_nested here");
|
|
751
|
+
await shared.write(`${baseDir}/sub1/level1.txt`, "target_nested here");
|
|
752
|
+
await shared.write(`${baseDir}/sub1/sub2/level2.txt`, "target_nested here");
|
|
753
|
+
const result = await shared.grepRaw("target_nested", baseDir);
|
|
754
|
+
expect(Array.isArray(result)).toBe(true);
|
|
755
|
+
expect(result.length).toBe(3);
|
|
756
|
+
}, timeout);
|
|
757
|
+
it("should report correct line numbers", async () => {
|
|
758
|
+
const shared = getShared();
|
|
759
|
+
const baseDir = config.resolvePath("gr-line-nums");
|
|
760
|
+
const content = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`).join("\n");
|
|
761
|
+
await shared.write(`${baseDir}/long.txt`, content);
|
|
762
|
+
const result = await shared.grepRaw("Line 50", baseDir);
|
|
763
|
+
expect(Array.isArray(result)).toBe(true);
|
|
764
|
+
const matches = result;
|
|
765
|
+
expect(matches.length).toBe(1);
|
|
766
|
+
expect(matches[0].line).toBe(50);
|
|
767
|
+
}, timeout);
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
//#endregion
|
|
772
|
+
//#region src/tests/glob-info.ts
|
|
773
|
+
/**
|
|
774
|
+
* Register globInfo() tests (wildcard, recursive, no matches, directories,
|
|
775
|
+
* extension filter, hidden files, character classes, question mark,
|
|
776
|
+
* multiple extensions, deeply nested).
|
|
777
|
+
*/
|
|
778
|
+
function registerGlobInfoTests(getShared, config, timeout) {
|
|
779
|
+
const { describe, it, expect } = config.runner;
|
|
780
|
+
describe("globInfo", () => {
|
|
781
|
+
it("should match basic wildcard pattern", async () => {
|
|
782
|
+
const shared = getShared();
|
|
783
|
+
const baseDir = config.resolvePath("gl-basic");
|
|
784
|
+
await shared.write(`${baseDir}/file1.txt`, "content");
|
|
785
|
+
await shared.write(`${baseDir}/file2.txt`, "content");
|
|
786
|
+
await shared.write(`${baseDir}/file3.py`, "content");
|
|
787
|
+
const result = await shared.globInfo("*.txt", baseDir);
|
|
788
|
+
expect(result.length).toBe(2);
|
|
789
|
+
const paths = result.map((info) => info.path);
|
|
790
|
+
expect(paths).toContain("file1.txt");
|
|
791
|
+
expect(paths).toContain("file2.txt");
|
|
792
|
+
expect(paths.every((p) => !p.endsWith(".py"))).toBe(true);
|
|
793
|
+
}, timeout);
|
|
794
|
+
it("should match recursive pattern (**)", async () => {
|
|
795
|
+
const shared = getShared();
|
|
796
|
+
const baseDir = config.resolvePath("gl-recursive");
|
|
797
|
+
await shared.write(`${baseDir}/root.txt`, "content");
|
|
798
|
+
await shared.write(`${baseDir}/subdir1/nested1.txt`, "content");
|
|
799
|
+
await shared.write(`${baseDir}/subdir2/nested2.txt`, "content");
|
|
800
|
+
const result = await shared.globInfo("**/*.txt", baseDir);
|
|
801
|
+
expect(result.length).toBeGreaterThanOrEqual(2);
|
|
802
|
+
const paths = result.map((info) => info.path);
|
|
803
|
+
expect(paths.some((p) => p.includes("nested1.txt"))).toBe(true);
|
|
804
|
+
expect(paths.some((p) => p.includes("nested2.txt"))).toBe(true);
|
|
805
|
+
}, timeout);
|
|
806
|
+
it("should return empty array when no matches", async () => {
|
|
807
|
+
const shared = getShared();
|
|
808
|
+
const baseDir = config.resolvePath("gl-no-match");
|
|
809
|
+
await shared.write(`${baseDir}/file.txt`, "content");
|
|
810
|
+
expect(await shared.globInfo("*.py", baseDir)).toEqual([]);
|
|
811
|
+
}, timeout);
|
|
812
|
+
it("should include directories in results", async () => {
|
|
813
|
+
const shared = getShared();
|
|
814
|
+
const baseDir = config.resolvePath("gl-dirs");
|
|
815
|
+
await shared.execute(`mkdir -p '${baseDir}/dir1' '${baseDir}/dir2'`);
|
|
816
|
+
await shared.write(`${baseDir}/file.txt`, "content");
|
|
817
|
+
const result = await shared.globInfo("*", baseDir);
|
|
818
|
+
expect(result.length).toBe(3);
|
|
819
|
+
const dirCount = result.filter((info) => info.is_dir).length;
|
|
820
|
+
const fileCount = result.filter((info) => !info.is_dir).length;
|
|
821
|
+
expect(dirCount).toBe(2);
|
|
822
|
+
expect(fileCount).toBe(1);
|
|
823
|
+
}, timeout);
|
|
824
|
+
it("should match specific file extensions", async () => {
|
|
825
|
+
const shared = getShared();
|
|
826
|
+
const baseDir = config.resolvePath("gl-ext");
|
|
827
|
+
await shared.write(`${baseDir}/test.py`, "content");
|
|
828
|
+
await shared.write(`${baseDir}/test.txt`, "content");
|
|
829
|
+
await shared.write(`${baseDir}/test.md`, "content");
|
|
830
|
+
const result = await shared.globInfo("*.py", baseDir);
|
|
831
|
+
expect(result.length).toBe(1);
|
|
832
|
+
expect(result[0].path).toContain("test.py");
|
|
833
|
+
}, timeout);
|
|
834
|
+
it("should match hidden files explicitly", async () => {
|
|
835
|
+
const shared = getShared();
|
|
836
|
+
const baseDir = config.resolvePath("gl-hidden");
|
|
837
|
+
await shared.write(`${baseDir}/.hidden1`, "content");
|
|
838
|
+
await shared.write(`${baseDir}/.hidden2`, "content");
|
|
839
|
+
await shared.write(`${baseDir}/visible.txt`, "content");
|
|
840
|
+
const paths = (await shared.globInfo(".*", baseDir)).map((info) => info.path);
|
|
841
|
+
expect(paths.some((p) => p.includes(".hidden1") || p.includes(".hidden2"))).toBe(true);
|
|
842
|
+
expect(paths.every((p) => !p.includes("visible"))).toBe(true);
|
|
843
|
+
}, timeout);
|
|
844
|
+
it("should match character class patterns", async () => {
|
|
845
|
+
const shared = getShared();
|
|
846
|
+
const baseDir = config.resolvePath("gl-charclass");
|
|
847
|
+
await shared.write(`${baseDir}/file1.txt`, "content");
|
|
848
|
+
await shared.write(`${baseDir}/file2.txt`, "content");
|
|
849
|
+
await shared.write(`${baseDir}/file3.txt`, "content");
|
|
850
|
+
await shared.write(`${baseDir}/fileA.txt`, "content");
|
|
851
|
+
const result = await shared.globInfo("file[1-2].txt", baseDir);
|
|
852
|
+
expect(result.length).toBe(2);
|
|
853
|
+
const paths = result.map((info) => info.path);
|
|
854
|
+
expect(paths).toContain("file1.txt");
|
|
855
|
+
expect(paths).toContain("file2.txt");
|
|
856
|
+
expect(paths).not.toContain("file3.txt");
|
|
857
|
+
expect(paths).not.toContain("fileA.txt");
|
|
858
|
+
}, timeout);
|
|
859
|
+
it("should match single character wildcard (?)", async () => {
|
|
860
|
+
const shared = getShared();
|
|
861
|
+
const baseDir = config.resolvePath("gl-question");
|
|
862
|
+
await shared.write(`${baseDir}/file1.txt`, "content");
|
|
863
|
+
await shared.write(`${baseDir}/file2.txt`, "content");
|
|
864
|
+
await shared.write(`${baseDir}/file10.txt`, "content");
|
|
865
|
+
const result = await shared.globInfo("file?.txt", baseDir);
|
|
866
|
+
expect(result.length).toBe(2);
|
|
867
|
+
expect(result.map((info) => info.path)).not.toContain("file10.txt");
|
|
868
|
+
}, timeout);
|
|
869
|
+
it("should match multiple extensions separately", async () => {
|
|
870
|
+
const shared = getShared();
|
|
871
|
+
const baseDir = config.resolvePath("gl-multi-ext");
|
|
872
|
+
await shared.write(`${baseDir}/file.txt`, "content");
|
|
873
|
+
await shared.write(`${baseDir}/file.py`, "content");
|
|
874
|
+
await shared.write(`${baseDir}/file.md`, "content");
|
|
875
|
+
await shared.write(`${baseDir}/file.js`, "content");
|
|
876
|
+
const resultTxt = await shared.globInfo("*.txt", baseDir);
|
|
877
|
+
const resultPy = await shared.globInfo("*.py", baseDir);
|
|
878
|
+
expect(resultTxt.length).toBe(1);
|
|
879
|
+
expect(resultPy.length).toBe(1);
|
|
880
|
+
}, timeout);
|
|
881
|
+
it("should match deeply nested patterns", async () => {
|
|
882
|
+
const shared = getShared();
|
|
883
|
+
const baseDir = config.resolvePath("gl-deep");
|
|
884
|
+
await shared.write(`${baseDir}/a/b/c/d/deep.txt`, "content");
|
|
885
|
+
await shared.write(`${baseDir}/a/b/other.txt`, "content");
|
|
886
|
+
const result = await shared.globInfo("**/deep.txt", baseDir);
|
|
887
|
+
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
888
|
+
expect(result.some((info) => info.path.includes("deep.txt"))).toBe(true);
|
|
889
|
+
}, timeout);
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
//#endregion
|
|
894
|
+
//#region src/tests/initial-files.ts
|
|
895
|
+
/**
|
|
896
|
+
* Register initialFiles tests (basic, deeply nested, empty).
|
|
897
|
+
*
|
|
898
|
+
* These tests create temporary sandboxes and tear them down immediately.
|
|
899
|
+
*/
|
|
900
|
+
function registerInitialFilesTests(config, timeout) {
|
|
901
|
+
const { describe, it, expect } = config.runner;
|
|
902
|
+
describe("initialFiles", () => {
|
|
903
|
+
it("should create sandbox with initial files", async () => {
|
|
904
|
+
const initPath = config.resolvePath("init-test.txt");
|
|
905
|
+
const nestedPath = config.resolvePath("nested/dir/file.txt");
|
|
906
|
+
const tmp = await withRetry(() => config.createSandbox({ initialFiles: {
|
|
907
|
+
[initPath]: "Hello from initial file!",
|
|
908
|
+
[nestedPath]: "Nested content"
|
|
909
|
+
} }));
|
|
910
|
+
try {
|
|
911
|
+
expect(tmp.isRunning).toBe(true);
|
|
912
|
+
const result1 = await tmp.execute(`cat ${initPath}`);
|
|
913
|
+
expect(result1.exitCode).toBe(0);
|
|
914
|
+
expect(result1.output.trim()).toBe("Hello from initial file!");
|
|
915
|
+
const result2 = await tmp.execute(`cat ${nestedPath}`);
|
|
916
|
+
expect(result2.exitCode).toBe(0);
|
|
917
|
+
expect(result2.output.trim()).toBe("Nested content");
|
|
918
|
+
} finally {
|
|
919
|
+
await config.closeSandbox?.(tmp);
|
|
920
|
+
}
|
|
921
|
+
}, timeout);
|
|
922
|
+
it("should create sandbox with deeply nested initial files", async () => {
|
|
923
|
+
const buttonPath = config.resolvePath("src/components/Button/index.tsx");
|
|
924
|
+
const helperPath = config.resolvePath("src/utils/helpers/string.ts");
|
|
925
|
+
const tmp = await withRetry(() => config.createSandbox({ initialFiles: {
|
|
926
|
+
[buttonPath]: "export const Button = () => <button>Click</button>;",
|
|
927
|
+
[helperPath]: "export const capitalize = (s: string) => s.toUpperCase();"
|
|
928
|
+
} }));
|
|
929
|
+
try {
|
|
930
|
+
expect(tmp.isRunning).toBe(true);
|
|
931
|
+
expect((await tmp.execute(`cat ${buttonPath}`)).output).toContain("Button");
|
|
932
|
+
expect((await tmp.execute(`cat ${helperPath}`)).output).toContain("capitalize");
|
|
933
|
+
} finally {
|
|
934
|
+
await config.closeSandbox?.(tmp);
|
|
935
|
+
}
|
|
936
|
+
}, timeout);
|
|
937
|
+
it("should create sandbox with empty initialFiles object", async () => {
|
|
938
|
+
const tmp = await withRetry(() => config.createSandbox({ initialFiles: {} }));
|
|
939
|
+
try {
|
|
940
|
+
expect(tmp.isRunning).toBe(true);
|
|
941
|
+
const result = await tmp.execute("echo \"Works!\"");
|
|
942
|
+
expect(result.exitCode).toBe(0);
|
|
943
|
+
expect(result.output).toContain("Works!");
|
|
944
|
+
} finally {
|
|
945
|
+
await config.closeSandbox?.(tmp);
|
|
946
|
+
}
|
|
947
|
+
}, timeout);
|
|
948
|
+
it("should make initialFiles accessible via read()", async () => {
|
|
949
|
+
const filePath = config.resolvePath("init-read-test.txt");
|
|
950
|
+
const tmp = await withRetry(() => config.createSandbox({ initialFiles: { [filePath]: "Content for read test" } }));
|
|
951
|
+
try {
|
|
952
|
+
expect(await tmp.read(filePath)).toContain("Content for read test");
|
|
953
|
+
} finally {
|
|
954
|
+
await config.closeSandbox?.(tmp);
|
|
955
|
+
}
|
|
956
|
+
}, timeout);
|
|
957
|
+
it("should make initialFiles accessible via downloadFiles()", async () => {
|
|
958
|
+
const filePath = config.resolvePath("init-download-test.txt");
|
|
959
|
+
const tmp = await withRetry(() => config.createSandbox({ initialFiles: { [filePath]: "Content for download test" } }));
|
|
960
|
+
try {
|
|
961
|
+
const results = await tmp.downloadFiles([filePath]);
|
|
962
|
+
expect(results[0].error).toBeNull();
|
|
963
|
+
expect(results[0].content).not.toBeNull();
|
|
964
|
+
expect(new TextDecoder().decode(results[0].content)).toContain("Content for download test");
|
|
965
|
+
} finally {
|
|
966
|
+
await config.closeSandbox?.(tmp);
|
|
967
|
+
}
|
|
968
|
+
}, timeout);
|
|
969
|
+
it("should execute a script created via initialFiles", async () => {
|
|
970
|
+
const scriptPath = config.resolvePath("init-script.sh");
|
|
971
|
+
const tmp = await withRetry(() => config.createSandbox({ initialFiles: { [scriptPath]: "#!/bin/sh\necho \"Hello from initialFiles script\"" } }));
|
|
972
|
+
try {
|
|
973
|
+
const result = await tmp.execute(`sh ${scriptPath}`);
|
|
974
|
+
expect(result.exitCode).toBe(0);
|
|
975
|
+
expect(result.output.trim()).toBe("Hello from initialFiles script");
|
|
976
|
+
} finally {
|
|
977
|
+
await config.closeSandbox?.(tmp);
|
|
978
|
+
}
|
|
979
|
+
}, timeout);
|
|
980
|
+
it("should make initialFiles in subdirectories visible via lsInfo()", async () => {
|
|
981
|
+
const dirPath = config.resolvePath("init-ls-dir");
|
|
982
|
+
const filePath = `${dirPath}/file.txt`;
|
|
983
|
+
const tmp = await withRetry(() => config.createSandbox({ initialFiles: { [filePath]: "ls test content" } }));
|
|
984
|
+
try {
|
|
985
|
+
expect((await tmp.lsInfo(dirPath)).map((e) => e.path.replace(/\/$/, ""))).toContain(filePath);
|
|
986
|
+
} finally {
|
|
987
|
+
await config.closeSandbox?.(tmp);
|
|
988
|
+
}
|
|
989
|
+
}, timeout);
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
//#endregion
|
|
994
|
+
//#region src/tests/integration.ts
|
|
995
|
+
/**
|
|
996
|
+
* Register integration workflow tests that combine multiple operations.
|
|
997
|
+
*/
|
|
998
|
+
function registerIntegrationTests(getShared, config, timeout) {
|
|
999
|
+
const { describe, it, expect } = config.runner;
|
|
1000
|
+
describe("integration workflows", () => {
|
|
1001
|
+
it("should complete a write-read-edit-read workflow", async () => {
|
|
1002
|
+
const shared = getShared();
|
|
1003
|
+
const filePath = config.resolvePath("intg-workflow.txt");
|
|
1004
|
+
expect((await shared.write(filePath, "Original content")).error).toBeUndefined();
|
|
1005
|
+
expect(await shared.read(filePath)).toContain("Original content");
|
|
1006
|
+
expect((await shared.edit(filePath, "Original", "Modified")).error).toBeUndefined();
|
|
1007
|
+
const updatedContent = await shared.read(filePath);
|
|
1008
|
+
expect(updatedContent).toContain("Modified content");
|
|
1009
|
+
expect(updatedContent).not.toContain("Original");
|
|
1010
|
+
}, timeout);
|
|
1011
|
+
it("should handle complex directory operations", async () => {
|
|
1012
|
+
const shared = getShared();
|
|
1013
|
+
const baseDir = config.resolvePath("intg-complex");
|
|
1014
|
+
await shared.write(`${baseDir}/root.txt`, "root file");
|
|
1015
|
+
await shared.write(`${baseDir}/subdir1/file1.txt`, "file 1");
|
|
1016
|
+
await shared.write(`${baseDir}/subdir1/file2.py`, "file 2");
|
|
1017
|
+
await shared.write(`${baseDir}/subdir2/file3.txt`, "file 3");
|
|
1018
|
+
const lsPaths = (await shared.lsInfo(baseDir)).map((info) => info.path.replace(/\/$/, ""));
|
|
1019
|
+
expect(lsPaths).toContain(`${baseDir}/root.txt`);
|
|
1020
|
+
expect(lsPaths).toContain(`${baseDir}/subdir1`);
|
|
1021
|
+
expect(lsPaths).toContain(`${baseDir}/subdir2`);
|
|
1022
|
+
expect((await shared.globInfo("**/*.txt", baseDir)).length).toBe(3);
|
|
1023
|
+
const grepResult = await shared.grepRaw("file", baseDir);
|
|
1024
|
+
expect(Array.isArray(grepResult)).toBe(true);
|
|
1025
|
+
expect(grepResult.length).toBeGreaterThanOrEqual(3);
|
|
1026
|
+
}, timeout);
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
//#endregion
|
|
1031
|
+
//#region src/sandbox.ts
|
|
1032
|
+
/**
|
|
1033
|
+
* Standard integration test suite for sandbox providers.
|
|
1034
|
+
*
|
|
1035
|
+
* This module provides a reusable set of integration tests that verify
|
|
1036
|
+
* common sandbox behavior across all provider implementations. Each provider
|
|
1037
|
+
* calls `sandboxStandardTests()` with its own configuration to run these
|
|
1038
|
+
* tests against its sandbox implementation.
|
|
1039
|
+
*
|
|
1040
|
+
* **Design**: A single shared sandbox is created once (in `beforeAll`) and
|
|
1041
|
+
* reused across all command-execution and file-operation tests. Only
|
|
1042
|
+
* lifecycle tests that verify create/close behaviour and initialFiles tests
|
|
1043
|
+
* that require a fresh sandbox spin up a temporary instance — and they tear
|
|
1044
|
+
* it down immediately inside the test so the concurrent sandbox count never
|
|
1045
|
+
* exceeds 2.
|
|
1046
|
+
*
|
|
1047
|
+
* Tests cover:
|
|
1048
|
+
* - Sandbox lifecycle (create, isRunning, close, two-step initialization)
|
|
1049
|
+
* - Command execution (echo, exit codes, multiline output, stderr, env vars)
|
|
1050
|
+
* - File operations (upload, download, read, write, edit, multiple files)
|
|
1051
|
+
* - write() (new file, parent dirs, existing file, special chars, unicode, long content)
|
|
1052
|
+
* - read() (basic, nonexistent, offset, limit, offset+limit, unicode, chunked)
|
|
1053
|
+
* - edit() (single/multi occurrence, replaceAll, not found, special chars, multiline, unicode)
|
|
1054
|
+
* - lsInfo() (basic listing, empty dir, hidden files, large dir, absolute paths)
|
|
1055
|
+
* - grepRaw() (basic search, glob filter, case sensitivity, nested dirs, unicode)
|
|
1056
|
+
* - globInfo() (wildcard, recursive, extension filter, character classes, deeply nested)
|
|
1057
|
+
* - Initial files support (basic, nested, empty)
|
|
1058
|
+
* - Integration workflows (write-read-edit, complex directory operations)
|
|
1059
|
+
* - Error handling (file not found, non-existent command)
|
|
1060
|
+
*/
|
|
1061
|
+
/**
|
|
1062
|
+
* Default number of retry attempts for sandbox creation.
|
|
1063
|
+
*/
|
|
1064
|
+
const DEFAULT_MAX_RETRIES = 5;
|
|
1065
|
+
/**
|
|
1066
|
+
* Default delay in milliseconds between retries.
|
|
1067
|
+
*/
|
|
1068
|
+
const DEFAULT_RETRY_DELAY_MS = 15e3;
|
|
1069
|
+
/**
|
|
1070
|
+
* Retry an async operation with a fixed delay between attempts.
|
|
1071
|
+
*
|
|
1072
|
+
* Useful for working around transient sandbox concurrency limits:
|
|
1073
|
+
* when a provider rejects creation because the organisation has too
|
|
1074
|
+
* many running sandboxes, waiting a short while and retrying usually
|
|
1075
|
+
* succeeds once a previous sandbox finishes shutting down.
|
|
1076
|
+
*
|
|
1077
|
+
* @param fn - The async operation to attempt
|
|
1078
|
+
* @param maxRetries - Maximum number of attempts (default: 3)
|
|
1079
|
+
* @param delayMs - Milliseconds to wait between attempts (default: 10 000)
|
|
1080
|
+
* @returns The result of the first successful attempt
|
|
1081
|
+
*
|
|
1082
|
+
* @example
|
|
1083
|
+
* ```ts
|
|
1084
|
+
* const sandbox = await withRetry(() => DenoSandbox.create({ memoryMb: 768 }));
|
|
1085
|
+
* ```
|
|
1086
|
+
*/
|
|
1087
|
+
async function withRetry(fn, maxRetries = DEFAULT_MAX_RETRIES, delayMs = DEFAULT_RETRY_DELAY_MS) {
|
|
1088
|
+
let lastError;
|
|
1089
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) try {
|
|
1090
|
+
return await fn();
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
lastError = error;
|
|
1093
|
+
if (attempt < maxRetries) await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1094
|
+
}
|
|
1095
|
+
throw lastError;
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Run the standard sandbox integration tests against a provider.
|
|
1099
|
+
*
|
|
1100
|
+
* A single shared sandbox is created in `beforeAll` and reused for the
|
|
1101
|
+
* majority of tests (command execution, file operations). Tests that
|
|
1102
|
+
* inherently need their own sandbox (lifecycle close/init, initialFiles)
|
|
1103
|
+
* create a temporary one and destroy it immediately, so the concurrent
|
|
1104
|
+
* sandbox count never exceeds **2** (shared + 1 temporary).
|
|
1105
|
+
*
|
|
1106
|
+
* @example
|
|
1107
|
+
* ```ts
|
|
1108
|
+
* import { sandboxStandardTests } from "@langchain/sandbox-standard-tests/vitest";
|
|
1109
|
+
* import { ModalSandbox } from "./sandbox.js";
|
|
1110
|
+
*
|
|
1111
|
+
* sandboxStandardTests({
|
|
1112
|
+
* name: "ModalSandbox",
|
|
1113
|
+
* skip: !process.env.MODAL_TOKEN_ID,
|
|
1114
|
+
* timeout: 180_000,
|
|
1115
|
+
* createSandbox: (opts) =>
|
|
1116
|
+
* ModalSandbox.create({ imageName: "alpine:3.21", ...opts }),
|
|
1117
|
+
* createUninitializedSandbox: () =>
|
|
1118
|
+
* new ModalSandbox({ imageName: "alpine:3.21" }),
|
|
1119
|
+
* closeSandbox: (sb) => sb.close(),
|
|
1120
|
+
* resolvePath: (name) => `/tmp/${name}`,
|
|
1121
|
+
* });
|
|
1122
|
+
* ```
|
|
1123
|
+
*/
|
|
1124
|
+
function sandboxStandardTests(config) {
|
|
1125
|
+
const { describe, beforeAll, afterAll } = config.runner;
|
|
1126
|
+
const timeout = config.timeout ?? 12e4;
|
|
1127
|
+
let outerDescribe;
|
|
1128
|
+
if (config.skip) outerDescribe = describe.skip ?? (() => {});
|
|
1129
|
+
else if (config.sequential) outerDescribe = describe.sequential ?? describe;
|
|
1130
|
+
else outerDescribe = describe;
|
|
1131
|
+
outerDescribe(`${config.name} Standard Tests`, () => {
|
|
1132
|
+
let shared;
|
|
1133
|
+
const getShared = () => shared;
|
|
1134
|
+
beforeAll(async () => {
|
|
1135
|
+
shared = await withRetry(() => config.createSandbox());
|
|
1136
|
+
}, timeout);
|
|
1137
|
+
afterAll(async () => {
|
|
1138
|
+
try {
|
|
1139
|
+
await config.closeSandbox?.(shared);
|
|
1140
|
+
} catch {}
|
|
1141
|
+
}, timeout);
|
|
1142
|
+
registerLifecycleTests(getShared, config, timeout);
|
|
1143
|
+
registerCommandExecutionTests(getShared, config, timeout);
|
|
1144
|
+
registerFileOperationTests(getShared, config, timeout);
|
|
1145
|
+
registerWriteTests(getShared, config, timeout);
|
|
1146
|
+
registerReadTests(getShared, config, timeout);
|
|
1147
|
+
registerEditTests(getShared, config, timeout);
|
|
1148
|
+
registerLsInfoTests(getShared, config, timeout);
|
|
1149
|
+
registerGrepRawTests(getShared, config, timeout);
|
|
1150
|
+
registerGlobInfoTests(getShared, config, timeout);
|
|
1151
|
+
registerInitialFilesTests(config, timeout);
|
|
1152
|
+
registerIntegrationTests(getShared, config, timeout);
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
//#endregion
|
|
1157
|
+
Object.defineProperty(exports, 'sandboxStandardTests', {
|
|
1158
|
+
enumerable: true,
|
|
1159
|
+
get: function () {
|
|
1160
|
+
return sandboxStandardTests;
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
Object.defineProperty(exports, 'withRetry', {
|
|
1164
|
+
enumerable: true,
|
|
1165
|
+
get: function () {
|
|
1166
|
+
return withRetry;
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
//# sourceMappingURL=sandbox-C22TOwhI.cjs.map
|