test(e2e): cover Insights and the Coach memory without AI

Adds the AI-optional journeys: the overview with AI switched off by the
operator and with no provider (no AI requests, no refusals, one setup
hint, remembered dismissal), and on an account of its own, withdrawing AI
consent deletes the stored briefing, and a hidden Coach's conversation is
read and deleted from Settings, AI.

Specs that drive AI surfaces against mocked routes now serve an available
capability map through /api/auth/me, since the web no longer assumes AI
is on until told otherwise.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 11:36:30 +02:00
1 parent e72441743f
commit b7830c3a72
17 files changed
+649 -8

No files matched your search

+10 -5
View File
@@ -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
+12 -2
View File
@@ -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
+119
View File
@@ -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<void> {
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);
});
+164
View File
@@ -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<void> {
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([]);
});
});
+8
View File
@@ -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 ({
+8
View File
@@ -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 = [
+8
View File
@@ -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 () => {
+8
View File
@@ -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 () => {
+8
View File
@@ -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<void> {
}
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 () => {
+8
View File
@@ -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<void> {
}
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 () => {
+8
View File
@@ -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 }) => {
+91
View File
@@ -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<void> {
await page.route("**/api/auth/me", async (route) => {
const response = await route.fetch();
const body = (await response.json()) as {
data: Record<string, unknown> | null;
};
if (body.data) body.data.ai = ai;
await route.fulfill({ response, json: body });
});
}
+129
View File
@@ -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<T>(
run: (pool: pg.Pool, userId: string) => Promise<T>,
): Promise<T> {
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<void> {
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<void> {
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<void> {
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<number> {
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);
});
}
+47 -1
View File
@@ -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<void> {
],
);
// 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<void> {
* 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<void> {
// 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);
+8
View File
@@ -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 }) => {
+8
View File
@@ -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 }) => {
+5
View File
@@ -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