Files

660 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page, type Locator, type Response } from '@playwright/test';
type NavItem = string | [string, string] | Locator | { text: string; exact?: boolean };
/**
* Execute a test suite only if the condition is true
*/
export const describeOnCondition = (shouldDescribe: boolean) =>
shouldDescribe ? test.describe : test.describe.skip;
/**
* Find an element in the dom after the previous element
* Useful for narrowing down which link to click when there are multiple with the same name
*/
// TODO: instead of siblingText + linkText, accept an array of any number items
export const locateFirstAfter = async (page: Page, firstText: string, secondText: string) => {
// It first searches for text containing "firstText" then uses xpath `following` to find "secondText" after it.
// `translate` is used to make the search case-insensitive
const item = page
.locator(
`xpath=//text()[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), "${firstText.toLowerCase()}")]/following::a[starts-with(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), "${secondText.toLowerCase()}")]`
)
.first();
return item;
};
interface LocatorCriteria {
type: string; // The HTML tag type (e.g., "div", "button", "a")
text: string; // The text content to locate
}
export const locateSequence = async (page: Page, sequence: LocatorCriteria[]) => {
if (sequence.length < 2) {
throw new Error('Sequence must contain at least two elements.');
}
let xpathExpression = '';
// Build the XPath for the sequence
for (let i = 0; i < sequence.length; i++) {
const { type, text } = sequence[i];
const tagCondition = type ? `self::${type}` : 'self::*';
const textCondition = `contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), "${text.toLowerCase()}")`;
if (i === 0) {
// The first element in the sequence
xpathExpression += `//${type || '*'}[${textCondition}]`;
} else {
// Subsequent elements in the sequence
xpathExpression += `/following::*[${tagCondition} and ${textCondition}]`;
}
}
// Create the locator
const locator = page.locator(`xpath=${xpathExpression}`).first();
return locator;
};
/**
* Navigate to a page and confirm the header, awaiting each step
*/
export const navToHeader = async (page: Page, navItems: NavItem[], headerText: string) => {
for (const navItem of navItems) {
// This handles some common issues
// 1. Uses name^= to only ensure starts with, because for example badge notifications cause "Settings" to really be "Settings 1"
// 2. To avoid duplicates, we accept a locator
// 3. To avoid duplicates and writing complex locators, we accept an array to pass to locateFirstAfter, which matches item0 then finds the next item1 in the dom
// 4. To avoid partial matches (e.g., "Cat" matching "Category"), accept an object with { text: string, exact?: boolean }
let item;
if (typeof navItem === 'string') {
item = page.locator(`role=link[name^="${navItem}"]`).last();
} else if (Array.isArray(navItem)) {
item = await locateFirstAfter(page, navItem[0], navItem[1]);
} else if (navItem && typeof navItem === 'object' && 'text' in navItem) {
// Object format: { text: string, exact?: boolean }
const { text, exact = false } = navItem;
if (exact) {
item = page.getByRole('link', { name: text, exact: true });
} else {
item = page.locator(`role=link[name^="${text}"]`).last();
}
} else {
// it's a Locator
item = navItem;
}
await expect(item).toBeVisible();
await item.click();
}
// Verify header is correct
const header = page.getByRole('heading', { name: headerText, exact: true });
await expect(header).toBeVisible();
return header;
};
/**
* Clicks on a link and waits for the page to load completely.
*
* NOTE: this util is used to avoid inconsistent behaviour on webkit
*
*/
export const clickAndWait = async (page: Page, locator: Locator) => {
await locator.click();
await page.waitForLoadState('networkidle');
};
// ---------------------------------------------------------------------------
// E2E timing / sync (toast vs API, SPA navigations, guided tour)
//
// Playwright already waits on locators; these helpers cover **ordering** (toast before API, etc.).
// See `tests/e2e/LOCAL_E2E.md` (“race synchronization”). Prefer `withContentManagerSave` /
// `withContentManagerPublish` when you click Save/Publish so the listener is always registered first.
// ---------------------------------------------------------------------------
/** What to wait for after a Content Manager write (see `waitForContentManagerMutation`). */
export type ContentManagerWritePhase = 'save' | 'publish';
/**
* Wait until the matching Content Manager HTTP response succeeds.
*
* - **`save`**: draft document PUT to `/content-manager/…/collection-types|single-types/…`
* - **`publish`**: POST to `…/actions/publish`
*
* On fast machines the success toast can appear before the API finishes; list/home queries may stay
* stale until this resolves.
*/
export const waitForContentManagerMutation = (
page: Page,
phase: ContentManagerWritePhase
): Promise<Response> => {
if (phase === 'save') {
return page.waitForResponse(
(response) =>
response.request().method() === 'PUT' &&
response.url().includes('/content-manager/') &&
(response.url().includes('/collection-types/') ||
response.url().includes('/single-types/')) &&
response.ok()
);
}
return page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
response.url().includes('/actions/publish') &&
response.ok()
);
};
/** Same as `waitForContentManagerMutation(page, 'save')`. */
export const waitForContentManagerDocumentPut = (page: Page) =>
waitForContentManagerMutation(page, 'save');
/** Same as `waitForContentManagerMutation(page, 'publish')`. */
export const waitForContentManagerPublish = (page: Page) =>
waitForContentManagerMutation(page, 'publish');
/**
* Registers the save-PUT listener, runs `act` (e.g. click Save), then awaits the PUT. Use this so
* you never forget to `await` the listener after the click.
*/
export const withContentManagerSave = async (
page: Page,
act: () => Promise<void>
): Promise<void> => {
const done = waitForContentManagerMutation(page, 'save');
await act();
await done;
};
/**
* Same as `withContentManagerSave` for the publish POST (pair with `findAndClose(…, 'Published document')`).
*/
export const withContentManagerPublish = async (
page: Page,
act: () => Promise<void>
): Promise<void> => {
const done = waitForContentManagerMutation(page, 'publish');
await act();
await done;
};
/** Map a segment under `/admin` (or legacy `/admin/…`) to a pathname. */
function resolveAdminUrl(adminPath: string): string {
let s = adminPath.trim();
if (s === '' || s === '/') {
return '/admin';
}
s = s.replace(/^\/+/, '');
if (s === 'admin' || s.startsWith('admin/')) {
s = s.replace(/^admin\/?/, '');
}
return s === '' ? '/admin' : `/admin/${s}`;
}
/**
* Navigate within the admin SPA when auth/session may have changed (cookies, localStorage, tokens).
*
* **`adminPath`** — path after `/admin`: omit or `''` for `/admin`; `'settings'` → `/admin/settings`.
* You do not repeat the `/admin` prefix (legacy strings starting with `/admin` are still accepted).
*
* **`options`** — forwarded to `page.goto` (default `waitUntil: 'domcontentloaded'` unless overridden).
* We default to `domcontentloaded` instead of Playwrights `load`: a client-side redirect to login can
* overlap a full navigation; waiting for `load` then races (Firefox: `NS_BINDING_ABORTED`).
*/
export const gotoAdminPath = async (
page: Page,
adminPath: string = '',
options?: Parameters<Page['goto']>[1]
): Promise<void> => {
const href = resolveAdminUrl(adminPath);
await page.goto(href, {
...options,
waitUntil: options?.waitUntil ?? 'domcontentloaded',
});
};
/**
* Waits until the homepage guided tour card is rendered (depends on guided-tour-meta and dev mode).
* Call before interacting with tour links to avoid racing login / RTK hydration.
*/
export const waitForGuidedTourOverviewReady = async (page: Page): Promise<void> => {
await expect(page.getByRole('heading', { name: 'Discover your application!' })).toBeVisible();
};
/**
* Clicks "Next" in a guided-tour step (`role="dialog"`).
* Tour popovers are often fixed to the viewport edge; Playwright may report the button as visible but
* still refuse to click with "outside of the viewport" after scroll (differs from headless CI vs local
* window chrome, DPI, or panel height). `force` skips the viewport intersection check while still hitting
* the real element.
*/
export const clickGuidedTourDialogNext = async (page: Page, dialogAccessibleName: string) => {
await page
.getByRole('dialog', { name: dialogAccessibleName })
.getByRole('button', { name: 'Next' })
.click({ force: true });
};
const STRAPI_GUIDED_TOUR_KEY = 'STRAPI_GUIDED_TOUR';
/**
* Wait until the guided tour state in localStorage marks a tour completed.
* Use before `page.goto('/admin')` (or any full reload): the UI can show "Done" from React
* while `usePersistentState` is still flushing; reloading rehydrates from storage and can
* briefly (or persistently) show stale progress if this wait is skipped.
*/
export const waitForGuidedTourCompletedInStorage = async (
page: Page,
tourName: 'strapiCloud' | 'contentTypeBuilder' | 'contentManager' | 'apiTokens'
): Promise<void> => {
await page.waitForFunction(
({ key, name }) => {
const raw = localStorage.getItem(key);
if (!raw) return false;
try {
const parsed = JSON.parse(raw) as { tours?: Record<string, { isCompleted?: boolean }> };
return parsed.tours?.[name]?.isCompleted === true;
} catch {
return false;
}
},
{ key: STRAPI_GUIDED_TOUR_KEY, name: tourName },
{ timeout: 15_000 }
);
};
/** @deprecated Renamed to `waitForGuidedTourCompletedInStorage`. */
export const waitForGuidedTourTourCompletedInStorage = waitForGuidedTourCompletedInStorage;
/**
* Look for an element containing text, and then click a sibling close button
*/
interface FindAndCloseOptions {
role?: string;
closeLabel?: string;
required?: boolean;
}
export const findAndClose = async (page: Page, text: string, options: FindAndCloseOptions = {}) => {
const { role = 'status', closeLabel = 'Close', required = true } = options;
// Verify the popup text is visible.
const elements = page.locator(`:has-text("${text}")[role="${role}"]`);
if (required) {
await expect(elements.first()).toBeVisible(); // expect at least one element
}
// Find all 'Close' buttons that are siblings of the elements containing the specified text.
const closeBtns = page.locator(
`:has-text("${text}")[role="${role}"] ~ button:has-text("${closeLabel}")`
);
// Click all 'Close' buttons.
const count = await closeBtns.count();
for (let i = 0; i < count; i++) {
if (await closeBtns.nth(i).isVisible()) {
await closeBtns
.nth(i)
.click()
.catch(() => {});
}
}
};
/**
* Finds a specific cell in a table by matching both the row text and the column header text.
*
* This function performs the following steps:
* 1. Finds a row in the table that contains the specified `rowText` (case-insensitive).
* 2. Finds the column header in the table that contains the specified `columnText` (case-insensitive).
* 3. Identifies the cell in the located row that corresponds to the column where the header matches the `columnText`.
* 4. Returns the found cell for further interactions or assertions.
*
* @param {Page} page - The Playwright `Page` object representing the browser page.
* @param {string} rowText - The text to match in the row (case-insensitive).
* @param {string} columnText - The text to match in the column header (case-insensitive).
*
* @returns {Locator} - A Playwright Locator object representing the intersecting cell.
*
* @throws Will throw an error if the row or column header is not found, or if the cell is not visible.
*
* @warning This function assumes a standard table structure where each row has an equal number of cells,
* and no cells are merged (`colspan` or `rowspan`). If the table contains merged cells,
* this method may return incorrect results or fail to locate the correct cell.
* Matches the header exactly (cell contains only exact text)
* Matches the row loosely (finds a row containing that text somewhere)
*/
export const findByRowColumn = async (page: Page, rowText: string, columnText: string) => {
// Locate the row that contains the rowText
// This just looks for the text in a row, so ensure that it is specific enough
const row = page.locator('tr').filter({ hasText: new RegExp(`${rowText}`) });
await expect(row).toBeVisible();
// Locate the column header that matches the columnText
// This assumes that header is exact (cell only contains that text and nothing else)
const header = page.locator('thead th').filter({ hasText: new RegExp(`^${columnText}$`, 'i') });
await expect(header).toBeVisible();
// Find the index of the matching column header
const columnIndex = await header.evaluate((el) => Array.from(el.parentNode.children).indexOf(el));
// Find the cell in the located row that corresponds to the matching column index
const cell = row.locator(`td:nth-child(${columnIndex + 1})`);
await expect(cell).toBeVisible();
// Return the found cell
return cell;
};
/**
* WebKit-specific implementation of ensureElementsInViewport.
* Ensures that two elements are fully visible in the viewport by calculating their bounding boxes
* and adjusting the viewport if necessary.
*
* @param {object} page - The Playwright page instance.
* @param {object} source - Locator for the source element.
* @param {object} target - Locator for the target element.
*/
export const ensureElementsInViewportWebkit = async (page, source, target) => {
const currentViewport = await page.viewportSize();
console.log('Current viewport size:', currentViewport);
let combinedBox = { top: Infinity, bottom: -Infinity, left: Infinity, right: -Infinity };
// Helper function to fetch the absolute bounding box
const calculateBoundingBox = async (element) => {
return await element.evaluate((el) => {
const rect = el.getBoundingClientRect();
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
return {
top: rect.top + scrollTop,
bottom: rect.bottom + scrollTop,
left: rect.left + scrollLeft,
right: rect.right + scrollLeft,
width: rect.width,
height: rect.height,
};
});
};
// Calculate the combined bounding box for both elements
const elements = [source, target];
for (const [index, element] of elements.entries()) {
console.log(`Processing element ${index + 1}/${elements.length}`);
const box = await calculateBoundingBox(element);
if (!box) {
console.error(`Bounding box for element ${index + 1} could not be determined.`);
continue;
}
console.log(`Absolute bounding box for element ${index + 1}:`, box);
combinedBox = {
top: Math.min(combinedBox.top, box.top),
bottom: Math.max(combinedBox.bottom, box.bottom),
left: Math.min(combinedBox.left, box.left),
right: Math.max(combinedBox.right, box.right),
};
console.log(`Updated combined bounding box after element ${index + 1}:`, combinedBox);
}
// Calculate the required scroll position
const scrollToY = Math.max(
0,
combinedBox.top - (currentViewport.height - (combinedBox.bottom - combinedBox.top)) / 2
);
const scrollToX = Math.max(0, combinedBox.left);
console.log('Scrolling to position:', { top: scrollToY, left: scrollToX });
// Scroll the viewport
await page.evaluate(
({ top, left }) => {
console.log('Before scroll:', { scrollX: window.scrollX, scrollY: window.scrollY });
window.scrollTo(left, top);
console.log('After scroll:', { scrollX: window.scrollX, scrollY: window.scrollY });
},
{ top: scrollToY, left: scrollToX }
);
// Validate visibility of each element
for (const [index, element] of elements.entries()) {
console.log(`Validating visibility of element ${index + 1}`);
const rect = await element.evaluate((el) => {
const rect = el.getBoundingClientRect();
const isVisible =
rect.top >= 0 &&
rect.bottom <= window.innerHeight &&
rect.left >= 0 &&
rect.right <= window.innerWidth;
console.log('Element rect:', rect, 'Is visible:', isVisible);
return isVisible;
});
if (!rect) {
console.warn(`Element ${index + 1} is NOT fully visible.`);
} else {
console.log(`Element ${index + 1} is fully visible.`);
}
}
console.log('ensureElementsInViewportWebkit completed.');
};
/**
* Ensures that the given elements are fully visible within the viewport.
* Resizes the viewport and scrolls if required.
*
* @param {object} page - The Playwright page instance.
* @param {object} source - Locator for the source element.
* @param {object} target - Locator for the target element.
*/
export const ensureElementsInViewport = async (page, source, target) => {
// Detect the browser type
const browserType = page.context().browser()?.browserType().name();
// Short-circuit to WebKit-specific implementation
if (browserType === 'webkit') {
return ensureElementsInViewportWebkit(page, source, target);
}
const currentViewport = await page.viewportSize();
// Helper to check if an element is fully visible in the viewport
const isElementFullyVisible = async (element) => {
const box = await element.boundingBox();
if (!box) return false;
const viewport = await page.viewportSize();
return box.y >= 0 && box.y + box.height <= viewport.height;
};
// Check if source and target are fully visible
const sourceVisible = await isElementFullyVisible(source);
const targetVisible = await isElementFullyVisible(target);
if (!sourceVisible || !targetVisible) {
const sourceBox = await source.boundingBox();
const targetBox = await target.boundingBox();
if (sourceBox && targetBox) {
// Determine the bounding box that contains both elements
const topElementY = Math.min(sourceBox.y, targetBox.y);
const bottomElementY = Math.max(
sourceBox.y + sourceBox.height,
targetBox.y + targetBox.height
);
const requiredHeight = bottomElementY - topElementY;
// Resize viewport if necessary
if (requiredHeight > currentViewport.height) {
await page.setViewportSize({
width: currentViewport.width,
height: requiredHeight,
});
}
// Scroll to the top element
await page.evaluate((y) => {
window.scrollTo(0, y);
}, topElementY);
} else {
throw new Error('Bounding boxes for source or target could not be determined.');
}
}
};
/**
* Wait for layout to settle by running a few animation frames (for use after drag start).
*/
const waitForLayoutFrames = async (page: Page, frames = 3) => {
for (let i = 0; i < frames; i++) {
await page.evaluate(
() => new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => r())))
);
}
};
/**
* Smoothly drags a draggable element within a source <li> to just above a target <li>.
* Automatically detects WebKit and uses a WebKit-specific implementation if needed.
* In Chromium, re-resolves the target position after mousedown so the drop uses the
* target's position after the source row collapses (avoids dragging to a stale offset).
*
* @param {object} page - The Playwright page instance.
* @param {object} options - Options for the drag operation.
* @param {object} options.source - Locator for the source <li> (containing the draggable element).
* @param {object} options.target - Locator for the target <li> (drop destination).
* @param {number} [options.steps=5] - Number of steps for smooth movement.
* @param {number} [options.delay=20] - Delay in milliseconds between steps.
*/
export const dragElementAbove = async (page, options) => {
// Extract options
const { source, target, steps = 5, delay = 20 } = options;
// Ensure both elements are fully visible in the viewport
await ensureElementsInViewport(page, source, target);
// Locate the draggable button within the source <li>
const draggable = source.locator('[draggable="true"]');
// Get bounding boxes of the draggable button and target <li>
const sourceBox = await draggable.boundingBox();
let targetBox = await target.boundingBox();
if (sourceBox && targetBox) {
// Calculate start position
const startX = sourceBox.x + sourceBox.width / 2;
const startY = sourceBox.y + sourceBox.height / 2;
// Move to the starting position and press the mouse
await page.mouse.move(startX, startY);
await page.mouse.down();
const browserType = page.context().browser()?.browserType().name() ?? '';
// In Chromium the source collapses into a floating drag preview; the target's position
// can change. Never use the pre-mousedown target position for the move — wait for layout
// to settle, then resolve the target's current position (via getBoundingClientRect in page)
// so we use the real post-collapse coordinates.
if (browserType === 'chromium') {
await page.waitForTimeout(100);
await waitForLayoutFrames(page, 6);
const freshTargetBox = await target.evaluate((el) => {
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
});
if (!freshTargetBox || freshTargetBox.width === 0) {
await page.mouse.up();
throw new Error(
'Chromium: target bounding box could not be resolved after drag start (layout may still be settling).'
);
}
targetBox = freshTargetBox;
}
// Resolve drop coordinates from the current target box (post-collapse in Chromium)
let endX = targetBox.x + targetBox.width / 2;
let endY = targetBox.y + targetBox.height * 0.35;
if (browserType === 'chromium') {
// Move in steps so dragover events fire; re-query target position right before final move
// so we end at the actual current drop zone (layout can shift during the move).
const stepDelay = Math.max(delay, 15);
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const x = startX + (endX - startX) * t;
const y = startY + (endY - startY) * t;
await page.mouse.move(x, y);
await page.waitForTimeout(stepDelay);
}
// Final position: re-resolve target so we release over the current drop area
const finalBox = await target.evaluate((el) => {
const r = el.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
});
if (finalBox && finalBox.width > 0) {
endX = finalBox.x + finalBox.width / 2;
endY = finalBox.y + finalBox.height * 0.35;
await page.mouse.move(endX, endY);
}
} else {
await page.mouse.move(endX, endY, { steps: steps });
}
// Brief pause at drop position so react-dnd can set isOver before release
await page.waitForTimeout(100);
// Release the mouse to drop the element
await page.mouse.up();
} else {
throw new Error('Bounding boxes for source or target could not be determined.');
}
};
/**
* Returns true if the first element appears before the second element in the DOM.
*
* @param {object} firstLocator - Playwright locator for the first element.
* @param {object} secondLocator - Playwright locator for the second element.
* @returns {Promise<boolean>} - Returns true if the first element is before the second element.
*/
export const isElementBefore = async (firstLocator, secondLocator) => {
const firstHandle = await firstLocator.elementHandle();
const secondHandle = await secondLocator.elementHandle();
if (!firstHandle || !secondHandle) {
throw new Error('One or both elements could not be found.');
}
// Compare positions in the DOM and return a boolean
return await firstHandle.evaluate((first, second) => {
return !!(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING);
}, secondHandle);
};
/**
* Ensures that the specified checkbox is in the desired checked state.
* If the checkbox's current state does not match the desired state, it clicks the checkbox to toggle it.
*
* @param {Locator} locator - Playwright locator for the checkbox element.
* @param {boolean} checked - Desired checked state of the checkbox (true for checked, false for unchecked).
* @returns {Promise<void>} - Resolves when the checkbox state is correctly set.
*/
export const ensureCheckbox = async (locator: Locator, checked: boolean) => {
const isChecked = await locator.isChecked();
if (isChecked !== checked) {
await locator.click();
}
};