Skip to content

Commit 8f017a0

Browse files
authored
Merge pull request #172 from SentienceAPI/gateway_error
pass gateway exception messages; enforce trace field image data
2 parents 9aa48ce + 687e95b commit 8f017a0

4 files changed

Lines changed: 234 additions & 14 deletions

File tree

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
export { SentienceBrowser, PermissionPolicy } from './browser';
6-
export { snapshot, SnapshotOptions } from './snapshot';
6+
export { snapshot, SnapshotOptions, SnapshotGatewayError } from './snapshot';
77
export { query, find, parseSelector } from './query';
88
export {
99
back,

src/snapshot.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,44 @@ import { BrowserEvaluator } from './utils/browser-evaluator';
1111
// Maximum payload size for API requests (10MB server limit)
1212
const MAX_PAYLOAD_BYTES = 10 * 1024 * 1024;
1313

14+
/**
15+
* Structured error for server-side (gateway) snapshot failures.
16+
*
17+
* Keeps HTTP status/URL/response details available to callers for better logging/debugging.
18+
*/
19+
export class SnapshotGatewayError extends Error {
20+
public statusCode?: number;
21+
public url?: string;
22+
public requestId?: string;
23+
public responseText?: string;
24+
public cause?: unknown;
25+
26+
constructor(
27+
message: string,
28+
opts?: {
29+
statusCode?: number;
30+
url?: string;
31+
requestId?: string;
32+
responseText?: string;
33+
cause?: unknown;
34+
}
35+
) {
36+
super(message);
37+
this.name = 'SnapshotGatewayError';
38+
this.statusCode = opts?.statusCode;
39+
this.url = opts?.url;
40+
this.requestId = opts?.requestId;
41+
this.responseText = opts?.responseText;
42+
this.cause = opts?.cause;
43+
}
44+
45+
static snip(s: string | undefined, n: number = 400): string | undefined {
46+
if (!s) return undefined;
47+
const t = String(s).replace(/\r/g, ' ').replace(/\n/g, ' ').trim();
48+
return t.slice(0, n);
49+
}
50+
}
51+
1452
export interface SnapshotOptions {
1553
screenshot?: boolean | { format: 'png' | 'jpeg'; quality?: number };
1654
limit?: number;
@@ -202,6 +240,7 @@ async function snapshotViaApi(
202240
if (!page) {
203241
throw new Error('Browser not started. Call start() first.');
204242
}
243+
const gatewayUrl = `${apiUrl}/v1/snapshot`;
205244

206245
// CRITICAL: Wait for extension injection to complete (CSP-resistant architecture)
207246
// Even for API mode, we need the extension to collect raw data locally
@@ -278,15 +317,38 @@ async function snapshotViaApi(
278317
};
279318

280319
try {
281-
const response = await fetch(`${apiUrl}/v1/snapshot`, {
320+
const response = await fetch(gatewayUrl, {
282321
method: 'POST',
283322
headers,
284323
body: payloadJson,
285324
});
286325

287326
if (!response.ok) {
288-
const errorText = await response.text();
289-
throw new Error(`API request failed: ${response.status} ${errorText}`);
327+
let errorText: string | undefined = undefined;
328+
try {
329+
errorText = await response.text();
330+
} catch (_e) {
331+
errorText = undefined;
332+
}
333+
const requestId =
334+
response.headers.get('x-request-id') || response.headers.get('x-trace-id') || undefined;
335+
const bodySnip = SnapshotGatewayError.snip(errorText);
336+
337+
const parts: string[] = [];
338+
parts.push(`status=${response.status}`);
339+
parts.push(`url=${gatewayUrl}`);
340+
if (requestId) parts.push(`request_id=${requestId}`);
341+
if (bodySnip) parts.push(`body=${bodySnip}`);
342+
343+
throw new SnapshotGatewayError(
344+
`Server-side snapshot API failed: ${parts.join(' ')}. Try using use_api: false to use local extension instead.`,
345+
{
346+
statusCode: response.status,
347+
url: gatewayUrl,
348+
requestId,
349+
responseText: bodySnip,
350+
}
351+
);
290352
}
291353

292354
const apiResult = await response.json();
@@ -359,6 +421,14 @@ async function snapshotViaApi(
359421

360422
return snapshotData;
361423
} catch (e: any) {
362-
throw new Error(`API request failed: ${e.message}`);
424+
if (e instanceof SnapshotGatewayError) {
425+
throw e;
426+
}
427+
const errType = e instanceof Error ? e.name : typeof e;
428+
const errMsg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
429+
throw new SnapshotGatewayError(
430+
`Server-side snapshot API failed: url=${gatewayUrl} err_type=${SnapshotGatewayError.snip(errType, 80)} err=${SnapshotGatewayError.snip(errMsg, 220)}. Try using use_api: false to use local extension instead.`,
431+
{ url: gatewayUrl, cause: e }
432+
);
363433
}
364434
}

src/tracing/cloud-sink.ts

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,57 @@ export class CloudTraceSink extends TraceSink {
773773
});
774774
}
775775

776+
/**
777+
* Normalize screenshot data by extracting base64 from data URL if needed.
778+
*
779+
* Handles both formats:
780+
* - Data URL: "data:image/jpeg;base64,/9j/4AAQ..."
781+
* - Pure base64: "/9j/4AAQ..."
782+
*
783+
* @param screenshotRaw - Raw screenshot data (data URL or base64)
784+
* @param defaultFormat - Default format if not detected from data URL
785+
* @returns Tuple of [base64String, formatString]
786+
*/
787+
private _normalizeScreenshotData(
788+
screenshotRaw: string,
789+
defaultFormat: string = 'jpeg'
790+
): [string, string] {
791+
if (!screenshotRaw) {
792+
return ['', defaultFormat];
793+
}
794+
795+
// Check if it's a data URL
796+
if (screenshotRaw.startsWith('data:image')) {
797+
// Extract format from "data:image/jpeg;base64,..." or "data:image/png;base64,..."
798+
try {
799+
// Split on comma to get the base64 part
800+
if (screenshotRaw.includes(',')) {
801+
const [header, base64Data] = screenshotRaw.split(',', 2);
802+
// Extract format from header: "data:image/jpeg;base64"
803+
if (header.includes('/') && header.includes(';')) {
804+
const formatPart = header.split('/')[1]?.split(';')[0];
805+
if (formatPart === 'jpeg' || formatPart === 'jpg') {
806+
return [base64Data, 'jpeg'];
807+
} else if (formatPart === 'png') {
808+
return [base64Data, 'png'];
809+
}
810+
}
811+
return [base64Data, defaultFormat];
812+
} else {
813+
// Malformed data URL - return as-is with warning
814+
this.logger?.warn('Malformed data URL in screenshot_base64 (missing comma)');
815+
return [screenshotRaw, defaultFormat];
816+
}
817+
} catch (error: any) {
818+
this.logger?.warn(`Error parsing screenshot data URL: ${error.message}`);
819+
return [screenshotRaw, defaultFormat];
820+
}
821+
}
822+
823+
// Already pure base64
824+
return [screenshotRaw, defaultFormat];
825+
}
826+
776827
/**
777828
* Extract screenshots from trace events.
778829
*
@@ -798,15 +849,24 @@ export class CloudTraceSink extends TraceSink {
798849
// Check if this is a snapshot event with screenshot
799850
if (event.type === 'snapshot') {
800851
const data = event.data || {};
801-
const screenshotBase64 = data.screenshot_base64;
802-
803-
if (screenshotBase64) {
804-
sequence += 1;
805-
screenshots.set(sequence, {
806-
base64: screenshotBase64,
807-
format: data.screenshot_format || 'jpeg',
808-
stepId: event.step_id,
809-
});
852+
const screenshotRaw = data.screenshot_base64;
853+
854+
if (screenshotRaw) {
855+
// Normalize: extract base64 from data URL if needed
856+
// Handles both "data:image/jpeg;base64,..." and pure base64
857+
const [screenshotBase64, screenshotFormat] = this._normalizeScreenshotData(
858+
screenshotRaw,
859+
data.screenshot_format || 'jpeg'
860+
);
861+
862+
if (screenshotBase64) {
863+
sequence += 1;
864+
screenshots.set(sequence, {
865+
base64: screenshotBase64,
866+
format: screenshotFormat,
867+
stepId: event.step_id,
868+
});
869+
}
810870
}
811871
}
812872
} catch {

tests/tracing/cloud-sink.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,96 @@ describe('CloudTraceSink', () => {
339339
});
340340
});
341341

342+
describe('Screenshot data URL handling', () => {
343+
it('should normalize screenshot data URLs to pure base64', async () => {
344+
const runId = 'test-run-' + Date.now();
345+
const sink = new CloudTraceSink(uploadUrl, runId);
346+
347+
// Test the private _normalizeScreenshotData method via type casting
348+
const sinkAny = sink as any;
349+
350+
// Test JPEG data URL
351+
const [jpegBase64, jpegFormat] = sinkAny._normalizeScreenshotData(
352+
'data:image/jpeg;base64,/9j/4AAQSkZJRg...',
353+
'png'
354+
);
355+
expect(jpegBase64).toBe('/9j/4AAQSkZJRg...');
356+
expect(jpegFormat).toBe('jpeg');
357+
358+
// Test PNG data URL
359+
const [pngBase64, pngFormat] = sinkAny._normalizeScreenshotData(
360+
'data:image/png;base64,iVBORw0KGgoAAAA...',
361+
'jpeg'
362+
);
363+
expect(pngBase64).toBe('iVBORw0KGgoAAAA...');
364+
expect(pngFormat).toBe('png');
365+
366+
// Test pure base64 (should pass through unchanged)
367+
const [pureBase64, pureFormat] = sinkAny._normalizeScreenshotData(
368+
'/9j/4AAQSkZJRg...',
369+
'jpeg'
370+
);
371+
expect(pureBase64).toBe('/9j/4AAQSkZJRg...');
372+
expect(pureFormat).toBe('jpeg');
373+
374+
// Test empty string
375+
const [emptyBase64, emptyFormat] = sinkAny._normalizeScreenshotData('', 'jpeg');
376+
expect(emptyBase64).toBe('');
377+
expect(emptyFormat).toBe('jpeg');
378+
379+
// Clean up
380+
await sink.close();
381+
});
382+
383+
it('should handle data URL screenshots when extracting from trace', async () => {
384+
const runId = 'test-run-' + Date.now();
385+
const sink = new CloudTraceSink(uploadUrl, runId);
386+
387+
// Create test screenshot as a data URL (how some demos send it)
388+
const testImageBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAE=';
389+
const dataUrl = `data:image/png;base64,${testImageBase64}`;
390+
391+
// Emit snapshot event with data URL
392+
sink.emit({
393+
v: 1,
394+
type: 'snapshot',
395+
ts: new Date().toISOString(),
396+
run_id: runId,
397+
seq: 1,
398+
step_id: 'step-1',
399+
data: {
400+
url: 'https://example.com',
401+
element_count: 10,
402+
screenshot_base64: dataUrl, // Data URL, not pure base64
403+
screenshot_format: 'png',
404+
},
405+
});
406+
407+
// Close the write stream first
408+
const sinkAny = sink as any;
409+
if (sinkAny.writeStream && !sinkAny.writeStream.destroyed) {
410+
await new Promise<void>(resolve => {
411+
sinkAny.writeStream.end(() => resolve());
412+
});
413+
}
414+
415+
// Extract screenshots - should normalize data URL to pure base64
416+
const screenshots = await sinkAny._extractScreenshotsFromTrace();
417+
418+
expect(screenshots.size).toBe(1);
419+
expect(screenshots.has(1)).toBe(true);
420+
421+
const screenshot = screenshots.get(1);
422+
// Verify the base64 was extracted from data URL (no "data:image" prefix)
423+
expect(screenshot.base64).toBe(testImageBase64);
424+
expect(screenshot.base64.startsWith('data:')).toBe(false);
425+
expect(screenshot.format).toBe('png');
426+
427+
// Clean up
428+
await sink.close();
429+
});
430+
});
431+
342432
describe('Index upload', () => {
343433
let indexServer: http.Server;
344434
let indexServerPort: number;

0 commit comments

Comments
 (0)