Two items published from the Review queue did not appear in Inventory. Publishing was never at fault: the items were published, the rows were right, and the API returned them. The Inventory tab was showing a list it had fetched earlier and never refreshed. antd keeps a tab pane mounted once it has been rendered, and Inventory is the default tab, so its pane mounts at page load whether or not anyone looks at it. Its only fetch runs from an effect depending on the filters, so it fires on mount and when a filter changes and at no other time. Open the admin, switch to the Review queue, publish, switch back, and nothing has re-run — the table still holds the list built before the submissions existed. A browser reload shows them, which is what makes this read as publishing being broken rather than as a stale table. The tab is controlled now and tells Inventory whether it is the one on screen, and Inventory refetches when that becomes true. Guarded on visibility rather than fetching unconditionally, because the pane lives for the life of the page and would otherwise keep refetching while hidden. destroyInactiveTabPane on the Tabs would also have fixed it, by remounting, and was rejected: it discards every tab's state on every switch — filters, scroll position, a half-filled form — and refetches all of them repeatedly, which is a much larger behavioural change than this bug is worth. The existing publish test passes against the broken behaviour, and that is the reason this went unnoticed. It asserts against GET /api/admin/items, and the API was always correct. The new test asserts through the UI and never reloads the page: Inventory is opened before publishing, so its list is fetched while the item still carries its submission-timestamp name, and the assertion afterwards looks for the name given at publish — which a stale list cannot contain. A reload anywhere in it would make it pass against the bug it exists to catch. AdminPage's comment claimed only the active panel is mounted. It is only the active panel that is visible; the rest stay in the DOM. That mistaken belief is the shape of this bug, so the comment now says which it is and why it matters. Not fixed here: Categories, Tags, Upload links and Customers are mounted-once children of the same Tabs and are all changeable from elsewhere, so they very likely share this. Recorded on the issue rather than assumed to be fine. Verified: tsc clean for src and tests, lint 0 errors, production build green. The new test needs CI to run — the e2e suite wants a browser and a database this machine cannot provide. Closes #327 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
120 lines
4.2 KiB
TypeScript
120 lines
4.2 KiB
TypeScript
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 *visible*, 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.
|
|
*
|
|
* Visible, not mounted. antd keeps a pane in the DOM once it has been rendered
|
|
* and only hides it, so scoping to `.ant-tabs-tabpane-active` is what makes
|
|
* these locators work — the inactive panels are still there. This comment used
|
|
* to say "mounted", and that mistake is the shape of #327: a tab that never
|
|
* unmounts also never re-runs its effects, so Inventory went on showing a list
|
|
* it had fetched before anything was published into it.
|
|
*
|
|
* `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 <body>, 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string> {
|
|
return locator.evaluate((el) => getComputedStyle(el).backgroundColor);
|
|
}
|
|
|
|
static async colorOf(locator: Locator): Promise<string> {
|
|
return locator.evaluate((el) => getComputedStyle(el).color);
|
|
}
|
|
|
|
/** Switches tabs without renavigating, for a test that visits two of them. */
|
|
async openTab(name: AdminTab): Promise<void> {
|
|
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();
|
|
}
|
|
}
|