Files
LivingDexTracker/tests/bdd/steps/pokedex-lifecycle.steps.ts
T
Josh Creek 4af33709a3 test: make the suite's assertions falsifiable and its state isolated
Several assertions could not fail:

- "the catch update remains saved" was `caught.isChecked() || notes.includes(...)`
  shared by two scenarios, so either half satisfied both. Split into two steps
  that each assert the outcome their own scenario is about.
- The token-refresh check read a global counter with `> 0` and asserted an upload
  had happened `some(...)`, both already satisfied by the preceding scenario. It
  now asserts exactly one refresh, ordered before the upload.
- The box step ignored its box argument and asserted on the first N entries on
  the page; it now scopes to that box and checks its full contents.
- The filter step asserted on whichever entry was first after filtering; it now
  records the caught entry beforehand and names it, and checks the filter did not
  exclude everything.
- The empty-state precondition asserted emptiness instead of establishing it,
  which a fresh user satisfies for free.
- Offline coverage was `caches.keys().length > 0`. It now checks the precache
  contract: one workbox cache holding the shell and a revisioned web manifest,
  with _app/immutable assets cached without a revision query. The scenario that
  claimed to test a trailing slash did not; it is replaced with real offline
  client-side navigation.

The mock provider kept recorded requests, its refresh counter and the
fail-uploads switch in one process-wide object that only one step reset, so
scenario order was load-bearing and the failing-upload scenario poisoned
everything after it. An auto fixture now resets it per scenario, and the mock no
longer records its own control-plane calls. That reset is why the suite stays on
a single worker, which is now documented.

Coverage was gated at 90% per file over an allowlist of exactly the five files
that had tests, so new code was invisible to it permanently. It now measures all
of src/lib with global thresholds at the measured baseline, and no longer runs
the unit tests twice.

Also: a global teardown removes the users each run creates, the Supabase wrapper
distinguishes a stopped stack from a broken CLI call and detects an unseeded
database, the sign-in rate limit is raised above what one serial run needs, and
the integration suite no longer falls back to a hard-coded anon key that would
mask a misconfigured run.

The password-reset scenarios are renamed to what they actually cover: following a
real recovery link bounces to /signin, because the browser client persists no
cookies and so cannot keep the session it parses out of the URL. The helper for
the real flow is left in place and the gap is documented.
2026-09-13 17:38:42 +01:00

167 lines
6.3 KiB
TypeScript

import { createBdd } from 'playwright-bdd';
import { test, expect } from '../fixtures';
import { createDexThroughUi, deleteAllPokedexes } from '../support/app';
const { Given, When, Then } = createBdd(test);
async function dexCard(page: Parameters<typeof createDexThroughUi>[0], name: string) {
await page.goto('/my-pokedexes');
const card = page.locator('.card').filter({ hasText: name }).first();
await expect(card).toBeVisible();
return card;
}
// Establishes the precondition rather than asserting it - a fresh user happens to be empty,
// which would make an assertion here pass without testing anything.
Given('I have no Pokédexes', async ({ state }) => {
await deleteAllPokedexes(state);
});
Given('I have a Living Dex named {string}', async ({ page, state }, name: string) => {
await createDexThroughUi(page, state, { name, type: 'Living Dex' });
});
Given('I have a Form Dex named {string}', async ({ page, state }, name: string) => {
await createDexThroughUi(page, state, { name, type: 'Form Dex' });
});
Given(
'I have a Form Dex named {string} scoped to game {string} and dex {string}',
async ({ page, state }, name: string, game: string, dex: string) => {
await createDexThroughUi(page, state, { name, type: 'Form Dex', game, dex });
}
);
Given('I have a Shiny Dex named {string}', async ({ page, state }, name: string) => {
await createDexThroughUi(page, state, { name, type: 'Shiny Dex' });
});
Given('another trainer has a Pokédex', async ({ state }) => {
const url = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321';
const key = process.env.E2E_SERVICE_ROLE_KEY;
if (!key) throw new Error('E2E_SERVICE_ROLE_KEY is required');
const headers = {
apikey: key,
Authorization: `Bearer ${key}`,
'Content-Type': 'application/json'
};
const userResponse = await fetch(`${url}/auth/v1/admin/users`, {
method: 'POST',
headers,
body: JSON.stringify({
email: `other-${Date.now()}@example.test`,
password: 'OtherPassword123!',
email_confirm: true
})
});
const other = (await userResponse.json()) as { id: string };
const dexResponse = await fetch(`${url}/rest/v1/pokedexes`, {
method: 'POST',
headers: { ...headers, Prefer: 'return=representation' },
body: JSON.stringify({ userId: other.id, name: 'Private', isLivingDex: true })
});
if (!dexResponse.ok) throw new Error(await dexResponse.text());
const [dex] = (await dexResponse.json()) as Array<{ id: string }>;
state.pokedexId = dex.id;
});
When('I visit my Pokédex list', async ({ page }) => {
await page.goto('/my-pokedexes');
});
When('I open the new Pokédex form', async ({ page }) => {
await page.goto('/my-pokedexes');
await page
.getByRole('button', { name: /Create (New|Your First) Pokédex/ })
.first()
.click();
});
When('I create a Pokédex named {string} of type {string}', async ({ page, state }, name, type) => {
await createDexThroughUi(page, state, { name, type });
});
When(
'I create a Living Dex named {string} scoped to game {string} and dex {string}',
async ({ page, state }, name: string, game: string, dex: string) => {
await createDexThroughUi(page, state, { name, type: 'Living Dex', game, dex });
}
);
When('I try to create another Living Dex named {string}', async ({ page, state }, name: string) => {
await page.goto('/my-pokedexes');
await page.getByRole('button', { name: 'Create New Pokédex', exact: true }).click();
const modal = page.locator('.modal-open');
await modal.getByLabel('Name').fill(name);
await modal.getByText('Living Dex', { exact: false }).locator('..').getByRole('checkbox').check();
page.once('dialog', async (dialog) => {
state.lastMessage = dialog.message();
await dialog.accept();
});
await modal.getByRole('button', { name: 'Create', exact: true }).click();
await expect.poll(() => state.lastMessage).not.toBeNull();
});
When('I rename it to {string} and enable forms', async ({ page }, name: string) => {
const card = await dexCard(page, 'Before Editing');
await card.getByRole('button', { name: 'Edit' }).click();
const modal = page.locator('.modal-open');
await modal.getByLabel('Name').fill(name);
await modal.getByText('Form Dex', { exact: false }).locator('..').getByRole('checkbox').check();
const response = page.waitForResponse(
(candidate) =>
candidate.url().includes('/api/pokedexes/') && candidate.request().method() === 'PUT'
);
await modal.getByRole('button', { name: 'Save', exact: true }).click();
expect((await response).ok()).toBe(true);
await expect(modal).toHaveCount(0);
});
When('I cancel deleting {string}', async ({ page }, name: string) => {
const card = await dexCard(page, name);
page.once('dialog', (dialog) => dialog.dismiss());
await card.getByRole('button', { name: 'Delete' }).click();
});
When('I confirm deleting {string}', async ({ page }, name: string) => {
const card = await dexCard(page, name);
page.once('dialog', (dialog) => dialog.accept());
const response = page.waitForResponse(
(candidate) =>
candidate.url().includes('/api/pokedexes/') && candidate.request().method() === 'DELETE'
);
await card.getByRole('button', { name: 'Delete' }).click();
expect((await response).ok()).toBe(true);
});
When("I request the other trainer's Pokédex", async ({ page, state }) => {
const response = await page.request.get(`/api/pokedexes/${state.pokedexId}`);
state.lastResponseStatus = response.status();
});
Then('I see the empty Pokédex message', async ({ page }) => {
await expect(page.getByText("You haven't created any pokédexes yet!")).toBeVisible();
});
Then('I cannot create a Pokédex without a name and type', async ({ page }) => {
await expect(page.getByRole('button', { name: 'Create', exact: true })).toBeDisabled();
await expect(page.getByText('At least one type required')).toBeVisible();
});
Then('the Pokédex {string} is available to view', async ({ page }, name: string) => {
await expect((await dexCard(page, name)).getByRole('button', { name: 'View' })).toBeVisible();
});
Then('I am told that the Pokédex name is already used', async ({ state }) => {
expect(state.lastMessage).toMatch(/already have a Pokédex named/i);
});
Then('the Pokédex {string} is no longer listed', async ({ page }, name: string) => {
await page.goto('/my-pokedexes');
await expect(page.locator('.card').filter({ hasText: name })).toHaveCount(0);
});
Then('the Pokédex is not disclosed', async ({ state }) => {
expect(state.lastResponseStatus).toBe(404);
});