feat(core): use append instead string join (#651)

* feat(utils): optimize reportHTMLContent to handle large data with Buffer and newlines

* feat(report): enhance test data handling and filtering in PlaywrightCaseSelector

* feat(report): enhance reportHTMLContent to support file writing and improved dump data handling

* fix(core): correct file writing logic and improve dump data handling in reportHTMLContent

* feat(core): add tests for reportHTMLContent

* feat(core): add performance test for handling multiple large reports in utils
This commit is contained in:
Leyang 2025-04-28 14:51:19 +08:00 committed by GitHub
parent c4112adb51
commit e93bc20cf1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 285 additions and 66 deletions

View File

@ -5,8 +5,19 @@ import { pluginLess } from '@rsbuild/plugin-less';
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill'; import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill';
import { pluginReact } from '@rsbuild/plugin-react'; import { pluginReact } from '@rsbuild/plugin-react';
const testDataPath = path.join(__dirname, 'test-data', 'swag-lab.json'); // Read all JSON files from test-data directory
const testData = JSON.parse(fs.readFileSync(testDataPath, 'utf-8')); const testDataDir = path.join(__dirname, 'test-data');
const jsonFiles = fs
.readdirSync(testDataDir)
.filter((file) => file.endsWith('.json'));
const allTestData = jsonFiles.map((file) => {
const filePath = path.join(testDataDir, file);
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
return {
fileName: file,
data,
};
});
const copyReportTemplate = () => ({ const copyReportTemplate = () => ({
name: 'copy-report-template', name: 'copy-report-template',
@ -34,21 +45,19 @@ export default defineConfig({
inject: 'body', inject: 'body',
tags: tags:
process.env.NODE_ENV === 'development' process.env.NODE_ENV === 'development'
? [ ? allTestData.map((item) => ({
{ tag: 'script',
tag: 'script', attrs: {
attrs: { type: 'midscene_web_dump',
type: 'midscene_web_dump', playwright_test_name: item.data.groupName,
playwright_test_name: testData.groupName, playwright_test_description: item.data.groupDescription,
playwright_test_description: testData.groupDescription, playwright_test_id: '8465e854a4d9a753cc87-1f096ece43c67754f95a',
playwright_test_id: '8465e854a4d9a753cc87-1f096ece43c67754f95a', playwright_test_title: 'test open new tab',
playwright_test_title: 'test open new tab', playwright_test_status: 'passed',
playwright_test_status: 'passed', playwright_test_duration: '44274',
playwright_test_duration: '44274',
},
children: JSON.stringify(testData),
}, },
] children: JSON.stringify(item.data),
}))
: [], : [],
}, },
resolve: { resolve: {

View File

@ -1,11 +1,21 @@
import { DownOutlined, SearchOutlined } from '@ant-design/icons'; import { DownOutlined, SearchOutlined } from '@ant-design/icons';
import type { GroupedActionDump } from '@midscene/core'; import type { GroupedActionDump } from '@midscene/core';
import { iconForStatus, timeCostStrElement } from '@midscene/visualizer'; import { iconForStatus, timeCostStrElement } from '@midscene/visualizer';
import { Dropdown, Input } from 'antd'; import { Dropdown, Input, Select, Space } from 'antd';
import type React from 'react'; import type React from 'react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ExecutionDumpWithPlaywrightAttributes } from '../types'; import type { ExecutionDumpWithPlaywrightAttributes } from '../types';
// define all possible test statuses
const TEST_STATUS_OPTIONS = [
{ value: 'all', label: 'All' },
{ value: 'passed', label: 'Passed' },
{ value: 'failed', label: 'Failed' },
{ value: 'skipped', label: 'Skipped' },
{ value: 'timedOut', label: 'Timed Out' },
{ value: 'interrupted', label: 'Interrupted' },
];
interface PlaywrightCaseSelectorProps { interface PlaywrightCaseSelectorProps {
dumps?: ExecutionDumpWithPlaywrightAttributes[]; dumps?: ExecutionDumpWithPlaywrightAttributes[];
selected?: GroupedActionDump | null; selected?: GroupedActionDump | null;
@ -20,6 +30,7 @@ export function PlaywrightCaseSelector({
if (!dumps || dumps.length <= 1) return null; if (!dumps || dumps.length <= 1) return null;
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [dropdownVisible, setDropdownVisible] = useState(false); const [dropdownVisible, setDropdownVisible] = useState(false);
const nameForDump = (dump: GroupedActionDump) => const nameForDump = (dump: GroupedActionDump) =>
@ -49,11 +60,24 @@ export function PlaywrightCaseSelector({
}; };
const filteredDumps = useMemo(() => { const filteredDumps = useMemo(() => {
if (!searchText) return dumps || []; let result = dumps || [];
return (dumps || []).filter((dump) =>
nameForDump(dump).toLowerCase().includes(searchText.toLowerCase()), // apply text filter
); if (searchText) {
}, [dumps, searchText]); result = result.filter((dump) =>
nameForDump(dump).toLowerCase().includes(searchText.toLowerCase()),
);
}
// apply status filter
if (statusFilter !== 'all') {
result = result.filter(
(dump) => dump.attributes?.playwright_test_status === statusFilter,
);
}
return result;
}, [dumps, searchText, statusFilter]);
const items = filteredDumps.map((dump, index) => { const items = filteredDumps.map((dump, index) => {
return { return {
@ -85,17 +109,30 @@ export function PlaywrightCaseSelector({
setSearchText(e.target.value); setSearchText(e.target.value);
}; };
const handleStatusChange = (value: string) => {
setStatusFilter(value);
};
const dropdownRender = (menu: React.ReactNode) => ( const dropdownRender = (menu: React.ReactNode) => (
<div> <div>
<div style={{ padding: '8px' }}> <div style={{ padding: '8px' }}>
<Input <Space style={{ width: '100%' }}>
placeholder="Search test case" <Input
value={searchText} placeholder="Search test case"
onChange={handleSearchChange} value={searchText}
prefix={<SearchOutlined />} onChange={handleSearchChange}
allowClear prefix={<SearchOutlined />}
autoFocus allowClear
/> autoFocus
style={{ flex: 1 }}
/>
<Select
value={statusFilter}
onChange={handleStatusChange}
style={{ width: 120 }}
options={TEST_STATUS_OPTIONS}
/>
</Space>
</div> </div>
{menu} {menu}
</div> </div>

View File

@ -71,6 +71,7 @@ export function replaceStringWithFirstAppearance(
export function reportHTMLContent( export function reportHTMLContent(
dumpData: string | ReportDumpWithAttributes[], dumpData: string | ReportDumpWithAttributes[],
reportPath?: string,
): string { ): string {
const tpl = getReportTpl(); const tpl = getReportTpl();
if (!tpl) { if (!tpl) {
@ -78,46 +79,90 @@ export function reportHTMLContent(
return ''; return '';
} }
let reportContent: string; const dumpPlaceholder = '{{dump}}';
// verify the template contains the placeholder
if (!tpl.includes(dumpPlaceholder)) {
console.warn('Template does not contain {{dump}} placeholder');
return '';
}
// find the first placeholder position
const placeholderIndex = tpl.indexOf(dumpPlaceholder);
// split the template into two parts before and after the placeholder
const firstPart = tpl.substring(0, placeholderIndex);
const secondPart = tpl.substring(placeholderIndex + dumpPlaceholder.length);
// if reportPath is set, it means we are in write to file mode
const writeToFile = reportPath && !ifInBrowser;
let resultContent = '';
// helper function: decide to write to file or append to resultContent
const appendOrWrite = (content: string): void => {
if (writeToFile) {
writeFileSync(reportPath!, `${content}\n`, {
flag: 'a',
});
} else {
resultContent += `${content}\n`;
}
};
// if writeToFile is true, write the first part to file, otherwise set the first part to the initial value of resultContent
if (writeToFile) {
writeFileSync(reportPath!, firstPart, { flag: 'w' }); // use 'w' flag to overwrite the existing file
} else {
resultContent = firstPart;
}
// generate dump content
// handle empty data or undefined
if ( if (
(Array.isArray(dumpData) && dumpData.length === 0) || (Array.isArray(dumpData) && dumpData.length === 0) ||
typeof dumpData === 'undefined' typeof dumpData === 'undefined'
) { ) {
reportContent = replaceStringWithFirstAppearance( const dumpContent =
tpl, '<script type="midscene_web_dump" type="application/json"></script>';
'{{dump}}', appendOrWrite(dumpContent);
'<script type="midscene_web_dump" type="application/json"></script>', }
); // handle string type dumpData
} else if (typeof dumpData === 'string') { else if (typeof dumpData === 'string') {
reportContent = replaceStringWithFirstAppearance( const dumpContent =
tpl, // biome-ignore lint/style/useTemplate: <explanation> do not use template string here, will cause bundle error
'{{dump}}',
// biome-ignore lint/style/useTemplate: <explanation>
'<script type="midscene_web_dump" type="application/json">\n' + '<script type="midscene_web_dump" type="application/json">\n' +
dumpData + dumpData +
'\n</script>', '\n</script>';
); appendOrWrite(dumpContent);
} else { }
const dumps = dumpData.map(({ dumpString, attributes }) => { // handle array type dumpData
else {
// for array, handle each item
for (let i = 0; i < dumpData.length; i++) {
const { dumpString, attributes } = dumpData[i];
const attributesArr = Object.keys(attributes || {}).map((key) => { const attributesArr = Object.keys(attributes || {}).map((key) => {
return `${key}="${encodeURIComponent(attributes![key])}"`; return `${key}="${encodeURIComponent(attributes![key])}"`;
}); });
return (
// biome-ignore lint/style/useTemplate: <explanation> const dumpContent =
// biome-ignore lint/style/useTemplate: <explanation> do not use template string here, will cause bundle error
'<script type="midscene_web_dump" type="application/json" ' + '<script type="midscene_web_dump" type="application/json" ' +
attributesArr.join(' ') + attributesArr.join(' ') +
'>\n' + '>\n' +
dumpString + dumpString +
'\n</script>' '\n</script>';
); appendOrWrite(dumpContent);
}); }
reportContent = replaceStringWithFirstAppearance(
tpl,
'{{dump}}',
dumps.join('\n'),
);
} }
return reportContent;
// add the second part
if (writeToFile) {
writeFileSync(reportPath!, secondPart, { flag: 'a' });
return reportPath!;
}
resultContent += secondPart;
return resultContent;
} }
export function writeDumpReport( export function writeDumpReport(
@ -140,12 +185,9 @@ export function writeDumpReport(
getMidsceneRunSubDir('report'), getMidsceneRunSubDir('report'),
`${fileName}.html`, `${fileName}.html`,
); );
const reportContent = reportHTMLContent(dumpData);
if (!reportContent) { reportHTMLContent(dumpData, reportPath);
console.warn('reportContent is empty, will not write report');
return null;
}
writeFileSync(reportPath, reportContent);
if (process.env.MIDSCENE_DEBUG_LOG_JSON) { if (process.env.MIDSCENE_DEBUG_LOG_JSON) {
writeFileSync( writeFileSync(
`${reportPath}.json`, `${reportPath}.json`,

View File

@ -1,9 +1,7 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { readFileSync } from 'node:fs'; import { existsSync, readFileSync, statSync } from 'node:fs';
import { import {
adaptBboxToRect,
adaptDoubaoBbox, adaptDoubaoBbox,
adaptGeminiBbox,
adaptQwenBbox, adaptQwenBbox,
expandSearchArea, expandSearchArea,
mergeRects, mergeRects,
@ -96,6 +94,139 @@ describe('utils', () => {
`<script type="midscene_web_dump" type="application/json">\n${content}\n</script>`, `<script type="midscene_web_dump" type="application/json">\n${content}\n</script>`,
); );
}); });
it('reportHTMLContent with reportPath', () => {
const tmpFile = getTmpFile('html');
expect(tmpFile).toBeTruthy();
if (!tmpFile) {
return;
}
// test empty array
const reportPathA = reportHTMLContent([], tmpFile);
expect(reportPathA).toBe(tmpFile);
const fileContentA = readFileSync(tmpFile, 'utf-8');
expect(fileContentA).toContain(
'<script type="midscene_web_dump" type="application/json"></script>',
);
// test string content
const content = JSON.stringify({ test: randomUUID() });
const reportPathB = reportHTMLContent(content, tmpFile);
expect(reportPathB).toBe(tmpFile);
const fileContentB = readFileSync(tmpFile, 'utf-8');
expect(fileContentB).toContain(
`<script type="midscene_web_dump" type="application/json">\n${content}\n</script>`,
);
// test array with attributes
const uuid1 = randomUUID();
const uuid2 = randomUUID();
const dumpArray = [
{
dumpString: JSON.stringify({ id: uuid1 }),
attributes: {
test_attr: 'test_value',
another_attr: 'another_value',
},
},
{
dumpString: JSON.stringify({ id: uuid2 }),
attributes: {
test_attr2: 'test_value2',
},
},
];
const reportPathC = reportHTMLContent(dumpArray, tmpFile);
expect(reportPathC).toBe(tmpFile);
const fileContentC = readFileSync(tmpFile, 'utf-8');
// verify the file content contains attributes and data
expect(fileContentC).toContain('test_attr="test_value"');
expect(fileContentC).toContain('another_attr="another_value"');
expect(fileContentC).toContain('test_attr2="test_value2"');
expect(fileContentC).toContain(uuid1);
expect(fileContentC).toContain(uuid2);
});
it('should handle multiple large reports correctly', () => {
const tmpFile = getTmpFile('html');
expect(tmpFile).toBeTruthy();
if (!tmpFile) {
return;
}
// Create a large string of approximately 100MB
const generateLargeString = (sizeInMB: number, identifier: string) => {
const approximateCharsPer1MB = 1024 * 1024; // 1MB in characters
const totalChars = approximateCharsPer1MB * sizeInMB;
// Create a basic JSON structure with a very large string
const baseObj = {
id: identifier,
timestamp: new Date().toISOString(),
data: 'X'.repeat(totalChars - 100), // subtract a small amount for the JSON structure
};
return JSON.stringify(baseObj);
};
// Monitor memory usage
const startMemory = process.memoryUsage();
console.log(
'Memory usage before test:',
`RSS: ${Math.round(startMemory.rss / 1024 / 1024)}MB, ` +
`Heap Total: ${Math.round(startMemory.heapTotal / 1024 / 1024)}MB, ` +
`Heap Used: ${Math.round(startMemory.heapUsed / 1024 / 1024)}MB`,
);
// Store start time
const startTime = Date.now();
// Generate 10 large reports (each ~100MB)
const numberOfReports = 10;
const dumpArray = Array.from({ length: numberOfReports }).map(
(_, index) => ({
dumpString: generateLargeString(100, `large-report-${index + 1}`),
attributes: {
report_number: `${index + 1}`,
report_size: '100MB',
},
}),
);
// Write the large reports
const reportPath = reportHTMLContent(dumpArray, tmpFile);
expect(reportPath).toBe(tmpFile);
// Calculate execution time
const executionTime = Date.now() - startTime;
console.log(`Execution time: ${executionTime}ms`);
// Check memory usage after test
const endMemory = process.memoryUsage();
console.log(
'Memory usage after test:',
`RSS: ${Math.round(endMemory.rss / 1024 / 1024)}MB, ` +
`Heap Total: ${Math.round(endMemory.heapTotal / 1024 / 1024)}MB, ` +
`Heap Used: ${Math.round(endMemory.heapUsed / 1024 / 1024)}MB`,
);
// Check if file exists
expect(existsSync(tmpFile)).toBe(true);
// Verify file size is approximately (100MB * 10) + template size
const stats = statSync(tmpFile);
const fileSizeInMB = stats.size / (1024 * 1024);
console.log(`File size: ${fileSizeInMB.toFixed(2)}MB`);
// We expect the file to be approximately 700MB plus template overhead
const expectedMinSize = 1000; // 10 reports × 100MB
expect(fileSizeInMB).toBeGreaterThan(expectedMinSize);
});
}); });
describe('extractJSONFromCodeBlock', () => { describe('extractJSONFromCodeBlock', () => {