diff --git a/packages/bruno-api-docs/e2e/tests/scripts/response-object-scripting.spec.ts b/packages/bruno-api-docs/e2e/tests/scripts/response-object-scripting.spec.ts new file mode 100644 index 00000000..214c7ec2 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/scripts/response-object-scripting.spec.ts @@ -0,0 +1,187 @@ +import { test, expect } from '../../playwright'; +import type { Page } from '@playwright/test'; +import type { CodeEditorComponent } from '../../components/code-editor/code-editor.component'; + +const RES_TESTS_SCRIPT = ` +test('res exposes the status code, statusText, headers, and parsed body as direct properties', function () { + expect(res.status).to.equal(200); + expect(res.statusText).to.be.a('string'); + expect(res.headers['content-type']).to.contain('application/json'); + expect(res.body.users).to.have.length(2); +}); + +test('res.getStatus, res.getStatusText, and res.getBody return the status and the parsed body', function () { + expect(res.getStatus()).to.equal(200); + expect(res.getStatusText()).to.be.a('string'); + expect(res.getBody().users).to.have.length(2); +}); + +test('res.getHeader finds a header no matter how it is capitalised, and res.getHeaders returns all of them', function () { + expect(res.getHeader('Content-Type')).to.contain('application/json'); + expect(res.getHeader('X-TOKEN')).to.equal('abc'); + expect(res.getHeaders()['x-token']).to.equal('abc'); +}); + +test('res exposes the response time and URL as both properties and getter methods', function () { + expect(res.responseTime).to.be.a('number'); + expect(res.getResponseTime()).to.be.a('number'); + expect(res.url).to.contain('/api/users'); + expect(res.getUrl()).to.contain('/api/users'); +}); + +test('res.getSize reports the header, body, and total sizes as numbers', function () { + var size = res.getSize(); + expect(size.header).to.be.a('number'); + expect(size.body).to.be.a('number'); + expect(size.total).to.be.at.least(0); +}); + +test('res.getDataBuffer can be called (the browser has no raw byte buffer, so it is empty)', function () { + var db = res.getDataBuffer(); + expect(db === undefined || db === null).to.equal(true); +}); + +test('res can be called to read into the body, with and without a filter function', function () { + expect(res('users[0].name')).to.equal('Ada'); + var filtered = JSON.stringify(res('users[?].name', function (u) { return u.id === 2; })); + expect(filtered).to.contain('Lin'); + expect(filtered).to.not.contain('Ada'); +}); + +test('res.headerList reads a single header case-insensitively: get, one, has, indexOf, count', function () { + expect(res.headerList.get('Content-Type')).to.contain('application/json'); + expect(res.headerList.one('X-Token').value).to.equal('abc'); + expect(res.headerList.has('x-token')).to.equal(true); + expect(res.headerList.has('x-token', 'abc')).to.equal(true); + expect(res.headerList.indexOf('X-Token')).to.be.at.least(0); + expect(res.headerList.count()).to.be.at.least(2); +}); + +test('res.headerList reads the whole list: all, toObject, toString, toJSON', function () { + expect(res.headerList.all().length).to.be.at.least(2); + expect(res.headerList.toObject()['x-token']).to.equal('abc'); + expect(res.headerList.toString()).to.contain('x-token'); + expect(res.headerList.toJSON().length).to.be.at.least(2); +}); + +test('res.headerList can be iterated: each, map, filter, find, and reduce (map and reduce accept a this value)', function () { + var keys = []; + res.headerList.each(function (h) { keys.push(h.key); }); + expect(keys.length).to.be.at.least(2); + + var tagged = res.headerList.map(function (h) { return this.prefix + h.key; }, { prefix: '#' }); + expect(tagged.join(',')).to.contain('#x-token'); + + expect(res.headerList.filter(function (h) { return h.key === 'x-token'; }).length).to.equal(1); + expect(res.headerList.find(function (h) { return h.key === 'x-token'; }).value).to.equal('abc'); + + var joined = res.headerList.reduce(function (acc, h) { return acc + h.key + ';'; }, ''); + expect(joined).to.contain('x-token;'); +}); + +test('res.headerList is read-only: add, upsert, remove, clear, populate, repopulate, and assimilate all throw', function () { + var writes = ['add', 'upsert', 'remove', 'clear', 'populate', 'repopulate', 'assimilate']; + writes.forEach(function (method) { + var threw = false; + try { res.headerList[method]({ key: 'x', value: 'y' }); } catch (e) { threw = true; } + expect(threw, method + '() should throw').to.equal(true); + }); +}); + +test('res.setBody replaces the response body, and res.getBody returns the new value', function () { + res.setBody({ replaced: true }); + expect(res.getBody().replaced).to.equal(true); +}); +`; + +const HIDDEN_HEADER_SCRIPT = ` +test('a header the server sends but does not CORS-expose is hidden by the browser and reads back empty on res', function () { + expect(res.body.users).to.have.length(1); + expect(res.getHeader('content-type')).to.contain('application/json'); + expect(res.getHeader('x-token') == null).to.equal(true); + expect(res.headers['x-token'] === undefined).to.equal(true); + expect(res.headerList.has('x-token')).to.equal(false); + expect(res.headerList.get('x-token') == null).to.equal(true); + expect(res.headerList.one('x-token') == null).to.equal(true); +}); +`; + +const setEditorScript = async (page: Page, editor: CodeEditorComponent, script: string): Promise => { + await editor.focus(); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.insertText(script); +}; + +test.describe('The res object available to scripts (end-to-end in the playground)', () => { + test.use({ viewport: { width: 1280, height: 900 } }); + + test.beforeEach(async ({ page }) => { + await page.route('**/api/users**', (route) => + route.fulfill({ + status: 200, + headers: { + 'content-type': 'application/json', + 'x-token': 'abc', + 'access-control-allow-origin': '*', + 'access-control-expose-headers': 'x-token, content-type' + }, + body: JSON.stringify({ users: [{ id: 1, name: 'Ada' }, { id: 2, name: 'Lin' }] }) + }) + ); + }); + + test('every res method and property runs in a tests script with no failures', async ({ page, playground, responsePane }) => { + await page.goto('/#/?pg=1&dock=bottom'); + await playground.openSidebarItem('get users'); + + await playground.selectTab('tests'); + await setEditorScript(page, playground.testsEditor, RES_TESTS_SCRIPT); + + await responsePane.send(); + await responsePane.switchToTab('tests'); + + await expect(page.getByText(/Passed: [1-9]\d*, Failed: 0/).first()).toBeVisible(); + await expect(page.getByText(/Failed: [1-9]/)).toHaveCount(0); + }); + + test('res.setBody in a post-response script replaces the body shown in the response pane', async ({ page, playground, responsePane }) => { + await page.goto('/#/?pg=1&dock=bottom'); + await playground.openSidebarItem('get users'); + + await playground.selectTab('scripts'); + await page.getByTestId('scripts-tabs-tab-post-response').click(); + await setEditorScript(page, playground.postResponseScriptEditor, `res.setBody({ marker: 'hey there' });`); + + await responsePane.send(); + + await expect(responsePane.bodyEditor.root).toContainText('marker'); + await expect(responsePane.bodyEditor.root).toContainText('there'); + await expect(responsePane.bodyEditor.root).not.toContainText('Ada'); + }); + + test('a custom header sent without access-control-expose-headers is hidden by the browser', async ({ page, playground, responsePane }) => { + await page.unroute('**/api/users**'); + await page.route('**/api/users**', (route) => + route.fulfill({ + status: 200, + headers: { + 'content-type': 'application/json', + 'x-token': 'abc', + 'access-control-allow-origin': '*' + }, + body: JSON.stringify({ users: [{ id: 1, name: 'Ada' }] }) + }) + ); + + await page.goto('/#/?pg=1&dock=bottom'); + await playground.openSidebarItem('get users'); + await playground.selectTab('tests'); + await setEditorScript(page, playground.testsEditor, HIDDEN_HEADER_SCRIPT); + + await responsePane.send(); + await responsePane.switchToTab('tests'); + + await expect(page.getByText(/Passed: [1-9]\d*, Failed: 0/).first()).toBeVisible(); + await expect(page.getByText(/Failed: [1-9]/)).toHaveCount(0); + }); +}); diff --git a/packages/bruno-api-docs/src/runner/index.ts b/packages/bruno-api-docs/src/runner/index.ts index a122d46b..ffa60594 100644 --- a/packages/bruno-api-docs/src/runner/index.ts +++ b/packages/bruno-api-docs/src/runner/index.ts @@ -1,6 +1,7 @@ import type { HttpRequest } from '@opencollection/types/requests/http'; import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types'; import type { Environment } from '@opencollection/types/config/environments'; +import type { Buffer } from 'buffer'; import { RequestExecutor } from './RequestExecutor'; import ScriptRuntime from '../scripting/runtime/script-runtime'; import AssertRuntime, { type AssertionResult } from '../scripting/runtime/assert-runtime'; @@ -66,6 +67,7 @@ export interface RunRequestResponse { statusText?: string; headers?: Record; data?: any; + dataBuffer?: Buffer; /** Present only when needed downstream — binary previews, byte views, or an unreconstructable body. */ base64Data?: string; /** Content type sniffed from the response bytes at parse time (magic numbers → SVG → text), or null. */ diff --git a/packages/bruno-api-docs/src/scripting/runtime/script-runtime.spec.ts b/packages/bruno-api-docs/src/scripting/runtime/script-runtime.spec.ts index e6af24db..284f9999 100644 --- a/packages/bruno-api-docs/src/scripting/runtime/script-runtime.spec.ts +++ b/packages/bruno-api-docs/src/scripting/runtime/script-runtime.spec.ts @@ -96,4 +96,69 @@ describe('ScriptRuntime', () => { } }); }); + + it('runs the full res API inside the QuickJS sandbox — headerList reads/iterators/read-only writes, case-insensitive getHeader, resolved responseTime and url, and res() query filters', async () => { + const runtime = new ScriptRuntime(); + + const mockResponse = { + status: 200, + statusText: 'OK', + headers: { 'content-type': 'application/json', 'x-token': 'abc' }, + data: { users: [{ id: 1, name: 'Ada' }, { id: 2, name: 'Lin' }] }, + duration: 42, + url: 'https://api.example.com/users' + }; + + const script = ` + await test('res.getHeader() resolves header names case-insensitively', () => { + expect(res.getHeader('Content-Type')).to.eql('application/json'); + expect(res.getHeader('X-TOKEN')).to.eql('abc'); + }); + await test('res.getResponseTime() and res.getUrl() resolve from the executor duration and url fields', () => { + expect(res.getResponseTime()).to.eql(42); + expect(res.getUrl()).to.eql('https://api.example.com/users'); + }); + await test('res.headerList reads (get, count, has, indexOf) resolve keys case-insensitively', () => { + expect(res.headerList.get('Content-Type')).to.eql('application/json'); + expect(res.headerList.count()).to.eql(2); + expect(res.headerList.has('x-token')).to.eql(true); + expect(res.headerList.indexOf('X-Token')).to.be.at.least(0); + }); + await test('res.headerList iterators (each, filter, find, map, reduce) run callbacks across the sandbox and bind thisArg', () => { + const keys = []; + res.headerList.each(function (h) { keys.push(h.key); }); + expect(keys).to.include('x-token'); + expect(res.headerList.filter(function (h) { return h.key === 'x-token'; })).to.have.lengthOf(1); + const found = res.headerList.find(function (h) { return h.key === 'x-token'; }); + expect(found.value).to.eql('abc'); + const tagged = res.headerList.map(function (h) { return this.p + h.key; }, { p: '#' }); + expect(tagged).to.include('#x-token'); + const joined = res.headerList.reduce(function (acc, h) { return acc + h.key + ';'; }, ''); + expect(joined).to.match(/x-token;/); + }); + await test('res.headerList write methods throw because response headers are read-only', () => { + let threw = false; + try { res.headerList.add({ key: 'x', value: 'y' }); } catch (e) { threw = true; } + expect(threw).to.eql(true); + }); + await test('res("path", fn) passes the filter function across the sandbox boundary and applies it to the body', () => { + const s = JSON.stringify(res('users[?].name', function (u) { return u.id === 2; })); + expect(s).to.match(/Lin/); + expect(s).to.not.match(/Ada/); + }); + `; + + const bru = await runtime.runScript({ + script, + response: mockResponse, + collectionName: 'C', + collectionPath: '/c', + variables: {} + }); + + if (!bru.getTestResults) throw new Error('getTestResults was not attached to bru'); + const results = await bru.getTestResults(); + expect(results.summary.failed).toBe(0); + expect(results.summary.total).toBe(6); + }); }); diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/bruno-response.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/bruno-response.ts index 300c0422..fa868ee5 100644 --- a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/bruno-response.ts +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/bruno-response.ts @@ -1,90 +1,124 @@ +import type { QuickJSContext, QuickJSHandle } from 'quickjs-emscripten'; import { marshallToVm } from '../utils'; - -const addBrunoResponseShimToContext = (vm: any, res: any) => { - const resFn = vm.newFunction('res', function (exprStr: any) { - return marshallToVm(res(vm.dump(exprStr)), vm); - }); - - const status = marshallToVm(res?.status, vm); - const statusText = marshallToVm(res?.statusText, vm); - const headers = marshallToVm(res?.headers, vm); - const body = marshallToVm(res?.body, vm); - const responseTime = marshallToVm(res?.responseTime, vm); - const url = marshallToVm(res?.url, vm); - - vm.setProp(resFn, 'status', status); - vm.setProp(resFn, 'statusText', statusText); - vm.setProp(resFn, 'headers', headers); - vm.setProp(resFn, 'body', body); - vm.setProp(resFn, 'responseTime', responseTime); - vm.setProp(resFn, 'url', url); - - status.dispose(); - headers.dispose(); - body.dispose(); - responseTime.dispose(); - url.dispose(); - statusText.dispose(); - - const getStatusText = vm.newFunction('getStatusText', function () { - return marshallToVm(res.getStatusText(), vm); - }); - vm.setProp(resFn, 'getStatusText', getStatusText); - getStatusText.dispose(); - - const getStatus = vm.newFunction('getStatus', function () { - return marshallToVm(res.getStatus(), vm); - }); - vm.setProp(resFn, 'getStatus', getStatus); - getStatus.dispose(); - - const getHeader = vm.newFunction('getHeader', function (name: any) { - return marshallToVm(res.getHeader(vm.dump(name)), vm); - }); - vm.setProp(resFn, 'getHeader', getHeader); - getHeader.dispose(); - - const getHeaders = vm.newFunction('getHeaders', function () { - return marshallToVm(res.getHeaders(), vm); - }); - vm.setProp(resFn, 'getHeaders', getHeaders); - getHeaders.dispose(); - - const getBody = vm.newFunction('getBody', function () { - return marshallToVm(res.getBody(), vm); - }); - vm.setProp(resFn, 'getBody', getBody); - getBody.dispose(); - - const getResponseTime = vm.newFunction('getResponseTime', function () { - return marshallToVm(res.getResponseTime(), vm); - }); - vm.setProp(resFn, 'getResponseTime', getResponseTime); - getResponseTime.dispose(); - - const getUrl = vm.newFunction('getUrl', function () { - return marshallToVm(res.getUrl(), vm); +import type { CallableResponse, QueryArg, JsonValue } from '../../../utils/bruno-response'; +import { READ_ONLY_METHODS, READ_ONLY_MESSAGE, type HeaderEntry } from '../../../utils/header-list'; + +const addBrunoResponseShimToContext = (vm: QuickJSContext, res: CallableResponse) => { + const setValue = (target: QuickJSHandle, name: string, value: JsonValue) => { + const handle: QuickJSHandle = marshallToVm(value, vm); + vm.setProp(target, name, handle); + handle.dispose(); + }; + + const setMethod = (target: QuickJSHandle, name: string, fn: (...args: JsonValue[]) => R) => { + const handle = vm.newFunction(name, (...args: QuickJSHandle[]) => + marshallToVm(fn(...args.map((a) => vm.dump(a) as JsonValue)), vm)); + vm.setProp(target, name, handle); + handle.dispose(); + }; + + const setThrowingMethod = (target: QuickJSHandle, name: string, message: string) => { + const handle = vm.newFunction(name, () => { throw vm.newError(message); }); + vm.setProp(target, name, handle); + handle.dispose(); + }; + + const defineMethod = (target: QuickJSHandle, name: string, handler: (...args: QuickJSHandle[]) => QuickJSHandle) => { + const handle = vm.newFunction(name, handler); + vm.setProp(target, name, handle); + handle.dispose(); + }; + + const callVmCallback = (fnHandle: QuickJSHandle, thisArg: QuickJSHandle, argHandles: QuickJSHandle[]): JsonValue => { + const result = vm.callFunction(fnHandle, thisArg, ...argHandles); + argHandles.forEach((handle) => handle.dispose()); + if (result.error) { + const error = vm.dump(result.error); + result.error.dispose(); + throw error; + } + const value = vm.dump(result.value) as JsonValue; + result.value.dispose(); + return value; + }; + + const thisArgOf = (ctxHandle?: QuickJSHandle): QuickJSHandle => + ctxHandle !== undefined && vm.typeof(ctxHandle) !== 'undefined' ? ctxHandle : vm.undefined; + + const toHostQueryArg = (arg: QuickJSHandle): QueryArg => { + if (vm.typeof(arg) !== 'function') return vm.dump(arg) as QueryArg; + return (item: JsonValue) => callVmCallback(arg, vm.undefined, [marshallToVm(item, vm)]); + }; + + const entryCallback = (fnHandle: QuickJSHandle, ctxHandle?: QuickJSHandle) => + (header: HeaderEntry, index: number): R => + callVmCallback(fnHandle, thisArgOf(ctxHandle), [marshallToVm(header, vm), marshallToVm(index, vm)]) as R; + + const reduceCallback = (fnHandle: QuickJSHandle, ctxHandle?: QuickJSHandle) => + (accumulator: JsonValue, header: HeaderEntry, index: number): JsonValue => + callVmCallback(fnHandle, thisArgOf(ctxHandle), + [marshallToVm(accumulator, vm), marshallToVm(header, vm), marshallToVm(index, vm)]); + + const resFn = vm.newFunction('res', function (exprStr: QuickJSHandle, ...queryArgs: QuickJSHandle[]) { + const nativeArgs = queryArgs.map((arg) => toHostQueryArg(arg)); + return marshallToVm(res(vm.dump(exprStr) as string, ...nativeArgs), vm); }); - vm.setProp(resFn, 'getUrl', getUrl); - getUrl.dispose(); - const setBody = vm.newFunction('setBody', function (data: any) { - res.setBody(vm.dump(data)); - }); - vm.setProp(resFn, 'setBody', setBody); - setBody.dispose(); - - const getSize = vm.newFunction('getSize', function () { - return marshallToVm(res.getSize(), vm); - }); - vm.setProp(resFn, 'getSize', getSize); - getSize.dispose(); - - const getDataBuffer = vm.newFunction('getDataBuffer', function () { - return marshallToVm(res.getDataBuffer(), vm); - }); - vm.setProp(resFn, 'getDataBuffer', getDataBuffer); - getDataBuffer.dispose(); + setValue(resFn, 'status', res.status); + setValue(resFn, 'statusText', res.statusText); + setValue(resFn, 'headers', res.headers); + setValue(resFn, 'body', res.body); + setValue(resFn, 'responseTime', res.responseTime); + setValue(resFn, 'url', res.url); + + setMethod(resFn, 'getStatus', () => res.getStatus()); + setMethod(resFn, 'getStatusText', () => res.getStatusText()); + setMethod(resFn, 'getHeader', (name) => res.getHeader(name as string)); + setMethod(resFn, 'getHeaders', () => res.getHeaders()); + setMethod(resFn, 'getBody', () => res.getBody()); + setMethod(resFn, 'getResponseTime', () => res.getResponseTime()); + setMethod(resFn, 'getUrl', () => res.getUrl()); + setMethod(resFn, 'setBody', (data) => res.setBody(data)); + setMethod(resFn, 'getSize', () => res.getSize()); + setMethod(resFn, 'getDataBuffer', () => res.getDataBuffer()); + + if (res.headerList) { + const hl = res.headerList; + const headerListObj = vm.newObject(); + + setMethod(headerListObj, 'get', (name) => hl.get(name as string)); + setMethod(headerListObj, 'one', (name) => hl.one(name as string)); + setMethod(headerListObj, 'all', () => hl.all()); + setMethod(headerListObj, 'count', () => hl.count()); + setMethod(headerListObj, 'has', (name, value) => + hl.has(name as Parameters[0], value as string | undefined)); + setMethod(headerListObj, 'indexOf', (item) => hl.indexOf(item as Parameters[0])); + setMethod(headerListObj, 'toObject', () => hl.toObject()); + setMethod(headerListObj, 'toString', () => hl.toString()); + setMethod(headerListObj, 'toJSON', () => hl.toJSON()); + + defineMethod(headerListObj, 'each', (fn, ctx) => { + hl.each(entryCallback(fn, ctx)); + return vm.undefined; + }); + defineMethod(headerListObj, 'filter', (fn, ctx) => + marshallToVm(hl.filter(entryCallback(fn, ctx)), vm)); + defineMethod(headerListObj, 'find', (fn, ctx) => + marshallToVm(hl.find(entryCallback(fn, ctx)), vm)); + defineMethod(headerListObj, 'map', (fn, ctx) => + marshallToVm(hl.map(entryCallback(fn, ctx)), vm)); + defineMethod(headerListObj, 'reduce', (fn, ...rest) => { + const ctx = rest.length > 1 ? rest[1] : undefined; + const callback = reduceCallback(fn, ctx); + const reduced = rest.length > 0 ? hl.reduce(callback, vm.dump(rest[0]) as JsonValue) : hl.reduce(callback); + return marshallToVm(reduced, vm); + }); + + READ_ONLY_METHODS.forEach((name) => setThrowingMethod(headerListObj, name, READ_ONLY_MESSAGE)); + + vm.setProp(resFn, 'headerList', headerListObj); + headerListObj.dispose(); + } vm.setProp(vm.global, 'res', resFn); resFn.dispose(); diff --git a/packages/bruno-api-docs/src/scripting/utils/bruno-response.spec.ts b/packages/bruno-api-docs/src/scripting/utils/bruno-response.spec.ts new file mode 100644 index 00000000..bc51901d --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/utils/bruno-response.spec.ts @@ -0,0 +1,125 @@ +import { Buffer } from 'buffer'; +import { describe, it, expect } from 'vitest'; +import BrunoResponse, { type CallableResponse, type ResponseData, type JsonValue } from './bruno-response'; + +const rawResponse = (): ResponseData => ({ + status: 200, + statusText: 'OK', + headers: { 'content-type': 'application/json', 'x-token': 'abc' }, + data: { users: [{ id: 1, name: 'Ada' }, { id: 2, name: 'Lin' }] }, + duration: 42, + url: 'https://api.example.com/users' +}); + +const makeRes = (over?: Partial): CallableResponse => + new BrunoResponse(over ? { ...rawResponse(), ...over } : rawResponse()) as CallableResponse; + +const decodeBody = (base64?: string): string => Buffer.from(base64 ?? '', 'base64').toString(); + +describe('BrunoResponse (res object)', () => { + it('maps the browser executor\'s `duration` field onto responseTime and its `url` field onto res.url/getUrl() (the app\'s axios response names them differently)', () => { + const res = makeRes(); + expect(res.responseTime).toBe(42); + expect(res.getResponseTime()).toBe(42); + expect(res.url).toBe('https://api.example.com/users'); + expect(res.getUrl()).toBe('https://api.example.com/users'); + }); + + it('resolves getHeader() case-insensitively and never returns inherited Object.prototype keys', () => { + const res = makeRes(); + expect(res.getHeader('Content-Type')).toBe('application/json'); + expect(res.getHeader('X-TOKEN')).toBe('abc'); + expect(res.getHeader('content-type')).toBe('application/json'); + expect(res.getHeader('missing')).toBe(null); + expect(res.getHeader('constructor')).toBe(null); + expect(res.getHeader('hasOwnProperty')).toBe(null); + expect(res.getHeader('toString')).toBe(null); + }); + + it('is callable as res("path") to read into the body, and forwards filter/mapper functions to the query engine', () => { + const res = makeRes(); + expect(res('users[0].name')).toBe('Ada'); + const filtered = JSON.stringify(res('users[?].name', (u) => (u as { id: number }).id === 2)); + expect(filtered).toMatch(/Lin/); + expect(filtered).not.toMatch(/Ada/); + }); + + it('exposes res.headerList for structured header reads and rejects any attempt to modify it', () => { + const res = makeRes(); + expect(res.headerList.get('Content-Type')).toBe('application/json'); + expect(res.headerList.count()).toBe(2); + expect(res.headerList.has('x-token')).toBe(true); + expect(() => res.headerList.add({ key: 'x', value: 'y' })).toThrow(/read-only/); + }); + + it('setBody() deep-clones the value and keeps res.body, getBody() and getSize() in sync', () => { + const res = makeRes({ data: { a: 1 } }); + res.setBody({ b: 2 }); + expect(res.getBody()).toEqual({ b: 2 }); + expect(res.body).toEqual({ b: 2 }); + expect(res.getSize().body).toBe(Buffer.byteLength(JSON.stringify({ b: 2 }))); + }); + + it('setBody() rewrites base64Data and size so the response pane, which renders from base64Data, shows the new body', () => { + const res = makeRes({ data: { original: true }, base64Data: Buffer.from('{"original":true}').toString('base64') }); + res.setBody({ marker: 'hey there' }); + const raw = JSON.stringify({ marker: 'hey there' }); + const stored = res.res as ResponseData; + expect(stored.data).toEqual({ marker: 'hey there' }); + expect(stored.base64Data).toBe(Buffer.from(raw).toString('base64')); + expect(decodeBody(stored.base64Data)).toBe(raw); + expect(stored.size).toBe(Buffer.byteLength(raw)); + }); + + it('setBody(null) empties the rendered body: data is null, base64Data is empty and size is 0', () => { + const res = makeRes({ data: { original: true }, base64Data: Buffer.from('{"original":true}').toString('base64') }); + res.setBody(null); + const stored = res.res as ResponseData; + expect(stored.data).toBeNull(); + expect(stored.base64Data).toBe(''); + expect(stored.size).toBe(0); + }); + + it('setBody() stores a string raw and unquoted, so the pane shows the text rather than a JSON-quoted string', () => { + const res = makeRes(); + res.setBody('plain text'); + const stored = res.res as ResponseData; + expect(res.getBody()).toBe('plain text'); + expect(decodeBody(stored.base64Data)).toBe('plain text'); + expect(stored.size).toBe(Buffer.byteLength('plain text')); + }); + + it('setBody() JSON-encodes numbers, booleans and arrays into base64Data', () => { + const num = makeRes(); + num.setBody(42); + expect(decodeBody((num.res as ResponseData).base64Data)).toBe('42'); + + const bool = makeRes(); + bool.setBody(true); + expect(decodeBody((bool.res as ResponseData).base64Data)).toBe('true'); + + const arr = makeRes(); + arr.setBody([1, 2, 3]); + expect(decodeBody((arr.res as ResponseData).base64Data)).toBe('[1,2,3]'); + }); + + it('setBody() never throws on an un-stringifiable (circular) value: it keeps the new body and degrades the buffer to empty', () => { + const res = makeRes({ data: { original: true } }); + const circular: Record = {}; + circular.self = circular; + expect(() => res.setBody(circular)).not.toThrow(); + const stored = res.res as ResponseData; + expect((stored.data as Record).self).toBeDefined(); + expect(stored.base64Data).toBe(''); + expect(stored.size).toBe(0); + }); + + it('returns safe null/empty values from every accessor when constructed without a response object', () => { + const res = new BrunoResponse(undefined) as CallableResponse; + expect(res.getStatus()).toBe(null); + expect(res.getResponseTime()).toBe(null); + expect(res.getUrl()).toBe(null); + expect(res.getHeader('x')).toBe(null); + expect(res.headerList.count()).toBe(0); + }); +}); diff --git a/packages/bruno-api-docs/src/scripting/utils/bruno-response.ts b/packages/bruno-api-docs/src/scripting/utils/bruno-response.ts index 70f88a1f..a9469e72 100644 --- a/packages/bruno-api-docs/src/scripting/utils/bruno-response.ts +++ b/packages/bruno-api-docs/src/scripting/utils/bruno-response.ts @@ -1,96 +1,126 @@ +import { Buffer } from 'buffer'; +import { cloneDeep } from 'lodash-es'; import { get } from './query-get'; +import { createResponseHeaderList, type ResponseHeaderList, type HeadersRecord } from './header-list'; +import type { RunRequestResponse } from '../../runner'; + +export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +export type ResponseData = RunRequestResponse; + +export type QueryArg = ((item: JsonValue) => boolean | JsonValue) | Record; class BrunoResponse { - res: any; + res: ResponseData | null; status: number | null; statusText: string | null; - headers: any; - body: any; + headers: HeadersRecord | null; + body: JsonValue | null; responseTime: number | null; url: string | null; + headerList: ResponseHeaderList; - constructor(res: any) { - this.res = res; - this.status = res ? res.status : null; - this.statusText = res ? res.statusText : null; - this.headers = res ? res.headers : null; - this.body = res ? res.data : null; - this.responseTime = res ? res.responseTime : null; - this.url = res?.request ? res.request.protocol + '//' + res.request.host + res.request.path : null; - - // Make the instance callable - const callable = (...args: any[]) => get(this.body, args[0], ...args.slice(1)); - Object.setPrototypeOf(callable, this.constructor.prototype); - Object.assign(callable, this); + constructor(res: ResponseData | null | undefined) { + this.res = res ?? null; + this.status = res ? (res.status ?? null) : null; + this.statusText = res ? (res.statusText ?? null) : null; + this.headers = res ? (res.headers ?? null) : null; + this.body = res ? (res.data ?? null) : null; + this.responseTime = res ? (res.duration ?? null) : null; + this.url = res?.url ?? null; - return callable as any; + this.headerList = createResponseHeaderList(() => this.res?.headers); + + const callable = (path: string, ...fns: QueryArg[]): JsonValue | undefined => get(this.body, path, ...fns); + Object.setPrototypeOf(callable, this.constructor.prototype); + return Object.assign(callable, this); } getStatus() { - return this.res ? this.res.status : null; + return this.res ? (this.res.status ?? null) : null; } getStatusText() { - return this.res ? this.res.statusText : null; + return this.res ? (this.res.statusText ?? null) : null; } getHeader(name: string) { - return this.res && this.res.headers ? this.res.headers[name] : null; + const headers = this.res?.headers; + if (typeof name !== 'string' || !headers) { + return null; + } + const match = Object.keys(headers).find((k) => k.toLowerCase() === name.toLowerCase()); + return match ? headers[match] : null; } getHeaders() { - return this.res ? this.res.headers : null; + return this.res ? (this.res.headers ?? null) : null; } getBody() { - return this.res ? this.res.data : null; + return this.res ? (this.res.data ?? null) : null; } getResponseTime() { - return this.res ? this.res.responseTime : null; + return this.res ? (this.res.duration ?? null) : null; } getUrl() { return this.res ? this.url : null; } - setBody(data: any) { - if (!this.res) { + setBody(data: JsonValue) { + const res = this.res; + if (!res) { return; } - const clonedData = JSON.parse(JSON.stringify(data)); - this.res.data = clonedData; + const clonedData = cloneDeep(data); + res.data = clonedData; this.body = clonedData; + + let dataBuffer: Buffer; + if (clonedData == null) { + dataBuffer = Buffer.from(''); + } else if (typeof clonedData === 'string') { + dataBuffer = Buffer.from(clonedData); + } else { + try { + dataBuffer = Buffer.from(JSON.stringify(clonedData)); + } catch { + dataBuffer = Buffer.from(''); + } + } + + res.dataBuffer = dataBuffer; + res.base64Data = dataBuffer.toString('base64'); + res.size = dataBuffer.length; } - // TODO: Refactor: dataBuffer size calculation should be handled in a shared utility so it can be passed and reused across the application getSize() { - if (!this.res) { + const res = this.res; + if (!res) { return { header: 0, body: 0, total: 0 }; } - const { data, dataBuffer, headers } = this.res; + const { data, dataBuffer, headers } = res; let bodySize = 0; - // Use raw received bytes if (Buffer.isBuffer(dataBuffer)) { bodySize = dataBuffer.length; } else { - // Use server-reported Content-Length - const contentLength = headers && (headers['content-length'] || headers['Content-Length']); - if (contentLength && !isNaN(contentLength)) { - bodySize = parseInt(contentLength, 10); + const contentLength = headers?.['content-length'] ?? headers?.['Content-Length']; + if (contentLength != null && !isNaN(Number(contentLength))) { + bodySize = parseInt(String(contentLength), 10); } else if (data != null) { - // Manual calculation const raw = typeof data === 'string' ? data : JSON.stringify(data); bodySize = Buffer.byteLength(raw); } } const headerLines = [ - `HTTP/1.1 ${this.res.status} ${this.res.statusText}`, - ...Object.entries(this.res.headers || {}).flatMap(([key, value]) => + `HTTP/1.1 ${res.status} ${res.statusText}`, + ...Object.entries(headers || {}).flatMap(([key, value]) => Array.isArray(value) ? value.map((v) => `${key}: ${v}`) : [`${key}: ${value}`] @@ -104,8 +134,10 @@ class BrunoResponse { } getDataBuffer() { - return this.res ? this.res.dataBuffer : null; + return this.res ? (this.res.dataBuffer ?? null) : null; } } +export type CallableResponse = BrunoResponse & ((path: string, ...fns: QueryArg[]) => JsonValue | undefined); + export default BrunoResponse; diff --git a/packages/bruno-api-docs/src/scripting/utils/header-list.spec.ts b/packages/bruno-api-docs/src/scripting/utils/header-list.spec.ts new file mode 100644 index 00000000..b26da4f6 --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/utils/header-list.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { createResponseHeaderList } from './header-list'; + +describe('createResponseHeaderList (read-only response headers)', () => { + const make = (h: Record) => createResponseHeaderList(() => h); + + it('looks up header keys case-insensitively across get, one, has, and indexOf (HTTP header names are case-insensitive)', () => { + const h = make({ 'content-type': 'application/json', 'x-count': '2' }); + expect(h.get('Content-Type')).toBe('application/json'); + expect(h.get('content-type')).toBe('application/json'); + expect(h.get('missing')).toBeUndefined(); + expect(h.one('X-COUNT')).toEqual({ key: 'x-count', value: '2' }); + expect(h.count()).toBe(2); + expect(h.has('Content-Type')).toBe(true); + expect(h.has('content-type', 'application/json')).toBe(true); + expect(h.has('content-type', 'text/html')).toBe(false); + expect(h.has('nope')).toBe(false); + expect(h.has({ key: 'X-Count' })).toBe(true); + expect(h.indexOf('X-Count')).toBe(1); + expect(h.indexOf('nope')).toBe(-1); + expect(h.indexOf({ key: 'X-Count' })).toBe(-1); + expect(h.indexOf({ key: 'X-Count', value: '2' })).toBe(1); + expect(h.indexOf({ key: 'x-count', value: 'wrong' })).toBe(-1); + }); + + it('exposes the array-style reads (all, find, filter, each, map, reduce) and the transforms (toObject, toString, toJSON)', () => { + const h = make({ 'content-type': 'application/json', 'x-count': '2' }); + expect(h.all()).toHaveLength(2); + expect(h.find((x) => x.key === 'x-count')).toMatchObject({ value: '2' }); + expect(h.filter((x) => x.key.startsWith('x-'))).toHaveLength(1); + expect(h.map((x) => x.key)).toEqual(['content-type', 'x-count']); + const keys: string[] = []; + h.each((x) => keys.push(x.key)); + expect(keys).toEqual(['content-type', 'x-count']); + expect(h.reduce((acc, x) => acc + x.key + ';', '')).toBe('content-type;x-count;'); + expect(h.toObject()).toEqual({ 'content-type': 'application/json', 'x-count': '2' }); + expect(h.toString()).toContain('content-type: application/json'); + expect(h.toJSON()).toHaveLength(2); + }); + + it('binds the optional second argument as `this` (thisArg) inside the map and reduce callbacks', () => { + const h = make({ a: '1', b: '2' }); + expect(h.map(function (this: { p: string }, x) { return this.p + x.key; }, { p: '#' })).toEqual(['#a', '#b']); + expect(h.reduce(function (this: { sep: string }, acc, x) { return acc + this.sep + x.key; }, '', { sep: '-' })).toBe('-a-b'); + }); + + it('throws a clear read-only error from every mutating method (add, upsert, remove, clear, populate, repopulate, assimilate) because response headers cannot be changed', () => { + const h = make({ a: '1' }); + expect(() => h.add({ key: 'x', value: 'y' })).toThrow(/read-only/); + expect(() => h.upsert({ key: 'x', value: 'y' })).toThrow(/read-only/); + expect(() => h.remove('x')).toThrow(/read-only/); + expect(() => h.clear()).toThrow(/read-only/); + expect(() => h.populate([])).toThrow(/read-only/); + expect(() => h.repopulate([])).toThrow(/read-only/); + expect(() => h.assimilate([])).toThrow(/read-only/); + }); + + it('re-reads the underlying headers on every call, and keeps a multi-value (array) header as a single entry — matching the app\'s response HeaderList', () => { + let hs: Record = { a: '1' }; + const h = createResponseHeaderList(() => hs); + expect(h.count()).toBe(1); + hs = { a: '1', b: ['2', '3'] }; + expect(h.count()).toBe(2); + expect(h.one('b')).toMatchObject({ value: ['2', '3'] }); + }); +}); diff --git a/packages/bruno-api-docs/src/scripting/utils/header-list.ts b/packages/bruno-api-docs/src/scripting/utils/header-list.ts new file mode 100644 index 00000000..987ed27d --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/utils/header-list.ts @@ -0,0 +1,107 @@ +export type HeaderValue = string | string[]; +export type HeadersRecord = Record; + +export interface HeaderEntry { + key: string; + value: HeaderValue; + disabled?: boolean; +} + +export type HeaderRef = { key: string; value?: HeaderValue }; +export type HeaderInput = HeaderRef | string; + +type HeaderPredicate = (header: HeaderEntry, index: number) => boolean; + +export interface ResponseHeaderList { + get(name: string): HeaderValue | undefined; + one(name: string): HeaderEntry | undefined; + all(): HeaderEntry[]; + count(): number; + has(nameOrObj: string | HeaderRef, value?: string): boolean; + indexOf(item: string | HeaderRef): number; + find(fn: HeaderPredicate, ctx?: object): HeaderEntry | undefined; + filter(fn: HeaderPredicate, ctx?: object): HeaderEntry[]; + each(fn: (header: HeaderEntry, index: number) => void, ctx?: object): void; + map(fn: (header: HeaderEntry, index: number) => T, ctx?: object): T[]; + reduce(fn: (accumulator: T, header: HeaderEntry, index: number) => T, initial?: T, ctx?: object): T; + toObject(): HeadersRecord; + toString(): string; + toJSON(): HeaderEntry[]; + add(itemOrName: HeaderInput, value?: string): void; + upsert(itemOrName: HeaderInput, value?: string): boolean | null; + remove(predicate: HeaderPredicate | string | HeaderRef, ctx?: object): void; + clear(): void; + populate(items: HeaderInput[] | string): void; + repopulate(items: HeaderInput[] | string): void; + assimilate(source: HeaderEntry[] | { all(): HeaderEntry[] }, prune?: boolean): void; +} + +const eqKey = (a: string, b: string): boolean => String(a).toLowerCase() === String(b).toLowerCase(); + +const toEntries = (headers: HeadersRecord | null | undefined): HeaderEntry[] => { + if (!headers || typeof headers !== 'object') return []; + return Object.keys(headers).map((key) => ({ key, value: headers[key] })); +}; + +export const READ_ONLY_MESSAGE = 'res.headerList is read-only; response headers cannot be modified'; +export const READ_ONLY_METHODS = [ + 'add', 'upsert', 'remove', 'clear', 'populate', 'repopulate', 'assimilate' +] as const; +type ReadOnlyMethod = typeof READ_ONLY_METHODS[number]; + +export const createResponseHeaderList = (getHeaders: () => HeadersRecord | null | undefined): ResponseHeaderList => { + const entries = (): HeaderEntry[] => toEntries(getHeaders()); + const readOnly = (): never => { + throw new Error(READ_ONLY_MESSAGE); + }; + const readOnlyMethods = {} as Record never>; + READ_ONLY_METHODS.forEach((name) => { readOnlyMethods[name] = readOnly; }); + + return { + get: (name) => entries().filter((h) => eqKey(h.key, name)).pop()?.value, + one: (name) => entries().filter((h) => eqKey(h.key, name)).pop(), + all: () => entries().map((h) => ({ ...h })), + count: () => entries().length, + has: (nameOrObj, value) => { + if (nameOrObj && typeof nameOrObj === 'object') { + return entries().some((h) => eqKey(h.key, nameOrObj.key)); + } + return entries().some((h) => eqKey(h.key, nameOrObj) && (value === undefined || h.value === value)); + }, + indexOf: (item) => { + const list = entries(); + if (typeof item === 'string') { + return list.findIndex((h) => eqKey(h.key, item)); + } + if (!item || typeof item !== 'object') return -1; + return list.findIndex((h) => eqKey(h.key, item.key) && h.value === item.value); + }, + find: (fn, ctx) => entries().find(fn, ctx), + filter: (fn, ctx) => entries().filter(fn, ctx), + each: (fn, ctx) => entries().forEach(fn, ctx), + map: (fn, ctx) => entries().map(fn, ctx), + reduce: ( + fn: (accumulator: T, header: HeaderEntry, index: number) => T, + ...rest: [initial?: T, ctx?: object] + ): T => { + const ctx = rest.length > 1 ? rest[1] : undefined; + const reducer = ctx !== undefined ? fn.bind(ctx) : fn; + const list = entries(); + const hasInitial = rest.length > 0; + let accumulator = (hasInitial ? rest[0] : list[0]) as T; + for (let i = hasInitial ? 0 : 1; i < list.length; i++) { + accumulator = reducer(accumulator, list[i], i); + } + return accumulator; + }, + toObject: () => { + const obj: HeadersRecord = {}; + entries().forEach((h) => { obj[h.key] = h.value; }); + return obj; + }, + toString: () => entries().filter((h) => !h.disabled).map((h) => `${h.key}: ${h.value}`).join('\n'), + toJSON: () => entries().map((h) => ({ ...h })), + + ...readOnlyMethods + }; +};