mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
test(e2e): the connect journey asserts after the status resolves
No journey reached the connect arm: bp answers `manual`, visit answers `file`, and the child path ends at confirm. The a11y sweep opened the screen and asserted the connect card was visible — which it was, for the few hundred milliseconds before the status envelope resolved, so the sweep passed through the defect rather than catching it. The new journey answers Q4 with a wearable and asserts only after `GET /api/integrations/status` has settled: nothing is claimed for an account with no connection and no result is stamped on its behalf, and a connection seeded as delivering is acknowledged rather than offered. The a11y sweep now waits for the tile the screen settled on instead of the frame before it.
This commit is contained in:
1 parent
8d81ac0708
commit
dfa5b9a5e0
4 files changed
+207
-1
No files matched your search
@@ -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),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
@@ -1261,6 +1281,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
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`,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user