mirror of
https://github.com/web-infra-dev/midscene.git
synced 2025-12-14 16:47:22 +00:00
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:
parent
c4112adb51
commit
e93bc20cf1
@ -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: testData.groupName,
|
playwright_test_name: item.data.groupName,
|
||||||
playwright_test_description: testData.groupDescription,
|
playwright_test_description: item.data.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: {
|
||||||
|
|||||||
@ -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) =>
|
|
||||||
|
// apply text filter
|
||||||
|
if (searchText) {
|
||||||
|
result = result.filter((dump) =>
|
||||||
nameForDump(dump).toLowerCase().includes(searchText.toLowerCase()),
|
nameForDump(dump).toLowerCase().includes(searchText.toLowerCase()),
|
||||||
);
|
);
|
||||||
}, [dumps, searchText]);
|
}
|
||||||
|
|
||||||
|
// 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,9 +109,14 @@ 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' }}>
|
||||||
|
<Space style={{ width: '100%' }}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search test case"
|
placeholder="Search test case"
|
||||||
value={searchText}
|
value={searchText}
|
||||||
@ -95,7 +124,15 @@ export function PlaywrightCaseSelector({
|
|||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
allowClear
|
allowClear
|
||||||
autoFocus
|
autoFocus
|
||||||
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={handleStatusChange}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={TEST_STATUS_OPTIONS}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
{menu}
|
{menu}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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`,
|
||||||
|
|||||||
@ -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', () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user