From d453c83a3c99d46234da0728009842b9fc3019dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20M=C3=B3ricz?= Date: Sat, 2 Aug 2025 22:46:16 +0200 Subject: [PATCH] feat(v2): Add viewport parameter for screenshots (#1910) Co-authored-by: Claude --- .../snips/v2-scrape-viewport.test.ts | 183 ++++++++++++++++++ .../snips/v2-system-prompt-rejection.test.ts | 100 +++++----- apps/api/src/controllers/v2/types.ts | 8 + .../scrapeURL/engines/fire-engine/index.ts | 2 + 4 files changed, 246 insertions(+), 47 deletions(-) create mode 100644 apps/api/src/__tests__/snips/v2-scrape-viewport.test.ts diff --git a/apps/api/src/__tests__/snips/v2-scrape-viewport.test.ts b/apps/api/src/__tests__/snips/v2-scrape-viewport.test.ts new file mode 100644 index 000000000..65bdbf133 --- /dev/null +++ b/apps/api/src/__tests__/snips/v2-scrape-viewport.test.ts @@ -0,0 +1,183 @@ +import request from "supertest"; +import { Identity, idmux } from "./lib"; + +const TEST_URL = process.env.TEST_URL || "http://127.0.0.1:3002"; + +describe("V2 Scrape Screenshot Viewport", () => { + if (!process.env.TEST_SUITE_SELF_HOSTED) { + let identity: Identity; + + beforeAll(async () => { + identity = await idmux({ + name: "v2-scrape-viewport", + concurrency: 100, + credits: 1000000, + }); + }, 10000); + + test("should take screenshot with custom viewport dimensions", async () => { + const response = await request(TEST_URL) + .post("/v2/scrape") + .set("Authorization", `Bearer ${identity.apiKey}`) + .set("Content-Type", "application/json") + .send({ + url: "https://example.com", + formats: [ + "markdown", + { + type: "screenshot", + viewport: { + width: 1920, + height: 1080 + } + } + ], + timeout: 30000, + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.screenshot).toBeDefined(); + expect(response.body.data.markdown).toBeDefined(); + }, 60000); + + test("should take full page screenshot with custom viewport width", async () => { + const response = await request(TEST_URL) + .post("/v2/scrape") + .set("Authorization", `Bearer ${identity.apiKey}`) + .set("Content-Type", "application/json") + .send({ + url: "https://example.com", + formats: [ + { + type: "screenshot", + fullPage: true, + viewport: { + width: 1440, + height: 900 + } + } + ], + timeout: 30000, + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.screenshot).toBeDefined(); + }, 60000); + + test("should work with screenshot format without viewport (backwards compatibility)", async () => { + const response = await request(TEST_URL) + .post("/v2/scrape") + .set("Authorization", `Bearer ${identity.apiKey}`) + .set("Content-Type", "application/json") + .send({ + url: "https://example.com", + formats: ["screenshot"], + timeout: 30000, + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.screenshot).toBeDefined(); + }, 60000); + + test("should work with object screenshot format without viewport", async () => { + const response = await request(TEST_URL) + .post("/v2/scrape") + .set("Authorization", `Bearer ${identity.apiKey}`) + .set("Content-Type", "application/json") + .send({ + url: "https://example.com", + formats: [ + { + type: "screenshot", + fullPage: false + } + ], + timeout: 30000, + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.screenshot).toBeDefined(); + }, 60000); + + test("should reject invalid viewport dimensions", async () => { + const response = await request(TEST_URL) + .post("/v2/scrape") + .set("Authorization", `Bearer ${identity.apiKey}`) + .set("Content-Type", "application/json") + .send({ + url: "https://example.com", + formats: [ + { + type: "screenshot", + viewport: { + width: -100, + height: 0 + } + } + ], + timeout: 30000, + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }, 60000); + + test("should reject non-integer viewport dimensions", async () => { + const response = await request(TEST_URL) + .post("/v2/scrape") + .set("Authorization", `Bearer ${identity.apiKey}`) + .set("Content-Type", "application/json") + .send({ + url: "https://example.com", + formats: [ + { + type: "screenshot", + viewport: { + width: "1920", + height: 1080.5 + } + } + ], + timeout: 30000, + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }, 60000); + + test("should reject viewport dimensions exceeding maximum limits", async () => { + const response = await request(TEST_URL) + .post("/v2/scrape") + .set("Authorization", `Bearer ${identity.apiKey}`) + .set("Content-Type", "application/json") + .send({ + url: "https://example.com", + formats: [ + { + type: "screenshot", + viewport: { + width: 8000, // exceeds max of 7680 + height: 5000 // exceeds max of 4320 + } + } + ], + timeout: 30000, + }); + + expect(response.status).toBe(400); + expect(response.body.success).toBe(false); + }, 60000); + } else { + it("mocked", () => { + expect(true).toBe(true); + }); + } +}); \ No newline at end of file diff --git a/apps/api/src/__tests__/snips/v2-system-prompt-rejection.test.ts b/apps/api/src/__tests__/snips/v2-system-prompt-rejection.test.ts index 4f2e6eafc..0252e44f3 100644 --- a/apps/api/src/__tests__/snips/v2-system-prompt-rejection.test.ts +++ b/apps/api/src/__tests__/snips/v2-system-prompt-rejection.test.ts @@ -13,58 +13,64 @@ async function scrapeV2Raw(body: ScrapeRequestInput, identity: Identity) { } describe("V2 System Prompt Rejection", () => { - let identity: Identity; - - beforeAll(async () => { - identity = await idmux({ - name: "v2-system-prompt-rejection", - concurrency: 100, - credits: 1000000, + if (!process.env.TEST_SUITE_SELF_HOSTED || process.env.OPENAI_API_KEY || process.env.OLLAMA_BASE_URL) { + let identity: Identity; + + beforeAll(async () => { + identity = await idmux({ + name: "v2-system-prompt-rejection", + concurrency: 100, + credits: 1000000, + }); }); - }); - it("should reject systemPrompt in json format options for v2 scrape", async () => { - const response = await scrapeV2Raw( - { - url: "https://example.com", - formats: [ - { - type: "json", - schema: { - type: "object", - properties: { title: { type: "string" } }, + it("should reject systemPrompt in json format options for v2 scrape", async () => { + const response = await scrapeV2Raw( + { + url: "https://example.com", + formats: [ + { + type: "json", + schema: { + type: "object", + properties: { title: { type: "string" } }, + }, + systemPrompt: "Custom system prompt that should be rejected", }, - systemPrompt: "Custom system prompt that should be rejected", - }, - ], - } as any, - identity, - ); + ], + } as any, + identity, + ); - expect(response.statusCode).toBe(400); - expect(response.body.success).toBe(false); - expect(response.body.error).toBe("Bad Request"); - }); + expect(response.statusCode).toBe(400); + expect(response.body.success).toBe(false); + expect(response.body.error).toBe("Bad Request"); + }); - it("should accept valid json format options without systemPrompt", async () => { - const response = await scrapeV2Raw( - { - url: "https://example.com", - formats: [ - { - type: "json", - schema: { - type: "object", - properties: { title: { type: "string" } }, + it("should accept valid json format options without systemPrompt", async () => { + const response = await scrapeV2Raw( + { + url: "https://example.com", + formats: [ + { + type: "json", + schema: { + type: "object", + properties: { title: { type: "string" } }, + }, + prompt: "Extract the title", }, - prompt: "Extract the title", - }, - ], - } as any, - identity, - ); + ], + } as any, + identity, + ); - expect(response.statusCode).toBe(200); - expect(response.body.success).toBe(true); - }); + expect(response.statusCode).toBe(200); + expect(response.body.success).toBe(true); + }); + } else { + it("mocked", () => { + expect(true).toBe(true); + }); + } }); diff --git a/apps/api/src/controllers/v2/types.ts b/apps/api/src/controllers/v2/types.ts index 433140db8..843bddc7e 100644 --- a/apps/api/src/controllers/v2/types.ts +++ b/apps/api/src/controllers/v2/types.ts @@ -131,6 +131,10 @@ export const actionSchema = z type: z.literal("screenshot"), fullPage: z.boolean().default(false), quality: z.number().min(1).max(100).optional(), + viewport: z.object({ + width: z.number().int().positive().finite().max(7680), // 8K resolution width + height: z.number().int().positive().finite().max(4320), // 8K resolution height + }).optional(), }), z.object({ type: z.literal("write"), @@ -197,6 +201,10 @@ export const screenshotFormatWithOptions = z.object({ type: z.literal("screenshot"), fullPage: z.boolean().default(false), quality: z.number().min(1).max(100).optional(), + viewport: z.object({ + width: z.number().int().positive().finite().max(7680), // 8K resolution width + height: z.number().int().positive().finite().max(4320), // 8K resolution height + }).optional(), }); export type ScreenshotFormatWithOptions = z.output; diff --git a/apps/api/src/scraper/scrapeURL/engines/fire-engine/index.ts b/apps/api/src/scraper/scrapeURL/engines/fire-engine/index.ts index c4fde76b7..f8059d82e 100644 --- a/apps/api/src/scraper/scrapeURL/engines/fire-engine/index.ts +++ b/apps/api/src/scraper/scrapeURL/engines/fire-engine/index.ts @@ -213,6 +213,8 @@ export async function scrapeURLWithFireEngineChromeCDP( type: "screenshot" as const, fullPage: meta.options.formats.includes("screenshot@fullPage") || meta.options.formats.find(x => typeof x === "object" && x.type === "screenshot")?.fullPage || false, + ...(meta.options.formats.find(x => typeof x === "object" && x.type === "screenshot")?.viewport ? + { viewport: meta.options.formats.find(x => typeof x === "object" && x.type === "screenshot")?.viewport } : {}), }, ] : []),