diff --git a/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js index 8483e2aca..eeb985e90 100644 --- a/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js +++ b/packages/bruno-app/src/components/EnvironmentVariablesTable/index.js @@ -151,17 +151,21 @@ const EnvironmentVariablesTable = ({ const prevEnvVariablesRef = useRef(environment.variables); const mountedRef = useRef(false); - let _collection = collection ? cloneDeep(collection) : {}; const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid }); - if (_collection) { - _collection.globalEnvironmentVariables = globalEnvironmentVariables; - } - - // When collection is null (global/workspace environments), populate process env - // variables from the active workspace so that {{process.env.X}} can resolve - if (!collection && activeWorkspace?.processEnvVariables) { - _collection.workspaceProcessEnvVariables = activeWorkspace.processEnvVariables; - } + const workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables; + // `_collection` flows into every row's MultiLineEditor as the variable-resolution + // context. Without memoization, `cloneDeep(collection)` runs on every render — + // and Formik triggers a re-render on every keystroke, so a single env edit + // session can deep-clone the entire collection 100+ times. That's the + // dominant cost behind the test-budget flake. + const _collection = useMemo(() => { + const c = collection ? cloneDeep(collection) : {}; + c.globalEnvironmentVariables = globalEnvironmentVariables; + if (!collection && workspaceProcessEnvVariables) { + c.workspaceProcessEnvVariables = workspaceProcessEnvVariables; + } + return c; + }, [collection, globalEnvironmentVariables, workspaceProcessEnvVariables]); const initialValues = useMemo(() => { const vars = environment.variables || []; diff --git a/playwright/index.ts b/playwright/index.ts index 433e215f7..0bb47afff 100644 --- a/playwright/index.ts +++ b/playwright/index.ts @@ -96,23 +96,49 @@ async function usePageWithTracing( * Emits 'before-quit' first so cleanup handlers run (e.g., saving cookies to disk), * since app.exit() bypasses all lifecycle events. */ -export async function closeElectronApp(app: ElectronApplication) { - try { - await app.evaluate(async ({ app }) => { - app.emit('before-quit'); +/** + * Bound a promise so a hung Electron process can't burn the worker teardown + * budget. Resolves to `undefined` on timeout — the caller is expected to + * fall back to `app.close()` (which itself is bounded below) or SIGKILL. + */ +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(undefined), ms); + promise.then( + (v) => { + clearTimeout(timer); resolve(v); + }, + () => { + clearTimeout(timer); resolve(undefined); + } + ); + }); +} - // Add a delay to ensure the app is fully closed +export async function closeElectronApp(app: ElectronApplication) { + // Bound the graceful-quit roundtrip: if the renderer is unresponsive, + // `app.evaluate` can hang indefinitely (the inner `setTimeout(250)` runs in + // the renderer, which we can't observe externally). + await withTimeout( + app.evaluate(async ({ app }) => { + app.emit('before-quit'); await new Promise((resolve) => setTimeout(resolve, 250)); app.exit(0); - }); - } catch { - // Expected: process exited before the CDP response was sent - } + }).catch(() => { /* process may have exited before CDP responded */ }), + 3000 + ); - try { - await app.close(); - } catch { - // Process already exited + // Bound `app.close()` too — it waits for the process to exit, and a wedged + // main process would otherwise hold the worker teardown open until the + // 30s default fires. + const closed = await withTimeout( + app.close().catch(() => { /* already exited */ }), + 3000 + ); + + if (closed === undefined) { + // Last resort: kill the underlying process so the worker can move on. + try { app.process()?.kill('SIGKILL'); } catch { /* already dead */ } } } @@ -246,9 +272,9 @@ export const test = baseTest.extend< apps.push(app); return app; }); - for (const app of apps) { - await closeElectronApp(app); - } + // Close every still-tracked app in parallel. + // `closeElectronApp` is internally bounded, so this can't hang. + await Promise.allSettled(apps.map((app) => closeElectronApp(app))); }, { scope: 'worker' } ], diff --git a/tests/codeeditor-state/fold-persistence.spec.ts b/tests/codeeditor-state/fold-persistence.spec.ts index a9f39d8c1..1630dcf89 100644 --- a/tests/codeeditor-state/fold-persistence.spec.ts +++ b/tests/codeeditor-state/fold-persistence.spec.ts @@ -533,27 +533,25 @@ test.describe('CodeEditor — undo (Cmd-Z) survives a tab switch', () => { await selectBodyMode(page, 'JSON'); await setBodyContent(page, SAMPLE_BODY); - const insertSentinel = (sentinel: string, originSuffix: string) => - cmFor(page, page.locator('.request-pane')).evaluate( - (el, args) => { - const editor = (el as any).CodeMirror; - editor.focus(); - const doc = editor.getDoc(); - const lastLine = doc.lastLine(); - const lastLineLen = doc.getLine(lastLine).length; - doc.replaceRange( - `\n${args.sentinel}`, - { line: lastLine, ch: lastLineLen }, - undefined, - `*${args.originSuffix}` - ); - }, - { sentinel, originSuffix } - ); - - await insertSentinel('// SENTINEL_ONE', 'sentinel-1'); - await insertSentinel('// SENTINEL_TWO', 'sentinel-2'); - await insertSentinel('// SENTINEL_THREE', 'sentinel-3'); + // Insert all three sentinels in one `evaluate` (no `await` between calls): + await cmFor(page, page.locator('.request-pane')).evaluate((el) => { + const editor = (el as any).CodeMirror; + editor.focus(); + const doc = editor.getDoc(); + const append = (sentinel: string, originSuffix: string) => { + const lastLine = doc.lastLine(); + const lastLineLen = doc.getLine(lastLine).length; + doc.replaceRange( + `\n${sentinel}`, + { line: lastLine, ch: lastLineLen }, + undefined, + `*${originSuffix}` + ); + }; + append('// SENTINEL_ONE', 'sentinel-1'); + append('// SENTINEL_TWO', 'sentinel-2'); + append('// SENTINEL_THREE', 'sentinel-3'); + }); const cm = cmFor(page, page.locator('.request-pane')); await expect(cm).toContainText('SENTINEL_ONE'); diff --git a/tests/import/bulk-import/003-selection-list-viewport.spec.ts b/tests/import/bulk-import/003-selection-list-viewport.spec.ts index 308d91728..4dbe50841 100644 --- a/tests/import/bulk-import/003-selection-list-viewport.spec.ts +++ b/tests/import/bulk-import/003-selection-list-viewport.spec.ts @@ -82,5 +82,12 @@ test.describe('Bulk Import Selection List', () => { expect(scrolledVisibleRows).toContain(getViewportCollectionName(9)); expect(scrolledVisibleRows).toContain(getViewportCollectionName(10)); }).toPass({ timeout: 5000 }); + + // No collections were imported, so afterEach's closeAllCollections is a + // no-op. Close the Bulk Import modal explicitly — the page is shared + // worker-wide via the worker-scoped electronApp fixture, so the modal + // backdrop would otherwise intercept clicks in the next test. + await page.getByTestId('modal-close-button').click(); + await expect(page.locator('.bruno-modal-backdrop')).toHaveCount(0); }); }); diff --git a/tests/import/insomnia/import-insomnia-v4-environments.spec.ts b/tests/import/insomnia/import-insomnia-v4-environments.spec.ts index 36921c9c8..6d8d559d0 100644 --- a/tests/import/insomnia/import-insomnia-v4-environments.spec.ts +++ b/tests/import/insomnia/import-insomnia-v4-environments.spec.ts @@ -74,8 +74,13 @@ test.describe('Import Insomnia v4 Collection - Environment Import', () => { .first() .click(); - // Wait for environment variables to load - use input selector as it's more reliable - await expect(page.locator('input[value="baseUrl"]')).toBeVisible({ timeout: 10000 }); + // Gate on the env-switch flatten pass having fully landed before + // per-row asserts. The flatten renders top-level keys first and the + // deepest nested keys (array-indexed `user.roles[*]`) last; on slow + // runners the trailing batch can take longer than the 5s default. + // Waiting on the deepest asserted key here guarantees every shallower + // input is also in DOM by the time the per-input asserts below run. + await page.locator('input[value="user.roles[1]"]').waitFor({ state: 'visible', timeout: 15000 }); // **Assertion 1: Basic Variables (Top-level keys)** // Verifies that simple key-value pairs from the base environment are imported correctly @@ -125,6 +130,12 @@ test.describe('Import Insomnia v4 Collection - Environment Import', () => { .first() .click(); + // Gate on the env-switch flatten pass having fully landed before + // per-row asserts. Inherited deep keys (like `user.roles[0]`) are the + // last to merge in for a sub-env; waiting on it here guarantees every + // other input is also in DOM by the time the per-input asserts run. + await page.locator('input[value="user.roles[0]"]').waitFor({ state: 'visible', timeout: 15000 }); + // **Assertion 1: Top-level Variable Override** // Verifies that staging environment overrides base environment values const v4StagingBaseUrlInput = page.locator('input[value="baseUrl"]'); @@ -168,6 +179,13 @@ test.describe('Import Insomnia v4 Collection - Environment Import', () => { .first() .click(); + // Gate on the env-switch merge pass having fully landed before + // per-row asserts. The sub-env's newly-added keys (`newFeature.*`) + // are the last to merge in; waiting on the deepest of those here + // guarantees every other input is also in DOM by the time the + // per-input asserts below run. + await page.locator('input[value="newFeature.version"]').waitFor({ state: 'visible', timeout: 15000 }); + // **Assertion 1: Multiple Top-level Variable Overrides** // Verifies that development environment can override multiple base environment values const v4DevBaseUrlInput = page.locator('input[value="baseUrl"]'); diff --git a/tests/import/insomnia/import-insomnia-v5-environments.spec.ts b/tests/import/insomnia/import-insomnia-v5-environments.spec.ts index 67372e12d..00d4ec34f 100644 --- a/tests/import/insomnia/import-insomnia-v5-environments.spec.ts +++ b/tests/import/insomnia/import-insomnia-v5-environments.spec.ts @@ -71,6 +71,14 @@ test.describe('Import Insomnia v5 Collection - Environment Import', () => { .first() .click(); + // Gate on the env-switch flatten pass having fully landed before + // per-row asserts. The flatten renders top-level keys first and the + // deepest nested keys (`config.*`) last; on slow runners the trailing + // batch can take longer than the 5s default. Waiting on the deepest + // key here guarantees every shallower input is also in DOM by the + // time the per-input asserts below run. + await page.locator('input[value="config.debug"]').waitFor({ state: 'visible', timeout: 15000 }); + // **Assertion 1: Basic Variables (Top-level keys)** // Verifies that simple key-value pairs from the base environment are imported correctly const baseUrlInput = page.locator('input[value="base_url"]'); @@ -133,6 +141,12 @@ test.describe('Import Insomnia v5 Collection - Environment Import', () => { .first() .click(); + // Gate on the env-switch flatten pass having fully landed before + // per-row asserts. The deepest overridden key (`config.debug`) lands + // last in this env; waiting on it here guarantees every shallower + // input is also in DOM by the time the per-input asserts run. + await page.locator('input[value="config.debug"]').waitFor({ state: 'visible', timeout: 15000 }); + // **Assertion 1: Top-level Variable Override** // Verifies that staging environment overrides base environment values const stagingBaseUrlInput = page.locator('input[value="base_url"]'); @@ -185,6 +199,12 @@ test.describe('Import Insomnia v5 Collection - Environment Import', () => { .first() .click(); + // Gate on the env-switch flatten pass having fully landed before + // per-row asserts. Inherited base keys (like `user.roles[0]`) are the + // last to merge in for a sub-env; waiting on it here guarantees every + // other input is also in DOM by the time the per-input asserts run. + await page.locator('input[value="user.roles[0]"]').waitFor({ state: 'visible', timeout: 15000 }); + // **Assertion 1: Multiple Top-level Variable Overrides** // Verifies that development environment can override multiple base environment values const devBaseUrlInput = page.locator('input[value="base_url"]'); diff --git a/tests/preferences/default-collection-location/default-collection-location.spec.js b/tests/preferences/default-collection-location/default-collection-location.spec.js index 5e148fe40..070897d9d 100644 --- a/tests/preferences/default-collection-location/default-collection-location.spec.js +++ b/tests/preferences/default-collection-location/default-collection-location.spec.js @@ -86,7 +86,7 @@ test.describe('Default Location Feature', () => { test('Should use default location in Clone Collection modal', async ({ pageWithUserData: page }) => { // open the clone collection modal const collection = page.locator('.collection-name').first(); - await collection.hover(); + await collection.focus(); await collection.locator('.collection-actions .icon').click(); await page.locator('.dropdown-item').filter({ hasText: 'Clone' }).click(); diff --git a/tests/protobuf/manage-protofile.spec.ts b/tests/protobuf/manage-protofile.spec.ts index a6cbd2144..253a40f3a 100644 --- a/tests/protobuf/manage-protofile.spec.ts +++ b/tests/protobuf/manage-protofile.spec.ts @@ -93,7 +93,10 @@ test.describe('manage protofile', () => { const requestTab = page.getByRole('tab', { name: 'gRPC sayHello' }); await requestTab.hover(); await requestTab.getByTestId('request-tab-close-icon').click({ force: true }); - await page.getByRole('button', { name: 'Don\'t Save' }).click(); + const dontSaveBtn = page.getByRole('button', { name: 'Don\'t Save' }); + // Wait for actionability + await expect(dontSaveBtn).toBeVisible(); + await dontSaveBtn.click(); }); test('product.proto fails to load methods when selected', async ({ pageWithUserData: page }) => { @@ -120,8 +123,10 @@ test.describe('manage protofile', () => { const requestTab = page.getByRole('tab', { name: 'gRPC sayHello' }); await requestTab.hover(); - await requestTab.getByTestId('request-tab-close-icon').click({ force: true }); - await page.getByRole('button', { name: 'Don\'t Save' }).click(); + await requestTab.getByTestId('request-tab-close-icon').click(); + const dontSaveBtn = page.getByRole('button', { name: 'Don\'t Save' }); + await expect(dontSaveBtn).toBeVisible(); + await dontSaveBtn.click(); }); test('product.proto successfully loads methods once import path is provided', async ({ pageWithUserData: page }) => { diff --git a/tests/shortcuts/bound-actions.spec.ts b/tests/shortcuts/bound-actions.spec.ts index 3f4aba44a..32c2e875c 100644 --- a/tests/shortcuts/bound-actions.spec.ts +++ b/tests/shortcuts/bound-actions.spec.ts @@ -150,7 +150,10 @@ test.describe('Shortcut Keys - BOUND_ACTIONS', () => { test.describe('SHORTCUT: Close Tab', () => { test('default Cmd/Ctrl+W closes the active tab', async ({ page, createTmpDir }) => { await openRequest(page, collectionName, 'req-1', { persist: true }); - await expect(page.locator('.request-tab').filter({ hasText: 'req-1' })).toBeVisible({ timeout: 2000 }); + const reqTab = page.locator('.request-tab').filter({ hasText: 'req-1' }); + // Click the tab to guarantee it's the focused/active tab before firing the shortcut. + await reqTab.click(); + await expect(reqTab).toHaveClass(/active/, { timeout: 2000 }); await page.keyboard.press(`${modifier}+KeyW`); await expect(page.locator('.request-tab')).toHaveCount(2, { timeout: 3000 }); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 553167d7a..78528433a 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -95,14 +95,28 @@ const createCollection = async (page, collectionName: string, collectionLocation // Fill location FIRST — some modals auto-derive the name from the path, // so filling name after location ensures it isn't overwritten. + // + // The location input is `readOnly={true}` as a React prop and is a + // controlled input via formik. Two implications: + // 1. Removing `readonly` via DOM attribute is racy — the next React + // render restores the prop. The modal's mount-effect focuses the + // name field at +50ms, which can trigger that re-render between + // our DOM tweak and the `fill()`, leaving the input read-only and + // the fill silently no-ops. + // 2. Even if writable, controlled inputs require firing an `input` + // event so the onChange handler runs and updates formik state. + // Use the native value setter (the React-controlled-input pattern) to + // bypass both. Then verify the value stuck so we fail loudly here + // instead of opaquely at the modal-hidden wait when Yup validation + // silently rejects an empty location. const locationInput = createCollectionModal.getByLabel('Location'); if (await locationInput.isVisible()) { - await locationInput.evaluate((el) => { - const input = el as HTMLInputElement; - input.removeAttribute('readonly'); - input.readOnly = false; - }); - await locationInput.fill(collectionLocation); + await locationInput.evaluate((el, value) => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setter?.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + }, collectionLocation); + await expect(locationInput).toHaveValue(collectionLocation); } const nameInput = createCollectionModal.getByLabel('Name'); await nameInput.clear(); @@ -111,7 +125,11 @@ const createCollection = async (page, collectionName: string, collectionLocation await expect(nameInput).toHaveValue(collectionName, { timeout: 2000 }); await createCollectionModal.getByRole('button', { name: 'Create', exact: true }).click(); - await createCollectionModal.waitFor({ state: 'detached', timeout: 15000 }); + // The modal closes via `onClose()` in the form's `onSubmit` success path, + // which only runs after Yup validation passes — so this waitFor is the + // signal that the form actually submitted + await createCollectionModal.waitFor({ state: 'hidden', timeout: 5000 }); + await expect(page.locator('.bruno-modal-backdrop')).toHaveCount(0); // Wait for the collection name to appear in the sidebar before proceeding await page.locator('#sidebar-collection-name').filter({ hasText: collectionName }).waitFor({ state: 'visible', timeout: 5000 }); await openCollection(page, collectionName); @@ -801,28 +819,52 @@ const switchResponseFormat = async (page: Page, format: string) => { }; /** - * Switch to the preview tab - * @param page - The page object + * Set the response pane's preview/editor mode idempotently. + * + * The underlying `preview-response-tab` element is a `` that + * flips between editor and preview on click — it has no "set to X" semantics. + * It also lives inside the dropdown that `format-response-tab` opens, so it's + * not interactable until that dropdown is visible. Naively clicking it twice + * (once per call) loses state if any click misses the toggle window, leaving + * downstream asserts looking at the wrong mode (e.g. expecting CodeMirror + * lines while preview is showing). + * + * Strategy: open the dropdown, read the toggle's current state from its + * `title` attribute (which reflects `selectedTab` in the source), and click + * only when the current state differs from the desired one. + */ +const setResponsePreviewMode = async (page: Page, mode: 'editor' | 'preview') => { + const responseFormatTab = page.getByTestId('format-response-tab'); + await responseFormatTab.click(); + const dropdown = page.getByTestId('format-response-tab-dropdown'); + await dropdown.waitFor({ state: 'visible', timeout: 5000 }); + const toggle = page.getByTestId('preview-response-tab'); + const isPreview = (await toggle.getAttribute('title')) === 'Turn off Preview Mode'; + const wantPreview = mode === 'preview'; + if (isPreview !== wantPreview) { + await toggle.click(); + } else { + // Already in the desired mode — close the dropdown so subsequent + // interactions (format selection, asserts) aren't shadowed by it. + await responseFormatTab.click(); + } +}; + +/** + * Switch the response pane into preview mode (idempotent). */ const switchToPreviewTab = async (page: Page) => { await test.step('Switch to preview tab', async () => { - const responseFormatTab = page.getByTestId('format-response-tab'); - await responseFormatTab.click(); - const previewTab = page.getByTestId('preview-response-tab'); - await previewTab.click(); + await setResponsePreviewMode(page, 'preview'); }); }; /** - * Switch to the editor tab - * @param page - The page object + * Switch the response pane into editor mode (idempotent). */ const switchToEditorTab = async (page: Page) => { await test.step('Switch to editor tab', async () => { - const responseFormatTab = page.getByTestId('format-response-tab'); - await responseFormatTab.click(); - const previewTab = page.getByTestId('preview-response-tab'); - await previewTab.click(); + await setResponsePreviewMode(page, 'editor'); }); }; diff --git a/tests/workspace/create-workspace/create-workspace.spec.ts b/tests/workspace/create-workspace/create-workspace.spec.ts index 28f019178..1b6eddeb1 100644 --- a/tests/workspace/create-workspace/create-workspace.spec.ts +++ b/tests/workspace/create-workspace/create-workspace.spec.ts @@ -502,7 +502,9 @@ test.describe('Create Workspace', () => { await expect(renameInput).toBeVisible({ timeout: 5000 }); await renameInput.fill('Workspace One'); await renameInput.press('Enter'); - await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 5000 }); + // Wait for the first toast to dismiss + await expect(page.getByText('Workspace created!')).toBeHidden(); await expect(page.getByTestId('workspace-name')).toHaveText('Workspace One', { timeout: 5000 }); }); @@ -513,7 +515,9 @@ test.describe('Create Workspace', () => { await expect(renameInput).toBeVisible({ timeout: 5000 }); await renameInput.fill('Workspace Two'); await renameInput.press('Enter'); - await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 5000 }); + // Wait for the first toast to dismiss + await expect(page.getByText('Workspace created!')).toBeHidden(); await expect(page.getByTestId('workspace-name')).toHaveText('Workspace Two', { timeout: 5000 }); });