From 3e85164eabc212a2dc7012ca3286df39693d00c8 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 15 Oct 2026 15:00:52 +0530 Subject: [PATCH 1/3] fix(automate): cap screenshot downloads and image decode size fetchAutomationScreenshots had no size limit on any of its allocations, so one call could grow a hosted pod by hundreds of MB (the sharp/libvips pixel buffers traced in ai-common-infra-ops#125). - Cap the /sessions/{id}/logs download at MAX_LOG_BYTES (5 MB on remote), matching getFailureLogs on the same endpoint. - Cap each screenshot download at 10 MB on remote; skip oversized ones and return the rest, or explain if all are too large. - In maybeCompressBase64, reject images over 25M px before decoding and downscale to at most 1920 px on the long side. Co-Authored-By: Claude Opus 5.5 --- src/lib/utils.ts | 17 ++- src/tools/automate-utils/fetch-screenshots.ts | 53 +++++-- tests/lib/utils.test.ts | 15 ++ tests/setup.ts | 11 +- .../tools/fetch-screenshots-size-cap.test.ts | 143 ++++++++++++++++++ 5 files changed, 221 insertions(+), 18 deletions(-) create mode 100644 tests/tools/fetch-screenshots-size-cap.test.ts diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e866f2e4..f554d12e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -13,6 +13,9 @@ export function sanitizeUrlParam(param: string): string { } const ONE_MB = 1048576; +// sharp's default (~268M px) lets one image decode to ~1 GB of RGBA; 25M px caps it near 100 MB. +const MAX_INPUT_PIXELS = 25_000_000; +const MAX_IMAGE_DIMENSION = 1920; let sharpConfigured = false; //Compresses a base64 image intelligently to keep it under 1 MB if needed. @@ -35,11 +38,21 @@ export async function maybeCompressBase64(base64: string): Promise { sharp.concurrency(1); sharpConfigured = true; } - const compressedBuffer = await sharp(buffer).png({ quality }).toBuffer(); + const compressedBuffer = await sharp(buffer, { + limitInputPixels: MAX_INPUT_PIXELS, + }) + .resize({ + width: MAX_IMAGE_DIMENSION, + height: MAX_IMAGE_DIMENSION, + fit: "inside", + withoutEnlargement: true, + }) + .png({ quality }) + .toBuffer(); return compressedBuffer.toString("base64"); } catch (err) { logger.warn( - "Image compression unavailable (sharp failed to load); returning the uncompressed image. %s", + "Image compression failed; returning the uncompressed image. %s", err instanceof Error ? err.message : String(err), ); return base64; diff --git a/src/tools/automate-utils/fetch-screenshots.ts b/src/tools/automate-utils/fetch-screenshots.ts index a570b6f2..99f3a030 100644 --- a/src/tools/automate-utils/fetch-screenshots.ts +++ b/src/tools/automate-utils/fetch-screenshots.ts @@ -4,6 +4,11 @@ import { getBrowserStackAuth } from "../../lib/get-auth.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { apiClient } from "../../lib/apiClient.js"; import globalConfig from "../../config.js"; +import { MAX_LOG_BYTES } from "../failurelogs-utils/utils.js"; + +export const MAX_SCREENSHOT_BYTES = globalConfig.REMOTE_MCP + ? 10 * 1024 * 1024 + : undefined; async function extractScreenshotUrls( sessionId: string, @@ -22,9 +27,15 @@ async function extractScreenshotUrls( "Content-Type": "application/json", Authorization: `Basic ${auth}`, }, + maxContentLength: MAX_LOG_BYTES, raise_error: false, }); + if (response.status === 413) { + throw new Error( + "The logs for this session are too large (over 5 MB) to find screenshots here. Open the session in the BrowserStack dashboard to view them.", + ); + } await assertOkResponse(response, "Session"); const text = @@ -54,30 +65,50 @@ async function extractScreenshotUrls( return urls; } -async function fetchAndCompress( - url: string, -): Promise<{ url: string; base64: string }> { - const response = await apiClient.get({ url, responseType: "arraybuffer" }); +type Screenshot = { url: string; base64: string }; + +// Returns null for a screenshot over MAX_SCREENSHOT_BYTES so the rest still load. +async function fetchAndCompress(url: string): Promise { + const response = await apiClient.get({ + url, + responseType: "arraybuffer", + maxContentLength: MAX_SCREENSHOT_BYTES, + raise_error: false, + }); + if (response.status === 413) { + return null; + } + if (!response.ok) { + throw new Error( + `Failed to download screenshot: ${response.status} ${response.statusText}`, + ); + } // Axios returns response.data as a Buffer for binary data const base64 = Buffer.from(response.data).toString("base64"); return { url, base64: await maybeCompressBase64(base64) }; } //Converts screenshot URLs to base64 encoded images -async function convertUrlsToBase64( - urls: string[], -): Promise> { +async function convertUrlsToBase64(urls: string[]): Promise { + let results: Array; // Hosted pods are memory-constrained: process one image at a time. Local runs // keep the concurrent path. if (globalConfig.REMOTE_MCP) { - const screenshots: Array<{ url: string; base64: string }> = []; + results = []; for (const url of urls) { - screenshots.push(await fetchAndCompress(url)); + results.push(await fetchAndCompress(url)); } - return screenshots; + } else { + results = await Promise.all(urls.map(fetchAndCompress)); } - return Promise.all(urls.map(fetchAndCompress)); + const screenshots = results.filter((s): s is Screenshot => s !== null); + if (urls.length > 0 && screenshots.length === 0) { + throw new Error( + "The screenshots in this session are too large (over 10 MB each) to fetch here. Open the session in the BrowserStack dashboard to view them.", + ); + } + return screenshots; } //Fetches and converts screenshot URLs to base64 encoded images diff --git a/tests/lib/utils.test.ts b/tests/lib/utils.test.ts index de320e56..3f23d670 100644 --- a/tests/lib/utils.test.ts +++ b/tests/lib/utils.test.ts @@ -30,6 +30,21 @@ describe("maybeCompressBase64", () => { expect(sharp as unknown as Mock).toHaveBeenCalledTimes(1); }); + it("bounds decoded pixels and downscales large images", async () => { + await maybeCompressBase64(overSized()); + const sharpMock = sharp as unknown as Mock; + expect(sharpMock.mock.calls[0][1]).toEqual({ + limitInputPixels: 25_000_000, + }); + const pipeline = sharpMock.mock.results[0].value; + expect(pipeline.resize).toHaveBeenCalledWith({ + width: 1920, + height: 1920, + fit: "inside", + withoutEnlargement: true, + }); + }); + it("falls back to the uncompressed image (and warns) when sharp fails to load", async () => { (sharp as unknown as Mock).mockImplementationOnce(() => { throw new Error("Could not load the sharp module (Node < 20.9)"); diff --git a/tests/setup.ts b/tests/setup.ts index e634ea58..f95a206c 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -2,11 +2,12 @@ import { vi } from "vitest"; // Mock sharp globally — it's a native binary module that may not be available in CI vi.mock("sharp", () => { - const sharp: any = vi.fn().mockReturnValue({ - png: vi.fn().mockReturnValue({ - toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-image")), - }), - }); + const pipeline: any = { + toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-image")), + }; + pipeline.png = vi.fn().mockReturnValue(pipeline); + pipeline.resize = vi.fn().mockReturnValue(pipeline); + const sharp: any = vi.fn().mockReturnValue(pipeline); sharp.cache = vi.fn(); sharp.concurrency = vi.fn(); return { default: sharp }; diff --git a/tests/tools/fetch-screenshots-size-cap.test.ts b/tests/tools/fetch-screenshots-size-cap.test.ts new file mode 100644 index 00000000..d2fbfc59 --- /dev/null +++ b/tests/tools/fetch-screenshots-size-cap.test.ts @@ -0,0 +1,143 @@ +import { + describe, + it, + expect, + vi, + beforeAll, + afterAll, + afterEach, +} from "vitest"; +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { apiClient } from "../../src/lib/apiClient"; +import { fetchAutomationScreenshots } from "../../src/tools/automate-utils/fetch-screenshots"; +import { SessionType } from "../../src/lib/constants"; + +vi.mock("../../src/index", () => ({ trackMCP: vi.fn() })); +vi.mock("../../src/lib/get-auth", () => ({ + getBrowserStackAuth: vi.fn(() => "user:key"), +})); + +vi.mock("../../src/config", async (importOriginal) => { + const actual: any = await importOriginal(); + return { default: { ...actual.default, REMOTE_MCP: true } }; +}); + +const config = { + "browserstack-username": "user", + "browserstack-access-key": "key", +} as any; + +const HUGE_BYTES = 30 * 1024 * 1024; + +function screenshotLog(urls: string[]): string { + return urls + .map( + (u) => + `REQUEST [2026-10-15] GET /session/sid/screenshot {}\nRESPONSE {"state":"success","value":"${u}"}`, + ) + .join("\n"); +} + +describe("fetchAutomationScreenshots with oversized downloads", () => { + let server: http.Server; + let base: string; + let logsBody = ""; + const bytesSent: Record = {}; + + beforeAll(async () => { + server = http.createServer((req, res) => { + if (req.url === "/logs") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end(logsBody); + return; + } + if (req.url === "/small.png") { + res.writeHead(200, { "Content-Type": "image/png" }); + res.end(Buffer.from("small-png")); + return; + } + // /huge.png and /huge-logs stream 30 MB unless the client aborts first + res.writeHead(200, { "Content-Type": "application/octet-stream" }); + const chunk = Buffer.alloc(64 * 1024, 1); + let sent = 0; + const write = () => { + while (sent < HUGE_BYTES) { + sent += chunk.length; + bytesSent[req.url!] = sent; + if (!res.write(chunk)) return void res.once("drain", write); + } + res.end(); + }; + res.on("error", () => {}); + write(); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + server.closeAllConnections?.(); + await new Promise((r) => server.close(r)); + }); + + afterEach(() => vi.restoreAllMocks()); + + function routeLogsTo(path: string) { + const realGet = Object.getPrototypeOf(apiClient).get.bind(apiClient); + vi.spyOn(apiClient, "get").mockImplementation((opts) => + realGet({ + ...opts, + url: opts.url.endsWith("/logs") ? `${base}${path}` : opts.url, + }), + ); + } + + it("skips an oversized screenshot, aborts its download, and returns the rest", async () => { + logsBody = screenshotLog([`${base}/huge.png`, `${base}/small.png`]); + routeLogsTo("/logs"); + + const shots = await fetchAutomationScreenshots( + "sid", + SessionType.Automate, + config, + ); + + expect(shots).toEqual([ + { + url: `${base}/small.png`, + base64: Buffer.from("small-png").toString("base64"), + }, + ]); + expect(bytesSent["/huge.png"]).toBeLessThan(HUGE_BYTES); + }); + + it("throws a too-large error when every screenshot is oversized", async () => { + logsBody = screenshotLog([`${base}/huge.png`]); + routeLogsTo("/logs"); + + await expect( + fetchAutomationScreenshots("sid", SessionType.Automate, config), + ).rejects.toThrow(/screenshots in this session are too large/); + }); + + it("throws a too-large error for oversized session logs", async () => { + routeLogsTo("/huge-logs"); + + await expect( + fetchAutomationScreenshots("sid", SessionType.Automate, config), + ).rejects.toThrow(/logs for this session are too large/); + expect(bytesSent["/huge-logs"]).toBeLessThan(HUGE_BYTES); + }); + + it("does not cap screenshot downloads for local (non-remote) servers", async () => { + vi.resetModules(); + vi.doMock("../../src/config", async (importOriginal) => { + const actual: any = await importOriginal(); + return { default: { ...actual.default, REMOTE_MCP: false } }; + }); + const { MAX_SCREENSHOT_BYTES } = + await import("../../src/tools/automate-utils/fetch-screenshots"); + expect(MAX_SCREENSHOT_BYTES).toBeUndefined(); + }); +}); From a607b4ae4e757b99684a887f9f1d76f85d9b0a58 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 15 Oct 2026 15:05:45 +0530 Subject: [PATCH 2/3] fix(automate): apply image decode and resize limits on remote only Local stdio runs keep full-resolution screenshots and sharp's default pixel limit, matching the REMOTE_MCP gating of the download caps. Co-Authored-By: Claude Opus 5.5 --- src/lib/utils.ts | 20 +++++++++----------- tests/lib/utils.test.ts | 17 +++++++++++++++-- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/lib/utils.ts b/src/lib/utils.ts index f554d12e..630c17dc 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -38,17 +38,15 @@ export async function maybeCompressBase64(base64: string): Promise { sharp.concurrency(1); sharpConfigured = true; } - const compressedBuffer = await sharp(buffer, { - limitInputPixels: MAX_INPUT_PIXELS, - }) - .resize({ - width: MAX_IMAGE_DIMENSION, - height: MAX_IMAGE_DIMENSION, - fit: "inside", - withoutEnlargement: true, - }) - .png({ quality }) - .toBuffer(); + const image = globalConfig.REMOTE_MCP + ? sharp(buffer, { limitInputPixels: MAX_INPUT_PIXELS }).resize({ + width: MAX_IMAGE_DIMENSION, + height: MAX_IMAGE_DIMENSION, + fit: "inside", + withoutEnlargement: true, + }) + : sharp(buffer); + const compressedBuffer = await image.png({ quality }).toBuffer(); return compressedBuffer.toString("base64"); } catch (err) { logger.warn( diff --git a/tests/lib/utils.test.ts b/tests/lib/utils.test.ts index 3f23d670..85841139 100644 --- a/tests/lib/utils.test.ts +++ b/tests/lib/utils.test.ts @@ -9,13 +9,18 @@ vi.mock("../../src/index", () => ({ trackMCP: vi.fn() })); vi.mock("../../src/logger", () => ({ default: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, })); +const cfg = vi.hoisted(() => ({ REMOTE_MCP: false })); +vi.mock("../../src/config", () => ({ default: cfg })); // sharp itself is globally mocked in tests/setup.ts (native module). const ONE_MB = 1048576; const overSized = () => Buffer.alloc(ONE_MB + 1, 7).toString("base64"); describe("maybeCompressBase64", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + cfg.REMOTE_MCP = false; + }); it("returns the input unchanged when already <= 1 MB (never touches sharp)", async () => { const small = Buffer.from("small-image").toString("base64"); @@ -30,7 +35,8 @@ describe("maybeCompressBase64", () => { expect(sharp as unknown as Mock).toHaveBeenCalledTimes(1); }); - it("bounds decoded pixels and downscales large images", async () => { + it("on remote, bounds decoded pixels and downscales large images", async () => { + cfg.REMOTE_MCP = true; await maybeCompressBase64(overSized()); const sharpMock = sharp as unknown as Mock; expect(sharpMock.mock.calls[0][1]).toEqual({ @@ -45,6 +51,13 @@ describe("maybeCompressBase64", () => { }); }); + it("locally, keeps full resolution and sharp's default pixel limit", async () => { + await maybeCompressBase64(overSized()); + const sharpMock = sharp as unknown as Mock; + expect(sharpMock.mock.calls[0]).toHaveLength(1); + expect(sharpMock.mock.results[0].value.resize).not.toHaveBeenCalled(); + }); + it("falls back to the uncompressed image (and warns) when sharp fails to load", async () => { (sharp as unknown as Mock).mockImplementationOnce(() => { throw new Error("Could not load the sharp module (Node < 20.9)"); From fbb3b2b80c299322375a081c8749be4e5706f981 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 15 Oct 2026 16:59:34 +0530 Subject: [PATCH 3/3] fix(automate): fold the visual-log screenshot parser into the size caps PR #481 and this branch both rewrote fetchAndCompress, so they could not be merged independently. This brings #481's changes onto the size-cap work: - parse DEBUG visual-log screenshots alongside test-initiated ones, anchored on the timestamped log prefix so a REQUEST cannot borrow a later RESPONSE - restrict screenshot URLs to https on BrowserStack, S3 and B2 hosts - detect the image mime type from the bytes, after compression: sharp re-encodes anything over 1 MB to PNG, so reading the original bytes would label a large JPEG wrongly - report the empty result as a debug-mode hint rather than an unexpected error Screenshot keeps both shapes: it carries mimeType and fetchAndCompress still returns null for an oversized download so the remaining screenshots load. The size-cap tests now use timestamped log lines and allowlisted https URLs, routed to the local server by the apiClient spy; the previous fixtures were written against the older permissive parser and would otherwise parse to no URLs at all. Co-Authored-By: Claude Opus 5 --- src/tools/automate-utils/fetch-screenshots.ts | 82 ++++++-- src/tools/automate.ts | 4 +- tests/tools/automate.test.ts | 6 +- .../tools/fetch-screenshots-size-cap.test.ts | 27 ++- tests/tools/fetch-screenshots.test.ts | 177 ++++++++++++++++++ 5 files changed, 271 insertions(+), 25 deletions(-) create mode 100644 tests/tools/fetch-screenshots.test.ts diff --git a/src/tools/automate-utils/fetch-screenshots.ts b/src/tools/automate-utils/fetch-screenshots.ts index 99f3a030..2a204df0 100644 --- a/src/tools/automate-utils/fetch-screenshots.ts +++ b/src/tools/automate-utils/fetch-screenshots.ts @@ -43,29 +43,74 @@ async function extractScreenshotUrls( ? response.data : JSON.stringify(response.data); + return parseScreenshotUrls(text); +} + +const LOG_PREFIX = String.raw`^\d{4}-\d{2}-\d{2} \d{1,2}:\d{1,2}:\d{1,2}:\d{1,3} `; +const LINE_TYPE_PATTERN = new RegExp(`${LOG_PREFIX}([A-Z_]+) `); +const SCREENSHOT_REQUEST_PATTERN = new RegExp( + String.raw`${LOG_PREFIX}REQUEST \[[^\]]+\] GET /session/[^/\s]+/screenshot(\s|$)`, +); +const RESPONSE_VALUE_PATTERN = new RegExp( + String.raw`${LOG_PREFIX}RESPONSE \{"value"\s*:\s*"(https://[^"]+)"`, +); +const DEBUG_SCREENSHOT_PATTERN = new RegExp( + String.raw`${LOG_PREFIX}DEBUG (https://\S+)$`, +); +const TRUSTED_HOST_PATTERN = + /(^|\.)browserstack\.com$|\.s3([.-][a-z0-9-]+)*\.amazonaws\.com$|\.backblazeb2\.com$/; + +function isTrustedScreenshotUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "https:" && TRUSTED_HOST_PATTERN.test(url.hostname); + } catch { + return false; + } +} + +export function parseScreenshotUrls(text: string): string[] { const urls: string[] = []; - const SCREENSHOT_PATTERN = /REQUEST.*GET.*\/screenshot/; - const RESPONSE_VALUE_PATTERN = /"value"\s*:\s*"([^"]+)"/; + const lines = text.split("\n").map((line) => line.trimEnd()); - // Split logs into lines and process them - const lines = text.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; - for (let i = 0; i < lines.length - 1; i++) { - const currentLine = lines[i]; - const nextLine = lines[i + 1]; + const debugMatch = line.match(DEBUG_SCREENSHOT_PATTERN); + if (debugMatch) { + urls.push(debugMatch[1]); + continue; + } - if (SCREENSHOT_PATTERN.test(currentLine)) { - const match = nextLine.match(RESPONSE_VALUE_PATTERN); - if (match && match[1]) { - urls.push(match[1]); + if (!SCREENSHOT_REQUEST_PATTERN.test(line)) { + continue; + } + for (let j = i + 1; j < lines.length; j++) { + const lineType = lines[j].match(LINE_TYPE_PATTERN)?.[1]; + if (lineType === "REQUEST") { + break; + } + if (lineType === "RESPONSE") { + const valueMatch = lines[j].match(RESPONSE_VALUE_PATTERN); + if (valueMatch) { + urls.push(valueMatch[1]); + } + break; } } } - return urls; + return [...new Set(urls)].filter(isTrustedScreenshotUrl); +} + +function detectImageMimeType(buffer: Buffer): string { + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return "image/jpeg"; + } + return "image/png"; } -type Screenshot = { url: string; base64: string }; +export type Screenshot = { url: string; base64: string; mimeType: string }; // Returns null for a screenshot over MAX_SCREENSHOT_BYTES so the rest still load. async function fetchAndCompress(url: string): Promise { @@ -84,8 +129,15 @@ async function fetchAndCompress(url: string): Promise { ); } // Axios returns response.data as a Buffer for binary data - const base64 = Buffer.from(response.data).toString("base64"); - return { url, base64: await maybeCompressBase64(base64) }; + const buffer = Buffer.from(response.data); + const base64 = await maybeCompressBase64(buffer.toString("base64")); + // Detect AFTER compression: sharp re-encodes anything over 1 MB to PNG, so + // reading the original bytes would label a large JPEG with the wrong type. + return { + url, + base64, + mimeType: detectImageMimeType(Buffer.from(base64, "base64")), + }; } //Converts screenshot URLs to base64 encoded images diff --git a/src/tools/automate.ts b/src/tools/automate.ts index 5c1ae7e6..963a7c1e 100644 --- a/src/tools/automate.ts +++ b/src/tools/automate.ts @@ -36,7 +36,7 @@ export async function fetchAutomationScreenshotsTool( content: [ { type: "text", - text: "No screenshots found in the session or some unexpected error occurred", + text: "No screenshots found in the session logs. Run with browserstack.debug: true to capture visual-log screenshots.", }, ], isError: true, @@ -46,7 +46,7 @@ export async function fetchAutomationScreenshotsTool( const results = screenshots.map((screenshot, index) => ({ type: "image" as const, data: screenshot.base64, - mimeType: "image/png", + mimeType: screenshot.mimeType, _meta: { url: screenshot.url, index: index + 1 }, })); diff --git a/tests/tools/automate.test.ts b/tests/tools/automate.test.ts index c0e22dd1..ef8ed209 100644 --- a/tests/tools/automate.test.ts +++ b/tests/tools/automate.test.ts @@ -21,8 +21,8 @@ describe("fetchAutomationScreenshotsTool", () => { it("SUCCESS: returns screenshots as image content", async () => { (fetchAutomationScreenshots as Mock).mockResolvedValue([ - { base64: "abc123", url: "https://example.com/1.png" }, - { base64: "def456", url: "https://example.com/2.png" }, + { base64: "abc123", url: "https://example.com/1.png", mimeType: "image/png" }, + { base64: "def456", url: "https://example.com/2.jpeg", mimeType: "image/jpeg" }, ]); const result = await fetchAutomationScreenshotsTool( @@ -34,6 +34,8 @@ describe("fetchAutomationScreenshotsTool", () => { expect(result.content.length).toBe(3); // 1 text + 2 images expect(result.content[0].type).toBe("text"); expect(result.content[1].type).toBe("image"); + expect(result.content[1].mimeType).toBe("image/png"); + expect(result.content[2].mimeType).toBe("image/jpeg"); }); it("SUCCESS: returns isError when no screenshots found", async () => { diff --git a/tests/tools/fetch-screenshots-size-cap.test.ts b/tests/tools/fetch-screenshots-size-cap.test.ts index d2fbfc59..67e7c57f 100644 --- a/tests/tools/fetch-screenshots-size-cap.test.ts +++ b/tests/tools/fetch-screenshots-size-cap.test.ts @@ -30,11 +30,23 @@ const config = { const HUGE_BYTES = 30 * 1024 * 1024; +// The parser only accepts allowlisted https hosts, so the log carries real +// BrowserStack URLs and the apiClient spy routes them to the local server. +const HUGE_URL = "https://automate.browserstack.com/s3-debug/huge.png"; +const SMALL_URL = "https://automate.browserstack.com/s3-debug/small.png"; + +const LOCAL_PATH: Record = { + [HUGE_URL]: "/huge.png", + [SMALL_URL]: "/small.png", +}; + +// Mirrors the SeleniumHub log shape: a timestamped REQUEST followed by its RESPONSE. function screenshotLog(urls: string[]): string { return urls .map( - (u) => - `REQUEST [2026-10-15] GET /session/sid/screenshot {}\nRESPONSE {"state":"success","value":"${u}"}`, + (u, i) => + `2026-10-15 10:0:${i}:100 REQUEST [abc] GET /session/sid/screenshot\n` + + `2026-10-15 10:0:${i}:200 RESPONSE {"value":"${u}"}`, ) .join("\n"); } @@ -88,13 +100,15 @@ describe("fetchAutomationScreenshots with oversized downloads", () => { vi.spyOn(apiClient, "get").mockImplementation((opts) => realGet({ ...opts, - url: opts.url.endsWith("/logs") ? `${base}${path}` : opts.url, + url: opts.url.endsWith("/logs") + ? `${base}${path}` + : `${base}${LOCAL_PATH[opts.url] ?? opts.url}`, }), ); } it("skips an oversized screenshot, aborts its download, and returns the rest", async () => { - logsBody = screenshotLog([`${base}/huge.png`, `${base}/small.png`]); + logsBody = screenshotLog([HUGE_URL, SMALL_URL]); routeLogsTo("/logs"); const shots = await fetchAutomationScreenshots( @@ -105,15 +119,16 @@ describe("fetchAutomationScreenshots with oversized downloads", () => { expect(shots).toEqual([ { - url: `${base}/small.png`, + url: SMALL_URL, base64: Buffer.from("small-png").toString("base64"), + mimeType: "image/png", }, ]); expect(bytesSent["/huge.png"]).toBeLessThan(HUGE_BYTES); }); it("throws a too-large error when every screenshot is oversized", async () => { - logsBody = screenshotLog([`${base}/huge.png`]); + logsBody = screenshotLog([HUGE_URL]); routeLogsTo("/logs"); await expect( diff --git a/tests/tools/fetch-screenshots.test.ts b/tests/tools/fetch-screenshots.test.ts new file mode 100644 index 00000000..22d45c9d --- /dev/null +++ b/tests/tools/fetch-screenshots.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { + fetchAutomationScreenshots, + parseScreenshotUrls, +} from "../../src/tools/automate-utils/fetch-screenshots"; +import { SessionType } from "../../src/lib/constants"; +import { apiClient } from "../../src/lib/apiClient"; + +vi.mock("../../src/lib/apiClient", () => ({ + apiClient: { get: vi.fn() }, +})); +vi.mock("../../src/logger", () => ({ + default: { error: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})); + +const mockConfig = { + "browserstack-username": "fake-user", + "browserstack-access-key": "fake-key", +}; + +const S = "9eaca30c2d3fb8517ac11e469724ab2421bc63c3"; +const S3 = `https://automate.browserstack.com/s3-debug/browserstack-debug-screenshots-prod-use1/${S}`; + +const debugOnlyLogs = [ + `2026-10-13 9:12:45:16 REQUEST [2026-10-13 9:12:45:16] POST /session/${S}/url {"url":"https://bstackdemo.com/signin"}`, + `2026-10-13 9:12:46:632 DEBUG ${S3}/screenshot-4.jpeg`, + `2026-10-13 9:12:46:633 RESPONSE {"text":{"response":{"guid":"response@23b4"}}}`, + `2026-10-13 9:12:48:839 REQUEST [2026-10-13 9:12:48:839] POST /session/${S}/click {"selector":"#username"}`, + `2026-10-13 9:12:49:127 DEBUG ${S3}/screenshot-6.jpeg`, + `2026-10-13 9:12:49:200 RESPONSE {"text":{}}`, +].join("\n"); + +const requestedOnlyLogs = [ + `2026-10-13 9:18:20:871 REQUEST [2026-10-13 9:18:20:871] GET /session/${S}/screenshot {"text":{"fullPage":false}}`, + `2026-10-13 9:18:21:3 RESPONSE {"value":"${S3}/screenshot-playwright-85c8de7c55.png"}`, + `2026-10-13 9:18:23:992 REQUEST [2026-10-13 9:18:23:992] GET /session/${S}/screenshot {"text":{}}`, + `2026-10-13 9:18:24:81 RESPONSE {"value":"${S3}/screenshot-playwright-304ad8e401.png"}`, +].join("\n"); + +describe("parseScreenshotUrls", () => { + it("extracts DEBUG visual-log screenshots", () => { + expect(parseScreenshotUrls(debugOnlyLogs)).toEqual([ + `${S3}/screenshot-4.jpeg`, + `${S3}/screenshot-6.jpeg`, + ]); + }); + + it("extracts screenshots requested via GET /screenshot", () => { + expect(parseScreenshotUrls(requestedOnlyLogs)).toEqual([ + `${S3}/screenshot-playwright-85c8de7c55.png`, + `${S3}/screenshot-playwright-304ad8e401.png`, + ]); + }); + + it("merges both sources in log order", () => { + const logs = [ + `2026-10-13 9:20:22:000 REQUEST [2026-10-13 9:20:22:000] POST /session/${S}/url {"url":"https://example.com"}`, + `2026-10-13 9:20:22:114 DEBUG ${S3}/screenshot-24ff228371.jpeg`, + `2026-10-13 9:20:22:200 RESPONSE {"value":null}`, + `2026-10-13 9:20:23:500 REQUEST [2026-10-13 9:20:23:500] GET /session/${S}/screenshot {}`, + `2026-10-13 9:20:23:740 RESPONSE {"value":"${S3}/screenshot-selenium-2d84facd1a.png"}`, + ].join("\n"); + + expect(parseScreenshotUrls(logs)).toEqual([ + `${S3}/screenshot-24ff228371.jpeg`, + `${S3}/screenshot-selenium-2d84facd1a.png`, + ]); + }); + + it("accepts B2 and direct bucket URLs on DEBUG lines", () => { + const b2 = `https://automate.browserstack.com/b2-debug/bucket/s3.us-east-005.backblazeb2.com/${S}/screenshot-2.jpeg`; + const direct = `https://browserstack-debug-screenshots-prod-use1.s3.amazonaws.com/${S}/screenshot-3.jpeg`; + const logs = [ + `2026-10-13 9:00:00:000 DEBUG ${b2}`, + `2026-10-13 9:00:01:000 DEBUG ${direct}`, + ].join("\r\n"); + + expect(parseScreenshotUrls(logs)).toEqual([b2, direct]); + }); + + it("skips intermediate lines between a screenshot request and its response", () => { + const logs = [ + `2026-10-13 9:00:00:000 REQUEST [2026-10-13 9:00:00:000] GET /session/${S}/screenshot {}`, + `2026-10-13 9:00:00:050 PERFORMANCE {"lcp":120}`, + `2026-10-13 9:00:00:100 RESPONSE {"value":"${S3}/screenshot-selenium-1.png"}`, + ].join("\n"); + + expect(parseScreenshotUrls(logs)).toEqual([ + `${S3}/screenshot-selenium-1.png`, + ]); + }); + + it("does not borrow a later request's response value", () => { + const logs = [ + `2026-10-13 9:00:00:000 REQUEST [2026-10-13 9:00:00:000] GET /session/${S}/screenshot {}`, + `2026-10-13 9:00:01:000 REQUEST [2026-10-13 9:00:01:000] GET /session/${S}/title`, + `2026-10-13 9:00:01:100 RESPONSE {"value":"Example Domain"}`, + ].join("\n"); + + expect(parseScreenshotUrls(logs)).toEqual([]); + }); + + it("ignores DEBUG and response URLs echoed inside request payloads", () => { + const logs = [ + `2026-10-13 9:00:00:000 REQUEST [2026-10-13 9:00:00:000] POST /session/${S}/value {"text":"x DEBUG https://evil.browserstack.com.attacker.io/a.png"}`, + `2026-10-13 9:00:00:100 RESPONSE {"value":null}`, + `2026-10-13 9:00:01:000 REQUEST [2026-10-13 9:00:01:000] POST /session/${S}/execute {"script":"GET /session/x/screenshot"}`, + `2026-10-13 9:00:01:100 RESPONSE {"value":"https://automate.browserstack.com/not-a-screenshot"}`, + `2026-10-13 9:00:02:000 REQUEST [2026-10-13 9:00:02:000] GET /session/${S}/screenshot {}`, + `2026-10-13 9:00:02:100 RESPONSE {"other":{"value":"${S3}/nested.png"}}`, + ].join("\n"); + + expect(parseScreenshotUrls(logs)).toEqual([]); + }); + + it("drops URLs outside BrowserStack, S3 and B2 hosts", () => { + const logs = [ + `2026-10-13 9:00:00:000 DEBUG http://169.254.169.254/latest/meta-data/`, + `2026-10-13 9:00:01:000 DEBUG https://169.254.169.254/latest/meta-data/`, + `2026-10-13 9:00:02:000 DEBUG https://localhost/screenshot-1.jpeg`, + `2026-10-13 9:00:03:000 DEBUG https://browserstack.com.evil.io/screenshot-2.jpeg`, + `2026-10-13 9:00:04:000 DEBUG https://sts.amazonaws.com/screenshot-3.jpeg`, + `2026-10-13 9:00:05:000 DEBUG ${S3}/screenshot-4.jpeg`, + ].join("\n"); + + expect(parseScreenshotUrls(logs)).toEqual([`${S3}/screenshot-4.jpeg`]); + }); + + it("returns nothing when the session has no screenshots", () => { + const logs = [ + `2026-10-13 9:21:1:278 REQUEST [2026-10-13 9:21:1:278] POST /session/${S}/url {"url":"https://example.com"}`, + `2026-10-13 9:21:3:848 RESPONSE {"value":null}`, + ].join("\n"); + + expect(parseScreenshotUrls(logs)).toEqual([]); + }); +}); + +describe("fetchAutomationScreenshots", () => { + beforeEach(() => vi.clearAllMocks()); + + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); + const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); + + it("returns the last five screenshots with detected mime types", async () => { + const urls = Array.from({ length: 6 }, (_, i) => `${S3}/screenshot-${i}.jpeg`); + const logs = urls + .map((u, i) => `2026-10-13 9:00:0${i}:000 DEBUG ${u}`) + .concat(requestedOnlyLogs.split("\n").slice(0, 2)) + .join("\n"); + + (apiClient.get as Mock).mockImplementation(async ({ url }) => { + if (url.endsWith("/logs")) { + return { ok: true, status: 200, data: logs }; + } + return { ok: true, status: 200, data: url.endsWith(".png") ? png : jpeg }; + }); + + const result = await fetchAutomationScreenshots( + S, + SessionType.Automate, + mockConfig, + ); + + expect(result.map((s) => s.url)).toEqual([ + ...urls.slice(2), + `${S3}/screenshot-playwright-85c8de7c55.png`, + ]); + expect(result.map((s) => s.mimeType)).toEqual([ + "image/jpeg", + "image/jpeg", + "image/jpeg", + "image/jpeg", + "image/png", + ]); + }); +});