ext-apps/tests/e2e/generate-grid-screenshots.spec.ts
Olivier Chafik 96daa46a1d
examples: add PDF viewer w/ chunked data loading, full-screen, model context updates, private tool (#267)
* feat(pdf-server): Interactive PDF viewer example

PDF viewer with PDF.js featuring:
- Chunked binary loading with progress bar
- Text extraction for AI context
- arXiv paper support (fetch by ID)
- Page navigation with keyboard shortcuts
- Zoom controls (including Ctrl+0 reset)
- Fullscreen mode support
- Horizontal swipe for page changes (disabled when zoomed)
- Page persistence in localStorage
- Text selection via PDF.js TextLayer
- Clickable title link to source URL
- Rounded corners and subtle border styling

* chore: Add pdf-server to screenshot generation list

* refactor(pdf-server): Simplify and generalize PDF loading

- Accept any HTTP(s) URLs instead of ArXiv-only
- Use HTTP Range requests for chunked binary loading
- Remove ArXiv-specific code (arxiv.ts, metadata fetching)
- Remove CLAUDE.md index generation
- Flatten hierarchical folder structure to simple entries list
- Remove dead code: getPdfSummary, httpFileSizes
- Simplify base64 encoding using Buffer
- Simplify chunk extraction using slice()
- Consolidate DEFAULT_PDF_URL constant

The server now works with any PDF URL, not just arXiv papers.
HTTP Range requests stream chunks on-demand when supported.

* feat(pdf-server): Include title and selection in model context

- Add pdfTitle to updateModelContext structuredContent
- Include selection position (text, start, end) when text is selected
- Add debounced selectionchange listener to update context on selection

* fix(pdf-server): Restore default URL in view_pdf schema

The UI needs the default value in the schema to show it properly.

* refactor(pdf-server): Further simplifications

- Remove hard-coded test paths from main()
- Remove unused resources: pdfs://metadata/{pdfId}, pdfs://content/{pdfId}
- Remove unused metadata fields: subject, creator, producer, creationDate, modDate
- Remove unused entry fields: relativePath, estimatedTextSize
- Remove filterEntriesByFolder and folder filter from list_pdfs
- Remove redundant output schema validation (trust typed returns)
- Simplify scanDirectory and createLocalEntry signatures

Total: 1836 → 1666 lines (-170 lines, -9%)

* refactor(pdf-server): Major simplification for didactic focus

Simplified the example to focus on key MCP Apps SDK patterns:
- Chunked data through size-limited tool calls
- Model context updates (page text + selection)
- Display modes (fullscreen vs inline)
- External links (openLink)

Changes:
- Remove local file support (HTTP URLs only)
- Restrict dynamic URLs to arxiv.org for security
- Simplify types: url instead of sourcePath/sourceType
- Simplify indexer: 168 → 44 lines
- Simplify loader: 318 → 171 lines
- Simplify server: 337 → 233 lines
- Fix selection text normalization
- Rewrite README with didactic focus

Total: 1836 → 1236 lines (-33%)

* feat(pdf-server): Add file:// URL support for local files

- Local paths are converted to file:// URLs on startup
- file:// URLs must be in the initial list (strict validation)
- Dynamic URLs still restricted to arxiv.org only
- Updated README with local file examples

* fix(pdf-server): Improve selection detection with logging

- Add logging to selectionchange handler to verify it fires
- Add fallback matching without spaces (TextLayer spans may lack spaces)
- Log selection detection success/failure for debugging

The issue: PDF.js TextLayer renders text as positioned spans without
space characters between them. When selecting across spans:
- pageText has spaces (items joined with ' ')
- sel.toString() may not have spaces
- indexOf fails to match

The fix tries exact match first, then falls back to spaceless matching.

* feat(pdf-server): Format model context as markdown with front matter

Model context now looks like:
```markdown
---
url: https://arxiv.org/pdf/...
page: 5/144
---

Page text with <pdf-selection>selected text</pdf-selection> inline.
```

This is cleaner for the model to parse and includes the source URL.

* refactor(pdf-server): Extract smart truncation helpers

Added two well-designed helpers:

formatPageContent(text, maxLength, selection?)
- Centers truncation window around selection if present
- Adds <truncated-content/> markers at elision points
- Wraps selection in <pdf-selection> tags
- Allocates 60% context before, 40% after for readability

findSelectionInText(pageText, selectedText)
- Tries exact match first
- Falls back to spaceless match for TextLayer quirks
- Returns { start, end } or undefined

Example output with selection:
```
<truncated-content/>
...context before... <pdf-selection>selected text</pdf-selection> ...context after...
<truncated-content/>
```

* fix(pdf-server): Truncate inside selection tags when selection too long

When selection is too large for the budget:
<truncated-content/><pdf-selection><truncated-content/>start...end<truncated-content/></pdf-selection><truncated-content/>

This keeps the selection structure intact while showing beginning and end.

* refactor(pdf-server): Remove unused read_pdf_text, use Attention paper as default

- Remove read_pdf_text tool (viewer extracts text client-side with pdfjs)
- Remove PdfTextChunk and ReadPdfTextInput types
- Remove loadPdfTextChunk from pdf-loader
- Change default PDF to 'Attention Is All You Need' (1706.03762)
- Update README with modest language

* refactor(pdf-server): Simplify to use URL as ID, rename view_pdf to display_pdf

Major simplifications:
- Use URL directly as identifier (no hashing)
- Remove displayName - show elided URL with full URL as tooltip
- Rename view_pdf to display_pdf with better description
- Update all references from pdfId to url
- Simplify storage key and model context

The tool description now explains it displays an interactive viewer in the chat.

* feat(pdf-server): Normalize arxiv URLs to PDF format

arxiv.org/abs/... -> arxiv.org/pdf/...

Applied both at startup and when loading dynamic URLs.

* docs(pdf-server): Add prompt engineering to display_pdf description

* fix(pdf-server): Sharp rendering on retina displays

Account for devicePixelRatio when rendering canvas:
- Scale canvas dimensions by dpr
- Scale context by dpr
- Keep CSS size at logical pixels

* fix(pdf-server): Normalize arxiv URLs in read_pdf_bytes too

* add to e2e spec

* add to e2e spec

* add to e2e spec

* add to e2e spec

* regen

* chore: regenerate package-lock.json and fix hono vulnerability

* docs: add pdf-server screenshot to READMEs

* regen

* ci: add missing examples to pkg-pr-new publish

* ci: add pdf-server to npm publish examples

* Update README.md

* pdf-server: improve tool response text for better model context

* revert unrelated screenshot changes

* pdf-server: dynamically add arxiv URLs in read_pdf_bytes

Fixes 'PDF not found' error when server restarts between display_pdf
(which adds the entry) and read_pdf_bytes (which previously only looked
up existing entries). Now read_pdf_bytes mirrors display_pdf's logic
and dynamically adds arxiv URLs to the index.
2026-01-14 14:25:24 +00:00

179 lines
5.3 KiB
TypeScript

/**
* Generate 300x300 grid-cell.png screenshots for each example server.
*
* Usage:
* npm run generate:screenshots
*
* Output: examples/<server-dir>/grid-cell.png (300x300, cropped top-aligned)
*
* For basic-server-* variants, only basic-server-react is included.
* integration-server is excluded (it's for E2E testing, same UI as basic-server-react).
*/
import { test, type Page } from "@playwright/test";
import * as path from "path";
import * as fs from "fs";
import sharp from "sharp";
const OUTPUT_SIZE = 300;
const APP_WIDTH = 500;
const DEFAULT_WAIT_MS = 5000;
// Extra wait time for slow-loading servers (tiles, etc.)
const EXTRA_WAIT_MS: Record<string, number> = {
"map-server": 45000, // CesiumJS needs time for map tiles
"pdf-server": 45000, // Chunked loading of file
};
// Servers to skip (screenshots maintained manually)
const SKIP_SERVERS = new Set([
"video-resource", // Uses custom screenshot from PR comment
]);
// Server configurations (excludes integration-server which is for E2E testing)
const SERVERS = [
{
key: "basic-react",
name: "Basic MCP App Server (React)",
dir: "basic-server-react",
},
{
key: "budget-allocator",
name: "Budget Allocator Server",
dir: "budget-allocator-server",
},
{
key: "cohort-heatmap",
name: "Cohort Heatmap Server",
dir: "cohort-heatmap-server",
},
{
key: "customer-segmentation",
name: "Customer Segmentation Server",
dir: "customer-segmentation-server",
},
{ key: "map-server", name: "CesiumJS Map Server", dir: "map-server" },
{ key: "pdf-server", name: "PDF Server", dir: "pdf-server" },
{
key: "scenario-modeler",
name: "SaaS Scenario Modeler",
dir: "scenario-modeler-server",
},
{ key: "shadertoy", name: "ShaderToy Server", dir: "shadertoy-server" },
{
key: "sheet-music",
name: "Sheet Music Server",
dir: "sheet-music-server",
},
{
key: "system-monitor",
name: "System Monitor Server",
dir: "system-monitor-server",
},
{ key: "threejs", name: "Three.js Server", dir: "threejs-server" },
{ key: "transcript", name: "Transcript Server", dir: "transcript-server" },
{
key: "video-resource",
name: "Video Resource Server",
dir: "video-resource-server",
},
{ key: "wiki-explorer", name: "Wiki Explorer", dir: "wiki-explorer-server" },
];
/**
* Wait for the MCP App to load inside nested iframes.
*/
async function waitForAppLoad(page: Page) {
const outerFrame = page.frameLocator("iframe").nth(0);
await outerFrame
.locator("iframe")
.waitFor({ state: "visible", timeout: 60000 });
}
/**
* Load a server by selecting it from dropdown and clicking Call Tool.
*/
async function loadServer(page: Page, serverName: string) {
await page.goto("/");
await page
.locator("select")
.nth(0)
.waitFor({ state: "visible", timeout: 30000 });
await page.waitForTimeout(500);
await page.locator("select").nth(0).selectOption({ label: serverName });
await page.click('button:has-text("Call Tool")');
await waitForAppLoad(page);
}
/**
* Capture the app iframe content and save both:
* - screenshot.png: full-size raw screenshot of the iframe
* - grid-cell.png: 300x300 cropped thumbnail (top-aligned)
*/
async function captureAppScreenshot(page: Page, outputDir: string) {
// Get the inner app iframe element
const outerFrame = page.frameLocator("iframe").nth(0);
const innerIframe = outerFrame.locator("iframe").nth(0);
// Screenshot the inner iframe element
const screenshot = await innerIframe.screenshot();
// Save full-size screenshot
const screenshotPath = path.join(outputDir, "screenshot.png");
await sharp(screenshot).png().toFile(screenshotPath);
// Save 300x300 grid cell thumbnail (crop to fill, align top)
const gridCellPath = path.join(outputDir, "grid-cell.png");
await sharp(screenshot)
.resize(OUTPUT_SIZE, OUTPUT_SIZE, {
fit: "cover",
position: "top",
})
.png()
.toFile(gridCellPath);
return { screenshotPath, gridCellPath };
}
// Use a constrained viewport width for consistent app rendering
test.use({ viewport: { width: APP_WIDTH, height: 600 } });
// Increase test timeout for slow servers
test.setTimeout(120000);
// Generate screenshots for each server
for (const server of SERVERS) {
test(`Generate grid-cell.png for ${server.dir}`, async ({ page }) => {
const examplesDir = path.join(process.cwd(), "examples");
const outputDir = path.join(examplesDir, server.dir);
// Skip if directory doesn't exist
if (!fs.existsSync(outputDir)) {
console.log(`⚠️ Skipping ${server.dir}: directory not found`);
test.skip();
return;
}
// Skip servers with manually maintained screenshots
if (SKIP_SERVERS.has(server.key)) {
console.log(`⏭️ Skipping ${server.dir}: manually maintained screenshot`);
test.skip();
return;
}
// Load the server
await loadServer(page, server.name);
// Wait for app to fully load (extra time for slow servers)
const waitMs = EXTRA_WAIT_MS[server.key] ?? DEFAULT_WAIT_MS;
console.log(`⏳ Waiting ${waitMs / 1000}s for ${server.dir}...`);
await page.waitForTimeout(waitMs);
// Capture and save both screenshot.png and grid-cell.png
const { screenshotPath, gridCellPath } = await captureAppScreenshot(
page,
outputDir,
);
console.log(`✅ Saved ${screenshotPath} + ${gridCellPath}`);
});
}