77 lines
3.7 KiB
TypeScript
77 lines
3.7 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;
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
});
|