Files
bruno/tests/utils/page/actions.ts
Abhishek S Lal ab2326deb3 fix(collection-watcher): prevent crash when deleting collections (#7470)
* fix(collection-watcher): guard against events firing after collection deletion

When deleting an OpenAPI-synced collection, saveBrunoConfig() writes to
bruno.json which creates buffered chokidar events (80ms stabilityThreshold).
If the collection directory is removed before those events fire,
getCollectionFormat() throws "No collection configuration found" for each
.bru file in the collection.

Add fs.existsSync(collectionPath) guards in the change, unlink, and
unlinkDir handlers to bail out early when the collection root no longer exists.

* fix(workspaces): ensure collection watcher stops before deletion

Added logic to remove the collection from the watcher when deleting files, preventing chokidar from firing events on a directory that is being removed. This change enhances stability during collection deletions by ensuring the watcher is properly managed.

* refactor(workspaces): remove redundant collection watcher logic during deletion

Eliminated the logic for stopping the collection watcher before deletion, streamlining the action for removing collections from workspaces. This change simplifies the code and maintains functionality without unnecessary complexity.

* test(collection): add integration test for collection deletion functionality

Introduced a new test suite to verify the deletion of collections from the workspace overview. The test ensures that collections are properly removed from both the UI and the file system, confirming the absence of any uncaught errors during the deletion process. This addition enhances the test coverage for collection management features.

* feat(collection): implement deleteCollectionFromOverview utility function

Added a new utility function to delete a collection directly from the workspace overview page. This function encapsulates the steps required to navigate the UI, confirm deletion, and ensure the collection is removed from both the interface and the file system. Updated the corresponding test to utilize this new function, enhancing code reusability and test clarity.

* fix(collection-watcher): add guards for collection path existence and error handling

Enhanced the unlink and unlinkDir functions to check for the existence of the collection path before proceeding. Added error handling for the getCollectionFormat function to prevent crashes when the collection format cannot be retrieved. These changes improve stability and robustness during collection deletion operations.
2026-03-18 18:44:04 +05:30

1089 lines
39 KiB
TypeScript

import { test, expect, Page } from '../../../playwright';
import { buildCommonLocators } from './locators';
type SandboxMode = 'safe' | 'developer';
/**
* 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.hover();
await firstCollection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').getByText('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 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) => {
await test.step(`Create 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 });
await createCollectionModal.getByLabel('Name').fill(collectionName);
const locationInput = createCollectionModal.getByLabel('Location');
if (await locationInput.isVisible()) {
// Location input can be read-only; drop the attribute so fill can type
await locationInput.evaluate((el) => {
const input = el as HTMLInputElement;
input.removeAttribute('readonly');
input.readOnly = false;
});
await locationInput.fill(collectionLocation);
}
await createCollectionModal.getByRole('button', { name: 'Create', exact: true }).click();
await createCollectionModal.waitFor({ state: 'detached', timeout: 15000 });
await page.waitForTimeout(200);
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;
};
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('#send-request').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();
await page.keyboard.press('Meta+s');
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 } = options;
const parentType = inFolder ? 'folder' : 'collection';
await test.step(`Create 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();
await locators.actions.collectionActions(parentName).click();
}
await locators.dropdown.item('New Request').click();
await page.getByPlaceholder('Request Name').fill(requestName);
if (method) {
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;
};
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();
}
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.getByPlaceholder('Folder Name').fill(folderName);
await locators.modal.button('Create').click();
await expect(locators.sidebar.folder(folderName)).toBeVisible();
});
};
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/);
}
});
};
/**
* 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
* @param page - The page object
* @param variable - The variable to add (name, value, and optional secret flag)
* @param index - The index of the variable (0-based)
* @returns void
*/
const addEnvironmentVariable = async (
page: Page,
variable: EnvironmentVariable,
index: number
) => {
await test.step(`Add environment variable "${variable.name}"`, async () => {
const nameInput = page.locator(`input[name="${index}.name"]`);
await nameInput.waitFor({ state: 'visible' });
await nameInput.fill(variable.name);
// Wait for the CodeMirror editor in the row to be ready
const variableRow = page.locator('tr').filter({ has: page.locator(`input[name="${index}.name"]`) });
const codeMirror = variableRow.locator('.CodeMirror');
await codeMirror.waitFor({ state: 'visible' });
await codeMirror.click();
await page.keyboard.type(variable.value);
if (variable.isSecret) {
const secretCheckbox = page.locator(`input[name="${index}.secret"]`);
await secretCheckbox.waitFor({ state: 'visible' });
await secretCheckbox.check();
}
});
};
/**
* Add multiple environment variables to the currently open environment
* @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], i);
}
});
};
/**
* 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.getByRole('button', { name: 'Save' }).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 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.click();
// Wait for dropdown to be visible before clicking the format option
const dropdown = page.getByTestId('format-response-tab-dropdown');
await dropdown.waitFor({ state: 'visible' });
await dropdown.getByText(format).click();
});
};
/**
* Switch to the preview tab
* @param page - The page object
*/
const switchToPreviewTab = async (page: Page) => {
await test.step('Switch to preview tab', async () => {
const responseFormatTab = page.getByTestId('format-response-tab');
await responseFormatTab.click();
const previewTab = page.getByTestId('preview-response-tab');
await previewTab.click();
});
};
/**
* Switch to the editor tab
* @param page - The page object
*/
const switchToEditorTab = async (page: Page) => {
await test.step('Switch to editor tab', async () => {
const responseFormatTab = page.getByTestId('format-response-tab');
await responseFormatTab.click();
const previewTab = page.getByTestId('preview-response-tab');
await previewTab.click();
});
};
/**
* 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 selectRequestPaneTab = async (page: Page, tabName: string) => {
await test.step(`Wait for request to open up "${tabName}"`, async () => {
const requestPane = page.locator('.request-pane > .px-4');
await expect(requestPane).toBeVisible();
await expect(requestPane.locator('.tabs')).toBeVisible();
});
await test.step(`Select request pane tab "${tabName}"`, async () => {
const visibleTab = page.locator('.tabs').getByRole('tab', { name: tabName });
// Check if tab is directly visible
if (await visibleTab.isVisible()) {
await visibleTab.click();
await expect(visibleTab).toContainClass('active');
return;
}
const overflowButton = page.locator('.tabs .more-tabs');
// Check if there's an overflow dropdown
if (await overflowButton.isVisible()) {
await overflowButton.click();
// Wait for dropdown to appear and click the menu item (overflow tabs are rendered as menuitems)
const dropdownItem = page.locator('.tippy-box .dropdown-item').filter({ hasText: tabName });
await dropdownItem.click();
await expect(visibleTab).toContainClass('active');
return;
}
// If neither found, fail with a helpful message
throw new Error(`Tab "${tabName}" not found in visible tabs or overflow dropdown`);
});
};
/**
* 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
const menu = page.getByTestId('response-actions-menu');
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 () => {
await page.keyboard.press('Meta+s');
await expect(page.getByText('Request saved successfully').last()).toBeVisible({ timeout: 3000 });
await page.waitForTimeout(200);
});
};
/**
* 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();
}
});
};
export {
closeAllCollections,
openCollection,
createCollection,
createRequest,
createUntitledRequest,
createTransientRequest,
fillRequestUrl,
deleteRequest,
deleteCollectionFromOverview,
importCollection,
removeCollection,
createFolder,
openEnvironmentSelector,
createEnvironment,
addEnvironmentVariable,
addEnvironmentVariables,
saveEnvironment,
closeEnvironmentPanel,
selectEnvironment,
sendRequest,
openRequest,
openFolderRequest,
getResponseBody,
expectResponseContains,
selectRequestPaneTab,
sendRequestAndWaitForResponse,
switchResponseFormat,
switchToPreviewTab,
switchToEditorTab,
clickResponseAction,
addAssertion,
editAssertion,
deleteAssertion,
saveRequest,
closeAllTabs
};
export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };