@ricsam/isolate-test-utils 0.0.1 → 0.1.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # @ricsam/isolate-test-utils
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - initial release
8
+ - Updated dependencies
9
+ - @ricsam/isolate-console@0.1.1
10
+ - @ricsam/isolate-core@0.1.1
11
+ - @ricsam/isolate-fetch@0.1.1
12
+ - @ricsam/isolate-fs@0.1.1
13
+ - @ricsam/isolate-runtime@0.1.1
package/package.json CHANGED
@@ -1,10 +1,55 @@
1
1
  {
2
2
  "name": "@ricsam/isolate-test-utils",
3
- "version": "0.0.1",
4
- "description": "OIDC trusted publishing setup package for @ricsam/isolate-test-utils",
5
- "keywords": [
6
- "oidc",
7
- "trusted-publishing",
8
- "setup"
9
- ]
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./src/index.ts",
10
+ "types": "./src/index.ts"
11
+ },
12
+ "./fs": {
13
+ "import": "./src/fs-context.ts",
14
+ "types": "./src/fs-context.ts"
15
+ },
16
+ "./runtime": {
17
+ "import": "./src/runtime-context.ts",
18
+ "types": "./src/runtime-context.ts"
19
+ }
20
+ },
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "test": "node --test --experimental-strip-types 'src/**/*.test.ts'",
24
+ "typecheck": "tsc --noEmit"
25
+ },
26
+ "dependencies": {
27
+ "isolated-vm": "^6",
28
+ "ts-morph": "^24.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "@ricsam/isolate-fs": "*",
32
+ "@ricsam/isolate-runtime": "*",
33
+ "@types/node": "^24",
34
+ "typescript": "^5"
35
+ },
36
+ "peerDependencies": {
37
+ "isolated-vm": "^6",
38
+ "@ricsam/isolate-console": "*",
39
+ "@ricsam/isolate-core": "*",
40
+ "@ricsam/isolate-fetch": "*",
41
+ "@ricsam/isolate-fs": "*",
42
+ "@ricsam/isolate-runtime": "*"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "@ricsam/isolate-console": {
46
+ "optional": true
47
+ },
48
+ "@ricsam/isolate-fs": {
49
+ "optional": true
50
+ },
51
+ "@ricsam/isolate-runtime": {
52
+ "optional": true
53
+ }
54
+ }
10
55
  }
@@ -0,0 +1,33 @@
1
+ import type ivm from "isolated-vm";
2
+ import type { TestContext } from "./index.ts";
3
+
4
+ export interface FetchTestContext extends TestContext {
5
+ // Context with fetch APIs set up
6
+ }
7
+
8
+ /**
9
+ * Create a test context with fetch APIs set up (Headers, Request, Response, FormData, fetch)
10
+ */
11
+ export async function createFetchTestContext(): Promise<FetchTestContext> {
12
+ const ivmModule = await import("isolated-vm");
13
+ const { setupFetch, clearAllInstanceState } = await import(
14
+ "@ricsam/isolate-fetch"
15
+ );
16
+
17
+ const isolate = new ivmModule.default.Isolate();
18
+ const context = await isolate.createContext();
19
+
20
+ clearAllInstanceState();
21
+
22
+ const fetchHandle = await setupFetch(context);
23
+
24
+ return {
25
+ isolate,
26
+ context,
27
+ dispose() {
28
+ fetchHandle.dispose();
29
+ context.release();
30
+ isolate.dispose();
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,65 @@
1
+ import type ivm from "isolated-vm";
2
+ import { MockFileSystem } from "./mock-fs.ts";
3
+
4
+ export interface FsTestContext {
5
+ isolate: ivm.Isolate;
6
+ context: ivm.Context;
7
+ mockFs: MockFileSystem;
8
+ dispose(): void;
9
+ }
10
+
11
+ /**
12
+ * Create a test context with file system APIs set up using a mock file system.
13
+ *
14
+ * @example
15
+ * const ctx = await createFsTestContext();
16
+ *
17
+ * // Set up initial files
18
+ * ctx.mockFs.setFile("/test.txt", "Hello, World!");
19
+ *
20
+ * // Use file system APIs in the isolate
21
+ * const result = await ctx.context.eval(`
22
+ * (async () => {
23
+ * const root = await navigator.storage.getDirectory();
24
+ * const fileHandle = await root.getFileHandle("test.txt");
25
+ * const file = await fileHandle.getFile();
26
+ * return await file.text();
27
+ * })()
28
+ * `, { promise: true });
29
+ *
30
+ * ctx.dispose();
31
+ */
32
+ export async function createFsTestContext(): Promise<FsTestContext> {
33
+ const ivmModule = await import("isolated-vm");
34
+ const { setupCore, clearAllInstanceState } = await import(
35
+ "@ricsam/isolate-core"
36
+ );
37
+ const { setupFs } = await import("@ricsam/isolate-fs");
38
+
39
+ const isolate = new ivmModule.default.Isolate();
40
+ const context = await isolate.createContext();
41
+
42
+ // Clear any previous instance state
43
+ clearAllInstanceState();
44
+
45
+ // Create mock file system
46
+ const mockFs = new MockFileSystem();
47
+
48
+ // Setup core APIs (required for Blob, File, streams)
49
+ const coreHandle = await setupCore(context);
50
+
51
+ // Setup file system APIs with mock handler
52
+ const fsHandle = await setupFs(context, { getDirectory: async () => mockFs });
53
+
54
+ return {
55
+ isolate,
56
+ context,
57
+ mockFs,
58
+ dispose() {
59
+ fsHandle.dispose();
60
+ coreHandle.dispose();
61
+ context.release();
62
+ isolate.dispose();
63
+ },
64
+ };
65
+ }
@@ -0,0 +1,473 @@
1
+ import { test, describe, afterEach } from "node:test";
2
+ import assert from "node:assert";
3
+ import {
4
+ createTestContext,
5
+ createCoreTestContext,
6
+ evalCode,
7
+ evalCodeAsync,
8
+ evalCodeJson,
9
+ evalCodeJsonAsync,
10
+ injectGlobals,
11
+ MockFileSystem,
12
+ createFsTestContext,
13
+ createRuntimeTestContext,
14
+ startIntegrationServer,
15
+ type TestContext,
16
+ type FsTestContext,
17
+ type RuntimeTestContext,
18
+ type IntegrationServer,
19
+ } from "./index.ts";
20
+
21
+ // ============================================================================
22
+ // createTestContext tests
23
+ // ============================================================================
24
+
25
+ describe("createTestContext", () => {
26
+ let ctx: TestContext | undefined;
27
+
28
+ afterEach(() => {
29
+ ctx?.dispose();
30
+ ctx = undefined;
31
+ });
32
+
33
+ test("creates a basic context", async () => {
34
+ ctx = await createTestContext();
35
+ assert.ok(ctx.isolate);
36
+ assert.ok(ctx.context);
37
+ assert.ok(typeof ctx.dispose === "function");
38
+ });
39
+
40
+ test("can evaluate code in context", async () => {
41
+ ctx = await createTestContext();
42
+ const result = ctx.context.evalSync("1 + 1");
43
+ assert.strictEqual(result, 2);
44
+ });
45
+ });
46
+
47
+ describe("createCoreTestContext", () => {
48
+ let ctx: TestContext | undefined;
49
+
50
+ afterEach(() => {
51
+ ctx?.dispose();
52
+ ctx = undefined;
53
+ });
54
+
55
+ test("creates a context with core APIs", async () => {
56
+ ctx = await createCoreTestContext();
57
+ assert.ok(ctx.isolate);
58
+ assert.ok(ctx.context);
59
+ });
60
+
61
+ test("has Blob available", async () => {
62
+ ctx = await createCoreTestContext();
63
+ const result = ctx.context.evalSync("typeof Blob");
64
+ assert.strictEqual(result, "function");
65
+ });
66
+
67
+ test("has URL available", async () => {
68
+ ctx = await createCoreTestContext();
69
+ const result = ctx.context.evalSync(
70
+ "new URL('https://example.com').hostname"
71
+ );
72
+ assert.strictEqual(result, "example.com");
73
+ });
74
+ });
75
+
76
+ // ============================================================================
77
+ // evalCode tests
78
+ // ============================================================================
79
+
80
+ describe("evalCode", () => {
81
+ let ctx: TestContext | undefined;
82
+
83
+ afterEach(() => {
84
+ ctx?.dispose();
85
+ ctx = undefined;
86
+ });
87
+
88
+ test("evaluates code synchronously", async () => {
89
+ ctx = await createTestContext();
90
+ const result = evalCode<number>(ctx.context, "2 + 3");
91
+ assert.strictEqual(result, 5);
92
+ });
93
+
94
+ test("returns string", async () => {
95
+ ctx = await createTestContext();
96
+ const result = evalCode<string>(ctx.context, '"hello"');
97
+ assert.strictEqual(result, "hello");
98
+ });
99
+ });
100
+
101
+ describe("evalCodeAsync", () => {
102
+ let ctx: TestContext | undefined;
103
+
104
+ afterEach(() => {
105
+ ctx?.dispose();
106
+ ctx = undefined;
107
+ });
108
+
109
+ test("evaluates async code", async () => {
110
+ ctx = await createTestContext();
111
+ const result = await evalCodeAsync<number>(
112
+ ctx.context,
113
+ "Promise.resolve(42)"
114
+ );
115
+ assert.strictEqual(result, 42);
116
+ });
117
+
118
+ test("handles async IIFE", async () => {
119
+ ctx = await createTestContext();
120
+ const result = await evalCodeAsync<string>(
121
+ ctx.context,
122
+ `(async () => { return "async result"; })()`
123
+ );
124
+ assert.strictEqual(result, "async result");
125
+ });
126
+ });
127
+
128
+ describe("evalCodeJson", () => {
129
+ let ctx: TestContext | undefined;
130
+
131
+ afterEach(() => {
132
+ ctx?.dispose();
133
+ ctx = undefined;
134
+ });
135
+
136
+ test("parses JSON result", async () => {
137
+ ctx = await createTestContext();
138
+ const result = evalCodeJson<{ name: string }>(
139
+ ctx.context,
140
+ 'JSON.stringify({ name: "test" })'
141
+ );
142
+ assert.deepStrictEqual(result, { name: "test" });
143
+ });
144
+ });
145
+
146
+ describe("evalCodeJsonAsync", () => {
147
+ let ctx: TestContext | undefined;
148
+
149
+ afterEach(() => {
150
+ ctx?.dispose();
151
+ ctx = undefined;
152
+ });
153
+
154
+ test("parses async JSON result", async () => {
155
+ ctx = await createTestContext();
156
+ const result = await evalCodeJsonAsync<{ value: number }>(
157
+ ctx.context,
158
+ `(async () => JSON.stringify({ value: 123 }))()`
159
+ );
160
+ assert.deepStrictEqual(result, { value: 123 });
161
+ });
162
+ });
163
+
164
+ describe("injectGlobals", () => {
165
+ let ctx: TestContext | undefined;
166
+
167
+ afterEach(() => {
168
+ ctx?.dispose();
169
+ ctx = undefined;
170
+ });
171
+
172
+ test("injects primitive values", async () => {
173
+ ctx = await createTestContext();
174
+ await injectGlobals(ctx.context, {
175
+ testString: "hello",
176
+ testNumber: 42,
177
+ testBool: true,
178
+ });
179
+ assert.strictEqual(evalCode<string>(ctx.context, "testString"), "hello");
180
+ assert.strictEqual(evalCode<number>(ctx.context, "testNumber"), 42);
181
+ assert.strictEqual(evalCode<boolean>(ctx.context, "testBool"), true);
182
+ });
183
+
184
+ test("injects objects", async () => {
185
+ ctx = await createTestContext();
186
+ await injectGlobals(ctx.context, {
187
+ testConfig: { debug: true, level: 3 },
188
+ });
189
+ const result = evalCodeJson<{ debug: boolean; level: number }>(
190
+ ctx.context,
191
+ "JSON.stringify(testConfig)"
192
+ );
193
+ assert.deepStrictEqual(result, { debug: true, level: 3 });
194
+ });
195
+ });
196
+
197
+ // ============================================================================
198
+ // MockFileSystem tests
199
+ // ============================================================================
200
+
201
+ describe("MockFileSystem", () => {
202
+ test("creates empty file system with root directory", () => {
203
+ const fs = new MockFileSystem();
204
+ assert.strictEqual(fs.directories.has("/"), true);
205
+ assert.strictEqual(fs.files.size, 0);
206
+ });
207
+
208
+ test("setFile creates file with content", async () => {
209
+ const fs = new MockFileSystem();
210
+ fs.setFile("/test.txt", "Hello, World!");
211
+
212
+ const file = await fs.readFile("/test.txt");
213
+ assert.strictEqual(new TextDecoder().decode(file.data), "Hello, World!");
214
+ });
215
+
216
+ test("getFile retrieves file content", () => {
217
+ const fs = new MockFileSystem();
218
+ fs.setFile("/test.txt", "content");
219
+
220
+ const data = fs.getFile("/test.txt");
221
+ assert.ok(data);
222
+ assert.strictEqual(new TextDecoder().decode(data), "content");
223
+ });
224
+
225
+ test("getFileAsString retrieves file as string", () => {
226
+ const fs = new MockFileSystem();
227
+ fs.setFile("/test.txt", "string content");
228
+
229
+ const content = fs.getFileAsString("/test.txt");
230
+ assert.strictEqual(content, "string content");
231
+ });
232
+
233
+ test("createDirectory creates nested directories", () => {
234
+ const fs = new MockFileSystem();
235
+ fs.createDirectory("/a/b/c");
236
+
237
+ assert.strictEqual(fs.directories.has("/a"), true);
238
+ assert.strictEqual(fs.directories.has("/a/b"), true);
239
+ assert.strictEqual(fs.directories.has("/a/b/c"), true);
240
+ });
241
+
242
+ test("reset clears all files and directories", async () => {
243
+ const fs = new MockFileSystem();
244
+ fs.setFile("/test.txt", "content");
245
+ fs.createDirectory("/dir");
246
+
247
+ fs.reset();
248
+
249
+ assert.strictEqual(fs.files.size, 0);
250
+ assert.strictEqual(fs.directories.size, 1); // Only root
251
+ assert.strictEqual(fs.directories.has("/"), true);
252
+ });
253
+
254
+ test("getFileHandle throws NotFoundError for missing file", async () => {
255
+ const fs = new MockFileSystem();
256
+ await assert.rejects(
257
+ fs.getFileHandle("/missing.txt"),
258
+ /NotFoundError/
259
+ );
260
+ });
261
+
262
+ test("getFileHandle creates file with create option", async () => {
263
+ const fs = new MockFileSystem();
264
+ await fs.getFileHandle("/new.txt", { create: true });
265
+ assert.strictEqual(fs.files.has("/new.txt"), true);
266
+ });
267
+
268
+ test("readDirectory lists files and directories", async () => {
269
+ const fs = new MockFileSystem();
270
+ fs.setFile("/file1.txt", "content");
271
+ fs.setFile("/file2.txt", "content");
272
+ fs.createDirectory("/subdir");
273
+
274
+ const entries = await fs.readDirectory("/");
275
+ assert.strictEqual(entries.length, 3);
276
+
277
+ const names = entries.map((e) => e.name).sort();
278
+ assert.deepStrictEqual(names, ["file1.txt", "file2.txt", "subdir"]);
279
+ });
280
+ });
281
+
282
+ // ============================================================================
283
+ // createFsTestContext tests
284
+ // ============================================================================
285
+
286
+ describe("createFsTestContext", () => {
287
+ let ctx: FsTestContext | undefined;
288
+
289
+ afterEach(() => {
290
+ ctx?.dispose();
291
+ ctx = undefined;
292
+ });
293
+
294
+ test("creates context with file system APIs", async () => {
295
+ ctx = await createFsTestContext();
296
+ assert.ok(ctx.isolate);
297
+ assert.ok(ctx.context);
298
+ assert.ok(ctx.mockFs);
299
+ });
300
+
301
+ test("mockFs is connected to context", async () => {
302
+ ctx = await createFsTestContext();
303
+
304
+ // Create a file in mockFs
305
+ ctx.mockFs.setFile("/test.txt", "Hello from test!");
306
+
307
+ // Read it from the isolate
308
+ const result = await ctx.context.eval(
309
+ `
310
+ (async () => {
311
+ const root = await getDirectory('/');
312
+ const fileHandle = await root.getFileHandle("test.txt");
313
+ const file = await fileHandle.getFile();
314
+ return await file.text();
315
+ })()
316
+ `,
317
+ { promise: true }
318
+ );
319
+
320
+ assert.strictEqual(result, "Hello from test!");
321
+ });
322
+ });
323
+
324
+ // ============================================================================
325
+ // createRuntimeTestContext tests
326
+ // ============================================================================
327
+
328
+ describe("createRuntimeTestContext", () => {
329
+ let ctx: RuntimeTestContext | undefined;
330
+
331
+ afterEach(() => {
332
+ ctx?.dispose();
333
+ ctx = undefined;
334
+ });
335
+
336
+ test("creates full runtime context", async () => {
337
+ ctx = await createRuntimeTestContext();
338
+ assert.ok(ctx.isolate);
339
+ assert.ok(ctx.context);
340
+ assert.ok(typeof ctx.tick === "function");
341
+ assert.ok(Array.isArray(ctx.logs));
342
+ assert.ok(Array.isArray(ctx.fetchCalls));
343
+ });
344
+
345
+ test("captures console logs", async () => {
346
+ ctx = await createRuntimeTestContext();
347
+
348
+ ctx.context.evalSync('console.log("test message")');
349
+ ctx.context.evalSync('console.warn("warning message")');
350
+
351
+ assert.strictEqual(ctx.logs.length, 2);
352
+ assert.strictEqual(ctx.logs[0].level, "log");
353
+ assert.deepStrictEqual(ctx.logs[0].args, ["test message"]);
354
+ assert.strictEqual(ctx.logs[1].level, "warn");
355
+ assert.deepStrictEqual(ctx.logs[1].args, ["warning message"]);
356
+ });
357
+
358
+ test("captures and mocks fetch calls", async () => {
359
+ ctx = await createRuntimeTestContext();
360
+
361
+ ctx.setMockResponse({
362
+ status: 200,
363
+ body: '{"data": "test"}',
364
+ headers: { "Content-Type": "application/json" },
365
+ });
366
+
367
+ const result = await ctx.context.eval(
368
+ `
369
+ (async () => {
370
+ const response = await fetch("https://api.example.com/data");
371
+ const json = await response.json();
372
+ return JSON.stringify({ status: response.status, data: json });
373
+ })()
374
+ `,
375
+ { promise: true }
376
+ );
377
+
378
+ const parsed = JSON.parse(result as string);
379
+ assert.strictEqual(parsed.status, 200);
380
+ assert.deepStrictEqual(parsed.data, { data: "test" });
381
+
382
+ assert.strictEqual(ctx.fetchCalls.length, 1);
383
+ assert.strictEqual(ctx.fetchCalls[0].url, "https://api.example.com/data");
384
+ assert.strictEqual(ctx.fetchCalls[0].method, "GET");
385
+ });
386
+
387
+ test("tick advances timers", async () => {
388
+ ctx = await createRuntimeTestContext();
389
+
390
+ ctx.context.evalSync(`
391
+ globalThis.timerFired = false;
392
+ setTimeout(() => { globalThis.timerFired = true; }, 100);
393
+ `);
394
+
395
+ // Timer should not have fired yet
396
+ assert.strictEqual(evalCode<boolean>(ctx.context, "timerFired"), false);
397
+
398
+ // Advance time
399
+ await ctx.tick(100);
400
+
401
+ // Timer should have fired
402
+ assert.strictEqual(evalCode<boolean>(ctx.context, "timerFired"), true);
403
+ });
404
+ });
405
+
406
+ // ============================================================================
407
+ // startIntegrationServer tests
408
+ // ============================================================================
409
+
410
+ describe("startIntegrationServer", () => {
411
+ let server: IntegrationServer | undefined;
412
+
413
+ afterEach(async () => {
414
+ await server?.close();
415
+ server = undefined;
416
+ });
417
+
418
+ test("starts server on available port", async () => {
419
+ server = await startIntegrationServer();
420
+ assert.ok(server.port > 0);
421
+ assert.ok(server.url.startsWith("http://localhost:"));
422
+ });
423
+
424
+ test("responds with configured response", async () => {
425
+ server = await startIntegrationServer();
426
+ server.setResponse("/api/test", {
427
+ status: 200,
428
+ body: '{"message": "hello"}',
429
+ headers: { "Content-Type": "application/json" },
430
+ });
431
+
432
+ const response = await fetch(`${server.url}/api/test`);
433
+ assert.strictEqual(response.status, 200);
434
+ const data = await response.json();
435
+ assert.deepStrictEqual(data, { message: "hello" });
436
+ });
437
+
438
+ test("records requests", async () => {
439
+ server = await startIntegrationServer();
440
+ server.setDefaultResponse({ status: 200, body: "OK" });
441
+
442
+ await fetch(`${server.url}/api/endpoint`, {
443
+ method: "POST",
444
+ headers: { "X-Custom": "value" },
445
+ body: "request body",
446
+ });
447
+
448
+ const requests = server.getRequests();
449
+ assert.strictEqual(requests.length, 1);
450
+ assert.strictEqual(requests[0].method, "POST");
451
+ assert.strictEqual(requests[0].path, "/api/endpoint");
452
+ assert.strictEqual(requests[0].headers["x-custom"], "value");
453
+ assert.strictEqual(requests[0].body, "request body");
454
+ });
455
+
456
+ test("clears requests", async () => {
457
+ server = await startIntegrationServer();
458
+ server.setDefaultResponse({ status: 200 });
459
+
460
+ await fetch(`${server.url}/test`);
461
+ assert.strictEqual(server.getRequests().length, 1);
462
+
463
+ server.clearRequests();
464
+ assert.strictEqual(server.getRequests().length, 0);
465
+ });
466
+
467
+ test("returns 404 for unmatched paths", async () => {
468
+ server = await startIntegrationServer();
469
+
470
+ const response = await fetch(`${server.url}/unknown`);
471
+ assert.strictEqual(response.status, 404);
472
+ });
473
+ });