CI failed on a test nobody had touched:
strict mode violation: getByRole('tab', { name: /Email address changed/ })
resolved to 2 elements
The locators in AdminEmails built an unanchored regex from the label, so a template whose name merely began with another's matched both. #337 added "Email address changed by the shop" alongside "Email address changed" and broke the assertion above it.
The failure named the assertion rather than the new template, which is what made it worth more than a rename. It is the same shape as the switch locator that silently retargeted in #317: a loose locator that keeps passing until something new is added nearby, and then fails somewhere that says nothing about the cause.
So the fix is the locator rather than the label. One helper now builds a matcher anchored at both ends, allowing only the optional "Customised" suffix a tab carries once its template has been edited, and escaping the label because these are copy and copy acquires brackets and full stops eventually. Both railTab and customisedTab go through it, and callers pass plain strings instead of assembling regexes at each call site.
The new template is added to the list the test walks, which is what it should have had in #337.
Verified the matcher against every real label plus the pairs that collide, including that a customised-only match still rejects an unedited tab. Frontend tsc and lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
113 lines
4.5 KiB
TypeScript
113 lines
4.5 KiB
TypeScript
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<void> {
|
|
await this.railTab(label).click();
|
|
await expect(this.subject(label)).toBeVisible();
|
|
}
|
|
}
|