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';
/**
* 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();
}
}