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; } // Fresh context → permission starts as 'default' (the opt-in prompt)… await expect(btn, 'fresh context shows the opt-in prompt').toHaveText('Enable notifications', { timeout: 10_000 }); // …and clicking requests it. Headless CI may deny — keep the assertion soft: accept either // outcome, but the label must reflect the RESULT of the request. await btn.click(); await expect(btn).toHaveText(/Notifications (enabled|blocked)/i, { timeout: 10_000 }); });