mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-24 10:39:57 +02:00
fix(browse): guard full-page screenshots against Anthropic vision API >2000px brick (#1214)
Full-page screenshots of tall pages routinely exceeded 2000px on the longest dimension, silently bricking the agent's session: the resulting base64 reached the Anthropic vision API which rejected the oversized image, leaving the agent burning turns on a useless blob with no stderr trace from the browse side. Adds browse/src/screenshot-size-guard.ts as a shared helper: - guardScreenshotBuffer(buf) → downscales in-memory if max(w,h) > 2000 - guardScreenshotPath(path) → file-mode variant that rewrites in place - Aspect ratio preserved via sharp's resize fit:inside - Stderr diagnostic on any downscale so callers can see when it fired - Lazy sharp import so non-screenshot paths pay no startup cost Wires the guard into all three full-page callsites codex review flagged: - browse/src/snapshot.ts: annotated + heatmap fullPage captures - browse/src/meta-commands.ts: screenshot command (path + base64 fullPage modes) plus the responsive 3-viewport sweep - browse/src/write-commands.ts: prettyscreenshot fullPage path Covers seven unit cases (pass-through, downscale, aspect ratio, exactly-2000px edge, file-mode rewrite) plus a static invariant test that fails the build if any of the three callsites stops importing the guard. Closes #1214. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Unit tests for the screenshot size guard (#1214).
|
||||
*
|
||||
* Verifies that images exceeding 2000px on the longest dimension get
|
||||
* downscaled to fit the Anthropic vision API cap, while images already
|
||||
* inside the cap pass through untouched.
|
||||
*
|
||||
* Integration with the three callsites (snapshot.ts, meta-commands.ts,
|
||||
* write-commands.ts) is exercised by the existing browse E2E suite — we
|
||||
* don't need to spin up Chromium just to verify the helper. The static
|
||||
* invariant test below pins that all three callsites import the guard.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import sharp from 'sharp';
|
||||
import {
|
||||
SCREENSHOT_MAX_DIMENSION_PX,
|
||||
guardScreenshotBuffer,
|
||||
guardScreenshotPath,
|
||||
} from '../src/screenshot-size-guard';
|
||||
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), 'screenshot-guard-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function makePng(width: number, height: number): Promise<Buffer> {
|
||||
return sharp({
|
||||
create: { width, height, channels: 3, background: { r: 200, g: 50, b: 50 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
describe('guardScreenshotBuffer', () => {
|
||||
test('passes through images already within the cap', async () => {
|
||||
const input = await makePng(1500, 1800);
|
||||
const { buffer, result } = await guardScreenshotBuffer(input);
|
||||
expect(result.resized).toBe(false);
|
||||
expect(result.width).toBe(1500);
|
||||
expect(result.height).toBe(1800);
|
||||
expect(buffer).toBe(input); // identity — no re-encode
|
||||
});
|
||||
|
||||
test('downscales a 5000px-tall image to fit the cap', async () => {
|
||||
const input = await makePng(1200, 5000);
|
||||
const { buffer, result } = await guardScreenshotBuffer(input);
|
||||
expect(result.resized).toBe(true);
|
||||
expect(result.originalHeight).toBe(5000);
|
||||
expect(Math.max(result.width, result.height)).toBeLessThanOrEqual(
|
||||
SCREENSHOT_MAX_DIMENSION_PX,
|
||||
);
|
||||
// Aspect ratio preserved.
|
||||
expect(result.height / result.width).toBeCloseTo(5000 / 1200, 1);
|
||||
// Buffer is a different (smaller) PNG.
|
||||
expect(buffer.length).toBeLessThan(input.length);
|
||||
});
|
||||
|
||||
test('downscales a 6000px-wide image', async () => {
|
||||
const input = await makePng(6000, 1200);
|
||||
const { buffer, result } = await guardScreenshotBuffer(input);
|
||||
expect(result.resized).toBe(true);
|
||||
expect(result.originalWidth).toBe(6000);
|
||||
expect(Math.max(result.width, result.height)).toBeLessThanOrEqual(
|
||||
SCREENSHOT_MAX_DIMENSION_PX,
|
||||
);
|
||||
expect(buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('treats exactly-2000px images as in-bounds (no resize)', async () => {
|
||||
const input = await makePng(2000, 1000);
|
||||
const { result } = await guardScreenshotBuffer(input);
|
||||
expect(result.resized).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardScreenshotPath', () => {
|
||||
test('rewrites the file in place when downscale is needed', async () => {
|
||||
const filePath = join(tmp, 'tall.png');
|
||||
writeFileSync(filePath, await makePng(1200, 5000));
|
||||
const result = await guardScreenshotPath(filePath);
|
||||
expect(result.resized).toBe(true);
|
||||
const written = readFileSync(filePath);
|
||||
const meta = await sharp(written).metadata();
|
||||
expect(Math.max(meta.width ?? 0, meta.height ?? 0)).toBeLessThanOrEqual(
|
||||
SCREENSHOT_MAX_DIMENSION_PX,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves the file untouched when already within cap', async () => {
|
||||
const filePath = join(tmp, 'short.png');
|
||||
const original = await makePng(800, 600);
|
||||
writeFileSync(filePath, original);
|
||||
const result = await guardScreenshotPath(filePath);
|
||||
expect(result.resized).toBe(false);
|
||||
const written = readFileSync(filePath);
|
||||
expect(written.equals(original)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('static invariant: all three full-page callsites import the guard', () => {
|
||||
test('snapshot.ts, meta-commands.ts, and write-commands.ts wire the size guard', () => {
|
||||
const browseSrc = join(import.meta.dir, '..', 'src');
|
||||
const paths = ['snapshot.ts', 'meta-commands.ts', 'write-commands.ts'];
|
||||
for (const rel of paths) {
|
||||
const content = readFileSync(join(browseSrc, rel), 'utf-8');
|
||||
expect(content).toContain('screenshot-size-guard');
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user