Leyang e93bc20cf1
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
2025-04-28 14:51:19 +08:00

374 lines
10 KiB
TypeScript

import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { dirname } from 'node:path';
import {
defaultRunDirName,
getMidsceneRunSubDir,
logDir,
} from '@midscene/shared/common';
import {
MIDSCENE_DEBUG_MODE,
MIDSCENE_OPENAI_INIT_CONFIG_JSON,
getAIConfig,
getAIConfigInJson,
} from '@midscene/shared/env';
import { getRunningPkgInfo } from '@midscene/shared/fs';
import { assert, getGlobalScope } from '@midscene/shared/utils';
import { ifInBrowser, uuid } from '@midscene/shared/utils';
import type { Rect, ReportDumpWithAttributes } from './types';
let logEnvReady = false;
export const groupedActionDumpFileExt = 'web-dump.json';
export function getLogDir() {
return logDir;
}
let reportTpl: string | null = null;
export function setReportTpl(tpl: string) {
reportTpl = tpl;
}
function getReportTpl() {
const globalScope = getGlobalScope();
if (
!reportTpl &&
globalScope &&
typeof (globalScope as any).get_midscene_report_tpl === 'function'
) {
reportTpl = (globalScope as any).get_midscene_report_tpl();
return reportTpl;
}
const __dirname = dirname(__filename);
if (!reportTpl && !ifInBrowser) {
const possiblePaths = [
path.join(__dirname, '../../report/index.html'),
path.join(__dirname, '../report/index.html'),
];
for (const reportPath of possiblePaths) {
if (existsSync(reportPath)) {
reportTpl = readFileSync(reportPath, 'utf-8');
break;
}
}
}
return reportTpl;
}
export function replaceStringWithFirstAppearance(
str: string,
target: string,
replacement: string,
) {
const index = str.indexOf(target);
return str.slice(0, index) + replacement + str.slice(index + target.length);
}
export function reportHTMLContent(
dumpData: string | ReportDumpWithAttributes[],
reportPath?: string,
): string {
const tpl = getReportTpl();
if (!tpl) {
console.warn('reportTpl is not set, will not write report');
return '';
}
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 (
(Array.isArray(dumpData) && dumpData.length === 0) ||
typeof dumpData === 'undefined'
) {
const dumpContent =
'<script type="midscene_web_dump" type="application/json"></script>';
appendOrWrite(dumpContent);
}
// handle string type dumpData
else if (typeof dumpData === 'string') {
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">\n' +
dumpData +
'\n</script>';
appendOrWrite(dumpContent);
}
// 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) => {
return `${key}="${encodeURIComponent(attributes![key])}"`;
});
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" ' +
attributesArr.join(' ') +
'>\n' +
dumpString +
'\n</script>';
appendOrWrite(dumpContent);
}
}
// add the second part
if (writeToFile) {
writeFileSync(reportPath!, secondPart, { flag: 'a' });
return reportPath!;
}
resultContent += secondPart;
return resultContent;
}
export function writeDumpReport(
fileName: string,
dumpData: string | ReportDumpWithAttributes[],
): string | null {
if (ifInBrowser) {
console.log('will not write report in browser');
return null;
}
const __dirname = dirname(__filename);
const midscenePkgInfo = getRunningPkgInfo(__dirname);
if (!midscenePkgInfo) {
console.warn('midscenePkgInfo not found, will not write report');
return null;
}
const reportPath = path.join(
getMidsceneRunSubDir('report'),
`${fileName}.html`,
);
reportHTMLContent(dumpData, reportPath);
if (process.env.MIDSCENE_DEBUG_LOG_JSON) {
writeFileSync(
`${reportPath}.json`,
typeof dumpData === 'string'
? dumpData
: JSON.stringify(dumpData, null, 2),
);
}
return reportPath;
}
export function writeLogFile(opts: {
fileName: string;
fileExt: string;
fileContent: string;
type: 'dump' | 'cache' | 'report' | 'tmp';
generateReport?: boolean;
}) {
if (ifInBrowser) {
return '/mock/report.html';
}
const { fileName, fileExt, fileContent, type = 'dump' } = opts;
const targetDir = getMidsceneRunSubDir(type);
// Ensure directory exists
if (!logEnvReady) {
assert(targetDir, 'logDir should be set before writing dump file');
// gitIgnore in the parent directory
const gitIgnorePath = path.join(targetDir, '../../.gitignore');
const gitPath = path.join(targetDir, '../../.git');
let gitIgnoreContent = '';
if (existsSync(gitPath)) {
// if the git path exists, we need to add the log folder to the git ignore file
if (existsSync(gitIgnorePath)) {
gitIgnoreContent = readFileSync(gitIgnorePath, 'utf-8');
}
// ignore the log folder
if (!gitIgnoreContent.includes(`${defaultRunDirName}/`)) {
writeFileSync(
gitIgnorePath,
`${gitIgnoreContent}\n# Midscene.js dump files\n${defaultRunDirName}/dump\n${defaultRunDirName}/report\n${defaultRunDirName}/tmp\n${defaultRunDirName}/log\n`,
'utf-8',
);
}
}
logEnvReady = true;
}
const filePath = path.join(targetDir, `${fileName}.${fileExt}`);
if (type !== 'dump') {
// do not write dump file any more
writeFileSync(filePath, fileContent);
}
if (opts?.generateReport) {
return writeDumpReport(fileName, fileContent);
}
return filePath;
}
export function getTmpDir(): string | null {
try {
const runningPkgInfo = getRunningPkgInfo();
if (!runningPkgInfo) {
return null;
}
const { name } = runningPkgInfo;
const tmpPath = path.join(tmpdir(), name);
mkdirSync(tmpPath, { recursive: true });
return tmpPath;
} catch (e) {
return null;
}
}
export function getTmpFile(fileExtWithoutDot: string): string | null {
if (ifInBrowser) {
return null;
}
const tmpDir = getTmpDir();
const filename = `${uuid()}.${fileExtWithoutDot}`;
return path.join(tmpDir!, filename);
}
export function overlapped(container: Rect, target: Rect) {
// container and the target have some part overlapped
return (
container.left < target.left + target.width &&
container.left + container.width > target.left &&
container.top < target.top + target.height &&
container.top + container.height > target.top
);
}
export async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function replacerForPageObject(key: string, value: any) {
if (value && value.constructor?.name === 'Page') {
return '[Page object]';
}
if (value && value.constructor?.name === 'Browser') {
return '[Browser object]';
}
return value;
}
export function stringifyDumpData(data: any, indents?: number) {
return JSON.stringify(data, replacerForPageObject, indents);
}
declare const __VERSION__: string;
export function getVersion() {
return __VERSION__;
}
function debugLog(...message: any[]) {
const debugMode = getAIConfig(MIDSCENE_DEBUG_MODE);
if (debugMode) {
console.log('[Midscene]', ...message);
}
}
let lastReportedRepoUrl = '';
export function uploadTestInfoToServer({ testUrl }: { testUrl: string }) {
let repoUrl = '';
let userEmail = '';
const extraConfig = getAIConfigInJson(MIDSCENE_OPENAI_INIT_CONFIG_JSON);
const serverUrl = extraConfig?.REPORT_SERVER_URL;
try {
repoUrl = execSync('git config --get remote.origin.url').toString().trim();
userEmail = execSync('git config --get user.email').toString().trim();
} catch (error) {
debugLog('Failed to get git info:', error);
}
// Only upload test info if:
// 1. Server URL is configured AND
// 2. Either:
// - We have a repo URL that's different from last reported one (to avoid duplicate reports)
// - OR we don't have a repo URL but have a test URL (for non-git environments)
if (
serverUrl &&
((repoUrl && repoUrl !== lastReportedRepoUrl) || (!repoUrl && testUrl))
) {
debugLog('Uploading test info to server', {
serverUrl,
repoUrl,
testUrl,
userEmail,
});
fetch(serverUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
repo_url: repoUrl,
test_url: testUrl,
user_email: userEmail,
}),
})
.then((response) => response.json())
.then((data) => {
debugLog('Successfully uploaded test info to server:', data);
})
.catch((error) =>
debugLog('Failed to upload test info to server:', error),
);
lastReportedRepoUrl = repoUrl;
}
}