mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-05 10:28:32 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d2af32d9c | ||
|
|
84337298e8 | ||
|
|
e49a823514 | ||
|
|
c1eab7e59b | ||
|
|
60fc17f10a | ||
|
|
71f5659763 |
@@ -390,7 +390,7 @@ const RequestTabPanel = () => {
|
||||
if (folder) {
|
||||
return (
|
||||
<ScopedPersistenceProvider scope={focusedTab.uid}>
|
||||
<FolderSettings collection={collection} folder={folder} />;
|
||||
<FolderSettings collection={collection} folder={folder} />
|
||||
</ScopedPersistenceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -350,6 +350,10 @@ const getAccessor = (tab) => {
|
||||
};
|
||||
|
||||
const getDefaultRequestPaneTabForType = (type) => {
|
||||
if (type === 'folder-settings') {
|
||||
return 'headers';
|
||||
}
|
||||
|
||||
if (type === 'grpc-request' || type === 'ws-request') {
|
||||
return 'body';
|
||||
}
|
||||
@@ -558,7 +562,7 @@ export const deserializeTab = (snapshotTab, collection) => {
|
||||
|
||||
if (accessor === 'pathname' && pathname) {
|
||||
const item = findItemInCollectionByPathname(collection, pathname);
|
||||
const resolvedType = item?.type || type;
|
||||
const resolvedType = (item && isRequestTab(item.type)) ? item.type : type;
|
||||
tab.type = resolvedType;
|
||||
if (!restoredRequestPaneTab) {
|
||||
tab.requestPaneTab = getDefaultRequestPaneTabForType(resolvedType);
|
||||
|
||||
@@ -286,6 +286,56 @@ 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',
|
||||
|
||||
@@ -100,7 +100,9 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
}));
|
||||
}
|
||||
} else if (contentType.startsWith('multipart/')) {
|
||||
if (Array.isArray(request?.data) && !isFormData(request.data)) {
|
||||
if (request?.data && typeof request.data === 'string') {
|
||||
request.data = _interpolate(request.data);
|
||||
} else if (Array.isArray(request?.data) && !isFormData(request.data)) {
|
||||
try {
|
||||
request.data = request?.data?.map((d) => ({
|
||||
...d,
|
||||
|
||||
@@ -488,7 +488,7 @@ const runSingleRequest = async function (
|
||||
|
||||
const contentType = contentTypeHeader ? request.headers[contentTypeHeader] : '';
|
||||
if (typeof contentType === 'string' && contentType.startsWith('multipart/')) {
|
||||
if (!isFormData(request?.data)) {
|
||||
if (typeof request.data !== 'string' && !isFormData(request?.data)) {
|
||||
request._originalMultipartData = request.data;
|
||||
request.collectionPath = collectionPath;
|
||||
let form = createFormData(request.data, collectionPath);
|
||||
|
||||
@@ -1,6 +1,60 @@
|
||||
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 = {
|
||||
|
||||
@@ -608,7 +608,7 @@ const registerNetworkIpc = (mainWindow) => {
|
||||
|
||||
const contentType = contentTypeHeader ? request.headers[contentTypeHeader] : '';
|
||||
if (typeof contentType === 'string' && contentType.startsWith('multipart/')) {
|
||||
if (!isFormData(request.data)) {
|
||||
if (typeof request.data !== 'string' && !isFormData(request.data)) {
|
||||
request._originalMultipartData = request.data;
|
||||
request.collectionPath = collectionPath;
|
||||
let form = createFormData(request.data, collectionPath);
|
||||
|
||||
@@ -137,7 +137,9 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
|
||||
}));
|
||||
}
|
||||
} else if (contentType.startsWith('multipart/')) {
|
||||
if (Array.isArray(request?.data) && !isFormData(request.data)) {
|
||||
if (request?.data && typeof request.data === 'string') {
|
||||
request.data = _interpolate(request.data);
|
||||
} else if (Array.isArray(request?.data) && !isFormData(request.data)) {
|
||||
try {
|
||||
request.data = request?.data?.map((d) => ({
|
||||
...d,
|
||||
|
||||
@@ -425,5 +425,199 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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']
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -30,8 +29,8 @@ type ModifiedAxiosResponse = AxiosResponse & {
|
||||
|
||||
const baseRequestConfig: Partial<AxiosRequestConfig> = {
|
||||
proxy: false,
|
||||
httpAgent: new http.Agent(defaultAgentOptions),
|
||||
httpsAgent: new https.Agent(defaultAgentOptions),
|
||||
httpAgent: new http.Agent({ keepAlive: true }),
|
||||
httpsAgent: new https.Agent({ keepAlive: true }),
|
||||
transformRequest: function transformRequest(data: any, headers: AxiosRequestHeaders) {
|
||||
const contentType = headers.getContentType() || '';
|
||||
const hasJSONContentType = contentType.includes('json');
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,6 @@ 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.
|
||||
@@ -268,16 +267,8 @@ 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(optimizedOptions);
|
||||
const resolvedOptions = applySecureContext(options);
|
||||
|
||||
let agent: HttpAgent | HttpsAgent;
|
||||
if (timeline) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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]+
|
||||
}
|
||||
118
tests/snapshots/folder.spec.ts
Normal file
118
tests/snapshots/folder.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -736,6 +736,43 @@ 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
|
||||
@@ -1305,7 +1342,9 @@ export {
|
||||
selectEnvironment,
|
||||
sendRequest,
|
||||
openRequest,
|
||||
openfolder,
|
||||
openFolderRequest,
|
||||
selectfolderPaneTab,
|
||||
getResponseBody,
|
||||
expectResponseContains,
|
||||
selectRequestPaneTab,
|
||||
|
||||
@@ -37,6 +37,7 @@ 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')
|
||||
|
||||
Reference in New Issue
Block a user