Skip to content

Commit 6af36e5

Browse files
committed
feat(screenshot): full-page capture, wait-for-selector, and --timeout
Adds three no-dependency options to the WebView screenshot helper + CLI: - fullPage / --full-page: measures the document scrollHeight via WebView.evaluate and re-renders at full height (WebView has no resize), so long pages (dashboards, docs) are captured whole instead of clipped to the viewport. - waitSelector / --wait-selector: polls the page until a CSS selector exists before capturing (client-rendered content, hydration, font swaps). - --timeout: exposes the existing timeoutMs navigation cap on the CLI. Dimensions clamp to WebView's 16384px ceiling. Tests cover full-page (PNG height > 3x viewport) and wait-selector.
1 parent b3e1c80 commit 6af36e5

3 files changed

Lines changed: 131 additions & 18 deletions

File tree

packages/stx/bin/cli.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2209,6 +2209,9 @@ catch (error) {
22092209
.option('--width <px>', 'Viewport width', { default: '1440' })
22102210
.option('--height <px>', 'Viewport height', { default: '2200' })
22112211
.option('--wait <ms>', 'Settle time after navigation before capture', { default: '800' })
2212+
.option('--timeout <ms>', 'Hard cap on waiting for navigation', { default: '15000' })
2213+
.option('--full-page', 'Capture the entire scrollable page, not just the viewport')
2214+
.option('--wait-selector <sel>', 'Wait until a CSS selector exists before capturing')
22122215
.option('--format <fmt>', 'Image format: png | jpeg | webp', { default: 'png' })
22132216
.option('--backend <name>', 'WebView backend: webkit (macOS, default) | chrome')
22142217
.action(async (input: string | undefined, options: any) => {
@@ -2219,6 +2222,9 @@ catch (error) {
22192222
width: Number.parseInt(options.width, 10),
22202223
height: Number.parseInt(options.height, 10),
22212224
waitMs: Number.parseInt(options.wait, 10),
2225+
timeoutMs: Number.parseInt(options.timeout, 10),
2226+
fullPage: Boolean(options.fullPage),
2227+
waitSelector: options.waitSelector,
22222228
format: options.format,
22232229
backend: options.backend,
22242230
})

packages/stx/src/screenshot.ts

Lines changed: 85 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import { WebView } from 'bun'
1212
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
1313
import path from 'node:path'
1414

15+
/** WebView viewport dimensions are clamped to [1, 16384] by Bun. */
16+
const MAX_DIMENSION = 16384
17+
1518
export interface ScreenshotOptions {
1619
/** Viewport width in CSS px. @default 1440 */
1720
width?: number
@@ -25,6 +28,19 @@ export interface ScreenshotOptions {
2528
format?: 'png' | 'jpeg' | 'webp'
2629
/** WebView backend. macOS defaults to system WebKit; elsewhere use 'chrome'. */
2730
backend?: 'webkit' | 'chrome'
31+
/**
32+
* Capture the entire scrollable page rather than just the viewport. The page
33+
* is measured (`scrollHeight`) and re-rendered at its full height so nothing
34+
* is cut off — ideal for long pages (dashboards, docs). Clamped to WebView's
35+
* 16384px ceiling. @default false
36+
*/
37+
fullPage?: boolean
38+
/**
39+
* Wait until this CSS selector is present in the DOM before capturing (polls
40+
* the page via `evaluate`). Use for content that renders after load (data
41+
* fetched client-side, hydration, fonts swapped in).
42+
*/
43+
waitSelector?: string
2844
}
2945

3046
export interface ShotResult {
@@ -40,37 +56,88 @@ function toUrl(input: string): string {
4056
return `file://${path.resolve(input)}`
4157
}
4258

59+
/** Resolve once navigation completes (+ a short settle), with a timeout fallback. */
60+
function waitForNavigation(view: any, waitMs: number, timeoutMs: number): Promise<void> {
61+
return new Promise<void>((resolve) => {
62+
let settled = false
63+
const finish = (): void => {
64+
if (!settled) {
65+
settled = true
66+
resolve()
67+
}
68+
}
69+
view.onNavigated = () => setTimeout(finish, waitMs)
70+
setTimeout(finish, timeoutMs)
71+
})
72+
}
73+
74+
/** Poll the page until `selector` exists, or `timeoutMs` elapses (best-effort). */
75+
async function waitForSelector(view: any, selector: string, timeoutMs: number): Promise<void> {
76+
const expr = `!!document.querySelector(${JSON.stringify(selector)})`
77+
const deadline = Date.now() + timeoutMs
78+
while (Date.now() < deadline) {
79+
try {
80+
if (await view.evaluate(expr))
81+
return
82+
}
83+
catch {
84+
// Navigation may still be settling; keep polling until the deadline.
85+
}
86+
await new Promise(r => setTimeout(r, 100))
87+
}
88+
}
89+
90+
/** Measure the full scrollable document height in CSS px (0 on failure). */
91+
async function measureFullHeight(view: any): Promise<number> {
92+
try {
93+
const h = await view.evaluate(
94+
'Math.ceil(Math.max('
95+
+ 'document.documentElement.scrollHeight, document.documentElement.offsetHeight, '
96+
+ 'document.body ? document.body.scrollHeight : 0, document.body ? document.body.offsetHeight : 0))',
97+
)
98+
return typeof h === 'number' && Number.isFinite(h) && h > 0 ? Math.min(h, MAX_DIMENSION) : 0
99+
}
100+
catch {
101+
return 0
102+
}
103+
}
104+
43105
/** Capture a single page to `output` using Bun's headless WebView. */
44106
export async function captureScreenshot(input: string, output: string, opts: ScreenshotOptions = {}): Promise<ShotResult> {
45-
const width = opts.width ?? 1440
46-
const height = opts.height ?? 2200
107+
const width = Math.min(opts.width ?? 1440, MAX_DIMENSION)
108+
const height = Math.min(opts.height ?? 2200, MAX_DIMENSION)
47109
const waitMs = opts.waitMs ?? 800
48110
const timeoutMs = opts.timeoutMs ?? 15000
49111
const format = opts.format ?? 'png'
112+
const url = toUrl(input)
50113

51-
const view = new WebView({
52-
url: toUrl(input),
114+
const open = (h: number): any => new WebView({
115+
url,
53116
width,
54-
height,
117+
height: h,
55118
headless: true,
56119
...(opts.backend ? { backend: opts.backend } : {}),
57120
} as any)
58121

122+
let view = open(height)
59123
try {
60-
// Resolve once navigation completes (+ a short settle), with a timeout
61-
// fallback in case onNavigated never fires.
62-
await new Promise<void>((resolve) => {
63-
let settled = false
64-
const finish = (): void => {
65-
if (!settled) {
66-
settled = true
67-
resolve()
68-
}
124+
await waitForNavigation(view, waitMs, timeoutMs)
125+
if (opts.waitSelector)
126+
await waitForSelector(view, opts.waitSelector, timeoutMs)
127+
128+
// Full-page: WebView has no resize(), so measure the document and re-render
129+
// at its full height. Only re-render when the page is actually taller than
130+
// the current viewport (a no-op otherwise).
131+
if (opts.fullPage) {
132+
const full = await measureFullHeight(view)
133+
if (full > height) {
134+
view.close()
135+
view = open(full)
136+
await waitForNavigation(view, waitMs, timeoutMs)
137+
if (opts.waitSelector)
138+
await waitForSelector(view, opts.waitSelector, timeoutMs)
69139
}
70-
const v = view as any
71-
v.onNavigated = () => setTimeout(finish, waitMs)
72-
setTimeout(finish, timeoutMs)
73-
})
140+
}
74141

75142
const buf = await view.screenshot({ encoding: 'buffer', format } as any)
76143
mkdirSync(path.dirname(path.resolve(output)), { recursive: true })

packages/stx/test/screenshot.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,44 @@ describe('screenshot (Bun WebView)', () => {
4242
rmSync(dir, { recursive: true, force: true })
4343
}
4444
})
45+
46+
it('full-page captures the whole scrollable document, not just the viewport', async () => {
47+
const dir = mkdtempSync(join(tmpdir(), 'stx-shot-full-'))
48+
// A page far taller than the viewport (a tall column).
49+
const tall = join(dir, 'tall.html')
50+
writeFileSync(tall, '<!doctype html><html><body style="margin:0"><div style="height:3000px;background:#0b1020"></div></body></html>')
51+
const viewportOut = join(dir, 'viewport.png')
52+
const fullOut = join(dir, 'full.png')
53+
try {
54+
await captureScreenshot(tall, viewportOut, { width: 400, height: 300, waitMs: 300 })
55+
await captureScreenshot(tall, fullOut, { width: 400, height: 300, waitMs: 300, fullPage: true })
56+
// PNG height lives in bytes 20-23 (big-endian) of the IHDR chunk.
57+
const pngHeight = async (p: string): Promise<number> => {
58+
const b = await Bun.file(p).bytes()
59+
return (b[20] << 24) | (b[21] << 16) | (b[22] << 8) | b[23]
60+
}
61+
const vh = await pngHeight(viewportOut)
62+
const fh = await pngHeight(fullOut)
63+
// The full-page shot is much taller than the 300px viewport capture.
64+
expect(fh).toBeGreaterThan(vh * 3)
65+
}
66+
finally {
67+
rmSync(dir, { recursive: true, force: true })
68+
}
69+
})
70+
71+
it('wait-selector resolves and still captures when the selector exists', async () => {
72+
const dir = mkdtempSync(join(tmpdir(), 'stx-shot-sel-'))
73+
const file = join(dir, 'sel.html')
74+
writeFileSync(file, '<!doctype html><html><body><div id="ready">ok</div></body></html>')
75+
const out = join(dir, 'sel.png')
76+
try {
77+
const r = await captureScreenshot(file, out, { width: 400, height: 300, waitMs: 200, waitSelector: '#ready', timeoutMs: 5000 })
78+
expect(existsSync(out)).toBe(true)
79+
expect(r.bytes).toBeGreaterThan(0)
80+
}
81+
finally {
82+
rmSync(dir, { recursive: true, force: true })
83+
}
84+
})
4585
})

0 commit comments

Comments
 (0)