From b9ee1ee5233b98ea3ed0c84e1f7a3fc82934d814 Mon Sep 17 00:00:00 2001 From: Chirag Chandrashekhar Date: Mon, 8 Jun 2026 20:57:33 +0530 Subject: [PATCH] test(core): current mount pipeline (#7466) --- .../method-indicators/bru/DELETE-Request.bru | 11 + .../method-indicators/bru/GET-Request.bru | 11 + .../method-indicators/bru/PATCH-Request.bru | 11 + .../method-indicators/bru/POST-Request.bru | 11 + .../method-indicators/bru/PUT-Request.bru | 11 + .../fixtures/method-indicators/bru/bruno.json | 5 + .../method-indicators/bru/collection.bru | 7 + .../method-indicators/yml/DELETE-Request.yml | 8 + .../method-indicators/yml/GET-Request.yml | 8 + .../method-indicators/yml/PATCH-Request.yml | 8 + .../method-indicators/yml/POST-Request.yml | 8 + .../method-indicators/yml/PUT-Request.yml | 8 + .../fixtures/method-indicators/yml/bruno.json | 5 + .../method-indicators/yml/opencollection.yml | 3 + tests/mounting/item-sorting.spec.ts | 116 +++ tests/mounting/loading-state.spec.ts | 69 ++ tests/mounting/tree-construction.spec.ts | 172 +++++ tests/utils/fixtures/generator.ts | 681 ++++++++++++++++++ tests/utils/fixtures/index.ts | 1 + tests/utils/page/index.ts | 1 + tests/utils/page/mounting.ts | 414 +++++++++++ 21 files changed, 1569 insertions(+) create mode 100644 tests/mounting/fixtures/method-indicators/bru/DELETE-Request.bru create mode 100644 tests/mounting/fixtures/method-indicators/bru/GET-Request.bru create mode 100644 tests/mounting/fixtures/method-indicators/bru/PATCH-Request.bru create mode 100644 tests/mounting/fixtures/method-indicators/bru/POST-Request.bru create mode 100644 tests/mounting/fixtures/method-indicators/bru/PUT-Request.bru create mode 100644 tests/mounting/fixtures/method-indicators/bru/bruno.json create mode 100644 tests/mounting/fixtures/method-indicators/bru/collection.bru create mode 100644 tests/mounting/fixtures/method-indicators/yml/DELETE-Request.yml create mode 100644 tests/mounting/fixtures/method-indicators/yml/GET-Request.yml create mode 100644 tests/mounting/fixtures/method-indicators/yml/PATCH-Request.yml create mode 100644 tests/mounting/fixtures/method-indicators/yml/POST-Request.yml create mode 100644 tests/mounting/fixtures/method-indicators/yml/PUT-Request.yml create mode 100644 tests/mounting/fixtures/method-indicators/yml/bruno.json create mode 100644 tests/mounting/fixtures/method-indicators/yml/opencollection.yml create mode 100644 tests/mounting/item-sorting.spec.ts create mode 100644 tests/mounting/loading-state.spec.ts create mode 100644 tests/mounting/tree-construction.spec.ts create mode 100644 tests/utils/fixtures/generator.ts create mode 100644 tests/utils/fixtures/index.ts create mode 100644 tests/utils/page/mounting.ts diff --git a/tests/mounting/fixtures/method-indicators/bru/DELETE-Request.bru b/tests/mounting/fixtures/method-indicators/bru/DELETE-Request.bru new file mode 100644 index 000000000..a813919ce --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/bru/DELETE-Request.bru @@ -0,0 +1,11 @@ +meta { + name: DELETE-Request + type: http + seq: 4 +} + +delete { + url: {{host}}/api/resource + body: none + auth: none +} diff --git a/tests/mounting/fixtures/method-indicators/bru/GET-Request.bru b/tests/mounting/fixtures/method-indicators/bru/GET-Request.bru new file mode 100644 index 000000000..328c67716 --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/bru/GET-Request.bru @@ -0,0 +1,11 @@ +meta { + name: GET-Request + type: http + seq: 1 +} + +get { + url: {{host}}/api/resource + body: none + auth: none +} diff --git a/tests/mounting/fixtures/method-indicators/bru/PATCH-Request.bru b/tests/mounting/fixtures/method-indicators/bru/PATCH-Request.bru new file mode 100644 index 000000000..103f55926 --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/bru/PATCH-Request.bru @@ -0,0 +1,11 @@ +meta { + name: PATCH-Request + type: http + seq: 5 +} + +patch { + url: {{host}}/api/resource + body: none + auth: none +} diff --git a/tests/mounting/fixtures/method-indicators/bru/POST-Request.bru b/tests/mounting/fixtures/method-indicators/bru/POST-Request.bru new file mode 100644 index 000000000..43c81c8d4 --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/bru/POST-Request.bru @@ -0,0 +1,11 @@ +meta { + name: POST-Request + type: http + seq: 2 +} + +post { + url: {{host}}/api/resource + body: none + auth: none +} diff --git a/tests/mounting/fixtures/method-indicators/bru/PUT-Request.bru b/tests/mounting/fixtures/method-indicators/bru/PUT-Request.bru new file mode 100644 index 000000000..0e5b18315 --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/bru/PUT-Request.bru @@ -0,0 +1,11 @@ +meta { + name: PUT-Request + type: http + seq: 3 +} + +put { + url: {{host}}/api/resource + body: none + auth: none +} diff --git a/tests/mounting/fixtures/method-indicators/bru/bruno.json b/tests/mounting/fixtures/method-indicators/bru/bruno.json new file mode 100644 index 000000000..6a7c33e68 --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/bru/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "Method Indicators", + "type": "collection" +} diff --git a/tests/mounting/fixtures/method-indicators/bru/collection.bru b/tests/mounting/fixtures/method-indicators/bru/collection.bru new file mode 100644 index 000000000..adc7d8955 --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/bru/collection.bru @@ -0,0 +1,7 @@ +meta { + name: Method Indicators +} + +auth { + mode: none +} diff --git a/tests/mounting/fixtures/method-indicators/yml/DELETE-Request.yml b/tests/mounting/fixtures/method-indicators/yml/DELETE-Request.yml new file mode 100644 index 000000000..2e50223cd --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/yml/DELETE-Request.yml @@ -0,0 +1,8 @@ +info: + name: DELETE-Request + type: http + seq: 4 + +http: + method: DELETE + url: "{{host}}/api/resource" diff --git a/tests/mounting/fixtures/method-indicators/yml/GET-Request.yml b/tests/mounting/fixtures/method-indicators/yml/GET-Request.yml new file mode 100644 index 000000000..a6c80163d --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/yml/GET-Request.yml @@ -0,0 +1,8 @@ +info: + name: GET-Request + type: http + seq: 1 + +http: + method: GET + url: "{{host}}/api/resource" diff --git a/tests/mounting/fixtures/method-indicators/yml/PATCH-Request.yml b/tests/mounting/fixtures/method-indicators/yml/PATCH-Request.yml new file mode 100644 index 000000000..5d5c11e0d --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/yml/PATCH-Request.yml @@ -0,0 +1,8 @@ +info: + name: PATCH-Request + type: http + seq: 5 + +http: + method: PATCH + url: "{{host}}/api/resource" diff --git a/tests/mounting/fixtures/method-indicators/yml/POST-Request.yml b/tests/mounting/fixtures/method-indicators/yml/POST-Request.yml new file mode 100644 index 000000000..40b208116 --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/yml/POST-Request.yml @@ -0,0 +1,8 @@ +info: + name: POST-Request + type: http + seq: 2 + +http: + method: POST + url: "{{host}}/api/resource" diff --git a/tests/mounting/fixtures/method-indicators/yml/PUT-Request.yml b/tests/mounting/fixtures/method-indicators/yml/PUT-Request.yml new file mode 100644 index 000000000..b51200b17 --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/yml/PUT-Request.yml @@ -0,0 +1,8 @@ +info: + name: PUT-Request + type: http + seq: 3 + +http: + method: PUT + url: "{{host}}/api/resource" diff --git a/tests/mounting/fixtures/method-indicators/yml/bruno.json b/tests/mounting/fixtures/method-indicators/yml/bruno.json new file mode 100644 index 000000000..6a7c33e68 --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/yml/bruno.json @@ -0,0 +1,5 @@ +{ + "version": "1", + "name": "Method Indicators", + "type": "collection" +} diff --git a/tests/mounting/fixtures/method-indicators/yml/opencollection.yml b/tests/mounting/fixtures/method-indicators/yml/opencollection.yml new file mode 100644 index 000000000..d34d79aad --- /dev/null +++ b/tests/mounting/fixtures/method-indicators/yml/opencollection.yml @@ -0,0 +1,3 @@ +opencollection: "1.0.0" +info: + name: Method Indicators diff --git a/tests/mounting/item-sorting.spec.ts b/tests/mounting/item-sorting.spec.ts new file mode 100644 index 000000000..bcc71dcf9 --- /dev/null +++ b/tests/mounting/item-sorting.spec.ts @@ -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' + ]); + }); + }); + }); +} diff --git a/tests/mounting/loading-state.spec.ts b/tests/mounting/loading-state.spec.ts new file mode 100644 index 000000000..14a59cb29 --- /dev/null +++ b/tests/mounting/loading-state.spec.ts @@ -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); + }); + }); +} diff --git a/tests/mounting/tree-construction.spec.ts b/tests/mounting/tree-construction.spec.ts new file mode 100644 index 000000000..af325f522 --- /dev/null +++ b/tests/mounting/tree-construction.spec.ts @@ -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'])); + }); + }); +} diff --git a/tests/utils/fixtures/generator.ts b/tests/utils/fixtures/generator.ts new file mode 100644 index 000000000..2fa664d46 --- /dev/null +++ b/tests/utils/fixtures/generator.ts @@ -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; + /** 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 { + 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 { + // 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 { + 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 { + 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 { + const distribution: Record = {}; + + // 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 { + // 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 +): Promise<{ + bru: GeneratedCollection; + yml: GeneratedCollection; + cleanup: () => Promise; +}> { + 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 { + 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 { + 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 { + 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; +} + +/** + * 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 { + 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; +} + +/** + * 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 { + 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; + } +} diff --git a/tests/utils/fixtures/index.ts b/tests/utils/fixtures/index.ts new file mode 100644 index 000000000..95bc5ff24 --- /dev/null +++ b/tests/utils/fixtures/index.ts @@ -0,0 +1 @@ +export * from './generator'; diff --git a/tests/utils/page/index.ts b/tests/utils/page/index.ts index 5eecb558f..341ec9a4a 100644 --- a/tests/utils/page/index.ts +++ b/tests/utils/page/index.ts @@ -1,3 +1,4 @@ export * from './actions'; export * from './runner'; export * from './locators'; +export * from './mounting'; diff --git a/tests/utils/page/mounting.ts b/tests/utils/page/mounting.ts new file mode 100644 index 000000000..57f320713 --- /dev/null +++ b/tests/utils/page/mounting.ts @@ -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) => itemRow.getByTestId('folder-chevron'), + /** Get the name text from an item row */ + getNameFromRow: (itemRow: ReturnType) => 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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, + collectionName?: string +): Promise { + 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 => { + 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 => { + 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 => { + 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 => { + 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; +};