/** * CE-WP-0005-T08 — full create → annotate → export → reimport E2E. * * Walks the demo loop end-to-end: * * 1. Load app → empty state. * 2. Create session "Demo" via the inline name input. * 3. Upload a synthetic PDF. * 4. Inject a selection for the manifest's known-good quote → save * evidence with a commentary. * 5. (Sanity) Click Export → Copy as Markdown; assert the clipboard * payload contains the quote + commentary + openContextUrl. * 6. Click Export ZIP; capture the Blob via a URL.createObjectURL * spy. * 7. Click Import ZIP with the captured Blob; assert merge: * - one document (deduped by fingerprint) * - two evidence rows (additive merge) * 8. Click Export → Copy as Markdown on the merged evidence; assert * the citation card text matches the original. */ // @vitest-environment happy-dom import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Document, DocumentRepresentation } from "@shared/document"; import type { DocumentId, RepresentationId } from "@shared/ids"; import type { Selector } from "@shared/selector"; import type { PdfSelectionCapture } from "@anchor/index"; import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; // --------------------------------------------------------------------------- // Mocks // --------------------------------------------------------------------------- interface ViewerProps { pdfUrl: string; storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[]; scrollToAnnotationId?: string; onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void; } interface ViewerSnapshot { pdfUrl: string | null; onSelectionCaptured: ViewerProps["onSelectionCaptured"] | null; } const viewerSnapshot: ViewerSnapshot = { pdfUrl: null, onSelectionCaptured: null, }; vi.mock("@anchor/index", async (importOriginal) => { const original = await importOriginal(); const MockPdfSpikeViewer = (props: ViewerProps) => { viewerSnapshot.pdfUrl = props.pdfUrl; viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured; return
; }; return { ...original, PdfSpikeViewer: MockPdfSpikeViewer }; }); const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!; const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" "); // Bypass real PDF.js extraction. The mock returns a stable fingerprint // so the merge-on-fingerprint dedup logic actually fires on re-import. vi.mock("@source/index", async (importOriginal) => { const original = await importOriginal(); let counter = 0; return { ...original, ingestPdfFromFile: vi.fn( async (file: File | Blob, store: import("@source/index").PdfByteStore) => { counter += 1; const filename = "name" in file && typeof file.name === "string" ? file.name : "uploaded.pdf"; const documentId = ("doc_e2e_" + counter) as DocumentId; const representationId = ("rep_e2e_" + counter) as RepresentationId; const bytes = new Uint8Array(await file.arrayBuffer()); const record = store.put(documentId, bytes); const document: Document = { id: documentId, mediaType: "application/pdf", title: filename, uri: record.blobUrl, // Stable fingerprint — the importer dedupes by fingerprint, so // re-importing the export merges into the same doc. fingerprint: "fp-stable-e2e", createdAt: "2026-05-25T00:00:00.000Z", updatedAt: "2026-05-25T00:00:00.000Z", }; const representation: DocumentRepresentation = { id: representationId, documentId, representationType: "pdf-text", contentHash: "fp-stable-e2e", canonicalText: SYNTHETIC_CANONICAL, pageMap: [{ page: 1, width: 595, height: 842 }], offsetMap: [ { page: 1, globalStart: 0, globalEnd: SYNTHETIC_CANONICAL.length, pageLength: SYNTHETIC_CANONICAL.length, }, ], generatedAt: "2026-05-25T00:00:00.000Z", }; return { document, representation }; }, ), }; }); // --------------------------------------------------------------------------- // Test-time intercepts: clipboard, URL.createObjectURL, file-picker input. // --------------------------------------------------------------------------- function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture { return { kind: "pdf", text, page, rects: [{ x: 0.1, y: 0.2, width: 0.4, height: 0.04 }], boundingRect: { x: 0.1, y: 0.2, width: 0.4, height: 0.04 }, }; } let writeText: ReturnType; function installClipboardSpy() { writeText = vi.fn(async () => undefined); const proto = Object.getPrototypeOf(navigator.clipboard); Object.defineProperty(proto, "writeText", { configurable: true, writable: true, value: writeText, }); } interface UrlInterceptor { blobs: Blob[]; restore(): void; } function installUrlInterceptor(): UrlInterceptor { const blobs: Blob[] = []; const origCreate = URL.createObjectURL; const origRevoke = URL.revokeObjectURL; let counter = 0; URL.createObjectURL = ((b: Blob) => { blobs.push(b); counter += 1; return `blob:test-${counter}`; }) as typeof URL.createObjectURL; URL.revokeObjectURL = (() => {}) as typeof URL.revokeObjectURL; return { blobs, restore() { URL.createObjectURL = origCreate; URL.revokeObjectURL = origRevoke; }, }; } interface FilePickerInterceptor { inputs: HTMLInputElement[]; restore(): void; } function installFilePickerInterceptor(): FilePickerInterceptor { const inputs: HTMLInputElement[] = []; const origCreate = document.createElement.bind(document); (document.createElement as unknown) = (tag: string) => { const el = origCreate(tag); if (tag === "input") inputs.push(el as HTMLInputElement); return el; }; return { inputs, restore() { document.createElement = origCreate; }, }; } async function loadApp() { const { App } = await import("@app/App"); return render(); } // --------------------------------------------------------------------------- // Test // --------------------------------------------------------------------------- describe("CE-WP-0005-T08 — full create → annotate → export → reimport E2E", () => { let url: UrlInterceptor; let picker: FilePickerInterceptor; beforeEach(() => { viewerSnapshot.pdfUrl = null; viewerSnapshot.onSelectionCaptured = null; globalThis.localStorage?.clear(); if (typeof window !== "undefined") { history.replaceState(null, "", window.location.pathname); } url = installUrlInterceptor(); picker = installFilePickerInterceptor(); }); afterEach(() => { cleanup(); url.restore(); picker.restore(); vi.restoreAllMocks(); }); it( "creates session, annotates, exports ZIP, re-imports ZIP, and round-trips the citation card", { timeout: 30000 }, async () => { const user = userEvent.setup(); await loadApp(); // Step 1: empty state. await screen.findByTestId("empty-state"); // Step 2: create session "Demo". const nameInput = screen.getByTestId("empty-state-input"); await user.type(nameInput, "Demo"); await user.click(screen.getByTestId("empty-state-create")); // Wait for the active session UI (collection list + upload dropzone). await screen.findByTestId("upload-dropzone"); // Step 3: upload a synthetic PDF. installClipboardSpy(); const fileBytes = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]); // %PDF- const pdfFile = new File([fileBytes], "e2e.pdf", { type: "application/pdf" }); const uploadInput = screen.getByTestId("upload-file-input") as HTMLInputElement; await user.upload(uploadInput, pdfFile); // Wait for the viewer to mount with the uploaded doc. await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull()); // Step 4: inject a selection + save evidence with commentary. await act(async () => { viewerSnapshot.onSelectionCaptured!( syntheticCaptureFor(FIXTURE.known_good_quote, FIXTURE.known_good_quote_page), [{ type: "TextQuoteSelector", exact: FIXTURE.known_good_quote }], ); }); await user.type( screen.getByTestId("inline-capture-commentary"), "E2E session commentary", ); await user.click(screen.getByTestId("inline-capture-save")); await screen.findByText(/E2E session commentary/); // Step 5 (sanity): export the evidence as Markdown via the sidebar. installClipboardSpy(); await user.click(await screen.findByLabelText("Export evidence item")); await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" })); await waitFor(() => expect(writeText).toHaveBeenCalled()); const firstCard = writeText.mock.calls[0]![0] as string; expect(firstCard).toContain(FIXTURE.known_good_quote); expect(firstCard).toContain("E2E session commentary"); expect(firstCard).toMatch(/\/viewer\?document=doc_[^&]+&annotation=ann_[^)]+/); // Step 6: click Export ZIP from the session menu and capture the // generated Blob via the URL.createObjectURL spy. const urlCountBeforeExport = url.blobs.length; await user.click(screen.getByTestId("session-menu-toggle")); await user.click(await screen.findByTestId("session-menu-export")); await waitFor(() => expect(url.blobs.length).toBeGreaterThan(urlCountBeforeExport)); // The exported ZIP is the most-recently-minted blob URL'd to . const zipBlob = url.blobs[url.blobs.length - 1]!; expect(zipBlob.size).toBeGreaterThan(0); // Step 7: import the ZIP back via the menu. Intercept the file // input the App opens, set our captured blob as its file, fire // change. const inputsBefore = picker.inputs.length; await user.click(screen.getByTestId("session-menu-toggle")); await user.click(await screen.findByTestId("session-menu-import")); // The handler created a new file input. await waitFor(() => expect(picker.inputs.length).toBeGreaterThan(inputsBefore)); const importInput = picker.inputs[picker.inputs.length - 1]!; const zipFile = new File([await zipBlob.arrayBuffer()], "demo.zip", { type: "application/zip", }); // happy-dom's `files` is a getter on HTMLInputElement.prototype; // a value-override on the instance silently doesn't take effect. // Replace with a property getter that returns our fake FileList. const fakeFileList = { 0: zipFile, length: 1, item: (i: number) => (i === 0 ? zipFile : null), [Symbol.iterator]: function* () { yield zipFile; }, }; Object.defineProperty(importInput, "files", { configurable: true, get: () => fakeFileList, }); await act(async () => { if (typeof importInput.onchange === "function") { importInput.onchange(new Event("change")); } // Yield so the import promise gets a chance to start. await new Promise((r) => setTimeout(r, 0)); }); // Wait for the merge to manifest in the DOM. ADR-0008's additive // policy means we should now see TWO evidence rows for the same // commentary (the original + the imported copy), and the // collection should still show ONE document (deduped by // fingerprint). await waitFor( () => { const matches = screen.queryAllByText(/E2E session commentary/); expect(matches.length).toBeGreaterThanOrEqual(2); }, { timeout: 12000 }, ); await waitFor(() => { const items = screen .getByTestId("collection-list-items") .querySelectorAll("li"); expect(items.length).toBe(1); }); // Step 8: export the *merged* evidence as Markdown and assert the // round-trip preserves quote + commentary + URL shape. installClipboardSpy(); const toggles = screen.getAllByLabelText("Export evidence item"); // Use the last evidence row (the one from the import) just to // distinguish from the original. await user.click(toggles[toggles.length - 1]!); await user.click(await screen.findByRole("menuitem", { name: "Copy as Markdown" })); await waitFor(() => expect(writeText).toHaveBeenCalled()); const mergedCard = writeText.mock.calls[0]![0] as string; expect(mergedCard).toContain(FIXTURE.known_good_quote); expect(mergedCard).toContain("E2E session commentary"); expect(mergedCard).toMatch(/\/viewer\?document=doc_[^&]+&annotation=ann_[^)]+/); }, ); });