Files
History/e2e/sim.spec.ts
2026-10-11 18:32:09 +00:00

563 lines
27 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, type Page } from '@playwright/test';
/**
* Fully deterministic end-to-end test. The Playwright webServer boots the real
* stack (Vite → Express) with LLM_STUB=1, so the backend uses the deterministic
* turn simulator in server/stub.ts.
*
* Batched-round model: actions and diplomatic messages are queued in
* `game.pending` (no turn advance, no LLM call); a single "Jump forward"
* commits the whole batch in one LLM turn. The stub emits one event per
* queued item (action → military/success, diplomacy → diplomatic/partial) or
* one political "The world moves on" event when the round is empty. It always
* sets the player's military to (current + 5, clamped) and allies the player
* with the first other nation, advancing the date by 2 days.
*/
test.describe('History — deterministic simulation (LLM_STUB=1)', () => {
test('scenario → nation → queue action + diplomacy → jump, with predicted stat changes', async ({ page }) => {
test.setTimeout(120000);
await page.goto('/');
// Home: the scenario grid loads with all 5 presets.
const presetCards = page.getByTestId('preset-card');
await expect(presetCards.first()).toBeVisible();
await expect(presetCards).toHaveCount(5);
// Start the first scenario (World War II, 1939).
await presetCards.first().getByRole('button', { name: 'Play' }).click();
// Nation select: the world brief is shown, then we lead the United Kingdom.
await expect(page.getByTestId('world-background')).toBeVisible();
await page
.getByTestId('nation-card-uk')
.getByRole('button', { name: 'Play as United Kingdom' })
.click();
// Game screen mounts: map, command deck, and event feed all present; turn 1.
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('action-panel')).toBeVisible();
await expect(page.getByTestId('event-feed-panel')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
// The dossier shows the player's four stat bars (value-colored, animated).
await expect(page.getByTestId('stat-military')).toBeVisible();
await expect(page.getByTestId('stat-economy')).toBeVisible();
await expect(page.getByTestId('stat-stability')).toBeVisible();
await expect(page.getByTestId('stat-population')).toBeVisible();
// The player (UK) starts at military 80, date 1939-09-01, no events yet.
const military = page.getByTestId('stat-military');
const date = page.getByTestId('game-date');
await expect(military).toHaveText('80');
await expect(date).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0);
// The map legend is auto-generated from the region map: all 7 nations of the
// scenario own at least one region, including the player (United Kingdom).
await expect(page.getByTestId('map-legend-item')).toHaveCount(7, { timeout: 15000 });
await expect(page.getByTestId('map-legend-item').filter({ hasText: 'United Kingdom' })).toBeVisible();
// --- Queue an action (no turn advance, no LLM call) ---
await page.getByTestId('action-textarea').fill('Mobilize the home guard and fortify Dover');
await page.getByTestId('add-action').click();
// The pending list shows one queued action; turn and date are unchanged.
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('pending-item').first()).toHaveAttribute('data-kind', 'action');
await expect(page.getByTestId('pending-item').first()).toContainText('Mobilize the home guard and fortify Dover');
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(date).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0);
// The "Your move" window's title badge shows the pending count.
await expect(page.getByTestId('title-queued')).toHaveText('1 queued');
// --- Queue a diplomatic message (no turn advance, no LLM call) ---
await page.getByTestId('tab-diplo').click();
await page.getByTestId('chat-target').click();
await page.getByRole('option', { name: 'United States' }).click();
await page.getByTestId('chat-message').fill('We propose a mutual defense pact');
await page.getByTestId('add-diplomacy').click();
// The pending list now shows two items (action + diplomacy); turn still 1.
await expect(page.getByTestId('pending-item')).toHaveCount(2);
await expect(page.getByTestId('pending-item').first()).toHaveAttribute('data-kind', 'action');
await expect(page.getByTestId('pending-item').nth(1)).toHaveAttribute('data-kind', 'diplomacy');
await expect(page.getByTestId('pending-item').nth(1)).toContainText('→ United States');
await expect(page.getByTestId('pending-item').nth(1)).toContainText('We propose a mutual defense pact');
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(date).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0);
await expect(page.getByTestId('title-queued')).toHaveText('2 queued');
// --- Jump forward: commit the whole pending round in a single LLM turn ---
// The button sits at the bottom of the scrollable "Your move" window body
// (max-h-[72vh] overflow-y-auto) and may be outside the page viewport; a
// native DOM click bypasses Playwright's viewport actionability check.
await page
.getByTestId('jump-forward')
.evaluate((el) => (el as HTMLElement).click());
// Turn advances to 2, date to 1939-09-03 (+2 days), military 80 → 85.
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
await expect(military).toHaveText('85', { timeout: 20000 });
await expect(date).toHaveText('1939-09-03', { timeout: 20000 });
// Two events were emitted (one per queued item): one military, one diplomatic.
await expect(page.getByTestId('event-item')).toHaveCount(2);
// The pending queue is now empty — the round was committed and cleared.
await expect(page.getByTestId('pending-empty')).toBeVisible();
await expect(page.getByTestId('title-queued')).not.toBeVisible();
// Newest first: the diplomatic event (2nd queued item) sits on top, the
// military event (1st queued item) is second.
await expect(page.getByTestId('event-item').first().getByTestId('event-type')).toHaveText('diplomatic');
await expect(page.getByTestId('event-item').first().getByTestId('event-outcome')).toHaveText('partial');
await expect(page.getByTestId('event-item').nth(1).getByTestId('event-type')).toHaveText('military');
await expect(page.getByTestId('event-item').nth(1).getByTestId('event-outcome')).toHaveText('success');
});
});
/**
* Save / resume across a page refresh. The active game's id is persisted in
* localStorage, so a reload (or the home screen's "Resume last game" button)
* must drop the user straight back into the same game — same turn, same stats,
* same map — rather than resetting to the home screen. Deterministic under the
* stub (UK military starts at 80 and +5 per committed turn).
*/
test.describe('History — save / resume across a page refresh', () => {
async function createUkGameAtTurn2(page: Page): Promise<void> {
await page.goto('/');
await page.getByTestId('preset-card').first().waitFor({ timeout: 15000 });
await page.getByTestId('preset-card').first().getByRole('button', { name: 'Play' }).click();
await page
.getByTestId('nation-card-uk')
.getByRole('button', { name: 'Play as United Kingdom' })
.click();
// Game screen mounts at turn 1 (UK military starts at 80).
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('stat-military')).toHaveText('80');
// Queue one action onto the pending round, then jump forward: the stub
// bumps UK military to 85 and advances to turn 2.
await page.getByTestId('action-textarea').fill('Reinforce the Atlantic convoys');
await page.getByTestId('add-action').click();
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await page.getByTestId('jump-forward').click();
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
await expect(page.getByTestId('stat-military')).toHaveText('85', { timeout: 20000 });
}
test('auto-resumes into the same game after a page reload', async ({ page }) => {
await createUkGameAtTurn2(page);
// Reload: the persisted active game id must restore the game — not home.
await page.reload();
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
await expect(page.getByTestId('stat-military')).toHaveText('85', { timeout: 20000 });
// The player's identity is restored in the header…
await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom');
// …and the map legend is regenerated from the persisted region map.
await expect(page.getByTestId('map-legend-item').first()).toBeVisible({ timeout: 15000 });
});
test('explicitly resumes a saved game from the home screen', async ({ page }) => {
await createUkGameAtTurn2(page);
// Back to the home screen: the "Resume last game" affordance is present.
await page.getByTestId('back-to-scenarios').click();
await expect(page.getByTestId('resume-last')).toBeVisible();
// Resuming drops back into the same game, at the saved turn.
await page.getByTestId('resume-last').click();
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('2', { timeout: 20000 });
await expect(page.getByTestId('stat-military')).toHaveText('85', { timeout: 20000 });
await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom');
});
});
/**
* Queue reliability — issue #10. A queued item must appear in the pending list
* right after its REST response AND stay there: before the fix, a stale SSE
* `state` snapshot (the on-connect resync taken just before the add was saved,
* carrying no freshness marker) could arrive after the REST response and
* clobber it — "add one: nothing happens, add another: both appear". The fix
* gives every mutation a monotonic `GameState.version` and the client applies
* an incoming state only when it is not older than the current one.
*/
test.describe('History — queue add is never clobbered by a stale SSE snapshot (#10)', () => {
test('one added action appears immediately and survives late SSE snapshots', async ({
page,
}) => {
test.setTimeout(90000);
const id = await createUkGame(page);
const origin = await page.evaluate(() => location.origin);
// Game screen at turn 1, nothing queued yet.
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
await expect(page.getByTestId('pending-empty')).toBeVisible();
// Queue exactly ONE action and assert it appears immediately (turn/date
// unchanged, no events yet).
await page.getByTestId('action-textarea').fill('Hold the line at the channel');
const [addResp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === `/api/games/${id}/action`,
),
page.getByTestId('add-action').click(),
]);
const addBody = (await addResp.json()) as { game: { version: number; pending: unknown[] } };
expect(addBody.game.pending).toHaveLength(1);
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('pending-item').first()).toHaveAttribute('data-kind', 'action');
await expect(page.getByTestId('pending-item').first()).toContainText('Hold the line at the channel');
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0);
await expect(page.getByTestId('title-queued')).toHaveText('1 queued');
// Let any in-flight / late SSE snapshot (on-connect resync, auto-reconnect)
// have its chance to arrive. Under the stub these settle in milliseconds;
// a second is generous. Before the version fix, a stale one could clobber
// the just-added item here.
await page.waitForTimeout(1000);
// The item is still there — the stale snapshot was dropped, not applied.
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('pending-item').first()).toContainText('Hold the line at the channel');
await expect(page.getByTestId('turn-count')).toHaveText('1');
// Protocol-level check: a later mutation (a second queued item) must carry
// a strictly higher version than the one we just saw.
await page.getByTestId('action-textarea').fill('Scramble the night fighters');
const [add2Resp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === `/api/games/${id}/action`,
),
page.getByTestId('add-action').click(),
]);
const add2Body = (await add2Resp.json()) as { game: { version: number } };
expect(add2Body.game.version).toBeGreaterThan(addBody.game.version);
await expect(page.getByTestId('pending-item')).toHaveCount(2);
// The game persisted with a version survives a full page reload.
await page.goto(`${origin}/#/game/${id}`);
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('pending-item')).toHaveCount(2, { timeout: 15000 });
});
});
/**
* Per-item removal — issue #11. Each queued row has a small trash button that
* opens a confirmation dialog; confirming removes the item server-side
* (POST /api/games/:id/queue/remove, no turn advance) and cancelling keeps it.
*/
test.describe('History — remove a queued item with confirmation (#11)', () => {
test('cancel keeps the item; confirm removes it', async ({ page }) => {
test.setTimeout(90000);
const id = await createUkGame(page);
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
// Queue one action.
await page.getByTestId('action-textarea').fill('Open the back channel with Vichy');
await page.getByTestId('add-action').click();
await expect(page.getByTestId('pending-item')).toHaveCount(1);
// Trash icon → confirmation dialog.
await page.getByTestId('remove-queue-item').click();
const dialog = page.getByTestId('remove-confirm-dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText('Open the back channel with Vichy');
// Cancel: the item stays queued.
await page.getByTestId('remove-cancel').click();
await expect(dialog).not.toBeVisible();
await expect(page.getByTestId('pending-item')).toHaveCount(1);
await expect(page.getByTestId('turn-count')).toHaveText('1');
// Open again and confirm: the item is removed server-side.
await page.getByTestId('remove-queue-item').click();
await expect(dialog).toBeVisible();
const [removeResp] = await Promise.all([
page.waitForResponse(
(r) =>
r.request().method() === 'POST' &&
new URL(r.url()).pathname === `/api/games/${id}/queue/remove`,
),
page.getByTestId('remove-confirm').click(),
]);
const removeBody = (await removeResp.json()) as { game: { version: number; pending: unknown[] } };
expect(removeBody.game.pending).toHaveLength(0);
await expect(dialog).not.toBeVisible();
await expect(page.getByTestId('pending-empty')).toBeVisible();
await expect(page.getByTestId('title-queued')).not.toBeVisible();
// Still turn 1, no events — removing never advances the round.
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('game-date')).toHaveText('1939-09-01');
await expect(page.getByTestId('event-item')).toHaveCount(0);
// The bumped version is persisted with the game (it survives a reload).
const after = await page.evaluate(
async (gameId) =>
((await fetch(`/api/games/${gameId}`)).json()) as Promise<{ game: { version: number } }>,
id,
);
expect(after.game.version).toBe(removeBody.game.version);
});
});
/**
* URL deep-linking (issue #3). The active game's id lives in the URL fragment:
* home is `#/` and an active game is `#/game/<id>`. A fresh page navigated to
* that hash loads that exact game; a bad/missing id falls back to home and
* clears the hash; returning to home clears it. A browser context is fresh per
* test, so localStorage is isolated, but the e2e webServer shares one SQLite
* (`data/e2e`) across tests — so each test captures the *specific* id of the
* game it creates (via the POST /api/games response) rather than assuming "the
* only game".
*/
/**
* Start the first scenario (World War II) and pick the United Kingdom, returning
* the freshly created game's id (read from the POST /api/games response). Leaves
* the caller on the game screen at turn 1.
*/
async function createUkGame(page: Page): Promise<string> {
await page.goto('/');
await page.getByTestId('preset-card').first().waitFor({ timeout: 15000 });
await page.getByTestId('preset-card').first().getByRole('button', { name: 'Play' }).click();
await expect(page.getByTestId('world-background')).toBeVisible();
const [resp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/games',
),
page
.getByTestId('nation-card-uk')
.getByRole('button', { name: 'Play as United Kingdom' })
.click(),
]);
const body = (await resp.json()) as { game: { id: string } };
return body.game.id;
}
/** Poll until `location.hash` equals `expected` (hash writes happen in effects). */
async function waitHash(page: Page, expected: string): Promise<void> {
await page.waitForFunction(
(exp) => window.location.hash === exp,
expected,
{ timeout: 15000 },
);
}
test.describe('History — URL deep-linking (#/game/<id>)', () => {
test('creating a game puts its id in the URL', async ({ page }) => {
test.setTimeout(60000);
const id = await createUkGame(page);
// The game screen mounts at turn 1; the URL-sync effect writes the deep link.
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom');
await waitHash(page, `#/game/${id}`);
});
test('a fresh page deep-links to the exact saved game', async ({ page, context }) => {
test.setTimeout(60000);
const id = await createUkGame(page);
await waitHash(page, `#/game/${id}`);
const origin = await page.evaluate(() => location.origin);
// A brand-new page navigated straight to the deep link must load that exact
// game (matching id, preset, and player) — the URL hash takes precedence over
// the (shared) localStorage, so this is deterministic.
const page2 = await context.newPage();
await page2.goto(`${origin}/#/game/${id}`);
await expect(page2.getByTestId('world-map')).toBeVisible();
await expect(page2.getByTestId('turn-count')).toHaveText('1');
await expect(page2.getByTestId('player-nation')).toHaveText('United Kingdom');
await waitHash(page2, `#/game/${id}`);
await page2.close();
});
test('a bad game id falls back to the home screen and clears the hash', async ({
page,
context,
}) => {
test.setTimeout(60000);
await page.goto('/');
const origin = await page.evaluate(() => location.origin);
// A fresh page navigated to a non-existent id must not crash: it lands on the
// home screen and the bad hash is cleared to `#/`.
const page2 = await context.newPage();
await page2.goto(`${origin}/#/game/does-not-exist-xyz`);
await expect(page2.getByTestId('preset-card').first()).toBeVisible({ timeout: 20000 });
await waitHash(page2, '#/');
await page2.close();
});
test('returning to home from a game clears the hash to #/', async ({ page }) => {
test.setTimeout(60000);
const id = await createUkGame(page);
await expect(page.getByTestId('world-map')).toBeVisible();
await waitHash(page, `#/game/${id}`);
// "← Scenarios" returns to the home screen; the URL-sync effect clears the hash.
await page.getByTestId('back-to-scenarios').click();
await expect(page.getByTestId('preset-card').first()).toBeVisible({ timeout: 15000 });
await waitHash(page, '#/');
});
});
/**
* HUD revamp (issues #4 and #5). The floating "Map" zoom window is gone — the
* full-bleed map is driven directly by wheel/trackpad + drag. The top bar is a
* *pinned* element: fixed, non-movable, centered at the top a few px down. The
* dossier / feed / action panels can each be docked into a full-height left or
* right pane (persisted in `localStorage`); the legend stays a small floating
* widget.
*/
test.describe('History — HUD revamp (pinned top bar, dockable panels, no Map window)', () => {
test('Map window is gone; top bar is pinned top-center; panels dock left/right', async ({
page,
}) => {
test.setTimeout(90000);
await createUkGame(page);
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
const vw = page.viewportSize()!.width;
const vh = page.viewportSize()!.height;
// --- #4: the "Map" zoom window is gone --------------------------------
// No zoom window element, and no zoom-in/zoom-out buttons anywhere on the
// game screen.
await expect(page.locator('[data-float-window="zoom"]')).toHaveCount(0);
await expect(page.getByTestId('zoom-in')).toHaveCount(0);
await expect(page.getByTestId('zoom-out')).toHaveCount(0);
// --- #5a: the top bar is pinned, centered at the top ------------------
const topBar = page.getByTestId('top-bar');
await expect(topBar).toBeVisible();
const barBox = (await topBar.boundingBox())!;
// Horizontally centered: x ≈ (viewportWidth − width) / 2 …
expect(Math.abs(barBox.x - (vw - barBox.width) / 2)).toBeLessThanOrEqual(3);
// …and a few px down from the top edge (y ≈ 8).
expect(barBox.y).toBeGreaterThanOrEqual(6);
expect(barBox.y).toBeLessThanOrEqual(10);
// A pointer-drag attempted on the pinned bar must not move it (it has no
// drag handlers; the map behind it never receives a mousedown).
await page.mouse.move(barBox.x + barBox.width / 2, barBox.y + barBox.height / 2);
await page.mouse.down();
await page.mouse.move(barBox.x + barBox.width / 2 + 60, barBox.y + barBox.height / 2 + 40, {
steps: 6,
});
await page.mouse.up();
const barBoxAfter = (await topBar.boundingBox())!;
expect(Math.abs(barBoxAfter.x - barBox.x)).toBeLessThanOrEqual(1);
expect(Math.abs(barBoxAfter.y - barBox.y)).toBeLessThanOrEqual(1);
// --- #5b: the dossier docks into a full-height left pane --------------
const dossier = page.getByTestId('panel-dossier');
await dossier.getByTestId('dock-left').click();
const dBox = (await dossier.boundingBox())!;
// Pinned to the left edge, filling the viewport height.
expect(dBox.x).toBeLessThanOrEqual(2);
expect(dBox.y).toBeLessThanOrEqual(2);
expect(Math.abs(dBox.height - vh)).toBeLessThanOrEqual(3);
expect(Math.abs(dBox.width - 300)).toBeLessThanOrEqual(3);
// --- #5b: the event feed docks into a full-height right pane ----------
const feed = page.getByTestId('panel-feed');
await feed.getByTestId('dock-right').click();
const fBox = (await feed.boundingBox())!;
// Pinned to the right edge: its right side sits on the viewport edge.
expect(Math.abs(fBox.x + fBox.width - vw)).toBeLessThanOrEqual(3);
expect(Math.abs(fBox.height - vh)).toBeLessThanOrEqual(3);
// The "Your move" deck is still a floating panel (we never docked it).
await expect(page.getByTestId('panel-action')).toBeVisible();
});
test('a dock choice survives a page reload (persisted in localStorage)', async ({
page,
}) => {
test.setTimeout(90000);
await createUkGame(page);
await expect(page.getByTestId('world-map')).toBeVisible();
const vh = page.viewportSize()!.height;
// Dock the dossier into the left pane.
const dossier = page.getByTestId('panel-dossier');
await dossier.getByTestId('dock-left').click();
const before = (await dossier.boundingBox())!;
expect(before.x).toBeLessThanOrEqual(2);
expect(Math.abs(before.height - vh)).toBeLessThanOrEqual(3);
// Reload: the persisted dock choice must restore the docked layout, and the
// deep link restores the game.
await page.reload();
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1', { timeout: 20000 });
const after = (await page.getByTestId('panel-dossier').boundingBox())!;
expect(after.x).toBeLessThanOrEqual(2);
expect(Math.abs(after.height - vh)).toBeLessThanOrEqual(3);
expect(Math.abs(after.width - 300)).toBeLessThanOrEqual(3);
});
});
/**
* Advisor "thinking" indicator (issue #15). Asking the advisor is a *separate*
* LLM call with its own `advisorBusy` flag — it must never block the turn flow.
* Under the deterministic stub the fetch resolves in milliseconds, so the
* transient thinking state is too short to assert mid-flight reliably. Instead
* we assert the *terminal* states, which are deterministic: before the fetch the
* button is enabled and the hint is absent; after it, the advisor list is
* populated (5 canned ideas), the hint is gone, the button is re-enabled, and
* the top bar is back to "Idle".
*/
test.describe('History — Advisor thinking indicator (#15)', () => {
test('asking the advisor populates the list and clears the busy state', async ({ page }) => {
test.setTimeout(90000);
await createUkGame(page);
await expect(page.getByTestId('world-map')).toBeVisible();
await expect(page.getByTestId('turn-count')).toHaveText('1');
// Open the Advisor tab.
await page.getByTestId('tab-advisor').click();
// Before any fetch: the button is enabled, no thinking hint, no results yet.
const ask = page.getByTestId('ask-advisor');
await expect(ask).toBeEnabled();
await expect(page.getByTestId('advisor-hint')).toHaveCount(0);
await expect(page.getByTestId('advisor-list')).toHaveCount(0);
// Ask the advisor (with an optional focus). The stub returns 5 canned ideas.
await page.getByTestId('advisor-prompt').fill('the nuclear standoff');
await ask.click();
// Terminal state: the result populates the advisor list.
await expect(page.getByTestId('advisor-list')).toBeVisible({ timeout: 20000 });
await expect(page.getByTestId('advisor-item')).toHaveCount(5, { timeout: 20000 });
// The fetch finished: the thinking hint is gone and the button is re-enabled.
await expect(page.getByTestId('advisor-hint')).toHaveCount(0);
await expect(ask).toBeEnabled();
// Nothing is in flight now, so the top bar reports idle (not the turn flow).
await expect(page.getByTestId('status-indicator')).toHaveText(/Idle/);
});
});