diff --git a/packages/insomnia/src/scripting/quickjs-script-engine.test.ts b/packages/insomnia/src/scripting/quickjs-script-engine.test.ts index 82beef67e1..f807185aa0 100644 --- a/packages/insomnia/src/scripting/quickjs-script-engine.test.ts +++ b/packages/insomnia/src/scripting/quickjs-script-engine.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { RequestContext } from '../../../insomnia-scripting-environment/src/objects/interfaces'; import { runScriptInQuickJs } from './quickjs-script-engine'; @@ -82,17 +82,150 @@ describe('runScriptInQuickJs', () => { expect(result.logs.some(row => row.includes('hello from quickjs'))).toBe(true); }); - it('throws a clear error when calling the unsupported sendRequest API', async () => { - const context = baseContext(); - - await expect( - runScriptInQuickJs({ script: 'await insomnia.sendRequest("https://example.com");', context }), - ).rejects.toThrow(/insomnia\.sendRequest\(\) is not supported/); - }); - it('propagates script errors with a useful message', async () => { const context = baseContext(); await expect(runScriptInQuickJs({ script: 'throw new Error("boom");', context })).rejects.toThrow('boom'); }); }); + +describe('runScriptInQuickJs sendRequest bridge', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const stubFetchOnce = (response: { ok: boolean; status?: number; body: unknown }) => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: response.ok, + status: response.status ?? (response.ok ? 200 : 500), + text: () => Promise.resolve(JSON.stringify(response.body)), + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; + }; + + it('sends a real request through the bridge and resolves with a response object', async () => { + stubFetchOnce({ + ok: true, + body: { + code: 200, + status: 'OK', + headers: [{ name: 'content-type', value: 'application/json' }], + body: '{"hello":"world"}', + responseTime: 12, + }, + }); + const context = baseContext(); + + const result = await runScriptInQuickJs({ + script: ` + const response = await insomnia.sendRequest('https://example.com'); + insomnia.environment.set('code', response.code); + insomnia.environment.set('parsedBody', response.json()); + `, + context, + }); + + expect(result.environment.data).toMatchObject({ code: 200, parsedBody: { hello: 'world' } }); + }); + + it('sends the auth token as a header on the bridge fetch call', async () => { + const fetchMock = stubFetchOnce({ + ok: true, + body: { code: 204, status: 'No Content', headers: [], body: '', responseTime: 1 }, + }); + const context = baseContext(); + + await runScriptInQuickJs({ + script: `await insomnia.sendRequest('https://example.com');`, + context, + authToken: 'super-secret-token', + }); + + expect(fetchMock).toHaveBeenCalledWith( + 'insomnia-templating-worker-database://network.sendrequestwithoutsideeffects', + expect.objectContaining({ headers: { 'x-insomnia-templating-auth': 'super-secret-token' } }), + ); + }); + + it('normalizes a plain-object request with headers and a string body', async () => { + const fetchMock = stubFetchOnce({ + ok: true, + body: { code: 200, status: 'OK', headers: [], body: '', responseTime: 1 }, + }); + const context = baseContext(); + + await runScriptInQuickJs({ + script: ` + await insomnia.sendRequest({ + url: 'https://example.com/items', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"a":1}', + }); + `, + context, + }); + + const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(sentBody).toEqual({ + options: { + request: { + url: 'https://example.com/items', + method: 'POST', + headers: [{ name: 'Content-Type', value: 'application/json' }], + body: { mimeType: 'text/plain', text: '{"a":1}' }, + }, + caCertficatePath: null, + }, + }); + }); + + it('supports the Postman-style (error, response) callback signature', async () => { + stubFetchOnce({ ok: true, body: { code: 200, status: 'OK', headers: [], body: 'ok', responseTime: 1 } }); + const context = baseContext(); + + const result = await runScriptInQuickJs({ + script: ` + await new Promise((resolve) => { + insomnia.sendRequest('https://example.com', (error, response) => { + insomnia.environment.set('callbackError', error ?? null); + insomnia.environment.set('callbackBody', response ? response.body : null); + resolve(); + }); + }); + `, + context, + }); + + expect(result.environment.data).toMatchObject({ callbackError: null, callbackBody: 'ok' }); + }); + + it('rejects with the bridge-reported error when the host handler fails', async () => { + stubFetchOnce({ ok: false, status: 500, body: { error: 'DNS resolution failed' } }); + const context = baseContext(); + + await expect( + runScriptInQuickJs({ script: `await insomnia.sendRequest('https://unreachable.invalid');`, context }), + ).rejects.toThrow('DNS resolution failed'); + }); + + it('reports the bridge error to the callback instead of throwing when a callback is given', async () => { + stubFetchOnce({ ok: false, status: 500, body: { error: 'DNS resolution failed' } }); + const context = baseContext(); + + const result = await runScriptInQuickJs({ + script: ` + await new Promise((resolve) => { + insomnia.sendRequest('https://unreachable.invalid', (error) => { + insomnia.environment.set('callbackError', error); + resolve(); + }); + }); + `, + context, + }); + + expect((result.environment.data as Record).callbackError).toBe('DNS resolution failed'); + }); +}); diff --git a/packages/insomnia/src/scripting/quickjs-script-engine.ts b/packages/insomnia/src/scripting/quickjs-script-engine.ts index c6284f30ae..49a4ccc8d9 100644 --- a/packages/insomnia/src/scripting/quickjs-script-engine.ts +++ b/packages/insomnia/src/scripting/quickjs-script-engine.ts @@ -14,15 +14,26 @@ import { getQuickJSModule } from '../templating/sandbox/quickjs-runtime'; * minimal API surface is bridged in — enough to demonstrate the architecture, not enough to replace * the hidden-window sandbox yet. Gated behind `settings.useQuickJsScriptSandbox` (default off). * - * Not supported (throws inside the script if called): insomnia.sendRequest(), insomnia.test()/ - * pm.test(), collectionVariables, vault, cookies, client certificates, and mutating the request. + * insomnia.sendRequest() bridges to the real `network.sendRequestWithoutSideEffects` host handler + * (the same one the template-tag sandbox uses) over the `insomnia-templating-worker-database://` + * fetch-based protocol — see `installSendRequestBridge` below. It supports only a minimal request + * shape (a URL string, or `{ url, method, headers, body }` with a plain string body) — no auth, + * client certificates, cookies, or multipart/urlencoded bodies yet, and the response object exposes + * only `code`/`status`/`headers`/`body`/`responseTime`/`json()`/`text()` (no chai assertions, no + * `originalRequest`). Full parity with the hidden-window `Response` class is deferred. + * + * Not supported (throws inside the script if called): insomnia.test()/pm.test(), collectionVariables, + * vault, cookies, client certificates, and mutating the request. */ export const runScriptInQuickJs = async ({ script, context, + authToken, }: { script: string; context: RequestContext; + /** Auth token for the `insomnia-templating-worker-database://` bridge — required for sendRequest. */ + authToken?: string; }): Promise => { const QuickJS = await getQuickJSModule(); const vm = QuickJS.newContext(); @@ -43,6 +54,7 @@ export const runScriptInQuickJs = async ({ installConsole(vm, scriptConsole); installKeyValueBridge(vm, '__envGet', '__envSet', environmentData); installKeyValueBridge(vm, '__varGet', '__varSet', variablesData); + installSendRequestBridge(vm, authToken); setGlobalString(vm, '__requestJSON', JSON.stringify(context.request ?? {})); evalOrThrow(vm, BOOTSTRAP, ''); @@ -88,7 +100,43 @@ globalThis.insomnia = { } return value; })(JSON.parse(__requestJSON)), - sendRequest: () => { throw new Error(${JSON.stringify(unsupportedApiMessage('insomnia.sendRequest()'))}); }, + sendRequest: (request, callback) => { + const normalized = typeof request === 'string' + ? { url: request, method: 'GET' } + : { + url: request.url, + method: request.method || 'GET', + headers: Array.isArray(request.headers) + ? request.headers + : Object.entries(request.headers || {}).map(([name, value]) => ({ name, value: String(value) })), + body: request.body !== undefined ? { mimeType: 'text/plain', text: String(request.body) } : undefined, + }; + const bodyJson = JSON.stringify({ options: { request: normalized, caCertficatePath: null } }); + const promise = __sendRequest(bodyJson).then((envelopeJson) => { + const envelope = JSON.parse(envelopeJson); + if (!envelope.ok) { + throw new Error(envelope.error); + } + const result = envelope.value; + return { + code: result.code, + status: result.status, + headers: result.headers, + body: result.body, + responseTime: result.responseTime, + json: () => JSON.parse(result.body), + text: () => result.body, + }; + }); + if (typeof callback === 'function') { + promise.then( + (response) => callback(undefined, response), + (err) => callback(err.message), + ); + return undefined; + } + return promise; + }, test: () => { throw new Error(${JSON.stringify(unsupportedApiMessage('insomnia.test()/pm.test()'))}); }, }; globalThis.$ = globalThis.insomnia; @@ -114,6 +162,57 @@ const installConsole = (vm: QuickJSContext, scriptConsole: Console): void => { consoleHandle.dispose(); }; +// The same fetch-based bridge the template-tag sandbox uses (`common/templating/liquid-extension-worker.ts`), +// reused here rather than adding a second protocol scheme — this endpoint already sends a real +// request via curl without persisting it to request/response history, which is exactly what an +// ad-hoc `insomnia.sendRequest()` call needs. +const SEND_REQUEST_ENDPOINT = 'insomnia-templating-worker-database://network.sendrequestwithoutsideeffects'; +const TEMPLATING_DB_AUTH_HEADER = 'x-insomnia-templating-auth'; + +const sendRequestViaFetch = async (requestBodyJson: string, authToken?: string): Promise> => { + const resp = await fetch(SEND_REQUEST_ENDPOINT, { + method: 'post', + headers: authToken ? { [TEMPLATING_DB_AUTH_HEADER]: authToken } : undefined, + body: requestBodyJson, + }); + const parsed = JSON.parse(await resp.text()); + if (!resp.ok) { + throw new Error(typeof parsed?.error === 'string' ? parsed.error : `sendRequest failed with status ${resp.status}`); + } + return parsed; +}; + +/** Registers the async `__sendRequest(bodyJson)` bridge backing `insomnia.sendRequest()` in BOOTSTRAP. */ +const installSendRequestBridge = (vm: QuickJSContext, authToken?: string): void => { + const fn = vm.newFunction('__sendRequest', bodyHandle => { + const bodyJson = vm.getString(bodyHandle); + const deferred = vm.newPromise(); + sendRequestViaFetch(bodyJson, authToken).then( + value => resolveDeferredWithString(vm, deferred, JSON.stringify({ ok: true, value })), + err => + resolveDeferredWithString( + vm, + deferred, + JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }), + ), + ); + // The settled VM promise schedules a job; pump it so the awaiting script resumes. + deferred.settled.then(() => vm.runtime.executePendingJobs()); + return deferred.handle; + }); + setGlobal(vm, '__sendRequest', fn); +}; + +const resolveDeferredWithString = ( + vm: QuickJSContext, + deferred: ReturnType, + value: string, +): void => { + const handle = vm.newString(value); + deferred.resolve(handle); + handle.dispose(); +}; + /** Registers `(key)`/`(key, jsonValue)` host functions backed by a plain object. */ const installKeyValueBridge = ( vm: QuickJSContext, diff --git a/packages/insomnia/src/scripting/quickjs-script.worker.ts b/packages/insomnia/src/scripting/quickjs-script.worker.ts index e9e3c45876..c0932fb052 100644 --- a/packages/insomnia/src/scripting/quickjs-script.worker.ts +++ b/packages/insomnia/src/scripting/quickjs-script.worker.ts @@ -12,6 +12,8 @@ interface WorkerRequest { id: string; script: string; context: RequestContext; + /** Auth token for the `insomnia-templating-worker-database://` bridge — see run-script-quickjs.ts. */ + authToken?: string; } interface WorkerErrorResponse { @@ -27,9 +29,9 @@ interface WorkerSuccessResponse { export type WorkerResponse = WorkerSuccessResponse | WorkerErrorResponse; self.onmessage = async (event: MessageEvent) => { - const { id, script, context } = event.data; + const { id, script, context, authToken } = event.data; try { - const result = await runScriptInQuickJs({ script, context }); + const result = await runScriptInQuickJs({ script, context, authToken }); self.postMessage({ id, result } satisfies WorkerSuccessResponse); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); diff --git a/packages/insomnia/src/scripting/run-script-quickjs.test.ts b/packages/insomnia/src/scripting/run-script-quickjs.test.ts index e0774a4525..25dea3f446 100644 --- a/packages/insomnia/src/scripting/run-script-quickjs.test.ts +++ b/packages/insomnia/src/scripting/run-script-quickjs.test.ts @@ -53,6 +53,10 @@ class FakeWorker { } } +// runScriptInQuickJs now awaits the (mocked) auth token before touching the worker, so callers must +// let that microtask resolve before inspecting postedMessages or emitting worker events. +const flushMicrotasks = (): Promise => new Promise(resolve => setTimeout(resolve, 0)); + describe('runScriptInQuickJs (worker client)', () => { let fakeWorker: FakeWorker; @@ -62,6 +66,9 @@ describe('runScriptInQuickJs (worker client)', () => { 'Worker', vi.fn(() => fakeWorker), ); + vi.stubGlobal('window', { + main: { templatingDb: { getAuthToken: vi.fn().mockResolvedValue('test-token') } }, + }); // The module keeps a lazily-created singleton worker at module scope — reset it between tests // by re-importing fresh so each test gets its own FakeWorker instance. vi.resetModules(); @@ -76,9 +83,11 @@ describe('runScriptInQuickJs (worker client)', () => { const context = baseContext(); const promise = runScriptInQuickJs({ script: 'console.log(1)', context }); + await flushMicrotasks(); expect(fakeWorker.postedMessages).toHaveLength(1); - const { id } = fakeWorker.postedMessages[0]; + const { id, authToken } = fakeWorker.postedMessages[0]; expect(typeof id).toBe('string'); + expect(authToken).toBe('test-token'); const result = { ...context, logs: ['log: 1\n'] }; fakeWorker.emitMessage({ id, result }); @@ -91,6 +100,7 @@ describe('runScriptInQuickJs (worker client)', () => { const context = baseContext(); const promise = runScriptInQuickJs({ script: 'throw new Error("boom")', context }); + await flushMicrotasks(); const { id } = fakeWorker.postedMessages[0]; fakeWorker.emitMessage({ id, error: { message: 'boom', name: 'Error' } }); @@ -102,6 +112,7 @@ describe('runScriptInQuickJs (worker client)', () => { const context = baseContext(); const promise = runScriptInQuickJs({ script: 'console.log(1)', context }); + await flushMicrotasks(); fakeWorker.emitMessage({ id: 'not-a-real-id', result: {} }); const { id } = fakeWorker.postedMessages[0]; @@ -116,6 +127,7 @@ describe('runScriptInQuickJs (worker client)', () => { const context = baseContext(); const promise = runScriptInQuickJs({ script: 'while(true){}', context }); + await flushMicrotasks(); fakeWorker.emitError('script execution context has been aborted'); await expect(promise).rejects.toThrow(/QuickJS sandbox worker crashed/); @@ -126,6 +138,7 @@ describe('runScriptInQuickJs (worker client)', () => { const context = baseContext(); const firstCall = runScriptInQuickJs({ script: 'while(true){}', context }); + await flushMicrotasks(); fakeWorker.emitError('boom'); await expect(firstCall).rejects.toThrow(); @@ -136,6 +149,7 @@ describe('runScriptInQuickJs (worker client)', () => { ); const secondCall = runScriptInQuickJs({ script: 'console.log(1)', context }); + await flushMicrotasks(); expect(secondWorker.postedMessages).toHaveLength(1); const { id } = secondWorker.postedMessages[0]; const result = { ...context, logs: [] }; diff --git a/packages/insomnia/src/scripting/run-script-quickjs.ts b/packages/insomnia/src/scripting/run-script-quickjs.ts index d096338b16..d6734e3191 100644 --- a/packages/insomnia/src/scripting/run-script-quickjs.ts +++ b/packages/insomnia/src/scripting/run-script-quickjs.ts @@ -12,6 +12,16 @@ import type { WorkerResponse } from './quickjs-script.worker'; let worker: Worker | null = null; const pending = new Map void; reject: (err: Error) => void }>(); +// The Worker has no window.main access, so this module fetches the auth token once (same pattern as +// ui/worker/templating-handler.ts) and forwards it on every postMessage instead. +let authTokenPromise: Promise | null = null; +const getAuthToken = (): Promise => { + if (!authTokenPromise) { + authTokenPromise = window.main.templatingDb.getAuthToken(); + } + return authTokenPromise; +}; + const getWorker = (): Worker => { if (worker) { return worker; @@ -57,16 +67,17 @@ const getWorker = (): Worker => { return instance; }; -export const runScriptInQuickJs = ({ +export const runScriptInQuickJs = async ({ script, context, }: { script: string; context: RequestContext; }): Promise => { + const authToken = await getAuthToken(); const id = `quickjs-${Date.now()}-${Math.random().toString(36).slice(2)}`; return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); - getWorker().postMessage({ id, script, context }); + getWorker().postMessage({ id, script, context, authToken }); }); };