feat(pdf-server): get_viewer_state interact action (#590)
* feat(pdf-server): get_viewer_state interact action
New interact action that returns a JSON snapshot of the live viewer:
{currentPage, pageCount, zoom, displayMode, selectedAnnotationIds,
selection: {text, contextBefore, contextAfter, boundingRect} | null}.
The viewer already pushes selection passively via setModelContext as
<pdf-selection> tags, but not all hosts surface model-context. This gives
the model an explicit pull.
selection.boundingRect is a single bbox in PDF points (top-left origin,
y-down) so it can be fed straight back into add_annotations. selection is
null when nothing is selected or the selection is outside the text-layer.
Wiring: new PdfCommand variant -> processCommands case ->
handleGetViewerState -> submit_viewer_state (new app-only tool, mirrors
submit_save_data) -> waitForViewerState -> text content block.
Also fills a gap in the display_pdf description: it listed interact
actions but was missing save_as; added that and get_viewer_state.
e2e: two tests covering selection:null and a programmatic text-layer
selection.
* test(pdf-server): fix get_viewer_state e2e race + assertions
readLastToolResult clicked .last() before the interact result panel
existed (callInteract doesn't block), so it expanded the display_pdf
panel instead. Wait for the expected panel count first.
Also: basic-host renders the full CallToolResult JSON, with the state
double-escaped inside content[0].text. Parse instead of regex-matching.
playwright.config.ts: honor PW_CHANNEL env to use system Chrome locally
when the bundled chromium_headless_shell is broken.
This commit is contained in:
parent
2e55c842ba
commit
4fc95131bf
1 changed files with 281 additions and 1 deletions
|
|
@ -314,3 +314,93 @@ test.describe("PDF Server - Annotations", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Read the most recent interact result text from the basic-host UI.
|
||||
* Waits for the result-panel count to reach `expectedCount` first —
|
||||
* `callInteract` doesn't block, so `.last()` would otherwise race to the
|
||||
* previous (display_pdf) panel.
|
||||
*/
|
||||
async function readLastToolResult(
|
||||
page: Page,
|
||||
expectedCount: number,
|
||||
): Promise<string> {
|
||||
const panels = page.locator('text="📤 Tool Result"');
|
||||
await expect(panels).toHaveCount(expectedCount, { timeout: 30000 });
|
||||
await panels.last().click();
|
||||
const pre = page.locator("pre").last();
|
||||
await expect(pre).toBeVisible({ timeout: 5000 });
|
||||
return (await pre.textContent()) ?? "";
|
||||
}
|
||||
|
||||
/** Unwrap basic-host's `CallToolResult` JSON to the first text block. */
|
||||
function unwrapTextResult(raw: string): string {
|
||||
const parsed = JSON.parse(raw) as {
|
||||
content?: { type: string; text?: string }[];
|
||||
};
|
||||
const block = parsed.content?.find((c) => c.type === "text");
|
||||
if (!block?.text) throw new Error(`No text block in: ${raw.slice(0, 200)}`);
|
||||
return block.text;
|
||||
}
|
||||
|
||||
test.describe("PDF Server - get_viewer_state", () => {
|
||||
test("returns page/zoom/mode and selection:null when nothing is selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await loadPdfServer(page);
|
||||
await waitForPdfCanvas(page);
|
||||
|
||||
const viewUUID = await extractViewUUID(page);
|
||||
|
||||
await callInteract(page, { viewUUID, action: "get_viewer_state" });
|
||||
const raw = await readLastToolResult(page, 2);
|
||||
const state = JSON.parse(unwrapTextResult(raw));
|
||||
|
||||
expect(state.currentPage).toBe(1);
|
||||
expect(state.pageCount).toBeGreaterThan(1);
|
||||
expect(typeof state.zoom).toBe("number");
|
||||
expect(state.displayMode).toBe("inline");
|
||||
expect(state.selection).toBeNull();
|
||||
expect(Array.isArray(state.selectedAnnotationIds)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns selected text and bounding rect when text-layer text is selected", async ({
|
||||
page,
|
||||
}) => {
|
||||
await loadPdfServer(page);
|
||||
await waitForPdfCanvas(page);
|
||||
|
||||
const viewUUID = await extractViewUUID(page);
|
||||
const app = getAppFrame(page);
|
||||
|
||||
// Programmatically select the contents of the first text-layer span.
|
||||
const selectedText = await app
|
||||
.locator("#text-layer span")
|
||||
.first()
|
||||
.evaluate((span) => {
|
||||
const range = span.ownerDocument.createRange();
|
||||
range.selectNodeContents(span);
|
||||
const sel = span.ownerDocument.defaultView!.getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
return sel.toString().replace(/\s+/g, " ").trim();
|
||||
});
|
||||
expect(selectedText.length).toBeGreaterThan(0);
|
||||
|
||||
await callInteract(page, { viewUUID, action: "get_viewer_state" });
|
||||
const raw = await readLastToolResult(page, 2);
|
||||
const state = JSON.parse(unwrapTextResult(raw));
|
||||
|
||||
expect(state.currentPage).toBe(1);
|
||||
expect(state.selection).not.toBeNull();
|
||||
expect(state.selection.text).toContain(selectedText);
|
||||
expect(state.selection.boundingRect).toEqual(
|
||||
expect.objectContaining({
|
||||
x: expect.any(Number),
|
||||
y: expect.any(Number),
|
||||
width: expect.any(Number),
|
||||
height: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue