Skip to content

Commit eea4013

Browse files
ottomaoclaude
andcommitted
refactor(core): per-execution append model for report generation
Change report writing granularity from "entire GroupedActionDump" to "single ExecutionDump" with append-only semantics. - IReportGenerator: onDumpUpdate(dump) -> onExecutionUpdate(exec, groupMeta) - ReportGenerator: frozen/active region tracking with proper screenshot memory lifecycle (active screenshots stay in memory, frozen are released) - Agent: pass individual ExecutionDump to report generator - Viewer: merge dump tags by data-group-id for backward-compatible UX - Add GroupMeta type for group-level metadata Validated: pnpm run lint, vitest report-generator (25 pass), vitest report-merge-count (8 pass), vitest html-utils, vitest report Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 56c692b commit eea4013

File tree

7 files changed

+641
-364
lines changed

7 files changed

+641
-364
lines changed

apps/report/src/App.tsx

Lines changed: 153 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -305,78 +305,175 @@ function Visualizer(props: VisualizerProps): JSX.Element {
305305
}
306306

307307
export function App() {
308+
/**
309+
* Parse attributes from a dump script element.
310+
*/
311+
function parseAttributesFromElement(el: Element): PlaywrightTaskAttributes {
312+
const attributes: Partial<PlaywrightTaskAttributes> & Record<string, any> =
313+
{
314+
playwright_test_description: '',
315+
playwright_test_id: '',
316+
playwright_test_title: '',
317+
playwright_test_status: undefined,
318+
playwright_test_duration: 0,
319+
};
320+
Array.from(el.attributes).forEach((attr) => {
321+
const { name, value } = attr;
322+
const valueDecoded = decodeURIComponent(value);
323+
if (name.startsWith('playwright_')) {
324+
if (name === 'playwright_test_duration') {
325+
attributes[name] = Number(valueDecoded) || 0;
326+
} else {
327+
attributes[name] = valueDecoded;
328+
}
329+
}
330+
});
331+
return attributes as PlaywrightTaskAttributes;
332+
}
333+
334+
/**
335+
* Build a PlaywrightTasks entry from a single dump element (original behavior).
336+
*/
337+
function buildPlaywrightTaskFromElement(el: Element): PlaywrightTasks {
338+
const attributes = parseAttributesFromElement(el);
339+
let cachedJsonContent: GroupedActionDump | null = null;
340+
let isParsed = false;
341+
342+
return {
343+
get: () => {
344+
if (!isParsed) {
345+
try {
346+
console.time('parse_dump');
347+
const content = antiEscapeScriptTag(el.textContent || '');
348+
const parsed = JSON.parse(content);
349+
const restored = restoreImageReferences(
350+
parsed,
351+
resolveImageFromDom,
352+
);
353+
cachedJsonContent = GroupedActionDump.fromJSON(restored);
354+
console.timeEnd('parse_dump');
355+
(cachedJsonContent as any).attributes = attributes;
356+
isParsed = true;
357+
} catch (e) {
358+
console.error(el);
359+
console.error('failed to parse json content', e);
360+
cachedJsonContent = new GroupedActionDump({
361+
sdkVersion: '',
362+
groupName: '',
363+
modelBriefs: [],
364+
executions: [],
365+
});
366+
(cachedJsonContent as any).attributes = attributes;
367+
(cachedJsonContent as any).error = 'Failed to parse JSON content';
368+
isParsed = true;
369+
}
370+
}
371+
return cachedJsonContent!;
372+
},
373+
attributes,
374+
};
375+
}
376+
308377
function getDumpElements(): PlaywrightTasks[] {
309378
const dumpElements = document.querySelectorAll(
310379
'script[type="midscene_web_dump"]',
311380
);
312-
const reportDump: PlaywrightTasks[] = [];
313-
Array.from(dumpElements)
314-
.filter((el) => {
315-
const textContent = el.textContent;
316-
if (!textContent) {
317-
console.warn('empty content in script tag', el);
381+
const validElements = Array.from(dumpElements).filter((el) => {
382+
const textContent = el.textContent;
383+
if (!textContent) {
384+
console.warn('empty content in script tag', el);
385+
}
386+
return !!textContent;
387+
});
388+
389+
// Group elements by data-group-id
390+
const groupMap = new Map<string, Element[]>();
391+
const ungrouped: Element[] = [];
392+
393+
for (const el of validElements) {
394+
const groupId = el.getAttribute('data-group-id');
395+
if (groupId) {
396+
const decodedGroupId = decodeURIComponent(groupId);
397+
if (!groupMap.has(decodedGroupId)) {
398+
groupMap.set(decodedGroupId, []);
318399
}
319-
return !!textContent;
320-
})
321-
.forEach((el) => {
322-
const attributes: Partial<PlaywrightTaskAttributes> &
323-
Record<string, any> = {
324-
playwright_test_description: '',
325-
playwright_test_id: '',
326-
playwright_test_title: '',
327-
playwright_test_status: undefined,
328-
playwright_test_duration: 0,
329-
};
330-
Array.from(el.attributes).forEach((attr) => {
331-
const { name, value } = attr;
332-
const valueDecoded = decodeURIComponent(value);
333-
if (name.startsWith('playwright_')) {
334-
if (name === 'playwright_test_duration') {
335-
attributes[name] = Number(valueDecoded) || 0;
336-
} else {
337-
attributes[name] = valueDecoded;
338-
}
339-
}
340-
});
400+
groupMap.get(decodedGroupId)!.push(el);
401+
} else {
402+
ungrouped.push(el);
403+
}
404+
}
341405

342-
// Lazy loading: Store raw content and parse only when get() is called
343-
let cachedJsonContent: GroupedActionDump | null = null;
344-
let isParsed = false;
406+
const result: PlaywrightTasks[] = [];
345407

346-
reportDump.push({
347-
get: () => {
348-
if (!isParsed) {
349-
try {
350-
console.time('parse_dump');
351-
const content = antiEscapeScriptTag(el.textContent || '');
408+
// Process grouped dump tags — merge into one PlaywrightTasks per group
409+
for (const [, elements] of groupMap) {
410+
const attributes = parseAttributesFromElement(elements[0]);
411+
let cachedJsonContent: GroupedActionDump | null = null;
412+
let isParsed = false;
413+
414+
result.push({
415+
get: () => {
416+
if (!isParsed) {
417+
try {
418+
console.time('parse_grouped_dump');
419+
const allExecutions: any[] = [];
420+
let baseDump: GroupedActionDump | null = null;
352421

422+
for (const el of elements) {
423+
const content = antiEscapeScriptTag(el.textContent || '');
353424
const parsed = JSON.parse(content);
354425
const restored = restoreImageReferences(
355426
parsed,
356427
resolveImageFromDom,
357428
);
358-
cachedJsonContent = GroupedActionDump.fromJSON(restored);
359-
360-
console.timeEnd('parse_dump');
361-
(cachedJsonContent as any).attributes = attributes;
362-
isParsed = true;
363-
} catch (e) {
364-
console.error(el);
365-
console.error('failed to parse json content', e);
366-
// Return a fallback object to prevent crashes
367-
cachedJsonContent = {
368-
attributes,
369-
error: 'Failed to parse JSON content',
370-
} as any;
371-
isParsed = true;
429+
const dump = GroupedActionDump.fromJSON(restored);
430+
if (!baseDump) {
431+
baseDump = dump;
432+
}
433+
allExecutions.push(...dump.executions);
434+
}
435+
436+
if (baseDump) {
437+
baseDump.executions = allExecutions;
438+
cachedJsonContent = baseDump;
439+
} else {
440+
cachedJsonContent = new GroupedActionDump({
441+
sdkVersion: '',
442+
groupName: '',
443+
modelBriefs: [],
444+
executions: [],
445+
});
372446
}
447+
448+
console.timeEnd('parse_grouped_dump');
449+
(cachedJsonContent as any).attributes = attributes;
450+
isParsed = true;
451+
} catch (e) {
452+
console.error('failed to parse grouped dump content', e);
453+
cachedJsonContent = new GroupedActionDump({
454+
sdkVersion: '',
455+
groupName: '',
456+
modelBriefs: [],
457+
executions: [],
458+
});
459+
(cachedJsonContent as any).attributes = attributes;
460+
(cachedJsonContent as any).error =
461+
'Failed to parse grouped JSON content';
462+
isParsed = true;
373463
}
374-
return cachedJsonContent;
375-
},
376-
attributes: attributes as PlaywrightTaskAttributes,
377-
});
464+
}
465+
return cachedJsonContent!;
466+
},
467+
attributes,
378468
});
379-
return reportDump;
469+
}
470+
471+
// Process ungrouped dump tags — original behavior (backward compatible)
472+
for (const el of ungrouped) {
473+
result.push(buildPlaywrightTaskFromElement(el));
474+
}
475+
476+
return result;
380477
}
381478

382479
const [reportDump, setReportDump] = useState<PlaywrightTasks[]>([]);

packages/core/src/agent/agent.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type ExecutionRecorderItem,
1919
type ExecutionTask,
2020
type ExecutionTaskLog,
21+
type GroupMeta,
2122
GroupedActionDump,
2223
type LocateOption,
2324
type LocateResultElement,
@@ -343,7 +344,7 @@ export class Agent<
343344
}
344345

345346
// Fire and forget - don't block task execution
346-
this.writeOutActionDumps();
347+
this.writeOutActionDumps(executionDump);
347348
},
348349
},
349350
});
@@ -451,11 +452,27 @@ export class Agent<
451452
return reportHTMLContent(this.dumpDataString(opt));
452453
}
453454

454-
writeOutActionDumps() {
455-
this.reportGenerator.onDumpUpdate(this.dump);
455+
private lastExecutionDump?: ExecutionDump;
456+
457+
writeOutActionDumps(executionDump?: ExecutionDump) {
458+
const exec = executionDump || this.lastExecutionDump;
459+
if (exec) {
460+
this.lastExecutionDump = exec;
461+
this.reportGenerator.onExecutionUpdate(exec, this.getGroupMeta());
462+
}
456463
this.reportFile = this.reportGenerator.getReportPath();
457464
}
458465

466+
private getGroupMeta(): GroupMeta {
467+
return {
468+
groupName: this.dump.groupName,
469+
groupDescription: this.dump.groupDescription,
470+
sdkVersion: this.dump.sdkVersion,
471+
modelBriefs: this.dump.modelBriefs,
472+
deviceType: this.dump.deviceType,
473+
};
474+
}
475+
459476
private async callbackOnTaskStartTip(task: ExecutionTask) {
460477
const param = paramStr(task);
461478
const tip = param ? `${typeStr(task)} - ${param}` : typeStr(task);
@@ -1264,7 +1281,7 @@ export class Agent<
12641281
// Wait for all queued write operations to complete
12651282
await this.reportGenerator.flush();
12661283

1267-
await this.reportGenerator.finalize(this.dump);
1284+
await this.reportGenerator.finalize();
12681285
this.reportFile = this.reportGenerator.getReportPath();
12691286

12701287
await this.interface.destroy?.();
@@ -1327,7 +1344,7 @@ export class Agent<
13271344
}
13281345
}
13291346

1330-
this.writeOutActionDumps();
1347+
this.writeOutActionDumps(executionDump);
13311348
await this.reportGenerator.flush();
13321349
}
13331350

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export {
2828
GroupedActionDump,
2929
type IExecutionDump,
3030
type IGroupedActionDump,
31+
type GroupMeta,
3132
} from './types';
3233

3334
export { z };

0 commit comments

Comments
 (0)