import { FrameLocator, Locator, Page, expect } from '@playwright/test'; /** * A matcher for one template label, against a tab's *whole* accessible name. * * A tab is named for its template, plus the word "Customised" once it has been * edited — the dot beside it carries that as an aria-label, so the state is not * colour-only. * * Anchored at both ends, which is the entire point of this function. The * locators here used to build an unanchored regex from the label, so a template * whose name merely *began* with another's matched both. Adding "Email address * changed by the shop" alongside "Email address changed" broke a passing test * with a strict-mode violation naming the assertion rather than the new * template — the same shape as the switch locator that silently retargeted in * #317, and the same cost to diagnose. * * The escape matters for the same reason: these labels are copy, and copy * acquires brackets and full stops eventually. */ function nameMatching(label: string, options: { customised?: boolean } = {}): RegExp { const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const suffix = options.customised ? '\\s+Customised' : '(?:\\s+Customised)?'; return new RegExp(`^${escaped}${suffix}$`); } /** * The Emails tab: a vertical rail of template types and one editor at a time. * * Only the active template's editor is mounted, which is what keeps the labels * below unambiguous — the previous stacked layout had every editor on screen at * once and a locator for "Save" matched all six (#135). * * A customised template is marked in the rail with a dot carrying an * aria-label, so the state is in the tab's accessible name rather than colour * alone — which is what lets a test assert it without opening the editor. */ export class AdminEmails { readonly saveButton: Locator; readonly restoreDefaultButton: Locator; constructor(private readonly page: Page) { this.saveButton = page.getByRole('button', { name: 'Save', exact: true }); this.restoreDefaultButton = page.getByRole('button', { name: 'Restore default' }); } /** * A placeholder chip, which inserts its tag at the caret (#143). * * Located by the accessible name rather than the visible text, because the * visible text is `{{name}}` and the braces make an awkward locator. */ placeholderChip(name: string): Locator { return this.page.getByRole('button', { name: `Insert {{${name}}}` }); } /** * One template's entry in the rail, matched on its whole accessible name. * * A tab's accessible name is the template's label, plus the word "Customised" * when it has been edited — the dot beside it carries that as an aria-label so * the state is not colour-only. * * Anchored at both ends, which is the point of this helper rather than a bare * substring match. These locators used to build an unanchored regex from the * label, so a template whose name merely *began* with another's matched both. * Adding "Email address changed by the shop" beside "Email address changed" * broke a passing test with a strict-mode violation, and the failure named the * assertion rather than the new template — the same shape as the switch * locator that silently retargeted in #317. * * The escape matters for the same reason: a label is copy, and copy acquires * brackets and full stops eventually. */ railTab(label: string): Locator { return this.page.getByRole('tab', { name: nameMatching(label) }); } customisedTab(label: string): Locator { return this.page.getByRole('tab', { name: nameMatching(label, { customised: true }) }); } subject(label: string): Locator { return this.page.getByLabel(`${label} subject`); } body(label: string): Locator { return this.page.getByLabel(`${label} body`); } /** * The rendered preview, which is an iframe. * * It is the server's rendering of the actual email rather than the markdown * editor's own, so it is the only thing that shows the consent footer and the * substituted placeholders. */ preview(label: string): FrameLocator { return this.page.frameLocator(`iframe[title="${label} preview"]`); } /** * Opens one template and waits for its editor. * * The wait is the action's contract: the rail swaps the editor, and a locator * resolved mid-swap finds the outgoing one. */ async openTemplate(label: string): Promise { await this.railTab(label).click(); await expect(this.subject(label)).toBeVisible(); } }