test(core): current mount pipeline (#7466)

This commit is contained in:
Chirag Chandrashekhar
2026-06-08 20:57:33 +05:30
committed by GitHub
parent 2d4d4e4037
commit b9ee1ee523
21 changed files with 1569 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
meta {
name: DELETE-Request
type: http
seq: 4
}
delete {
url: {{host}}/api/resource
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: GET-Request
type: http
seq: 1
}
get {
url: {{host}}/api/resource
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: PATCH-Request
type: http
seq: 5
}
patch {
url: {{host}}/api/resource
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: POST-Request
type: http
seq: 2
}
post {
url: {{host}}/api/resource
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: PUT-Request
type: http
seq: 3
}
put {
url: {{host}}/api/resource
body: none
auth: none
}

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "Method Indicators",
"type": "collection"
}

View File

@@ -0,0 +1,7 @@
meta {
name: Method Indicators
}
auth {
mode: none
}

View File

@@ -0,0 +1,8 @@
info:
name: DELETE-Request
type: http
seq: 4
http:
method: DELETE
url: "{{host}}/api/resource"

View File

@@ -0,0 +1,8 @@
info:
name: GET-Request
type: http
seq: 1
http:
method: GET
url: "{{host}}/api/resource"

View File

@@ -0,0 +1,8 @@
info:
name: PATCH-Request
type: http
seq: 5
http:
method: PATCH
url: "{{host}}/api/resource"

View File

@@ -0,0 +1,8 @@
info:
name: POST-Request
type: http
seq: 2
http:
method: POST
url: "{{host}}/api/resource"

View File

@@ -0,0 +1,8 @@
info:
name: PUT-Request
type: http
seq: 3
http:
method: PUT
url: "{{host}}/api/resource"

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "Method Indicators",
"type": "collection"
}

View File

@@ -0,0 +1,3 @@
opencollection: "1.0.0"
info:
name: Method Indicators

View File

@@ -0,0 +1,116 @@
import { test, expect, ElectronApplication, Page } from '../../playwright';
import { setupSortingTestFixture, TestFixture } from '../utils/fixtures';
import { getCollectionTreeStructure, closeAllCollections } from '../utils/page';
const formats = ['bru', 'yml'] as const;
for (const format of formats) {
test.describe(`[${format}] Item Sorting`, () => {
test.describe('sequence-based sorting', () => {
let fixture: TestFixture;
let app: ElectronApplication;
let page: Page;
test.beforeAll(async ({ launchElectronApp }) => {
// Create items with non-alphabetical names but defined sequences
// Sequence order should override alphabetical order
fixture = await setupSortingTestFixture({
name: 'Sequence Sort Collection',
format,
items: [
{ name: 'Zebra Request', seq: 1 },
{ name: 'Apple Request', seq: 2 },
{ name: 'Mango Request', seq: 3 },
{ name: 'Banana Request', seq: 4 }
]
});
app = await launchElectronApp({ userDataPath: fixture.userDataPath });
page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
});
test.afterAll(async () => {
if (page) {
await closeAllCollections(page);
}
if (app) {
await app.context().close();
await app.close();
}
if (fixture) {
await fixture.cleanup();
}
});
test('should sort items by sequence when defined', async () => {
const tree = await getCollectionTreeStructure(page, 'Sequence Sort Collection');
// Extract item names in their rendered order
const itemNames = tree.items.map((item) => item.name);
// Items should be in sequence order, not alphabetical
// Zebra (seq: 1), Apple (seq: 2), Mango (seq: 3), Banana (seq: 4)
expect(itemNames).toEqual([
'Zebra Request',
'Apple Request',
'Mango Request',
'Banana Request'
]);
});
});
test.describe('alphabetical sorting', () => {
let fixture: TestFixture;
let app: ElectronApplication;
let page: Page;
test.beforeAll(async ({ launchElectronApp }) => {
// Create items without sequence numbers - should sort alphabetically
fixture = await setupSortingTestFixture({
name: 'Alpha Sort Collection',
format,
items: [
{ name: 'Zebra Request' },
{ name: 'Apple Request' },
{ name: 'Mango Request' },
{ name: 'Banana Request' }
]
});
app = await launchElectronApp({ userDataPath: fixture.userDataPath });
page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
});
test.afterAll(async () => {
if (page) {
await closeAllCollections(page);
}
if (app) {
await app.context().close();
await app.close();
}
if (fixture) {
await fixture.cleanup();
}
});
test('should sort items alphabetically when no sequence defined', async () => {
const tree = await getCollectionTreeStructure(page, 'Alpha Sort Collection');
// Extract item names in their rendered order
const itemNames = tree.items.map((item) => item.name);
// Items should be in alphabetical order
// Apple, Banana, Mango, Zebra
expect(itemNames).toEqual([
'Apple Request',
'Banana Request',
'Mango Request',
'Zebra Request'
]);
});
});
});
}

View File

@@ -0,0 +1,69 @@
import { test, expect, ElectronApplication, Page } from '../../playwright';
import { setupTestFixture, TestFixture } from '../utils/fixtures';
import { isCollectionLoading, closeAllCollections } from '../utils/page';
const formats = ['bru', 'yml'] as const;
for (const format of formats) {
test.describe(`[${format}] Loading State`, () => {
let fixture: TestFixture;
let app: ElectronApplication;
let page: Page;
test.beforeAll(async ({ launchElectronApp }) => {
// Set up test fixture (collection + user data)
fixture = await setupTestFixture({
name: 'Test Collection',
requestCount: 10,
depth: 2,
foldersPerLevel: 2,
format,
environmentCount: 2
});
// Launch app with the prepared user data
app = await launchElectronApp({ userDataPath: fixture.userDataPath });
page = await app.firstWindow();
// Wait for app to be ready
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
});
test.afterAll(async () => {
// Close collections before app teardown
if (page) {
await closeAllCollections(page);
}
// Close the app to release file locks
if (app) {
await app.context().close();
await app.close();
}
// Cleanup generated files
if (fixture) {
await fixture.cleanup();
}
});
test('collection should be mounted and visible in sidebar', async () => {
// Collection should be in the sidebar
const collectionRow = page.getByTestId('sidebar-collection-row').filter({
has: page.locator('#sidebar-collection-name', { hasText: 'Test Collection' })
});
await expect(collectionRow).toBeVisible({ timeout: 30000 });
});
test('mounting spinner should not be visible for loaded collections', async () => {
const collectionRow = page.getByTestId('sidebar-collection-row').filter({
has: page.locator('#sidebar-collection-name', { hasText: 'Test Collection' })
});
await expect(collectionRow).toBeVisible({ timeout: 30000 });
// Verify via helper
const loading = await isCollectionLoading(page, 'Test Collection');
expect(loading).toBe(false);
});
});
}

View File

@@ -0,0 +1,172 @@
import * as path from 'path';
import { test, expect, ElectronApplication, Page } from '../../playwright';
import { setupTestFixture, setupStaticFixture, TestFixture, StaticTestFixture } from '../utils/fixtures';
import { getCollectionTreeStructure, CollectionTreeItem, closeAllCollections } from '../utils/page';
const formats = ['bru', 'yml'] as const;
for (const format of formats) {
test.describe(`[${format}] Collection Tree Construction`, () => {
let fixture: TestFixture;
let app: ElectronApplication;
let page: Page;
test.beforeAll(async ({ launchElectronApp }) => {
fixture = await setupTestFixture({
name: 'Tree Test Collection',
requestCount: 10,
depth: 2,
foldersPerLevel: 2,
format,
environmentCount: 2,
mixedMethods: true
});
app = await launchElectronApp({ userDataPath: fixture.userDataPath });
page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
});
test.afterAll(async () => {
if (page) {
await closeAllCollections(page);
}
if (app) {
await app.context().close();
await app.close();
}
if (fixture) {
await fixture.cleanup();
}
});
test('should render folders with correct hierarchy', async () => {
const tree = await getCollectionTreeStructure(page, 'Tree Test Collection');
// Verify collection name
expect(tree.name).toBe('Tree Test Collection');
// With depth=2 and foldersPerLevel=2, we expect:
// - 2 top-level folders (F1, F2)
// - Each top-level folder has 2 nested folders (F1-F1, F1-F2, etc.)
const folders = tree.items.filter((item) => item.type === 'folder');
expect(folders.length).toBeGreaterThanOrEqual(2);
// Check that folders have the expected naming pattern (F1, F2)
const folderNames = folders.map((f) => f.name);
expect(folderNames.some((name) => /^F\d+$/.test(name))).toBe(true);
});
test('should render requests under correct parent folders', async () => {
const tree = await getCollectionTreeStructure(page, 'Tree Test Collection');
// Helper to count requests recursively
const countRequests = (items: CollectionTreeItem[]): number => {
return items.reduce((count, item) => {
if (item.type === 'request') {
return count + 1;
}
if (item.type === 'folder' && item.items) {
return count + countRequests(item.items);
}
return count;
}, 0);
};
// We generated 10 requests, they should all be present
const totalRequests = countRequests(tree.items);
expect(totalRequests).toBe(fixture.collection.requestCount);
// Verify requests exist at various levels (root and in folders)
const rootRequests = tree.items.filter((item) => item.type === 'request');
const folders = tree.items.filter((item) => item.type === 'folder');
// Requests should be distributed - some at root, some in folders
const hasRequestsInFolders = folders.some(
(folder) => folder.items && folder.items.some((item) => item.type === 'request')
);
// Either we have root requests or requests in folders (distribution depends on generator)
expect(rootRequests.length > 0 || hasRequestsInFolders).toBe(true);
});
test('should have correct parent-child relationships via naming convention', async () => {
const tree = await getCollectionTreeStructure(page, 'Tree Test Collection');
// Verify items are named with their parent prefix:
// - Root requests: R1, R2, ...
// - Folder F1 requests: F1-R1, F1-R2, ...
// - Nested folder F1-F1 requests: F1-F1-R1, ...
const verifyNaming = (items: CollectionTreeItem[], parentPrefix: string) => {
for (const item of items) {
if (item.type === 'request') {
// Request should start with parent prefix (or be R1, R2 at root)
if (parentPrefix) {
expect(item.name.startsWith(parentPrefix + '-R')).toBe(true);
} else {
expect(item.name).toMatch(/^R\d+$/);
}
}
if (item.type === 'folder') {
// Folder should start with parent prefix (or be F1, F2 at root)
if (parentPrefix) {
expect(item.name.startsWith(parentPrefix + '-F')).toBe(true);
} else {
expect(item.name).toMatch(/^F\d+$/);
}
// Recursively verify children
if (item.items) {
verifyNaming(item.items, item.name);
}
}
}
};
verifyNaming(tree.items, '');
});
});
}
// Method indicators are verified against a static, hand-authored collection with
// exactly one request per HTTP method, so the expected badge set is fixed rather
// than dependent on the generator's method-cycling behaviour.
for (const format of formats) {
test.describe(`[${format}] Request Method Indicators`, () => {
let fixture: StaticTestFixture;
let app: ElectronApplication;
let page: Page;
test.beforeAll(async ({ launchElectronApp }) => {
fixture = await setupStaticFixture(
path.join(__dirname, 'fixtures', 'method-indicators', format)
);
app = await launchElectronApp({ userDataPath: fixture.userDataPath });
page = await app.firstWindow();
await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 });
});
test.afterAll(async () => {
if (page) {
await closeAllCollections(page);
}
if (app) {
await app.context().close();
await app.close();
}
if (fixture) {
await fixture.cleanup();
}
});
test('should display correct request method indicators', async () => {
const tree = await getCollectionTreeStructure(page, 'Method Indicators');
const requests = tree.items.filter((item) => item.type === 'request');
const methods = requests.map((r) => r.method).filter(Boolean);
// One request per method. UI truncates methods > 5 chars to 3 chars (DELETE -> DEL).
expect(new Set(methods)).toEqual(new Set(['GET', 'POST', 'PUT', 'DEL', 'PATCH']));
});
});
}

View File

@@ -0,0 +1,681 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
export type CollectionFormat = 'bru' | 'yml';
export interface GenerateCollectionOptions {
/** Name of the collection */
name?: string;
/** Total number of requests to generate */
requestCount: number;
/** Maximum folder nesting depth (0 = no folders, all requests at root) */
depth?: number;
/** Number of folders per level (default: 3) */
foldersPerLevel?: number;
/** Collection format: 'bru' or 'yml' */
format: CollectionFormat;
/** Number of environments to generate (default: 2) */
environmentCount?: number;
/** Include mixed HTTP methods (default: true) */
mixedMethods?: boolean;
}
export interface GeneratedCollection {
/** Path to the generated collection directory */
path: string;
/** Cleanup function to remove the collection */
cleanup: () => Promise<void>;
/** Collection name */
name: string;
/** Number of requests generated */
requestCount: number;
/** Number of folders generated */
folderCount: number;
/** Format used */
format: CollectionFormat;
}
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const;
/**
* Generate a collection with the specified parameters
*/
export async function generateCollection(
options: GenerateCollectionOptions
): Promise<GeneratedCollection> {
const {
name = `Generated Collection ${Date.now()}`,
requestCount,
depth = 2,
foldersPerLevel = 3,
format,
environmentCount = 2,
mixedMethods = true
} = options;
// Create temp directory
const tempDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'bruno-test-collection-')
);
try {
// Generate collection root files
await generateCollectionRoot(tempDir, name, format);
// Generate environments
if (environmentCount > 0) {
await generateEnvironments(tempDir, environmentCount, format);
}
// Calculate folder structure
const folders = generateFolderStructure(depth, foldersPerLevel);
const folderCount = folders.length;
// Create folders
for (const folder of folders) {
await createFolder(tempDir, folder, format);
}
// Distribute requests across folders
const requestsPerLocation = distributeRequests(requestCount, folders);
let globalIndex = 0;
for (const [folderPath, count] of Object.entries(requestsPerLocation)) {
for (let i = 0; i < count; i++) {
const method = mixedMethods
? HTTP_METHODS[globalIndex % HTTP_METHODS.length]
: 'GET';
// Pass per-folder index (i) for naming, global index for method cycling
await createRequest(tempDir, folderPath, i, method, format);
globalIndex++;
}
}
return {
path: tempDir,
cleanup: async () => {
await fs.promises.rm(tempDir, { recursive: true, force: true });
},
name,
requestCount,
folderCount,
format
};
} catch (error) {
// Cleanup on error
await fs.promises.rm(tempDir, { recursive: true, force: true });
throw error;
}
}
/**
* Generate collection root files (bruno.json and collection.bru/opencollection.yml)
*/
async function generateCollectionRoot(
basePath: string,
name: string,
format: CollectionFormat
): Promise<void> {
// Always create bruno.json
const brunoJson = {
version: '1',
name,
type: 'collection'
};
await fs.promises.writeFile(
path.join(basePath, 'bruno.json'),
JSON.stringify(brunoJson, null, 2)
);
if (format === 'bru') {
const collectionBru = `meta {
name: ${name}
}
auth {
mode: none
}
`;
await fs.promises.writeFile(
path.join(basePath, 'collection.bru'),
collectionBru
);
} else {
const opencollectionYml = `opencollection: "1.0.0"
info:
name: ${name}
`;
await fs.promises.writeFile(
path.join(basePath, 'opencollection.yml'),
opencollectionYml
);
}
}
/**
* Generate environment files
*/
async function generateEnvironments(
basePath: string,
count: number,
format: CollectionFormat
): Promise<void> {
const envDir = path.join(basePath, 'environments');
await fs.promises.mkdir(envDir, { recursive: true });
const envNames = ['dev', 'staging', 'prod', 'local', 'test'];
for (let i = 0; i < count; i++) {
const envName = envNames[i % envNames.length];
const ext = format === 'bru' ? 'bru' : 'yml';
const filePath = path.join(envDir, `${envName}.${ext}`);
if (format === 'bru') {
const content = `vars {
host: https://api.${envName}.example.com
apiKey: ${envName}-api-key-${i}
}
`;
await fs.promises.writeFile(filePath, content);
} else {
const content = `name: ${envName}
variables:
- name: host
value: https://api.${envName}.example.com
- name: apiKey
value: ${envName}-api-key-${i}
`;
await fs.promises.writeFile(filePath, content);
}
}
}
/**
* Generate folder structure paths
* Folder names include parent prefix for verifiable parent-child relationships
* Uses short names: F1, F1-F1, F1-F1-F1 to avoid OS filename length limits
*/
function generateFolderStructure(
depth: number,
foldersPerLevel: number
): string[] {
if (depth === 0) {
return ['']; // Root only
}
const folders: string[] = [''];
function addFolders(currentPath: string, parentName: string, currentDepth: number): void {
if (currentDepth >= depth) return;
for (let i = 0; i < foldersPerLevel; i++) {
// Short folder name with parent prefix: F1, F1-F1, F1-F1-F1
const folderName = parentName ? `${parentName}-F${i + 1}` : `F${i + 1}`;
const newPath = currentPath ? `${currentPath}/${folderName}` : folderName;
folders.push(newPath);
addFolders(newPath, folderName, currentDepth + 1);
}
}
addFolders('', '', 0);
return folders;
}
/**
* Create a folder with its metadata file
*/
async function createFolder(
basePath: string,
folderPath: string,
format: CollectionFormat
): Promise<void> {
if (!folderPath) return; // Skip root
const fullPath = path.join(basePath, folderPath);
await fs.promises.mkdir(fullPath, { recursive: true });
const folderName = path.basename(folderPath);
const ext = format === 'bru' ? 'bru' : 'yml';
const metaPath = path.join(fullPath, `folder.${ext}`);
if (format === 'bru') {
const content = `meta {
name: ${folderName}
}
auth {
mode: inherit
}
`;
await fs.promises.writeFile(metaPath, content);
} else {
const content = `info:
name: ${folderName}
`;
await fs.promises.writeFile(metaPath, content);
}
}
/**
* Distribute requests across folders
*/
function distributeRequests(
requestCount: number,
folders: string[]
): Record<string, number> {
const distribution: Record<string, number> = {};
// Initialize all folders with 0
for (const folder of folders) {
distribution[folder] = 0;
}
// Distribute requests evenly, with remainder going to first folders
const perFolder = Math.floor(requestCount / folders.length);
const remainder = requestCount % folders.length;
for (let i = 0; i < folders.length; i++) {
distribution[folders[i]] = perFolder + (i < remainder ? 1 : 0);
}
return distribution;
}
/**
* Create a request file
* Request names include parent folder prefix for verifiable parent-child relationships
* Uses short names: R1, F1-R1, F1-F1-R1 to avoid OS filename length limits
*/
async function createRequest(
basePath: string,
folderPath: string,
indexInFolder: number,
method: typeof HTTP_METHODS[number],
format: CollectionFormat
): Promise<void> {
// Get parent folder name from path (e.g., "F1/F1-F1" -> "F1-F1")
const parentName = folderPath ? path.basename(folderPath) : '';
const requestName = parentName ? `${parentName}-R${indexInFolder + 1}` : `R${indexInFolder + 1}`;
const ext = format === 'bru' ? 'bru' : 'yml';
const fullFolderPath = folderPath
? path.join(basePath, folderPath)
: basePath;
const filePath = path.join(fullFolderPath, `${requestName}.${ext}`);
// Ensure folder exists
await fs.promises.mkdir(fullFolderPath, { recursive: true });
const hasBody = ['POST', 'PUT', 'PATCH'].includes(method);
if (format === 'bru') {
let content = `meta {
name: ${requestName}
type: http
seq: ${(indexInFolder % 100) + 1}
}
${method.toLowerCase()} {
url: {{host}}/api/resource/${indexInFolder + 1}
body: ${hasBody ? 'json' : 'none'}
auth: none
}
`;
if (hasBody) {
content += `
body:json {
{
"id": ${indexInFolder + 1},
"name": "Resource ${indexInFolder + 1}",
"timestamp": "${new Date().toISOString()}"
}
}
`;
}
await fs.promises.writeFile(filePath, content);
} else {
let content = `info:
name: ${requestName}
type: http
seq: ${(indexInFolder % 100) + 1}
http:
method: ${method}
url: "{{host}}/api/resource/${indexInFolder + 1}"
`;
if (hasBody) {
content += ` body:
mode: json
json: |
{
"id": ${indexInFolder + 1},
"name": "Resource ${indexInFolder + 1}",
"timestamp": "${new Date().toISOString()}"
}
`;
}
await fs.promises.writeFile(filePath, content);
}
}
/**
* Generate collections in both formats for comparison testing
*/
export async function generateCollectionPair(
options: Omit<GenerateCollectionOptions, 'format'>
): Promise<{
bru: GeneratedCollection;
yml: GeneratedCollection;
cleanup: () => Promise<void>;
}> {
const bru = await generateCollection({ ...options, format: 'bru' });
const yml = await generateCollection({ ...options, format: 'yml' }).catch(async (err) => {
await bru.cleanup();
throw err;
});
return {
bru,
yml,
cleanup: async () => {
await Promise.all([bru.cleanup(), yml.cleanup()]);
}
};
}
/**
* Item definition for sorting test collections
*/
export interface SortingTestItem {
/** Name of the request */
name: string;
/** Sequence number (omit to test alphabetical sorting) */
seq?: number;
/** HTTP method (default: GET) */
method?: typeof HTTP_METHODS[number];
}
/**
* Options for generating a sorting test collection
*/
export interface SortingTestCollectionOptions {
/** Name of the collection */
name: string;
/** Items to generate with specific names and sequences */
items: SortingTestItem[];
/** Collection format */
format: CollectionFormat;
}
/**
* Generate a collection specifically for sorting tests.
* Allows specifying exact item names and sequence numbers.
*/
export async function generateSortingTestCollection(
options: SortingTestCollectionOptions
): Promise<GeneratedCollection> {
const { name, items, format } = options;
// Create temp directory
const tempDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'bruno-test-sorting-')
);
try {
// Generate collection root files
await generateCollectionRoot(tempDir, name, format);
// Create each request with specified name and sequence
for (const item of items) {
await createSortingTestRequest(tempDir, item, format);
}
return {
path: tempDir,
cleanup: async () => {
await fs.promises.rm(tempDir, { recursive: true, force: true });
},
name,
requestCount: items.length,
folderCount: 0,
format
};
} catch (error) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
throw error;
}
}
/**
* Create a request file for sorting tests with specific name and sequence
*/
async function createSortingTestRequest(
basePath: string,
item: SortingTestItem,
format: CollectionFormat
): Promise<void> {
const { name, seq, method = 'GET' } = item;
const ext = format === 'bru' ? 'bru' : 'yml';
const filePath = path.join(basePath, `${name}.${ext}`);
if (format === 'bru') {
const seqLine = seq !== undefined ? `\n seq: ${seq}` : '';
const content = `meta {
name: ${name}
type: http${seqLine}
}
${method.toLowerCase()} {
url: {{host}}/api/${name.toLowerCase().replace(/\s+/g, '-')}
body: none
auth: none
}
`;
await fs.promises.writeFile(filePath, content);
} else {
const seqLine = seq !== undefined ? `\n seq: ${seq}` : '';
const content = `info:
name: ${name}
type: http${seqLine}
http:
method: ${method}
url: "{{host}}/api/${name.toLowerCase().replace(/\s+/g, '-')}"
`;
await fs.promises.writeFile(filePath, content);
}
}
/**
* Set up a sorting test fixture with collection and user data directory
*/
export async function setupSortingTestFixture(
options: SortingTestCollectionOptions
): Promise<TestFixture> {
const collection = await generateSortingTestCollection(options);
const userDataPath = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'bruno-test-userdata-')
);
try {
const preferences = {
lastOpenedCollections: [collection.path],
preferences: {
onboarding: {
hasLaunchedBefore: true,
hasSeenWelcomeModal: true
}
}
};
await fs.promises.writeFile(
path.join(userDataPath, 'preferences.json'),
JSON.stringify(preferences, null, 2)
);
return {
collection,
userDataPath,
cleanup: async () => {
await Promise.all([
collection.cleanup(),
fs.promises.rm(userDataPath, { recursive: true, force: true })
]);
}
};
} catch (error) {
await Promise.all([
collection.cleanup(),
fs.promises.rm(userDataPath, { recursive: true, force: true })
]);
throw error;
}
}
/**
* Options for setting up a test fixture
*/
export interface TestFixtureOptions extends GenerateCollectionOptions {
/** Additional collections to preload (paths) */
additionalCollections?: string[];
}
/**
* Result of setting up a test fixture
*/
export interface TestFixture {
/** Generated collection */
collection: GeneratedCollection;
/** Path to user data directory with preferences */
userDataPath: string;
/** Cleanup function to remove all generated files */
cleanup: () => Promise<void>;
}
/**
* Set up a complete test fixture with collection and user data directory.
* This creates:
* 1. A generated collection based on the provided options
* 2. A user data directory with preferences.json configured to preload the collection
*
* Use this in beforeAll to set up fixtures, and call cleanup() in afterAll.
* Note: This does NOT handle app launching or closing - that should be done separately.
*/
export async function setupTestFixture(
options: TestFixtureOptions
): Promise<TestFixture> {
const { additionalCollections = [], ...collectionOptions } = options;
// Generate the collection
const collection = await generateCollection(collectionOptions);
// Create user data directory
const userDataPath = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'bruno-test-userdata-')
);
try {
// Create preferences.json with collection preloaded
const preferences = {
lastOpenedCollections: [collection.path, ...additionalCollections],
preferences: {
onboarding: {
hasLaunchedBefore: true,
hasSeenWelcomeModal: true
}
}
};
await fs.promises.writeFile(
path.join(userDataPath, 'preferences.json'),
JSON.stringify(preferences, null, 2)
);
return {
collection,
userDataPath,
cleanup: async () => {
await Promise.all([
collection.cleanup(),
fs.promises.rm(userDataPath, { recursive: true, force: true })
]);
}
};
} catch (error) {
// Cleanup on error
await Promise.all([
collection.cleanup(),
fs.promises.rm(userDataPath, { recursive: true, force: true })
]);
throw error;
}
}
/**
* Result of setting up a static (committed) collection fixture
*/
export interface StaticTestFixture {
/** Path to the temp copy of the collection that is actually mounted */
collectionPath: string;
/** Path to user data directory with preferences */
userDataPath: string;
/** Cleanup function — removes the temp copy and user data dir; never touches the source */
cleanup: () => Promise<void>;
}
/**
* Set up a test fixture that preloads a static, committed collection from disk.
*
* Unlike setupTestFixture (which generates a throwaway collection), this uses an
* existing collection directory checked into the repo — for tests that need a
* fixed, hand-authored layout rather than generated data. The source is copied
* to a temp dir before mounting so the running app never mutates the committed
* fixture. cleanup() removes the temp copy and the user data dir.
*/
export async function setupStaticFixture(
sourceCollectionPath: string
): Promise<StaticTestFixture> {
const userDataPath = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'bruno-test-userdata-')
);
const collectionPath = await fs.promises.mkdtemp(
path.join(os.tmpdir(), 'bruno-test-collection-')
);
const cleanup = async () => {
await Promise.all([
fs.promises.rm(userDataPath, { recursive: true, force: true }),
fs.promises.rm(collectionPath, { recursive: true, force: true })
]);
};
try {
// Copy the committed collection into the temp dir so the app mounts the copy.
await fs.promises.cp(sourceCollectionPath, collectionPath, { recursive: true });
const preferences = {
lastOpenedCollections: [collectionPath],
preferences: {
onboarding: {
hasLaunchedBefore: true,
hasSeenWelcomeModal: true
}
}
};
await fs.promises.writeFile(
path.join(userDataPath, 'preferences.json'),
JSON.stringify(preferences, null, 2)
);
return { collectionPath, userDataPath, cleanup };
} catch (error) {
await cleanup();
throw error;
}
}

View File

@@ -0,0 +1 @@
export * from './generator';

View File

@@ -1,3 +1,4 @@
export * from './actions';
export * from './runner';
export * from './locators';
export * from './mounting';

View File

@@ -0,0 +1,414 @@
import { test, expect, Page, ElectronApplication } from '../../../playwright';
/**
* Collection tree item structure for assertions
*/
export type CollectionTreeItem = {
name: string;
type: 'folder' | 'request';
method?: string; // For requests: GET, POST, PUT, DELETE, etc.
items?: CollectionTreeItem[]; // For folders: nested items
};
export type CollectionTreeStructure = {
name: string;
items: CollectionTreeItem[];
};
/**
* Build locators for collection tree elements in the sidebar
*/
export const buildCollectionTreeLocators = (page: Page) => {
const collectionRow = (name: string) => page.getByTestId('sidebar-collection-row').filter({
has: page.locator('#sidebar-collection-name', { hasText: name })
});
const itemScope = (collectionName?: string) => collectionName
? collectionRow(collectionName).locator('..')
: page;
return {
/**
* Collection-level locators
*/
collection: {
/** Get collection row by name */
row: collectionRow,
/** Get collection name element */
name: (name: string) => page.locator('#sidebar-collection-name').filter({ hasText: name }),
/** Get collection chevron (expand/collapse icon) */
chevron: (name: string) => collectionRow(name).locator('.chevron-icon'),
/** Get collection loading spinner */
loadingSpinner: (name: string) => collectionRow(name).locator('.animate-spin'),
/** Check if collection is expanded (chevron rotated) */
isExpanded: async (name: string) => {
return await collectionRow(name).locator('.rotate-90').count() > 0;
}
},
/**
* Collection item (folder/request) locators
*/
item: {
/** Get item row by name (exact match) and collectionName */
row: (name: string, collectionName?: string) => itemScope(collectionName).getByTestId('sidebar-collection-item-row').filter({
has: page.locator('.item-name').getByText(name, { exact: true })
}),
/** Get item name element */
name: (name: string) => page.locator('.item-name').getByText(name, { exact: true }),
/** Get all item rows */
allRows: (collectionName?: string) => itemScope(collectionName).getByTestId('sidebar-collection-item-row'),
/** Check if a given item row is a folder (has folder chevron) */
isFolderRow: (itemRow: ReturnType<Page['locator']>) => itemRow.getByTestId('folder-chevron'),
/** Get the name text from an item row */
getNameFromRow: (itemRow: ReturnType<Page['locator']>) => itemRow.locator('.item-name').first()
},
/**
* Folder-specific locators
*/
folder: {
/** Get folder row by name (exact match) */
row: (name: string, collectionName?: string) => itemScope(collectionName).getByTestId('sidebar-collection-item-row').filter({
has: page.locator('.item-name').getByText(name, { exact: true })
}).filter({
has: page.getByTestId('folder-chevron')
}),
/** Get folder chevron (expand/collapse icon) - exact name match */
chevron: (name: string, collectionName?: string) => itemScope(collectionName).getByTestId('sidebar-collection-item-row').filter({
has: page.locator('.item-name').getByText(name, { exact: true })
}).getByTestId('folder-chevron'),
/** Check if folder is expanded (exact name match) */
isExpanded: async (name: string, collectionName?: string) => {
const row = itemScope(collectionName).getByTestId('sidebar-collection-item-row').filter({
has: page.locator('.item-name').getByText(name, { exact: true })
});
return await row.locator('.rotate-90').count() > 0;
}
},
/**
* Request-specific locators
*/
request: {
/** Get request row by name */
row: (name: string, collectionName?: string) => itemScope(collectionName).getByTestId('sidebar-collection-item-row').filter({
has: page.locator('.item-name', { hasText: name })
}).filter({
hasNot: page.getByTestId('folder-chevron')
}),
/** Get request method badge */
methodBadge: (name: string, collectionName?: string) => itemScope(collectionName).getByTestId('sidebar-collection-item-row').filter({
has: page.locator('.item-name', { hasText: name })
}).locator('.mr-1 span').first()
}
};
};
/**
* Open a collection from a filesystem path by mocking the Electron dialog
* @param page - The Playwright page object
* @param electronApp - The Electron application instance
* @param collectionPath - The absolute path to the collection directory
* @returns Promise that resolves when the collection appears in the sidebar
*/
export const openCollectionFromPath = async (
page: Page,
electronApp: ElectronApplication,
collectionPath: string
): Promise<void> => {
await test.step(`Open collection from path: ${collectionPath}`, async () => {
// Mock the electron dialog to return the collection path
await electronApp.evaluate(({ dialog }, { collectionPath }) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: [collectionPath]
});
}, { collectionPath });
// Click on plus icon button and then "Open collection" in the dropdown
await page.getByTestId('collections-header-add-menu').click();
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Open collection' }).click();
});
};
/**
* Wait for a collection to finish mounting (loading spinner disappears and items are stable)
* @param page - The Playwright page object
* @param collectionName - The name of the collection to wait for
* @param options - Optional timeout settings
*/
export const waitForCollectionMount = async (
page: Page,
collectionName: string,
options: { timeout?: number } = {}
): Promise<void> => {
const { timeout = 30000 } = options;
const locators = buildCollectionTreeLocators(page);
await test.step(`Wait for collection "${collectionName}" to finish mounting`, async () => {
// First, wait for the collection to appear in the sidebar
await expect(locators.collection.row(collectionName)).toBeVisible({ timeout });
// Wait for the loading spinner to disappear
await expect(locators.collection.loadingSpinner(collectionName)).not.toBeVisible({ timeout });
});
};
/**
* Check if a collection is currently loading
* @param page - The Playwright page object
* @param collectionName - The name of the collection to check
* @returns True if the collection is loading, false otherwise
*/
export const isCollectionLoading = async (
page: Page,
collectionName: string
): Promise<boolean> => {
const locators = buildCollectionTreeLocators(page);
return await locators.collection.loadingSpinner(collectionName).isVisible();
};
/**
* Get the loading state of a collection
* @param page - The Playwright page object
* @param collectionName - The name of the collection
* @returns Object with isLoading and isVisible flags
*/
export const getCollectionLoadingState = async (
page: Page,
collectionName: string
): Promise<{ isVisible: boolean; isLoading: boolean }> => {
const locators = buildCollectionTreeLocators(page);
const isVisible = await locators.collection.row(collectionName).isVisible();
if (!isVisible) {
return { isVisible: false, isLoading: false };
}
const isLoading = await locators.collection.loadingSpinner(collectionName).isVisible();
return { isVisible, isLoading };
};
/**
* Count the number of items (requests + folders) in a collection
* @param page - The Playwright page object
* @param collectionName - The name of the collection
* @returns The count of visible items in the collection
*/
export const getCollectionItemCount = async (
page: Page,
collectionName: string
): Promise<number> => {
const locators = buildCollectionTreeLocators(page);
// Get the parent wrapper that contains the collection and its items
const collectionWrapper = locators.collection.row(collectionName).locator('..');
// Count all collection items within this collection
const items = collectionWrapper.getByTestId('sidebar-collection-item-row');
return await items.count();
};
/**
* Get the tree structure of a collection for assertions
* @param page - The Playwright page object
* @param collectionName - The name of the collection
* @returns The collection tree structure
*/
export const getCollectionTreeStructure = async (
page: Page,
collectionName: string
): Promise<CollectionTreeStructure> => {
const locators = buildCollectionTreeLocators(page);
return await test.step(`Get tree structure for collection "${collectionName}"`, async () => {
const collectionRow = locators.collection.row(collectionName);
// Ensure collection is expanded
const isExpanded = await locators.collection.isExpanded(collectionName);
if (!isExpanded) {
await collectionRow.click();
}
// Wait for collection to finish mounting after expansion
await waitForCollectionMount(page, collectionName);
// Collection structure:
// StyledWrapper > [collection-row, children-wrapper > inner-container > items]
// Get the sibling div that contains the children (not the collection row itself)
const collectionWrapper = collectionRow.locator('..');
const childrenContainer = collectionWrapper.locator(':scope > div:not([data-testid="sidebar-collection-row"]) > div').first();
const items = await extractItemsFromContainer(page, childrenContainer, collectionName);
return {
name: collectionName,
items
};
});
};
/**
* Helper function to extract items from a container (collection or folder).
*/
async function extractItemsFromContainer(
page: Page,
container: ReturnType<Page['locator']>,
collectionName?: string
): Promise<CollectionTreeItem[]> {
const locators = buildCollectionTreeLocators(page);
const items: CollectionTreeItem[] = [];
// Get direct child StyledWrappers, each contains one item
// Structure: container > StyledWrapper > [item-row, children-div?]
const childWrappers = container.locator(':scope > div:has([data-testid="sidebar-collection-item-row"])');
const count = await childWrappers.count();
for (let i = 0; i < count; i++) {
const wrapper = childWrappers.nth(i);
const itemRow = wrapper.getByTestId('sidebar-collection-item-row').first();
const itemName = (await locators.item.getNameFromRow(itemRow).innerText()).trim();
// Check if it's a folder by looking for folder chevron within this specific row
const isFolder = await locators.item.isFolderRow(itemRow).count() > 0;
if (isFolder) {
// It's a folder - expand it via the chevron in this exact row to avoid
// matching same-named folders elsewhere in the tree.
const folderChevron = locators.item.isFolderRow(itemRow);
const rowIsExpanded = await itemRow.locator('.rotate-90').count() > 0;
if (!rowIsExpanded) {
await folderChevron.click();
await expect.poll(async () => await itemRow.locator('.rotate-90').count() > 0).toBe(true);
}
// Children are in a sibling div after the item row (within the same wrapper)
// Structure: wrapper > [item-row, children-container]
const childrenContainer = wrapper.locator(':scope > div:not([data-testid="sidebar-collection-item-row"])').first();
const hasChildren = await childrenContainer.count() > 0;
const nestedItems = hasChildren ? await extractItemsFromContainer(page, childrenContainer, collectionName) : [];
items.push({
name: itemName,
type: 'folder',
items: nestedItems
});
} else {
// It's a request - read the method badge from this exact row to avoid
// colliding with same-named requests elsewhere.
const methodBadge = itemRow.locator('.mr-1 span').first();
let method = '';
if (await methodBadge.count() > 0) {
method = (await methodBadge.innerText()).trim().toUpperCase();
}
items.push({
name: itemName,
type: 'request',
method: method || undefined
});
}
}
return items;
}
/**
* Get all environment names from the environment selector for a collection
* @param page - The Playwright page object
* @returns Array of environment names
*/
export const getEnvironmentNames = async (page: Page): Promise<string[]> => {
return await test.step('Get environment names from selector', async () => {
// Open environment selector
await page.getByTestId('environment-selector-trigger').click();
// Wait for dropdown to appear
await page.locator('.dropdown-item').first().waitFor({ state: 'visible' });
// Get all environment options (excluding "No Environment" and action items)
const envOptions = page.locator('.dropdown-item').filter({
hasNot: page.locator('[data-item-id="no-environment"]')
}).filter({
hasNot: page.locator('[data-item-id="configure"]')
});
const names: string[] = [];
const count = await envOptions.count();
for (let i = 0; i < count; i++) {
const text = await envOptions.nth(i).innerText();
if (text && text.trim() !== 'No Environment' && text.trim() !== 'Configure') {
names.push(text.trim());
}
}
// Close dropdown by clicking elsewhere
await page.keyboard.press('Escape');
return names;
});
};
/**
* Wait for a specific number of items to be loaded in a collection
* @param page - The Playwright page object
* @param collectionName - The name of the collection
* @param expectedCount - The expected number of items
* @param options - Optional timeout settings
*/
export const waitForItemCount = async (
page: Page,
collectionName: string,
expectedCount: number,
options: { timeout?: number } = {}
): Promise<void> => {
const { timeout = 30000 } = options;
const locators = buildCollectionTreeLocators(page);
await test.step(`Wait for ${expectedCount} items in collection "${collectionName}"`, async () => {
const collectionWrapper = locators.collection.row(collectionName).locator('..');
const items = collectionWrapper.getByTestId('sidebar-collection-item-row');
await expect(items).toHaveCount(expectedCount, { timeout });
});
};
/**
* Check if a collection has an error indicator on any item
* @param page - The Playwright page object
* @param collectionName - The name of the collection
* @returns True if any item has an error indicator
*/
export const hasErrorItems = async (page: Page, collectionName: string): Promise<boolean> => {
const locators = buildCollectionTreeLocators(page);
const collectionWrapper = locators.collection.row(collectionName).locator('..');
// Look for error indicators (typically a red icon or error class)
const errorIndicators = collectionWrapper.locator('.item-error, .error-indicator, [class*="error"]');
return await errorIndicators.count() > 0;
};
/**
* Get names of items with errors in a collection
* @param page - The Playwright page object
* @param collectionName - The name of the collection
* @returns Array of item names that have errors
*/
export const getErrorItemNames = async (page: Page, collectionName: string): Promise<string[]> => {
const locators = buildCollectionTreeLocators(page);
const collectionWrapper = locators.collection.row(collectionName).locator('..');
const errorItems = collectionWrapper.getByTestId('sidebar-collection-item-row').filter({
has: page.locator('.item-error, .error-indicator, [class*="error"]')
});
const names: string[] = [];
const count = await errorItems.count();
for (let i = 0; i < count; i++) {
const name = await errorItems.nth(i).locator('.item-name').innerText();
names.push(name.trim());
}
return names;
};