diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 63a0d5fb0..7f80a9c0f 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -3,6 +3,7 @@ import type { Locator, Page, Route } from "@playwright/test"; import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { openMenu } from "./open-menu"; import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot"; @@ -334,6 +335,9 @@ async function visitAndScan( } async function installA11yMocks(page: Page) { + // Every AI surface renders from the `ai` block of `/api/auth/me`; the + // scans cover the AI states too, so every capability reads available. + await serveAiBlock(page, aiBlockAvailable()); await page.route(/\/api\/analytics(\?|$)/, (route) => fulfilJson(route, { summaries: POPULATED_SUMMARIES, @@ -760,12 +764,13 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [ { name: "/insights overview", path: "/insights", + // The vitals grid, settled. The scores strip is not a stable gate: it + // unmounts for an account with no score yet, and since v1.39 it no longer + // keeps an empty cell for a cycle ring that has no dial to draw. painted: (page) => - page.locator('[data-slot="wellness-scores"]').filter({ - has: page.locator( - '[data-slot="wellness-scores-grid"]:not([aria-busy="true"])', - ), - }), + page.locator( + '[data-slot="vitals-dashboard-grid"]:not([aria-busy="true"])', + ), }, { // `.recharts-wrapper` is a class the charting library writes, not a diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index cf5d0d2ab..a15a9e0ee 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -64,6 +64,7 @@ */ import type { BrowserContext, Page } from "@playwright/test"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { expect, test } from "./setup/test"; import { DELEGATE_STORAGE_STATE_PATH, @@ -303,10 +304,15 @@ test.describe("account sharing", () => { // delegate, in their OWN record, is offered the Coach launcher like anyone // else. Without this line the later "it is gone" check would also pass on // a build where the launcher had stopped rendering for everybody. + // The launcher follows the `coach` capability, which needs a provider the + // suite cannot reach, so the capability reads available for this one + // look; the shared-record check below reads the real payload again. + await serveAiBlock(page, aiBlockAvailable()); await page.goto("/"); await expect(page.locator('[data-slot="coach-fab"]')).toBeVisible({ timeout: 10_000, }); + await page.unroute("**/api/auth/me"); // Back to where the rest of this journey happens. The check above leaves // the browser on the dashboard, and Accept lives on the access page. @@ -369,8 +375,12 @@ test.describe("account sharing", () => { // needs to and a fast one moves on immediately. await page.goto("/"); // Gate on rendered content first (the dashboard must actually mount and - // populate the query cache before there is anything to persist). - await expect(page.locator('[data-slot="coach-fab"]')).toBeVisible({ + // populate the query cache before there is anything to persist). The + // tile strip, not the Coach launcher: the launcher follows the `coach` + // capability, which needs a provider this suite does not have. + await expect( + page.locator('[data-slot="dashboard-tile-strip"]'), + ).toBeVisible({ timeout: 10_000, }); await expect diff --git a/e2e/ai-optional-account.spec.ts b/e2e/ai-optional-account.spec.ts new file mode 100644 index 000000000..3e7f138fe --- /dev/null +++ b/e2e/ai-optional-account.spec.ts @@ -0,0 +1,119 @@ +/** + * What happens to stored AI text and stored Coach rows, against the real + * server, on an account of its own (`E2E_AI_OPTIONAL`). + * + * J5 — consent withdrawn. The account has a local model (an address nothing + * listens on: presence is all the capability reads, nothing here calls + * it), a daily briefing a model wrote earlier, and an AI consent. The + * briefing is on the overview; the consent is withdrawn in Settings, AI + * through the real control; the briefing is gone, because the server + * deleted the notes written under that consent. + * J4 — Coach hidden. With Hide Coach on, Settings, Coach is out of reach, so + * Settings, AI lists what the Coach stored; one stored conversation is + * deleted from there and is gone from the database. + * + * Both journeys move the same account's state, so they run serially, in one + * project (see `playwright.config.ts`). + */ +import type { Page } from "@playwright/test"; + +import { AI_OPTIONAL_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + coachConversationCount, + resetAiOptionalAccount, + seedHiddenCoachWithConversation, + seedStoredBriefing, +} from "./setup/ai-optional-fixture"; +import { expect, test } from "./setup/test"; + +test.use({ storageState: AI_OPTIONAL_STORAGE_STATE_PATH }); +test.describe.configure({ mode: "serial" }); + +test.beforeEach(async () => { + await resetAiOptionalAccount(); +}); +test.afterAll(async () => { + await resetAiOptionalAccount(); +}); + +async function openOverview(page: Page): Promise { + await page.goto("/insights"); + await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({ + timeout: 20_000, + }); + await page.waitForLoadState("networkidle"); +} + +test("J5: withdrawing AI consent deletes the notes written under it", async ({ + page, +}) => { + test.setTimeout(90_000); + const marker = `Stored briefing ${Date.now()}`; + await seedStoredBriefing(marker); + + // Positive control: the stored briefing is what the overview shows. + await openOverview(page); + await expect(page.locator('[data-slot="daily-briefing"]')).toHaveCount(1); + await expect(page.getByText(marker).first()).toBeVisible(); + + // Withdraw through the real control. The confirm names what goes and what + // stays before anything happens. + await page.goto("/settings/ai"); + await page.locator('[data-slot="ai-consent-withdraw"]').click(); + const withdrawn = page.waitForResponse( + (res) => + res.url().includes("/api/consent/ai/latest") && + res.request().method() === "DELETE", + ); + await page.locator('[data-slot="ai-consent-withdraw-confirm"]').click(); + expect((await withdrawn).status()).toBe(200); + await expect(page.locator('[data-slot="ai-consent-grant"]')).toBeVisible(); + + await openOverview(page); + await expect(page.getByText(marker)).toHaveCount(0); +}); + +test("J4: the Coach memory stays readable and deletable with the Coach hidden", async ({ + page, +}) => { + test.setTimeout(90_000); + const title = `Stored conversation ${Date.now()}`; + await seedHiddenCoachWithConversation(title); + + // Hide Coach takes the Coach page and its launcher away... + await page.goto("/insights"); + await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({ + timeout: 20_000, + }); + await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0); + + // ...and Settings, AI lists what the Coach stored. + await page.goto("/settings/ai"); + const card = page.locator( + '[data-testid="settings-coach-conversations-card"]', + ); + await expect(card).toBeVisible({ timeout: 20_000 }); + const row = card + .locator('[data-testid="settings-coach-conversation"]') + .filter({ hasText: title }); + await expect(row).toHaveCount(1); + + // Readable: the row opens its transcript in place. + await row.locator('[data-slot="settings-coach-conversation-open"]').click(); + await expect( + row.locator('[data-slot="settings-coach-conversation-open"]'), + ).toHaveAttribute("aria-expanded", "true"); + + // Deletable: the row goes at once, and the delete reaches the server once + // the undo window closes. + const deleted = page.waitForResponse( + (res) => + /\/api\/insights\/chat\/[^/]+$/.test(new URL(res.url()).pathname) && + res.request().method() === "DELETE", + { timeout: 30_000 }, + ); + await row.locator('[data-slot="settings-coach-conversation-delete"]').click(); + await expect(row).toHaveCount(0); + expect((await deleted).ok()).toBe(true); + expect(await coachConversationCount()).toBe(0); +}); diff --git a/e2e/ai-optional-overview.spec.ts b/e2e/ai-optional-overview.spec.ts new file mode 100644 index 000000000..69c8bac60 --- /dev/null +++ b/e2e/ai-optional-overview.spec.ts @@ -0,0 +1,164 @@ +/** + * Insights without AI (#975): switched off by the operator, or never set up. + * + * J1 — every AI capability `operator_disabled`, nothing configurable here. + * The overview renders its data sections; nothing AI-shaped paints, not + * even a hint; not one request goes to a route that calls a model or + * serves model text, and no request is answered 403. + * J3 — no provider, one could be set up here. Exactly one calm setup hint on + * the overview, no Coach launcher, no per-card "connect a provider" + * state. Dismissing the hint in Settings → AI keeps it gone on the + * overview. + * + * Both states are served through the `ai` block of `/api/auth/me` + * (`serveAiBlock`, which says why): the operator's switches are instance-wide + * and a parallel spec would lose its AI under a real flip. + * + * Every assertion is addressed to a stable `data-*` attribute, never copy. + */ +import type { Page, Request, Response } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockUnavailable, serveAiBlock } from "./setup/ai-capabilities"; +import { expect, test } from "./setup/test"; + +test.use({ storageState: STORAGE_STATE_PATH }); + +/** Routes that call a model or serve model-written text. */ +const AI_ROUTE = new RegExp( + [ + "/api/insights/generate", + "/api/insights/(weight|bmi|pulse|blood-pressure|mood|medication-compliance)-status", + "/api/insights/metric-status", + "/api/insights/biomarker-assessment", + "/api/insights/chat", + "/api/insights/coach/nudge-status", + "/api/medications/extract", + "/api/labs/ocr/", + "/api/feature-flags", + ].join("|"), +); + +function watchNetwork(page: Page): { + aiRequests: string[]; + refused: string[]; +} { + const aiRequests: string[] = []; + const refused: string[] = []; + page.on("request", (request: Request) => { + const url = new URL(request.url()); + if (AI_ROUTE.test(url.pathname)) aiRequests.push(url.pathname); + }); + page.on("response", (response: Response) => { + const url = new URL(response.url()); + if (url.pathname.startsWith("/api/") && response.status() === 403) { + refused.push(url.pathname); + } + }); + return { aiRequests, refused }; +} + +async function clearHintDismissal(page: Page): Promise { + await page.evaluate(() => { + for (const key of Object.keys(window.localStorage)) { + if (key.startsWith("healthlog.ai-setup-hint.")) { + window.localStorage.removeItem(key); + } + } + }); +} + +test.describe("J1: AI switched off by the operator", () => { + test("the overview shows data, no AI, no AI requests and no refusals", async ({ + page, + }) => { + test.setTimeout(60_000); + await serveAiBlock( + page, + aiBlockUnavailable("operator_disabled", { + configured: false, + managedBy: null, + canConfigure: false, + }), + ); + const network = watchNetwork(page); + + await page.goto("/insights"); + await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible( + { timeout: 20_000 }, + ); + // Let every section's reads land before judging what painted. + await page.waitForLoadState("networkidle"); + + await expect( + page.locator('[data-slot="insights-overview-error"]'), + ).toHaveCount(0); + await expect( + page.locator('[data-slot="insights-overview-section-error"]'), + ).toHaveCount(0); + await expect(page.locator('[data-slot="chart-error-state"]')).toHaveCount( + 0, + ); + await expect(page.locator('[data-slot="daily-briefing"]')).toHaveCount(0); + await expect(page.locator('[data-slot="ai-setup-hint"]')).toHaveCount(0); + await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0); + + // The ECG page is data and never waits on AI. + await page.goto("/insights/ecg"); + await page.waitForLoadState("networkidle"); + await expect(page.locator('[data-slot="query-error-card"]')).toHaveCount(0); + + expect(network.aiRequests).toEqual([]); + expect(network.refused).toEqual([]); + }); +}); + +test.describe("J3: no AI provider", () => { + test("one calm setup hint, no Coach, no per-card provider state", async ({ + page, + }) => { + test.setTimeout(60_000); + await serveAiBlock( + page, + aiBlockUnavailable("no_provider", { + configured: false, + managedBy: null, + canConfigure: true, + }), + ); + const network = watchNetwork(page); + + await page.goto("/insights"); + await clearHintDismissal(page); + await page.reload(); + + await expect(page.locator('[data-slot="ai-setup-hint"]')).toHaveCount(1, { + timeout: 20_000, + }); + await page.waitForLoadState("networkidle"); + await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0); + await expect(page.locator('[data-slot="daily-briefing"]')).toHaveCount(0); + await expect( + page.locator('[data-slot="insight-status-no-provider-cta"]'), + ).toHaveCount(0); + await expect( + page.locator('[data-slot="daily-briefing-no-provider"]'), + ).toHaveCount(0); + + // Settings → AI carries the same hint; dismissing it there is remembered. + await page.goto("/settings/ai"); + const hint = page.locator('[data-slot="ai-setup-hint"]'); + await expect(hint).toHaveCount(1, { timeout: 20_000 }); + await page.locator('[data-slot="ai-setup-hint-dismiss"]').click(); + await expect(hint).toHaveCount(0); + + await page.goto("/insights"); + await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible( + { timeout: 20_000 }, + ); + await page.waitForLoadState("networkidle"); + await expect(page.locator('[data-slot="ai-setup-hint"]')).toHaveCount(0); + + expect(network.aiRequests).toEqual([]); + }); +}); diff --git a/e2e/coach-conversation-rename.spec.ts b/e2e/coach-conversation-rename.spec.ts index 1a8677e9f..aff2c0e94 100644 --- a/e2e/coach-conversation-rename.spec.ts +++ b/e2e/coach-conversation-rename.spec.ts @@ -2,12 +2,20 @@ import { expect, test } from "./setup/test"; import { setTimeout as delay } from "node:timers/promises"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; const CONVERSATION_ID = "conversation-rename-e2e"; const INITIAL_TITLE = "Morning check-in"; const RENAMED_TITLE = "Weekly health review"; test.describe("Coach conversation rename", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test("renames by keyboard once and survives reload on desktop and mobile", async ({ diff --git a/e2e/coach-guided-questions.spec.ts b/e2e/coach-guided-questions.spec.ts index 638d9c74e..036b86d1c 100644 --- a/e2e/coach-guided-questions.spec.ts +++ b/e2e/coach-guided-questions.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; /** * Guided clarifying-questions flow on the full-page Coach route @@ -31,6 +32,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * ordering contract is pinned here). */ test.describe("Coach guided clarifying questions", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); const QUESTIONS = [ diff --git a/e2e/documents-ai.spec.ts b/e2e/documents-ai.spec.ts index 17a6e786c..9f2fad569 100644 --- a/e2e/documents-ai.spec.ts +++ b/e2e/documents-ai.spec.ts @@ -20,6 +20,7 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { ensureVaultFixture, ensureVaultAiFixture, @@ -121,6 +122,13 @@ async function mockAiEnabled( } test.describe("document vault — AI assist + content search", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test.beforeAll(async () => { diff --git a/e2e/documents-auto-read.spec.ts b/e2e/documents-auto-read.spec.ts index 867a72e35..be6087370 100644 --- a/e2e/documents-auto-read.spec.ts +++ b/e2e/documents-auto-read.spec.ts @@ -19,6 +19,7 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { ensureVaultFixture, ensureVaultAiFixture, @@ -176,6 +177,13 @@ test.describe("automatic AI reading — settings toggle", () => { }); test.describe("automatic AI reading — vault per-document contract", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test.beforeAll(async () => { diff --git a/e2e/documents-chat.spec.ts b/e2e/documents-chat.spec.ts index 85afe9b79..44248f031 100644 --- a/e2e/documents-chat.spec.ts +++ b/e2e/documents-chat.spec.ts @@ -26,6 +26,7 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { ensureVaultFixture, ensureVaultAiFixture, @@ -86,6 +87,13 @@ async function mockAiEnabled(page: Page): Promise { } test.describe("document vault — chat about a document", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test.beforeAll(async () => { diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 6fbcfc54b..ac82ceebe 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -21,6 +21,7 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { ensureVaultFixture, seedNamespaceDocs, @@ -137,6 +138,13 @@ async function mockDocumentCoach(page: Page): Promise { } test.describe("document vault", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test.beforeAll(async () => { diff --git a/e2e/insights-generate.spec.ts b/e2e/insights-generate.spec.ts index 1bce560a7..5a733c773 100644 --- a/e2e/insights-generate.spec.ts +++ b/e2e/insights-generate.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; /** * Insights generation flow — `/settings/ai` exposes a "Regenerate @@ -19,6 +20,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * they're touched after the regenerate call). */ test.describe("insights generation flow (mocked)", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test("regenerate button updates insight cards", async ({ page }) => { diff --git a/e2e/setup/ai-capabilities.ts b/e2e/setup/ai-capabilities.ts new file mode 100644 index 000000000..a59901aab --- /dev/null +++ b/e2e/setup/ai-capabilities.ts @@ -0,0 +1,91 @@ +/** + * Serve `GET /api/auth/me` with a chosen `ai` block. + * + * Every AI surface on the web renders from the capability map `/me` carries, + * and nothing else. The operator's switches are instance-wide, so flipping + * the real master switch in one spec would switch AI off under every spec + * running beside it; a real provider cannot be reached from the suite at all. + * A journey that asserts what the web does in one AI state therefore takes + * the real `/me` answer and replaces only its `ai` block, and states the + * limit plainly: it proves the web's rendering and its requests for that + * state. What the server publishes for which inputs is proven by the + * resolver's unit tests and `tests/integration/auth-me-ai-capabilities`. + */ +import type { Page } from "@playwright/test"; + +export const AI_CAPABILITY_KEYS = [ + "coach", + "briefing", + "periodNarrative", + "statusText", + "workoutInsights", + "reactionLines", + "aboutMeQuestions", + "documentAi", + "labsOcr", + "medicationExtract", +] as const; + +type Reason = + | "operator_disabled" + | "not_permitted_for_record" + | "module_disabled" + | "user_disabled" + | "no_provider" + | "consent_required" + | "check_failed"; + +interface AiBlock { + capabilities: Record< + string, + { available: boolean; reason: Reason | null; onDeviceAllowed: boolean } + >; + provider: { + configured: boolean; + managedBy: "user" | "local" | "server" | null; + canConfigure: boolean; + }; +} + +/** Every capability unavailable for one reason. */ +export function aiBlockUnavailable( + reason: Reason, + provider: AiBlock["provider"], +): AiBlock { + const onDeviceAllowed = + reason === "no_provider" || reason === "consent_required"; + return { + capabilities: Object.fromEntries( + AI_CAPABILITY_KEYS.map((key) => [ + key, + { available: false, reason, onDeviceAllowed }, + ]), + ), + provider, + }; +} + +/** Every capability available, a provider of the person's own. */ +export function aiBlockAvailable(): AiBlock { + return { + capabilities: Object.fromEntries( + AI_CAPABILITY_KEYS.map((key) => [ + key, + { available: true, reason: null, onDeviceAllowed: true }, + ]), + ), + provider: { configured: true, managedBy: "user", canConfigure: true }, + }; +} + +/** Replace the `ai` block of every `/api/auth/me` answer on this page. */ +export async function serveAiBlock(page: Page, ai: AiBlock): Promise { + await page.route("**/api/auth/me", async (route) => { + const response = await route.fetch(); + const body = (await response.json()) as { + data: Record | null; + }; + if (body.data) body.data.ai = ai; + await route.fulfill({ response, json: body }); + }); +} diff --git a/e2e/setup/ai-optional-fixture.ts b/e2e/setup/ai-optional-fixture.ts new file mode 100644 index 000000000..bea502931 --- /dev/null +++ b/e2e/setup/ai-optional-fixture.ts @@ -0,0 +1,129 @@ +/** + * Fixture for the AI-optional journeys that need server state + * (`e2e/ai-optional-consent.spec.ts`, `e2e/ai-optional-coach-memory.spec.ts`). + * + * Both run on their own account (`E2E_AI_OPTIONAL`). What they need has no + * product endpoint that can write it without a working model: + * + * - a provider the account can "have" without any egress: a local model on + * an address nothing listens on. Presence is what the capability reads; + * nothing on these journeys calls it. + * - a briefing a model wrote earlier, stored as the cache the read path + * serves. The journey proves it is deleted when the consent goes. + * - an AI consent receipt to withdraw. + * - a Coach conversation, which only a chat with a model would create. + * + * Every read and every change the journeys ASSERT goes through the app; this + * file only prepares the starting state, through `pg` like the other fixtures. + */ +import pg from "pg"; + +import { E2E_AI_OPTIONAL } from "./global-setup"; + +function connect(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[ai-optional-fixture] DATABASE_URL is not set"); + return new pg.Pool({ connectionString: url }); +} + +async function withAccount( + run: (pool: pg.Pool, userId: string) => Promise, +): Promise { + const pool = connect(); + try { + const { rows } = await pool.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [E2E_AI_OPTIONAL.username], + ); + const userId = rows[0]?.id; + if (!userId) { + throw new Error( + "[ai-optional-fixture] account not seeded — global-setup must run first", + ); + } + return await run(pool, userId); + } finally { + await pool.end(); + } +} + +/** Back to a plain account: no provider, no stored AI text, no consent. */ +export async function resetAiOptionalAccount(): Promise { + await withAccount(async (pool, userId) => { + await pool.query( + `UPDATE users SET + ai_provider = NULL, + ai_base_url = NULL, + insights_cached_text = NULL, + insights_cached_at = NULL, + insights_cached_locale = NULL, + disable_coach = false + WHERE id = $1`, + [userId], + ); + await pool.query("DELETE FROM consent_receipts WHERE user_id = $1", [ + userId, + ]); + await pool.query("DELETE FROM coach_conversations WHERE user_id = $1", [ + userId, + ]); + }); +} + +/** + * A local provider, a stored daily briefing whose paragraph is `marker`, and + * an active AI consent. + */ +export async function seedStoredBriefing(marker: string): Promise { + const insights = { + dailyBriefing: { paragraph: marker, keyFindings: [] }, + }; + await withAccount(async (pool, userId) => { + await pool.query( + `UPDATE users SET + ai_provider = 'LOCAL', + ai_base_url = 'http://127.0.0.1:9/v1', + insights_cached_text = $2, + insights_cached_at = now(), + insights_cached_locale = 'en' + WHERE id = $1`, + [userId, JSON.stringify(insights)], + ); + await pool.query( + `INSERT INTO consent_receipts (id, user_id, kind, artefact, signed_at) + VALUES ($1, $2, 'ai_full', $3, now())`, + [ + `e2e-consent-${Date.now()}`, + userId, + JSON.stringify({ source: "e2e", kind: "ai_full" }), + ], + ); + }); +} + +/** Hide the Coach and give it one stored conversation titled `title`. */ +export async function seedHiddenCoachWithConversation( + title: string, +): Promise { + await withAccount(async (pool, userId) => { + await pool.query("UPDATE users SET disable_coach = true WHERE id = $1", [ + userId, + ]); + await pool.query( + `INSERT INTO coach_conversations (id, user_id, title, created_at, updated_at) + VALUES ($1, $2, $3, now(), now())`, + [`e2e-conv-${Date.now()}`, userId, title], + ); + }); +} + +/** How many Coach conversations the account still stores. */ +export async function coachConversationCount(): Promise { + return withAccount(async (pool, userId) => { + const { rows } = await pool.query<{ n: string }>( + "SELECT count(*)::text AS n FROM coach_conversations WHERE user_id = $1", + [userId], + ); + return Number(rows[0]?.n ?? 0); + }); +} diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 50791808c..bfd62f548 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -165,6 +165,26 @@ export const MODULES_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateModules.json", ); +/** + * The AI-optional journeys' own account (`ai-optional-*.spec.ts`). + * + * The consent journey gives it a provider, a stored briefing and an AI + * consent, then withdraws the consent; the Coach-memory journey hides its + * Coach and deletes a stored conversation. Both change what every AI surface + * of the account shows, so they run on a record nobody else reads. + */ +export const E2E_AI_OPTIONAL = { + email: "e2e-ai-optional@healthlog.test", + username: "e2e-ai-optional", + password: "Rw5!Nc9pT3xLq6Zb", + role: "USER", +} as const; + +export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateAiOptional.json", +); + /** * The notification-dispatch journey's own account. * @@ -766,6 +786,29 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The AI-optional journeys' account. Its provider, stored briefing, + // consent and Coach rows are set by `e2e/setup/ai-optional-fixture.ts` + // before each journey. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`, + [ + cuid(), + E2E_AI_OPTIONAL.username, + E2E_AI_OPTIONAL.email, + await hashPassword(E2E_AI_OPTIONAL.password), + now, + ], + ); + // The notification-dispatch journey's account. Seeded like the others; // its channels, devices, ledger rows and preferences are reset by // `e2e/setup/notification-fixture.ts` before every test, because the @@ -1265,7 +1308,7 @@ export default async function globalSetup(config: FullConfig): Promise { * Log one account in, clearing the login bucket first. * * The ceiling is FIVE attempts per IP per quarter-hour and this setup now - * signs in seventeen times, so clearing once before the batch is no longer + * signs in eighteen times, so clearing once before the batch is no longer * enough — the sixth would be answered by the fixture's own 429 rather * than by the product. Only the auth surfaces' buckets are touched, and * only between logins this setup is itself performing. @@ -1302,6 +1345,9 @@ export default async function globalSetup(config: FullConfig): Promise { // The module-surfaces journey's jar — see `E2E_MODULES`. await capture(E2E_MODULES, MODULES_STORAGE_STATE_PATH); + // The AI-optional journeys' jar — see `E2E_AI_OPTIONAL`. + await capture(E2E_AI_OPTIONAL, AI_OPTIONAL_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 724a99535..fc36a45e6 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; /** * Coach launch surfaces on `/insights/{slug}` sub-pages. @@ -31,6 +32,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * data → full chart). The FAB launcher is present on both. */ test.describe("Coach launch surfaces on insights sub-pages", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(async ({ page }) => { diff --git a/e2e/v1427-insights-empty-state.spec.ts b/e2e/v1427-insights-empty-state.spec.ts index 62bbcff16..1f6027ff3 100644 --- a/e2e/v1427-insights-empty-state.spec.ts +++ b/e2e/v1427-insights-empty-state.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; /** * v1.4.27 F17 — insights metric-availability gating. @@ -19,6 +20,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * branches. */ test.describe("v1.4.27 — insights empty-state with metric gating", () => { + // AI surfaces render from the `ai` block of `/api/auth/me`, and the suite + // has no reachable provider; every capability reads available here so the + // mocked AI routes below are what the surfaces talk to. + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(async ({ page }) => { diff --git a/playwright.config.ts b/playwright.config.ts index 57cb11be7..626d35b11 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -251,6 +251,11 @@ export default defineConfig({ // under the first mid-assertion. It sets the mobile width itself for // the add menu, so it runs in one project. "modules-off.spec.ts", + // The AI-optional account journeys withdraw their account's consent + // and delete its stored Coach rows; a second project would reset the + // account under the first. They prove server state through stable + // data-slots, not a mobile layout, so they run in one project. + "ai-optional-account.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs