@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.
@@ -0,0 +1,120 @@
1
+ import type ivm from "isolated-vm";
2
+ import { MockFileSystem } from "./mock-fs.ts";
3
+
4
+ export interface MockResponse {
5
+ status?: number;
6
+ body?: string;
7
+ headers?: Record<string, string>;
8
+ }
9
+
10
+ export interface RuntimeTestContextOptions {
11
+ /** Enable file system APIs with mock file system */
12
+ fs?: boolean;
13
+ }
14
+
15
+ export interface RuntimeTestContext {
16
+ isolate: ivm.Isolate;
17
+ context: ivm.Context;
18
+ /** Advance virtual time and process pending timers */
19
+ tick(ms?: number): Promise<void>;
20
+ dispose(): void;
21
+ /** Captured console.log calls */
22
+ logs: Array<{ level: string; args: unknown[] }>;
23
+ /** Captured fetch calls */
24
+ fetchCalls: Array<{ url: string; method: string; headers: [string, string][] }>;
25
+ /** Set the mock response for the next fetch call */
26
+ setMockResponse(response: MockResponse): void;
27
+ /** Mock file system (only available if fs option is true) */
28
+ mockFs: MockFileSystem;
29
+ }
30
+
31
+ /**
32
+ * Create a full runtime test context with all APIs set up.
33
+ * Includes console logging capture, fetch mocking, and optionally file system.
34
+ *
35
+ * @example
36
+ * const ctx = await createRuntimeTestContext({ fs: true });
37
+ *
38
+ * // Set up mock response for fetch
39
+ * ctx.setMockResponse({ status: 200, body: '{"data": "test"}' });
40
+ *
41
+ * // Run code
42
+ * await ctx.context.eval(`
43
+ * (async () => {
44
+ * console.log("Starting fetch...");
45
+ * const response = await fetch("https://api.example.com/data");
46
+ * const data = await response.json();
47
+ * console.log("Got data:", data);
48
+ * })()
49
+ * `, { promise: true });
50
+ *
51
+ * // Check logs
52
+ * console.log(ctx.logs); // [{ level: "log", args: ["Starting fetch..."] }, ...]
53
+ *
54
+ * // Check fetch calls
55
+ * console.log(ctx.fetchCalls); // [{ url: "https://api.example.com/data", method: "GET", ... }]
56
+ *
57
+ * ctx.dispose();
58
+ */
59
+ export async function createRuntimeTestContext(
60
+ options?: RuntimeTestContextOptions
61
+ ): Promise<RuntimeTestContext> {
62
+ const opts = options ?? {};
63
+ const { createRuntime } = await import("@ricsam/isolate-runtime");
64
+ const { clearAllInstanceState } = await import("@ricsam/isolate-core");
65
+
66
+ // Clear any previous instance state
67
+ clearAllInstanceState();
68
+
69
+ // State for capturing logs and fetch calls
70
+ const logs: Array<{ level: string; args: unknown[] }> = [];
71
+ const fetchCalls: Array<{
72
+ url: string;
73
+ method: string;
74
+ headers: [string, string][];
75
+ }> = [];
76
+
77
+ let mockResponse: MockResponse = { status: 200, body: "" };
78
+
79
+ // Create mock file system
80
+ const mockFs = new MockFileSystem();
81
+
82
+ // Create runtime with configured handlers
83
+ const runtime = await createRuntime({
84
+ console: {
85
+ onLog: (level: string, ...args: unknown[]) => {
86
+ logs.push({ level, args });
87
+ },
88
+ },
89
+ fetch: {
90
+ onFetch: async (request: Request) => {
91
+ // Capture fetch call
92
+ fetchCalls.push({
93
+ url: request.url,
94
+ method: request.method,
95
+ headers: [...request.headers.entries()],
96
+ });
97
+
98
+ // Return mock response
99
+ return new Response(mockResponse.body ?? "", {
100
+ status: mockResponse.status ?? 200,
101
+ headers: mockResponse.headers,
102
+ });
103
+ },
104
+ },
105
+ fs: opts.fs ? { getDirectory: async () => mockFs } : undefined,
106
+ });
107
+
108
+ return {
109
+ isolate: runtime.isolate,
110
+ context: runtime.context,
111
+ tick: runtime.tick.bind(runtime),
112
+ dispose: runtime.dispose.bind(runtime),
113
+ logs,
114
+ fetchCalls,
115
+ setMockResponse(response: MockResponse) {
116
+ mockResponse = response;
117
+ },
118
+ mockFs,
119
+ };
120
+ }
package/src/server.ts ADDED
@@ -0,0 +1,150 @@
1
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
2
+
3
+ export interface MockServerResponse {
4
+ status?: number;
5
+ body?: string;
6
+ headers?: Record<string, string>;
7
+ }
8
+
9
+ export interface RecordedRequest {
10
+ method: string;
11
+ path: string;
12
+ headers: Record<string, string>;
13
+ body?: string;
14
+ }
15
+
16
+ export interface IntegrationServer {
17
+ /** The base URL of the server (e.g., "http://localhost:3000") */
18
+ url: string;
19
+ /** The port the server is listening on */
20
+ port: number;
21
+ /** Close the server */
22
+ close(): Promise<void>;
23
+ /** Set the response for a specific path */
24
+ setResponse(path: string, response: MockServerResponse): void;
25
+ /** Set a default response for any unmatched path */
26
+ setDefaultResponse(response: MockServerResponse): void;
27
+ /** Get all recorded requests */
28
+ getRequests(): RecordedRequest[];
29
+ /** Clear all recorded requests */
30
+ clearRequests(): void;
31
+ /** Clear all configured responses */
32
+ clearResponses(): void;
33
+ }
34
+
35
+ /**
36
+ * Start an HTTP server for integration tests.
37
+ * Useful for testing fetch operations against a real server.
38
+ *
39
+ * @example
40
+ * const server = await startIntegrationServer();
41
+ *
42
+ * server.setResponse("/api/data", {
43
+ * status: 200,
44
+ * body: JSON.stringify({ message: "Hello" }),
45
+ * headers: { "Content-Type": "application/json" }
46
+ * });
47
+ *
48
+ * // In your test
49
+ * const response = await fetch(`${server.url}/api/data`);
50
+ * const data = await response.json();
51
+ *
52
+ * // Check what requests were made
53
+ * const requests = server.getRequests();
54
+ * console.log(requests[0].path); // "/api/data"
55
+ *
56
+ * await server.close();
57
+ */
58
+ export async function startIntegrationServer(
59
+ port?: number
60
+ ): Promise<IntegrationServer> {
61
+ const responses = new Map<string, MockServerResponse>();
62
+ const requests: RecordedRequest[] = [];
63
+ let defaultResponse: MockServerResponse = { status: 404, body: "Not Found" };
64
+
65
+ const server: Server = createServer(
66
+ async (req: IncomingMessage, res: ServerResponse) => {
67
+ const path = req.url ?? "/";
68
+ const method = req.method ?? "GET";
69
+
70
+ // Read request body
71
+ const chunks: Buffer[] = [];
72
+ for await (const chunk of req) {
73
+ chunks.push(chunk as Buffer);
74
+ }
75
+ const body = chunks.length > 0 ? Buffer.concat(chunks).toString() : undefined;
76
+
77
+ // Record the request
78
+ const headers: Record<string, string> = {};
79
+ for (const [key, value] of Object.entries(req.headers)) {
80
+ if (typeof value === "string") {
81
+ headers[key] = value;
82
+ } else if (Array.isArray(value)) {
83
+ headers[key] = value.join(", ");
84
+ }
85
+ }
86
+ requests.push({ method, path, headers, body });
87
+
88
+ // Find and send response
89
+ const mockResponse = responses.get(path) ?? defaultResponse;
90
+
91
+ res.statusCode = mockResponse.status ?? 200;
92
+
93
+ if (mockResponse.headers) {
94
+ for (const [key, value] of Object.entries(mockResponse.headers)) {
95
+ res.setHeader(key, value);
96
+ }
97
+ }
98
+
99
+ res.end(mockResponse.body ?? "");
100
+ }
101
+ );
102
+
103
+ // Find an available port
104
+ const actualPort = await new Promise<number>((resolve, reject) => {
105
+ server.listen(port ?? 0, () => {
106
+ const address = server.address();
107
+ if (address && typeof address === "object") {
108
+ resolve(address.port);
109
+ } else {
110
+ reject(new Error("Failed to get server address"));
111
+ }
112
+ });
113
+ server.on("error", reject);
114
+ });
115
+
116
+ return {
117
+ url: `http://localhost:${actualPort}`,
118
+ port: actualPort,
119
+
120
+ async close() {
121
+ return new Promise((resolve, reject) => {
122
+ server.close((err) => {
123
+ if (err) reject(err);
124
+ else resolve();
125
+ });
126
+ });
127
+ },
128
+
129
+ setResponse(path: string, response: MockServerResponse) {
130
+ responses.set(path, response);
131
+ },
132
+
133
+ setDefaultResponse(response: MockServerResponse) {
134
+ defaultResponse = response;
135
+ },
136
+
137
+ getRequests() {
138
+ return [...requests];
139
+ },
140
+
141
+ clearRequests() {
142
+ requests.length = 0;
143
+ },
144
+
145
+ clearResponses() {
146
+ responses.clear();
147
+ defaultResponse = { status: 404, body: "Not Found" };
148
+ },
149
+ };
150
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Type-checking utility for isolated-vm user code using ts-morph.
3
+ *
4
+ * This utility allows you to validate TypeScript code strings against
5
+ * the isolate global type definitions before running them in the sandbox.
6
+ *
7
+ * @example
8
+ * import { typecheckIsolateCode } from "@ricsam/isolate-test-utils";
9
+ *
10
+ * const result = typecheckIsolateCode(`
11
+ * serve({
12
+ * fetch(request, server) {
13
+ * return new Response("Hello!");
14
+ * }
15
+ * });
16
+ * `, { include: ["fetch"] });
17
+ *
18
+ * if (!result.success) {
19
+ * console.error("Type errors:", result.errors);
20
+ * }
21
+ */
22
+
23
+ import { Project, ts } from "ts-morph";
24
+ import { TYPE_DEFINITIONS, type TypeDefinitionKey } from "./isolate-types.ts";
25
+
26
+ /**
27
+ * Result of type-checking isolate code.
28
+ */
29
+ export interface TypecheckResult {
30
+ /**
31
+ * Whether the code passed type checking.
32
+ */
33
+ success: boolean;
34
+
35
+ /**
36
+ * Array of type errors found in the code.
37
+ */
38
+ errors: TypecheckError[];
39
+ }
40
+
41
+ /**
42
+ * A single type-checking error.
43
+ */
44
+ export interface TypecheckError {
45
+ /**
46
+ * The error message from TypeScript.
47
+ */
48
+ message: string;
49
+
50
+ /**
51
+ * The line number where the error occurred (1-indexed).
52
+ */
53
+ line?: number;
54
+
55
+ /**
56
+ * The column number where the error occurred (1-indexed).
57
+ */
58
+ column?: number;
59
+
60
+ /**
61
+ * The TypeScript error code.
62
+ */
63
+ code?: number;
64
+ }
65
+
66
+ /**
67
+ * A single library type file to inject into the virtual file system.
68
+ */
69
+ export interface LibraryTypeFile {
70
+ /** The file content (e.g., .d.ts or package.json content) */
71
+ content: string;
72
+ /** The virtual path (e.g., "node_modules/zod/index.d.ts") */
73
+ path: string;
74
+ }
75
+
76
+ /**
77
+ * Library types bundle for a single package.
78
+ */
79
+ export interface LibraryTypes {
80
+ files: LibraryTypeFile[];
81
+ }
82
+
83
+ /**
84
+ * Options for type-checking isolate code.
85
+ */
86
+ export interface TypecheckOptions {
87
+ /**
88
+ * Which isolate global types to include.
89
+ * @default ["core", "fetch", "fs"]
90
+ */
91
+ include?: Array<"core" | "fetch" | "fs" | "console" | "encoding" | "timers" | "testEnvironment">;
92
+
93
+ /**
94
+ * Library type definitions to inject for import resolution.
95
+ * These are added to the virtual node_modules/ for module resolution.
96
+ *
97
+ * Use the build-library-types.ts script to generate these bundles from
98
+ * your project's node_modules, then pass them here.
99
+ *
100
+ * @example
101
+ * import { LIBRARY_TYPES } from "./my-library-types.ts";
102
+ *
103
+ * typecheckIsolateCode(code, {
104
+ * libraryTypes: {
105
+ * zod: LIBRARY_TYPES.zod,
106
+ * "@richie-rpc/core": LIBRARY_TYPES["@richie-rpc/core"],
107
+ * }
108
+ * });
109
+ */
110
+ libraryTypes?: Record<string, LibraryTypes>;
111
+
112
+ /**
113
+ * Additional compiler options to pass to TypeScript.
114
+ */
115
+ compilerOptions?: Partial<ts.CompilerOptions>;
116
+ }
117
+
118
+ /**
119
+ * Get the message text from a TypeScript diagnostic message.
120
+ * Handles both string messages and DiagnosticMessageChain objects.
121
+ */
122
+ function getMessageText(messageText: unknown): string {
123
+ if (typeof messageText === "string") {
124
+ return messageText;
125
+ }
126
+
127
+ // Handle ts-morph DiagnosticMessageChain wrapper
128
+ if (
129
+ messageText &&
130
+ typeof messageText === "object" &&
131
+ "getMessageText" in messageText &&
132
+ typeof (messageText as { getMessageText: unknown }).getMessageText ===
133
+ "function"
134
+ ) {
135
+ return (messageText as { getMessageText: () => string }).getMessageText();
136
+ }
137
+
138
+ // Handle raw TypeScript DiagnosticMessageChain
139
+ if (
140
+ messageText &&
141
+ typeof messageText === "object" &&
142
+ "messageText" in messageText
143
+ ) {
144
+ return String((messageText as { messageText: unknown }).messageText);
145
+ }
146
+
147
+ return String(messageText);
148
+ }
149
+
150
+
151
+ /**
152
+ * Type-check isolate user code against the package type definitions.
153
+ *
154
+ * @param code - The TypeScript/JavaScript code to check
155
+ * @param options - Configuration options
156
+ * @returns The result of type checking
157
+ *
158
+ * @example
159
+ * // Check code that uses the fetch API
160
+ * const result = typecheckIsolateCode(`
161
+ * const response = await fetch("https://api.example.com/data");
162
+ * const data = await response.json();
163
+ * `, { include: ["core", "fetch"] });
164
+ *
165
+ * @example
166
+ * // Check code that uses serve()
167
+ * const result = typecheckIsolateCode(`
168
+ * serve({
169
+ * fetch(request, server) {
170
+ * return new Response("Hello!");
171
+ * }
172
+ * });
173
+ * `, { include: ["fetch"] });
174
+ *
175
+ * @example
176
+ * // Check code that uses the file system API
177
+ * const result = typecheckIsolateCode(`
178
+ * const root = await getDirectory("/data");
179
+ * const file = await root.getFileHandle("config.json");
180
+ * `, { include: ["core", "fs"] });
181
+ */
182
+ export function typecheckIsolateCode(
183
+ code: string,
184
+ options?: TypecheckOptions
185
+ ): TypecheckResult {
186
+ const include = options?.include ?? ["core", "fetch", "fs"];
187
+ const libraryTypes = options?.libraryTypes ?? {};
188
+ const hasLibraries = Object.keys(libraryTypes).length > 0;
189
+
190
+ // Create a project with in-memory file system
191
+ const project = new Project({
192
+ useInMemoryFileSystem: true,
193
+ compilerOptions: {
194
+ target: ts.ScriptTarget.ESNext,
195
+ module: ts.ModuleKind.ESNext,
196
+ // Use NodeJs resolution for node_modules/ lookup when libraries are included
197
+ moduleResolution: hasLibraries
198
+ ? ts.ModuleResolutionKind.NodeJs
199
+ : undefined,
200
+ lib: ["lib.esnext.d.ts", "lib.dom.d.ts"],
201
+ strict: true,
202
+ noEmit: true,
203
+ skipLibCheck: true,
204
+ esModuleInterop: true,
205
+ allowSyntheticDefaultImports: true,
206
+ ...options?.compilerOptions,
207
+ },
208
+ });
209
+
210
+ const memFs = project.getFileSystem();
211
+
212
+ // Add type definition files from embedded strings (isolate globals)
213
+ for (const pkg of include) {
214
+ const content = TYPE_DEFINITIONS[pkg as TypeDefinitionKey];
215
+ if (content) {
216
+ project.createSourceFile(`${pkg}.d.ts`, content);
217
+ }
218
+ }
219
+
220
+ // Add library type definitions to virtual node_modules/
221
+ for (const [_libName, lib] of Object.entries(libraryTypes)) {
222
+ for (const typeFile of lib.files) {
223
+ // JSON files go to file system, TS files as source files
224
+ if (typeFile.path.endsWith(".json")) {
225
+ memFs.writeFileSync(`/${typeFile.path}`, typeFile.content);
226
+ } else {
227
+ project.createSourceFile(`/${typeFile.path}`, typeFile.content, { overwrite: true });
228
+ }
229
+ }
230
+ }
231
+
232
+ // Add the user code
233
+ const sourceFile = project.createSourceFile("usercode.ts", code);
234
+
235
+ // Get diagnostics
236
+ const diagnostics = sourceFile.getPreEmitDiagnostics();
237
+
238
+ // Convert diagnostics to our error format
239
+ const errors: TypecheckError[] = diagnostics.map((diagnostic) => {
240
+ const start = diagnostic.getStart();
241
+ const sourceFile = diagnostic.getSourceFile();
242
+
243
+ let line: number | undefined;
244
+ let column: number | undefined;
245
+
246
+ if (start !== undefined && sourceFile) {
247
+ const lineAndChar = sourceFile.getLineAndColumnAtPos(start);
248
+ line = lineAndChar.line;
249
+ column = lineAndChar.column;
250
+ }
251
+
252
+ return {
253
+ message: getMessageText(diagnostic.getMessageText()),
254
+ line,
255
+ column,
256
+ code: diagnostic.getCode(),
257
+ };
258
+ });
259
+
260
+ return {
261
+ success: errors.length === 0,
262
+ errors,
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Format type-check errors for display.
268
+ *
269
+ * @param result - The type-check result
270
+ * @returns A formatted string of errors
271
+ *
272
+ * @example
273
+ * const result = typecheckIsolateCode(code);
274
+ * if (!result.success) {
275
+ * console.error(formatTypecheckErrors(result));
276
+ * }
277
+ */
278
+ export function formatTypecheckErrors(result: TypecheckResult): string {
279
+ if (result.success) {
280
+ return "No type errors found.";
281
+ }
282
+
283
+ return result.errors
284
+ .map((error) => {
285
+ const location =
286
+ error.line !== undefined ? `:${error.line}:${error.column ?? 1}` : "";
287
+ const code = error.code ? ` (TS${error.code})` : "";
288
+ return `usercode.ts${location}${code}: ${error.message}`;
289
+ })
290
+ .join("\n");
291
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src"
5
+ },
6
+ "include": ["src/**/*"],
7
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
8
+ }
package/README.md DELETED
@@ -1,45 +0,0 @@
1
- # @ricsam/isolate-test-utils
2
-
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
4
-
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
6
-
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
8
-
9
- ## Purpose
10
-
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@ricsam/isolate-test-utils`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
15
-
16
- ## What is OIDC Trusted Publishing?
17
-
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
19
-
20
- ## Setup Instructions
21
-
22
- To properly configure OIDC trusted publishing for this package:
23
-
24
- 1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
25
- 2. Configure the trusted publisher (e.g., GitHub Actions)
26
- 3. Specify the repository and workflow that should be allowed to publish
27
- 4. Use the configured workflow to publish your actual package
28
-
29
- ## DO NOT USE THIS PACKAGE
30
-
31
- This package is a placeholder for OIDC configuration only. It:
32
- - Contains no executable code
33
- - Provides no functionality
34
- - Should not be installed as a dependency
35
- - Exists only for administrative purposes
36
-
37
- ## More Information
38
-
39
- For more details about npm's trusted publishing feature, see:
40
- - [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
41
- - [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
42
-
43
- ---
44
-
45
- **Maintained for OIDC setup purposes only**