Files
Flight-Ops/e2e/toasts.spec.ts
T
2026-09-19 14:52:36 +00:00

69 lines
3.2 KiB
TypeScript

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<StateBody> {
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 });
});