Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -35,11 +38,19 @@ export async function maybeCompressBase64(base64: string): Promise<string> {
sharp.concurrency(1);
sharpConfigured = true;
}
const compressedBuffer = await sharp(buffer).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(
"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;
Expand Down
133 changes: 108 additions & 25 deletions src/tools/automate-utils/fetch-screenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,62 +27,140 @@ 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 =
typeof response.data === "string"
? 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);
}

async function fetchAndCompress(
url: string,
): Promise<{ url: string; base64: string }> {
const response = await apiClient.get({ url, responseType: "arraybuffer" });
function detectImageMimeType(buffer: Buffer): string {
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return "image/jpeg";
}
return "image/png";
}

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<Screenshot | null> {
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) };
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
async function convertUrlsToBase64(
urls: string[],
): Promise<Array<{ url: string; base64: string }>> {
async function convertUrlsToBase64(urls: string[]): Promise<Screenshot[]> {
let results: Array<Screenshot | null>;
// 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
Expand Down
4 changes: 2 additions & 2 deletions src/tools/automate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 },
}));

Expand Down
30 changes: 29 additions & 1 deletion tests/lib/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -30,6 +35,29 @@ describe("maybeCompressBase64", () => {
expect(sharp as unknown as Mock).toHaveBeenCalledTimes(1);
});

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({
limitInputPixels: 25_000_000,
});
const pipeline = sharpMock.mock.results[0].value;
expect(pipeline.resize).toHaveBeenCalledWith({
width: 1920,
height: 1920,
fit: "inside",
withoutEnlargement: true,
});
});

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)");
Expand Down
11 changes: 6 additions & 5 deletions tests/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
6 changes: 4 additions & 2 deletions tests/tools/automate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 () => {
Expand Down
Loading
Loading