mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-28 07:04:10 +00:00
* feat(variables): add variable persistence with scripting feat(collections): implement script-driven update for collection variables, ensuring direct root modification and draft synchronization feat(collections): enhance script variable management with baseline tracking and draft preservation * feat(variables): add runtime variable updates and optimize disk writes by implementing dirty flags fix(collections): handle errors during environment persistence in script execution feat(collections): implement baseline clearing for script execution and optimize variable update handling feat(tests): add default persistence tests for environment variables and update runtime variable handling refactor(collections): streamline variable update handling and improve draft management by removing redundant comments and optimizing code clarity test(collection-vars): add verification for draft edits and script variable visibility in collection settings UI refactor(collection-vars): update header value selection logic for improved clarity and accuracy in draft isolation tests * feat(global-environments): enhance global environment updates to resolve stale active UIDs and improve persistence logic - Updated the `updateGlobalEnvironments` reducer to handle stale active UIDs by matching against environment names. - Improved the logic for setting global environments and active UIDs to ensure consistency after disk reloads. - Removed outdated tests related to persisted values in favor of more relevant assertions for environment variable handling. * feat(variables): enhance typed value handling and persistence in global and collection environments - Added tests to infer data types (number, boolean, object) when setting environment and collection variables. - Updated the logic to preserve existing data types when variables are not modified by scripts. - Implemented dirty flags to track changes in typed variables, ensuring accurate persistence across sessions. - Refactored related tests to verify the correct behavior of typed variables in various scenarios. * refactor(variables): streamline data type inference and enhance deletion methods - Removed redundant data type inference logic from global and collection variable updates to simplify the codebase. - Updated deletion methods in the Bru class to use Object.keys for improved resilience against user-defined properties. - Added tests to ensure deletion methods function correctly even when properties are shadowed. - Enhanced clarity in draft merge tests by standardizing keyboard shortcuts for selecting all text. * fix(tests): correct variable naming and improve environment panel interactions - Updated test cases to reflect the correct variable name 'wasSaved' instead of 'was-saved'. - Modified environment panel interaction to remove forced click, enhancing test reliability. - Added a utility function to close the environment panel in safe mode tests for better readability and maintainability. * feat(runtime): enhance variable management and cleanup logic - Introduced a new method to clear script-driven variable baselines for collections, ensuring no stale data leaks into new requests. - Updated the handling of runtime variables in the Bru class to track changes with a new dirty flag, improving state management. - Refactored the application of script environment variables to prevent direct mutations, ensuring immutability and cleaner state updates. - Enhanced the response handling in the script runtime to conditionally include runtime variables based on their dirty state. * feat(variables): improve request handling and state management for collections and environments - Enhanced event listeners to clear global environment baselines on both 'testrun-started' and 'request-queued' events, preventing stale data issues. - Updated global environment and collection variable update events to ignore stale updates from superseded requests, ensuring accurate state management. - Refactored the Bru class to optimize variable management, including checks for existing keys before updates and deletions, improving performance and reliability. - Introduced request UID tracking to maintain consistency across variable updates during concurrent requests. * refactor(collections): update action to clear script variable baselines - Replaced the dispatch of `_clearScriptGlobalEnvBaseline` with `clearScriptVariableBaselines` to improve clarity and maintainability in the Redux action handling for collections. * feat(environments): introduce getScriptModifiedKeys utility for improved variable management - Added a new utility function, `getScriptModifiedKeys`, to identify keys modified by scripts relative to a baseline, enhancing the handling of data types during variable updates. - Updated the application of script environment variables to prevent overwriting user-defined draft changes during no-op writes. - Refactored related logic in collections and global environments to utilize the new utility, ensuring accurate state management and improved clarity in the Redux slices. * refactor(global-environments): simplify active UID resolution logic in updateGlobalEnvironments reducer - Streamlined the logic for resolving the active global environment UID by consolidating conditions into a more concise format. - Removed outdated comments to enhance code clarity and maintainability. - Updated tests to ensure accurate resolution of active UIDs based on incoming environment data. * refactor(tests): remove outdated comments and streamline environment variable row expectations - Eliminated comments related to state sync and inference issues to enhance code clarity. - Adjusted expectations for environment variable row rendering in tests, focusing on relevant assertions. * feat(tests): add comprehensive tests for secret variable persistence in environments - Introduced new test cases to validate the preservation of secret variables when updated via scripts in both collection and global environments. - Implemented tests to ensure that secret values are encrypted before storage and can be correctly decrypted for subsequent requests. - Added fixtures and environment configurations for testing secret variable behavior in both bru and yml formats. - Enhanced utility functions for managing environment configurations and interactions within the test suite. * feat(tests): enhance environment variable tests and add global variable persistence - Updated MultiLineEditor and SingleLineEditor components to include data-testid for secret reveal toggle buttons, improving testability. - Introduced new tests for global environment variable persistence, ensuring non-secret variables survive app restarts and are correctly interpolated. - Added fixtures for workspace and collections to support the new global variable tests, enhancing the overall test coverage for environment management. - Refactored utility functions to streamline interactions with environment variables in tests. * refactor(collections): optimize environment and collection saving logic - Simplified the persistence logic for active environments by directly constructing the environment copy, reducing unnecessary cloning. - Updated the collection saving process to utilize the fresh collection state, ensuring accurate data is saved without drafts. - Enhanced error handling during the save operations to improve reliability and maintainability. * feat(tests): implement collection variable persistence tests - Added multiple test cases to validate the persistence of collection variables across app restarts, including typed values and multiple variable settings. - Created new fixtures for collection variables to support the tests, ensuring accurate simulation of variable management scenarios. - Enhanced the existing collection management logic to ensure that variables are correctly set and deleted as per the test requirements. * feat(tests): add tests for typed global environment variable persistence - Introduced a new test suite to validate the persistence of typed global environment variables across app restarts, ensuring correct data types are maintained. - Created a fixture for the test collection to simulate setting global variables with various data types, including number, boolean, object, and string. - Enhanced the test logic to verify that the environment file reflects the correct state before and after application restarts. * fix(tests): update request tab close interaction in variable persistence tests * fix(tests): improve hover interaction for collection actions in runner tests - Updated the hover logic for revealing collection actions to handle sidebar re-renders more reliably. - Replaced one-shot hover with a polling mechanism to ensure visibility of actions, enhancing test stability. * refactor(environments): streamline environment variable handling and remove ephemeral metadata logic - Simplified the comparison logic for environment variables by removing unnecessary ephemeral metadata handling. - Updated the saving process to directly use the environment variables without stripping metadata, enhancing clarity and maintainability. - Removed outdated comments and unused utility functions related to ephemeral variables, improving code cleanliness. * fix(ipc): update persistActiveEnvironment to handle requestUid for stale updates - Modified the persistActiveEnvironment function to accept a requestUid parameter, allowing for better management of stale updates. - Enhanced the logic to prevent disk writes for superseded requests, improving data integrity during environment persistence. * refactor(bru): remove unused envName variable in deleteAllEnvVars method - Eliminated the envName variable from the deleteAllEnvVars method, simplifying the logic for deleting environment variables. - Cleaned up the method by removing unnecessary checks related to the envName, enhancing code clarity and maintainability. * fix(bru): prevent deletion of internal __name__ variable in deleteEnvVar method - Added a check in the deleteEnvVar method to silently ignore attempts to delete the internal __name__ variable, preserving its integrity. - Updated tests to verify that the __name__ variable remains unchanged when deleteEnvVar is called with this key. - Enhanced runtime tests to ensure compatibility with QuickJS by confirming that environment variables set with persist options are handled correctly. * feat(tests): add legacy support test for environment variable persistence - Introduced a new test suite to validate that the legacy argument for setting environment variables with persistence is still functional in version 4. - Created a fixture to simulate the legacy syntax, ensuring that the variable is correctly persisted on disk without errors. - Enhanced integration testing to confirm that the legacy behavior aligns with the current implementation, maintaining backward compatibility. * test(tests): enhance legacy environment variable persistence tests for safe and developer modes - Updated the test suite for `bru.setEnvVar` to verify that the legacy persist flag is correctly handled in both safe and developer modes. - Introduced a helper function to streamline the verification process and ensure consistent behavior across different execution contexts. - Adjusted the test logic to reset the environment state between mode switches, maintaining test integrity. - Improved hover interaction in multiple persistent variable tests to ensure reliable visibility of actions during execution. * fix(EnvironmentVariablesTable): correct change detection logic for environment variables - Updated the logic for determining changes in environment variables to compare active current and saved values instead of previously used variablesToSave and savedValues. - This change ensures accurate detection of modifications before saving, improving user feedback when no changes are present. * test(tests): enhance secret variable persistence tests for environment configurations - Updated the test suites for `bru.setEnvVar` and `bru.setGlobalEnvVar` to include interactions with the secrets tab, ensuring visibility of secret variables during various states of the environment. - Added checks to confirm that the eye toggle functionality correctly reveals the values of secret variables after setting and overwriting them. - Improved test coverage for secret variable persistence, validating that the expected values are displayed in both collection and global environment contexts.
2301 lines
86 KiB
TypeScript
2301 lines
86 KiB
TypeScript
import { test, expect, Page, Locator, ElectronApplication, waitForReadyPage as waitForReadyPageImpl } from '../../../playwright';
|
|
import process from 'node:process';
|
|
import * as path from 'path';
|
|
import { buildCommonLocators, buildScriptErrorLocators, buildGrpcCommonLocators, buildWebsocketCommonLocators } from './locators';
|
|
import { waitForCollectionMount } from './mounting';
|
|
|
|
type SandboxMode = 'safe' | 'developer';
|
|
|
|
type CollectionFormat = 'bru' | 'yml';
|
|
|
|
type WaitForAppReadyOptions = {
|
|
timeout?: number;
|
|
};
|
|
|
|
/**
|
|
* Wait for the Electron app to have a ready, loaded window.
|
|
* Handles cases where the first window is slow to appear.
|
|
*/
|
|
const waitForReadyPage = (
|
|
app: ElectronApplication,
|
|
options: WaitForAppReadyOptions = {}
|
|
) => waitForReadyPageImpl(app, options);
|
|
|
|
/**
|
|
* Dismiss all import issues toasts (they use infinite duration and persist across tests).
|
|
* @param page - The page object
|
|
* @returns void
|
|
*/
|
|
const dismissImportIssuesToasts = async (page: Page) => {
|
|
await test.step('Dismiss import issues toasts', async () => {
|
|
const toasts = page.getByTestId('import-issues-toast');
|
|
while (await toasts.count() > 0) {
|
|
const toast = toasts.first();
|
|
await toast.getByTestId('import-issues-toast-close').click();
|
|
await expect(toast).not.toBeVisible({ timeout: 5000 });
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Close all collections
|
|
* @param page - The page object
|
|
* @returns void
|
|
*/
|
|
const closeAllCollections = async (page) => {
|
|
await test.step('Close all collections', async () => {
|
|
const numberOfCollections = await page.locator('[data-testid="collections"] .collection-name').count();
|
|
|
|
for (let i = 0; i < numberOfCollections; i++) {
|
|
const firstCollection = page.locator('[data-testid="collections"] .collection-name').first();
|
|
await firstCollection.scrollIntoViewIfNeeded();
|
|
|
|
const removeMenuItem = page.locator('.dropdown-item').getByText('Remove');
|
|
await expect(async () => {
|
|
await firstCollection.hover();
|
|
await firstCollection.locator('.collection-actions .icon').click({ force: true });
|
|
await expect(removeMenuItem).toBeVisible({ timeout: 2000 });
|
|
}).toPass({ timeout: 15000 });
|
|
await removeMenuItem.click();
|
|
|
|
// Wait for modal to appear - could be either regular remove or drafts confirmation
|
|
const removeModal = page.locator('.bruno-modal').filter({ hasText: 'Remove Collection' });
|
|
await removeModal.waitFor({ state: 'visible', timeout: 5000 });
|
|
|
|
// Check if it's the drafts confirmation modal (has "Discard All and Remove" button)
|
|
const hasDiscardButton = await page.getByRole('button', { name: 'Discard All and Remove' }).isVisible().catch(() => false);
|
|
|
|
if (hasDiscardButton) {
|
|
// Drafts modal - the modal animates in and the footer can shift mid-frame,
|
|
// causing Playwright's "element is stable" actionability check to fail
|
|
// intermittently on slower machines. Use force to skip the stability check;
|
|
// visibility is already verified above via waitFor.
|
|
await page.getByRole('button', { name: 'Discard All and Remove' }).click({ force: true });
|
|
} else {
|
|
// Regular modal - click the submit button
|
|
await page.locator('.bruno-modal-footer .submit').click();
|
|
}
|
|
|
|
// Wait for modal to close
|
|
await removeModal.waitFor({ state: 'hidden', timeout: 5000 });
|
|
}
|
|
|
|
// Wait until no collections are left open (check sidebar only)
|
|
await expect(page.getByTestId('collections').locator('.collection-name')).toHaveCount(0);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Open a collection from the sidebar and accept the JavaScript Sandbox modal
|
|
* @param page - The page object
|
|
* @param collectionName - The name of the collection to open
|
|
* @returns void
|
|
*/
|
|
const openCollection = async (page, collectionName: string) => {
|
|
await test.step(`Open collection "${collectionName}"`, async () => {
|
|
await page.locator('#sidebar-collection-name').filter({ hasText: collectionName }).click();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Create a collection
|
|
* @param page - The page object
|
|
* @param collectionName - The name of the collection to create
|
|
* @param collectionLocation - The location of the collection to create (eg)
|
|
* @param options - The options for creating the collection
|
|
*
|
|
* @returns void
|
|
*/
|
|
const createCollection = async (
|
|
page,
|
|
collectionName: string,
|
|
collectionLocation: string,
|
|
format: CollectionFormat = 'yml'
|
|
) => {
|
|
await test.step(`Create ${format} collection "${collectionName}"`, async () => {
|
|
await page.getByTestId('collections-header-add-menu').click();
|
|
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' }).click();
|
|
|
|
// Wait for inline creator to appear, then click the cog button to open advanced modal
|
|
const inlineCreator = page.locator('.inline-collection-creator');
|
|
await inlineCreator.waitFor({ state: 'visible', timeout: 5000 });
|
|
await inlineCreator.locator('.cog-btn').click();
|
|
|
|
const createCollectionModal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Collection' });
|
|
await createCollectionModal.waitFor({ state: 'visible', timeout: 5000 });
|
|
|
|
// 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, 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();
|
|
await nameInput.fill(collectionName);
|
|
// Verify the name is correct before creating
|
|
await expect(nameInput).toHaveValue(collectionName, { timeout: 2000 });
|
|
|
|
if (format) {
|
|
const advancedBtn = createCollectionModal.locator('.advanced-options .btn-advanced');
|
|
const showFileFormatToggle = page.getByTestId('show-file-format-toggle');
|
|
const formatSelect = createCollectionModal.locator('#format');
|
|
|
|
await expect(async () => {
|
|
if (!(await formatSelect.isVisible())) {
|
|
await advancedBtn.click();
|
|
await showFileFormatToggle.click({ timeout: 2000 });
|
|
}
|
|
await expect(formatSelect).toBeVisible({ timeout: 2000 });
|
|
}).toPass({ timeout: 15000 });
|
|
|
|
await formatSelect.selectOption(format);
|
|
}
|
|
|
|
await createCollectionModal.getByRole('button', { name: 'Create', exact: true }).click();
|
|
|
|
// 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);
|
|
});
|
|
};
|
|
|
|
const STANDARD_HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'TRACE', 'CONNECT'];
|
|
|
|
type CreateRequestOptions = {
|
|
url?: string;
|
|
method?: string;
|
|
inFolder?: boolean;
|
|
requestType?: 'http' | 'graphql' | 'ws' | 'grpc';
|
|
};
|
|
|
|
type CreateUntitledRequestOptions = {
|
|
requestType?: 'HTTP' | 'GraphQL' | 'WebSocket' | 'gRPC';
|
|
requestName?: string;
|
|
url?: string;
|
|
tag?: string;
|
|
};
|
|
|
|
/**
|
|
* Create an untitled request using the new dropdown flow (from tabs area)
|
|
* @param page - The page object
|
|
* @param options - Optional settings (requestType, url, tag)
|
|
* @returns void
|
|
*/
|
|
const createUntitledRequest = async (
|
|
page: Page,
|
|
options: CreateUntitledRequestOptions = {}
|
|
) => {
|
|
const { requestType = 'HTTP', url, tag } = options;
|
|
|
|
await test.step(`Create untitled ${requestType} request${url ? ' with URL' : ''}${tag ? ' with tag' : ''}`, async () => {
|
|
// Click the + icon to open the dropdown
|
|
const createButton = page.locator('.short-tab').locator('svg').first();
|
|
await createButton.waitFor({ state: 'visible' });
|
|
await createButton.click();
|
|
|
|
// Select the request type from dropdown
|
|
await page.locator('.tippy-box .dropdown-item').filter({ hasText: requestType }).waitFor({ state: 'visible' });
|
|
await page.locator('.tippy-box .dropdown-item').filter({ hasText: requestType }).click();
|
|
|
|
// Wait for the request tab to be active
|
|
await page.locator('.request-tab.active').waitFor({ state: 'visible' });
|
|
await page.waitForTimeout(300);
|
|
|
|
// Fill URL if provided
|
|
if (url) {
|
|
await page.locator('#request-url .CodeMirror').click();
|
|
await page.locator('#request-url textarea').fill(url);
|
|
await page.locator('#request-actions').getByTitle('Save Request').click();
|
|
await page.waitForTimeout(200);
|
|
}
|
|
|
|
// Add tag if provided
|
|
if (tag) {
|
|
await selectRequestPaneTab(page, 'Settings');
|
|
await page.waitForTimeout(200);
|
|
const tagInput = await page.getByTestId('tag-input').getByRole('textbox');
|
|
await tagInput.fill(tag);
|
|
await tagInput.press('Enter');
|
|
await page.waitForTimeout(200);
|
|
await expect(page.locator('.tag-item', { hasText: tag })).toBeVisible();
|
|
const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s';
|
|
await page.keyboard.press(saveShortcut);
|
|
await page.waitForTimeout(200);
|
|
}
|
|
|
|
// Wait for toast message to ensure request creation is complete
|
|
// This helps prevent race conditions when creating multiple requests
|
|
await expect(page.getByText('New request created!')).toBeVisible({ timeout: 2000 }).catch(() => {
|
|
// Toast might have already disappeared, that's okay
|
|
});
|
|
});
|
|
};
|
|
|
|
type CreateTransientRequestOptions = {
|
|
requestType?: 'HTTP' | 'GraphQL' | 'gRPC' | 'WebSocket';
|
|
};
|
|
|
|
/**
|
|
* Create a transient request using the + icon button in the tabs area
|
|
* Based on the CreateTransientRequest component behavior
|
|
* @param page - The page object
|
|
* @param options - Optional settings (requestType)
|
|
* @returns void
|
|
*/
|
|
const createTransientRequest = async (
|
|
page: Page,
|
|
options: CreateTransientRequestOptions = {}
|
|
) => {
|
|
const { requestType = 'HTTP' } = options;
|
|
|
|
await test.step(`Create transient ${requestType} request`, async () => {
|
|
// Find the + icon button (ActionIcon with aria-label="New Transient Request")
|
|
const createButton = page.getByRole('button', { name: 'New Transient Request' });
|
|
await createButton.waitFor({ state: 'visible', timeout: 5000 });
|
|
|
|
// Click the + icon to open the dropdown
|
|
await createButton.click({
|
|
button: 'right'
|
|
});
|
|
|
|
// Wait for dropdown to be visible
|
|
await page.locator('.dropdown-item').first().waitFor({ state: 'visible' });
|
|
|
|
// Select the request type from dropdown
|
|
// The dropdown items have both icon and label, we match by the label text
|
|
await page.locator('.dropdown-item').filter({ hasText: requestType }).click();
|
|
|
|
// Wait for the request tab to be active (transient requests show as "Untitled X")
|
|
await page.locator('.request-tab.active').waitFor({ state: 'visible' });
|
|
await expect(page.locator('.request-tab.active')).toContainText('Untitled');
|
|
await page.waitForTimeout(300);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Fill the URL field in the currently active request
|
|
* Works with HTTP, GraphQL, gRPC, and WebSocket requests
|
|
* @param page - The page object
|
|
* @param url - The URL to fill
|
|
* @returns void
|
|
*/
|
|
const fillRequestUrl = async (page: Page, url: string) => {
|
|
await test.step(`Fill request URL: ${url}`, async () => {
|
|
// HTTP/GraphQL requests use #request-url
|
|
// gRPC/WebSocket don't have a specific ID, so we need to find the CodeMirror in the active request pane
|
|
const httpGraphqlUrl = page.locator('#request-url .CodeMirror');
|
|
const grpcWsUrl = page.locator('.input-container .CodeMirror').first();
|
|
|
|
// Try HTTP/GraphQL selector first
|
|
const isHttpOrGraphql = await httpGraphqlUrl.isVisible().catch(() => false);
|
|
|
|
if (isHttpOrGraphql) {
|
|
await httpGraphqlUrl.click();
|
|
await page.locator('#request-url textarea').fill(url);
|
|
} else {
|
|
// Fall back to generic selector for gRPC/WebSocket
|
|
await grpcWsUrl.click();
|
|
await page.locator('.input-container textarea').first().fill(url);
|
|
}
|
|
|
|
await page.waitForTimeout(200);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Create a request in a collection or folder
|
|
* @param page - The page object
|
|
* @param requestName - The name of the request to create
|
|
* @param parentName - The name of the collection or folder
|
|
* @param options - Optional settings (url, inFolder)
|
|
* @returns void
|
|
*/
|
|
const createRequest = async (
|
|
page: Page,
|
|
requestName: string,
|
|
parentName: string,
|
|
options: CreateRequestOptions = {}
|
|
) => {
|
|
const { url, method, inFolder = false, requestType = 'http' } = options;
|
|
const parentType = inFolder ? 'folder' : 'collection';
|
|
const hasMethodSelector = requestType === 'http' || requestType === 'graphql';
|
|
|
|
await test.step(`Create ${requestType.toUpperCase()} request "${requestName}" in ${parentType} "${parentName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
|
|
if (inFolder) {
|
|
await locators.sidebar.folder(parentName).hover();
|
|
await locators.actions.collectionItemActions(parentName).click();
|
|
} else {
|
|
await locators.sidebar.collection(parentName).hover();
|
|
const collectionAction = locators.actions.collectionActions(parentName);
|
|
await expect(collectionAction).toBeVisible({ timeout: 2000 });
|
|
await collectionAction.click();
|
|
}
|
|
|
|
await locators.dropdown.item('New Request').click();
|
|
|
|
// The modal defaults to HTTP; switch the radio for the other three types.
|
|
if (requestType !== 'http') {
|
|
await page.getByTestId(`${requestType}-request`).click();
|
|
}
|
|
|
|
await page.getByPlaceholder('Request Name').fill(requestName);
|
|
|
|
if (method && hasMethodSelector) {
|
|
await page.locator('.bruno-modal .method-selector').click();
|
|
const isStandardMethod = STANDARD_HTTP_METHODS.includes(method.toUpperCase());
|
|
if (isStandardMethod) {
|
|
await locators.modal.newRequestMethodOption(method).click();
|
|
} else {
|
|
await locators.modal.newRequestMethodOption('add-custom').click();
|
|
await page.locator('.bruno-modal .method-selector input').fill(method);
|
|
await page.keyboard.press('Enter');
|
|
}
|
|
await page.waitForTimeout(200);
|
|
}
|
|
|
|
if (url) {
|
|
await page.locator('#new-request-url .CodeMirror').click();
|
|
await page.keyboard.type(url);
|
|
}
|
|
|
|
await locators.modal.button('Create').click();
|
|
|
|
if (inFolder) {
|
|
await expect(locators.sidebar.folderRequest(parentName, requestName)).toBeVisible();
|
|
} else {
|
|
await expect(locators.sidebar.request(requestName)).toBeVisible();
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Delete a request from a collection
|
|
* @param page - The page object
|
|
* @param requestName - The name of the request to delete
|
|
* @param collectionName - The name of the collection
|
|
* @returns void
|
|
*/
|
|
const deleteRequest = async (page, requestName: string, collectionName: string) => {
|
|
await test.step(`Delete request "${requestName}" from collection "${collectionName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
|
|
// Click on the collection first to open it if it's closed
|
|
await locators.sidebar.collection(collectionName).click();
|
|
|
|
// Find the request within the collection's context
|
|
// Use the collection container (.collection-name) scoped to sidebar to scope the search
|
|
const collectionContainer = page.getByTestId('collections').locator('.collection-name').filter({ hasText: collectionName });
|
|
const collectionWrapper = collectionContainer.locator('..');
|
|
const request = collectionWrapper.locator('.collection-item-name').filter({ hasText: requestName });
|
|
|
|
await request.hover();
|
|
await request.locator('.menu-icon').click();
|
|
await locators.dropdown.item('Delete').click();
|
|
await locators.modal.button('Delete').click();
|
|
await expect(request).not.toBeVisible();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Delete a collection permanently from disk via the workspace overview page
|
|
* @param page - The page object
|
|
* @param collectionName - The name of the collection to delete
|
|
* @returns void
|
|
*/
|
|
const deleteCollectionFromOverview = async (page: Page, collectionName: string) => {
|
|
await test.step(`Delete collection "${collectionName}" from workspace overview`, async () => {
|
|
// Navigate to workspace overview
|
|
await page.locator('.home-button').click();
|
|
const overviewTab = page.locator('.request-tab').filter({ hasText: 'Overview' });
|
|
await overviewTab.click();
|
|
|
|
// Find the collection card and open its menu
|
|
const collectionCard = page.locator('.collection-card').filter({ hasText: collectionName });
|
|
await collectionCard.waitFor({ state: 'visible', timeout: 5000 });
|
|
await collectionCard.locator('.collection-menu').click();
|
|
|
|
// Click Delete from the dropdown
|
|
await page.locator('.dropdown-item').filter({ hasText: 'Delete' }).click();
|
|
|
|
// Wait for delete confirmation modal
|
|
const deleteModal = page.locator('.bruno-modal').filter({ hasText: 'Delete Collection' });
|
|
await deleteModal.waitFor({ state: 'visible', timeout: 5000 });
|
|
|
|
// Type 'delete' to confirm
|
|
await deleteModal.locator('#delete-confirm-input').fill('delete');
|
|
|
|
// Click the Delete button
|
|
await deleteModal.getByRole('button', { name: 'Delete', exact: true }).click();
|
|
|
|
// Wait for modal to close
|
|
await deleteModal.waitFor({ state: 'hidden', timeout: 10000 });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Import a collection from a file
|
|
* @param page - The page object
|
|
* @param filePath - The path to the collection file to import
|
|
* @param collectionLocation - The directory where the collection will be saved
|
|
* @param options - Optional settings for import
|
|
* @returns void
|
|
*/
|
|
type ImportCollectionOptions = {
|
|
expectedCollectionName?: string;
|
|
expectIssues?: boolean;
|
|
};
|
|
|
|
const importCollection = async (
|
|
page: Page,
|
|
filePath: string,
|
|
collectionLocation: string,
|
|
options: ImportCollectionOptions = {}
|
|
) => {
|
|
await test.step(`Import collection from "${filePath}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
|
|
await page.getByTestId('collections-header-add-menu').click();
|
|
await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
|
|
|
|
// Wait for import modal
|
|
const importModal = page.getByRole('dialog');
|
|
await importModal.waitFor({ state: 'visible' });
|
|
await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
|
|
|
// Set the file
|
|
await page.setInputFiles('input[type="file"]', filePath);
|
|
|
|
// Wait for location modal to appear
|
|
const locationModal = page.locator('[data-testid="import-collection-location-modal"]');
|
|
await locationModal.waitFor({ state: 'visible', timeout: 10000 });
|
|
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
|
|
|
|
// Verify expected collection name if provided
|
|
if (options.expectedCollectionName) {
|
|
await expect(locationModal.getByText(options.expectedCollectionName)).toBeVisible();
|
|
}
|
|
|
|
// Set location and import
|
|
await page.locator('#collection-location').fill(collectionLocation);
|
|
await locationModal.getByRole('button', { name: 'Import' }).click();
|
|
|
|
// Wait for collection to appear in sidebar
|
|
if (options.expectedCollectionName) {
|
|
await expect(
|
|
page.locator('#sidebar-collection-name').filter({ hasText: options.expectedCollectionName })
|
|
).toBeVisible();
|
|
}
|
|
|
|
// Wait for import issues toast if expected
|
|
if (options.expectIssues) {
|
|
await expect(locators.import.issuesToast()).toBeVisible({ timeout: 10000 });
|
|
}
|
|
|
|
if (options.expectedCollectionName) {
|
|
await openCollection(page, options.expectedCollectionName);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Remove a specific collection from the sidebar
|
|
* @param page - The page object
|
|
* @param collectionName - The name of the collection to remove
|
|
* @returns void
|
|
*/
|
|
const removeCollection = async (page: Page, collectionName: string) => {
|
|
await test.step(`Remove collection "${collectionName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
const collectionRow = page.locator('.collection-name').filter({
|
|
has: page.locator('#sidebar-collection-name', { hasText: collectionName })
|
|
});
|
|
|
|
await collectionRow.hover();
|
|
await collectionRow.locator('.collection-actions .icon').click();
|
|
await locators.dropdown.item('Remove').click();
|
|
|
|
// Wait for modal to appear - could be either regular remove or drafts confirmation
|
|
const removeModal = page.locator('.bruno-modal').filter({ hasText: 'Remove Collection' });
|
|
await removeModal.waitFor({ state: 'visible', timeout: 5000 });
|
|
|
|
// Check if it's the drafts confirmation modal (has "Discard All and Remove" button)
|
|
const hasDiscardButton = await page.getByRole('button', { name: 'Discard All and Remove' }).isVisible().catch(() => false);
|
|
|
|
if (hasDiscardButton) {
|
|
// Drafts modal - click "Discard All and Remove"
|
|
await page.getByRole('button', { name: 'Discard All and Remove' }).click();
|
|
} else {
|
|
// Regular modal - click Remove button
|
|
await locators.modal.button('Remove').click();
|
|
}
|
|
|
|
// Wait for modal to close
|
|
await removeModal.waitFor({ state: 'hidden', timeout: 5000 });
|
|
|
|
// Verify collection is removed
|
|
await expect(
|
|
page.locator('#sidebar-collection-name').filter({ hasText: collectionName })
|
|
).not.toBeVisible();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Create a folder inside a collection or another folder
|
|
* @param page - The page object
|
|
* @param folderName - The name of the folder to create
|
|
* @param parentName - The name of the parent collection or folder
|
|
* @param isCollection - Whether the parent is a collection (true) or folder (false)
|
|
* @returns void
|
|
*/
|
|
const createFolder = async (
|
|
page: Page,
|
|
folderName: string,
|
|
parentName: string,
|
|
isCollection: boolean = true
|
|
) => {
|
|
await test.step(`Create folder "${folderName}" in "${parentName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
|
|
if (isCollection) {
|
|
await locators.sidebar.collection(parentName).hover();
|
|
await locators.actions.collectionActions(parentName).click();
|
|
} else {
|
|
await locators.sidebar.folder(parentName).hover();
|
|
await locators.actions.collectionItemActions(parentName).click();
|
|
}
|
|
|
|
await locators.dropdown.item('New Folder').click();
|
|
await page.getByTestId('new-folder-input').fill(folderName);
|
|
await locators.modal.button('Create').click();
|
|
await expect(locators.sidebar.folder(folderName)).toBeVisible();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Expand a folder in the sidebar so its child requests/subfolders become visible.
|
|
* No-op if the folder is already expanded.
|
|
*/
|
|
const expandFolder = async (page: Page, folderName: string) => {
|
|
await test.step(`Expand folder "${folderName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
const chevron = locators.folder.chevron(folderName);
|
|
await chevron.waitFor({ state: 'visible', timeout: 5000 });
|
|
const isExpanded = await chevron.evaluate((el: HTMLElement) => el.classList.contains('rotate-90'));
|
|
if (!isExpanded) await chevron.click();
|
|
});
|
|
};
|
|
|
|
type EnvironmentType = 'collection' | 'global';
|
|
|
|
/**
|
|
* Open the environment selector panel
|
|
* @param page - The page object
|
|
* @param type - The type of environment tab to select
|
|
* @returns void
|
|
*/
|
|
const openEnvironmentSelector = async (page: Page, type: EnvironmentType = 'collection') => {
|
|
await test.step(`Open ${type} environment selector`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
|
|
await locators.environment.selector().click();
|
|
|
|
if (type === 'global') {
|
|
await locators.environment.globalTab().click();
|
|
await expect(locators.environment.globalTab()).toHaveClass(/active/);
|
|
} else {
|
|
await expect(locators.environment.collectionTab()).toHaveClass(/active/);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Open the configuration tab for the currently active environment (collection or global).
|
|
* Combines opening the env selector dropdown + clicking the "configure" button + waiting
|
|
* for the resulting env config tab to appear. Use this when the test needs to interact
|
|
* with the env variable rows (read a value, toggle the secret eye, etc.).
|
|
* @param page - The page object
|
|
* @param type - The type of environment configuration tab to open
|
|
*/
|
|
const openEnvironmentConfigTab = async (page: Page, type: EnvironmentType = 'collection') => {
|
|
await test.step(`Open ${type} environment configuration tab`, async () => {
|
|
await openEnvironmentSelector(page, type);
|
|
|
|
const locators = buildCommonLocators(page);
|
|
// `waitFor` + `dispatchEvent` keeps the click stable when the dropdown is mid-transition;
|
|
// the menu item briefly intercepts pointer events during the open animation.
|
|
await locators.environment.configureButton().waitFor({ state: 'visible' });
|
|
await locators.environment.configureButton().dispatchEvent('click');
|
|
|
|
const envTab = type === 'global'
|
|
? locators.environment.globalEnvTab()
|
|
: locators.environment.collectionEnvTab();
|
|
await expect(envTab).toBeVisible();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Create a new environment
|
|
* @param page - The page object
|
|
* @param environmentName - The name of the environment
|
|
* @param type - The type of environment (collection or global)
|
|
* @returns void
|
|
*/
|
|
const createEnvironment = async (
|
|
page: Page,
|
|
environmentName: string,
|
|
type: EnvironmentType = 'collection'
|
|
) => {
|
|
await test.step(`Create ${type} environment "${environmentName}"`, async () => {
|
|
await openEnvironmentSelector(page, type);
|
|
|
|
await page.locator('button[id="create-env"]').click();
|
|
|
|
const nameInput = type === 'collection'
|
|
? page.locator('input[name="name"]')
|
|
: page.locator('#environment-name');
|
|
await expect(nameInput).toBeVisible();
|
|
await nameInput.fill(environmentName);
|
|
await page.getByRole('button', { name: 'Create' }).click();
|
|
|
|
const tabLabel = type === 'collection' ? 'Environments' : 'Global Environments';
|
|
await expect(page.locator('.request-tab').filter({ hasText: tabLabel })).toBeVisible();
|
|
|
|
const locators = buildCommonLocators(page);
|
|
await page.waitForTimeout(200); // @TODO replace with dynamic waiting logic
|
|
await locators.environment.selector().click();
|
|
if (type === 'global') {
|
|
await locators.environment.globalTab().click();
|
|
}
|
|
await locators.environment.envOption(environmentName).click();
|
|
await expect(page.locator('.current-environment')).toContainText(environmentName);
|
|
});
|
|
};
|
|
|
|
type EnvironmentVariable = {
|
|
name: string;
|
|
value: string;
|
|
isSecret?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Add an environment variable to the currently open environment. Variables and
|
|
* secrets live on separate tabs, so a secret is routed to the Secrets tab and a
|
|
* plain variable to the Variables tab before the row is added.
|
|
* @param page - The page object
|
|
* @param variable - The variable to add (name, value, and optional secret flag)
|
|
* @returns void
|
|
*/
|
|
const addEnvironmentVariable = async (page: Page, variable: EnvironmentVariable) => {
|
|
await test.step(`Add environment ${variable.isSecret ? 'secret' : 'variable'} "${variable.name}"`, async () => {
|
|
const tab = variable.isSecret
|
|
? page.getByTestId('responsive-tab-secrets')
|
|
: page.getByTestId('responsive-tab-variables');
|
|
await tab.click();
|
|
await expect(tab).toHaveClass(/active/);
|
|
|
|
await addRowToActiveTab(page, variable.name, variable.value);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Add multiple environment variables to the currently open environment. Each entry
|
|
* is routed to the Variables or Secrets tab based on its `isSecret` flag.
|
|
* @param page - The page object
|
|
* @param variables - Array of variables to add
|
|
* @returns void
|
|
*/
|
|
const addEnvironmentVariables = async (page: Page, variables: EnvironmentVariable[]) => {
|
|
await test.step(`Add ${variables.length} environment variables`, async () => {
|
|
for (let i = 0; i < variables.length; i++) {
|
|
await addEnvironmentVariable(page, variables[i]);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Add a variable or secret to whichever environment tab (Variables / Secrets) is
|
|
* currently active. The active tab determines the row's type, so select the tab
|
|
* before calling.
|
|
* @param page - The page object
|
|
* @param name - The variable/secret name
|
|
* @param value - The variable/secret value
|
|
* @returns void
|
|
*/
|
|
const addRowToActiveTab = async (page: Page, name: string, value: string) => {
|
|
await test.step(`Add row "${name}" to the active environment tab`, async () => {
|
|
const nameInput = page.locator('input[placeholder="Name"]').last();
|
|
await nameInput.waitFor({ state: 'visible' });
|
|
await nameInput.fill(name);
|
|
|
|
const row = page.getByTestId(`env-var-row-${name}`);
|
|
await row.waitFor({ state: 'visible' });
|
|
|
|
const codeMirror = row.locator('.CodeMirror');
|
|
await codeMirror.scrollIntoViewIfNeeded();
|
|
await codeMirror.click();
|
|
await page.keyboard.type(value);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Delete every global environment in the workspace. Global environments persist at
|
|
* the workspace level (closeAllCollections does not remove them), so call this to keep
|
|
* tests isolated. Deletes the currently-selected environment first, since a tab with
|
|
* unsaved changes blocks switching to another env via the list.
|
|
* @param page - The page object
|
|
* @returns void
|
|
*/
|
|
const deleteAllGlobalEnvironments = async (page: Page) => {
|
|
await test.step('Delete all global environments', async () => {
|
|
await page.getByTestId('environment-selector-trigger').click();
|
|
await page.getByTestId('env-tab-global').click();
|
|
await page.getByTestId('configure-env').click();
|
|
|
|
const envItems = page.locator('.environment-item');
|
|
const deleteBtn = page.locator('button[title="Delete"]');
|
|
const modal = page.locator('.bruno-modal').filter({ hasText: 'Delete Environment' });
|
|
|
|
await page.locator('.environments-container').first().waitFor({ state: 'visible' }).catch(() => {});
|
|
|
|
while (true) {
|
|
if ((await deleteBtn.count()) === 0) {
|
|
if ((await envItems.count()) === 0) break;
|
|
await envItems.first().click();
|
|
await deleteBtn.waitFor({ state: 'visible' });
|
|
}
|
|
await deleteBtn.first().click();
|
|
await modal.getByRole('button', { name: 'Delete', exact: true }).click();
|
|
await modal.waitFor({ state: 'hidden' });
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Save the current environment settings
|
|
* @param page - The page object
|
|
* @returns void
|
|
*/
|
|
const saveEnvironment = async (page: Page) => {
|
|
await test.step('Save environment', async () => {
|
|
await page.getByTestId('save-all-env').click();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Close the environment tab
|
|
* @param page - The page object
|
|
* @param type - The type of environment tab to close
|
|
* @returns void
|
|
*/
|
|
const closeEnvironmentPanel = async (page: Page, type: EnvironmentType = 'collection') => {
|
|
await test.step('Close environment tab', async () => {
|
|
const tabLabel = type === 'collection' ? 'Environments' : 'Global Environments';
|
|
const envTab = page.locator('.request-tab').filter({ hasText: tabLabel });
|
|
await envTab.hover();
|
|
await envTab.getByTestId('request-tab-close-icon').click({ force: true });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Select an environment from the dropdown
|
|
* @param page - The page object
|
|
* @param environmentName - The name of the environment to select
|
|
* @param type - The type of environment (collection or global)
|
|
* @returns void
|
|
*/
|
|
const selectEnvironment = async (
|
|
page: Page,
|
|
environmentName: string,
|
|
type: EnvironmentType = 'collection'
|
|
) => {
|
|
await test.step(`Select ${type} environment "${environmentName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
|
|
await locators.environment.selector().click();
|
|
|
|
if (type === 'global') {
|
|
await locators.environment.globalTab().click();
|
|
}
|
|
|
|
await locators.environment.envOption(environmentName).click();
|
|
|
|
// Verify selection
|
|
await expect(page.locator('.current-environment')).toContainText(environmentName);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Send the current request and wait for response
|
|
* @param page - The page object
|
|
* @param expectedStatusCode - Optional expected status code to wait for
|
|
* @param timeout - Timeout in milliseconds (default: 30000)
|
|
* @returns void
|
|
*/
|
|
const sendRequest = async (
|
|
page: Page,
|
|
expectedStatusCode?: number,
|
|
timeout: number = 30000
|
|
) => {
|
|
await test.step('Send request', async () => {
|
|
await page.getByTestId('send-arrow-icon').click();
|
|
await page.getByTestId('response-status-code').waitFor({ state: 'visible', timeout });
|
|
|
|
if (expectedStatusCode !== undefined) {
|
|
await expect(page.getByTestId('response-status-code')).toContainText(
|
|
String(expectedStatusCode),
|
|
{ timeout }
|
|
);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Open a request by clicking on it in the sidebar
|
|
* @param page - The page object
|
|
* @param requestName - The name of the request to open
|
|
* @returns void
|
|
*/
|
|
// const openRequest = async (page: Page, requestName: string) => {
|
|
// await test.step(`Open request "${requestName}"`, async () => {
|
|
// const locators = buildCommonLocators(page);
|
|
// await locators.sidebar.request(requestName).click();
|
|
// await expect(locators.tabs.activeRequestTab()).toContainText(requestName);
|
|
// });
|
|
// };
|
|
|
|
/**
|
|
* Navigate to a collection and open a request
|
|
* @param page - The page object
|
|
* @param collectionName - The name of the collection
|
|
* @param requestName - The name of the request
|
|
*/
|
|
const openRequest = async (page: Page, collectionName: string, requestName: string, { persist = false } = {}) => {
|
|
await test.step(`Navigate to collection "${collectionName}" and open request "${requestName}"`, async () => {
|
|
const collectionContainer = page.getByTestId('sidebar-collection-row').filter({ hasText: collectionName });
|
|
await collectionContainer.click();
|
|
const collectionWrapper = collectionContainer.locator('..');
|
|
const request = collectionWrapper.getByTestId('sidebar-collection-item-row').filter({ hasText: requestName });
|
|
if (!persist) {
|
|
await request.click();
|
|
} else {
|
|
await request.dblclick();
|
|
}
|
|
});
|
|
};
|
|
/**
|
|
* Open a folder's settings tab by clicking on it in the sidebar
|
|
* @param page - The page object
|
|
* @param collectionName - The name of the collection
|
|
* @param folderName - The name of the folder
|
|
* @param options - Optional settings (persist: double-click to make tab permanent)
|
|
* @returns void
|
|
*/
|
|
const openfolder = async (page: Page, collectionName: string, folderName: string, { persist = false } = {}) => {
|
|
await test.step(`Open folder "${folderName}" in collection "${collectionName}"`, async () => {
|
|
const collectionContainer = page.getByTestId('sidebar-collection-row').filter({ hasText: collectionName });
|
|
await collectionContainer.click();
|
|
const collectionWrapper = collectionContainer.locator('..');
|
|
const folder = collectionWrapper.getByTestId('sidebar-collection-item-row').filter({ hasText: folderName });
|
|
if (!persist) {
|
|
await folder.click();
|
|
} else {
|
|
await folder.dblclick();
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Select a tab in the folder settings pane
|
|
* @param page - The page object
|
|
* @param tabName - The tab name key (e.g. 'auth', 'headers', 'docs', 'script', 'vars', 'test')
|
|
* @returns void
|
|
*/
|
|
const selectfolderPaneTab = async (page: Page, tabName: string) => {
|
|
await test.step(`Select folder pane tab "${tabName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
const tab = locators.paneTabs.folderSettingsTab(tabName.toLowerCase());
|
|
await tab.click();
|
|
await expect(tab).toContainClass('active');
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Select a sub-tab in the folder script pane (Pre Request or Post Response)
|
|
* @param page - The page object
|
|
* @param tabName - 'pre-request' or 'post-response'
|
|
* @returns void
|
|
*/
|
|
const selectFolderScriptPaneTab = async (page: Page, tabName: 'pre-request' | 'post-response') => {
|
|
await test.step(`Select folder script pane tab "${tabName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
const tab = locators.paneTabs.folderScriptTab(tabName);
|
|
await tab.click();
|
|
await expect(tab).toContainClass('active');
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Open a collection's settings tab by clicking on it in the sidebar
|
|
* @param page - The page object
|
|
* @param collectionName - The name of the collection
|
|
* @param options - Optional settings (persist: double-click to make tab permanent)
|
|
* @returns void
|
|
*/
|
|
const openCollectionSettings = async (page: Page, collectionName: string, { persist = false } = {}) => {
|
|
await test.step(`Open collection settings for "${collectionName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
const collection = locators.sidebar.collection(collectionName);
|
|
if (!persist) {
|
|
await collection.click();
|
|
} else {
|
|
await collection.dblclick();
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Select a tab in the collection settings pane
|
|
* @param page - The page object
|
|
* @param tabName - The tab name key (e.g. 'auth', 'headers', 'overview', 'script', 'vars')
|
|
* @returns void
|
|
*/
|
|
const selectCollectionPaneTab = async (page: Page, tabName: string) => {
|
|
await test.step(`Select collection pane tab "${tabName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
const tab = locators.paneTabs.collectionSettingsTab(tabName.toLowerCase());
|
|
await tab.click();
|
|
await expect(tab).toContainClass('active');
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Select a sub-tab in the collection script pane (Pre Request or Post Response)
|
|
* @param page - The page object
|
|
* @param tabName - 'pre-request' or 'post-response'
|
|
* @returns void
|
|
*/
|
|
const selectCollectionScriptPaneTab = async (page: Page, tabName: 'pre-request' | 'post-response') => {
|
|
await test.step(`Select collection script pane tab "${tabName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
const tab = locators.paneTabs.tabTrigger(tabName);
|
|
await tab.click();
|
|
await expect(tab).toContainClass('active');
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Focus the folder settings tab in the tab bar after restore
|
|
* @param page - The page object
|
|
* @param folderName - The name of the folder
|
|
* @param options - Optional timeout in milliseconds
|
|
* @returns void
|
|
*/
|
|
const focusFolderSettingsTab = async (page: Page, folderName: string, { timeout = 10000 } = {}) => {
|
|
await test.step(`Focus folder settings tab "${folderName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
const tab = locators.tabs.folderTab(folderName);
|
|
await expect(tab).toBeVisible({ timeout });
|
|
await tab.click({ force: true });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Focus the collection settings tab in the tab bar after restore
|
|
* @param page - The page object
|
|
* @param options - Optional timeout in milliseconds
|
|
* @returns void
|
|
*/
|
|
const focusCollectionSettingsTab = async (page: Page, { timeout = 10000 } = {}) => {
|
|
await test.step('Focus collection settings tab', async () => {
|
|
const locators = buildCommonLocators(page);
|
|
const tab = locators.tabs.collectionSettingsTab();
|
|
await expect(tab).toBeVisible({ timeout });
|
|
await tab.click({ force: true });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Open a request within a folder
|
|
* @param page - The page object
|
|
* @param folderName - The name of the folder
|
|
* @param requestName - The name of the request
|
|
* @returns void
|
|
*/
|
|
const openFolderRequest = async (page: Page, folderName: string, requestName: string) => {
|
|
await test.step(`Open request "${requestName}" in folder "${folderName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
await locators.sidebar.folderRequest(folderName, requestName).click();
|
|
await expect(locators.tabs.activeRequestTab()).toContainText(requestName);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Send a request and wait for the response
|
|
* @param page - The page object
|
|
* @param expectedStatusCode - The expected status code (default: 200)
|
|
* @param options - The options for sending the request (default: { timeout: 15000 })
|
|
*/
|
|
const sendRequestAndWaitForResponse = async (page: Page,
|
|
expectedStatusCode: number = 200,
|
|
options: {
|
|
ignoreCase?: boolean;
|
|
timeout?: number;
|
|
useInnerText?: boolean;
|
|
} = { timeout: 15000 }) => {
|
|
await test.step(`Send request and wait for status code ${expectedStatusCode}`, async () => {
|
|
await page.getByTestId('send-arrow-icon').click();
|
|
await expect(page.getByTestId('response-status-code')).toContainText(String(expectedStatusCode), options);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Switch the response format
|
|
* @param page - The page object
|
|
* @param format - The format to switch to (e.g., 'JSON', 'HTML', 'XML', 'JavaScript', 'Raw', 'Hex', 'Base64')
|
|
*/
|
|
const switchResponseFormat = async (page: Page, format: string) => {
|
|
await test.step(`Switch response format to ${format}`, async () => {
|
|
const responseFormatTab = page.getByTestId('format-response-tab');
|
|
await responseFormatTab.waitFor({ state: 'visible', timeout: 15000 });
|
|
await responseFormatTab.click();
|
|
// Wait for dropdown to be visible before clicking the format option
|
|
const dropdown = page.getByTestId('format-response-tab-dropdown');
|
|
try {
|
|
await dropdown.waitFor({ state: 'visible', timeout: 15000 });
|
|
} catch {
|
|
// If the dropdown didn't appear, try clicking the tab again before failing
|
|
await responseFormatTab.click();
|
|
await dropdown.waitFor({ state: 'visible', timeout: 15000 });
|
|
}
|
|
await dropdown.getByText(format).click();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Set the response pane's preview/editor mode idempotently.
|
|
*
|
|
* The underlying `preview-response-tab` element is a `<ToggleSwitch>` 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');
|
|
// The toggle's `title` reflects current state (`Turn off|on Preview Mode`).
|
|
// Wait until it's actually one of those values — `getAttribute` returns
|
|
// `null` if read before React flushes props to DOM, which would mislead
|
|
// the state check below into thinking we're already in editor mode and
|
|
// skip the toggle click, leaving us stuck in preview.
|
|
await expect(toggle).toHaveAttribute('title', /^Turn (off|on) Preview Mode$/);
|
|
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();
|
|
}
|
|
// Confirm the dropdown actually closed before returning. Otherwise a
|
|
// subsequent format-selector click can land in a half-open state and
|
|
// miss the next interaction.
|
|
await dropdown.waitFor({ state: 'hidden', timeout: 5000 });
|
|
};
|
|
|
|
/**
|
|
* Switch the response pane into preview mode (idempotent).
|
|
*/
|
|
const switchToPreviewTab = async (page: Page) => {
|
|
await test.step('Switch to preview tab', async () => {
|
|
await setResponsePreviewMode(page, 'preview');
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Switch the response pane into editor mode (idempotent).
|
|
*/
|
|
const switchToEditorTab = async (page: Page) => {
|
|
await test.step('Switch to editor tab', async () => {
|
|
await setResponsePreviewMode(page, 'editor');
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Get the response body text
|
|
* @param page - The page object
|
|
* @returns The response body text
|
|
*/
|
|
const getResponseBody = async (page: Page): Promise<string> => {
|
|
return await page.locator('.response-pane').innerText();
|
|
};
|
|
|
|
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
const trySelectPaneTabOnce = async (page: Page, paneSelector: string, tabName: string) => {
|
|
const pane = page.locator(paneSelector);
|
|
const visibleTab = pane.locator('.tabs').getByRole('tab', { name: tabName });
|
|
|
|
if (await visibleTab.isVisible().catch(() => false)) {
|
|
try {
|
|
await visibleTab.click({ timeout: 2000 });
|
|
await expect(visibleTab).toContainClass('active', { timeout: 500 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const overflowButton = pane.locator('.tabs .more-tabs');
|
|
if (!(await overflowButton.isVisible().catch(() => false))) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await overflowButton.click({ force: true, timeout: 1000 });
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
const dropdownItem = page
|
|
.getByRole('menuitem', { name: new RegExp(escapeRegExp(tabName), 'i') })
|
|
.first();
|
|
|
|
if (await dropdownItem.isVisible({ timeout: 1500 }).catch(() => false)) {
|
|
try {
|
|
await dropdownItem.click({ force: true, timeout: 2000 });
|
|
await expect(visibleTab).toContainClass('active', { timeout: 500 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const fallbackDropdownItem = page.locator('.tippy-box .dropdown-item').filter({ hasText: tabName }).first();
|
|
if (await fallbackDropdownItem.isVisible({ timeout: 1500 }).catch(() => false)) {
|
|
try {
|
|
await fallbackDropdownItem.click({ force: true, timeout: 2000 });
|
|
await expect(visibleTab).toContainClass('active', { timeout: 500 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const selectPaneTab = async (page: Page, paneSelector: string, tabName: string) => {
|
|
await test.step(`Select tab "${tabName}" in ${paneSelector}`, async () => {
|
|
const pane = page.locator(paneSelector);
|
|
await expect(pane).toBeVisible();
|
|
await expect(pane.locator('.tabs')).toBeVisible();
|
|
|
|
// await expect
|
|
// .poll(
|
|
// async () => trySelectPaneTabOnce(page, paneSelector, tabName),
|
|
// {
|
|
// message: `Tab "${tabName}" not found in visible tabs or overflow dropdown`,
|
|
// timeout: 8000,
|
|
// intervals: [100, 150, 200, 250]
|
|
// }
|
|
// )
|
|
// .toBe(true);
|
|
|
|
const visibleTab = pane.locator('.tabs').getByRole('tab', { name: tabName });
|
|
const overflowButton = pane.locator('.tabs .more-tabs');
|
|
|
|
// ResponsiveTabs recalculates layout via ResizeObserver/rAF, so the tab or
|
|
// the overflow trigger can detach mid-click. Retry the whole sequence so a
|
|
// mid-action remount doesn't fail the test.
|
|
await expect(async () => {
|
|
if (await visibleTab.isVisible()) {
|
|
await visibleTab.click({ timeout: 2000 });
|
|
await expect(visibleTab).toContainClass('active', { timeout: 2000 });
|
|
return;
|
|
}
|
|
|
|
if (await overflowButton.isVisible()) {
|
|
await overflowButton.click({ timeout: 2000 });
|
|
|
|
const dropdownItem = page.locator('.tippy-box .dropdown-item').filter({ hasText: tabName });
|
|
await dropdownItem.waitFor({ state: 'visible', timeout: 2000 });
|
|
await dropdownItem.click({ force: true, timeout: 2000 });
|
|
await expect(visibleTab).toContainClass('active', { timeout: 2000 });
|
|
return;
|
|
}
|
|
|
|
throw new Error(`Tab "${tabName}" not found in visible tabs or overflow dropdown`);
|
|
}).toPass({ timeout: 15000 });
|
|
});
|
|
};
|
|
|
|
const selectResponsePaneTab = async (page: Page, tabName: string) => {
|
|
await selectPaneTab(page, '[data-testid="response-pane"]', tabName);
|
|
};
|
|
|
|
const selectRequestPaneTab = async (page: Page, tabName: string) => {
|
|
await selectPaneTab(page, '[data-testid="request-pane"] > .px-4', tabName);
|
|
};
|
|
|
|
const selectRequestBodyMode = async (page: Page, mode: string) => {
|
|
await test.step(`Select request body mode "${mode}"`, async () => {
|
|
await selectRequestPaneTab(page, 'Body');
|
|
const locators = buildCommonLocators(page);
|
|
await locators.request.bodyModeSelector().click();
|
|
await locators.dropdown.item(mode).click();
|
|
});
|
|
};
|
|
|
|
const mockBrowseFiles = async (electronApp: ElectronApplication, filePaths: string[]) => {
|
|
await electronApp.evaluate(({ dialog }, selectedPaths: string[]) => {
|
|
const originalShowOpenDialog = dialog.showOpenDialog;
|
|
dialog.showOpenDialog = async (...args) => {
|
|
dialog.showOpenDialog = originalShowOpenDialog;
|
|
return {
|
|
canceled: false,
|
|
filePaths: selectedPaths
|
|
};
|
|
};
|
|
}, filePaths);
|
|
};
|
|
|
|
const addMultipartFileToLastRow = async (page: Page, electronApp: ElectronApplication, filePath: string) => {
|
|
await test.step(`Add multipart file "${path.basename(filePath)}"`, async () => {
|
|
await mockBrowseFiles(electronApp, [filePath]);
|
|
|
|
const table = buildCommonLocators(page).table('editable-table');
|
|
// The last row is the empty "add" row. Capture its index now, because once
|
|
// we set a file the table appends a new empty row — so `.last()` would jump
|
|
// to that new row instead of staying on the one we just filled.
|
|
const rowIndex = (await table.allRows().count()) - 1;
|
|
const targetRow = table.allRows().nth(rowIndex);
|
|
|
|
await expect(targetRow.locator('.upload-btn')).toBeVisible();
|
|
await targetRow.locator('.upload-btn').click();
|
|
await expect(targetRow.locator('.file-value-cell')).toBeVisible();
|
|
const inlineChip = targetRow.getByTestId('multipart-file-chip').filter({ hasText: path.basename(filePath) });
|
|
const summary = targetRow.getByTestId('multipart-file-summary');
|
|
await expect(inlineChip.or(summary)).toBeVisible();
|
|
});
|
|
};
|
|
|
|
const removeFirstMultipartFile = async (page: Page) => {
|
|
await test.step('Remove first multipart file', async () => {
|
|
const table = buildCommonLocators(page).table('editable-table');
|
|
const firstRow = table.allRows().first();
|
|
await expect(firstRow.locator('.file-value-cell')).toBeVisible();
|
|
|
|
const inlineRemove = firstRow.getByTestId('multipart-file-chip-remove').first();
|
|
const summary = firstRow.getByTestId('multipart-file-summary');
|
|
|
|
if (await inlineRemove.count() > 0) {
|
|
await inlineRemove.click();
|
|
} else {
|
|
await expect(summary).toBeVisible();
|
|
await summary.click();
|
|
const overflowRemove = page.getByTestId('multipart-file-overflow-remove').first();
|
|
await expect(overflowRemove).toBeVisible();
|
|
await overflowRemove.click();
|
|
}
|
|
await expect(firstRow.locator('.file-value-cell')).toHaveCount(0);
|
|
await expect(firstRow.locator('.value-cell')).toBeVisible();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Verify response contains specific text
|
|
* @param page - The page object
|
|
* @param texts - Array of texts to verify in the response
|
|
* @returns void
|
|
*/
|
|
const expectResponseContains = async (page: Page, texts: string[]) => {
|
|
await test.step('Verify response content', async () => {
|
|
const responsePane = page.locator('.response-pane');
|
|
for (const text of texts) {
|
|
await expect(responsePane).toContainText(text);
|
|
}
|
|
});
|
|
};
|
|
|
|
// Map button testIds to menu item IDs
|
|
const buttonToMenuItemMap: Record<string, string> = {
|
|
'response-copy-btn': 'copy-response',
|
|
'response-bookmark-btn': 'save-response',
|
|
'response-download-btn': 'download-response',
|
|
'response-clear-btn': 'clear-response',
|
|
'response-layout-toggle-btn': 'change-layout'
|
|
};
|
|
|
|
// Click a response action - handles both visible buttons and menu items
|
|
const clickResponseAction = async (page: Page, actionTestId: string) => {
|
|
const actionButton = page.getByTestId(actionTestId).first();
|
|
if (await actionButton.isVisible()) {
|
|
await actionButton.click();
|
|
} else {
|
|
// Open the menu dropdown (wait for response pane to fully render)
|
|
const menu = page.getByTestId('response-actions-menu');
|
|
await menu.waitFor({ state: 'visible', timeout: 15000 });
|
|
await menu.click();
|
|
|
|
// Click the corresponding menu item
|
|
const menuItemId = buttonToMenuItemMap[actionTestId];
|
|
if (menuItemId) {
|
|
await page.locator(`[role="menuitem"][data-item-id="${menuItemId}"]`).click();
|
|
} else {
|
|
throw new Error(`Unknown action testId: ${actionTestId}. Add mapping to buttonToMenuItemMap.`);
|
|
}
|
|
}
|
|
};
|
|
|
|
type AssertionInput = {
|
|
expr: string;
|
|
value: string;
|
|
operator?: string;
|
|
};
|
|
|
|
/**
|
|
* Add an assertion to the current request (adds to the last empty row)
|
|
* @param page - The page object
|
|
* @param assertion - The assertion to add (expr, value, optional operator)
|
|
* @returns The row index where the assertion was added
|
|
*/
|
|
const addAssertion = async (page: Page, assertion: AssertionInput): Promise<number> => {
|
|
const operator = assertion.operator || 'eq';
|
|
|
|
return await test.step(`Add assertion: ${assertion.expr} ${operator} ${assertion.value}`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
const table = locators.assertionsTable();
|
|
|
|
// Ensure assertions table is visible
|
|
await expect(table.container()).toBeVisible();
|
|
|
|
// Find the last row (which is the empty row for adding new assertions)
|
|
const rowCount = await table.allRows().count();
|
|
const targetRowIndex = rowCount - 1; // Last row is the empty row
|
|
|
|
// Wait for the row to exist
|
|
await expect(table.row(targetRowIndex)).toBeVisible();
|
|
|
|
// Fill in the expression
|
|
const exprInput = table.rowExprInput(targetRowIndex);
|
|
await expect(exprInput).toBeVisible({ timeout: 2000 });
|
|
await exprInput.click();
|
|
await page.keyboard.type(assertion.expr);
|
|
|
|
// The component creates a new empty row when the key field is filled
|
|
await expect(table.allRows()).toHaveCount(rowCount + 1);
|
|
|
|
// Fill in the value first (defaults to 'eq value')
|
|
const valueInput = table.rowValueInput(targetRowIndex);
|
|
await valueInput.click();
|
|
await page.keyboard.type(assertion.value);
|
|
|
|
// Select the operator from dropdown (if provided and not default 'eq')
|
|
// This will update the value field to combine operator + value
|
|
if (assertion.operator && assertion.operator !== 'eq') {
|
|
const operatorSelect = table.rowOperatorSelect(targetRowIndex);
|
|
await operatorSelect.selectOption(assertion.operator);
|
|
}
|
|
|
|
// Wait for the assertion to be fully processed
|
|
// Verify the expression was actually saved by checking the input value
|
|
const exprInputAfter = table.rowExprInput(targetRowIndex);
|
|
await expect(exprInputAfter).toHaveValue(assertion.expr, { timeout: 2000 });
|
|
|
|
return targetRowIndex;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Edit an assertion at a specific row index
|
|
* @param page - The page object
|
|
* @param rowIndex - The row index of the assertion to edit
|
|
* @param assertion - The assertion data to update (expr, value, optional operator)
|
|
* @returns void
|
|
*/
|
|
const editAssertion = async (page: Page, rowIndex: number, assertion: AssertionInput) => {
|
|
const operator = assertion.operator || 'eq';
|
|
|
|
await test.step(`Edit assertion at row ${rowIndex}: ${assertion.expr} ${operator} ${assertion.value}`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
const table = locators.assertionsTable();
|
|
|
|
// Ensure assertions table is visible
|
|
await expect(table.container()).toBeVisible();
|
|
|
|
// Wait for the row to exist
|
|
await expect(table.row(rowIndex)).toBeVisible();
|
|
|
|
// Update the expression
|
|
const exprInput = table.rowExprInput(rowIndex);
|
|
await expect(exprInput).toBeVisible({ timeout: 2000 });
|
|
await exprInput.click();
|
|
// Clear the input and type new value - use triple-click to select all (works cross-platform)
|
|
await exprInput.click({ clickCount: 3 });
|
|
await page.keyboard.press('Backspace'); // Clear selection
|
|
await page.keyboard.type(assertion.expr);
|
|
|
|
// Update the operator from dropdown (if provided)
|
|
if (assertion.operator) {
|
|
const operatorSelect = table.rowOperatorSelect(rowIndex);
|
|
await operatorSelect.selectOption(assertion.operator);
|
|
}
|
|
|
|
// Update the value (just the value, operator is already selected)
|
|
// The value cell contains a SingleLineEditor, so we need to click and type
|
|
const valueInput = table.rowValueInput(rowIndex);
|
|
await valueInput.click({ clickCount: 3 });
|
|
await page.keyboard.press('Backspace'); // Clear selection
|
|
await page.keyboard.type(assertion.value);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Delete an assertion from the current request by row index
|
|
* @param page - The page object
|
|
* @param rowIndex - The row index of the assertion to delete
|
|
* @returns void
|
|
*/
|
|
const deleteAssertion = async (page: Page, rowIndex: number) => {
|
|
await test.step(`Delete assertion at row ${rowIndex}`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
const table = locators.assertionsTable();
|
|
|
|
await expect(table.container()).toBeVisible();
|
|
|
|
const initialRowCount = await table.allRows().count();
|
|
const deleteButton = table.rowDeleteButton(rowIndex);
|
|
|
|
await deleteButton.click();
|
|
await expect(table.allRows()).toHaveCount(initialRowCount - 1);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Save the current request and verify success toast
|
|
* @param page - The page object
|
|
* @returns void
|
|
*/
|
|
const saveRequest = async (page: Page) => {
|
|
await test.step('Save request', async () => {
|
|
const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s';
|
|
await page.keyboard.press(saveShortcut);
|
|
await expect(page.getByText('Request saved successfully').last()).toBeVisible({ timeout: 3000 });
|
|
await page.waitForTimeout(200);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Click the gRPC "Add Message" button to append a new message to the request
|
|
* @param page - The page object
|
|
*/
|
|
const addGrpcMessage = async (page: Page) => {
|
|
await test.step('Add gRPC message', async () => {
|
|
const locators = buildGrpcCommonLocators(page);
|
|
await locators.request.addMessageButton().click();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Click the "Generate sample" button on a gRPC message to populate it with a sample payload
|
|
* @param page - The page object
|
|
* @param index - The 0-based index of the message (default: 0)
|
|
*/
|
|
const generateGrpcSampleMessage = async (page: Page, index: number = 0) => {
|
|
await test.step(`Generate sample for gRPC message #${index}`, async () => {
|
|
const locators = buildGrpcCommonLocators(page);
|
|
await locators.request.regenerateMessage(index).click();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Open the gRPC method dropdown and select a method by name
|
|
* @param page - The page object
|
|
* @param methodName - The name of the gRPC method to select (e.g. "BidiHello")
|
|
*/
|
|
const selectGrpcMethod = async (page: Page, methodName: string) => {
|
|
await test.step(`Select gRPC method "${methodName}"`, async () => {
|
|
const locators = buildGrpcCommonLocators(page);
|
|
await locators.method.dropdownTrigger().click();
|
|
await locators.method.dropdown().waitFor({ state: 'visible', timeout: 5000 });
|
|
await locators.method.item(methodName).first().click();
|
|
await expect(locators.method.selectedName()).toContainText(methodName);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Close all open request tabs using the right-click context menu
|
|
* @param page - The page object
|
|
* @returns void
|
|
*/
|
|
const closeAllTabs = async (page: Page) => {
|
|
await test.step('Close all tabs', async () => {
|
|
// Find actual request tabs (those with .tab-method, not Overview/Environments)
|
|
const requestTabLabel = page.locator('.request-tab').filter({ has: page.locator('.tab-method') }).locator('.tab-label').first();
|
|
if (!(await requestTabLabel.isVisible().catch(() => false))) {
|
|
return; // No request tabs to close
|
|
}
|
|
|
|
// Right-click on the tab label to open context menu
|
|
await requestTabLabel.click({ button: 'right' });
|
|
|
|
// Wait for the dropdown menu to appear
|
|
const dropdown = page.locator('.tippy-box.dropdown');
|
|
await dropdown.waitFor({ state: 'visible', timeout: 5000 });
|
|
|
|
// Click "Close All" menu item
|
|
await dropdown.locator('[role="menuitem"][data-item-id="close-all"]').click();
|
|
|
|
// Handle "Unsaved Transient Requests" modal if it appears
|
|
const discardAllButton = page.getByRole('button', { name: 'Discard All' });
|
|
if (await discardAllButton.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
await discardAllButton.click();
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Create a new workspace via the title bar dropdown inline rename flow
|
|
* @param page - The page object
|
|
* @param workspaceName - The name of the workspace to create
|
|
* @returns void
|
|
*/
|
|
const createWorkspace = async (page: Page, workspaceName: string) => {
|
|
await test.step(`Create workspace "${workspaceName}"`, async () => {
|
|
await page.locator('.workspace-name-container').click();
|
|
await page.locator('.dropdown-item').filter({ hasText: 'Create workspace' }).click();
|
|
|
|
const renameInput = page.locator('.workspace-name-input');
|
|
await expect(renameInput).toBeVisible({ timeout: 5000 });
|
|
await renameInput.fill(workspaceName);
|
|
await renameInput.press('Enter');
|
|
|
|
await expect(page.getByText('Workspace created!')).toBeVisible({ timeout: 10000 });
|
|
await expect(page.getByTestId('workspace-name')).toHaveText(workspaceName, { timeout: 5000 });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Switch to an existing workspace via the title bar dropdown
|
|
* @param page - The page object
|
|
* @param workspaceName - The name of the workspace to switch to
|
|
* @returns void
|
|
*/
|
|
const switchWorkspace = async (page: Page, workspaceName: string) => {
|
|
await test.step(`Switch to workspace "${workspaceName}"`, async () => {
|
|
await page.locator('.workspace-name-container').click();
|
|
await page.locator('.workspace-item, .dropdown-item').filter({ hasText: workspaceName }).click();
|
|
await expect(page.getByTestId('workspace-name')).toHaveText(workspaceName, { timeout: 5000 });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Navigate to a Script sub-tab (pre-request / post-response)
|
|
* @param page - The page object
|
|
* @param subTab - The sub-tab to select
|
|
*/
|
|
const selectScriptSubTab = async (page: Page, subTab: 'pre-request' | 'post-response') => {
|
|
await test.step(`Select Script sub-tab "${subTab}"`, async () => {
|
|
await selectRequestPaneTab(page, 'Script');
|
|
const trigger = buildCommonLocators(page).paneTabs.tabTrigger(subTab);
|
|
await trigger.click();
|
|
await expect(trigger).toContainClass('active');
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Clear and type into a CodeMirror editor identified by test ID
|
|
* @param page - The page object
|
|
* @param editorTestId - The test ID of the editor container
|
|
* @param newContent - The content to type
|
|
*/
|
|
const editCodeMirrorEditor = async (page: Page, editorTestId: string, newContent: string) => {
|
|
await test.step(`Edit CodeMirror editor "${editorTestId}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
const editor = locators.codeMirror.byTestId(editorTestId);
|
|
await editor.waitFor({ state: 'visible' });
|
|
const textarea = editor.locator('textarea[tabindex="0"]');
|
|
await textarea.focus();
|
|
const selectAll = process.platform === 'darwin' ? 'Meta+a' : 'Control+a';
|
|
await page.keyboard.press(selectAll);
|
|
await page.keyboard.press('Backspace');
|
|
await page.keyboard.type(newContent, { delay: 5 });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Add a pre-request script (navigates to Script > Pre Request and replaces editor content)
|
|
* @param page - The page object
|
|
* @param content - The script content to add
|
|
*/
|
|
const addPreRequestScript = async (page: Page, content: string) => {
|
|
await test.step('Add pre-request script', async () => {
|
|
await selectScriptSubTab(page, 'pre-request');
|
|
await editCodeMirrorEditor(page, 'pre-request-script-editor', content);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Add a post-response script (navigates to Script > Post Response and replaces editor content)
|
|
* @param page - The page object
|
|
* @param content - The script content to add
|
|
*/
|
|
const addPostResponseScript = async (page: Page, content: string) => {
|
|
await test.step('Add post-response script', async () => {
|
|
await selectScriptSubTab(page, 'post-response');
|
|
await editCodeMirrorEditor(page, 'post-response-script-editor', content);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Add a test script (navigates to Tests tab and replaces editor content)
|
|
* @param page - The page object
|
|
* @param content - The test script content to add
|
|
*/
|
|
const addTestScript = async (page: Page, content: string) => {
|
|
await test.step('Add test script', async () => {
|
|
await selectRequestPaneTab(page, 'Tests');
|
|
await editCodeMirrorEditor(page, 'test-script-editor', content);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Add a script to a folder's Settings → Script tab.
|
|
* @param page - The page object
|
|
* @param folderName - The folder to target (must be visible in the sidebar)
|
|
* @param phase - Which phase to write: 'pre-request' or 'post-response'
|
|
* @param content - The script content to add
|
|
*/
|
|
const addFolderScript = async (
|
|
page: Page,
|
|
folderName: string,
|
|
phase: 'pre-request' | 'post-response',
|
|
content: string
|
|
) => {
|
|
await test.step(`Add ${phase} script on folder "${folderName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
await locators.sidebar.folder(folderName).first().dblclick();
|
|
await locators.paneTabs.folderSettingsTab('script').click();
|
|
await locators.paneTabs.tabTrigger(phase).click();
|
|
await editCodeMirrorEditor(page, `folder-${phase}-script-editor`, content);
|
|
const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s';
|
|
await page.keyboard.press(saveShortcut);
|
|
await page.waitForTimeout(400);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Add a script to a collection's Settings → Script tab.
|
|
* @param page - The page object
|
|
* @param collectionName - The collection to target
|
|
* @param phase - Which phase to write: 'pre-request' or 'post-response'
|
|
* @param content - The script content to add
|
|
*/
|
|
const addCollectionScript = async (
|
|
page: Page,
|
|
collectionName: string,
|
|
phase: 'pre-request' | 'post-response',
|
|
content: string
|
|
) => {
|
|
await test.step(`Add ${phase} script on collection "${collectionName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
await locators.sidebar.collection(collectionName).hover();
|
|
await locators.actions.collectionActions(collectionName).click();
|
|
await locators.dropdown.item('Settings').click();
|
|
await locators.paneTabs.collectionSettingsTab('script').click();
|
|
await locators.paneTabs.tabTrigger(phase).click();
|
|
await editCodeMirrorEditor(page, `collection-${phase}-script-editor`, content);
|
|
const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s';
|
|
await page.keyboard.press(saveShortcut);
|
|
await page.waitForTimeout(400);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Click send and wait for at least one error card to appear.
|
|
* @param page - The page object
|
|
*/
|
|
const sendAndWaitForErrorCard = async (page: Page) => {
|
|
await test.step('Send request and wait for error card', async () => {
|
|
const { request } = buildCommonLocators(page);
|
|
const scriptErrorLocators = buildScriptErrorLocators(page);
|
|
await request.sendButton().click();
|
|
await scriptErrorLocators.card().waitFor({ state: 'visible', timeout: 15000 });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Click send and wait for a response status code to appear.
|
|
* Used for requests that succeed at HTTP level but may have post-response/test errors.
|
|
* @param page - The page object
|
|
*/
|
|
const sendAndWaitForResponse = async (page: Page) => {
|
|
await test.step('Send request and wait for response', async () => {
|
|
const { request, response } = buildCommonLocators(page);
|
|
await request.sendButton().click();
|
|
await response.statusCode().waitFor({ state: 'visible', timeout: 15000 });
|
|
});
|
|
};
|
|
|
|
const fieldEditor = (page: Page, labelText: string) =>
|
|
page
|
|
.locator('label')
|
|
.filter({ hasText: new RegExp(`^${escapeRegExp(labelText)}$`) })
|
|
.locator('..')
|
|
.locator('.single-line-editor-wrapper .CodeMirror');
|
|
|
|
/**
|
|
* Open the auth mode dropdown and pick a mode by its visible label.
|
|
* @param page - The page object
|
|
* @param modeLabel - Dropdown item text (e.g. 'Bearer Token', 'Basic Auth')
|
|
*/
|
|
const selectAuthMode = async (page: Page, modeLabel: string) => {
|
|
await page.locator('.auth-mode-label').click();
|
|
await page.locator('.dropdown-item').filter({ hasText: modeLabel }).click();
|
|
};
|
|
|
|
/**
|
|
* Type into a single-line CodeMirror editor identified by its sibling label.
|
|
* @param page - The page object
|
|
* @param labelText - Exact label text next to the editor
|
|
* @param value - The text to type
|
|
*/
|
|
const typeIntoField = async (page: Page, labelText: string, value: string) => {
|
|
await fieldEditor(page, labelText).click();
|
|
await page.keyboard.type(value);
|
|
};
|
|
|
|
/**
|
|
* Read the current value of a single-line CodeMirror editor identified by its sibling label.
|
|
* @param page - The page object
|
|
* @param labelText - Exact label text next to the editor
|
|
*/
|
|
const readField = async (page: Page, labelText: string): Promise<string> => {
|
|
const editor = fieldEditor(page, labelText).first();
|
|
await editor.waitFor({ state: 'visible' });
|
|
return editor.evaluate((el: any) => (el as any).CodeMirror?.getValue() ?? '');
|
|
};
|
|
|
|
const createExampleFromSidebar = async (page: Page, requestName: string, exampleName: string, description: string = '') => {
|
|
const requestRow = page.locator('.collection-item-name').filter({ hasText: requestName }).first();
|
|
|
|
await requestRow.hover();
|
|
await requestRow.locator('..').locator('.menu-icon').click({ force: true });
|
|
await page.locator('.dropdown-item').filter({ hasText: 'Create Example' }).click();
|
|
|
|
const exampleInput = page.getByTestId('create-example-name-input');
|
|
await expect(exampleInput).toBeVisible();
|
|
await exampleInput.clear();
|
|
await exampleInput.fill(exampleName);
|
|
const descriptionInput = page.getByTestId('create-example-description-input');
|
|
await descriptionInput.clear();
|
|
await descriptionInput.fill(description);
|
|
await page.getByRole('button', { name: 'Create Example' }).click();
|
|
await expect(page.locator('text=Create Response Example')).not.toBeAttached();
|
|
};
|
|
|
|
const openExampleFromSidebar = async (page: Page, requestName: string, exampleName: string, index: number = 0) => {
|
|
const requestRow = page.locator('.collection-item-name').filter({ hasText: requestName }).first();
|
|
const requestBranch = requestRow.locator('..');
|
|
const exampleRow = requestBranch
|
|
.locator('.collection-item-name')
|
|
.filter({ has: page.locator('.example-icon') })
|
|
.getByText(exampleName, { exact: true })
|
|
.nth(index);
|
|
|
|
if (!(await exampleRow.isVisible())) {
|
|
await requestRow.getByTestId('request-item-chevron').click();
|
|
}
|
|
|
|
await expect(exampleRow).toBeVisible();
|
|
await exampleRow.click();
|
|
};
|
|
|
|
/**
|
|
* Open the Generate Code dialog and return the visible snippet text.
|
|
* @param page - The page object
|
|
* @returns The text content of the generated code snippet
|
|
*/
|
|
const getGeneratedSnippet = async (page: Page): Promise<string> => {
|
|
return await test.step('Open Generate Code dialog and read snippet', async () => {
|
|
const { request } = buildCommonLocators(page);
|
|
|
|
await request.generateCodeButton().click();
|
|
await expect(page.getByRole('dialog')).toBeVisible();
|
|
|
|
const codeEditor = page.locator('.editor-content .CodeMirror').first();
|
|
await expect(codeEditor).toBeVisible();
|
|
|
|
return (await codeEditor.textContent()) ?? '';
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Close the Generate Code dialog and wait for it to disappear.
|
|
* @param page - The page object
|
|
* @returns void
|
|
*/
|
|
const closeGenerateCodeDialog = async (page: Page) => {
|
|
await test.step('Close Generate Code dialog', async () => {
|
|
const { modal } = buildCommonLocators(page);
|
|
await modal.closeButton().click();
|
|
await modal.closeButton().waitFor({ state: 'hidden' });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Open a request inside a folder by exact request name.
|
|
* @param page - The page object
|
|
* @param folderName - The name of the folder containing the request
|
|
* @param requestName - The exact name of the request to open
|
|
* @returns void
|
|
*/
|
|
const openRequestInFolder = async (page: Page, folderName: string, requestName: string) => {
|
|
await test.step(`Open request "${requestName}" in folder "${folderName}"`, async () => {
|
|
const { sidebar } = buildCommonLocators(page);
|
|
await sidebar.folder(folderName).click();
|
|
|
|
const folderWrapper = page.locator('.collection-item-name').filter({ hasText: folderName }).locator('..');
|
|
const escapedName = requestName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const requestRow = folderWrapper.locator('.collection-item-name').filter({
|
|
has: page.locator('.item-name').filter({ hasText: new RegExp(`^${escapedName}$`) })
|
|
});
|
|
await requestRow.click();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Toggle the URL encoding setting on the current request idempotently.
|
|
* @param page - The page object
|
|
* @param enabled - Whether URL encoding should be enabled
|
|
* @returns void
|
|
*/
|
|
const setUrlEncoding = async (page: Page, enabled: boolean) => {
|
|
await test.step(`Set URL encoding ${enabled ? 'ON' : 'OFF'}`, async () => {
|
|
await selectRequestPaneTab(page, 'Settings');
|
|
const toggle = page.getByTestId('encode-url-toggle');
|
|
await expect(toggle).toBeVisible();
|
|
const current = (await toggle.getAttribute('aria-checked')) === 'true';
|
|
if (current !== enabled) {
|
|
await toggle.click();
|
|
await expect(toggle).toHaveAttribute('aria-checked', String(enabled));
|
|
}
|
|
});
|
|
};
|
|
|
|
type DialogOptions = {
|
|
showOpenDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>;
|
|
};
|
|
|
|
const openWorkspaceFromDialog = async (app: any, page: any, targetPath: string) => {
|
|
await app.evaluate(
|
|
({ dialog }: { dialog: DialogOptions }, workspacePath: string) => {
|
|
dialog.showOpenDialog = () =>
|
|
Promise.resolve({ canceled: false, filePaths: [workspacePath] });
|
|
},
|
|
targetPath
|
|
);
|
|
|
|
await page.getByTestId('workspace-menu').click();
|
|
await page.locator('.dropdown-item').filter({ hasText: 'Open workspace' }).click();
|
|
};
|
|
|
|
/**
|
|
* Trigger "Generate Docs" from a collection's sidebar context menu and capture
|
|
* the generated HTML documentation.
|
|
*
|
|
* The GenerateDocumentation modal hands the file to `FileSaver.saveAs`, which
|
|
* builds an in-memory Blob and saves it via an `<a download>` click rather than
|
|
* an Electron IPC write. Electron doesn't surface that as a Playwright
|
|
* `download` event, so instead we intercept it in the renderer: `URL.createObjectURL`
|
|
* gives us the Blob's content, and overriding the anchor click captures the
|
|
* suggested file name while suppressing the real save (no file leaks to disk).
|
|
*
|
|
* @param page - The page object
|
|
* @param collectionName - The name of the collection to generate docs for
|
|
* @returns The generated HTML content and the download's suggested file name
|
|
*/
|
|
const generateCollectionDocs = async (
|
|
page: Page,
|
|
collectionName: string,
|
|
beforeGenerate?: () => Promise<void>
|
|
): Promise<{ content: string; fileName: string }> => {
|
|
return await test.step(`Generate docs for collection "${collectionName}"`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
|
|
// Make sure the collection has finished mounting before interacting — on a
|
|
// cold start the row (and its hover-revealed actions icon) isn't ready yet,
|
|
// so this keeps the helper self-sufficient for any caller.
|
|
await waitForCollectionMount(page, collectionName);
|
|
|
|
// Open the collection's context menu and click "Generate Docs"
|
|
await locators.sidebar.collection(collectionName).hover();
|
|
const collectionAction = locators.actions.collectionActions(collectionName);
|
|
await expect(collectionAction).toBeVisible({ timeout: 2000 });
|
|
await collectionAction.click();
|
|
await locators.generateDocs.menuItem().click();
|
|
|
|
// Wait for the Generate Documentation modal to reach its ready (non-loading)
|
|
// state — the confirm button reads "Loading..." while the collection's items
|
|
// are still mounting and only becomes "Generate" once they are ready.
|
|
const modal = locators.generateDocs.modal();
|
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
|
const generateButton = locators.generateDocs.generateButton();
|
|
await expect(generateButton).toBeEnabled({ timeout: 10000 });
|
|
|
|
// Let the caller interact with the modal (e.g. toggle environment selection)
|
|
// after it is ready and before the docs are generated.
|
|
if (beforeGenerate) {
|
|
await beforeGenerate();
|
|
}
|
|
|
|
// Arm the renderer-side interception before the save fires. `file-saver`
|
|
// (v2) reads the Blob through `URL.createObjectURL` and then triggers the
|
|
// save by dispatching a synthetic click on a detached `<a download>` (via
|
|
// `setTimeout(…, 0)`), so both points are intercepted. Each is exposed as a
|
|
// promise to absorb that deferred dispatch without a race.
|
|
await page.evaluate(() => {
|
|
const w = window as any;
|
|
const originalCreate = URL.createObjectURL.bind(URL);
|
|
const originalDispatch = HTMLAnchorElement.prototype.dispatchEvent;
|
|
|
|
w.__docsContent = new Promise<string>((resolve) => {
|
|
URL.createObjectURL = function (obj: Blob | MediaSource) {
|
|
if (obj instanceof Blob) {
|
|
obj.text().then(resolve);
|
|
}
|
|
return originalCreate(obj as Blob);
|
|
};
|
|
});
|
|
|
|
w.__docsFileName = new Promise<string>((resolve) => {
|
|
HTMLAnchorElement.prototype.dispatchEvent = function (this: HTMLAnchorElement, event: Event) {
|
|
if (this.download && event && event.type === 'click') {
|
|
resolve(this.download);
|
|
// Suppress the actual save — the Blob content is already captured.
|
|
return true;
|
|
}
|
|
return originalDispatch.call(this, event);
|
|
};
|
|
});
|
|
});
|
|
|
|
await generateButton.click();
|
|
|
|
const content = await page.evaluate(() => (window as any).__docsContent as Promise<string>);
|
|
const fileName = await page.evaluate(() => (window as any).__docsFileName as Promise<string>);
|
|
|
|
// The modal closes itself on the success path.
|
|
await expect(modal).toBeHidden({ timeout: 5000 });
|
|
|
|
return { content, fileName };
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Set the request's app code. Opens the App tab and writes the editor value
|
|
* directly via the CodeMirror API (avoids auto-close-bracket corruption when
|
|
* typing HTML/JS char-by-char). The app must not be enabled (editor visible).
|
|
* @param page - The page object
|
|
* @param code - The HTML/JS app code
|
|
*/
|
|
const setAppCode = async (page: Page, code: string) => {
|
|
await test.step('Set app code', async () => {
|
|
await selectRequestPaneTab(page, 'App');
|
|
const editor = page.getByTestId('app-code-editor').locator('.CodeMirror').first();
|
|
await editor.waitFor({ state: 'visible' });
|
|
await editor.evaluate((el, val) => {
|
|
const cm = (el as any).CodeMirror;
|
|
if (cm) cm.setValue(val);
|
|
}, code);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Enable app mode via the App tab's "Enable App" toggle. Asserts the app view
|
|
* takes over the request/response area.
|
|
* @param page - The page object
|
|
*/
|
|
const enableApp = async (page: Page) => {
|
|
await test.step('Enable app mode (App tab toggle)', async () => {
|
|
await selectRequestPaneTab(page, 'App');
|
|
await page.getByTestId('app-enable-toggle').click();
|
|
await expect(page.getByTestId('app-view')).toBeVisible({ timeout: 5000 });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Exit app mode via the app view's "Exit to editor" button.
|
|
* @param page - The page object
|
|
*/
|
|
const exitApp = async (page: Page) => {
|
|
await test.step('Exit app mode', async () => {
|
|
await page.getByTestId('app-exit-button').click();
|
|
await expect(page.getByTestId('app-view')).toBeHidden({ timeout: 5000 });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Switch the active request's view mode using the collection toolbar toggle.
|
|
* @param page - The page object
|
|
* @param mode - 'request' | 'app' | 'file'
|
|
*/
|
|
const selectViewMode = async (page: Page, mode: 'request' | 'app' | 'file') => {
|
|
await test.step(`Switch view mode to "${mode}"`, async () => {
|
|
await page.getByTestId(`view-mode-${mode}`).click();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Read the decoded HTML the app webview is loading (its data: URL src).
|
|
* Useful for asserting the injected ctx bootstrap and user code.
|
|
* @param page - The page object
|
|
* @returns The decoded HTML document string
|
|
*/
|
|
const getAppWebviewHtml = async (page: Page): Promise<string> => {
|
|
const webview = page.getByTestId('app-view').locator('webview');
|
|
await webview.waitFor({ state: 'attached', timeout: 5000 });
|
|
const src = await webview.getAttribute('src');
|
|
if (!src) return '';
|
|
const comma = src.indexOf(',');
|
|
return decodeURIComponent(src.slice(comma + 1));
|
|
};
|
|
|
|
/**
|
|
* Create a standalone (collection-level or folder-level) app via the sidebar
|
|
* context menu. Opens the new tab once created.
|
|
* @param page - The page object
|
|
* @param appName - Name to give the new app
|
|
* @param parent - Either `{ collectionName }` for a collection-level app,
|
|
* or `{ collectionName, folderName }` for a folder-level app.
|
|
*/
|
|
const createApp = async (
|
|
page: Page,
|
|
appName: string,
|
|
parent: { collectionName: string; folderName?: string }
|
|
) => {
|
|
await test.step(`Create app "${appName}" in ${parent.folderName ? `folder "${parent.folderName}"` : `collection "${parent.collectionName}"`}`, async () => {
|
|
const locators = buildCommonLocators(page);
|
|
|
|
if (parent.folderName) {
|
|
const collectionScope = locators.sidebar.collectionScope(parent.collectionName);
|
|
const folderRow = collectionScope.locator('.collection-item-name').filter({ hasText: parent.folderName });
|
|
await folderRow.hover();
|
|
await folderRow.locator('.menu-icon').click();
|
|
} else {
|
|
await locators.sidebar.collection(parent.collectionName).hover();
|
|
const collectionAction = locators.actions.collectionActions(parent.collectionName);
|
|
await expect(collectionAction).toBeVisible({ timeout: 2000 });
|
|
await collectionAction.click();
|
|
}
|
|
|
|
await page.locator('.tippy-box:visible .dropdown-item').filter({ hasText: 'New App' }).click();
|
|
|
|
const modal = page.locator('.bruno-modal').filter({ hasText: 'New App' });
|
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
|
await modal.locator('input[name="appName"]').fill(appName);
|
|
await modal.getByRole('button', { name: 'Create', exact: true }).click();
|
|
await expect(modal).toBeHidden({ timeout: 5000 });
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Switch the CollectionApp tab between Code and Preview views.
|
|
* @param page - The page object
|
|
* @param view - 'code' | 'preview'
|
|
*/
|
|
const selectAppView = async (page: Page, view: 'code' | 'preview') => {
|
|
await test.step(`Switch collection app to "${view}"`, async () => {
|
|
await page.getByTestId(`collection-app-view-${view}`).click();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Rename a websocket message by double-clicking its label and typing a new name.
|
|
* @param page - The page object
|
|
* @param index - The zero-based index of the message in the list
|
|
* @param name - The new message name
|
|
*/
|
|
const renameWsMessage = async (page: Page, index: number, name: string) => {
|
|
await test.step(`Rename websocket message ${index} to "${name}"`, async () => {
|
|
const ws = buildWebsocketCommonLocators(page);
|
|
await ws.message.label(index).dblclick();
|
|
const nameInput = ws.message.nameInput(index);
|
|
await expect(nameInput).toBeVisible();
|
|
await nameInput.selectText();
|
|
await page.keyboard.type(name);
|
|
await nameInput.press('Enter');
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Scroll a row inside a react-virtuoso table (request/folder/collection vars or
|
|
* env vars — both rendered with the `table-container` className) into view so it
|
|
* mounts in the DOM. Virtuoso only keeps rows near the viewport mounted and can
|
|
* restore a persisted scroll position, so reset to the top first — retried, to
|
|
* beat that restore — then walk down until `target` mounts.
|
|
*/
|
|
const scrollVirtuosoRowIntoView = async (page: Page, target: Locator) => {
|
|
if (await target.count()) {
|
|
await target.scrollIntoViewIfNeeded().catch(() => {});
|
|
return;
|
|
}
|
|
|
|
const scroll = (toTop: boolean) => page.evaluate((toTop) => {
|
|
let moved = false;
|
|
document.querySelectorAll('.table-container').forEach((el) => {
|
|
const before = el.scrollTop;
|
|
el.scrollTop = toTop ? 0 : el.scrollTop + el.clientHeight * 0.7 + 80;
|
|
if (el.scrollTop !== before) moved = true;
|
|
});
|
|
return moved;
|
|
}, toTop);
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
await scroll(true);
|
|
await page.waitForTimeout(120);
|
|
if (await target.count()) {
|
|
await target.scrollIntoViewIfNeeded().catch(() => {});
|
|
return;
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < 40; i++) {
|
|
if (await target.count()) break;
|
|
if (!(await scroll(false))) break;
|
|
await page.waitForTimeout(120);
|
|
}
|
|
await target.scrollIntoViewIfNeeded().catch(() => {});
|
|
};
|
|
|
|
export {
|
|
waitForReadyPage,
|
|
scrollVirtuosoRowIntoView,
|
|
dismissImportIssuesToasts,
|
|
closeAllCollections,
|
|
openCollection,
|
|
createCollection,
|
|
createRequest,
|
|
createUntitledRequest,
|
|
createTransientRequest,
|
|
fillRequestUrl,
|
|
deleteRequest,
|
|
deleteCollectionFromOverview,
|
|
importCollection,
|
|
removeCollection,
|
|
createFolder,
|
|
openEnvironmentSelector,
|
|
openEnvironmentConfigTab,
|
|
createEnvironment,
|
|
addEnvironmentVariable,
|
|
addEnvironmentVariables,
|
|
addRowToActiveTab,
|
|
deleteAllGlobalEnvironments,
|
|
saveEnvironment,
|
|
closeEnvironmentPanel,
|
|
selectEnvironment,
|
|
sendRequest,
|
|
openRequest,
|
|
openfolder,
|
|
openFolderRequest,
|
|
selectfolderPaneTab,
|
|
selectFolderScriptPaneTab,
|
|
openCollectionSettings,
|
|
selectCollectionPaneTab,
|
|
selectCollectionScriptPaneTab,
|
|
focusFolderSettingsTab,
|
|
focusCollectionSettingsTab,
|
|
getResponseBody,
|
|
expectResponseContains,
|
|
selectRequestPaneTab,
|
|
selectRequestBodyMode,
|
|
selectResponsePaneTab,
|
|
mockBrowseFiles,
|
|
addMultipartFileToLastRow,
|
|
removeFirstMultipartFile,
|
|
sendRequestAndWaitForResponse,
|
|
switchResponseFormat,
|
|
switchToPreviewTab,
|
|
switchToEditorTab,
|
|
clickResponseAction,
|
|
addAssertion,
|
|
editAssertion,
|
|
deleteAssertion,
|
|
saveRequest,
|
|
addGrpcMessage,
|
|
generateGrpcSampleMessage,
|
|
selectGrpcMethod,
|
|
closeAllTabs,
|
|
createWorkspace,
|
|
switchWorkspace,
|
|
selectScriptSubTab,
|
|
editCodeMirrorEditor,
|
|
addPreRequestScript,
|
|
addPostResponseScript,
|
|
addTestScript,
|
|
addFolderScript,
|
|
addCollectionScript,
|
|
expandFolder,
|
|
sendAndWaitForErrorCard,
|
|
sendAndWaitForResponse,
|
|
selectAuthMode,
|
|
typeIntoField,
|
|
readField,
|
|
createExampleFromSidebar,
|
|
openExampleFromSidebar,
|
|
openWorkspaceFromDialog,
|
|
getGeneratedSnippet,
|
|
closeGenerateCodeDialog,
|
|
openRequestInFolder,
|
|
setUrlEncoding,
|
|
generateCollectionDocs,
|
|
setAppCode,
|
|
enableApp,
|
|
exitApp,
|
|
selectViewMode,
|
|
getAppWebviewHtml,
|
|
createApp,
|
|
selectAppView,
|
|
renameWsMessage
|
|
};
|
|
|
|
export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };
|