Skip to content

Commit 5caaa37

Browse files
committed
test(pdf-server): e2e for restoredRemovedIds tombstone preservation through viewer
Adds /with-native-annot.pdf fixture (2 pages, native /Text annot on page 2) and a Playwright test that drives delete on page 2 → iframe reload → interact add_annotations on page 1 (persist with page 2 unscanned) → assert localStorage diff still contains the removed id → page 2 still shows it gone. Covers the mcp-app.ts glue the unit tests can't reach.
1 parent ce4600f commit 5caaa37

2 files changed

Lines changed: 139 additions & 1 deletion

File tree

tests/e2e/pdf-incremental-load.spec.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,105 @@ test.describe("PDF Server — incremental loading", () => {
187187
const { overlapBytes } = rangeServer.stats();
188188
expect(overlapBytes).toBeLessThan(fileSize * 0.5);
189189
});
190+
191+
test("deleted native annotation tombstone survives a persist before its page is scanned", async ({
192+
page,
193+
}) => {
194+
// Regression for the lazy baseline scan: restoredRemovedIds must be
195+
// unioned into persistAnnotations() and getAnnotatedPdfBytes() so a
196+
// delete on page 2 isn't silently dropped when an unrelated edit on
197+
// page 1 triggers a persist before page 2 has been re-scanned.
198+
199+
await displayPdf(page, `${rangeServer.baseUrl}/with-native-annot.pdf`);
200+
await waitForAppLoad(page);
201+
await waitForFirstPageRendered(page);
202+
const sc = await readStructuredContent(page);
203+
const viewUUID = sc.viewUUID as string;
204+
expect(viewUUID).toBeTruthy();
205+
206+
const app = getAppFrame(page);
207+
208+
// 1. Go to page 2, open the panel, delete the native annotation via UI.
209+
await app.locator("#next-btn").click();
210+
await expect(app.locator("#page-input")).toHaveValue("2");
211+
await app.locator("#annotations-btn").click();
212+
const nativeCard = app.locator(
213+
'.annotation-card[data-annotation-id^="pdf-"]',
214+
);
215+
await expect(nativeCard).toBeVisible({ timeout: 10_000 });
216+
const nativeId = await nativeCard.getAttribute("data-annotation-id");
217+
expect(nativeId).toMatch(/^pdf-\d+R?$/);
218+
await nativeCard.locator(".annotation-card-delete").click();
219+
await expect(nativeCard).toHaveCount(0);
220+
221+
// 2. Back to page 1 so the post-reload viewer restores there (page 2
222+
// must stay unscanned until the very end).
223+
await app.locator("#page-input").fill("1");
224+
await app.locator("#page-input").press("Enter");
225+
await expect(app.locator("#page-input")).toHaveValue("1");
226+
227+
// 3. Capture the annotation localStorage key and confirm the delete was
228+
// persisted.
229+
const storageKey = await app
230+
.locator("body")
231+
.evaluate(
232+
() =>
233+
Object.keys(localStorage).find(
234+
(k) => k.startsWith("pdf-annot:") || k.endsWith(":annotations"),
235+
) ?? null,
236+
);
237+
expect(storageKey).toBeTruthy();
238+
const diffBefore = await app
239+
.locator("body")
240+
.evaluate((_, k) => localStorage.getItem(k), storageKey!);
241+
expect(JSON.parse(diffBefore!).removed).toContain(nativeId);
242+
243+
// 4. Reload the inner viewer iframe ONLY (basic-host keeps the same
244+
// cached tool result → same viewUUID/toolId → same storage key).
245+
// restoreAnnotations() now seeds restoredRemovedIds from localStorage
246+
// while the lazy scan has only seen page 1.
247+
await app.locator("body").evaluate(() => location.reload());
248+
await waitForFirstPageRendered(page);
249+
await expect(app.locator("#page-input")).toHaveValue("1");
250+
251+
// 5. Trigger persistAnnotations() via an unrelated edit on page 1 — the
252+
// bug scenario: page 2 has not been scanned yet.
253+
const toolSelect = page.locator("select").nth(1);
254+
await toolSelect.selectOption("interact");
255+
await page.locator("textarea").fill(
256+
JSON.stringify({
257+
viewUUID,
258+
action: "add_annotations",
259+
annotations: [
260+
{
261+
id: "probe-on-page-1",
262+
type: "highlight",
263+
page: 1,
264+
rects: [{ x: 50, y: 700, width: 100, height: 12 }],
265+
},
266+
],
267+
}),
268+
);
269+
await page.click('button:has-text("Call Tool")');
270+
await expect(
271+
app.locator('[data-annotation-id="probe-on-page-1"]'),
272+
).toHaveCount(1, { timeout: 10_000 });
273+
274+
// 6. Load-bearing assertion: the persisted diff still carries the
275+
// tombstone. Pre-fix, computeDiff() over the page-1-only baseline
276+
// yielded removed=[], overwriting it.
277+
const diffAfter = await app
278+
.locator("body")
279+
.evaluate((_, k) => localStorage.getItem(k), storageKey!);
280+
const removedAfter: string[] = JSON.parse(diffAfter!).removed;
281+
expect(removedAfter).toContain(nativeId);
282+
283+
// 7. Belt-and-suspenders: navigate to page 2 and confirm the native
284+
// annotation has not resurrected in the panel.
285+
await app.locator("#next-btn").click();
286+
await expect(app.locator("#page-input")).toHaveValue("2");
287+
await expect(
288+
app.locator(`.annotation-card[data-annotation-id="${nativeId}"]`),
289+
).toHaveCount(0);
290+
});
190291
});

tests/helpers/range-counting-server.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
*/
99
import http from "node:http";
1010
import type { AddressInfo } from "node:net";
11-
import { PDFDocument, StandardFonts } from "pdf-lib";
11+
import { PDFDocument, PDFName, PDFString, StandardFonts } from "pdf-lib";
1212

1313
export interface RangeRequest {
1414
path: string;
@@ -96,6 +96,42 @@ function makeRandomJpeg(len: number): Uint8Array {
9696
/** Threshold after which /error.pdf starts returning 500. */
9797
export const ERROR_AFTER_BYTES = 50_000;
9898

99+
/**
100+
* Two pages, page 1 text-only, page 2 carries one native /Text (sticky-note)
101+
* annotation. Used by the tombstone-preservation e2e: the viewer's lazy
102+
* baseline scan must not have visited page 2 when persistAnnotations runs.
103+
*/
104+
async function buildWithNativeAnnotPdf(): Promise<Uint8Array> {
105+
const doc = await PDFDocument.create();
106+
const font = await doc.embedFont(StandardFonts.Helvetica);
107+
const page1 = doc.addPage([612, 792]);
108+
page1.drawText("Page 1 — no native annotations here.", {
109+
x: 36,
110+
y: 740,
111+
size: 12,
112+
font,
113+
});
114+
const page2 = doc.addPage([612, 792]);
115+
page2.drawText("Page 2 — has one native /Text annot.", {
116+
x: 36,
117+
y: 740,
118+
size: 12,
119+
font,
120+
});
121+
const annotRef = doc.context.register(
122+
doc.context.obj({
123+
Type: "Annot",
124+
Subtype: "Text",
125+
Rect: [100, 700, 120, 720],
126+
Contents: PDFString.of("native sticky note"),
127+
Open: false,
128+
Name: "Comment",
129+
}),
130+
);
131+
page2.node.set(PDFName.of("Annots"), doc.context.obj([annotRef]));
132+
return doc.save();
133+
}
134+
99135
async function buildFormsPdf(): Promise<Uint8Array> {
100136
const doc = await PDFDocument.create();
101137
const form = doc.getForm();
@@ -119,6 +155,7 @@ export async function startRangeServer(): Promise<RangeServer> {
119155
const files: Record<string, Uint8Array> = {
120156
"/noforms.pdf": noforms,
121157
"/forms.pdf": await buildFormsPdf(),
158+
"/with-native-annot.pdf": await buildWithNativeAnnotPdf(),
122159
// Same bytes as noforms.pdf, but the route 500s after ERROR_AFTER_BYTES
123160
// total have been served — exercises display_pdf's transport.failed path.
124161
"/error.pdf": noforms,

0 commit comments

Comments
 (0)