Skip to content
96 changes: 96 additions & 0 deletions apps/chrome-extension/src/extension/recorder/screenshot-export.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {
type ScreenshotImageFormat,
type ScreenshotImageMimeType,
inferScreenshotImageFormatFromBase64,
screenshotImageExtension,
screenshotImageMimeType,
} from '@midscene/shared/img';
import type { RecordingSession } from '../../store';

export interface RecorderScreenshotAsset {
body: string;
extension: ScreenshotImageFormat;
mimeType: ScreenshotImageMimeType;
}

export const recorderScreenshotAsset = (
screenshotBase64: string,
): RecorderScreenshotAsset => {
const separator = ';base64,';
const separatorIndex = screenshotBase64.indexOf(separator);
const body = (
separatorIndex === -1
? screenshotBase64
: screenshotBase64.slice(separatorIndex + separator.length)
).replace(/\s/g, '');
const format = inferScreenshotImageFormatFromBase64(body);
if (!format) {
throw new Error('Unsupported recorder screenshot image format');
}

return {
body,
extension: screenshotImageExtension(format),
mimeType: screenshotImageMimeType(format),
};
};

export const generateEventsMarkdownTable = (
sessions: RecordingSession[],
): string => {
let markdown = '# Test Events Report\n\n';

sessions.forEach((session, sessionIndex) => {
if (session.events.length === 0) return;

markdown += `## ${session.name}\n\n`;
if (session.description) {
markdown += `**Description:** ${session.description}\n\n`;
}
markdown += `**Created:** ${new Date(session.createdAt).toLocaleString()}\n\n`;
markdown += '| Page | Screenshot Before | Screenshot After | Action |\n';
markdown += '|------|------------|------------|--------|\n';

session.events.forEach((event, eventIndex) => {
const page = event.title || event.url || '';
const screenshotBefore = event.screenshotBefore
? `![](./images/screenshot_${sessionIndex}_${eventIndex}_before.${recorderScreenshotAsset(event.screenshotBefore).extension})`
: 'N/A';
const screenshotAfter = event.screenshotAfter
? `![](./images/screenshot_${sessionIndex}_${eventIndex}_after.${recorderScreenshotAsset(event.screenshotAfter).extension})`
: 'N/A';
let action = '';
switch (event.type) {
case 'click':
action = `Click on ${event.elementDescription || 'element'}`;
break;
case 'input':
action = `Input "${event.value}" into ${event.elementDescription || 'field'}`;
break;
case 'navigation':
action = `Navigate to ${event.url}`;
break;
default:
action = `${event.type} on ${event.elementDescription || 'element'}`;
}

markdown += `| ${page} | ${screenshotBefore} | ${screenshotAfter} | ${action} |\n`;
});

if (session.generatedCode?.yaml || session.generatedCode?.playwright) {
markdown += '## Generated Code\n\n';
if (session.generatedCode?.yaml) {
markdown += '### YAML\n\n';
markdown += `\`\`\`yaml\n${session.generatedCode.yaml}\n\`\`\`\n\n`;
}
if (session.generatedCode?.playwright) {
markdown += '### Playwright\n\n';
markdown += `\`\`\`playwright\n${session.generatedCode.playwright}\n\`\`\`\n\n`;
}
}

markdown += '\n\n\n';
});

return markdown;
};
89 changes: 16 additions & 73 deletions apps/chrome-extension/src/extension/recorder/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import type { ChatCompletionContentPart } from 'openai/resources/index';
import type { RecordingSession } from '../../store';
import { recordLogger } from './logger';
import { withRecorderMessageTimeout } from './messageTimeout';
import {
type RecorderScreenshotAsset,
generateEventsMarkdownTable,
recorderScreenshotAsset,
} from './screenshot-export';
import { isChromeExtension, safeChromeAPI } from './types';

const CONTENT_SCRIPT_MESSAGE_TIMEOUT_MS = 5_000;
Expand Down Expand Up @@ -778,74 +783,11 @@ const generateDetailedSequentialMindmap = (
return mermaid;
};

// Generate markdown table for event details
const generateEventsMarkdownTable = (sessions: RecordingSession[]): string => {
let markdown = '# Test Events Report\n\n';

sessions.forEach((session, sessionIndex) => {
if (session.events.length === 0) return;

markdown += `## ${session.name}\n\n`;
if (session.description) {
markdown += `**Description:** ${session.description}\n\n`;
}
markdown += `**Created:** ${new Date(session.createdAt).toLocaleString()}\n\n`;

markdown += '| Page | Screenshot Before | Screenshot After | Action |\n';
markdown += '|------|------------|------------|--------|\n';

session.events.forEach((event, eventIndex) => {
let expected = 'N/A';
const page = event.title || event.url || '';
const screenshotBefore = event.screenshotBefore
? `![](./images/screenshot_${sessionIndex}_${eventIndex}_before.png)`
: 'N/A';
const screenshotAfter = event.screenshotAfter
? `![](./images/screenshot_${sessionIndex}_${eventIndex}_after.png)`
: 'N/A';
if (event.type === 'navigation') {
expected = `Navigate to ${event.url}`;
}

let action = '';
switch (event.type) {
case 'click':
action = `Click on ${event.elementDescription || 'element'}`;
break;
case 'input':
action = `Input "${event.value}" into ${event.elementDescription || 'field'}`;
break;
case 'navigation':
action = `Navigate to ${event.url}`;
break;
default:
action = `${event.type} on ${event.elementDescription || 'element'}`;
}

markdown += `| ${page} | ${screenshotBefore} | ${screenshotAfter} | ${action} |\n`;
});

if (session.generatedCode?.yaml || session.generatedCode?.playwright) {
markdown += '## Generated Code\n\n';
if (session.generatedCode?.yaml) {
markdown += '### YAML\n\n';
markdown += `\`\`\`yaml\n${session.generatedCode.yaml}\n\`\`\`\n\n`;
}
if (session.generatedCode?.playwright) {
markdown += '### Playwright\n\n';
markdown += `\`\`\`playwright\n${session.generatedCode.playwright}\n\`\`\`\n\n`;
}
}

markdown += '\n\n\n';
});

return markdown;
};

// Convert base64 to blob
const base64ToBlob = (base64: string, mimeType: string): Blob => {
const byteCharacters = atob(base64.split(',')[1]);
const base64BodyToBlob = (
base64Body: string,
mimeType: RecorderScreenshotAsset['mimeType'],
): Blob => {
const byteCharacters = atob(base64Body);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
Expand Down Expand Up @@ -875,15 +817,16 @@ export const exportAllEventsToZip = async (sessions: RecordingSession[]) => {
// Process each session and extract images
sessionsWithEvents.forEach((session, sessionIndex) => {
session.events.forEach((event, eventIndex) => {
const ext = 'png';
if (event.screenshotBefore) {
const fileName = `screenshot_${sessionIndex}_${eventIndex}_before.${ext}`;
const blob = base64ToBlob(event.screenshotBefore, `image/${ext}`);
const asset = recorderScreenshotAsset(event.screenshotBefore);
const fileName = `screenshot_${sessionIndex}_${eventIndex}_before.${asset.extension}`;
const blob = base64BodyToBlob(asset.body, asset.mimeType);
imagesFolder?.file(fileName, blob);
}
if (event.screenshotAfter) {
const fileName = `screenshot_${sessionIndex}_${eventIndex}_after.${ext}`;
const blob = base64ToBlob(event.screenshotAfter, `image/${ext}`);
const asset = recorderScreenshotAsset(event.screenshotAfter);
const fileName = `screenshot_${sessionIndex}_${eventIndex}_after.${asset.extension}`;
const blob = base64BodyToBlob(asset.body, asset.mimeType);
imagesFolder?.file(fileName, blob);
}
});
Expand Down
57 changes: 57 additions & 0 deletions apps/chrome-extension/tests/recorder-export-image-format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from '@rstest/core';
import {
generateEventsMarkdownTable,
recorderScreenshotAsset,
} from '../src/extension/recorder/screenshot-export';
import type { RecordingSession } from '../src/store';

const webpBody =
'UklGRioAAABXRUJQVlA4IB4AAAAwAQCdASoBAAEAAUAmJQBOgCHwAP7+hNQAAAA=';
const pngBody =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';

describe('Chrome recorder screenshot export', () => {
it('derives the exported extension and MIME type from the image bytes', () => {
expect(
recorderScreenshotAsset(`data:image/png;base64,${webpBody}`),
).toEqual({
body: webpBody,
extension: 'webp',
mimeType: 'image/webp',
});
expect(recorderScreenshotAsset(`data:image/png;base64,${pngBody}`)).toEqual(
{
body: pngBody,
extension: 'png',
mimeType: 'image/png',
},
);
});

it('uses each screenshot actual extension in the Markdown table', () => {
const sessions = [
{
id: 'session-1',
name: 'WebP export',
createdAt: 1,
updatedAt: 1,
status: 'completed',
events: [
{
type: 'click',
timestamp: 1,
hashId: 'event-1',
screenshotBefore: `data:image/webp;base64,${webpBody}`,
screenshotAfter: `data:image/png;base64,${pngBody}`,
},
],
},
] as RecordingSession[];

const markdown = generateEventsMarkdownTable(sessions);

expect(markdown).toContain('![](./images/screenshot_0_0_before.webp)');
expect(markdown).toContain('![](./images/screenshot_0_0_after.png)');
expect(markdown).not.toContain('screenshot_0_0_before.png');
});
});
15 changes: 5 additions & 10 deletions apps/report/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
reportToMarkdown,
restoreImageReferences,
} from '@midscene/core';
import type { StoredImageRef } from '@midscene/core/dump';
import { antiEscapeScriptTag } from '@midscene/shared/utils';
import {
Logo,
Expand Down Expand Up @@ -55,14 +56,13 @@ import {
getEmptyDumpDescription,
parseDumpAttributes,
} from './utils/report-dump';
import { resolveScreenshotFallbackPath } from './utils/screenshot-source';

// Shared image cache across all test cases — resolved images are cached by id
const imageCache = new Map<string, string>();

function resolveImageFromDom(
refOrId: string | { id: string; storage?: 'inline' | 'file'; path?: string },
): string {
const id = typeof refOrId === 'string' ? refOrId : refOrId.id;
function resolveImageFromDom(ref: StoredImageRef): string {
const id = ref.id;
const cached = imageCache.get(id);
if (cached) return cached;

Expand All @@ -75,12 +75,7 @@ function resolveImageFromDom(
return data;
}

if (typeof refOrId === 'object' && refOrId?.storage === 'file') {
return refOrId.path || `./screenshots/${id}.png`;
}

// Fallback to directory path
return `./screenshots/${id}.png`;
return resolveScreenshotFallbackPath(ref);
}

let globalRenderCount = 1;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { buildTimelineScreenshots } from './build-timeline-screenshots';

const onePixelPngBase64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lz8yrwAAAABJRU5ErkJggg==';
const webpBase64 =
'UklGRjQAAABXRUJQVlA4ICgAAACQAQCdASoCAAMAAMASJQBOl0AAjNAA/v4icv1difCfoP7mxzi2QwAA';

interface TaskFixtureOptions {
id: string;
Expand Down Expand Up @@ -103,6 +105,19 @@ describe('buildTimelineScreenshots', () => {
);
});

it('infers WebP for recorder screenshots stored as raw base64 strings', () => {
const task = makeTask({
id: 'raw-webp-recorder',
startTs: 1000,
recorder: [{ ts: 1200, screenshot: webpBase64 }],
});

const { allScreenshots } = buildTimelineScreenshots([task]);

expect(allScreenshots).toHaveLength(1);
expect(allScreenshots[0].img).toBe(`data:image/webp;base64,${webpBase64}`);
});

it('keeps already-prefixed data URLs unchanged', () => {
const dataUrl = `data:image/png;base64,${onePixelPngBase64}`;
const task = makeTask({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ExecutionTask } from '@midscene/core';
import { parseScreenshotBase64 } from '@midscene/shared/img';

export interface TimelineScreenshot {
id: string;
Expand All @@ -23,19 +24,17 @@ const rawBase64BodyPattern = /^[a-zA-Z0-9+/=\s]+$/;
const imageSrcFromString = (value: string): string => {
const trimmed = value.trim();
if (trimmed.startsWith('data:')) {
return trimmed;
return parseScreenshotBase64(trimmed, {
label: 'Report timeline screenshot',
}).dataUrl;
}
if (trimmed.length < 32 || !rawBase64BodyPattern.test(trimmed)) {
return value;
}

const body = trimmed.replace(/\s/g, '');
const mimeType = body.startsWith('/9j/')
? 'image/jpeg'
: body.startsWith('UklGR')
? 'image/webp'
: 'image/png';
return `data:${mimeType};base64,${body}`;
return parseScreenshotBase64(trimmed, {
label: 'Report timeline screenshot',
}).dataUrl;
};

const screenshotBase64 = (screenshot: unknown): string | undefined => {
Expand Down
18 changes: 18 additions & 0 deletions apps/report/src/utils/markdown-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,24 @@ describe('markdown-export helpers', () => {
);
});

it('packages an in-memory WebP attachment without changing its bytes', () => {
const webpBytes = 'webp-image-bytes';
const files = buildMarkdownArchiveFiles('# report', [
{
id: 'inline-webp',
suggestedFileName: 'inline.webp',
mimeType: 'image/webp',
executionIndex: 0,
taskIndex: 0,
base64Data: `data:image/webp;base64,${btoa(webpBytes)}`,
},
]);

expect(new TextDecoder().decode(files['screenshots/inline.webp'])).toBe(
webpBytes,
);
});

it('builds display items from markdown attachment names and paths', () => {
const items = getMarkdownAttachmentDisplayItems([
{
Expand Down
Loading
Loading