mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
test(core): current mount pipeline (#7466)
This commit is contained in:
committed by
GitHub
parent
2d4d4e4037
commit
b9ee1ee523
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: DELETE-Request
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
delete {
|
||||
url: {{host}}/api/resource
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: GET-Request
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{host}}/api/resource
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: PATCH-Request
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
patch {
|
||||
url: {{host}}/api/resource
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: POST-Request
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{host}}/api/resource
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: PUT-Request
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
put {
|
||||
url: {{host}}/api/resource
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
5
tests/mounting/fixtures/method-indicators/bru/bruno.json
Normal file
5
tests/mounting/fixtures/method-indicators/bru/bruno.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "Method Indicators",
|
||||
"type": "collection"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
meta {
|
||||
name: Method Indicators
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: none
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
info:
|
||||
name: DELETE-Request
|
||||
type: http
|
||||
seq: 4
|
||||
|
||||
http:
|
||||
method: DELETE
|
||||
url: "{{host}}/api/resource"
|
||||
@@ -0,0 +1,8 @@
|
||||
info:
|
||||
name: GET-Request
|
||||
type: http
|
||||
seq: 1
|
||||
|
||||
http:
|
||||
method: GET
|
||||
url: "{{host}}/api/resource"
|
||||
@@ -0,0 +1,8 @@
|
||||
info:
|
||||
name: PATCH-Request
|
||||
type: http
|
||||
seq: 5
|
||||
|
||||
http:
|
||||
method: PATCH
|
||||
url: "{{host}}/api/resource"
|
||||
@@ -0,0 +1,8 @@
|
||||
info:
|
||||
name: POST-Request
|
||||
type: http
|
||||
seq: 2
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: "{{host}}/api/resource"
|
||||
@@ -0,0 +1,8 @@
|
||||
info:
|
||||
name: PUT-Request
|
||||
type: http
|
||||
seq: 3
|
||||
|
||||
http:
|
||||
method: PUT
|
||||
url: "{{host}}/api/resource"
|
||||
5
tests/mounting/fixtures/method-indicators/yml/bruno.json
Normal file
5
tests/mounting/fixtures/method-indicators/yml/bruno.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "Method Indicators",
|
||||
"type": "collection"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
opencollection: "1.0.0"
|
||||
info:
|
||||
name: Method Indicators
|
||||
116
tests/mounting/item-sorting.spec.ts
Normal file
116
tests/mounting/item-sorting.spec.ts
Normal 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'
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
69
tests/mounting/loading-state.spec.ts
Normal file
69
tests/mounting/loading-state.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
172
tests/mounting/tree-construction.spec.ts
Normal file
172
tests/mounting/tree-construction.spec.ts
Normal 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']));
|
||||
});
|
||||
});
|
||||
}
|
||||
681
tests/utils/fixtures/generator.ts
Normal file
681
tests/utils/fixtures/generator.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
tests/utils/fixtures/index.ts
Normal file
1
tests/utils/fixtures/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './generator';
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './actions';
|
||||
export * from './runner';
|
||||
export * from './locators';
|
||||
export * from './mounting';
|
||||
|
||||
414
tests/utils/page/mounting.ts
Normal file
414
tests/utils/page/mounting.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user