-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat(web-integration): use WebP for browser screenshot producers #2858
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
e924e4d
c31d6c4
de44500
6a21bb5
7248ab1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| ? `.extension})` | ||
| : 'N/A'; | ||
| const screenshotAfter = 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; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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), | ||
| (error) => { | ||
| console.error( | ||
| '[ServiceWorker] Failed to encode recorder screenshot as WebP:', | ||
| error, | ||
| ); | ||
| sendResponse(null); | ||
|
Comment on lines
+460
to
+465
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If AGENTS.md reference: AGENTS.md:L6-L8 Useful? React with 👍 / 👎. |
||
| }, | ||
| ); | ||
| } | ||
| }, | ||
| ); | ||
|
|
||
| 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(); | ||
| } | ||
| } |
| 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(''); | ||
| expect(markdown).toContain(''); | ||
| expect(markdown).not.toContain('screenshot_0_0_before.png'); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 inevent-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 whencaptureVisibleTabsucceeded; extend the capture budget or move conversion off the response-critical path.Useful? React with 👍 / 👎.