Files
TheLastGarden/tests/e2e/season1-loop.spec.ts
josh 47b5b8d6b0 test(02-06): playwright e2e assertions for G1+G2 — phase-2 gap closure complete
Threads 3 new assertions into the existing PIPE-07 spec to verify the
G1 + G2 gap fixes hold end-to-end in a real Chromium browser:

- Assertion A (G1, after initial nav): document.body computed
  background-color is rgb(26, 26, 26) — proves src/index.css bundled
  cleanly into the entry chunk and applies before React mounts.
- Assertion B (G2, after Begin click): the FirstRunHint element
  (data-testid="first-run-hint") is visible with non-empty externalized
  text content from ui-strings.yaml.
- Assertion C (G2, after first plant): the FirstRunHint is gone —
  proves the component's tiles subscription dismisses on the first
  plant !== null transition.

The existing 16-step assertion chain continues to pass unchanged. Test
runtime grew from 1.6s → 1.7s (3 cheap evaluations + 1 visibility +
1 negation). All 4 first-impression UX gaps are now structurally closed.

npm run ci exits 0 (333/333 vitest green; ~21 new tests across G1+G2+G3+G4).
npm run test:e2e exits 0 (Playwright PIPE-07 + 3 new gap-closure assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:17:55 -04:00

241 lines
8.7 KiB
TypeScript

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<void> {
// 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');
// ASSERTION A (Plan 02-06 G1) — body background is #1a1a1a from frame
// one. The dark canvas no longer floats in a sea of white.
const bodyBg = await page.evaluate(() => {
return window.getComputedStyle(document.body).backgroundColor;
});
expect(bodyBg).toBe('rgb(26, 26, 26)'); // #1a1a1a in computed-style form
// 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 });
// ASSERTION B (Plan 02-06 G2) — first-run hint is visible immediately
// after Begin dismisses (the A-Dark-Room first-prompt). Player sees
// the externalized line from ui-strings.yaml.
await expect(page.getByTestId('first-run-hint')).toBeVisible({ timeout: 5000 });
const hintText = await page.getByTestId('first-run-hint').textContent();
expect(hintText).toBeTruthy();
expect(hintText!.length).toBeGreaterThan(0);
// 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 },
);
// ASSERTION C (Plan 02-06 G2) — first-run hint auto-dismisses on the
// first plant. The component subscribes to the tiles slice and
// dismisses when any tile transitions to plant !== null.
await expect(page.getByTestId('first-run-hint')).not.toBeVisible({ 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();
});
});