Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
20 changes: 20 additions & 0 deletions packages/core/src/dump/html-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,26 @@ export function generateImageScriptTag(id: string, data: string): string {
);
}

/**
* Inline script that fixes relative URL resolution for directory-mode reports.
*
* Problem: when a static server (e.g. `npx serve`) serves `name/index.html`
* at URL `/name` (without trailing slash), relative paths like
* `./screenshots/xxx.png` resolve to `/screenshots/xxx.png` instead of
* `/name/screenshots/xxx.png`.
*
* Fix: dynamically insert a <base> tag so relative URLs resolve correctly.
*/
// Do not use template string here, will cause bundle error with <script
export const BASE_URL_FIX_SCRIPT =
'\n<script>(function(){' +
'var p=window.location.pathname;' +
'if(p.endsWith("/")||/\\.\\w+$/.test(p))return;' +
'var b=document.createElement("base");' +
'b.href=p+"/";' +
'document.head.insertBefore(b,document.head.firstChild)' +
'})()</script>\n';

export function generateDumpScriptTag(
json: string,
attributes?: Record<string, string>,
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/report-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from '@midscene/shared/env';
import { ifInBrowser, logMsg } from '@midscene/shared/utils';
import {
BASE_URL_FIX_SCRIPT,
generateDumpScriptTag,
generateImageScriptTag,
} from './dump/html-utils';
Expand Down Expand Up @@ -223,7 +224,7 @@ export class ReportGenerator implements IReportGenerator {
const serialized = dump.serialize();
writeFileSync(
this.reportPath,
`${getReportTpl()}\n${generateDumpScriptTag(serialized)}`,
`${getReportTpl()}${BASE_URL_FIX_SCRIPT}${generateDumpScriptTag(serialized)}`,
);
}
}
83 changes: 75 additions & 8 deletions packages/core/src/report.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { appendFileSync, existsSync, unlinkSync } from 'node:fs';
import {
appendFileSync,
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
rmSync,
unlinkSync,
} from 'node:fs';
import * as path from 'node:path';
import { getMidsceneRunSubDir } from '@midscene/shared/common';
import { logMsg } from '@midscene/shared/utils';
import { getReportFileName } from './agent';
import {
BASE_URL_FIX_SCRIPT,
extractLastDumpScriptSync,
streamImageScriptsToFile,
} from './dump/html-utils';
Expand All @@ -19,6 +28,18 @@ export class ReportMergingTool {
this.reportInfos = [];
}

/**
* Check if a report is in directory mode (html-and-external-assets).
* Directory mode reports: {name}/index.html + {name}/screenshots/
*/
private isDirectoryModeReport(reportFilePath: string): boolean {
const reportDir = path.dirname(reportFilePath);
return (
path.basename(reportFilePath) === 'index.html' &&
existsSync(path.join(reportDir, 'screenshots'))
);
}

public mergeReports(
reportFileName: 'AUTO' | string = 'AUTO',
opts?: {
Expand All @@ -34,18 +55,37 @@ export class ReportMergingTool {
const { rmOriginalReports = false, overwrite = false } = opts ?? {};
const targetDir = getMidsceneRunSubDir('report');

const outputFilePath =
// Check if any source report is directory mode
const hasDirectoryModeReport = this.reportInfos.some((info) =>
this.isDirectoryModeReport(info.reportFilePath),
);

const resolvedName =
reportFileName === 'AUTO'
? path.resolve(targetDir, `${getReportFileName('merged-report')}.html`)
: path.resolve(targetDir, `${reportFileName}.html`);
? getReportFileName('merged-report')
: reportFileName;

// Directory mode: output as {name}/index.html to keep relative paths working
// Inline mode: output as {name}.html (single file)
const outputFilePath = hasDirectoryModeReport
? path.resolve(targetDir, resolvedName, 'index.html')
: path.resolve(targetDir, `${resolvedName}.html`);

if (reportFileName !== 'AUTO' && existsSync(outputFilePath)) {
if (!overwrite) {
throw new Error(
`Report file already exists: ${outputFilePath}\nSet overwrite to true to overwrite this file.`,
);
}
unlinkSync(outputFilePath);
if (hasDirectoryModeReport) {
rmSync(path.dirname(outputFilePath), { recursive: true, force: true });
} else {
unlinkSync(outputFilePath);
}
}

if (hasDirectoryModeReport) {
mkdirSync(path.dirname(outputFilePath), { recursive: true });
}

logMsg(
Expand All @@ -56,13 +96,34 @@ export class ReportMergingTool {
// Write template
appendFileSync(outputFilePath, getReportTpl());

// For directory-mode output, inject base URL fix script
if (hasDirectoryModeReport) {
appendFileSync(outputFilePath, BASE_URL_FIX_SCRIPT);
}

// Process all reports one by one
for (let i = 0; i < this.reportInfos.length; i++) {
const reportInfo = this.reportInfos[i];
logMsg(`Processing report ${i + 1}/${this.reportInfos.length}`);

// Stream image scripts directly to output file (constant memory per image)
streamImageScriptsToFile(reportInfo.reportFilePath, outputFilePath);
if (this.isDirectoryModeReport(reportInfo.reportFilePath)) {
// Directory mode: copy external screenshot files
const reportDir = path.dirname(reportInfo.reportFilePath);
const screenshotsDir = path.join(reportDir, 'screenshots');
const mergedScreenshotsDir = path.join(
path.dirname(outputFilePath),
'screenshots',
);
mkdirSync(mergedScreenshotsDir, { recursive: true });
for (const file of readdirSync(screenshotsDir)) {
const src = path.join(screenshotsDir, file);
const dest = path.join(mergedScreenshotsDir, file);
copyFileSync(src, dest);
}
} else {
// Inline mode: stream image scripts to output file
streamImageScriptsToFile(reportInfo.reportFilePath, outputFilePath);
}

const dumpString = extractLastDumpScriptSync(reportInfo.reportFilePath);
const { reportAttributes } = reportInfo;
Expand Down Expand Up @@ -92,7 +153,13 @@ export class ReportMergingTool {
if (rmOriginalReports) {
for (const info of this.reportInfos) {
try {
unlinkSync(info.reportFilePath);
if (this.isDirectoryModeReport(info.reportFilePath)) {
// Directory mode: remove the entire report directory
const reportDir = path.dirname(info.reportFilePath);
rmSync(reportDir, { recursive: true, force: true });
} else {
unlinkSync(info.reportFilePath);
}
} catch (error) {
logMsg(`Error deleting report ${info.reportFilePath}: ${error}`);
}
Expand Down
208 changes: 208 additions & 0 deletions packages/core/tests/unit-test/merge-browser-parse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Test that simulates the browser's parsing logic for merged directory-mode reports.
* Verifies the complete data roundtrip:
* ReportGenerator → directory mode HTML → mergeReports → extract dump → parse dump → verify executions
*/
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { extractLastDumpScriptSync } from '@/dump/html-utils';
import { ReportMergingTool } from '@/report';
import { ReportGenerator } from '@/report-generator';
import { ScreenshotItem } from '@/screenshot-item';
import { ExecutionDump, GroupedActionDump, type UIContext } from '@/types';
import { antiEscapeScriptTag, escapeScriptTag } from '@midscene/shared/utils';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

function fakeBase64(sizeBytes: number): string {
return `data:image/png;base64,${'A'.repeat(sizeBytes)}`;
}

function createDump(screenshots: ScreenshotItem[]): GroupedActionDump {
const tasks = screenshots.map((s, i) => ({
type: 'Insight' as const,
subType: 'Locate',
param: { prompt: `task-${i}` },
uiContext: {
screenshot: s,
size: { width: 1920, height: 1080 },
} as UIContext,
executor: async () => undefined,
recorder: [],
status: 'finished' as const,
}));

return new GroupedActionDump({
sdkVersion: '1.0.0-test',
groupName: 'test-group',
groupDescription: 'test desc',
modelBriefs: ['test-model'],
executions: [
new ExecutionDump({
logTime: Date.now(),
name: 'test-execution',
tasks,
}),
],
});
}

describe('browser parse simulation for merged directory-mode reports', () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = join(tmpdir(), `midscene-browser-parse-test-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
});

afterEach(() => {
if (existsSync(tmpDir)) {
rmSync(tmpDir, { recursive: true, force: true });
}
});

it('step 1: verify directory mode report dump can be extracted', async () => {
const reportDir = join(tmpDir, 'step1-report');
const reportPath = join(reportDir, 'index.html');
const generator = new ReportGenerator({
reportPath,
screenshotMode: 'directory',
autoPrint: false,
});

const screenshot = ScreenshotItem.create(fakeBase64(500));
const dump = createDump([screenshot]);
await generator.finalize(dump);

// Read the HTML and extract dump the same way mergeReports does
const dumpString = extractLastDumpScriptSync(reportPath);
expect(dumpString).toBeTruthy();

// Unescape and parse (as the browser would)
const unescaped = antiEscapeScriptTag(dumpString);
const parsed = JSON.parse(unescaped);

expect(parsed.executions).toBeDefined();
expect(parsed.executions.length).toBe(1);
expect(parsed.executions[0].tasks.length).toBe(1);
});

it('step 2: verify merged report preserves directory mode with screenshots', async () => {
const tool = new ReportMergingTool();
const numReports = 2;

// Create 2 directory mode reports
for (let r = 0; r < numReports; r++) {
const reportDir = join(tmpDir, `step2-report-${r}`);
const reportPath = join(reportDir, 'index.html');
const generator = new ReportGenerator({
reportPath,
screenshotMode: 'directory',
autoPrint: false,
});

const screenshots = [ScreenshotItem.create(fakeBase64(400 + r * 50))];
const dump = createDump(screenshots);
await generator.finalize(dump);

tool.append({
reportFilePath: reportPath,
reportAttributes: {
testDescription: `Test ${r}`,
testDuration: 1000,
testId: `test-${r}`,
testStatus: 'passed',
testTitle: `Test Case ${r}`,
},
});
}

const mergedPath = tool.mergeReports('browser-parse-test', {
overwrite: true,
});
expect(existsSync(mergedPath!)).toBe(true);

// Merged output should be directory mode: {name}/index.html
expect(mergedPath!).toMatch(/index\.html$/);

// Read the full merged HTML
const mergedHtml = readFileSync(mergedPath!, 'utf-8');

// Verify merged file is not bloated with garbage from streamImageScriptsToFile
const mergedSizeMB = mergedHtml.length / 1024 / 1024;
expect(mergedSizeMB).toBeLessThan(15);

// Verify base URL fix script is injected
expect(mergedHtml).toContain('document.createElement("base")');

// Verify screenshots directory was created alongside merged report
const mergedScreenshotsDir = join(dirname(mergedPath!), 'screenshots');
expect(existsSync(mergedScreenshotsDir)).toBe(true);

// Verify screenshot files were copied
const screenshotFiles = readdirSync(mergedScreenshotsDir);
expect(screenshotFiles.length).toBeGreaterThanOrEqual(numReports);

// Extract and parse the last dump
const lastDump = extractLastDumpScriptSync(mergedPath!);
expect(lastDump).toBeTruthy();

const content = antiEscapeScriptTag(lastDump);
const parsed = JSON.parse(content);

expect(parsed.executions).toBeDefined();
expect(parsed.executions.length).toBe(1);
expect(parsed.executions[0].tasks.length).toBe(1);

// Verify screenshot reference uses relative path (directory mode preserved)
const screenshotRef = parsed.executions[0].tasks[0].uiContext?.screenshot;
expect(screenshotRef).toBeDefined();
expect(screenshotRef.base64).toMatch(/^\.\/screenshots\//);

// Verify the referenced screenshot file exists
const screenshotBasename = screenshotRef.base64.replace(
'./screenshots/',
'',
);
expect(screenshotFiles).toContain(screenshotBasename);
});

it('step 3: verify extractLastDumpScriptSync + escapeScriptTag roundtrip', async () => {
const reportDir = join(tmpDir, 'step3-report');
const reportPath = join(reportDir, 'index.html');
const generator = new ReportGenerator({
reportPath,
screenshotMode: 'directory',
autoPrint: false,
});

const screenshot = ScreenshotItem.create(fakeBase64(300));
const dump = createDump([screenshot]);
await generator.finalize(dump);

// Step 1: extract (what mergeReports does)
const extracted = extractLastDumpScriptSync(reportPath);

// Step 2: escape again (what reportHTMLContent does)
const doubleEscaped = escapeScriptTag(extracted);

// Step 3: unescape (what browser does via antiEscapeScriptTag)
const finalContent = antiEscapeScriptTag(doubleEscaped);

// Verify the JSON is valid and has correct structure
const parsed = JSON.parse(finalContent);
expect(parsed.executions).toBeDefined();
expect(parsed.executions.length).toBe(1);
expect(parsed.executions[0].tasks.length).toBe(1);

// Verify screenshot reference
const screenshotRef = parsed.executions[0].tasks[0].uiContext?.screenshot;
expect(screenshotRef).toBeDefined();
});
});
Loading
Loading