mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The virtualized vault grid only mounts cards near the viewport, so the provenance marker must be brought into view the way a user would — through the content search that already backs the neighbouring spec. The auto-read sheet assertion anchors on the footer's download action (rendered only once the document GET resolved) so the absence checks cannot pass vacuously against the loading skeleton.
281 lines
9.9 KiB
TypeScript
281 lines
9.9 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 { expect, test, type Page } from "@playwright/test";
|
|
|
|
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
import {
|
|
ensureVaultFixture,
|
|
ensureVaultAiFixture,
|
|
AI_PROBE_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 manual AI action row", async ({
|
|
page,
|
|
}) => {
|
|
// Auto-read reads every upload with no per-document tap, so the manual
|
|
// Read / Suggest actions are redundant and the WHOLE block collapses
|
|
// (v1.28.22 — no status chrome; provenance lives on the vault card).
|
|
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();
|
|
|
|
// v1.28.22 — with auto-read ON and nothing pending the WHOLE block
|
|
// collapses (no status chrome; provenance lives on the vault card), so the
|
|
// sheet content starts straight with the document fields. 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);
|
|
});
|
|
});
|