diff --git a/e2e/setup-flow-a11y.spec.ts b/e2e/setup-flow-a11y.spec.ts index 3891eb7c4..688fe7665 100644 --- a/e2e/setup-flow-a11y.spec.ts +++ b/e2e/setup-flow-a11y.spec.ts @@ -118,7 +118,14 @@ const SCREENS: ScreenCase[] = [ { screen: "first-result", path: "/onboarding/first-result", - painted: (page) => page.locator('[data-slot="onboarding-task-connect"]'), + // v1.38.19 (wave B) — wait for the tile the screen SETTLED on, not for + // the connect card. This sweep used to name the connect card, which is + // painted for the few hundred milliseconds before the status envelope + // resolves whatever the account's real state is — so it scanned a frame + // the person may never see, and passed straight through the defect that + // frame was hiding. `data-connect-slot` appears only once the screen has + // decided, and it is on every one of the four tiles. + painted: (page) => page.locator("[data-connect-slot]"), prepare: (page) => complete(page), }, { diff --git a/e2e/setup-flow-connect.spec.ts b/e2e/setup-flow-connect.spec.ts new file mode 100644 index 000000000..9781f3514 --- /dev/null +++ b/e2e/setup-flow-connect.spec.ts @@ -0,0 +1,177 @@ +/** + * v1.38.19 (wave B / C1, C2) — the setup flow, path 5: a wearable on Q4. + * + * The connect arm is the only place in the flow that renders a claim about + * real account state, and until this file no journey reached it: bp answers + * `manual`, visit answers `file`, and the child path ends at confirm. The + * a11y sweep does open the screen, but asserted the connect card was visible + * — which it was, for the few hundred milliseconds before the status query + * resolved. So the sweep passed THROUGH the defect it should have caught. + * + * Both cases here assert AFTER `GET /api/integrations/status` has settled, + * which is the only moment at which the screen's claim means anything. + * + * Runs in one project (see `playwright.config.ts`): it resets and mutates its + * own account. + */ +import pg from "pg"; + +import { + E2E_SETUP_CONNECT, + SETUP_CONNECT_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { resetSetupFlow } from "./setup/setup-flow-fixture"; +import type { Page, Response } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { answer, expectScreen, readMe } from "./setup-flow-helpers"; + +function pool(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[setup-flow-connect] DATABASE_URL is not set"); + return new pg.Pool({ connectionString: url }); +} + +/** + * Put the account's Oura back to "never connected". The journey's second case + * writes a token column and a ledger row, neither of which `resetSetupFlow` + * knows about — it resets the FLOW, and a leftover connection would make the + * first case assert against the second case's state. + */ +async function clearOura(): Promise { + const db = pool(); + try { + const { rows } = await db.query<{ id: string }>( + `SELECT id FROM users WHERE username = $1`, + [E2E_SETUP_CONNECT.username], + ); + const id = rows[0]?.id; + if (!id) throw new Error("[setup-flow-connect] no account"); + await db.query( + `UPDATE users SET oura_access_token_encrypted = NULL WHERE id = $1`, + [id], + ); + await db.query( + `DELETE FROM integration_statuses WHERE user_id = $1 AND integration = 'oura'`, + [id], + ); + } finally { + await db.end(); + } +} + +/** + * A connection that has been delivering for an hour: a stored token (the + * envelope reads `connected` off the column's presence alone) and a ledger + * row whose last success is recent enough for the `fresh` verdict. + */ +async function seedDeliveringOura(): Promise { + const db = pool(); + try { + const { rows } = await db.query<{ id: string }>( + `SELECT id FROM users WHERE username = $1`, + [E2E_SETUP_CONNECT.username], + ); + const id = rows[0]?.id; + if (!id) throw new Error("[setup-flow-connect] no account"); + await db.query( + `UPDATE users SET oura_access_token_encrypted = 'e2e-placeholder' WHERE id = $1`, + [id], + ); + await db.query( + `INSERT INTO integration_statuses + (id, user_id, integration, state, last_success_at, last_attempt_at, created_at, updated_at) + VALUES ($1, $2, 'oura', 'connected', NOW(), NOW(), NOW(), NOW()) + ON CONFLICT (user_id, integration) DO UPDATE + SET state = 'connected', + last_success_at = NOW(), + last_attempt_at = NOW(), + updated_at = NOW()`, + [`e2e-connect-oura-${Date.now()}`, id], + ); + } finally { + await db.end(); + } +} + +/** Walk the questions through the API, then open the screen under test. */ +async function reachFirstResult(page: Page): Promise { + await answer(page, { step: "who", recordTarget: "me" }); + await answer(page, { step: "areas", areas: ["sleep"] }); + await answer(page, { step: "medication", medication: "no" }); + await answer(page, { step: "sources", sources: ["oura"] }); + await answer(page, { step: "visit", visit: "no" }); + const settled = page.waitForResponse( + (res: Response) => + res.url().includes("/api/integrations/status") && res.status() === 200, + ); + await page.goto("/onboarding/confirm"); + await expectScreen(page, "confirm"); + await page.locator('[data-slot="onboarding-next"]').click(); + await expectScreen(page, "first-result"); + // Every assertion below is about what the screen says once it KNOWS. + await settled; +} + +test.describe("setup flow — a wearable on Q4", () => { + test.use({ storageState: SETUP_CONNECT_STORAGE_STATE_PATH }); + + test.beforeEach(async () => { + await resetSetupFlow(E2E_SETUP_CONNECT.username); + await clearOura(); + }); + + test.afterAll(async () => { + await clearOura(); + }); + + test("never claims a connection the account does not have", async ({ + page, + }) => { + test.setTimeout(90_000); + await page.goto("/onboarding"); + await reachFirstResult(page); + + // The tile the screen settled on: either the connect card, or — on an + // instance with no Oura app configured — the credentials note. Never a + // result: nothing was connected. + const tile = page.locator("[data-connect-slot]"); + await expect(tile).toBeVisible({ timeout: 15_000 }); + const slot = await tile.getAttribute("data-connect-slot"); + expect(["connect", "credentials"]).toContain(slot); + await expect(tile).toHaveAttribute("data-connect-state", "disconnected"); + await expect( + page.locator('[data-slot="onboarding-first-result-done"]'), + ).toHaveCount(0); + + // And the ledger was not stamped on the person's behalf. + const me = await readMe(page); + expect(me.onboarding.firstResult?.completedAt ?? null).toBeNull(); + }); + + test("acknowledges a connection that is already delivering", async ({ + page, + }) => { + test.setTimeout(90_000); + await seedDeliveringOura(); + await page.goto("/onboarding"); + await reachFirstResult(page); + + const tile = page.locator('[data-connect-slot="result"]'); + await expect(tile).toBeVisible({ timeout: 15_000 }); + await expect(tile).toHaveAttribute("data-connect-state", "fresh"); + + // The heading is the acknowledgment, not an offer: the connect CTA that + // used to sit under "Oura verbinden" is gone. + await expect( + page.locator('[data-slot="onboarding-task-connect"]'), + ).toHaveCount(0); + + // A working connection is a result the flow may record. + await expect + .poll(async () => (await readMe(page)).onboarding.firstResult?.task, { + timeout: 15_000, + }) + .toBe("connect-source"); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 13bfccc33..7dc203437 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -480,6 +480,21 @@ export const E2E_SETUP_A11Y = { role: "USER", } as const; +/** + * v1.38.19 (wave B) — the connect-source journey's account. + * + * The one arm of the flow that renders a claim about real account state had + * no journey that reached it: bp answers `manual`, visit answers `file`, and + * the child path ends at confirm. This account answers Q4 with a wearable, so + * `/onboarding/first-result` really does render the connect step. + */ +export const E2E_SETUP_CONNECT = { + email: "e2e-setup-connect@healthlog.test", + username: "e2e-setup-connect", + password: "Cn6!Zx4tB9wQm3Jf", + role: "USER", +} as const; + export const E2E_SETUP_LOCALE = { email: "e2e-setup-locale@healthlog.test", username: "e2e-setup-locale", @@ -507,6 +522,10 @@ export const SETUP_A11Y_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupA11y.json", ); +export const SETUP_CONNECT_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSetupConnect.json", +); export const SETUP_LOCALE_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateSetupLocale.json", @@ -519,6 +538,7 @@ const SETUP_FLOW_ACCOUNTS = [ E2E_SETUP_CHILD, E2E_SETUP_A11Y, E2E_SETUP_LOCALE, + E2E_SETUP_CONNECT, ] as const; async function hashPassword(password: string): Promise { @@ -1261,6 +1281,7 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SETUP_VISIT, SETUP_VISIT_STORAGE_STATE_PATH); await capture(E2E_SETUP_A11Y, SETUP_A11Y_STORAGE_STATE_PATH); await capture(E2E_SETUP_LOCALE, SETUP_LOCALE_STORAGE_STATE_PATH); + await capture(E2E_SETUP_CONNECT, SETUP_CONNECT_STORAGE_STATE_PATH); await capture(E2E_SETUP_CHILD, SETUP_CHILD_STORAGE_STATE_PATH); await pool.query( `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, diff --git a/playwright.config.ts b/playwright.config.ts index a6231feed..c958c8d77 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -232,6 +232,7 @@ export default defineConfig({ "setup-flow-visit.spec.ts", "setup-flow-child.spec.ts", "setup-flow-a11y.spec.ts", + "setup-flow-connect.spec.ts", "setup-flow-locale-overflow.spec.ts", ], use: {