Files
HealthLog/e2e/documents-auto-read.spec.ts
T
Marc-André Bombeck 91d4908204 e2e: pin the auto-read contract per document read state
The auto-read spec pinned the old collapse rule: with auto-read ON the
sheet always dropped the AI block. Since the read action stays visible
for a document that has no content index yet, the collapse assertion
now runs against the indexed content fixture, and a second test pins
the recovery path: an unread document keeps its AI section and manual
read under auto-read. Refs #777.
2026-08-13 07:35:15 +02:00

306 lines
11 KiB
TypeScript

/**
* Automatic AI document reading — the per-user opt-in acceptance suite.
*
* Two surfaces:
* (1) AI settings — the single switch reveals a once-shown honesty confirm
* BEFORE writing the setting (off → on), and the confirm is what commits
* `documentsAutoAiRead: true`. Turning it off is immediate.
* (2) The vault detail sheet — the per-egress "leaves your machine" notice is
* shown once and the per-document "Read with AI" action is the explicit
* path; reading through it succeeds with no per-document consent prompt.
*
* Everything is `page.route`-mocked so the suite mutates no shared demo state.
* The document-scoped capability endpoint is mocked on every path — an unmocked
* / changed capability endpoint is the shipped regression that breaks the vault
* AI e2e, so it is pinned here too. Assertions target stable data-slots, never
* viewport-dependent text.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ensureVaultFixture,
ensureVaultAiFixture,
AI_PROBE_DOC_ID,
CONTENT_DOC_ID,
} from "./setup/vault-fixture";
async function fulfilJson(
route: Parameters<Parameters<Page["route"]>[1]>[0],
body: unknown,
) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
/**
* Mock the FULL AI-availability surface the vault section probes so the read
* action renders AND is enabled: `usage` (assist + content-index flags — this is
* what un-disables the action), the labs OCR capability, and the document-scoped
* capability (provider order + vendor-blind egress class). Mirrors
* `mockAiEnabled` in documents-ai.spec.ts; egress-specific tests set the class.
*/
async function mockDocumentCapability(
page: Page,
opts: {
egress: "local" | "external";
pdfSupported?: boolean;
/** The per-user auto-AI-read opt-in. OFF keeps the manual action row. */
autoRead?: boolean;
},
): Promise<void> {
const pdfSupported = opts.pdfSupported ?? true;
const autoRead = opts.autoRead ?? false;
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
fulfilJson(route, {
data: { documentsAutoAiRead: autoRead },
error: null,
}),
);
await page.route("**/api/documents/inbound/usage", (route) =>
fulfilJson(route, {
data: {
usedBytes: 4096,
quotaBytes: 1_073_741_824,
maxFileBytes: 26_214_400,
acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"],
linkedEpisodes: [],
assistAvailable: true,
contentIndex: { enabled: true, indexedCount: 0, totalCount: 1 },
},
error: null,
}),
);
await page.route("**/api/labs/ocr/capability", (route) =>
fulfilJson(route, {
data: { available: true, mode: "vision", reason: null, pdfSupported },
error: null,
}),
);
await page.route("**/api/documents/inbound/capability", (route) =>
fulfilJson(route, {
data: {
available: true,
mode: "vision",
reason: null,
pdfSupported,
egress: opts.egress,
},
error: null,
}),
);
}
test.describe("automatic AI reading — settings toggle", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
// The confirm logic is viewport-independent; run the settings flow once on
// desktop to keep the heavy AI-settings page off the mobile matrix.
test.skip(
testInfo.project.name !== "chromium-desktop",
"settings toggle flow runs on desktop only",
);
test.slow();
});
test("turning it on reveals a once-shown honesty confirm before writing", async ({
page,
}) => {
// The toggle starts OFF; capture the write so we can prove it only fires on
// confirm (never on the raw switch flip).
let patchBody: unknown = null;
await page.route("**/api/auth/me/documents-auto-ai-read", (route) => {
if (route.request().method() === "PATCH") {
patchBody = JSON.parse(route.request().postData() ?? "{}");
return fulfilJson(route, {
data: { documentsAutoAiRead: true },
error: null,
});
}
return fulfilJson(route, {
data: { documentsAutoAiRead: false },
error: null,
});
});
await page.goto("/settings/ai");
const card = page.locator('[data-slot="documents-auto-read-card"]');
await expect(card).toBeVisible();
// Flip the switch ON — the honesty confirm appears and NOTHING is written.
await card.getByTestId("documents-auto-read-enable").click();
const confirm = card.locator('[data-slot="documents-auto-read-confirm"]');
await expect(confirm).toBeVisible();
expect(patchBody).toBeNull();
// Acknowledge — now (and only now) the write fires with the true flag.
await confirm
.locator('[data-slot="documents-auto-read-confirm-cta"]')
.click();
await expect.poll(() => patchBody).toEqual({ documentsAutoAiRead: true });
await expect(confirm).not.toBeVisible();
});
test("cancelling the confirm writes nothing", async ({ page }) => {
let patchCalls = 0;
await page.route("**/api/auth/me/documents-auto-ai-read", (route) => {
if (route.request().method() === "PATCH") {
patchCalls += 1;
return fulfilJson(route, {
data: { documentsAutoAiRead: true },
error: null,
});
}
return fulfilJson(route, {
data: { documentsAutoAiRead: false },
error: null,
});
});
await page.goto("/settings/ai");
const card = page.locator('[data-slot="documents-auto-read-card"]');
await expect(card).toBeVisible();
await card.getByTestId("documents-auto-read-enable").click();
const confirm = card.locator('[data-slot="documents-auto-read-confirm"]');
await expect(confirm).toBeVisible();
await confirm.getByRole("button", { name: /cancel/i }).click();
await expect(confirm).not.toBeVisible();
await page.waitForTimeout(300);
expect(patchCalls).toBe(0);
});
});
test.describe("automatic AI reading — vault per-document contract", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture();
await ensureVaultAiFixture();
});
test.beforeEach(() => test.slow());
test("the vault shows the explicit read action for an external provider", async ({
page,
}) => {
// External egress (the codex/OAuth or any non-local provider case). The
// per-document egress notice was retired — the settings-toggle honesty
// confirm already covers the egress trade — so no per-document notice
// renders here regardless of egress mode.
await mockDocumentCapability(page, { egress: "external" });
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
// With auto-read OFF the per-document read is the explicit path — the action
// is present and the user taps it. (When auto-read is ON the row collapses;
// that is covered by its own test below.)
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
});
test("reading through the action succeeds with no per-document consent prompt", async ({
page,
}) => {
await mockDocumentCapability(page, { egress: "external" });
let indexCalls = 0;
await page.route(
`**/api/documents/inbound/${AI_PROBE_DOC_ID}/index`,
(route) => {
indexCalls += 1;
return fulfilJson(route, {
data: { documentId: AI_PROBE_DOC_ID, indexed: true, tokenCount: 9 },
error: null,
});
},
);
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
const read = sheet.locator('[data-slot="document-read-ai"]');
await expect(read).toBeVisible();
await read.click();
await expect(page.getByText("The AI read your document.")).toBeVisible();
expect(indexCalls).toBe(1);
});
test("a local-egress provider shows the explicit read action", async ({
page,
}) => {
// A self-hosted local model never leaves the machine; the read action is
// offered the same way as for an external provider (no per-document notice
// in either mode after the notice was retired).
await mockDocumentCapability(page, {
egress: "local",
pdfSupported: false,
});
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
});
test("with auto-read ON the sheet drops the AI row only for a read document", async ({
page,
}) => {
// Auto-read reads every upload with no per-document tap, so once a
// document IS read the manual Read / Suggest actions are redundant and
// the WHOLE block collapses (v1.28.22 — no status chrome; provenance
// lives on the vault card). `CONTENT_DOC_ID` ships with a content index,
// so it is the read-document case.
await mockDocumentCapability(page, { egress: "external", autoRead: true });
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
// The footer's download action is the loaded-state anchor (it renders
// only once the document GET resolved), so the absence checks below
// cannot pass vacuously against the loading skeleton.
await expect(
sheet.locator('[data-slot="document-download"]'),
).toBeVisible();
await expect(
sheet.locator('[data-slot="document-ai-section"]'),
).toHaveCount(0);
await expect(sheet.locator('[data-slot="document-read-ai"]')).toHaveCount(
0,
);
await expect(sheet.locator('[data-slot="assist-suggest"]')).toHaveCount(0);
});
test("with auto-read ON an unread document keeps its read action", async ({
page,
}) => {
// v1.37.13 — auto-read reads new uploads, but a document that has no
// content index yet (upload predates the switch, or its read failed)
// still needs a way in: the AI section stays, with the manual read as
// the per-document recovery path. Collapsing it here would strand the
// document with no route to a read at all. The probe document is seeded
// without a content index, so it is exactly that case.
await mockDocumentCapability(page, { egress: "external", autoRead: true });
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
const sheet = page.getByRole("dialog");
await expect(sheet).toBeVisible();
await expect(
sheet.locator('[data-slot="document-download"]'),
).toBeVisible();
await expect(
sheet.locator('[data-slot="document-ai-section"]'),
).toHaveCount(1);
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
});
});