url deep-link: game id in #/game/<id>, multi-tab + bad-link fallback (v0.3.0)
CI / build + unit + e2e (pull_request) Successful in 1m52s

This commit is contained in:
2026-10-11 15:32:51 +00:00
parent 502fbd4874
commit f622cb676a
5 changed files with 210 additions and 17 deletions
+103
View File
@@ -180,3 +180,106 @@ test.describe('History — save / resume across a page refresh', () => {
await expect(page.getByTestId('player-nation')).toHaveText('United Kingdom');
});
});
/**
* 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, '#/');
});
});