mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
test(documents): e2e for the document chat panel; clear tail on settle
Add an e2e suite mocking the capability probe, the chat history GET, and the streaming chat POST so the panel renders (v1.27.31 lesson): the entry opens an empty safety-noted surface, a turn streams a reply that settles to one assistant bubble, a non-indexed document shows the calm hint, and no provider leaves no chat surface. Clear the streamed tail + optimistic bubble once the persisted twin lands so the reply never double-renders.
This commit is contained in:
1 parent
2ede0886c3
commit
8cead5ccdb
2 files changed
+299
-1
No files matched your search
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Document vault — the P4 "chat about this document" acceptance suite.
|
||||
*
|
||||
* Pins the scoped chat that lives in the detail sheet's AI area: the entry is
|
||||
* offered only when a provider is available AND the document is content-indexed;
|
||||
* a non-indexed document shows a calm read-it-first hint (never an error); a
|
||||
* conversation streams the assistant reply and settles it as plain text with the
|
||||
* always-on "not medical advice" safety note; and with NO provider the AI area
|
||||
* collapses to the settings pointer with no chat surface at all.
|
||||
*
|
||||
* v1.27.31 lesson: the panel depends on new endpoints (the capability probe, the
|
||||
* chat history GET, the streaming chat POST), so every one is mocked here —
|
||||
* otherwise the panel never renders and the specs time out waiting for elements.
|
||||
* The provider round-trips are mocked, so the SSE reply is deterministic. The
|
||||
* seeded demo user has no AI provider, so the no-provider state is the default.
|
||||
*/
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
ensureVaultFixture,
|
||||
ensureVaultAiFixture,
|
||||
AI_PROBE_DOC_ID,
|
||||
CONTENT_DOC_ID,
|
||||
} from "./setup/vault-fixture";
|
||||
|
||||
/** Fulfil the envelope shape the client unwraps (`(await res.json()).data`). */
|
||||
async function fulfilJson(
|
||||
route: Parameters<Parameters<Page["route"]>[1]>[0],
|
||||
body: unknown,
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** The vault usage DTO the sheet reads to gate the AI affordances. */
|
||||
function usageBody() {
|
||||
return {
|
||||
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: 1, totalCount: 2 },
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialise SSE frames the way the chat route emits them. */
|
||||
function sse(frames: Array<Record<string, unknown>>): string {
|
||||
return frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the AI-availability surface (usage + the document-scoped capability
|
||||
* probe) so the sheet offers the AI area and the chat entry. `egress` drives the
|
||||
* vendor-blind notice; default external.
|
||||
*/
|
||||
async function mockAiEnabled(
|
||||
page: Page,
|
||||
opts: { egress?: "local" | "external" } = {},
|
||||
): Promise<void> {
|
||||
await page.route("**/api/documents/inbound/usage", (route) =>
|
||||
fulfilJson(route, usageBody()),
|
||||
);
|
||||
await page.route("**/api/documents/inbound/capability", (route) =>
|
||||
fulfilJson(route, {
|
||||
data: {
|
||||
available: true,
|
||||
mode: "vision",
|
||||
reason: null,
|
||||
pdfSupported: true,
|
||||
egress: opts.egress ?? "external",
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("document vault — chat about a document", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await ensureVaultFixture();
|
||||
await ensureVaultAiFixture();
|
||||
});
|
||||
|
||||
// The chat drives a mocked provider round-trip; on a loaded CI runner it races
|
||||
// the default 30s, so the whole group earns the tripled timeout.
|
||||
test.beforeEach(() => {
|
||||
test.slow();
|
||||
});
|
||||
|
||||
// ── (a) Indexed + available: the entry opens an empty, safe chat surface ──
|
||||
|
||||
test("offers the chat entry and opens an empty, safety-noted surface", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAiEnabled(page);
|
||||
// The document has no thread yet → the history list is empty.
|
||||
await page.route(
|
||||
`**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`,
|
||||
(route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback();
|
||||
return fulfilJson(route, {
|
||||
data: { conversations: [], nextCursor: null },
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
const open = sheet.locator('[data-slot="document-chat-open"]');
|
||||
await expect(open).toBeVisible();
|
||||
await open.click();
|
||||
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-chat-log"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-chat-empty"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-chat-input"]'),
|
||||
).toBeVisible();
|
||||
// The always-on safety note is present.
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-chat-safety"]'),
|
||||
).toContainText("not medical advice");
|
||||
});
|
||||
|
||||
// ── (b) A turn streams a grounded reply that settles as plain text ──
|
||||
|
||||
test("streams the assistant reply and settles the conversation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockAiEnabled(page);
|
||||
|
||||
const CONV_ID = "e2edocchatconv0000000001";
|
||||
const QUESTION = "What does the Impression say?";
|
||||
const REPLY = "Per the report's Impression, the findings are unremarkable.";
|
||||
let posted = false;
|
||||
|
||||
await page.route(
|
||||
`**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`,
|
||||
async (route) => {
|
||||
const req = route.request();
|
||||
if (req.method() === "POST") {
|
||||
posted = true;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream; charset=utf-8",
|
||||
body: sse([
|
||||
{ type: "token", token: REPLY },
|
||||
{
|
||||
type: "done",
|
||||
conversationId: CONV_ID,
|
||||
messageId: "e2edocchatmsg0000000001",
|
||||
},
|
||||
]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const url = new URL(req.url());
|
||||
if (url.searchParams.get("conversationId")) {
|
||||
// The persisted thread the post-`done` refetch reads back.
|
||||
await fulfilJson(route, {
|
||||
data: {
|
||||
id: CONV_ID,
|
||||
title: QUESTION,
|
||||
createdAt: "2026-07-07T10:00:00.000Z",
|
||||
updatedAt: "2026-07-07T10:00:02.000Z",
|
||||
messageCount: 2,
|
||||
messages: [
|
||||
{
|
||||
id: "e2edocchatu0000000001",
|
||||
role: "user",
|
||||
content: QUESTION,
|
||||
createdAt: "2026-07-07T10:00:00.000Z",
|
||||
metricSource: null,
|
||||
providerType: null,
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
},
|
||||
{
|
||||
id: "e2edocchatmsg0000000001",
|
||||
role: "assistant",
|
||||
content: REPLY,
|
||||
createdAt: "2026-07-07T10:00:02.000Z",
|
||||
metricSource: null,
|
||||
providerType: "mock",
|
||||
promptVersion: null,
|
||||
tokensUsed: 42,
|
||||
model: "mock-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// The thread list: empty until the turn is posted, then the new thread.
|
||||
await fulfilJson(route, {
|
||||
data: {
|
||||
conversations: posted
|
||||
? [
|
||||
{
|
||||
id: CONV_ID,
|
||||
title: QUESTION,
|
||||
createdAt: "2026-07-07T10:00:00.000Z",
|
||||
updatedAt: "2026-07-07T10:00:02.000Z",
|
||||
messageCount: 2,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
nextCursor: null,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await sheet.locator('[data-slot="document-chat-open"]').click();
|
||||
const input = sheet.locator('[data-slot="document-chat-input"]');
|
||||
await input.fill(QUESTION);
|
||||
await sheet.locator('[data-slot="document-chat-send"]').click();
|
||||
|
||||
// The user's question and the streamed/settled reply both land in the log.
|
||||
const log = sheet.locator('[data-slot="document-chat-log"]');
|
||||
await expect(log.getByText(QUESTION)).toBeVisible();
|
||||
await expect(log.getByText(/findings are unremarkable/)).toBeVisible();
|
||||
// The reply settled to a single assistant bubble (no duplicate).
|
||||
await expect(
|
||||
log.locator('[data-slot="document-chat-message"][data-role="assistant"]'),
|
||||
).toHaveCount(1);
|
||||
});
|
||||
|
||||
// ── (c) Not indexed: a calm read-it-first hint, never an error ──
|
||||
|
||||
test("shows a calm hint when the document is not indexed", async ({
|
||||
page,
|
||||
}) => {
|
||||
// AI_PROBE_DOC_ID carries no content index (hasContentIndex === false).
|
||||
await mockAiEnabled(page);
|
||||
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-chat-not-indexed"]'),
|
||||
).toBeVisible();
|
||||
// No chat entry, and no error styling.
|
||||
await expect(sheet.locator('[data-slot="document-chat-open"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-chat"] [role="alert"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── (d) No provider: the calm settings pointer, no chat surface at all ──
|
||||
|
||||
test("with no provider there is no chat surface, only the settings pointer", async ({
|
||||
page,
|
||||
}) => {
|
||||
// No mocks — the seeded demo user has no AI provider configured, so the AI
|
||||
// area collapses to the calm pointer and the chat slot never renders.
|
||||
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await expect(
|
||||
sheet.locator('[data-slot="assist-unavailable"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
sheet.getByRole("link", { name: "Open AI settings" }),
|
||||
).toBeVisible();
|
||||
await expect(sheet.locator('[data-slot="document-chat"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -275,10 +275,14 @@ export function useSendDocumentChatMessage(documentId: string) {
|
||||
|
||||
if (resolvedConversationId) {
|
||||
// Reload the encrypted-on-disk thread (canonical ids + any outbound
|
||||
// editorialisation). The persisted twin replaces the optimistic bubble.
|
||||
// editorialisation), then clear the live tail + optimistic bubble so the
|
||||
// persisted turns are the single source — the refetch is awaited, so the
|
||||
// canonical messages are in cache before the streamed copy is dropped
|
||||
// (no gap, no duplicate assistant bubble).
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.inboundDocumentChat(documentId),
|
||||
});
|
||||
setStreaming(EMPTY_STREAMING);
|
||||
setOptimisticUser(null);
|
||||
}
|
||||
return resolvedConversationId;
|
||||
|
||||
Reference in new issue
Block a user