Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Comment thread
sachin-bruno marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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<void> => {
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': '*'
},
Comment thread
sachin-bruno marked this conversation as resolved.
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);
});
});
2 changes: 2 additions & 0 deletions packages/bruno-api-docs/src/runner/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -66,6 +67,7 @@ export interface RunRequestResponse {
statusText?: string;
headers?: Record<string, any>;
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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading
Loading