Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
97 changes: 97 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,97 @@
import {
type ScreenshotImageFormat,
type ScreenshotImageMimeType,
inferScreenshotImageFormatFromBase64,
screenshotImageExtension,
screenshotImageMimeType,
} from '@midscene/shared/img/image-format';
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;
};
88 changes: 16 additions & 72 deletions apps/chrome-extension/src/extension/recorder/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import JSZip from 'jszip';
import type { ChatCompletionContentPart } from 'openai/resources/index';
import type { RecordingSession } from '../../store';
import { recordLogger } from './logger';
import {
type RecorderScreenshotAsset,
generateEventsMarkdownTable,
recorderScreenshotAsset,
} from './screenshot-export';
import { isChromeExtension, safeChromeAPI } from './types';

// Generate default session name with current time
Expand Down Expand Up @@ -713,74 +718,12 @@ 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 @@ -810,15 +753,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
17 changes: 16 additions & 1 deletion apps/chrome-extension/src/scripts/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { UIContext } from '@midscene/core';
import { uuid } from '@midscene/shared/utils';
import { BridgeConnector, type BridgeStatus } from '../utils/bridgeConnector';
import { registerAlarmListener, safeSetupKeepalive } from '../utils/keepalive';
import { canonicalizeRecorderScreenshot } from '../utils/screenshot';
import { workerMessageTypes } from '../utils/workerMessageTypes';

// save screenshot
Expand Down Expand Up @@ -448,8 +449,22 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
chrome.runtime.lastError,
);
sendResponse(null);
} else if (!dataUrl) {
console.error(
'[ServiceWorker] Screenshot capture returned empty data',
);
sendResponse(null);
} else {
sendResponse(dataUrl);
void canonicalizeRecorderScreenshot(dataUrl).then(
(webpDataUrl) => sendResponse(webpDataUrl),
Comment on lines +458 to +459

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep WebP encoding within the recorder's one-second budget

On high-resolution displays or slower machines, decoding the full PNG and completing OffscreenCanvas.convertToBlob() can take more than the fixed 1,000 ms timeout in event-recorder-bridge.ts:78-97. Because the response is now delayed until this conversion completes, the bridge times out and records the event without its screenshot even when captureVisibleTab succeeded; extend the capture budget or move conversion off the response-critical path.

Useful? React with 👍 / 👎.

(error) => {
console.error(
'[ServiceWorker] Failed to encode recorder screenshot as WebP:',
error,
);
sendResponse(null);
Comment on lines +460 to +465

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the PNG when WebP encoding fails

If createImageBitmap, the canvas context, or WebP encoding fails, this converts a successfully captured PNG into a null response; the recorder interprets that as no screenshot and silently omits the before/after image. Return the original PNG as a fallback or propagate an explicit failure instead of discarding valid capture data.

AGENTS.md reference: AGENTS.md:L6-L8

Useful? React with 👍 / 👎.

},
);
}
},
);
Expand Down
77 changes: 77 additions & 0 deletions apps/chrome-extension/src/utils/screenshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
const WEBP_QUALITY = 0.9;
const DATA_URL_PATTERN = /^data:image\/(png|jpe?g|webp);base64,([\s\S]+)$/i;

function decodeScreenshotDataUrl(dataUrl: string): {
bytes: Uint8Array;
mimeType: string;
} {
const match = DATA_URL_PATTERN.exec(dataUrl);
if (!match) {
throw new Error(
'Recorder screenshot must be a PNG, JPEG, or WebP data URL',
);
}

const binary = atob(match[2]);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
return {
bytes,
mimeType: `image/${match[1].toLowerCase().replace('jpg', 'jpeg')}`,
};
}

function isWebp(bytes: Uint8Array): boolean {
return (
bytes.length >= 12 &&
String.fromCharCode(...bytes.subarray(0, 4)) === 'RIFF' &&
String.fromCharCode(...bytes.subarray(8, 12)) === 'WEBP'
);
}

function encodeBase64(bytes: Uint8Array): string {
let binary = '';
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(
...bytes.subarray(offset, offset + chunkSize),
);
}
return btoa(binary);
}

/** Convert captureVisibleTab's PNG output to the recorder's canonical WebP. */
export async function canonicalizeRecorderScreenshot(
dataUrl: string,
): Promise<string> {
const source = decodeScreenshotDataUrl(dataUrl);
if (source.mimeType === 'image/webp') {
if (!isWebp(source.bytes)) {
throw new Error('Recorder screenshot has an invalid WebP signature');
}
return dataUrl;
}

const bitmap = await createImageBitmap(
new Blob([source.bytes], { type: source.mimeType }),
);
try {
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Recorder screenshot WebP encoder is unavailable');
}
context.drawImage(bitmap, 0, 0);

const output = await canvas.convertToBlob({
type: 'image/webp',
quality: WEBP_QUALITY,
});
const bytes = new Uint8Array(await output.arrayBuffer());
if (output.type !== 'image/webp' || !isWebp(bytes)) {
throw new Error('Recorder screenshot encoder returned invalid WebP');
}
return `data:image/webp;base64,${encodeBase64(bytes)}`;
} finally {
bitmap.close();
}
}
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 'vitest';
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');
});
});
Loading
Loading