fix: rm ss comparison logic

This commit is contained in:
Rohit Rajan
2026-09-14 21:13:52 +05:30
parent dd90ee60b6
commit 313bd7885d
12 changed files with 18 additions and 503 deletions
-1
View File
@@ -54,7 +54,6 @@
"rrweb": "2.0.0-alpha.4",
"sequelize": "6.37.7",
"sequelize-typescript": "2.1.6",
"sharp": "0.35.3",
"socket.io": "4.8.1",
"socket.io-client": "4.8.1",
"swagger-jsdoc": "6.2.8",
-1
View File
@@ -78,7 +78,6 @@
"rrweb": "^2.0.0-alpha.4",
"sequelize": "^6.37.3",
"sequelize-typescript": "^2.1.6",
"sharp": "^0.35.3",
"socket.io": "^4.4.1",
"socket.io-client": "^4.4.1",
"styled-components": "^5.3.3",
+2 -23
View File
@@ -955,21 +955,10 @@ async function executeRun(id: string, userId: string) {
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
if ((recording.recording_meta as any).compareRuns) {
try {
const comparison = await compareRunOutputsWithPrevious(run, serializableOutput, binaryOutput);
const comparison = await compareRunOutputsWithPrevious(run, serializableOutput);
hasChanges = comparison.hasChanges;
serializableOutput._comparison = {
changedFormats: comparison.changedFormats,
screenshots: Object.fromEntries(await Promise.all(
Object.entries(comparison.screenshotComparisons).map(async ([key, value]) => {
if (!value) return [key, null];
const { diff: _diff, ...metadata } = value;
const diff = comparison.screenshotDiffs[`${key}-diff`];
const diffUrl = diff
? await binaryOutputService.uploadBinaryOutputItem(plainRun.runId, `${key}-diff`, diff, 'image/png')
: null;
return [key, { ...metadata, diff: diffUrl || (diff ? `data:image/png;base64,${diff.toString('base64')}` : null) }];
}),
)),
};
} catch (comparisonError: any) {
logger.warn(`Monitoring comparison failed for API scrape run ${plainRun.runId}: ${comparisonError.message}`);
@@ -1197,20 +1186,10 @@ async function executeRun(id: string, userId: string) {
let hasChanges = false;
if (robotType === 'extract' && (recording.recording_meta as any).compareRuns) {
try {
const comparison = await compareExtractRunWithPrevious(run, finalSerializableOutput, postBinaryOutput);
const comparison = await compareExtractRunWithPrevious(run, finalSerializableOutput);
hasChanges = comparison.hasChanges;
finalSerializableOutput._comparison = {
changedFormats: comparison.changedFormats,
screenshots: Object.fromEntries(await Promise.all(
Object.entries(comparison.screenshotComparisons).map(async ([key, value]) => {
const { diff: _diff, ...metadata } = value;
const diff = comparison.screenshotDiffs[`${key}-diff`];
const diffUrl = diff
? await binaryOutputService.uploadBinaryOutputItem(plainRun.runId, `${key}-diff`, diff, 'image/png')
: null;
return [key, { ...metadata, diff: diffUrl || (diff ? `data:image/png;base64,${diff.toString('base64')}` : null) }];
}),
)),
};
} catch (comparisonError: any) {
logger.warn(`Monitoring comparison failed for API extract run ${plainRun.runId}: ${comparisonError.message}`);
-17
View File
@@ -1306,7 +1306,6 @@ router.get('/runs/:id/diff', requireSignIn, async (req: AuthenticatedRequest, re
const currentText = formats.text?.current || '';
const previousText = formats.text?.previous || '';
const screenshotMetadata = currentOutput?._comparison?.screenshots || {};
const isExtract = robot.recording_meta.type === 'extract';
const currentCapturedText = currentOutput?.scrapeSchema || {};
const previousCapturedText = previousOutput?.scrapeSchema || {};
@@ -1323,21 +1322,6 @@ router.get('/runs/:id/diff', requireSignIn, async (req: AuthenticatedRequest, re
&& (Object.keys(currentCapturedLists).length > 0 || Object.keys(previousCapturedLists).length > 0)
? { current: currentCapturedLists, previous: previousCapturedLists }
: null;
const screenshotNames = isExtract
? Object.keys(run.binaryOutput || {}).filter((name) => !name.endsWith('-diff'))
: ['screenshot-visible', 'screenshot-fullpage'];
const screenshotFormats = screenshotNames.reduce((result, format) => {
const current = run.binaryOutput?.[format];
if (!current) return result;
result[format] = {
current,
previous: previousRun.binaryOutput?.[format] || null,
diff: screenshotMetadata[format]?.diff || run.binaryOutput?.[`${format}-diff`] || null,
metadata: screenshotMetadata[format] || null,
};
return result;
}, {} as Record<string, any>);
return res.json({
currentRunId: run.runId,
previousRunId: previousRun.runId,
@@ -1346,7 +1330,6 @@ router.get('/runs/:id/diff', requireSignIn, async (req: AuthenticatedRequest, re
formats,
capturedText,
capturedLists,
screenshots: screenshotFormats,
changedFormats: currentOutput?._comparison?.changedFormats || [],
});
} catch (e) {
+2 -19
View File
@@ -341,22 +341,11 @@ async function processRunExecution(data: ExecuteRunData): Promise<void> {
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
if ((recording.recording_meta as any).compareRuns) {
try {
const comparison = await compareRunOutputsWithPrevious(run, serializableOutput, binaryOutput);
const comparison = await compareRunOutputsWithPrevious(run, serializableOutput);
hasChanges = comparison.hasChanges;
serializableOutput._comparison = {
changedFormats: comparison.changedFormats,
screenshots: Object.fromEntries(await Promise.all(
Object.entries(comparison.screenshotComparisons).map(async ([key, value]) => {
if (!value) return [key, null];
const { diff: _diff, ...metadata } = value;
const diff = comparison.screenshotDiffs[`${key}-diff`];
const diffUrl = diff
? await binaryOutputService.uploadBinaryOutputItem(plainRun.runId, `${key}-diff`, diff, 'image/png')
: null;
return [key, { ...metadata, diff: diffUrl || (diff ? `data:image/png;base64,${diff.toString('base64')}` : null) }];
}),
)),
};
if (comparison.previousRun) {
@@ -498,16 +487,10 @@ async function processRunExecution(data: ExecuteRunData): Promise<void> {
let hasChanges = false;
const binarySvc = new BinaryOutputService('maxun-run-screenshots');
if (robotType === 'extract' && (recording.recording_meta as any).compareRuns) {
const comparison = await compareExtractRunWithPrevious(run, finalSerializableOutput, binaryOutput);
const comparison = await compareExtractRunWithPrevious(run, finalSerializableOutput);
hasChanges = comparison.hasChanges;
finalSerializableOutput._comparison = {
changedFormats: comparison.changedFormats,
screenshots: Object.fromEntries(await Promise.all(Object.entries(comparison.screenshotComparisons).map(async ([key, value]) => {
const { diff: _diff, ...metadata } = value;
const diff = comparison.screenshotDiffs[`${key}-diff`];
const diffUrl = diff ? await binarySvc.uploadBinaryOutputItem(plainRun.runId, `${key}-diff`, diff, 'image/png') : null;
return [key, { ...metadata, diff: diffUrl || (diff ? `data:image/png;base64,${diff.toString('base64')}` : null) }];
}))),
};
}
+4 -42
View File
@@ -1,9 +1,4 @@
import Run from '../models/Run';
import {
COMPARABLE_SCREENSHOT_FORMATS,
compareScreenshots,
ScreenshotComparisonResult,
} from './screenshot-comparison';
export const COMPARABLE_RUN_FORMATS = ['text', 'markdown', 'html'] as const;
export type ComparableRunFormat = (typeof COMPARABLE_RUN_FORMATS)[number];
@@ -27,7 +22,7 @@ export async function findPreviousSuccessfulRun(currentRun: any) {
robotMetaId: currentRun.robotMetaId,
status: 'success',
},
attributes: ['runId', 'finishedAt', 'startedAt', 'serializableOutput', 'binaryOutput'],
attributes: ['runId', 'finishedAt', 'startedAt', 'serializableOutput'],
});
const currentTimestamp = getRunTimestamp(currentRun);
@@ -62,15 +57,13 @@ export async function compareRunTextWithPrevious(currentRun: any, currentText: s
* Compares every text-based output produced by the current run against the
* matching output from the previous successful run.
*/
export async function compareRunOutputsWithPrevious(currentRun: any, currentOutput: any, currentBinaryOutput?: any) {
export async function compareRunOutputsWithPrevious(currentRun: any, currentOutput: any) {
const previousRun = await findPreviousSuccessfulRun(currentRun);
if (!previousRun) {
return {
previousRun: null,
hasChanges: false,
changedFormats: [] as string[],
screenshotComparisons: {} as Partial<Record<string, ScreenshotComparisonResult>>,
screenshotDiffs: {} as Record<string, Buffer>,
};
}
@@ -83,25 +76,10 @@ export async function compareRunOutputsWithPrevious(currentRun: any, currentOutp
return normalizeComparableText(previousContent) !== normalizeComparableText(currentContent);
});
const screenshotComparisons: Partial<Record<string, ScreenshotComparisonResult>> = {};
const screenshotDiffs: Record<string, Buffer> = {};
for (const format of COMPARABLE_SCREENSHOT_FORMATS) {
if (!currentBinaryOutput?.[format]) continue;
const comparison = await compareScreenshots(currentBinaryOutput[format], previousRun.binaryOutput?.[format]);
if (!comparison) continue;
screenshotComparisons[format] = comparison;
if (comparison.changed) {
changedFormats.push(format);
if (comparison.diff) screenshotDiffs[`${format}-diff`] = comparison.diff;
}
}
return {
previousRun,
hasChanges: changedFormats.length > 0,
changedFormats,
screenshotComparisons,
screenshotDiffs,
};
}
@@ -130,16 +108,14 @@ const canonicalizeCapturedLists = (value: any): any => {
export const serializeCapturedLists = (value: any) => JSON.stringify(canonicalizeCapturedLists(value || {}));
/** Compares named text and screenshot captures produced by an extract robot. */
export async function compareExtractRunWithPrevious(currentRun: any, currentOutput: any, binaryOutput: any) {
/** Compares captured text and lists produced by an extract robot. */
export async function compareExtractRunWithPrevious(currentRun: any, currentOutput: any) {
const previousRun = await findPreviousSuccessfulRun(currentRun);
if (!previousRun) {
return {
previousRun: null,
hasChanges: false,
changedFormats: [] as string[],
screenshotComparisons: {} as Record<string, ScreenshotComparisonResult>,
screenshotDiffs: {} as Record<string, Buffer>,
};
}
@@ -151,23 +127,9 @@ export async function compareExtractRunWithPrevious(currentRun: any, currentOutp
changedFormats.push('captured-list');
}
const screenshotComparisons: Record<string, ScreenshotComparisonResult> = {};
const screenshotDiffs: Record<string, Buffer> = {};
for (const name of Object.keys(binaryOutput || {}).filter((key) => !key.endsWith('-diff'))) {
const comparison = await compareScreenshots(binaryOutput[name], previousRun.binaryOutput?.[name]);
if (!comparison) continue;
screenshotComparisons[name] = comparison;
if (comparison.changed) {
changedFormats.push(`screenshot:${name}`);
if (comparison.diff) screenshotDiffs[`${name}-diff`] = comparison.diff;
}
}
return {
previousRun,
hasChanges: changedFormats.length > 0,
changedFormats,
screenshotComparisons,
screenshotDiffs,
};
}
-237
View File
@@ -1,237 +0,0 @@
import sharp from 'sharp';
import { BinaryOutputService } from '../storage/mino';
export const COMPARABLE_SCREENSHOT_FORMATS = ['screenshot-visible', 'screenshot-fullpage'] as const;
export type ComparableScreenshotFormat = (typeof COMPARABLE_SCREENSHOT_FORMATS)[number];
const PIXEL_CHANNEL_THRESHOLD = 32;
const HORIZONTAL_POSITION_TOLERANCE = 2;
const VERTICAL_POSITION_TOLERANCE = 3;
const CHANGED_PERCENTAGE_THRESHOLD = 0.01;
const MAX_COMPARISON_PIXELS = 4_000_000;
export interface ScreenshotComparisonResult {
changed: boolean;
changedPixels: number;
changedPercentage: number;
previousWidth: number;
previousHeight: number;
currentWidth: number;
currentHeight: number;
comparedWidth: number;
comparedHeight: number;
diff?: Buffer;
}
function binaryEntryToBuffer(entry: any): Buffer | null {
const value = entry?.data ?? entry;
if (Buffer.isBuffer(value)) return value;
if (value?.type === 'Buffer' && Array.isArray(value.data)) return Buffer.from(value.data);
if (typeof value !== 'string' || /^https?:\/\//.test(value)) return null;
const dataUri = value.match(/^data:[^;]+;base64,(.+)$/);
return Buffer.from(dataUri?.[1] ?? value, 'base64');
}
async function loadStoredScreenshot(entry: any): Promise<Buffer | null> {
const inline = binaryEntryToBuffer(entry);
if (inline) return inline;
const value = entry?.data ?? entry;
if (typeof value !== 'string' || !/^https?:\/\//.test(value)) return null;
try {
const url = new URL(value);
const bucketSegment = '/maxun-run-screenshots/';
const bucketIndex = url.pathname.indexOf(bucketSegment);
if (bucketIndex === -1) return null;
const key = decodeURIComponent(url.pathname.slice(bucketIndex + bucketSegment.length));
return await new BinaryOutputService('maxun-run-screenshots').getBinaryOutputFromMinioBucket(key);
} catch {
return null;
}
}
async function renderComparableImage(
buffer: Buffer,
imageWidth: number,
imageHeight: number,
canvasWidth: number,
canvasHeight: number,
): Promise<Buffer> {
return sharp(buffer)
.resize(imageWidth, imageHeight, { fit: 'fill' })
.extend({
top: 0,
left: 0,
right: canvasWidth - imageWidth,
bottom: canvasHeight - imageHeight,
background: { r: 255, g: 255, b: 255, alpha: 255 },
})
.ensureAlpha()
.raw()
.toBuffer();
}
const ROW_FINGERPRINT_BINS = 24;
function createRowFingerprints(buffer: Buffer, canvasWidth: number, imageWidth: number, imageHeight: number) {
const fingerprints = new Float32Array(imageHeight * ROW_FINGERPRINT_BINS);
for (let y = 0; y < imageHeight; y += 1) {
for (let bin = 0; bin < ROW_FINGERPRINT_BINS; bin += 1) {
const start = Math.floor(bin * imageWidth / ROW_FINGERPRINT_BINS);
const end = Math.max(start + 1, Math.floor((bin + 1) * imageWidth / ROW_FINGERPRINT_BINS));
const step = Math.max(1, Math.floor((end - start) / 6));
let total = 0;
let samples = 0;
for (let x = start; x < end; x += step) {
const offset = (y * canvasWidth + x) * 4;
total += buffer[offset] * 0.299 + buffer[offset + 1] * 0.587 + buffer[offset + 2] * 0.114;
samples += 1;
}
fingerprints[y * ROW_FINGERPRINT_BINS + bin] = samples ? total / samples : 255;
}
}
return fingerprints;
}
function alignRows(current: Buffer, previous: Buffer, width: number, currentWidth: number, currentHeight: number, previousWidth: number, previousHeight: number) {
const currentRows = createRowFingerprints(current, width, currentWidth, currentHeight);
const previousRows = createRowFingerprints(previous, width, previousWidth, previousHeight);
const searchRadius = Math.min(250, Math.max(40, Math.abs(currentHeight - previousHeight) + 30));
const rowMap = new Int32Array(currentHeight);
const blockHeight = 24;
let previousOffset = 0;
for (let blockStart = 0; blockStart < currentHeight; blockStart += blockHeight) {
const blockEnd = Math.min(currentHeight, blockStart + blockHeight);
const minimumOffset = Math.max(-searchRadius, -blockStart);
const maximumOffset = Math.min(searchRadius, previousHeight - blockEnd);
let bestOffset = Math.max(minimumOffset, Math.min(maximumOffset, previousOffset));
let bestScore = Number.POSITIVE_INFINITY;
for (let offset = minimumOffset; offset <= maximumOffset; offset += 1) {
let score = Math.abs(offset - previousOffset) * 2 + Math.abs(offset) * 0.05;
for (let currentY = blockStart; currentY < blockEnd; currentY += 3) {
const previousY = currentY + offset;
for (let bin = 0; bin < ROW_FINGERPRINT_BINS; bin += 1) {
score += Math.abs(
currentRows[currentY * ROW_FINGERPRINT_BINS + bin]
- previousRows[previousY * ROW_FINGERPRINT_BINS + bin],
);
}
}
if (score < bestScore) {
bestScore = score;
bestOffset = offset;
}
}
previousOffset = bestOffset;
for (let currentY = blockStart; currentY < blockEnd; currentY += 1) {
rowMap[currentY] = Math.max(0, Math.min(previousHeight - 1, currentY + bestOffset));
}
}
return rowMap;
}
export async function compareScreenshots(currentEntry: any, previousEntry: any): Promise<ScreenshotComparisonResult | null> {
const [currentBuffer, previousBuffer] = await Promise.all([
loadStoredScreenshot(currentEntry),
loadStoredScreenshot(previousEntry),
]);
if (!currentBuffer || !previousBuffer) return null;
const [currentMetadata, previousMetadata] = await Promise.all([
sharp(currentBuffer).metadata(),
sharp(previousBuffer).metadata(),
]);
if (!currentMetadata.width || !currentMetadata.height || !previousMetadata.width || !previousMetadata.height) return null;
const sourceWidth = Math.max(currentMetadata.width, previousMetadata.width);
const sourceHeight = Math.max(currentMetadata.height, previousMetadata.height);
const scale = Math.min(1, Math.sqrt(MAX_COMPARISON_PIXELS / (sourceWidth * sourceHeight)));
const width = Math.max(1, Math.round(sourceWidth * scale));
const height = Math.max(1, Math.round(sourceHeight * scale));
const currentWidth = Math.max(1, Math.round(currentMetadata.width * scale));
const currentHeight = Math.max(1, Math.round(currentMetadata.height * scale));
const previousWidth = Math.max(1, Math.round(previousMetadata.width * scale));
const previousHeight = Math.max(1, Math.round(previousMetadata.height * scale));
const [currentPixels, previousPixels] = await Promise.all([
renderComparableImage(currentBuffer, currentWidth, currentHeight, width, height),
renderComparableImage(previousBuffer, previousWidth, previousHeight, width, height),
]);
const previousRowForCurrent = alignRows(
currentPixels, previousPixels, width, currentWidth, currentHeight, previousWidth, previousHeight,
);
const changedMask = new Uint8Array(width * currentHeight);
let changedPixels = 0;
for (let y = 0; y < currentHeight; y += 1) {
const previousY = previousRowForCurrent[y];
for (let x = 0; x < currentWidth; x += 1) {
const pixelIndex = y * width + x;
const currentOffset = pixelIndex * 4;
let delta = 255;
for (let dy = -VERTICAL_POSITION_TOLERANCE; dy <= VERTICAL_POSITION_TOLERANCE; dy += 1) {
const candidatePreviousY = previousY + dy;
if (candidatePreviousY < 0 || candidatePreviousY >= previousHeight) continue;
for (let dx = -HORIZONTAL_POSITION_TOLERANCE; dx <= HORIZONTAL_POSITION_TOLERANCE; dx += 1) {
const previousX = x + dx;
if (previousX < 0 || previousX >= previousWidth) continue;
const previousOffset = (candidatePreviousY * width + previousX) * 4;
delta = Math.min(delta, Math.max(
Math.abs(currentPixels[currentOffset] - previousPixels[previousOffset]),
Math.abs(currentPixels[currentOffset + 1] - previousPixels[previousOffset + 1]),
Math.abs(currentPixels[currentOffset + 2] - previousPixels[previousOffset + 2]),
Math.abs(currentPixels[currentOffset + 3] - previousPixels[previousOffset + 3]),
));
}
}
if (delta > PIXEL_CHANNEL_THRESHOLD) {
changedPixels += 1;
changedMask[pixelIndex] = 1;
}
}
}
const changedPercentage = (changedPixels / (currentWidth * currentHeight)) * 100;
const changed = changedPercentage >= CHANGED_PERCENTAGE_THRESHOLD;
let diff: Buffer | undefined;
if (changed) {
const highlightedMask = new Uint8Array(changedMask.length);
const radius = 1;
for (let index = 0; index < changedMask.length; index += 1) {
if (!changedMask[index]) continue;
const centerX = index % width;
const centerY = Math.floor(index / width);
for (let y = Math.max(0, centerY - radius); y <= Math.min(currentHeight - 1, centerY + radius); y += 1) {
for (let x = Math.max(0, centerX - radius); x <= Math.min(width - 1, centerX + radius); x += 1) {
highlightedMask[y * width + x] = 1;
}
}
}
const diffPixels = Buffer.from(currentPixels.subarray(0, width * currentHeight * 4));
for (let index = 0; index < highlightedMask.length; index += 1) {
if (!highlightedMask[index]) continue;
const offset = index * 4;
diffPixels[offset] = Math.round(diffPixels[offset] * 0.25 + 255 * 0.75);
diffPixels[offset + 1] = Math.round(diffPixels[offset + 1] * 0.25);
diffPixels[offset + 2] = Math.round(diffPixels[offset + 2] * 0.25 + 195 * 0.75);
diffPixels[offset + 3] = 255;
}
diff = await sharp(diffPixels, { raw: { width, height: currentHeight, channels: 4 } }).png().toBuffer();
}
return {
changed,
changedPixels,
changedPercentage,
previousWidth: previousMetadata.width,
previousHeight: previousMetadata.height,
currentWidth: currentMetadata.width,
currentHeight: currentMetadata.height,
comparedWidth: width,
comparedHeight: height,
diff,
};
}
@@ -447,22 +447,11 @@ async function executeRun(id: string, userId: string) {
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
if ((recording.recording_meta as any).compareRuns) {
try {
const comparison = await compareRunOutputsWithPrevious(run, serializableOutput, binaryOutput);
const comparison = await compareRunOutputsWithPrevious(run, serializableOutput);
hasChanges = comparison.hasChanges;
serializableOutput._comparison = {
changedFormats: comparison.changedFormats,
screenshots: Object.fromEntries(await Promise.all(
Object.entries(comparison.screenshotComparisons).map(async ([key, value]) => {
if (!value) return [key, null];
const { diff: _diff, ...metadata } = value;
const diff = comparison.screenshotDiffs[`${key}-diff`];
const diffUrl = diff
? await binaryOutputService.uploadBinaryOutputItem(plainRun.runId, `${key}-diff`, diff, 'image/png')
: null;
return [key, { ...metadata, diff: diffUrl || (diff ? `data:image/png;base64,${diff.toString('base64')}` : null) }];
}),
)),
};
if (comparison.previousRun) {
@@ -689,16 +678,10 @@ async function executeRun(id: string, userId: string) {
let hasChanges = false;
const binaryOutputService = new BinaryOutputService('maxun-run-screenshots');
if (robotType === 'extract' && (recording.recording_meta as any).compareRuns) {
const comparison = await compareExtractRunWithPrevious(run, finalSerializableOutput, binaryOutput);
const comparison = await compareExtractRunWithPrevious(run, finalSerializableOutput);
hasChanges = comparison.hasChanges;
finalSerializableOutput._comparison = {
changedFormats: comparison.changedFormats,
screenshots: Object.fromEntries(await Promise.all(Object.entries(comparison.screenshotComparisons).map(async ([key, value]) => {
const { diff: _diff, ...metadata } = value;
const diff = comparison.screenshotDiffs[`${key}-diff`];
const diffUrl = diff ? await binaryOutputService.uploadBinaryOutputItem(plainRun.runId, `${key}-diff`, diff, 'image/png') : null;
return [key, { ...metadata, diff: diffUrl || (diff ? `data:image/png;base64,${diff.toString('base64')}` : null) }];
}))),
};
}
-16
View File
@@ -184,22 +184,6 @@ export interface RunDiffResponse {
}>>;
capturedText: { current: string; previous: string } | null;
capturedLists: { current: Record<string, any>; previous: Record<string, any> } | null;
screenshots: Record<string, {
current: string | { data?: string };
previous: string | { data?: string } | null;
diff: string | { data?: string } | null;
metadata: {
changed: boolean;
changedPixels: number;
changedPercentage: number;
previousWidth: number;
previousHeight: number;
currentWidth: number;
currentHeight: number;
comparedWidth: number;
comparedHeight: number;
} | null;
}>;
changedFormats: string[];
}
@@ -243,7 +243,7 @@ export const RobotSettingsPage = ({ handleStart }: RobotSettingsProps) => {
{(robot.recording_meta.type === 'scrape' || robot.recording_meta.type === 'extract') && (
<>
<Typography variant="h6">Monitoring</Typography>
<p>When enabled, each run is compared against the previous one to detect changes. The changed values can be viewed in the run comparison results. </p>
<p>When enabled, each run is compared against the previous one for text content, Markdown, HTML, captured text, and captured lists. Screenshots are not compared. Changed values can be viewed in the run comparison results.</p>
<FormControlLabel
control={
<Checkbox
+3 -25
View File
@@ -10,8 +10,6 @@ export const DIFF_FORMAT_LABELS: Record<string, string> = {
text: 'Text Content',
markdown: 'Markdown',
html: 'HTML',
'screenshot-visible': 'Visible Screenshot',
'screenshot-fullpage': 'Full-page Screenshot',
};
export type CapturedListRow = Record<string, any>;
@@ -112,11 +110,9 @@ export function useRunDiff(runId: string | undefined) {
...TEXT_DIFF_FORMATS.filter((format) => data.formats?.[format]),
...(data.capturedText ? ['captured-text'] : []),
...(data.capturedLists ? ['captured-list'] : []),
...Object.keys(data.screenshots || {}).map((name) => `screenshot:${name}`),
] : [];
const firstChangedFormat = availableFormats.find((format) => {
const changedKey = format.startsWith('screenshot:') ? format.slice('screenshot:'.length) : format;
return data?.changedFormats?.includes(format) || data?.changedFormats?.includes(changedKey);
return data?.changedFormats?.includes(format);
});
const initialFormat = firstChangedFormat || availableFormats[0];
if (initialFormat) setSelectedDiffFormat(initialFormat);
@@ -145,7 +141,7 @@ export function useRunDiff(runId: string | undefined) {
}, [runId]);
const diffParts = useMemo<Change[]>(() => {
if (!diffData || selectedDiffFormat.startsWith('screenshot:')) return [];
if (!diffData) return [];
const selectedOutput = selectedDiffFormat === 'captured-text'
? diffData.capturedText
: diffData.formats?.[selectedDiffFormat as TextDiffFormat];
@@ -155,28 +151,12 @@ export function useRunDiff(runId: string | undefined) {
}, [diffData, selectedDiffFormat]);
const hasDiff = diffParts.some((part) => part.added || part.removed);
const selectedScreenshotName = selectedDiffFormat.startsWith('screenshot:')
? selectedDiffFormat.slice('screenshot:'.length)
: undefined;
const selectedScreenshot = selectedScreenshotName ? diffData?.screenshots?.[selectedScreenshotName] : undefined;
const getDiffImageSrc = (entry: string | { data?: string } | null | undefined) => {
const value = typeof entry === 'object' && entry !== null ? entry.data : entry;
if (!value) return '';
if (value.startsWith('http') || value.startsWith('data:')) return value;
return `data:image/png;base64,${value}`;
};
const diffOptions = useMemo(() => {
if (!diffData) return [];
return [
...TEXT_DIFF_FORMATS.filter((format) => diffData.formats?.[format]).map((format) => ({ key: format, label: DIFF_FORMAT_LABELS[format] })),
...(diffData.capturedText ? [{ key: 'captured-text', label: 'Captured Text' }] : []),
...(diffData.capturedLists ? [{ key: 'captured-list', label: 'Captured Lists' }] : []),
...Object.keys(diffData.screenshots || {}).map((name) => ({
key: `screenshot:${name}`,
label: DIFF_FORMAT_LABELS[name] || name,
})),
];
}, [diffData]);
@@ -254,13 +234,11 @@ export function useRunDiff(runId: string | undefined) {
setSelectedCapturedList,
diffParts,
hasDiff,
selectedScreenshot,
diffOptions,
capturedGroups,
capturedTableRows,
capturedListGroups,
capturedListRows,
capturedListColumns,
getDiffImageSrc,
};
}
}
+4 -102
View File
@@ -19,35 +19,10 @@ export const RunDiffPage: React.FC = () => {
diffData, isDiffLoading, selectedDiffFormat, setSelectedDiffFormat,
selectedCapturedGroup, setSelectedCapturedGroup,
selectedCapturedList, setSelectedCapturedList,
diffParts, hasDiff, selectedScreenshot, diffOptions,
diffParts, hasDiff, diffOptions,
capturedGroups, capturedTableRows, capturedListGroups, capturedListRows, capturedListColumns,
getDiffImageSrc,
} = useRunDiff(runId);
// Group all individual "screenshot:<name>" entries the hook produces into one
// top-level "Screenshots" tab, with the individual names becoming a sub-tab row —
// same pattern as the captured-list groups below.
const screenshotOptions = diffOptions.filter((option) => option.key.startsWith('screenshot:'));
const nonScreenshotOptions = diffOptions.filter((option) => !option.key.startsWith('screenshot:'));
const topLevelOptions = [
...nonScreenshotOptions,
...(screenshotOptions.length > 0 ? [{ key: 'screenshots', label: 'Screenshots' }] : []),
];
const isScreenshotsActive = selectedDiffFormat.startsWith('screenshot:');
const topLevelValue = isScreenshotsActive ? 'screenshots' : selectedDiffFormat;
const handleTopLevelChange = (_: React.SyntheticEvent, value: string) => {
if (value === 'screenshots') {
// Only switch format if we weren't already showing a screenshot; otherwise
// keep whichever one was selected.
if (!isScreenshotsActive && screenshotOptions.length > 0) {
setSelectedDiffFormat(screenshotOptions[0].key);
}
return;
}
setSelectedDiffFormat(value);
};
return (
<Box sx={{ minHeight: '100vh', width: '100%', minWidth: 0, overflowX: 'hidden', bgcolor: 'background.paper', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 3, py: 2 }}>
@@ -73,8 +48,8 @@ export const RunDiffPage: React.FC = () => {
) : (
<>
<Tabs
value={topLevelValue}
onChange={handleTopLevelChange}
value={selectedDiffFormat}
onChange={(_, value) => setSelectedDiffFormat(value)}
centered
sx={{
minHeight: 36,
@@ -87,34 +62,11 @@ export const RunDiffPage: React.FC = () => {
'& .MuiTabs-indicator': { height: 2 },
}}
>
{topLevelOptions.map((option) => (
{diffOptions.map((option) => (
<Tab key={option.key} value={option.key} label={option.label} />
))}
</Tabs>
{isScreenshotsActive && screenshotOptions.length > 1 && (
<Tabs
value={selectedDiffFormat}
onChange={(_, value) => setSelectedDiffFormat(value)}
variant="scrollable"
scrollButtons="auto"
sx={{
minHeight: 36,
mb: 3,
flexShrink: 0,
'& .MuiTab-root': {
minHeight: 36, paddingX: 2, paddingY: 1.5, minWidth: 0,
color: theme => `${theme.palette.mode === 'dark' ? '#fff' : '#000'} !important`,
},
'& .MuiTabs-indicator': { height: 2 },
}}
>
{screenshotOptions.map((option) => (
<Tab key={option.key} value={option.key} label={option.label} sx={{ minHeight: 36 }} />
))}
</Tabs>
)}
<Box sx={{ flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
{selectedDiffFormat === 'captured-text' ? (
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
@@ -300,56 +252,6 @@ export const RunDiffPage: React.FC = () => {
</TableContainer>
)}
</Box>
) : selectedScreenshot ? (
<Box sx={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
{selectedScreenshot.metadata && (
<Typography variant="body2" align="center" sx={{ mb: 2 }}>
{selectedScreenshot.metadata.changedPercentage.toFixed(2)}% of compared pixels changed
{' · '}
Previous {selectedScreenshot.metadata.previousWidth}×{selectedScreenshot.metadata.previousHeight}
{' · '}
Current {selectedScreenshot.metadata.currentWidth}×{selectedScreenshot.metadata.currentHeight}
</Typography>
)}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: 'repeat(3, 1fr)' }, gap: 2 }}>
{[
{ label: 'Previous Run', source: selectedScreenshot.previous },
{ label: 'Current Run', source: selectedScreenshot.current },
].map((image) => (
<Box key={image.label}>
<Typography variant="subtitle2" align="center" gutterBottom>{image.label}</Typography>
{getDiffImageSrc(image.source) ? (
<Box component="img" src={getDiffImageSrc(image.source)} alt={image.label} sx={{ display: 'block', width: '100%', height: 'auto', border: `1px solid ${theme.palette.divider}` }} />
) : (
<Typography align="center" color="text.secondary">Screenshot unavailable</Typography>
)}
</Box>
))}
<Box>
<Typography variant="subtitle2" align="center" gutterBottom>
Changes Highlighted
</Typography>
{selectedScreenshot.diff ? (
<>
<Box
component="img"
src={getDiffImageSrc(selectedScreenshot.diff)}
alt="Current screenshot with changes highlighted"
sx={{ display: 'block', width: '100%', height: 'auto', border: `1px solid ${theme.palette.divider}` }}
/>
<Typography variant="caption" display="flex" alignItems="center" justifyContent="center" gap={0.75} sx={{ mt: 1, color: 'text.secondary' }}>
<Box component="span" sx={{ width: 12, height: 12, bgcolor: '#ff00c3', borderRadius: '2px' }} />
Magenta highlights show changed areas on the current screenshot.
</Typography>
</>
) : (
<Typography align="center" color="text.secondary" sx={{ mt: 4 }}>
A highlighted visual diff was not generated for this run. Run the robot again to create one with the updated comparison.
</Typography>
)}
</Box>
</Box>
</Box>
) : !hasDiff ? (
<Box display="flex" alignItems="center" justifyContent="center" flex={1}>
<Typography color="text.secondary">