Skip to content

Commit 30405dd

Browse files
quanruyuyutaotao
andauthored
feat(core): add merge action to report-tool CLI (#2497)
* feat(core): add merge action to report-tool CLI Add a `merge` action to the generic `report-tool` command so multiple HTML reports produced by separate CLI invocations can be combined into a single report without writing custom scripts. Expose the matching `mergeReportFiles` helper from `@midscene/core` as a JS SDK so the same workflow is reachable in code. - `report-tool --action merge` accepts `--htmlPaths` (JSON array or comma-separated list) plus optional `--outputDir`, `--outputName`, and `--overwrite`. Source paths may be HTML files or directories containing `index.html`. - `ReportMergingTool.mergeReports` now accepts an optional `outputDir` override; default behavior (write under `midscene_run/report`) is unchanged, so existing callers (Playwright reporter, etc.) are not affected. - The CLI auto-derives `testTitle` / `testDescription` from each source report's `groupName` / `groupDescription`, so users do not have to supply per-report metadata. - Update EN/ZH `consume-report-file` docs with CLI and SDK examples. * refactor(shared): accumulate repeated CLI flags into arrays Repeated `--key value` (or `--key=value`) flags now collect into a string array via `parseCliArgs`, so command schemas can opt into yargs-style multi-value inputs without ad-hoc CSV/JSON splitting. * refactor(core): switch report-tool merge to repeated --htmlReport flag Replace the JSON/CSV `--htmlPaths` argument with the more idiomatic `--htmlReport` flag, accumulating repeated occurrences into an array via the shared `parseCliArgs` helper. The handler also accepts a single string value, and the merge action now reads from `htmlReport`. Adds an end-to-end test that drives the merge action through `runToolsCLI` argv to exercise parsing + schema validation + handler in one path. * docs(site): document repeated --htmlReport flag for report-tool merge Update the bilingual report-file consumption guide to show the new yargs-style invocation with one `--htmlReport` per source report. * docs(site): remove summary paragraph from report parsing docs (#2536) --------- Co-authored-by: yuyutaotao <167746126+yuyutaotao@users.noreply.github.com>
1 parent 2069978 commit 30405dd

7 files changed

Lines changed: 429 additions & 21 deletions

File tree

apps/site/docs/en/consume-report-file.md

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,27 @@ Convert the report file into Markdown and write the result into the `output-mark
6161
npx @midscene/web report-tool --action to-markdown --htmlPath ./midscene_run/report/puppeteer-2026/index.html --outputDir ./output-markdown
6262
```
6363

64+
Merge multiple report files into a single combined report:
65+
66+
```shell
67+
npx @midscene/web report-tool --action merge-html \
68+
--htmlReport ./midscene_run/report/case-a/index.html \
69+
--htmlReport ./midscene_run/report/case-b.html \
70+
--outputDir ./merged --outputName all-cases
71+
```
72+
73+
Repeat `--htmlReport` once per source report. `--outputDir` and `--outputName` are optional; when omitted, the merged file is written to the default Midscene report directory with an auto-generated name. Pass `--overwrite` to replace an existing merged file.
74+
6475
## Parse With The JavaScript SDK
6576

66-
If you prefer to control report parsing in code, use `splitReportFile` and `reportFileToMarkdown` from `@midscene/core`.
77+
If you prefer to control report parsing in code, use `splitReportFile`, `reportFileToMarkdown`, and `mergeReportFiles` from `@midscene/core`.
6778

6879
```ts
69-
import { reportFileToMarkdown, splitReportFile } from '@midscene/core';
80+
import {
81+
mergeReportFiles,
82+
reportFileToMarkdown,
83+
splitReportFile,
84+
} from '@midscene/core';
7085

7186
const splitResult = splitReportFile({
7287
htmlPath: './midscene_run/report/puppeteer-2026/index.html',
@@ -79,14 +94,24 @@ const markdownResult = await reportFileToMarkdown({
7994
outputDir: './output-markdown',
8095
});
8196
console.log(markdownResult.markdownFiles);
97+
98+
const mergedResult = mergeReportFiles({
99+
htmlPaths: [
100+
'./midscene_run/report/case-a/index.html',
101+
'./midscene_run/report/case-b.html',
102+
],
103+
outputDir: './merged',
104+
outputName: 'all-cases',
105+
});
106+
console.log(mergedResult.mergedReportPath);
82107
```
83108

84-
`splitReportFile` and `reportFileToMarkdown` serve different outputs:
109+
`splitReportFile`, `reportFileToMarkdown`, and `mergeReportFiles` serve different outputs:
85110

86111
- `splitReportFile` generates JSON files for the original structured objects (one `*.execution.json` per execution). The JSON keeps the raw `ReportActionDump`-style data and exports screenshots alongside it. The returned `executionJsonFiles` and `screenshotFiles` are lists of generated file paths.
87112
- `reportFileToMarkdown` converts the same report into human-readable Markdown and exports the screenshots referenced by that Markdown. The returned `markdownFiles` contains the generated Markdown file paths.
113+
- `mergeReportFiles` combines several report files into one merged HTML report. It is a thin wrapper over [`ReportMergingTool`](./api#new-reportmergingtool) that derives `testTitle`/`testDescription` from each source report's `groupName` automatically. Use it when you run multiple CLI actions or tests and want to consolidate their reports.
88114

89-
Use `splitReportFile` when you want the most complete, programmatic raw data. Use `reportFileToMarkdown` when you want readable content for summarization or downstream content generation.
90115

91116
## About Fields In JSON And Markdown
92117

apps/site/docs/zh/consume-report-file.md

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,27 @@ npx @midscene/web report-tool --action split --htmlPath ./midscene_run/report/pu
6161
npx @midscene/web report-tool --action to-markdown --htmlPath ./midscene_run/report/puppeteer-2026/index.html --outputDir ./output-markdown
6262
```
6363

64+
将多个报告文件合并为一份汇总报告:
65+
66+
```shell
67+
npx @midscene/web report-tool --action merge-html \
68+
--htmlReport ./midscene_run/report/case-a/index.html \
69+
--htmlReport ./midscene_run/report/case-b.html \
70+
--outputDir ./merged --outputName all-cases
71+
```
72+
73+
每多合并一份报告就重复一次 `--htmlReport``--outputDir``--outputName` 都是可选项,留空时合并后的报告会写入 Midscene 默认的报告目录、并生成自动文件名。已存在同名文件时使用 `--overwrite` 进行覆盖。
74+
6475
## 使用 JavaScript SDK 解析
6576

66-
如果你希望在代码里控制报告解析,可以使用 `@midscene/core` 提供的 `splitReportFile``reportFileToMarkdown`
77+
如果你希望在代码里控制报告解析,可以使用 `@midscene/core` 提供的 `splitReportFile``reportFileToMarkdown``mergeReportFiles`
6778

6879
```ts
69-
import { reportFileToMarkdown, splitReportFile } from '@midscene/core';
80+
import {
81+
mergeReportFiles,
82+
reportFileToMarkdown,
83+
splitReportFile,
84+
} from '@midscene/core';
7085

7186
const splitResult = splitReportFile({
7287
htmlPath: './midscene_run/report/puppeteer-2026/index.html',
@@ -79,14 +94,24 @@ const markdownResult = await reportFileToMarkdown({
7994
outputDir: './output-markdown',
8095
});
8196
console.log(markdownResult.markdownFiles);
97+
98+
const mergedResult = mergeReportFiles({
99+
htmlPaths: [
100+
'./midscene_run/report/case-a/index.html',
101+
'./midscene_run/report/case-b.html',
102+
],
103+
outputDir: './merged',
104+
outputName: 'all-cases',
105+
});
106+
console.log(mergedResult.mergedReportPath);
82107
```
83108

84-
`splitReportFile``reportFileToMarkdown` 的用途不同:
109+
`splitReportFile``reportFileToMarkdown``mergeReportFiles` 的用途不同:
85110

86111
- `splitReportFile` 会产出“原始对象”对应的 JSON 文件(每个 execution 一个 `*.execution.json`),内容是 `ReportActionDump` 的原始结构化数据,同时会导出截图文件。返回值中的 `executionJsonFiles``screenshotFiles` 都是生成后的文件路径列表。
87112
- `reportFileToMarkdown` 会把同一份报告转成更易读、便于给其他工具继续处理的 Markdown 文本,并导出 Markdown 里引用到的截图。返回值里的 `markdownFiles` 对应 Markdown 文件路径。
113+
- `mergeReportFiles` 会把多份报告合并成一份汇总 HTML 报告,是 [`ReportMergingTool`](./api#new-reportmergingtool) 的轻量封装:会自动从每份源报告里读取 `groupName` 作为 `testTitle`/`testDescription`,省去了手工准备 `reportAttributes` 的步骤。命令行多次调用或多个测试用例产生多份报告后,使用它进行汇总最为合适。
88114

89-
如果你想保留最完整、可编程处理的数据,优先使用 `splitReportFile`;如果你想直接阅读、总结,或用于二次生成(例如生成视频脚本),优先使用 `reportFileToMarkdown`
90115

91116
## 关于 JSON 和 Markdown 的内容字段
92117

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,14 @@ export {
8484
createReportCliCommands,
8585
reportFileToMarkdown,
8686
splitReportFile,
87+
mergeReportFiles,
8788
type ConsumeReportFileAction,
8889
type ReportFileToMarkdownOptions,
8990
type ReportCliCommandDefinition,
9091
type ReportCliCommandEntry,
9192
type SplitReportFileOptions,
93+
type MergeReportFilesOptions,
94+
type MergeReportFilesResult,
9295
} from './report-cli';
9396

9497
// ScreenshotItem

packages/core/src/report-cli.ts

Lines changed: 155 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@ import {
88
import * as path from 'node:path';
99
import { z } from 'zod';
1010
import { resolveScreenshotSource } from './dump/screenshot-store';
11-
import { collectDedupedExecutions, splitReportHtmlByExecution } from './report';
11+
import {
12+
ReportMergingTool,
13+
collectDedupedExecutions,
14+
splitReportHtmlByExecution,
15+
} from './report';
1216
import { reportToMarkdown } from './report-markdown';
1317
import type { MarkdownAttachment } from './report-markdown';
18+
import type { ReportFileAttributes, TestStatus } from './types';
1419
import { ReportActionDump } from './types';
1520

1621
type ReportCliToolResult = {
@@ -35,7 +40,7 @@ export interface ReportCliCommandEntry {
3540
def: ReportCliCommandDefinition;
3641
}
3742

38-
export type ConsumeReportFileAction = 'split' | 'to-markdown';
43+
export type ConsumeReportFileAction = 'split' | 'to-markdown' | 'merge-html';
3944

4045
export interface ConsumeReportFileOptions {
4146
htmlPath: string;
@@ -45,6 +50,17 @@ export interface ConsumeReportFileOptions {
4550
export type SplitReportFileOptions = ConsumeReportFileOptions;
4651
export type ReportFileToMarkdownOptions = ConsumeReportFileOptions;
4752

53+
export interface MergeReportFilesOptions {
54+
htmlPaths: string[];
55+
outputDir?: string;
56+
outputName?: string;
57+
overwrite?: boolean;
58+
}
59+
60+
export interface MergeReportFilesResult {
61+
mergedReportPath: string;
62+
}
63+
4864
function writeAttachmentFromReport(
4965
attachment: MarkdownAttachment,
5066
opts: {
@@ -200,42 +216,172 @@ export async function reportFileToMarkdown(
200216
return markdownFromReport(resolvedHtmlPath, outputDir);
201217
}
202218

219+
function deriveReportAttributesFromHtml(
220+
htmlPath: string,
221+
index: number,
222+
): ReportFileAttributes {
223+
const fallbackId = `${path.basename(path.dirname(htmlPath)) || path.basename(htmlPath, path.extname(htmlPath))}-${index + 1}`;
224+
try {
225+
const { baseDump } = collectDedupedExecutions(htmlPath);
226+
return {
227+
testId: fallbackId,
228+
testTitle: baseDump.groupName || fallbackId,
229+
testDescription: baseDump.groupDescription ?? '',
230+
testDuration: 0,
231+
testStatus: 'passed' as TestStatus,
232+
};
233+
} catch {
234+
return {
235+
testId: fallbackId,
236+
testTitle: fallbackId,
237+
testDescription: '',
238+
testDuration: 0,
239+
testStatus: 'passed' as TestStatus,
240+
};
241+
}
242+
}
243+
244+
export function mergeReportFiles(
245+
options: MergeReportFilesOptions,
246+
): MergeReportFilesResult {
247+
const { htmlPaths, outputDir, outputName, overwrite = false } = options;
248+
if (!htmlPaths || htmlPaths.length === 0) {
249+
throw new Error('mergeReportFiles: htmlPaths is required');
250+
}
251+
252+
const resolvedPaths = htmlPaths.map((p) => resolveReportHtmlPath(p));
253+
254+
const tool = new ReportMergingTool();
255+
resolvedPaths.forEach((htmlPath, index) => {
256+
tool.append({
257+
reportFilePath: htmlPath,
258+
reportAttributes: deriveReportAttributesFromHtml(htmlPath, index),
259+
});
260+
});
261+
262+
const mergedReportPath = tool.mergeReports(outputName ?? 'AUTO', {
263+
overwrite,
264+
outputDir,
265+
});
266+
267+
if (!mergedReportPath) {
268+
throw new Error('mergeReportFiles: failed to produce a merged report');
269+
}
270+
271+
return { mergedReportPath };
272+
}
273+
274+
function normalizeHtmlReportArg(raw: unknown): string[] | undefined {
275+
if (raw === undefined || raw === null) return undefined;
276+
if (Array.isArray(raw)) {
277+
return raw.filter(
278+
(p): p is string => typeof p === 'string' && p.length > 0,
279+
);
280+
}
281+
if (typeof raw === 'string') {
282+
const trimmed = raw.trim();
283+
return trimmed ? [trimmed] : [];
284+
}
285+
return undefined;
286+
}
287+
203288
const reportCommandDefinition: ReportCliCommandDefinition = {
204289
name: 'report-tool',
205290
description:
206-
'Transform Midscene report artifacts, including splitting executions and converting to markdown.',
291+
'Transform Midscene report artifacts, including splitting executions, converting to markdown, and merging multiple reports.',
207292
schema: {
208293
action: z
209-
.enum(['split', 'to-markdown'])
294+
.enum(['split', 'to-markdown', 'merge-html'])
210295
.optional()
211296
.describe(
212-
'Report action to run. Supports: split, to-markdown. Defaults to split.',
297+
'Report action to run. Supports: split, to-markdown, merge-html. Defaults to split.',
213298
),
214299
htmlPath: z
215300
.string()
216301
.optional()
217-
.describe('Input report HTML path (e.g. ./report/index.html)'),
302+
.describe(
303+
'Input report HTML path (e.g. ./report/index.html). Used by split and to-markdown.',
304+
),
305+
htmlReport: z
306+
.union([z.string(), z.array(z.string())])
307+
.optional()
308+
.describe(
309+
'Input report HTML path for the merge action. Repeat the flag to merge multiple reports (e.g. --htmlReport ./a/index.html --htmlReport ./b.html).',
310+
),
218311
outputDir: z
219312
.string()
220313
.optional()
221-
.describe('Output directory for generated report artifacts'),
314+
.describe(
315+
'Output directory for generated report artifacts. For merge, defaults to the Midscene report directory.',
316+
),
317+
outputName: z
318+
.string()
319+
.optional()
320+
.describe(
321+
'Output report file/directory name (without .html) for the merge action. Defaults to an auto-generated name.',
322+
),
323+
overwrite: z
324+
.union([z.boolean(), z.string()])
325+
.optional()
326+
.describe(
327+
'Overwrite the existing merged report file if present (merge action only).',
328+
),
222329
},
223330
handler: async (args) => {
224331
const {
225332
action = 'split',
226333
htmlPath,
334+
htmlReport,
227335
outputDir,
336+
outputName,
337+
overwrite,
228338
} = args as {
229339
action?: string;
230340
htmlPath?: string;
341+
htmlReport?: unknown;
231342
outputDir?: string;
343+
outputName?: string;
344+
overwrite?: unknown;
232345
};
233-
if (action !== 'split' && action !== 'to-markdown') {
346+
if (
347+
action !== 'split' &&
348+
action !== 'to-markdown' &&
349+
action !== 'merge-html'
350+
) {
234351
throw new Error(
235-
`report-tool: unsupported --action value "${action}". Currently supported: split, to-markdown`,
352+
`report-tool: unsupported --action value "${action}". Currently supported: split, to-markdown, merge-html`,
236353
);
237354
}
238355

356+
if (action === 'merge-html') {
357+
const paths = normalizeHtmlReportArg(htmlReport);
358+
if (!paths || paths.length === 0) {
359+
throw new Error(
360+
'report-tool: --htmlReport is required for action "merge-html". Repeat --htmlReport for each report (e.g. --htmlReport ./a/index.html --htmlReport ./b.html).',
361+
);
362+
}
363+
364+
const overwriteFlag =
365+
overwrite === true || overwrite === 'true' || overwrite === '1';
366+
367+
const result = mergeReportFiles({
368+
htmlPaths: paths,
369+
outputDir,
370+
outputName,
371+
overwrite: overwriteFlag,
372+
});
373+
374+
return {
375+
isError: false,
376+
content: [
377+
{
378+
type: 'text',
379+
text: `Merged ${paths.length} report(s) into ${result.mergedReportPath}`,
380+
},
381+
],
382+
};
383+
}
384+
239385
if (!htmlPath) {
240386
throw new Error('report-tool: --htmlPath is required');
241387
}

packages/core/src/report.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,16 +142,26 @@ export class ReportMergingTool {
142142
opts?: {
143143
rmOriginalReports?: boolean;
144144
overwrite?: boolean;
145+
outputDir?: string;
145146
},
146147
): string | null {
147-
const { rmOriginalReports = false, overwrite = false } = opts ?? {};
148+
const {
149+
rmOriginalReports = false,
150+
overwrite = false,
151+
outputDir,
152+
} = opts ?? {};
148153

149154
if (this.reportInfos.length === 0) {
150155
logMsg('No reports to merge');
151156
return null;
152157
}
153158

154-
const targetDir = getMidsceneRunSubDir('report');
159+
const targetDir = outputDir
160+
? path.resolve(outputDir)
161+
: getMidsceneRunSubDir('report');
162+
if (outputDir) {
163+
mkdirSync(targetDir, { recursive: true });
164+
}
155165

156166
// Check if any source report is directory mode
157167
const hasDirectoryModeReport = this.reportInfos.some((info) => {

0 commit comments

Comments
 (0)