Skip to content

Commit 687e95b

Browse files
author
SentienceDEV
committed
hardening validation rule for cloud trace sink fields image
1 parent f351d18 commit 687e95b

2 files changed

Lines changed: 159 additions & 9 deletions

File tree

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)