import { test, expect, type Page } from '@playwright/test'; /** * PIPE-07 — Phase-2 Season-1 full-loop smoke test. * * Exercises the architecture firewall + save round-trip + FakeClock * fast-forward end-to-end: * * load (?devtime=fake) → Begin → plant rosemary → fast-forward 3min * → harvest → fragment-reveal modal → close → journal-icon visible * → open journal → fragment present → reload page → fragment persists * * The spec uses the test-only window slots PhaserGame.tsx exposes when * the URL flag is set: * - window.__tlgFakeClock — FakeClock instance with .advance(ms) * - window.__tlgStore — appStore (zustand vanilla) for command * dispatch + state read * The slots are gated by import.meta.env.PROD so they never appear in * production builds. * * Avoiding pixel-precise canvas clicks: this spec dispatches sim * commands via __tlgStore.enqueueCommand({...}) rather than clicking * tiles in the Phaser canvas. The behavioral coverage (the sim runs the * harvest pipeline → selector picks a fragment → reveal modal pops → it * lands in the journal → reload restores via the save layer) is the * load-bearing verification. */ interface DevTimeWindow { __tlgFakeClock?: { advance: (ms: number) => void }; __tlgStore?: { getState: () => { enqueueCommand: (cmd: { kind: 'plantSeed' | 'harvest' | 'compost'; tileIdx: number; plantTypeId?: string; }) => void; tiles: Array<{ idx: number; plant: { plantTypeId: string } | null } | null>; harvestedFragmentIds: string[]; fragmentRevealId: string | null; pendingCommands: { kind: string }[]; }; }; } async function ensureFreshSave(page: Page): Promise { // Clear IDB + localStorage so each test run starts at first-run state. // Must happen AFTER navigation (browser context required). await page.evaluate(async () => { try { indexedDB.deleteDatabase('tlg-save'); } catch { /* no-op */ } try { localStorage.clear(); } catch { /* no-op */ } }); } test.describe('PIPE-07 — Season 1 full loop', () => { test('load → begin → plant → fast-forward → harvest → reveal → journal → reload → persist', async ({ page, }) => { // 1) Initial navigation to clear state. await page.goto('/?devtime=fake'); await ensureFreshSave(page); // Reload after clearing so the boot path sees a fresh DB. await page.goto('/?devtime=fake'); // 2) Begin screen visible (first run). const beginButton = page.getByRole('button', { name: 'Begin' }); await expect(beginButton).toBeVisible({ timeout: 15000 }); // 3) Press Begin to dismiss + bootstrap audio. await beginButton.click(); await expect(beginButton).not.toBeVisible({ timeout: 5000 }); // 4) Wait for the test-exposed store + fake clock. await page.waitForFunction(() => { const w = window as unknown as DevTimeWindow; return Boolean(w.__tlgStore && w.__tlgFakeClock); }); // 5) Enqueue a plantSeed command on tile 0. await page.evaluate(() => { const w = window as unknown as DevTimeWindow; w.__tlgStore!.getState().enqueueCommand({ kind: 'plantSeed', tileIdx: 0, plantTypeId: 'rosemary', }); }); // 6) Advance the FakeClock by 1s so the Garden scene's update() // drains a tick and applies the plantSeed command. await page.evaluate(() => { const w = window as unknown as DevTimeWindow; w.__tlgFakeClock!.advance(1000); }); // Wait for the plantSeed to be applied to tile 0. await page.waitForFunction( () => { const w = window as unknown as DevTimeWindow; return ( w.__tlgStore!.getState().tiles[0]?.plant?.plantTypeId === 'rosemary' ); }, { timeout: 5000 }, ); // 7) Fast-forward growth past 600 ticks (5Hz * 600 = 120s = 2min). // Advance 3 minutes wall-clock so the rosemary plant is solidly // in the 'ready' stage. await page.evaluate(() => { const w = window as unknown as DevTimeWindow; w.__tlgFakeClock!.advance(3 * 60 * 1000); }); // Give the Phaser scene a frame to drain ticks. await page.waitForTimeout(500); // 8) Enqueue harvest on tile 0. await page.evaluate(() => { const w = window as unknown as DevTimeWindow; w.__tlgStore!.getState().enqueueCommand({ kind: 'harvest', tileIdx: 0, }); }); // Advance the clock + wait for the Phaser scene's update loop to // drain the harvest command. drainCommands runs once per drainTicks // call, which fires once per requestAnimationFrame tick. await page.evaluate(() => { const w = window as unknown as DevTimeWindow; w.__tlgFakeClock!.advance(2000); }); // Wait for the harvest to actually commit (fragmentRevealId set). await page.waitForFunction( () => { const w = window as unknown as DevTimeWindow; const s = w.__tlgStore!.getState(); return ( s.harvestedFragmentIds.length > 0 && s.fragmentRevealId !== null ); }, { timeout: 15000 }, ); // 9) Fragment reveal modal appears with text from a Season 1 fragment. await expect( page.getByRole('dialog', { name: 'A new memory' }), ).toBeVisible({ timeout: 10000 }); // 10) Capture the harvested fragment id via store. const harvestedId = await page.evaluate(() => { const w = window as unknown as DevTimeWindow; const ids = w.__tlgStore!.getState().harvestedFragmentIds; return ids[ids.length - 1] as string; }); expect(harvestedId).toMatch(/^season1\.soil\./); // 11) Close reveal modal (Close button has aria-label="Close"). await page .getByRole('button', { name: 'Close' }) .first() .click(); await expect( page.getByRole('dialog', { name: 'A new memory' }), ).not.toBeVisible({ timeout: 5000 }); // 12) Journal icon appears in corner (D-23: reveals after first harvest). await expect(page.getByTestId('journal-icon')).toBeVisible(); // 13) Open Journal modal and confirm fragment text + selectable. await page.getByTestId('journal-icon').click(); const journal = page.getByRole('dialog', { name: 'Memory Journal' }); await expect(journal).toBeVisible(); await expect( journal.locator(`[data-fragment-id="${harvestedId}"]`), ).toBeVisible(); // 14) Reload — fragment must persist (CORE-04 + Phase-2 save lifecycle). // The save lifecycle hooks (visibilitychange→hidden + // beforeunload) fire on reload, writing the current state to // LocalStorage synchronously and best-effort to IDB. await page.reload(); // Wait for boot to complete; the begin screen should NOT appear // (returning player path). await page.waitForFunction(() => { const w = window as unknown as DevTimeWindow; return Boolean(w.__tlgStore); }); await expect( page.getByRole('button', { name: 'Begin' }), ).not.toBeVisible({ timeout: 10000 }); // 15) The harvested fragment is still in the store. const harvestedAfterReload = await page.evaluate(() => { const w = window as unknown as DevTimeWindow; return w.__tlgStore!.getState().harvestedFragmentIds; }); expect(harvestedAfterReload).toContain(harvestedId); // 16) Journal still shows the fragment after reload. await expect(page.getByTestId('journal-icon')).toBeVisible(); await page.getByTestId('journal-icon').click(); await expect( page .getByRole('dialog', { name: 'Memory Journal' }) .locator(`[data-fragment-id="${harvestedId}"]`), ).toBeVisible(); }); });