mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-27 06:34:06 +00:00
* feat: add functionality to retrieve collection path from transient file requests in IPC module * feat: implement transient directory handling in collection mounting process * add action to store transient directory paths in Redux state * update IPC handler to create and return a temporary directory for collections * modify collection mount action to dispatch transient directory addition * feat: add CreateTransientRequest component for managing transient requests * implement CreateTransientRequest component to facilitate the creation of HTTP, GraphQL, gRPC, and WebSocket transient requests * integrate the component into RequestTabs for user interaction * update collection and request handling to differentiate between transient and non-transient requests * enhance Redux actions to support transient request creation and management * feat: enhance transient request handling and add temp directory watcher * refactor Redux actions for HTTP, gRPC, and WebSocket requests to utilize a unified task queue for transient requests * implement a new helper function to retrieve collection paths from temporary directory metadata * add functionality to watch transient directories for file changes, excluding metadata.json * integrate transient directory watcher into the collection mounting process * feat: enhance transient request management with temp directory integration * update generateTransientRequestName function to accept tempDirectory parameter for improved request naming * modify CreateTransientRequest component to utilize tempDirectory for transient request creation * adjust middleware to check for transient status based on tempDirectory * implement transient file and directory identification in collections slice for better state management * feat: add SaveTransientRequest component for managing transient requests * implement SaveTransientRequest component to facilitate saving transient requests to selected folders * create StyledWrapper for component styling * introduce useCollectionFolderTree hook for managing folder navigation and state * update Redux actions to handle saving requests from transient state * feat: implement SaveTransientRequestContainer and enhance modal management * add SaveTransientRequestContainer to manage multiple transient request modals * refactor SaveTransientRequest component to utilize Redux for modal state management * implement open and close actions for transient request modals in Redux slice * update Bruno page to include SaveTransientRequestContainer for improved UI integration * feat: enhance SaveTransientRequest component with new folder creation functionality * add input for creating new folders within the SaveTransientRequest component * implement validation for new folder names and filesystem names * integrate folder creation logic with Redux actions for better state management * update styling for new folder input elements in StyledWrapper * improve modal behavior to reset state when opened * feat: update CreateTransientRequest to utilize collection presets for request URLs * Refactored CreateTransientRequest component to retrieve request URLs from collection presets. * Enhanced request handling by dynamically setting request URLs based on the selected collection's configuration. * refactor: clean up unused imports and adjust request handling in collections actions * Removed unused imports from actions.js to streamline the code. * Updated the saveRequest function to reject the modal instead of resolving it when handling transient requests. * Cleaned up comments in index.js for better clarity. * refactor: streamline transient request handling and improve save functionality * Removed success toast notifications from CreateTransientRequest component to simplify user feedback. * Enhanced SaveTransientRequest component to handle transient requests more effectively, including improved filename resolution and validation. * Added IPC handler for saving transient requests, ensuring proper file management and error handling. * Updated Redux actions to check for duplicate transient request names within the temporary directory. * feat: enhance request handling in ConfirmCollectionCloseDrafts component * Added logic to differentiate between transient and non-transient drafts, ensuring transient requests are saved individually before closing the collection. * Improved user feedback by displaying unsaved changes for both regular and transient requests. * Updated save and discard functionality to handle all drafts appropriately, enhancing overall user experience. * fix:fixed useCallback dependency array * fix:added request name checks before save * fix: added isTransient to files * fix: added watcher cleanup for temp directory * refactor: enhance transient request handling and optimize component logic * Updated CreateTransientRequest to utilize useMemo for improved performance and prevent unnecessary re-renders. * Refactored generateTransientRequestName to focus solely on transient requests, removing tempDirectory dependency. * Streamlined SaveTransientRequest by consolidating form reset logic and removing unused state variables. * Improved ConfirmCollectionCloseDrafts to differentiate between transient and non-transient drafts more effectively. * Cleaned up imports and optimized Redux actions for better maintainability. * feat: implement transient request file deletion on tab close * Added middleware to handle the deletion of transient request files when tabs are closed. * Enhanced collection-watcher to unlink temporary files, ensuring metadata.json is skipped and only request files are processed. * Improved error handling for file deletion operations. * feat: enhance autosave middleware to skip transient requests * Updated autosave middleware to check for transient requests and skip auto-save operations accordingly. * fix: update ConfirmCollectionCloseDrafts to display all transient drafts * Modified the ConfirmCollectionCloseDrafts component to show all transient drafts without limiting the display to a maximum number. * Removed the conditional message for additional drafts not shown, enhancing the user experience by providing complete visibility of transient requests. * feat: enhance SaveTransientRequest component for better modal management * Refactored SaveTransientRequest and its container to improve modal handling for unsaved transient requests. * Introduced state management for opening specific modals and added functionality to discard all unsaved requests. * Updated Redux actions to manage transient request modals more effectively, ensuring no duplicates are added. * Enhanced user interface to display a list of unsaved requests with options to save or discard them. * feat: improve modal management in SaveTransientRequestContainer * Added useEffect to reset openItemUid when the corresponding modal is no longer present. * Implemented functionality to close all tabs associated with transient requests and show a success message upon discarding them. * Removed unnecessary modal close handler and streamlined modal opening logic for better clarity and performance. * refactor: streamline code formatting and improve readability in collection actions * Consolidated multiple lines of code into single lines for better readability in ConfirmCollectionCloseDrafts and actions.js. * Enhanced consistency in the formatting of function parameters and return statements across the collections slice. * Removed unnecessary line breaks and improved the structure of the code for easier maintenance. * refactor: improve code readability and structure in middleware and actions * Consolidated multiple lines of code into single lines for better readability in middleware.js and actions.js. * Enhanced consistency in formatting function parameters and return statements across the collections slice. * Removed unnecessary line breaks and improved the structure of the code for easier maintenance. * Streamlined dispatch calls for better clarity and performance. * refactor: enhance code readability and consistency in middleware and actions * Improved formatting and structure in middleware.js for dispatch calls. * Streamlined comments and indentation in actions.js for better clarity. * Consolidated multiple lines into single lines where appropriate to enhance readability. * refactor: enhance transient request handling and modal interactions * Improved the modal handling logic for removing collections to differentiate between regular and drafts confirmation modals. * Added new tests for creating and saving transient requests (HTTP, GraphQL, gRPC, WebSocket) ensuring they do not appear in the sidebar until saved. * Introduced utility functions for creating transient requests and filling request URLs, improving code reusability and clarity. * refactor: simplify transient request modal rendering and improve collection watcher logic * Introduced a new TransientRequestModalsRenderer component to streamline modal rendering based on the number of transient requests. * Refactored the collection watcher logic to enhance readability by removing unnecessary setTimeout and consolidating file handling functions. * Improved error handling and logging for the temp directory watcher. * fix: correct spelling of 'WebSocket' in transient request components and tests * Updated the spelling of 'Websocket' to 'WebSocket' in CreateTransientRequest component, transient requests test, and action type definitions for consistency and accuracy.
995 lines
35 KiB
TypeScript
995 lines
35 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();
|
|
|
|
const createCollectionModal = page.locator('.bruno-modal-card').filter({ hasText: 'Create Collection' });
|
|
|
|
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);
|
|
});
|
|
};
|
|
|
|
type CreateRequestOptions = {
|
|
url?: 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, 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 (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();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 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();
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 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 | string,
|
|
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: string = '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(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();
|
|
await page.getByTestId('format-response-tab-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);
|
|
});
|
|
};
|
|
|
|
export {
|
|
closeAllCollections,
|
|
openCollection,
|
|
createCollection,
|
|
createRequest,
|
|
createUntitledRequest,
|
|
createTransientRequest,
|
|
fillRequestUrl,
|
|
deleteRequest,
|
|
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
|
|
};
|
|
|
|
export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };
|