Skip to content
Open
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
210 changes: 178 additions & 32 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { test, expect } from '../../playwright';
import type { Page } from '@playwright/test';
import type { CodeEditorComponent } from '../../components/code-editor/code-editor.component';

const LIBRARY_TESTS_SCRIPT = `
const moment = require('moment');
const CryptoJS = require('crypto-js');
const { v4, validate } = require('uuid');
const { nanoid } = require('nanoid');
const jwt = require('jsonwebtoken');

test('moment formats a date', function () {
expect(moment('2026-01-02').format('YYYY-MM-DD')).to.equal('2026-01-02');
});

test('crypto-js hashes and uuid validates', function () {
expect(CryptoJS.SHA256('abc').toString()).to.have.lengthOf(64);
expect(validate(v4())).to.equal(true);
expect(nanoid(10)).to.have.lengthOf(10);
});

test('jsonwebtoken round-trips a signed token', function () {
const token = jwt.sign({ userId: 7 }, 'secret', { expiresIn: '1h' });
expect(jwt.verify(token, 'secret').userId).to.equal(7);
});
`;

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('playground script execution', () => {
test.use({ viewport: { width: 1280, height: 900 } });

test('runs a tests script using the safe-mode libraries on Send', async ({ page, playground, responsePane }) => {
await page.route('**/api/users**', (route) =>
route.fulfill({
status: 200,
headers: { 'content-type': 'application/json', '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, LIBRARY_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);
});
});
15 changes: 14 additions & 1 deletion packages/bruno-api-docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,13 @@
"@types/markdown-it": "^14.1.2",
"@types/prismjs": "^1.26.5",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"buffer": "^6.0.3",
"chai": "~5.3.3",
"codemirror": "^6.0.2",
"crypto-js": "^4.2.0",
"fast-json-format": "~0.4.0",
"fuse.js": "^7.5.0",
"js-md5": "^0.9.2",
Expand All @@ -84,12 +88,14 @@
"jsonpath-plus": "^10.3.0",
"lodash-es": "~4.17.21",
"markdown-it": "^14.1.0",
"moment": "^2.30.1",
"monaco-editor": "^0.53.0",
"nanoid": "~3.3.11",
"node-html-parser": "^8.0.4",
"path-browserify": "^1.0.1",
"prettier": "^2.7.1",
"prismjs": "^1.29.0",
"quickjs-emscripten": "~0.31.0",
"quickjs-emscripten": "~0.32.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-markdown": "^10.0.0",
Expand All @@ -99,6 +105,8 @@
"react-router-dom": "^7.3.0",
"remark-gfm": "^4.0.1",
"strip-json-comments": "^3.1.1",
"tv4": "^1.3.0",
"uuid": "^10.0.0",
"xml-formatter": "^3.5.0"
},
"devDependencies": {
Expand All @@ -108,12 +116,17 @@
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4.1.13",
"@tailwindcss/typography": "^0.5.10",
"@types/atob": "^2.1.4",
"@types/btoa": "^1.2.5",
"@types/crypto-js": "^4.2.2",
"@types/express": "^4.17.21",
"@types/lodash-es": "~4.17.12",
"@types/node": "^26.2.0",
"@types/path-browserify": "^1.0.3",
"@types/prismjs": "^1.26.3",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/tv4": "^1.2.33",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import type { HttpRequest } from '@opencollection/types/requests/http';
import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types';
import type { Item } from '@opencollection/types/collection/item';
import { requestRunner } from '@/runner';
import { getAncestorsByUuid } from '@/utils/fileUtils';
import { ItemVariableResolverProvider } from '@/hooks';
import TitleLabel from '@/components/TitleLabel/TitleLabel';
Expand Down Expand Up @@ -35,7 +34,6 @@
// The request/response split is one draggable divider whose axis follows the
// orientation: horizontal layout resizes width, vertical layout resizes height.
const { size: paneSize, isResizing, containerRef, startResize } = useSplitPane(orientation);
const runner = useMemo(() => requestRunner, []);
const ancestry = useMemo(
() => (collection && itemUuid ? getAncestorsByUuid(collection, itemUuid) : []),
[collection, itemUuid]
Expand Down Expand Up @@ -87,7 +85,8 @@
const environment = envs.find(
(env: any) => env.name === selectedEnvironment
);
const result = await runner.runRequest({
const { requestRunner } = await import('@/runner');
const result = await requestRunner.runRequest({
item: editableItem,
collection,
environment,
Expand All @@ -112,7 +111,7 @@
} finally {
setIsLoading(false);
}
}, [collection, editableItem, runner, selectedEnvironment, itemUuid]);
}, [collection, editableItem, selectedEnvironment, itemUuid]);

Check warning on line 114 in packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

React Hook useCallback has a missing dependency: 'dispatch'. Either include it or remove the dependency array

return (
<ItemVariableResolverProvider
Expand Down
65 changes: 65 additions & 0 deletions packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,71 @@
expect(fetchMock.mock.calls[0][0]).toBe('https://api.example.com/data?api_key=secret123');
});

describe('auth header precedence', () => {
const jsonResponse = () => ({
status: 200,
statusText: 'OK',
url: 'https://api.example.com/data',
headers: new Headers({ 'content-type': 'application/json' }),
text: async () => JSON.stringify({ ok: true })
});

it('keeps an existing Authorization header over the bearer auth config', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse());
global.fetch = fetchMock as unknown as typeof fetch;

await new RequestExecutor().executeRequest({
name: 'script-set auth wins',
type: 'http',
http: {
method: 'GET',
url: 'https://api.example.com/data',
headers: [{ name: 'authorization', value: 'Bearer script-signed-token' }],
auth: { type: 'bearer', token: 'config-token' }
}
} as any);

Check warning on line 88 in packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type

expect(fetchMock.mock.calls[0][1].headers['authorization']).toBe('Bearer script-signed-token');
expect(fetchMock.mock.calls[0][1].headers['Authorization']).toBeUndefined();
});

it('applies the bearer auth config when no Authorization header exists', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse());
global.fetch = fetchMock as unknown as typeof fetch;

await new RequestExecutor().executeRequest({
name: 'auth config applies',
type: 'http',
http: {
method: 'GET',
url: 'https://api.example.com/data',
auth: { type: 'bearer', token: 'config-token' }
}
} as any);

Check warning on line 106 in packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type

expect(fetchMock.mock.calls[0][1].headers['Authorization']).toBe('Bearer config-token');
});

it('keeps an existing header over an apikey header placement', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse());
global.fetch = fetchMock as unknown as typeof fetch;

await new RequestExecutor().executeRequest({
name: 'apikey header respects existing',
type: 'http',
http: {
method: 'GET',
url: 'https://api.example.com/data',
headers: [{ name: 'X-Api-Key', value: 'from-script' }],
auth: { type: 'apikey', key: 'x-api-key', value: 'from-config', placement: 'header' }
}
} as any);

Check warning on line 124 in packages/bruno-api-docs/src/runner/RequestExecutor.spec.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type

expect(fetchMock.mock.calls[0][1].headers['X-Api-Key']).toBe('from-script');
expect(fetchMock.mock.calls[0][1].headers['x-api-key']).toBeUndefined();
});
});

describe('request body', () => {
const sendWithBody = async (method: string) => {
const fetchMock = vi.fn().mockResolvedValue({
Expand Down
9 changes: 6 additions & 3 deletions packages/bruno-api-docs/src/runner/RequestExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,21 +191,24 @@ export class RequestExecutor {
}

private setAuthHeaders(headers: Record<string, string>, auth: any) {
const hasHeader = (name: string) =>
Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());

switch (auth.type) {
case 'basic':
if (auth.username && auth.password) {
if (auth.username && auth.password && !hasHeader('Authorization')) {
const credentials = btoa(`${auth.username}:${auth.password}`);
headers['Authorization'] = `Basic ${credentials}`;
}
break;
case 'bearer':
if (auth.token) {
if (auth.token && !hasHeader('Authorization')) {
headers['Authorization'] = `Bearer ${auth.token}`;
}
break;
case 'apikey':
if (auth.key && auth.value) {
if (auth.placement === 'header') {
if (auth.placement === 'header' && !hasHeader(auth.key)) {
headers[auth.key] = auth.value;
}
}
Expand Down
4 changes: 3 additions & 1 deletion packages/bruno-api-docs/src/sampleCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ request:
bru.setVar('collection-var-set-by-collection-script', 'collection-var-value-set-by-collection-script');
}
- type: after-response
code: wefewfewfewfewfwefwefewfewfewfewfewfewfewfewf
code: |-
// Collection · post-response (L0)
console.log('POST > L0 collection');
- type: tests
code: |-
// used by \`scripting/js/folder-collection script-tests\`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,44 @@
import { expect, assert } from 'chai';
// todo: add all the supported libraries
import { Buffer } from 'buffer';
import moment from 'moment';
import btoa from 'btoa';
import atob from 'atob';
import CryptoJS from 'crypto-js';
import tv4 from 'tv4';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import * as uuid from 'uuid';
import * as nanoid from 'nanoid';
import path from 'path-browserify';
import jwt from './lib/jwt';

(globalThis as any).expect = expect;

Check warning on line 15 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).assert = assert;

Check warning on line 16 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).moment = moment;

Check warning on line 17 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).btoa = btoa;

Check warning on line 18 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).atob = atob;

Check warning on line 19 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).Buffer = Buffer;

Check warning on line 20 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).tv4 = tv4;
(globalThis as any).Ajv = Ajv;
(globalThis as any).addFormats = addFormats;
(globalThis as any).uuid = uuid;
(globalThis as any).nanoid = nanoid;
(globalThis as any).path = path;
(globalThis as any).jwt = jwt;

(globalThis as any).requireObject = {
...((globalThis as any).requireObject || {}),
chai: { expect, assert }
'chai': { expect, assert },
'moment': moment,
'buffer': { Buffer },
'btoa': btoa,
'atob': atob,
'crypto-js': CryptoJS,
'tv4': tv4,
'ajv': Ajv,
'ajv-formats': addFormats,
'uuid': uuid,
'nanoid': nanoid,
'path': path,
'jsonwebtoken': jwt
};
58 changes: 20 additions & 38 deletions packages/bruno-api-docs/src/scripting/sandbox/quickjs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import addBrunoRequestShimToContext from './shims/bruno-request';
import addConsoleShimToContext from './shims/console';
import addBrunoResponseShimToContext from './shims/bruno-response';
import addTestShimToContext from './shims/test';
import addCryptoUtilsShimToContext from './shims/lib/crypto-utils';
import addAxiosShimToContext from './shims/lib/axios';
import { newQuickJSWASMModule, memoizePromiseFactory } from 'quickjs-emscripten';
import { marshallToVm } from './utils';
import { getBundledCode } from './bundled-libraries.iife.js';
import { getRequireCode } from './shims/require';

let QuickJSSyncContext: any;
const loader = memoizePromiseFactory(() => newQuickJSWASMModule());
Expand Down Expand Up @@ -95,51 +98,28 @@ const executeQuickJsVmAsync = async ({
externalScript = externalScript?.trim();

try {
const module = await newQuickJSWASMModule();
const module = await loader();
const vm = module.newContext();

const bundledCode = getBundledCode?.toString() || '';

const moduleLoaderCode = function () {
return `
globalThis.require = (mod) => {
let lib = globalThis.requireObject[mod];
let isModuleAPath = (module) => (module?.startsWith('.') || module?.startsWith?.(''))
if (lib) {
return lib;
}
else if (isModuleAPath(mod)) {
// fetch local module
let localModuleCode = globalThis.__brunoLoadLocalModule(mod);

// compile local module as iife
(function (){
const initModuleExportsCode = "const module = { exports: {} };"
const copyModuleExportsCode = "\\n;globalThis.requireObject[mod] = module.exports;";
const patchedRequire = ${`
"\\n;" +
"let require = (subModule) => isModuleAPath(subModule) ? globalThis.require(path.resolve('', mod, '..', subModule)) : globalThis.require(subModule)" +
"\\n;"
`}
eval(initModuleExportsCode + patchedRequire + localModuleCode + copyModuleExportsCode);
})();

// resolve module
return globalThis.requireObject[mod];
}
else {
throw new Error("Cannot find module " + mod);
}
}
`;
};
addCryptoUtilsShimToContext(vm);

if (typeof getBundledCode !== 'function') {
throw new Error('Sandbox library bundle is missing; run build:lib-bundle before executing scripts.');
}
const bundledCode = getBundledCode.toString();

vm.evalCode(
const bootResult = vm.evalCode(
`
(${bundledCode})();
${moduleLoaderCode()}
${getRequireCode()}
`
);
if (bootResult.error) {
const bootError = vm.dump(bootResult.error);
bootResult.error.dispose();
throw new Error(`Failed to load sandbox libraries: ${bootError?.message || String(bootError)}`);
}
bootResult.value.dispose();

const { bru, req, res, test, __brunoTestResults, console: consoleFn } = externalContext;

Expand All @@ -149,6 +129,8 @@ const executeQuickJsVmAsync = async ({
if (res) addBrunoResponseShimToContext(vm, res);
if (test && __brunoTestResults) addTestShimToContext(vm, __brunoTestResults);

addAxiosShimToContext(vm);

const script = `
(async () => {
const setTimeout = async(fn, timer) => {
Expand Down
Loading
Loading