@ricsam/isolate-test-utils 0.0.1 → 0.1.2
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 +25 -0
- package/package.json +51 -7
- package/src/fetch-context.ts +33 -0
- package/src/fs-context.ts +65 -0
- package/src/index.test.ts +473 -0
- package/src/index.ts +177 -0
- package/src/mock-fs.ts +246 -0
- package/src/native-input-test.ts +412 -0
- package/src/runtime-context.ts +120 -0
- package/src/server.ts +150 -0
- package/tsconfig.json +8 -0
- package/README.md +0 -45
package/src/index.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type ivm from "isolated-vm";
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Types
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export interface TestContext {
|
|
8
|
+
isolate: ivm.Isolate;
|
|
9
|
+
context: ivm.Context;
|
|
10
|
+
dispose(): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TestResult<T> {
|
|
14
|
+
result: T;
|
|
15
|
+
logs: Array<{ level: string; args: unknown[] }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// Context Creation
|
|
20
|
+
// ============================================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a basic test context for isolated-vm tests.
|
|
24
|
+
* This creates a bare context without any APIs set up.
|
|
25
|
+
*/
|
|
26
|
+
export async function createTestContext(): Promise<TestContext> {
|
|
27
|
+
const ivm = await import("isolated-vm");
|
|
28
|
+
const isolate = new ivm.default.Isolate();
|
|
29
|
+
const context = await isolate.createContext();
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
isolate,
|
|
33
|
+
context,
|
|
34
|
+
dispose() {
|
|
35
|
+
context.release();
|
|
36
|
+
isolate.dispose();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a test context with core APIs set up (Blob, File, URL, streams, etc.)
|
|
43
|
+
*/
|
|
44
|
+
export async function createCoreTestContext(): Promise<TestContext> {
|
|
45
|
+
const ivm = await import("isolated-vm");
|
|
46
|
+
const { setupCore } = await import("@ricsam/isolate-core");
|
|
47
|
+
|
|
48
|
+
const isolate = new ivm.default.Isolate();
|
|
49
|
+
const context = await isolate.createContext();
|
|
50
|
+
const coreHandle = await setupCore(context);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
isolate,
|
|
54
|
+
context,
|
|
55
|
+
dispose() {
|
|
56
|
+
coreHandle.dispose();
|
|
57
|
+
context.release();
|
|
58
|
+
isolate.dispose();
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// Code Evaluation Helpers
|
|
65
|
+
// ============================================================================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Synchronously evaluate code and return typed result.
|
|
69
|
+
* Use this for simple expressions that don't involve promises.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* const result = evalCode<number>(ctx.context, "1 + 1");
|
|
73
|
+
* // result === 2
|
|
74
|
+
*/
|
|
75
|
+
export function evalCode<T = unknown>(context: ivm.Context, code: string): T {
|
|
76
|
+
return context.evalSync(code) as T;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Asynchronously evaluate code that may return promises.
|
|
81
|
+
* Automatically wraps code to handle promise resolution.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* const result = await evalCodeAsync<string>(ctx.context, `
|
|
85
|
+
* (async () => {
|
|
86
|
+
* return "hello";
|
|
87
|
+
* })()
|
|
88
|
+
* `);
|
|
89
|
+
*/
|
|
90
|
+
export async function evalCodeAsync<T = unknown>(
|
|
91
|
+
context: ivm.Context,
|
|
92
|
+
code: string
|
|
93
|
+
): Promise<T> {
|
|
94
|
+
return (await context.eval(code, { promise: true })) as T;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Evaluate code and return the result as JSON (for complex objects).
|
|
99
|
+
* Useful when you need to extract structured data from the isolate.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* const data = evalCodeJson<{ name: string }>(ctx.context, `
|
|
103
|
+
* JSON.stringify({ name: "test" })
|
|
104
|
+
* `);
|
|
105
|
+
*/
|
|
106
|
+
export function evalCodeJson<T = unknown>(context: ivm.Context, code: string): T {
|
|
107
|
+
const jsonString = context.evalSync(code) as string;
|
|
108
|
+
return JSON.parse(jsonString) as T;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Evaluate async code and return the result as JSON (for complex objects).
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* const data = await evalCodeJsonAsync<{ status: number }>(ctx.context, `
|
|
116
|
+
* (async () => {
|
|
117
|
+
* const response = await fetch("...");
|
|
118
|
+
* return JSON.stringify({ status: response.status });
|
|
119
|
+
* })()
|
|
120
|
+
* `);
|
|
121
|
+
*/
|
|
122
|
+
export async function evalCodeJsonAsync<T = unknown>(
|
|
123
|
+
context: ivm.Context,
|
|
124
|
+
code: string
|
|
125
|
+
): Promise<T> {
|
|
126
|
+
const jsonString = (await context.eval(code, { promise: true })) as string;
|
|
127
|
+
return JSON.parse(jsonString) as T;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Inject values into the isolate's global scope before running code.
|
|
132
|
+
*
|
|
133
|
+
* @example
|
|
134
|
+
* await injectGlobals(ctx.context, {
|
|
135
|
+
* testInput: "hello",
|
|
136
|
+
* testConfig: { debug: true }
|
|
137
|
+
* });
|
|
138
|
+
* const result = evalCode<string>(ctx.context, "testInput");
|
|
139
|
+
*/
|
|
140
|
+
export async function injectGlobals(
|
|
141
|
+
context: ivm.Context,
|
|
142
|
+
values: Record<string, unknown>
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const global = context.global;
|
|
145
|
+
|
|
146
|
+
for (const [key, value] of Object.entries(values)) {
|
|
147
|
+
if (typeof value === "function") {
|
|
148
|
+
const ivm = await import("isolated-vm");
|
|
149
|
+
global.setSync(key, new ivm.default.Callback(value as (...args: unknown[]) => unknown));
|
|
150
|
+
} else if (typeof value === "object" && value !== null) {
|
|
151
|
+
// For objects, serialize as JSON and inject
|
|
152
|
+
context.evalSync(`globalThis.${key} = ${JSON.stringify(value)}`);
|
|
153
|
+
} else {
|
|
154
|
+
// For primitives, set directly
|
|
155
|
+
global.setSync(key, value);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ============================================================================
|
|
161
|
+
// Exports from other modules
|
|
162
|
+
// ============================================================================
|
|
163
|
+
|
|
164
|
+
export { MockFileSystem } from "./mock-fs.ts";
|
|
165
|
+
export { createFsTestContext } from "./fs-context.ts";
|
|
166
|
+
export type { FsTestContext } from "./fs-context.ts";
|
|
167
|
+
export { createRuntimeTestContext } from "./runtime-context.ts";
|
|
168
|
+
export type { RuntimeTestContext } from "./runtime-context.ts";
|
|
169
|
+
export { startIntegrationServer } from "./server.ts";
|
|
170
|
+
export type { IntegrationServer } from "./server.ts";
|
|
171
|
+
export { runTestCode } from "./native-input-test.ts";
|
|
172
|
+
export type { TestRunner, TestRuntime } from "./native-input-test.ts";
|
|
173
|
+
export { createFetchTestContext } from "./fetch-context.ts";
|
|
174
|
+
export type { FetchTestContext } from "./fetch-context.ts";
|
|
175
|
+
|
|
176
|
+
// Re-export useful types
|
|
177
|
+
export type { FileSystemHandler } from "@ricsam/isolate-fs";
|
package/src/mock-fs.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { FileSystemHandler } from "@ricsam/isolate-fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory file system implementation for testing.
|
|
5
|
+
* Implements the FileSystemHandler interface from @ricsam/isolate-fs.
|
|
6
|
+
*/
|
|
7
|
+
export class MockFileSystem implements FileSystemHandler {
|
|
8
|
+
files = new Map<
|
|
9
|
+
string,
|
|
10
|
+
{ data: Uint8Array; lastModified: number; type: string }
|
|
11
|
+
>();
|
|
12
|
+
directories = new Set<string>(["/"]);
|
|
13
|
+
|
|
14
|
+
async getFileHandle(
|
|
15
|
+
path: string,
|
|
16
|
+
options?: { create?: boolean }
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const exists = this.files.has(path);
|
|
19
|
+
if (!exists && !options?.create) {
|
|
20
|
+
throw new Error("[NotFoundError]File not found: " + path);
|
|
21
|
+
}
|
|
22
|
+
if (this.directories.has(path)) {
|
|
23
|
+
throw new Error("[TypeMismatchError]Path is a directory: " + path);
|
|
24
|
+
}
|
|
25
|
+
if (!exists && options?.create) {
|
|
26
|
+
this.files.set(path, {
|
|
27
|
+
data: new Uint8Array(0),
|
|
28
|
+
lastModified: Date.now(),
|
|
29
|
+
type: "",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async getDirectoryHandle(
|
|
35
|
+
path: string,
|
|
36
|
+
options?: { create?: boolean }
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
const exists = this.directories.has(path);
|
|
39
|
+
if (!exists && !options?.create) {
|
|
40
|
+
throw new Error("[NotFoundError]Directory not found: " + path);
|
|
41
|
+
}
|
|
42
|
+
if (this.files.has(path)) {
|
|
43
|
+
throw new Error("[TypeMismatchError]Path is a file: " + path);
|
|
44
|
+
}
|
|
45
|
+
if (!exists && options?.create) {
|
|
46
|
+
this.directories.add(path);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async removeEntry(
|
|
51
|
+
path: string,
|
|
52
|
+
options?: { recursive?: boolean }
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
if (this.files.has(path)) {
|
|
55
|
+
this.files.delete(path);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (this.directories.has(path)) {
|
|
60
|
+
const prefix = path === "/" ? "/" : path + "/";
|
|
61
|
+
const hasChildren =
|
|
62
|
+
[...this.files.keys()].some((p) => p.startsWith(prefix)) ||
|
|
63
|
+
[...this.directories].some((p) => p !== path && p.startsWith(prefix));
|
|
64
|
+
|
|
65
|
+
if (hasChildren && !options?.recursive) {
|
|
66
|
+
throw new Error("[InvalidModificationError]Directory not empty: " + path);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const p of this.files.keys()) {
|
|
70
|
+
if (p.startsWith(prefix)) {
|
|
71
|
+
this.files.delete(p);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const p of this.directories) {
|
|
75
|
+
if (p.startsWith(prefix) || p === path) {
|
|
76
|
+
this.directories.delete(p);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new Error("[NotFoundError]Entry not found: " + path);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async readDirectory(
|
|
86
|
+
path: string
|
|
87
|
+
): Promise<Array<{ name: string; kind: "file" | "directory" }>> {
|
|
88
|
+
if (!this.directories.has(path)) {
|
|
89
|
+
throw new Error("[NotFoundError]Directory not found: " + path);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const prefix = path === "/" ? "/" : path + "/";
|
|
93
|
+
const entries: Array<{ name: string; kind: "file" | "directory" }> = [];
|
|
94
|
+
const seen = new Set<string>();
|
|
95
|
+
|
|
96
|
+
for (const p of this.files.keys()) {
|
|
97
|
+
if (p.startsWith(prefix)) {
|
|
98
|
+
const rest = p.slice(prefix.length);
|
|
99
|
+
const name = rest.split("/")[0];
|
|
100
|
+
if (name && !rest.includes("/") && !seen.has(name)) {
|
|
101
|
+
seen.add(name);
|
|
102
|
+
entries.push({ name, kind: "file" });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const p of this.directories) {
|
|
108
|
+
if (p !== path && p.startsWith(prefix)) {
|
|
109
|
+
const rest = p.slice(prefix.length);
|
|
110
|
+
const name = rest.split("/")[0];
|
|
111
|
+
if (name && !rest.includes("/") && !seen.has(name)) {
|
|
112
|
+
seen.add(name);
|
|
113
|
+
entries.push({ name, kind: "directory" });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return entries;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async readFile(
|
|
122
|
+
path: string
|
|
123
|
+
): Promise<{ data: Uint8Array; size: number; lastModified: number; type: string }> {
|
|
124
|
+
const file = this.files.get(path);
|
|
125
|
+
if (!file) {
|
|
126
|
+
throw new Error("[NotFoundError]File not found: " + path);
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
data: file.data,
|
|
130
|
+
size: file.data.length,
|
|
131
|
+
lastModified: file.lastModified,
|
|
132
|
+
type: file.type,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async writeFile(
|
|
137
|
+
path: string,
|
|
138
|
+
data: Uint8Array,
|
|
139
|
+
position?: number
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const existing = this.files.get(path);
|
|
142
|
+
if (!existing) {
|
|
143
|
+
throw new Error("[NotFoundError]File not found: " + path);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (position !== undefined && position > 0) {
|
|
147
|
+
const newSize = Math.max(existing.data.length, position + data.length);
|
|
148
|
+
const newData = new Uint8Array(newSize);
|
|
149
|
+
newData.set(existing.data);
|
|
150
|
+
newData.set(data, position);
|
|
151
|
+
existing.data = newData;
|
|
152
|
+
} else if (position === 0) {
|
|
153
|
+
const newSize = Math.max(existing.data.length, data.length);
|
|
154
|
+
const newData = new Uint8Array(newSize);
|
|
155
|
+
newData.set(existing.data);
|
|
156
|
+
newData.set(data, 0);
|
|
157
|
+
existing.data = newData;
|
|
158
|
+
} else {
|
|
159
|
+
existing.data = data;
|
|
160
|
+
}
|
|
161
|
+
existing.lastModified = Date.now();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async truncateFile(path: string, size: number): Promise<void> {
|
|
165
|
+
const file = this.files.get(path);
|
|
166
|
+
if (!file) {
|
|
167
|
+
throw new Error("[NotFoundError]File not found: " + path);
|
|
168
|
+
}
|
|
169
|
+
if (size < file.data.length) {
|
|
170
|
+
file.data = file.data.slice(0, size);
|
|
171
|
+
} else if (size > file.data.length) {
|
|
172
|
+
const newData = new Uint8Array(size);
|
|
173
|
+
newData.set(file.data);
|
|
174
|
+
file.data = newData;
|
|
175
|
+
}
|
|
176
|
+
file.lastModified = Date.now();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async getFileMetadata(
|
|
180
|
+
path: string
|
|
181
|
+
): Promise<{ size: number; lastModified: number; type: string }> {
|
|
182
|
+
const file = this.files.get(path);
|
|
183
|
+
if (!file) {
|
|
184
|
+
throw new Error("[NotFoundError]File not found: " + path);
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
size: file.data.length,
|
|
188
|
+
lastModified: file.lastModified,
|
|
189
|
+
type: file.type,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Test helper methods
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reset the mock file system to its initial state (empty, with only root directory)
|
|
197
|
+
*/
|
|
198
|
+
reset(): void {
|
|
199
|
+
this.files.clear();
|
|
200
|
+
this.directories.clear();
|
|
201
|
+
this.directories.add("/");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Convenience method to set a file with string or binary content
|
|
206
|
+
*/
|
|
207
|
+
setFile(path: string, content: string | Uint8Array, type?: string): void {
|
|
208
|
+
const data =
|
|
209
|
+
typeof content === "string"
|
|
210
|
+
? new TextEncoder().encode(content)
|
|
211
|
+
: content;
|
|
212
|
+
this.files.set(path, {
|
|
213
|
+
data,
|
|
214
|
+
lastModified: Date.now(),
|
|
215
|
+
type: type ?? "",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get file content as Uint8Array, or undefined if not found
|
|
221
|
+
*/
|
|
222
|
+
getFile(path: string): Uint8Array | undefined {
|
|
223
|
+
return this.files.get(path)?.data;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get file content as string, or undefined if not found
|
|
228
|
+
*/
|
|
229
|
+
getFileAsString(path: string): string | undefined {
|
|
230
|
+
const data = this.getFile(path);
|
|
231
|
+
if (!data) return undefined;
|
|
232
|
+
return new TextDecoder().decode(data);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Create a directory (and any necessary parent directories)
|
|
237
|
+
*/
|
|
238
|
+
createDirectory(path: string): void {
|
|
239
|
+
const parts = path.split("/").filter(Boolean);
|
|
240
|
+
let current = "";
|
|
241
|
+
for (const part of parts) {
|
|
242
|
+
current += "/" + part;
|
|
243
|
+
this.directories.add(current);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|