import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; // v0.17.0 — ISS-008: action errors surface as bottom-right toasts (in addition to the inline // pane error) and game events appear as info toasts via snapshot diffing; the left-pane Time // section offers an explicit "Enable notifications" opt-in for browser notifications. interface CatalogEntry { model: string; label: string; price: number; } interface StateBody { account: { cash: number }; catalog: CatalogEntry[]; } async function getState(page: Page): Promise { const res = await page.request.get('/api/state'); expect(res.status()).toBe(200); return (await res.json()) as StateBody; } test('failed actions toast bottom-right (inline error stays) and the Time section offers a notifications opt-in', async ({ page }) => { await page.goto('/'); const buyModel = page.getByTestId('market-buy-model'); await expect(buyModel, 'the market section renders once the first snapshot lands').toBeVisible({ timeout: 15_000 }); // Part A — deterministic error path: the most expensive catalog model (ATR 72-600, €200k) is // unaffordable with starting cash (€20k). On a long-lived local instance cash may have grown // past it — then this part is skipped in place; CI's isolated stack always starts fresh. const body = await getState(page); const priciest = [...body.catalog].sort((a, b) => b.price - a.price)[0]; if (body.account.cash < priciest.price) { await buyModel.selectOption(priciest.model); await page.getByTestId('market-buy').click(); // The toast appears with the server's error message and the error variant class… const toast = page.locator('[data-testid="toast"]').filter({ hasText: /insufficient funds/i }); await expect(toast, 'the error toast appears bottom-right').toBeVisible({ timeout: 10_000 }); await expect(toast).toHaveClass(/toast-error/); // …and the inline pane error still shows (both surfaces by design). It auto-clears on the // next poll (~1 s), so assert right after the toast — both are set in the same commit. await expect(page.getByTestId('right-error')).toContainText(/insufficient funds/); } // Part B — notifications opt-in in the Time section. const btn = page.getByTestId('enable-notifications'); await expect(btn, 'the notifications button renders in the Time section').toBeVisible({ timeout: 15_000 }); // Browsers without the Notification API: disabled with a tooltip-ish title. const supported = await page.evaluate(() => 'Notification' in window); if (!supported) { await expect(btn).toBeDisabled(); await expect(btn).toHaveAttribute('title', /does not support/i); return; } // Branch on the ACTUAL permission state — headless CI reports 'denied' (not 'default'), so a // fresh context is not guaranteed to show the opt-in prompt (CI run #47). const permission = await page.evaluate(() => Notification.permission); if (permission === 'denied') { // LeftPane renders the blocked-state label without any click. await expect(btn, 'denied context shows the blocked-state label').toHaveText('Notifications blocked', { timeout: 10_000 }); } else if (permission === 'granted') { await expect(btn, 'pre-granted context shows the enabled label').toHaveText('Notifications enabled', { timeout: 10_000 }); } else { // 'default' — the opt-in prompt. Clicking requests it; headless may deny, so accept either // outcome, but the label must CHANGE from the prompt to reflect the result. await expect(btn, 'non-denied context shows the opt-in prompt').toHaveText('Enable notifications', { timeout: 10_000 }); await btn.click(); await expect(btn).not.toHaveText('Enable notifications', { timeout: 10_000 }); } });