@@ -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 ( / ^ p d f - \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} ) ;
0 commit comments