import { Locator, Page, expect } from '@playwright/test'; /** The admin tabs, in the order the shell renders them. */ export type AdminTab = | 'Inventory' | 'Categories' | 'Tags' | 'Customers' | 'Emails' | 'Settings' | 'Review queue'; /** * The admin shell: the tab strip and the panel it swaps. * * Only the active tab's panel is mounted, which is what keeps the locators * inside each panel unambiguous — the email editors used to be stacked and a * locator for "Save" matched all six. * * `activePanel` exists because several specs reached for * `.ant-tabs-tabpane-active .ant-table` and similar to scope themselves to the * visible panel. That knowledge belongs here rather than in five spec files. */ export class AdminPage { readonly tabList: Locator; readonly activePanel: Locator; readonly activeTabLabel: Locator; readonly themeToggle: Locator; readonly themedBody: Locator; /** * antd renders Select popups into a portal at the end of , outside the * panel they belong to — so a dropdown cannot be found by scoping to the tab. */ readonly selectDropdown: Locator; constructor(private readonly page: Page) { this.tabList = page.getByRole('tablist'); this.activePanel = page.locator('.ant-tabs-tabpane-active').first(); this.activeTabLabel = page.locator('.ant-tabs-tab-active .ant-tabs-tab-btn').first(); this.themeToggle = page.getByRole('switch'); this.themedBody = page.locator('body'); this.selectDropdown = page.locator('.ant-select-dropdown').first(); } async goto(): Promise { await this.page.goto('/admin'); } /** * A top-level admin tab. * * `exact` matters on the Emails tab: the template rail inside it also renders * tabs, and "Email verification" contains "Email". Exact matching keeps the * shell's tabs and the rail's apart. */ tab(name: AdminTab | RegExp, exact = false): Locator { return typeof name === 'string' ? this.page.getByRole('tab', { name, exact }) : this.page.getByRole('tab', { name }); } /** * Opens a tab from wherever the browser is, navigating to /admin first. * * Takes the navigation rather than assuming it, because every caller in the * suite did both and half of them wrote the goto themselves. */ async open(name: AdminTab): Promise { await this.goto(); await this.openTab(name); } /** The tree or table inside whichever panel is showing. */ get activeTree(): Locator { return this.activePanel.locator('.ant-tree').first(); } get activeTable(): Locator { return this.activePanel.locator('.ant-table').first(); } /** * Puts the admin in dark mode, whatever it was in before. * * Idempotent rather than a toggle: the theme persists across reloads, so a * blind click leaves the state depending on what the previous test chose. */ async switchToDark(): Promise { await this.goto(); if ((await this.themeToggle.getAttribute('aria-checked')) !== 'true') { await this.themeToggle.click(); } await expect(this.themedBody).toHaveAttribute('data-theme', 'dark'); } /** The computed background of an element, for the contrast assertions. */ static async backgroundOf(locator: Locator): Promise { return locator.evaluate((el) => getComputedStyle(el).backgroundColor); } static async colorOf(locator: Locator): Promise { return locator.evaluate((el) => getComputedStyle(el).color); } /** Switches tabs without renavigating, for a test that visits two of them. */ async openTab(name: AdminTab): Promise { await this.tab(name, true).click(); // The panel being mounted is the completion of the click. Without this a // caller's first locator resolves against the outgoing panel. await expect(this.activePanel).toBeVisible(); } }