Compare commits

..

1 Commits

Author SHA1 Message Date
Sid
659e02ac44 feat: error-boundary + crash cache addition (#8056)
* internal emit chain for clearance

* fix: crash ui

* fix(ErrorBoundary): ensure cache clearing is awaited before force quitting

* test(e2e): environment persistence across collections

* test: migration test

* Update environment.spec.ts

* fix: reduce padding for dark mode app errors

* chore: re-add waitForReadyPage
2026-05-20 22:25:06 +05:30
20 changed files with 135 additions and 550 deletions

View File

@@ -390,7 +390,7 @@ const RequestTabPanel = () => {
if (folder) {
return (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<FolderSettings collection={collection} folder={folder} />
<FolderSettings collection={collection} folder={folder} />;
</ScopedPersistenceProvider>
);
}

View File

@@ -350,10 +350,6 @@ const getAccessor = (tab) => {
};
const getDefaultRequestPaneTabForType = (type) => {
if (type === 'folder-settings') {
return 'headers';
}
if (type === 'grpc-request' || type === 'ws-request') {
return 'body';
}
@@ -562,7 +558,7 @@ export const deserializeTab = (snapshotTab, collection) => {
if (accessor === 'pathname' && pathname) {
const item = findItemInCollectionByPathname(collection, pathname);
const resolvedType = (item && isRequestTab(item.type)) ? item.type : type;
const resolvedType = item?.type || type;
tab.type = resolvedType;
if (!restoredRequestPaneTab) {
tab.requestPaneTab = getDefaultRequestPaneTabForType(resolvedType);

View File

@@ -286,56 +286,6 @@ describe('deserializeTab', () => {
expect(tab.uid).toBe('collection-uid-preferences');
});
it('defaults folder settings request pane tab to headers', () => {
const snapshotTab = {
type: 'folder-settings',
accessor: 'pathname',
pathname: '/collections/a/folder',
permanent: true
};
const tab = deserializeTab(snapshotTab, collection);
expect(tab.requestPaneTab).toBe('headers');
});
it('restores folder settings request pane tab from snapshot', () => {
const snapshotTab = {
type: 'folder-settings',
accessor: 'pathname',
pathname: '/collections/a/folder',
request: { tab: 'auth' },
permanent: true
};
const tab = deserializeTab(snapshotTab, collection);
expect(tab.requestPaneTab).toBe('auth');
});
it('keeps folder-settings type when pathname resolves to a non-request item', () => {
const collectionWithFolderItem = {
...collection,
items: [
{
uid: 'folder-1',
pathname: '/collections/a/folder',
type: 'folder'
}
]
};
const snapshotTab = {
type: 'folder-settings',
accessor: 'pathname',
pathname: '/collections/a/folder',
permanent: true
};
const tab = deserializeTab(snapshotTab, collectionWithFolderItem);
expect(tab.type).toBe('folder-settings');
expect(tab.folderUid).toBe('folder-1');
expect(tab.requestPaneTab).toBe('headers');
});
it('restores response example by index when duplicate names exist', () => {
const collectionWithDuplicateExamples = {
uid: 'collection-uid',

View File

@@ -100,9 +100,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
}));
}
} else if (contentType.startsWith('multipart/')) {
if (request?.data && typeof request.data === 'string') {
request.data = _interpolate(request.data);
} else if (Array.isArray(request?.data) && !isFormData(request.data)) {
if (Array.isArray(request?.data) && !isFormData(request.data)) {
try {
request.data = request?.data?.map((d) => ({
...d,

View File

@@ -488,7 +488,7 @@ const runSingleRequest = async function (
const contentType = contentTypeHeader ? request.headers[contentTypeHeader] : '';
if (typeof contentType === 'string' && contentType.startsWith('multipart/')) {
if (typeof request.data !== 'string' && !isFormData(request?.data)) {
if (!isFormData(request?.data)) {
request._originalMultipartData = request.data;
request.collectionPath = collectionPath;
let form = createFormData(request.data, collectionPath);

View File

@@ -1,60 +1,6 @@
const { describe, it, expect } = require('@jest/globals');
const interpolateVars = require('../../src/runner/interpolate-vars');
describe('interpolate-vars: interpolateVars', () => {
it('preserves raw string body when Content-Type is multipart/mixed', () => {
const rawMultipartBody = [
'--TestBoundary123',
'Content-Type: application/json',
'',
'{"test": true}',
'--TestBoundary123--',
''
].join('\r\n');
const request = {
method: 'POST',
mode: 'text',
url: 'https://httpbin.dev/post',
headers: { 'content-type': 'multipart/mixed; boundary=TestBoundary123' },
data: rawMultipartBody
};
const result = interpolateVars(request, {}, null, null);
expect(result.data).toBe(rawMultipartBody);
});
it('interpolates variables in raw multipart/mixed string body', () => {
const boundary = 'CustomBoundary123';
const rawMultipartBody = [
`--${boundary}`,
'Content-Type: text/plain',
'',
'Token: {{token}}',
`--${boundary}`,
'Content-Type: application/json',
'',
'{"id": "{{id}}", "msg": "{{msg}}"}',
`--${boundary}--`,
''
].join('\r\n');
const request = {
method: 'POST',
mode: 'text',
url: 'https://api.example/send',
headers: { 'content-type': `multipart/mixed; boundary=${boundary}` },
data: rawMultipartBody
};
const result = interpolateVars(request, { token: 'abc123', id: 42, msg: 'hello' }, null, null);
expect(result.data).toContain('Token: abc123');
expect(result.data).toContain('{"id": "42", "msg": "hello"}');
expect(result.data).toContain(`--${boundary}`);
expect(result.data).toContain(`--${boundary}--`);
});
});
describe('interpolate-vars: api key header name sidecar', () => {
it('interpolates apiKeyHeaderName in lockstep with interpolated header keys', () => {
const request = {

View File

@@ -608,7 +608,7 @@ const registerNetworkIpc = (mainWindow) => {
const contentType = contentTypeHeader ? request.headers[contentTypeHeader] : '';
if (typeof contentType === 'string' && contentType.startsWith('multipart/')) {
if (typeof request.data !== 'string' && !isFormData(request.data)) {
if (!isFormData(request.data)) {
request._originalMultipartData = request.data;
request.collectionPath = collectionPath;
let form = createFormData(request.data, collectionPath);

View File

@@ -137,9 +137,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
}));
}
} else if (contentType.startsWith('multipart/')) {
if (request?.data && typeof request.data === 'string') {
request.data = _interpolate(request.data);
} else if (Array.isArray(request?.data) && !isFormData(request.data)) {
if (Array.isArray(request?.data) && !isFormData(request.data)) {
try {
request.data = request?.data?.map((d) => ({
...d,

View File

@@ -425,199 +425,5 @@ describe('interpolate-vars: interpolateVars', () => {
expect(result.data).toContain('{"test": true}');
expect(result.data).toContain('--TestBoundary123--');
});
it('interpolates variables in text-based multipart/mixed body with manual boundaries', () => {
// User manually constructs a multipart/mixed body as a string
const boundary = 'CustomBoundary123';
const rawMultipartBody = [
`--${boundary}`,
'Content-Type: text/plain',
'',
'Token: {{token}}',
`--${boundary}`,
'Content-Type: application/json',
'',
'{"id": "{{id}}", "msg": "{{msg}}"}',
`--${boundary}--`,
''
].join('\r\n');
const request = {
method: 'POST',
url: 'https://api.example/send',
headers: { 'content-type': `multipart/mixed; boundary=${boundary}` },
data: rawMultipartBody
};
const result = interpolateVars(request, { token: 'abc123', id: 42, msg: 'hello' }, null, null);
expect(result.data).toContain('Token: abc123');
expect(result.data).toContain('{"id": "42", "msg": "hello"}');
// Ensure boundaries are preserved
expect(result.data).toContain(`--${boundary}`);
expect(result.data).toContain(`--${boundary}--`);
});
it('interpolates variables in boundary lines themselves', () => {
const boundaryVar = 'BoundaryVar';
const rawMultipartBody = [
`--{{boundary}}`,
'Content-Type: text/plain',
'',
'Hello',
`--{{boundary}}--`,
''
].join('\r\n');
const request = {
method: 'POST',
url: 'https://api.example/send',
headers: { 'content-type': 'multipart/mixed; boundary={{boundary}}' },
data: rawMultipartBody
};
const result = interpolateVars(request, { boundary: boundaryVar }, null, null);
expect(result.data).toContain(`--${boundaryVar}`);
expect(result.data).toContain(`--${boundaryVar}--`);
});
it('interpolates variables that resolve to empty string or undefined', () => {
const boundary = 'B';
const rawMultipartBody = [
`--${boundary}`,
'Content-Type: text/plain',
'',
'Token: {{missingVar}}',
`--${boundary}--`,
''
].join('\r\n');
const request = {
method: 'POST',
url: 'https://api.example/send',
headers: { 'content-type': `multipart/mixed; boundary=${boundary}` },
data: rawMultipartBody
};
const result = interpolateVars(request, {} /* no missingVar */, null, null);
expect(result.data).toContain('Token: ');
});
it('interpolates multiple variables in a single line or JSON object', () => {
const boundary = 'B2';
const rawMultipartBody = [
`--${boundary}`,
'Content-Type: application/json',
'',
'{"id": "{{id}}", "msg": "{{msg}}", "extra": "{{extra}}"}',
`--${boundary}--`,
''
].join('\r\n');
const request = {
method: 'POST',
url: 'https://api.example/send',
headers: { 'content-type': `multipart/mixed; boundary=${boundary}` },
data: rawMultipartBody
};
const result = interpolateVars(request, { id: 1, msg: 'hi', extra: 'x' }, null, null);
expect(result.data).toContain('"id": "1", "msg": "hi", "extra": "x"');
});
it('interpolates variables inside quoted and unquoted contexts', () => {
const boundary = 'B3';
const rawMultipartBody = [
`--${boundary}`,
'Content-Disposition: form-data; name="{{fieldName}}"',
'',
'Value',
`--${boundary}--`,
''
].join('\r\n');
const request = {
method: 'POST',
url: 'https://api.example/send',
headers: { 'content-type': `multipart/mixed; boundary=${boundary}` },
data: rawMultipartBody
};
const result = interpolateVars(request, { fieldName: 'theField' }, null, null);
expect(result.data).toContain('name="theField"');
});
it('interpolates variables in both part headers and part bodies', () => {
const boundary = 'B4';
const rawMultipartBody = [
`--${boundary}`,
'Content-Type: text/plain; charset={{charset}}',
'',
'Token: {{token}}',
`--${boundary}--`,
''
].join('\r\n');
const request = {
method: 'POST',
url: 'https://api.example/send',
headers: { 'content-type': `multipart/mixed; boundary=${boundary}` },
data: rawMultipartBody
};
const result = interpolateVars(request, { charset: 'utf-8', token: 'abc' }, null, null);
expect(result.data).toContain('charset=utf-8');
expect(result.data).toContain('Token: abc');
});
it('interpolates variables in the final boundary line', () => {
const boundary = 'B5';
const rawMultipartBody = [
`--${boundary}`,
'Content-Type: text/plain',
'',
'End',
`--{{finalBoundary}}--`,
''
].join('\r\n');
const request = {
method: 'POST',
url: 'https://api.example/send',
headers: { 'content-type': `multipart/mixed; boundary=${boundary}` },
data: rawMultipartBody
};
const result = interpolateVars(request, { finalBoundary: boundary }, null, null);
expect(result.data).toContain(`--${boundary}--`);
});
it('interpolates variables that appear multiple times in the body', () => {
const boundary = 'B6';
const rawMultipartBody = [
`--${boundary}`,
'Content-Type: text/plain',
'',
'Token: {{token}}, Again: {{token}}',
`--${boundary}--`,
''
].join('\r\n');
const request = {
method: 'POST',
url: 'https://api.example/send',
headers: { 'content-type': `multipart/mixed; boundary=${boundary}` },
data: rawMultipartBody
};
const result = interpolateVars(request, { token: 'repeat' }, null, null);
expect(result.data.match(/repeat/g).length).toBe(2);
});
it('leaves body unchanged if no variables present', () => {
const boundary = 'B7';
const rawMultipartBody = [
`--${boundary}`,
'Content-Type: text/plain',
'',
'No variables here',
`--${boundary}--`,
''
].join('\r\n');
const request = {
method: 'POST',
url: 'https://api.example/send',
headers: { 'content-type': `multipart/mixed; boundary=${boundary}` },
data: rawMultipartBody
};
const result = interpolateVars(request, {}, null, null);
expect(result.data).toBe(rawMultipartBody);
});
});
});

View File

@@ -0,0 +1,24 @@
import http from 'node:http';
import { fastLookup } from './fast-lookup';
/**
* Shared agent configuration for HTTP/HTTPS agents across the application.
*
* - keepAlive: Reuse TCP connections to avoid repeated handshakes.
* - maxSockets: 100 concurrent sockets per host — high enough for parallel
* collection runs, low enough to avoid file-descriptor exhaustion.
* - maxFreeSockets: 10 idle sockets kept alive for reuse between bursts.
* - scheduling: 'fifo' distributes requests across connections evenly,
* which avoids head-of-line blocking that 'lifo' (Node's default) can
* cause when one connection stalls.
* - lookup: fastLookup uses async c-ares (dns.resolve4/6) to bypass the
* libuv thread pool bottleneck, falling back to dns.lookup for /etc/hosts
* and mDNS hostnames.
*/
export const defaultAgentOptions: http.AgentOptions = {
keepAlive: true,
maxSockets: 100,
maxFreeSockets: 10,
scheduling: 'fifo',
lookup: fastLookup as http.AgentOptions['lookup']
};

View File

@@ -1,6 +1,7 @@
import { default as axios, AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import http from 'node:http';
import https from 'node:https';
import { defaultAgentOptions } from './agent-defaults';
/**
*
@@ -29,8 +30,8 @@ type ModifiedAxiosResponse = AxiosResponse & {
const baseRequestConfig: Partial<AxiosRequestConfig> = {
proxy: false,
httpAgent: new http.Agent({ keepAlive: true }),
httpsAgent: new https.Agent({ keepAlive: true }),
httpAgent: new http.Agent(defaultAgentOptions),
httpsAgent: new https.Agent(defaultAgentOptions),
transformRequest: function transformRequest(data: any, headers: AxiosRequestHeaders) {
const contentType = headers.getContentType() || '';
const hasJSONContentType = contentType.includes('json');

View File

@@ -0,0 +1,60 @@
import dns from 'node:dns';
import { fastLookup } from './fast-lookup';
type DnsMethod = 'resolve4' | 'resolve6';
function mockResolve(method: DnsMethod, result: string[], err: Error | null = null): void {
(jest.spyOn(dns, method) as any).mockImplementation((_hostname: string, cb: Function) => {
cb(err, result);
});
}
function mockLookup(address: string, family: number): void {
(jest.spyOn(dns, 'lookup') as any).mockImplementation((_hostname: string, _options: dns.LookupOptions, cb: Function) => {
cb(null, address, family);
});
}
describe('fastLookup', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should resolve a public hostname via dns.resolve4', (done) => {
mockResolve('resolve4', ['93.184.216.34']);
fastLookup('example.com', {}, (err, address, family) => {
expect(err).toBeNull();
expect(address).toBe('93.184.216.34');
expect(family).toBe(4);
done();
});
});
it('should fall back to dns.lookup when both resolvers fail', (done) => {
mockResolve('resolve4', [], new Error('ENOTFOUND'));
mockResolve('resolve6', [], new Error('ENOTFOUND'));
mockLookup('127.0.0.1', 4);
fastLookup('my-local-host', {}, (err, address, family) => {
expect(err).toBeNull();
expect(address).toBe('127.0.0.1');
expect(family).toBe(4);
done();
});
});
it('should return all addresses when options.all is true', (done) => {
mockResolve('resolve4', ['1.2.3.4', '5.6.7.8']);
fastLookup('example.com', { all: true }, (err, addresses) => {
expect(err).toBeNull();
expect(Array.isArray(addresses)).toBe(true);
expect(addresses).toEqual([
{ address: '1.2.3.4', family: 4 },
{ address: '5.6.7.8', family: 4 }
]);
done();
});
});
});

View File

@@ -0,0 +1,32 @@
import dns from 'node:dns';
/**
* Fast DNS lookup that bypasses the libuv thread pool.
*
* Tries dns.resolve4 then dns.resolve6 (async, c-ares based),
* falls back to dns.lookup for /etc/hosts and mDNS hostnames.
*
* NOTE: `options.family` is not currently respected — the function always
* tries IPv4 first regardless of the caller's preference. This is safe today
* because Bruno's HTTP agents use the default family (0), but should be
* addressed if any code path starts specifying a family.
*/
export function fastLookup(
hostname: string,
options: dns.LookupOptions | undefined,
callback: (err: Error | null, address: string | dns.LookupAddress[], family?: number) => void
): void {
dns.resolve4(hostname, (err4, addresses4) => {
if (!err4 && addresses4?.length) {
return options?.all
? callback(null, addresses4.map((a) => ({ address: a, family: 4 })))
: callback(null, addresses4[0], 4);
}
// Forward to standard dns.lookup for /etc/hosts, mDNS, and other
// non-public hostnames that c-ares cannot resolve.
dns.lookup(hostname, options ?? {}, (err, address, family) => {
callback(err, address, family);
});
});
}

View File

@@ -3,6 +3,7 @@ import tls from 'node:tls';
import type { Agent as HttpAgent } from 'node:http';
import type { Agent as HttpsAgent } from 'node:https';
import { createTimelineAgentClass, createTimelineHttpAgentClass, type TimelineEntry, type AgentOptions, type HttpAgentOptions, type AgentClass, type HttpAgentClass } from './timeline-agent';
import { defaultAgentOptions } from '../network/agent-defaults';
/**
* Agent cache for SSL session reuse.
@@ -267,8 +268,16 @@ function getOrCreateAgentInternal<TOptions extends HttpAgentOptions>(
}
const AgentClass = timeline ? getTimelineClass(BaseAgentClass) : BaseAgentClass;
// Inject shared agent defaults (DNS lookup, socket pool settings), then
// layer on the caller's options so per-agent overrides still take effect.
const optimizedOptions = {
...defaultAgentOptions,
...options
};
// Convert raw `ca` to a secureContext that adds CAs on top of OpenSSL defaults
const resolvedOptions = applySecureContext(options);
const resolvedOptions = applySecureContext(optimizedOptions);
let agent: HttpAgent | HttpsAgent;
if (timeline) {

View File

@@ -1,34 +0,0 @@
meta {
name: content-types-mixed-interpolation
type: http
seq: 1
}
post {
url: {{echo-host}}
body: text
auth: inherit
}
body:text {
------MyCustomBoundaryString
Content-Disposition: form-data; name="metadata"
Content-Type: application/json
{{version}}
------MyCustomBoundaryString--
}
vars:pre-request {
version: 0.0.1
}
assert {
res.body: contains 0.0.1
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,20 +0,0 @@
meta {
name: multipart-mixed-form-data-file
type: http
seq: 3
}
post {
url: {{echo-host}}
body: multipartForm
auth: none
}
body:multipart-form {
sample: @file(bruno.png) @contentType(image/png)
}
assert {
res.body: matches ^[-]+[a-z0-9]+
res.body: contains Content-Type: image/png
}

View File

@@ -1,23 +0,0 @@
meta {
name: multipart-mixed-form-data-parse
type: http
seq: 1
}
post {
url: {{echo-host}}
body: multipartForm
auth: none
}
headers {
Content-Type: multipart/mixed
}
body:multipart-form {
sample: sample
}
assert {
res.body: matches ^[-]+[a-z0-9]+
}

View File

@@ -1,118 +0,0 @@
import path from 'path';
import fs from 'fs';
import { test, expect, closeElectronApp } from '../../playwright';
import {
createCollection,
createFolder,
createWorkspace,
openfolder,
selectfolderPaneTab,
switchWorkspace,
waitForReadyPage
} from '../utils/page';
import { buildCommonLocators } from '../utils/page/locators';
const readSnapshot = (userDataPath: string) => {
const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json');
if (!fs.existsSync(snapshotPath)) return null;
return JSON.parse(fs.readFileSync(snapshotPath, 'utf-8'));
};
const findSnapshotFolderTab = (snapshot: any, folderName: string) => {
if (!snapshot || !Array.isArray(snapshot.collections)) return null;
for (const collection of snapshot.collections) {
if (!Array.isArray(collection?.tabs)) continue;
const tab = collection.tabs.find(
(t: any) => t?.type === 'folder-settings' && typeof t?.pathname === 'string' && t.pathname.includes(folderName)
);
if (tab) return tab;
}
return null;
};
test.describe('Snapshot: folder Pane Interactivity', () => {
test('folder pane tab interactivity is preserved after workspace switch', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-folder-workspace-switch');
const colPath = await createTmpDir('col');
const app = await launchElectronApp({ userDataPath });
const page = await waitForReadyPage(app);
await test.step('Create collection and folder, open folder settings', async () => {
await createCollection(page, 'TestCol', colPath);
await createFolder(page, 'TestFolder', 'TestCol');
await openfolder(page, 'TestCol', 'TestFolder', { persist: true });
await selectfolderPaneTab(page, 'auth');
});
await test.step('Switch to a new workspace', async () => {
await page.waitForTimeout(1000);
await createWorkspace(page, 'SecondWorkspace');
await expect(page.getByTestId('workspace-name')).toHaveText('SecondWorkspace', { timeout: 5000 });
});
await test.step('Switch back to original workspace and verify folder pane interactivity', async () => {
await switchWorkspace(page, 'My Workspace');
await openfolder(page, 'TestCol', 'TestFolder', { persist: true });
const locators = buildCommonLocators(page);
await expect(locators.tabs.folderTab('TestFolder')).toBeVisible({ timeout: 10000 });
await locators.tabs.folderTab('TestFolder').click({ force: true });
await selectfolderPaneTab(page, 'auth');
await selectfolderPaneTab(page, 'headers');
await selectfolderPaneTab(page, 'docs');
await selectfolderPaneTab(page, 'script');
await selectfolderPaneTab(page, 'vars');
});
await closeElectronApp(app);
});
test('folder pane tab interactivity is preserved after app restart', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('snap-folder-restart');
const colPath = await createTmpDir('col');
const app = await launchElectronApp({ userDataPath });
const page = await waitForReadyPage(app);
await test.step('Create collection and folder, open folder settings on auth tab', async () => {
await createCollection(page, 'TestCol', colPath);
await createFolder(page, 'TestFolder', 'TestCol');
await openfolder(page, 'TestCol', 'TestFolder', { persist: true });
await selectfolderPaneTab(page, 'auth');
});
await test.step('Close app and verify snapshot stores folder-settings tab', async () => {
await page.waitForTimeout(2000);
await closeElectronApp(app);
const snapshotPath = path.join(userDataPath, 'ui-state-snapshot.json');
await expect.poll(() => fs.existsSync(snapshotPath)).toBe(true);
const snapshot = readSnapshot(userDataPath);
const tab = findSnapshotFolderTab(snapshot, 'TestFolder');
expect(tab).toBeTruthy();
expect(tab.type).toBe('folder-settings');
expect(tab.permanent).toBe(true);
});
await test.step('Restart app and verify folder pane interactivity is restored', async () => {
const app2 = await launchElectronApp({ userDataPath });
const page2 = await waitForReadyPage(app2);
const locators = buildCommonLocators(page2);
await expect(locators.tabs.folderTab('TestFolder')).toBeVisible({ timeout: 15000 });
await locators.tabs.folderTab('TestFolder').click({ force: true });
await selectfolderPaneTab(page2, 'auth');
await selectfolderPaneTab(page2, 'headers');
await selectfolderPaneTab(page2, 'docs');
await selectfolderPaneTab(page2, 'script');
await selectfolderPaneTab(page2, 'vars');
await closeElectronApp(app2);
});
});
});

View File

@@ -736,43 +736,6 @@ const openRequest = async (page: Page, collectionName: string, requestName: stri
}
});
};
/**
* Open a folder's settings tab by clicking on it in the sidebar
* @param page - The page object
* @param collectionName - The name of the collection
* @param folderName - The name of the folder
* @param options - Optional settings (persist: double-click to make tab permanent)
* @returns void
*/
const openfolder = async (page: Page, collectionName: string, folderName: string, { persist = false } = {}) => {
await test.step(`Open folder "${folderName}" in collection "${collectionName}"`, async () => {
const collectionContainer = page.getByTestId('sidebar-collection-row').filter({ hasText: collectionName });
await collectionContainer.click();
const collectionWrapper = collectionContainer.locator('..');
const folder = collectionWrapper.getByTestId('sidebar-collection-item-row').filter({ hasText: folderName });
if (!persist) {
await folder.click();
} else {
await folder.dblclick();
}
});
};
/**
* Select a tab in the folder settings pane
* @param page - The page object
* @param tabName - The tab name key (e.g. 'auth', 'headers', 'docs', 'script', 'vars', 'test')
* @returns void
*/
const selectfolderPaneTab = async (page: Page, tabName: string) => {
await test.step(`Select folder pane tab "${tabName}"`, async () => {
const locators = buildCommonLocators(page);
const tab = locators.paneTabs.folderSettingsTab(tabName.toLowerCase());
await tab.click();
await expect(tab).toContainClass('active');
});
};
/**
* Open a request within a folder
* @param page - The page object
@@ -1342,9 +1305,7 @@ export {
selectEnvironment,
sendRequest,
openRequest,
openfolder,
openFolderRequest,
selectfolderPaneTab,
getResponseBody,
expectResponseContains,
selectRequestPaneTab,

View File

@@ -37,7 +37,6 @@ export const buildCommonLocators = (page: Page) => ({
},
tabs: {
requestTab: (requestName: string) => page.locator('.request-tab .tab-label').filter({ hasText: requestName }),
folderTab: (folderName: string) => page.locator('.request-tab .tab-label').filter({ hasText: folderName }),
activeRequestTab: () => page.locator('.request-tab.active'),
closeTab: (requestName: string) => page.locator('.request-tab').filter({ hasText: requestName }).getByTestId('request-tab-close-icon'),
draftIndicator: () => page.locator('.request-tab.active .has-changes-icon')