mirror of
https://github.com/strapi/strapi.git
synced 2026-05-03 16:22:30 +00:00
660 lines
25 KiB
TypeScript
660 lines
25 KiB
TypeScript
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 Playwright’s `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();
|
||
}
|
||
};
|