@liquidmetal-ai/precip 1.0.0
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/.prettierrc +9 -0
- package/CHANGELOG.md +8 -0
- package/eslint.config.mjs +28 -0
- package/package.json +53 -0
- package/src/engine/agent.ts +478 -0
- package/src/engine/llm-provider.test.ts +275 -0
- package/src/engine/llm-provider.ts +330 -0
- package/src/engine/stream-parser.ts +170 -0
- package/src/index.ts +142 -0
- package/src/mounts/mount-manager.test.ts +516 -0
- package/src/mounts/mount-manager.ts +327 -0
- package/src/mounts/mount-registry.ts +196 -0
- package/src/mounts/zod-to-string.test.ts +154 -0
- package/src/mounts/zod-to-string.ts +213 -0
- package/src/presets/agent-tools.ts +57 -0
- package/src/presets/index.ts +5 -0
- package/src/sandbox/README.md +1321 -0
- package/src/sandbox/bridges/README.md +571 -0
- package/src/sandbox/bridges/actor.test.ts +229 -0
- package/src/sandbox/bridges/actor.ts +195 -0
- package/src/sandbox/bridges/bridge-fixes.test.ts +614 -0
- package/src/sandbox/bridges/bucket.test.ts +300 -0
- package/src/sandbox/bridges/cleanup-reproduction.test.ts +225 -0
- package/src/sandbox/bridges/console-multiple.test.ts +187 -0
- package/src/sandbox/bridges/console.test.ts +157 -0
- package/src/sandbox/bridges/console.ts +122 -0
- package/src/sandbox/bridges/fetch.ts +93 -0
- package/src/sandbox/bridges/index.ts +78 -0
- package/src/sandbox/bridges/readable-stream.ts +323 -0
- package/src/sandbox/bridges/response.test.ts +154 -0
- package/src/sandbox/bridges/response.ts +123 -0
- package/src/sandbox/bridges/review-fixes.test.ts +331 -0
- package/src/sandbox/bridges/search.test.ts +475 -0
- package/src/sandbox/bridges/search.ts +264 -0
- package/src/sandbox/bridges/shared/body-methods.ts +93 -0
- package/src/sandbox/bridges/shared/cleanup.ts +112 -0
- package/src/sandbox/bridges/shared/convert.ts +76 -0
- package/src/sandbox/bridges/shared/headers.ts +181 -0
- package/src/sandbox/bridges/shared/index.ts +36 -0
- package/src/sandbox/bridges/shared/json-helpers.ts +77 -0
- package/src/sandbox/bridges/shared/path-parser.ts +109 -0
- package/src/sandbox/bridges/shared/promise-helper.ts +108 -0
- package/src/sandbox/bridges/shared/registry-setup.ts +84 -0
- package/src/sandbox/bridges/shared/response-object.ts +280 -0
- package/src/sandbox/bridges/shared/result-builder.ts +130 -0
- package/src/sandbox/bridges/shared/scope-helpers.ts +44 -0
- package/src/sandbox/bridges/shared/stream-reader.ts +90 -0
- package/src/sandbox/bridges/storage-bridge.test.ts +893 -0
- package/src/sandbox/bridges/storage.ts +421 -0
- package/src/sandbox/bridges/text-decoder.ts +190 -0
- package/src/sandbox/bridges/text-encoder.ts +102 -0
- package/src/sandbox/bridges/types.ts +39 -0
- package/src/sandbox/bridges/utils.ts +123 -0
- package/src/sandbox/index.ts +6 -0
- package/src/sandbox/quickjs-wasm.d.ts +9 -0
- package/src/sandbox/sandbox.test.ts +191 -0
- package/src/sandbox/sandbox.ts +831 -0
- package/src/sandbox/test-helper.ts +43 -0
- package/src/sandbox/test-mocks.ts +154 -0
- package/src/sandbox/user-stream.test.ts +77 -0
- package/src/skills/frontmatter.test.ts +305 -0
- package/src/skills/frontmatter.ts +200 -0
- package/src/skills/index.ts +9 -0
- package/src/skills/skills-loader.test.ts +237 -0
- package/src/skills/skills-loader.ts +200 -0
- package/src/tools/actor-storage-tools.ts +250 -0
- package/src/tools/code-tools.test.ts +199 -0
- package/src/tools/code-tools.ts +444 -0
- package/src/tools/file-tools.ts +206 -0
- package/src/tools/registry.ts +125 -0
- package/src/tools/script-tools.ts +145 -0
- package/src/tools/smartbucket-tools.ts +203 -0
- package/src/tools/sql-tools.ts +213 -0
- package/src/tools/tool-factory.ts +119 -0
- package/src/types.ts +512 -0
- package/tsconfig.eslint.json +5 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +33 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for bridge fixes: leaks, edge cases, and error handling
|
|
3
|
+
*
|
|
4
|
+
* These tests verify the fixes for issues identified in code review:
|
|
5
|
+
* 1. TextDecoder memory leak (cleanup handler)
|
|
6
|
+
* 2. Bridge installer error handling (try-catch)
|
|
7
|
+
* 3. Promise state check error handling
|
|
8
|
+
* 4. ReadableStream locked property (dynamic getter)
|
|
9
|
+
* 5. Error formatting with circular references (safe stringify)
|
|
10
|
+
* 6. TextDecoder silent fallback (explicit error)
|
|
11
|
+
* 7. CoalescingReader state reset on error
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
15
|
+
import { executeWithAsyncHost } from '../test-helper.js';
|
|
16
|
+
import type { BridgeInstaller } from './types.js';
|
|
17
|
+
import { sandboxAsync, sandboxSync } from '../../types.js';
|
|
18
|
+
import { createHttpbinMocks } from '../test-mocks.js';
|
|
19
|
+
|
|
20
|
+
let mockFetch: any;
|
|
21
|
+
|
|
22
|
+
function setupMockFetch() {
|
|
23
|
+
const mocks = createHttpbinMocks();
|
|
24
|
+
mockFetch = vi.fn(async (url: string, _init?: RequestInit) => {
|
|
25
|
+
const urlKey = url.split('?')[0];
|
|
26
|
+
const mock = mocks.get(urlKey);
|
|
27
|
+
if (!mock) {
|
|
28
|
+
throw new Error(`No mock for: ${urlKey}`);
|
|
29
|
+
}
|
|
30
|
+
if (mock.delay) {
|
|
31
|
+
await new Promise(resolve => setTimeout(resolve, mock.delay));
|
|
32
|
+
}
|
|
33
|
+
return new Response(mock.body, {
|
|
34
|
+
status: mock.status ?? 200,
|
|
35
|
+
statusText: mock.statusText ?? 'OK',
|
|
36
|
+
headers: new Headers(mock.headers)
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
globalThis.fetch = mockFetch;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function restoreFetch() {
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('TextDecoder Cleanup', () => {
|
|
47
|
+
it('should not leak decoder instances across multiple executions', async () => {
|
|
48
|
+
// Run many iterations using TextDecoder with streaming mode
|
|
49
|
+
// If cleanup isn't working, memory would grow
|
|
50
|
+
for (let i = 0; i < 20; i++) {
|
|
51
|
+
const result = await executeWithAsyncHost(
|
|
52
|
+
`
|
|
53
|
+
const decoder = new TextDecoder();
|
|
54
|
+
// Use streaming mode which creates stateful decoder
|
|
55
|
+
const chunk1 = new Uint8Array([104, 101]); // "he"
|
|
56
|
+
const chunk2 = new Uint8Array([108, 108, 111]); // "llo"
|
|
57
|
+
|
|
58
|
+
const text1 = decoder.decode(chunk1, { stream: true });
|
|
59
|
+
const text2 = decoder.decode(chunk2, { stream: true });
|
|
60
|
+
const text3 = decoder.decode(); // flush
|
|
61
|
+
|
|
62
|
+
return text1 + text2 + text3
|
|
63
|
+
`,
|
|
64
|
+
{}
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(result.success).toBe(true);
|
|
68
|
+
expect(result.result).toBe('hello');
|
|
69
|
+
}
|
|
70
|
+
// If we get here without memory issues, cleanup is working
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should handle multiple TextDecoder instances in single execution', async () => {
|
|
74
|
+
const result = await executeWithAsyncHost(
|
|
75
|
+
`
|
|
76
|
+
const decoders = [];
|
|
77
|
+
for (let i = 0; i < 10; i++) {
|
|
78
|
+
decoders.push(new TextDecoder());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Use all decoders
|
|
82
|
+
const results = decoders.map((d, i) => {
|
|
83
|
+
const bytes = new Uint8Array([48 + i]); // '0' to '9'
|
|
84
|
+
return d.decode(bytes);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return results.join('')
|
|
88
|
+
`,
|
|
89
|
+
{}
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
expect(result.success).toBe(true);
|
|
93
|
+
expect(result.result).toBe('0123456789');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('Bridge Installer Error Handling', () => {
|
|
98
|
+
it('should handle throwing bridge installer gracefully', async () => {
|
|
99
|
+
const throwingInstaller: BridgeInstaller = () => {
|
|
100
|
+
throw new Error('Bridge installer failed');
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const result = await executeWithAsyncHost(
|
|
104
|
+
'return 42',
|
|
105
|
+
{},
|
|
106
|
+
{ bridgeInstallers: [throwingInstaller] }
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(result.success).toBe(false);
|
|
110
|
+
expect(result.error).toContain('Bridge installer failed');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should execute installers in order and stop on first error', async () => {
|
|
114
|
+
const installOrder: number[] = [];
|
|
115
|
+
|
|
116
|
+
const installer1: BridgeInstaller = () => {
|
|
117
|
+
installOrder.push(1);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const installer2: BridgeInstaller = () => {
|
|
121
|
+
installOrder.push(2);
|
|
122
|
+
throw new Error('Installer 2 failed');
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const installer3: BridgeInstaller = () => {
|
|
126
|
+
installOrder.push(3); // Should never be called
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const result = await executeWithAsyncHost(
|
|
130
|
+
'return 42',
|
|
131
|
+
{},
|
|
132
|
+
{ bridgeInstallers: [installer1, installer2, installer3] }
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(result.success).toBe(false);
|
|
136
|
+
expect(installOrder).toEqual([1, 2]); // installer3 should not run
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should cleanup properly after installer error', async () => {
|
|
140
|
+
// Run multiple times to ensure no resource leaks
|
|
141
|
+
for (let i = 0; i < 5; i++) {
|
|
142
|
+
const throwingInstaller: BridgeInstaller = () => {
|
|
143
|
+
throw new Error('Intentional failure');
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const result = await executeWithAsyncHost(
|
|
147
|
+
'return 42',
|
|
148
|
+
{},
|
|
149
|
+
{ bridgeInstallers: [throwingInstaller] }
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
expect(result.success).toBe(false);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('Promise State Error Handling', () => {
|
|
158
|
+
it('should handle promise rejection with string error', async () => {
|
|
159
|
+
const result = await executeWithAsyncHost(
|
|
160
|
+
`
|
|
161
|
+
throw new Error('Simple error message')
|
|
162
|
+
`,
|
|
163
|
+
{}
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(result.success).toBe(false);
|
|
167
|
+
expect(result.error).toContain('Simple error message');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should handle promise rejection with object error', async () => {
|
|
171
|
+
const result = await executeWithAsyncHost(
|
|
172
|
+
`
|
|
173
|
+
const err = { code: 'ERR_001', message: 'Complex error' };
|
|
174
|
+
throw err
|
|
175
|
+
`,
|
|
176
|
+
{}
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(result.success).toBe(false);
|
|
180
|
+
expect(result.error).toBeDefined();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('should handle async function throwing error', async () => {
|
|
184
|
+
const globals = {
|
|
185
|
+
asyncThrow: sandboxAsync(async () => {
|
|
186
|
+
throw new Error('Async error');
|
|
187
|
+
})
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result = await executeWithAsyncHost(
|
|
191
|
+
`
|
|
192
|
+
return await asyncThrow()
|
|
193
|
+
`,
|
|
194
|
+
globals
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
expect(result.success).toBe(false);
|
|
198
|
+
expect(result.error).toContain('Async error');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('Safe Stringify for Error Formatting', () => {
|
|
203
|
+
it('should handle circular references in errors', async () => {
|
|
204
|
+
const result = await executeWithAsyncHost(
|
|
205
|
+
`
|
|
206
|
+
const obj = { a: 1 };
|
|
207
|
+
obj.self = obj; // Create circular reference
|
|
208
|
+
throw obj
|
|
209
|
+
`,
|
|
210
|
+
{}
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(result.success).toBe(false);
|
|
214
|
+
// Should not crash - error should be stringified safely
|
|
215
|
+
expect(result.error).toBeDefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should handle undefined error', async () => {
|
|
219
|
+
const result = await executeWithAsyncHost(
|
|
220
|
+
`
|
|
221
|
+
throw undefined
|
|
222
|
+
`,
|
|
223
|
+
{}
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(result.success).toBe(false);
|
|
227
|
+
expect(result.error).toBeDefined();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should handle null error', async () => {
|
|
231
|
+
const result = await executeWithAsyncHost(
|
|
232
|
+
`
|
|
233
|
+
throw null
|
|
234
|
+
`,
|
|
235
|
+
{}
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(result.success).toBe(false);
|
|
239
|
+
expect(result.error).toBeDefined();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('TextDecoder Error Handling', () => {
|
|
244
|
+
it('should handle invalid input to decode()', async () => {
|
|
245
|
+
const result = await executeWithAsyncHost(
|
|
246
|
+
`
|
|
247
|
+
const decoder = new TextDecoder();
|
|
248
|
+
try {
|
|
249
|
+
// Try to decode a non-buffer value
|
|
250
|
+
decoder.decode("not a buffer");
|
|
251
|
+
return "no error"
|
|
252
|
+
} catch (e) {
|
|
253
|
+
return "error caught: " + e.message
|
|
254
|
+
}
|
|
255
|
+
`,
|
|
256
|
+
{}
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
expect(result.success).toBe(true);
|
|
260
|
+
expect(result.result).toContain('error caught');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should properly decode valid ArrayBuffer', async () => {
|
|
264
|
+
const result = await executeWithAsyncHost(
|
|
265
|
+
`
|
|
266
|
+
const decoder = new TextDecoder();
|
|
267
|
+
const buffer = new ArrayBuffer(5);
|
|
268
|
+
const view = new Uint8Array(buffer);
|
|
269
|
+
view[0] = 104; // 'h'
|
|
270
|
+
view[1] = 101; // 'e'
|
|
271
|
+
view[2] = 108; // 'l'
|
|
272
|
+
view[3] = 108; // 'l'
|
|
273
|
+
view[4] = 111; // 'o'
|
|
274
|
+
return decoder.decode(buffer)
|
|
275
|
+
`,
|
|
276
|
+
{}
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
expect(result.success).toBe(true);
|
|
280
|
+
expect(result.result).toBe('hello');
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe('ReadableStream Locked Property', () => {
|
|
285
|
+
beforeEach(() => setupMockFetch());
|
|
286
|
+
afterEach(() => restoreFetch());
|
|
287
|
+
|
|
288
|
+
it('should reflect locked state dynamically after getReader()', async () => {
|
|
289
|
+
const result = await executeWithAsyncHost(
|
|
290
|
+
`
|
|
291
|
+
const response = await fetch('https://httpbin.org/stream-bytes/10');
|
|
292
|
+
const body = response.body;
|
|
293
|
+
|
|
294
|
+
// Check initial state
|
|
295
|
+
const initialLocked = body.locked;
|
|
296
|
+
|
|
297
|
+
// Get reader - should lock the stream
|
|
298
|
+
const reader = body.getReader();
|
|
299
|
+
|
|
300
|
+
// Check locked state after getting reader
|
|
301
|
+
const afterLocked = body.locked;
|
|
302
|
+
|
|
303
|
+
// Read and release
|
|
304
|
+
const { done, value } = await reader.read();
|
|
305
|
+
reader.releaseLock();
|
|
306
|
+
|
|
307
|
+
// Check state after release
|
|
308
|
+
const afterRelease = body.locked;
|
|
309
|
+
|
|
310
|
+
return { initialLocked, afterLocked, afterRelease }
|
|
311
|
+
`,
|
|
312
|
+
{},
|
|
313
|
+
{ timeoutMs: 10000 }
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(result.success).toBe(true);
|
|
317
|
+
expect(result.result.initialLocked).toBe(false);
|
|
318
|
+
expect(result.result.afterLocked).toBe(true);
|
|
319
|
+
// Note: afterRelease might still be true if stream is consumed
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should report locked=true when reader is active', async () => {
|
|
323
|
+
const result = await executeWithAsyncHost(
|
|
324
|
+
`
|
|
325
|
+
const response = await fetch('https://httpbin.org/stream-bytes/50');
|
|
326
|
+
const body = response.body;
|
|
327
|
+
|
|
328
|
+
const reader = body.getReader();
|
|
329
|
+
const isLocked = body.locked;
|
|
330
|
+
|
|
331
|
+
// Clean up
|
|
332
|
+
reader.releaseLock();
|
|
333
|
+
|
|
334
|
+
return isLocked
|
|
335
|
+
`,
|
|
336
|
+
{},
|
|
337
|
+
{ timeoutMs: 10000 }
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
expect(result.success).toBe(true);
|
|
341
|
+
expect(result.result).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('Stream Error Handling', () => {
|
|
346
|
+
beforeEach(() => setupMockFetch());
|
|
347
|
+
afterEach(() => restoreFetch());
|
|
348
|
+
it('should handle stream read errors gracefully', async () => {
|
|
349
|
+
// Create a scenario where stream reading might fail
|
|
350
|
+
const globals = {
|
|
351
|
+
createFailingStream: sandboxSync(() => {
|
|
352
|
+
// Return a stream-like object that will fail
|
|
353
|
+
return {
|
|
354
|
+
getReader: () => ({
|
|
355
|
+
read: async () => {
|
|
356
|
+
throw new Error('Stream read failed');
|
|
357
|
+
},
|
|
358
|
+
releaseLock: () => {}
|
|
359
|
+
})
|
|
360
|
+
};
|
|
361
|
+
})
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const result = await executeWithAsyncHost(
|
|
365
|
+
`
|
|
366
|
+
try {
|
|
367
|
+
const stream = createFailingStream();
|
|
368
|
+
const reader = stream.getReader();
|
|
369
|
+
await reader.read();
|
|
370
|
+
return "no error"
|
|
371
|
+
} catch (e) {
|
|
372
|
+
return "error: " + e.message
|
|
373
|
+
}
|
|
374
|
+
`,
|
|
375
|
+
globals
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
expect(result.success).toBe(true);
|
|
379
|
+
expect(result.result).toContain('error');
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe('Cleanup After Errors', () => {
|
|
384
|
+
beforeEach(() => setupMockFetch());
|
|
385
|
+
afterEach(() => restoreFetch());
|
|
386
|
+
|
|
387
|
+
it('should cleanup all resources after timeout', async () => {
|
|
388
|
+
const result = await executeWithAsyncHost(
|
|
389
|
+
`
|
|
390
|
+
// Start a long operation
|
|
391
|
+
while (true) {
|
|
392
|
+
// Infinite loop
|
|
393
|
+
}
|
|
394
|
+
`,
|
|
395
|
+
{},
|
|
396
|
+
{ timeoutMs: 100 }
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
expect(result.success).toBe(false);
|
|
400
|
+
expect(result.error).toMatch(/timeout|interrupted/i);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('should cleanup TextEncoder/TextDecoder after error', async () => {
|
|
404
|
+
// Run an execution that creates encoders but fails
|
|
405
|
+
const result = await executeWithAsyncHost(
|
|
406
|
+
`
|
|
407
|
+
const encoder = new TextEncoder();
|
|
408
|
+
const decoder = new TextDecoder();
|
|
409
|
+
const bytes = encoder.encode("test");
|
|
410
|
+
decoder.decode(bytes);
|
|
411
|
+
|
|
412
|
+
// Now fail
|
|
413
|
+
throw new Error('Intentional failure');
|
|
414
|
+
`,
|
|
415
|
+
{}
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
expect(result.success).toBe(false);
|
|
419
|
+
|
|
420
|
+
// Should be able to run another execution without issues
|
|
421
|
+
const result2 = await executeWithAsyncHost(
|
|
422
|
+
`
|
|
423
|
+
const encoder = new TextEncoder();
|
|
424
|
+
return encoder.encode("ok").length
|
|
425
|
+
`,
|
|
426
|
+
{}
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
expect(result2.success).toBe(true);
|
|
430
|
+
expect(result2.result).toBe(2);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('should cleanup with active fetch when error occurs', async () => {
|
|
434
|
+
// This test verifies that starting a fetch and immediately throwing
|
|
435
|
+
// doesn't crash the process. The cleanup now detects lingering promise
|
|
436
|
+
// objects and skips runtime disposal to avoid the GC assertion.
|
|
437
|
+
for (let i = 0; i < 3; i++) {
|
|
438
|
+
const result = await executeWithAsyncHost(
|
|
439
|
+
`
|
|
440
|
+
// Start a fetch but throw before it completes
|
|
441
|
+
const p = fetch('https://httpbin.org/delay/5');
|
|
442
|
+
throw new Error('Interrupting');
|
|
443
|
+
`,
|
|
444
|
+
{},
|
|
445
|
+
{ timeoutMs: 1000 }
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
expect(result.success).toBe(false);
|
|
449
|
+
expect(result.error).toContain('Interrupting');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Verify the process still works after the edge case
|
|
453
|
+
const normalResult = await executeWithAsyncHost(`return 42`, {});
|
|
454
|
+
expect(normalResult.success).toBe(true);
|
|
455
|
+
expect(normalResult.result).toBe(42);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe('Multiple Concurrent Async Operations', () => {
|
|
460
|
+
it('should handle rapid promise additions', async () => {
|
|
461
|
+
const code = `
|
|
462
|
+
const promises = [];
|
|
463
|
+
for (let i = 0; i < 50; i++) {
|
|
464
|
+
promises.push(asyncOp(i));
|
|
465
|
+
}
|
|
466
|
+
const results = await Promise.all(promises);
|
|
467
|
+
return results.reduce((a, b) => a + b, 0)
|
|
468
|
+
`;
|
|
469
|
+
|
|
470
|
+
const globals = {
|
|
471
|
+
asyncOp: sandboxAsync(async (n: number) => {
|
|
472
|
+
// Variable delays to create interleaving
|
|
473
|
+
await new Promise(r => setTimeout(r, Math.random() * 5));
|
|
474
|
+
return n;
|
|
475
|
+
})
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const result = await executeWithAsyncHost(code, globals, { timeoutMs: 5000 });
|
|
479
|
+
|
|
480
|
+
expect(result.success).toBe(true);
|
|
481
|
+
// Sum of 0 to 49 = 1225
|
|
482
|
+
expect(result.result).toBe(1225);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('should handle promise race conditions', async () => {
|
|
486
|
+
const code = `
|
|
487
|
+
const results = [];
|
|
488
|
+
|
|
489
|
+
// Fire off operations that resolve in different orders
|
|
490
|
+
// Using async host functions instead of setTimeout
|
|
491
|
+
const a = Promise.resolve('a');
|
|
492
|
+
const b = delay(10).then(() => 'b');
|
|
493
|
+
const c = Promise.resolve('c');
|
|
494
|
+
|
|
495
|
+
results.push(await a);
|
|
496
|
+
results.push(await c);
|
|
497
|
+
results.push(await b);
|
|
498
|
+
|
|
499
|
+
return results.join('')
|
|
500
|
+
`;
|
|
501
|
+
|
|
502
|
+
const globals = {
|
|
503
|
+
delay: sandboxAsync((ms: number) => new Promise(r => setTimeout(r, ms)))
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const result = await executeWithAsyncHost(code, globals);
|
|
507
|
+
|
|
508
|
+
expect(result.success).toBe(true);
|
|
509
|
+
expect(result.result).toBe('acb');
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
describe('Resource Leak Prevention', () => {
|
|
514
|
+
beforeEach(() => setupMockFetch());
|
|
515
|
+
afterEach(() => restoreFetch());
|
|
516
|
+
|
|
517
|
+
it('should not leak handles with repeated JSON operations', async () => {
|
|
518
|
+
for (let i = 0; i < 30; i++) {
|
|
519
|
+
const result = await executeWithAsyncHost(
|
|
520
|
+
`
|
|
521
|
+
const data = { i: ${i}, nested: { arr: [1, 2, 3] } };
|
|
522
|
+
const json = JSON.stringify(data);
|
|
523
|
+
const parsed = JSON.parse(json);
|
|
524
|
+
return parsed.i + parsed.nested.arr.length
|
|
525
|
+
`,
|
|
526
|
+
{}
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
expect(result.success).toBe(true);
|
|
530
|
+
expect(result.result).toBe(i + 3);
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('should not leak with repeated fetch operations', async () => {
|
|
535
|
+
for (let i = 0; i < 5; i++) {
|
|
536
|
+
const result = await executeWithAsyncHost(
|
|
537
|
+
`
|
|
538
|
+
const response = await fetch('https://httpbin.org/uuid');
|
|
539
|
+
const data = await response.json();
|
|
540
|
+
return typeof data.uuid
|
|
541
|
+
`,
|
|
542
|
+
{},
|
|
543
|
+
{ timeoutMs: 10000 }
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
expect(result.success).toBe(true);
|
|
547
|
+
expect(result.result).toBe('string');
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe('Edge Cases', () => {
|
|
553
|
+
it('should handle zero timeout gracefully', async () => {
|
|
554
|
+
const result = await executeWithAsyncHost(
|
|
555
|
+
`
|
|
556
|
+
return 42
|
|
557
|
+
`,
|
|
558
|
+
{},
|
|
559
|
+
{ timeoutMs: 0 }
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
// With 0 timeout, synchronous code may still succeed since the timeout
|
|
563
|
+
// is checked between async operations, not during sync execution.
|
|
564
|
+
// The important thing is it doesn't crash or hang.
|
|
565
|
+
expect(result.success !== undefined).toBe(true);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('should handle very small memory limit', async () => {
|
|
569
|
+
const result = await executeWithAsyncHost(
|
|
570
|
+
`
|
|
571
|
+
const arr = [];
|
|
572
|
+
for (let i = 0; i < 10000; i++) {
|
|
573
|
+
arr.push({ x: i, y: i * 2, z: "string".repeat(100) });
|
|
574
|
+
}
|
|
575
|
+
return arr.length
|
|
576
|
+
`,
|
|
577
|
+
{},
|
|
578
|
+
{ memoryLimitBytes: 1024 * 1024 }
|
|
579
|
+
); // 1MB
|
|
580
|
+
|
|
581
|
+
// Should either succeed with a smaller allocation or fail gracefully
|
|
582
|
+
expect(result.success !== undefined).toBe(true);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('should handle empty code', async () => {
|
|
586
|
+
const result = await executeWithAsyncHost('', {});
|
|
587
|
+
expect(result.success).toBe(true);
|
|
588
|
+
expect(result.result).toBeUndefined();
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('should handle code with only comments', async () => {
|
|
592
|
+
const result = await executeWithAsyncHost(
|
|
593
|
+
`
|
|
594
|
+
// This is a comment
|
|
595
|
+
/* Multi-line
|
|
596
|
+
comment */
|
|
597
|
+
`,
|
|
598
|
+
{}
|
|
599
|
+
);
|
|
600
|
+
expect(result.success).toBe(true);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it('should handle code returning undefined', async () => {
|
|
604
|
+
const result = await executeWithAsyncHost('return undefined', {});
|
|
605
|
+
expect(result.success).toBe(true);
|
|
606
|
+
expect(result.result).toBeUndefined();
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('should handle code returning null', async () => {
|
|
610
|
+
const result = await executeWithAsyncHost('return null', {});
|
|
611
|
+
expect(result.success).toBe(true);
|
|
612
|
+
expect(result.result).toBeNull();
|
|
613
|
+
});
|
|
614
|
+
});
|