From 1bfd4c10c4ee9e1d35f592c49fdd5797e10cf1bf Mon Sep 17 00:00:00 2001 From: tangjialei Date: Mon, 29 Sep 2025 19:45:28 +0800 Subject: [PATCH 1/3] feat(android): aggregate reports when running more than one test (#1163) --- packages/android/package.json | 3 +- .../report-aggregation/cases/setting1.test.ts | 59 ++++++++++++++++++ .../report-aggregation/cases/setting2.test.ts | 59 ++++++++++++++++++ .../tests/report-aggregation/gather-report.ts | 60 +++++++++++++++++++ .../tests/report-aggregation/run-tests.ts | 37 ++++++++++++ .../tests/report-aggregation/setup-test.ts | 25 ++++++++ packages/android/vitest.config.ts | 3 + packages/core/src/agent/agent.ts | 60 +++++++++++++------ packages/core/src/agent/index.ts | 1 + packages/core/src/utils.ts | 39 +++++++++++- 10 files changed, 325 insertions(+), 21 deletions(-) create mode 100644 packages/android/tests/report-aggregation/cases/setting1.test.ts create mode 100644 packages/android/tests/report-aggregation/cases/setting2.test.ts create mode 100644 packages/android/tests/report-aggregation/gather-report.ts create mode 100644 packages/android/tests/report-aggregation/run-tests.ts create mode 100644 packages/android/tests/report-aggregation/setup-test.ts diff --git a/packages/android/package.json b/packages/android/package.json index 808550629..0f280ffcc 100644 --- a/packages/android/package.json +++ b/packages/android/package.json @@ -29,7 +29,8 @@ "test": "vitest --run", "test:u": "vitest --run -u", "test:ai": "AI_TEST_TYPE=android npm run test", - "test:ai:cache": "MIDSCENE_CACHE=true AI_TEST_TYPE=android npm run test" + "test:ai:cache": "MIDSCENE_CACHE=true AI_TEST_TYPE=android npm run test", + "test:rep-agg": "AI_TEST_TYPE=report-aggregation npx tsx ./tests/report-aggregation/run-tests.ts" }, "dependencies": { "@midscene/core": "workspace:*", diff --git a/packages/android/tests/report-aggregation/cases/setting1.test.ts b/packages/android/tests/report-aggregation/cases/setting1.test.ts new file mode 100644 index 000000000..d41d0c694 --- /dev/null +++ b/packages/android/tests/report-aggregation/cases/setting1.test.ts @@ -0,0 +1,59 @@ + +import { sleep } from '@midscene/core/utils'; +import type { TestStatus } from '@midscene/core/agent'; +import { getMidsceneRunSubDir } from '@midscene/shared/common' +import { AndroidAgent, AndroidDevice, getConnectedDevices } from '@midscene/android'; +import { beforeAll, describe, it } from 'vitest'; + +const caseName = 'settings1'; + +describe(`${caseName}`, () => { + let agent: AndroidAgent; + let startTime: number; + let testStatus: TestStatus = 'passed' + beforeAll(async () => { + startTime = performance.now() + const devices = await getConnectedDevices(); + const page = new AndroidDevice(devices[0].udid); + agent = new AndroidAgent(page, { + groupName: `${caseName}`, + generateReport: false + }); + const adb = await page.getAdb(); + await adb.shell('input keyevent KEYCODE_HOME'); + await sleep(1000); + await adb.shell('am start -n com.android.settings/.Settings'); + await sleep(1000); + }); + + it( + 'switch wlan', + async (ctx) => { + ctx.onTestFinished((result) => { + // update status + console.log(result.task.result); + if (result.task.result?.state === 'pass') { + testStatus = "passed"; + } else if (result.task.result?.state === 'skip') { + testStatus = "skipped"; + } else if (result.task.result?.errors?.[0].message.includes("timed out")) { + testStatus = "timedOut"; + } else { + testStatus = 'failed'; + } + agent.teardownTestAgent({ + testId: `${caseName}`,//ID is a unique identifier used by the front end to distinguish each use case! + testTitle: `${caseName}`, + testDescription: 'desc', + testDuration: (performance.now() - startTime) | 0, + testStatus, + cacheFilePath: getMidsceneRunSubDir('cache') + "/cache_data" // setup-test.ts creates an empty cache file before all tests + }); + }); + + await agent.aiAction('find and enter WLAN setting'); + await agent.aiAction('toggle WLAN status, if WLAN is off pls turn it on, otherwise turn it off.'); + } + ); + +}); diff --git a/packages/android/tests/report-aggregation/cases/setting2.test.ts b/packages/android/tests/report-aggregation/cases/setting2.test.ts new file mode 100644 index 000000000..f809c6297 --- /dev/null +++ b/packages/android/tests/report-aggregation/cases/setting2.test.ts @@ -0,0 +1,59 @@ + +import { sleep } from '@midscene/core/utils'; +import type { TestStatus } from '@midscene/core/agent'; +import { getMidsceneRunSubDir } from '@midscene/shared/common' +import { AndroidAgent, AndroidDevice, getConnectedDevices } from '@midscene/android'; +import { beforeAll, describe, it } from 'vitest'; + +const caseName = 'settings2'; + +describe(`${caseName}`, () => { + let agent: AndroidAgent; + let startTime: number; + let testStatus: TestStatus = 'passed' + beforeAll(async () => { + startTime = performance.now() + const devices = await getConnectedDevices(); + const page = new AndroidDevice(devices[0].udid); + agent = new AndroidAgent(page, { + groupName: `${caseName}`, + generateReport: false + }); + const adb = await page.getAdb(); + await adb.shell('input keyevent KEYCODE_HOME'); + await sleep(1000); + await adb.shell('am start -n com.android.settings/.Settings'); + await sleep(1000); + }); + + it( + 'switch wlan', + async (ctx) => { + ctx.onTestFinished((result) => { + // update status + console.log(result.task.result); + if (result.task.result?.state === 'pass') { + testStatus = "passed"; + } else if (result.task.result?.state === 'skip') { + testStatus = "skipped"; + } else if (result.task.result?.errors?.[0].message.includes("timed out")) { + testStatus = "timedOut"; + } else { + testStatus = 'failed'; + } + agent.teardownTestAgent({ + testId: `${caseName}`,//ID is a unique identifier used by the front end to distinguish each use case! + testTitle: `${caseName}`, + testDescription: 'desc', + testDuration: (performance.now() - startTime) | 0, + testStatus, + cacheFilePath: getMidsceneRunSubDir('cache') + "/cache_data" // setup-test.ts creates an empty cache file before all tests + }); + }); + + await agent.aiAction('find and enter Bluetooth setting'); + await agent.aiAction('toggle Bluetooth status, if Bluetooth is off pls turn it on, otherwise turn it off.'); + } + ); + +}); diff --git a/packages/android/tests/report-aggregation/gather-report.ts b/packages/android/tests/report-aggregation/gather-report.ts new file mode 100644 index 000000000..0dc682ddd --- /dev/null +++ b/packages/android/tests/report-aggregation/gather-report.ts @@ -0,0 +1,60 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getMidsceneRunSubDir } from '@midscene/shared/common' + +const cacheDir = getMidsceneRunSubDir('cache'); +const cachePath = path.join(cacheDir, 'cache_data'); + +function getReportTpl() { + const __DEV_REPORT_PATH__ = path.resolve(__dirname, '../../../../apps/report/dist/index.html'); + if (typeof __DEV_REPORT_PATH__ === 'string' && __DEV_REPORT_PATH__) { + return fs.readFileSync(__DEV_REPORT_PATH__, 'utf-8'); + } + const reportTpl = 'REPLACE_ME_WITH_REPORT_HTML'; + + return reportTpl; +} +/** + * reach cache data + */ +function readReportCache(): string { + try { + return fs.readFileSync(cachePath, 'utf-8'); + } catch (err) { + console.error('reading cache file failed:', err); + return ''; // Return an empty string as default + } +} +/** + * generate the final report and clear the cache + */ +function gatherReport(): void { + // 1. prepare path + const reportDir = getMidsceneRunSubDir('report'); + // get current time and format into YYYY-MM-DD_HH-MM-SS + const now = new Date(); + const dateStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}_` + + `${now.getHours().toString().padStart(2, '0')}-${now.getMinutes().toString().padStart(2, '0')}-${now.getSeconds().toString().padStart(2, '0')}`; + // join dateStr into file name + const outFilePath = path.join(reportDir, `aggregated_report_${dateStr}.html`); + + // 2. reach cache data + const cacheData = readReportCache(); + + // 3. generate report content + const reportContent = getReportTpl() + cacheData; + + // 4. write report file + fs.mkdirSync(reportDir, { recursive: true }); + fs.writeFileSync(outFilePath, reportContent, 'utf-8'); + + // 5. clean up cache + try { + fs.unlinkSync(cachePath); + console.log('cache file cleaned, report location:', outFilePath); + } catch (err) { + console.error('fail to clean cache file:', err); + } +} + +gatherReport(); diff --git a/packages/android/tests/report-aggregation/run-tests.ts b/packages/android/tests/report-aggregation/run-tests.ts new file mode 100644 index 000000000..dcf0b2108 --- /dev/null +++ b/packages/android/tests/report-aggregation/run-tests.ts @@ -0,0 +1,37 @@ +import { spawn } from 'child_process'; +const REPORT_AGGREGATION__SCRIPTS_DIR = './tests/report-aggregation' +async function runCommand(command: string, args: string[] = []) { + return new Promise((resolve) => { + const [cmd, ...cmdArgs] = command.split(' '); + const finalArgs = [...cmdArgs, ...args]; + const child = spawn(cmd, finalArgs, { stdio: 'inherit', shell: true }); + + child.on('close', (code) => { + resolve(code === 0); + }); + + child.on('error', (error) => { + console.error(`[ERROR] run ${command} failed:`, error.message); + resolve(false); + }); + }); +} + +async function main() { + // 1. run setup-test.ts + const setupSuccess = await runCommand(`npx tsx ${REPORT_AGGREGATION__SCRIPTS_DIR}/setup-test.ts`); + if (!setupSuccess) { + console.error('fail to run setup-test.ts, abort process.'); + return; + } + + // 2. run vitest(continue when failed) + await runCommand(`vitest --run ${REPORT_AGGREGATION__SCRIPTS_DIR}/cases/`); + console.log('Vitest done, run gather-reports.ts'); + + + // 3. run gather-report.ts + await runCommand(`npx tsx ${REPORT_AGGREGATION__SCRIPTS_DIR}/gather-report.ts`); +} + +main(); \ No newline at end of file diff --git a/packages/android/tests/report-aggregation/setup-test.ts b/packages/android/tests/report-aggregation/setup-test.ts new file mode 100644 index 000000000..0b08df72d --- /dev/null +++ b/packages/android/tests/report-aggregation/setup-test.ts @@ -0,0 +1,25 @@ + +import { getMidsceneRunSubDir } from '@midscene/shared/common' +import * as fs from 'fs'; +import * as path from 'path'; + +function createCacheFile(cachePath: string, filename: string, content = ''): string { + // Ensure that the directory exists (created recursively) + if (!fs.existsSync(cachePath)) { + fs.mkdirSync(cachePath, { recursive: true }); + } + + // join complete file path + const filePath = path.join(cachePath, filename); + + // write the content of the file + fs.writeFileSync(filePath, content); + + return filePath; +} + +function newReportCache(): string { + const cachePath = getMidsceneRunSubDir('cache'); + return createCacheFile(cachePath, 'cache_data'); +} +console.log("cache file created:", newReportCache()); diff --git a/packages/android/vitest.config.ts b/packages/android/vitest.config.ts index 409b13880..bbfc069b2 100644 --- a/packages/android/vitest.config.ts +++ b/packages/android/vitest.config.ts @@ -19,6 +19,8 @@ const testFiles = (() => { switch (aiTestType) { case 'android': return [...aiAndroidTests]; + case 'report-aggregation': + return ['tests/report-aggregation/cases/**/*.test.ts'] default: return unitTests; } @@ -34,6 +36,7 @@ export default defineConfig({ include: testFiles, testTimeout: 3 * 60 * 1000, // Global timeout set to 10 seconds dangerouslyIgnoreUnhandledErrors: !!process.env.CI, // showcase.test.ts is not stable + fileParallelism: false, // disable parallel file test }, define: { __VERSION__: `'${version}'`, diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index ae4040ab5..060916105 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -25,7 +25,7 @@ import { type TUserPrompt, type UIContext, } from '../index'; - +export type TestStatus = "passed" | "failed" | "timedOut" | "skipped" | "interrupted"; import yaml from 'js-yaml'; import { @@ -33,6 +33,8 @@ import { reportHTMLContent, stringifyDumpData, writeLogFile, + getHtmlScripts, + appendFileSync } from '@/utils'; import { ScriptPlayer, @@ -259,6 +261,29 @@ export class Agent< reportHTMLString() { return reportHTMLContent(this.dumpDataString()); } + teardownTestAgent( + teardownOpts: { + testId: string, + testTitle: string, + testDescription: string, + testDuration: number, + testStatus: TestStatus + cacheFilePath: string + } + ) { + + const s = getHtmlScripts({ + dumpString: this.dumpDataString(), + attributes: { + playwright_test_duration: teardownOpts.testDuration, + playwright_test_status: teardownOpts.testStatus, + playwright_test_title: teardownOpts.testTitle, + playwright_test_id: teardownOpts.testId, + playwright_test_description: teardownOpts.testDescription + }, + }) + '\n'; + appendFileSync(teardownOpts.cacheFilePath, s); + } writeOutActionDumps() { if (this.destroyed) { @@ -850,9 +875,8 @@ export class Agent< const message = output ? undefined - : `Assertion failed: ${msg || (typeof assertion === 'string' ? assertion : assertion.prompt)}\nReason: ${ - thought || executor.latestErrorTask()?.error || '(no_reason)' - }`; + : `Assertion failed: ${msg || (typeof assertion === 'string' ? assertion : assertion.prompt)}\nReason: ${thought || executor.latestErrorTask()?.error || '(no_reason)' + }`; if (opt?.keepRawResponse) { return { @@ -983,7 +1007,7 @@ export class Agent< param: { content: opt?.content || '', }, - executor: async () => {}, + executor: async () => { }, }; // 4. build ExecutionDump const executionDump: ExecutionDump = { @@ -1009,17 +1033,17 @@ export class Agent< const { groupName, groupDescription, executions } = this.dump; const newExecutions = Array.isArray(executions) ? executions.map((execution: any) => { - const { tasks, ...restExecution } = execution; - let newTasks = tasks; - if (Array.isArray(tasks)) { - newTasks = tasks.map((task: any) => { - // only remove uiContext and log from task - const { uiContext, log, ...restTask } = task; - return restTask; - }); - } - return { ...restExecution, ...(newTasks ? { tasks: newTasks } : {}) }; - }) + const { tasks, ...restExecution } = execution; + let newTasks = tasks; + if (Array.isArray(tasks)) { + newTasks = tasks.map((task: any) => { + // only remove uiContext and log from task + const { uiContext, log, ...restTask } = task; + return restTask; + }); + } + return { ...restExecution, ...(newTasks ? { tasks: newTasks } : {}) }; + }) : []; return { groupName, @@ -1065,7 +1089,7 @@ export class Agent< if (opts.cache === true) { throw new Error( 'cache: true requires an explicit cache ID. Please provide:\n' + - 'Example: cache: { id: "my-cache-id" }', + 'Example: cache: { id: "my-cache-id" }', ); } @@ -1075,7 +1099,7 @@ export class Agent< if (!config.id) { throw new Error( 'cache configuration requires an explicit id. Please provide:\n' + - 'Example: cache: { id: "my-cache-id" }', + 'Example: cache: { id: "my-cache-id" }', ); } const id = config.id; diff --git a/packages/core/src/agent/index.ts b/packages/core/src/agent/index.ts index b204d602d..a0659539a 100644 --- a/packages/core/src/agent/index.ts +++ b/packages/core/src/agent/index.ts @@ -1,4 +1,5 @@ export { Agent, createAgent } from './agent'; +export type { TestStatus } from './agent' export { commonContextParser } from './utils'; export { getReportFileName, diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index a8edb2dac..077d9be9b 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -20,6 +20,8 @@ import type { Cache, Rect, ReportDumpWithAttributes } from './types'; let logEnvReady = false; +export { appendFileSync } from 'fs' + export const groupedActionDumpFileExt = 'web-dump.json'; /** @@ -160,6 +162,39 @@ export function reportHTMLContent( return tpl + dumpContent; } +export function getHtmlScripts( + dumpData: ReportDumpWithAttributes, +): string { + + // if reportPath is set, it means we are in write to file mode + let dumpContent = ''; + + if (typeof dumpData === 'string') { + // do not use template string here, will cause bundle error + dumpContent = + // biome-ignore lint/style/useTemplate: + ''; + } else { + const { dumpString, attributes } = dumpData; + const attributesArr = Object.keys(attributes || {}).map((key) => { + return `${key}="${encodeURIComponent(attributes![key])}"`; + }); + + dumpContent = + // do not use template string here, will cause bundle error + // biome-ignore lint/style/useTemplate: + ''; + } + + return dumpContent; +} + export function writeDumpReport( fileName: string, dumpData: string | ReportDumpWithAttributes, @@ -200,7 +235,7 @@ export function writeDumpReport( export function writeLogFile(opts: { fileName: string; fileExt: string; - fileContent: string; + fileContent: string | ReportDumpWithAttributes; type: 'dump' | 'cache' | 'report' | 'tmp'; generateReport?: boolean; appendReport?: boolean; @@ -242,7 +277,7 @@ export function writeLogFile(opts: { if (type !== 'dump') { // do not write dump file any more - writeFileSync(filePath, fileContent); + writeFileSync(filePath, JSON.stringify(fileContent)); } if (opts?.generateReport) { From 1a63542cb4788c71a89ca7b9bd29484b60e0c930 Mon Sep 17 00:00:00 2001 From: tangjialei Date: Sun, 5 Oct 2025 13:39:29 +0800 Subject: [PATCH 2/3] feat(core): add ReportMergingTool into core/utils with its unit-tests, remove agent.teardownTestAgent, add one case in android project to show the usage of ReportMergingTool --- packages/android/package.json | 3 +- .../android/tests/multi-tasks/setting.test.ts | 80 ++++++ .../report-aggregation/cases/setting1.test.ts | 59 ----- .../report-aggregation/cases/setting2.test.ts | 59 ----- .../tests/report-aggregation/gather-report.ts | 60 ----- .../tests/report-aggregation/run-tests.ts | 37 --- .../tests/report-aggregation/setup-test.ts | 25 -- packages/android/vitest.config.ts | 2 +- packages/core/src/agent/agent.ts | 26 -- packages/core/src/agent/index.ts | 1 - packages/core/src/types.ts | 25 +- packages/core/src/utils.ts | 114 ++++++++- packages/core/tests/unit-test/utils.test.ts | 233 +++++++++++------- 13 files changed, 361 insertions(+), 363 deletions(-) create mode 100644 packages/android/tests/multi-tasks/setting.test.ts delete mode 100644 packages/android/tests/report-aggregation/cases/setting1.test.ts delete mode 100644 packages/android/tests/report-aggregation/cases/setting2.test.ts delete mode 100644 packages/android/tests/report-aggregation/gather-report.ts delete mode 100644 packages/android/tests/report-aggregation/run-tests.ts delete mode 100644 packages/android/tests/report-aggregation/setup-test.ts diff --git a/packages/android/package.json b/packages/android/package.json index 0f280ffcc..808550629 100644 --- a/packages/android/package.json +++ b/packages/android/package.json @@ -29,8 +29,7 @@ "test": "vitest --run", "test:u": "vitest --run -u", "test:ai": "AI_TEST_TYPE=android npm run test", - "test:ai:cache": "MIDSCENE_CACHE=true AI_TEST_TYPE=android npm run test", - "test:rep-agg": "AI_TEST_TYPE=report-aggregation npx tsx ./tests/report-aggregation/run-tests.ts" + "test:ai:cache": "MIDSCENE_CACHE=true AI_TEST_TYPE=android npm run test" }, "dependencies": { "@midscene/core": "workspace:*", diff --git a/packages/android/tests/multi-tasks/setting.test.ts b/packages/android/tests/multi-tasks/setting.test.ts new file mode 100644 index 000000000..70bde3694 --- /dev/null +++ b/packages/android/tests/multi-tasks/setting.test.ts @@ -0,0 +1,80 @@ + +import { sleep, ReportMergingTool } from '@midscene/core/utils'; +import { type TestStatus } from '@midscene/core'; +import { AndroidAgent, AndroidDevice, getConnectedDevices } from '@midscene/android'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, it } from 'vitest'; +import ADB from 'appium-adb'; + + +describe(`Test Setting`, () => { + let page: AndroidDevice; + let adb: ADB; + let agent: AndroidAgent; + let startTime: number; + let itTestStatus: TestStatus = 'passed' + const reportMergingTool = new ReportMergingTool(); + + beforeAll(async () => { + const devices = await getConnectedDevices(); + page = new AndroidDevice(devices[0].udid); + adb = await page.getAdb(); + }); + + beforeEach((ctx) => { + startTime = performance.now() + agent = new AndroidAgent(page, { + groupName: ctx.task.name, + }); + }); + + afterEach((ctx) => { + if (ctx.task.result?.state === 'pass') { + itTestStatus = "passed"; + } else if (ctx.task.result?.state === 'skip') { + itTestStatus = "skipped"; + } else if (ctx.task.result?.errors?.[0].message.includes("timed out")) { + itTestStatus = "timedOut"; + } else { + itTestStatus = 'failed'; + } + reportMergingTool.append({ + reportFilePath: agent.reportFile as string, + reportAttributes: { + testId: `${ctx.task.name}`, //ID is a unique identifier used by the front end to distinguish each use case! + testTitle: `${ctx.task.name}`, + testDescription: 'desc', + testDuration: (Date.now() - ctx.task.result?.startTime!) | 0, + testStatus: itTestStatus + } + }); + }); + + afterAll(() => { + reportMergingTool.mergeReports(); + }); + + it( + 'toggle wlan', + async () => { + await adb.shell('input keyevent KEYCODE_HOME'); + await sleep(1000); + await adb.shell('am start -n com.android.settings/.Settings'); + await sleep(1000); + await agent.aiAction('find and enter WLAN setting'); + await agent.aiAction('toggle WLAN status *once*, if WLAN is off pls turn it on, otherwise turn it off.'); + } + ); + + it( + 'toggle bluetooth', + async (ctx) => { + const adb = await page.getAdb(); + await adb.shell('input keyevent KEYCODE_HOME'); + await sleep(1000); + await adb.shell('am start -n com.android.settings/.Settings'); + await sleep(1000); + await agent.aiAction('find and enter bluetooth setting'); + await agent.aiAction('toggle bluetooth status *once*, if bluetooth is off pls turn it on, otherwise turn it off.'); + } + ); +}); diff --git a/packages/android/tests/report-aggregation/cases/setting1.test.ts b/packages/android/tests/report-aggregation/cases/setting1.test.ts deleted file mode 100644 index d41d0c694..000000000 --- a/packages/android/tests/report-aggregation/cases/setting1.test.ts +++ /dev/null @@ -1,59 +0,0 @@ - -import { sleep } from '@midscene/core/utils'; -import type { TestStatus } from '@midscene/core/agent'; -import { getMidsceneRunSubDir } from '@midscene/shared/common' -import { AndroidAgent, AndroidDevice, getConnectedDevices } from '@midscene/android'; -import { beforeAll, describe, it } from 'vitest'; - -const caseName = 'settings1'; - -describe(`${caseName}`, () => { - let agent: AndroidAgent; - let startTime: number; - let testStatus: TestStatus = 'passed' - beforeAll(async () => { - startTime = performance.now() - const devices = await getConnectedDevices(); - const page = new AndroidDevice(devices[0].udid); - agent = new AndroidAgent(page, { - groupName: `${caseName}`, - generateReport: false - }); - const adb = await page.getAdb(); - await adb.shell('input keyevent KEYCODE_HOME'); - await sleep(1000); - await adb.shell('am start -n com.android.settings/.Settings'); - await sleep(1000); - }); - - it( - 'switch wlan', - async (ctx) => { - ctx.onTestFinished((result) => { - // update status - console.log(result.task.result); - if (result.task.result?.state === 'pass') { - testStatus = "passed"; - } else if (result.task.result?.state === 'skip') { - testStatus = "skipped"; - } else if (result.task.result?.errors?.[0].message.includes("timed out")) { - testStatus = "timedOut"; - } else { - testStatus = 'failed'; - } - agent.teardownTestAgent({ - testId: `${caseName}`,//ID is a unique identifier used by the front end to distinguish each use case! - testTitle: `${caseName}`, - testDescription: 'desc', - testDuration: (performance.now() - startTime) | 0, - testStatus, - cacheFilePath: getMidsceneRunSubDir('cache') + "/cache_data" // setup-test.ts creates an empty cache file before all tests - }); - }); - - await agent.aiAction('find and enter WLAN setting'); - await agent.aiAction('toggle WLAN status, if WLAN is off pls turn it on, otherwise turn it off.'); - } - ); - -}); diff --git a/packages/android/tests/report-aggregation/cases/setting2.test.ts b/packages/android/tests/report-aggregation/cases/setting2.test.ts deleted file mode 100644 index f809c6297..000000000 --- a/packages/android/tests/report-aggregation/cases/setting2.test.ts +++ /dev/null @@ -1,59 +0,0 @@ - -import { sleep } from '@midscene/core/utils'; -import type { TestStatus } from '@midscene/core/agent'; -import { getMidsceneRunSubDir } from '@midscene/shared/common' -import { AndroidAgent, AndroidDevice, getConnectedDevices } from '@midscene/android'; -import { beforeAll, describe, it } from 'vitest'; - -const caseName = 'settings2'; - -describe(`${caseName}`, () => { - let agent: AndroidAgent; - let startTime: number; - let testStatus: TestStatus = 'passed' - beforeAll(async () => { - startTime = performance.now() - const devices = await getConnectedDevices(); - const page = new AndroidDevice(devices[0].udid); - agent = new AndroidAgent(page, { - groupName: `${caseName}`, - generateReport: false - }); - const adb = await page.getAdb(); - await adb.shell('input keyevent KEYCODE_HOME'); - await sleep(1000); - await adb.shell('am start -n com.android.settings/.Settings'); - await sleep(1000); - }); - - it( - 'switch wlan', - async (ctx) => { - ctx.onTestFinished((result) => { - // update status - console.log(result.task.result); - if (result.task.result?.state === 'pass') { - testStatus = "passed"; - } else if (result.task.result?.state === 'skip') { - testStatus = "skipped"; - } else if (result.task.result?.errors?.[0].message.includes("timed out")) { - testStatus = "timedOut"; - } else { - testStatus = 'failed'; - } - agent.teardownTestAgent({ - testId: `${caseName}`,//ID is a unique identifier used by the front end to distinguish each use case! - testTitle: `${caseName}`, - testDescription: 'desc', - testDuration: (performance.now() - startTime) | 0, - testStatus, - cacheFilePath: getMidsceneRunSubDir('cache') + "/cache_data" // setup-test.ts creates an empty cache file before all tests - }); - }); - - await agent.aiAction('find and enter Bluetooth setting'); - await agent.aiAction('toggle Bluetooth status, if Bluetooth is off pls turn it on, otherwise turn it off.'); - } - ); - -}); diff --git a/packages/android/tests/report-aggregation/gather-report.ts b/packages/android/tests/report-aggregation/gather-report.ts deleted file mode 100644 index 0dc682ddd..000000000 --- a/packages/android/tests/report-aggregation/gather-report.ts +++ /dev/null @@ -1,60 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { getMidsceneRunSubDir } from '@midscene/shared/common' - -const cacheDir = getMidsceneRunSubDir('cache'); -const cachePath = path.join(cacheDir, 'cache_data'); - -function getReportTpl() { - const __DEV_REPORT_PATH__ = path.resolve(__dirname, '../../../../apps/report/dist/index.html'); - if (typeof __DEV_REPORT_PATH__ === 'string' && __DEV_REPORT_PATH__) { - return fs.readFileSync(__DEV_REPORT_PATH__, 'utf-8'); - } - const reportTpl = 'REPLACE_ME_WITH_REPORT_HTML'; - - return reportTpl; -} -/** - * reach cache data - */ -function readReportCache(): string { - try { - return fs.readFileSync(cachePath, 'utf-8'); - } catch (err) { - console.error('reading cache file failed:', err); - return ''; // Return an empty string as default - } -} -/** - * generate the final report and clear the cache - */ -function gatherReport(): void { - // 1. prepare path - const reportDir = getMidsceneRunSubDir('report'); - // get current time and format into YYYY-MM-DD_HH-MM-SS - const now = new Date(); - const dateStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}_` + - `${now.getHours().toString().padStart(2, '0')}-${now.getMinutes().toString().padStart(2, '0')}-${now.getSeconds().toString().padStart(2, '0')}`; - // join dateStr into file name - const outFilePath = path.join(reportDir, `aggregated_report_${dateStr}.html`); - - // 2. reach cache data - const cacheData = readReportCache(); - - // 3. generate report content - const reportContent = getReportTpl() + cacheData; - - // 4. write report file - fs.mkdirSync(reportDir, { recursive: true }); - fs.writeFileSync(outFilePath, reportContent, 'utf-8'); - - // 5. clean up cache - try { - fs.unlinkSync(cachePath); - console.log('cache file cleaned, report location:', outFilePath); - } catch (err) { - console.error('fail to clean cache file:', err); - } -} - -gatherReport(); diff --git a/packages/android/tests/report-aggregation/run-tests.ts b/packages/android/tests/report-aggregation/run-tests.ts deleted file mode 100644 index dcf0b2108..000000000 --- a/packages/android/tests/report-aggregation/run-tests.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { spawn } from 'child_process'; -const REPORT_AGGREGATION__SCRIPTS_DIR = './tests/report-aggregation' -async function runCommand(command: string, args: string[] = []) { - return new Promise((resolve) => { - const [cmd, ...cmdArgs] = command.split(' '); - const finalArgs = [...cmdArgs, ...args]; - const child = spawn(cmd, finalArgs, { stdio: 'inherit', shell: true }); - - child.on('close', (code) => { - resolve(code === 0); - }); - - child.on('error', (error) => { - console.error(`[ERROR] run ${command} failed:`, error.message); - resolve(false); - }); - }); -} - -async function main() { - // 1. run setup-test.ts - const setupSuccess = await runCommand(`npx tsx ${REPORT_AGGREGATION__SCRIPTS_DIR}/setup-test.ts`); - if (!setupSuccess) { - console.error('fail to run setup-test.ts, abort process.'); - return; - } - - // 2. run vitest(continue when failed) - await runCommand(`vitest --run ${REPORT_AGGREGATION__SCRIPTS_DIR}/cases/`); - console.log('Vitest done, run gather-reports.ts'); - - - // 3. run gather-report.ts - await runCommand(`npx tsx ${REPORT_AGGREGATION__SCRIPTS_DIR}/gather-report.ts`); -} - -main(); \ No newline at end of file diff --git a/packages/android/tests/report-aggregation/setup-test.ts b/packages/android/tests/report-aggregation/setup-test.ts deleted file mode 100644 index 0b08df72d..000000000 --- a/packages/android/tests/report-aggregation/setup-test.ts +++ /dev/null @@ -1,25 +0,0 @@ - -import { getMidsceneRunSubDir } from '@midscene/shared/common' -import * as fs from 'fs'; -import * as path from 'path'; - -function createCacheFile(cachePath: string, filename: string, content = ''): string { - // Ensure that the directory exists (created recursively) - if (!fs.existsSync(cachePath)) { - fs.mkdirSync(cachePath, { recursive: true }); - } - - // join complete file path - const filePath = path.join(cachePath, filename); - - // write the content of the file - fs.writeFileSync(filePath, content); - - return filePath; -} - -function newReportCache(): string { - const cachePath = getMidsceneRunSubDir('cache'); - return createCacheFile(cachePath, 'cache_data'); -} -console.log("cache file created:", newReportCache()); diff --git a/packages/android/vitest.config.ts b/packages/android/vitest.config.ts index bbfc069b2..3ffa5ad2b 100644 --- a/packages/android/vitest.config.ts +++ b/packages/android/vitest.config.ts @@ -20,7 +20,7 @@ const testFiles = (() => { case 'android': return [...aiAndroidTests]; case 'report-aggregation': - return ['tests/report-aggregation/cases/**/*.test.ts'] + return ['tests/multi-tasks/**/*.test.ts'] default: return unitTests; } diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 060916105..571a3ca8d 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -25,7 +25,6 @@ import { type TUserPrompt, type UIContext, } from '../index'; -export type TestStatus = "passed" | "failed" | "timedOut" | "skipped" | "interrupted"; import yaml from 'js-yaml'; import { @@ -33,8 +32,6 @@ import { reportHTMLContent, stringifyDumpData, writeLogFile, - getHtmlScripts, - appendFileSync } from '@/utils'; import { ScriptPlayer, @@ -261,29 +258,6 @@ export class Agent< reportHTMLString() { return reportHTMLContent(this.dumpDataString()); } - teardownTestAgent( - teardownOpts: { - testId: string, - testTitle: string, - testDescription: string, - testDuration: number, - testStatus: TestStatus - cacheFilePath: string - } - ) { - - const s = getHtmlScripts({ - dumpString: this.dumpDataString(), - attributes: { - playwright_test_duration: teardownOpts.testDuration, - playwright_test_status: teardownOpts.testStatus, - playwright_test_title: teardownOpts.testTitle, - playwright_test_id: teardownOpts.testId, - playwright_test_description: teardownOpts.testDescription - }, - }) + '\n'; - appendFileSync(teardownOpts.cacheFilePath, s); - } writeOutActionDumps() { if (this.destroyed) { diff --git a/packages/core/src/agent/index.ts b/packages/core/src/agent/index.ts index a0659539a..b204d602d 100644 --- a/packages/core/src/agent/index.ts +++ b/packages/core/src/agent/index.ts @@ -1,5 +1,4 @@ export { Agent, createAgent } from './agent'; -export type { TestStatus } from './agent' export { commonContextParser } from './utils'; export { getReportFileName, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 62f7ad70f..6cbe5ead5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -303,10 +303,10 @@ export interface BaseAgentParserOpt { selector?: string; } // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PuppeteerParserOpt extends BaseAgentParserOpt {} +export interface PuppeteerParserOpt extends BaseAgentParserOpt { } // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface PlaywrightParserOpt extends BaseAgentParserOpt {} +export interface PlaywrightParserOpt extends BaseAgentParserOpt { } /* action @@ -376,11 +376,11 @@ export type ExecutionTask< > = E & ExecutionTaskReturn< E extends ExecutionTaskApply - ? TaskOutput - : unknown, + ? TaskOutput + : unknown, E extends ExecutionTaskApply - ? TaskLog - : unknown + ? TaskLog + : unknown > & { status: 'pending' | 'running' | 'finished' | 'failed' | 'cancelled'; error?: Error; @@ -605,3 +605,16 @@ export interface AgentOpt { cache?: Cache; replanningCycleLimit?: number; } + +export type TestStatus = "passed" | "failed" | "timedOut" | "skipped" | "interrupted"; + +export interface ReportFileWithAttributes { + reportFilePath: string; + reportAttributes: { + testDuration: number; + testStatus: TestStatus; + testTitle: string; + testId: string; + testDescription: string; + }; +} \ No newline at end of file diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 077d9be9b..98d6b6c4f 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -16,8 +16,8 @@ import { ifInWorker, uuid, } from '@midscene/shared/utils'; -import type { Cache, Rect, ReportDumpWithAttributes } from './types'; - +import type { Cache, Rect, ReportDumpWithAttributes, ReportFileWithAttributes } from './types'; +import { getReportFileName } from './agent'; let logEnvReady = false; export { appendFileSync } from 'fs' @@ -406,3 +406,113 @@ export function uploadTestInfoToServer({ lastReportedRepoUrl = repoUrl; } } + + +export class ReportMergingTool { + private reportInfos: ReportFileWithAttributes[] = []; + constructor() { + + } + public append(reportInfo: ReportFileWithAttributes) { + this.reportInfos.push(reportInfo); + } + + private extractLastScriptContentFromEnd(filePath: string): string { + const INITIAL_CHUNK_SIZE = 1024 * 1024; // Initial chunk size 1MB (adjustable based on content) + const fd = fs.openSync(filePath, 'r'); + const fileSize = fs.statSync(filePath).size; + let position = fileSize; + let buffer = Buffer.alloc(INITIAL_CHUNK_SIZE); + let lastScriptContent: string | null = null; + let isInsideScript = false; // Flag indicating whether + if (isInsideScript) { + const scriptEndIdx = accumulatedContent.indexOf(''); + if (scriptEndIdx !== -1) { + // Extract complete content (from ) + const fullScriptTag = accumulatedContent.slice(0, scriptEndIdx + ''.length); + const contentStartIdx = fullScriptTag.indexOf('>') + 1; + lastScriptContent = fullScriptTag.slice(contentStartIdx, scriptEndIdx).trim(); + break; + } + } + } + + fs.closeSync(fd); + return lastScriptContent ?? ''; + } + + public mergeReports(rmOriginalReports: boolean = false): string | null { + if (this.reportInfos.length <= 1) { + console.log(`Not enough report to merge`); + return null; + } + const outputFilePath = path.resolve(`${getMidsceneRunSubDir('report')}`, `${getReportFileName('merged-report')}.html`); + console.log(`Start merging ${this.reportInfos.length} reports...\nCreating template file...`); + + try { + // Write template + fs.appendFileSync(outputFilePath, getReportTpl()); + + // Process all reports one by one + for (let i = 0; i < this.reportInfos.length; i++) { + const reportInfo = this.reportInfos[i]; + console.log(`Processing report ${i + 1}/${this.reportInfos.length}`); + + const dumpString = this.extractLastScriptContentFromEnd(reportInfo.reportFilePath); + const reportAttributes = reportInfo.reportAttributes; + + const reportHtmlStr = getHtmlScripts({ + dumpString, + attributes: { + playwright_test_duration: reportAttributes.testDuration, + playwright_test_status: reportAttributes.testStatus, + playwright_test_title: reportAttributes.testTitle, + playwright_test_id: reportAttributes.testId, + playwright_test_description: reportAttributes.testDescription, + }, + }) + '\n'; + + fs.appendFileSync(outputFilePath, reportHtmlStr); + } + + console.log(`Successfully merged new report: ${outputFilePath}`); + + // Remove original reports if needed + if (rmOriginalReports) { + for (const info of this.reportInfos) { + try { + fs.unlinkSync(info.reportFilePath); + } catch (error) { + console.error(`Error deleting report ${info.reportFilePath}:`, error); + } + } + console.log(`Removed ${this.reportInfos.length} original reports`); + } + return outputFilePath; + } catch (error) { + console.error('Error in mergeReports:', error); + throw error; + } + + } + +} \ No newline at end of file diff --git a/packages/core/tests/unit-test/utils.test.ts b/packages/core/tests/unit-test/utils.test.ts index 939475d72..4df8ddb50 100644 --- a/packages/core/tests/unit-test/utils.test.ts +++ b/packages/core/tests/unit-test/utils.test.ts @@ -24,7 +24,7 @@ import { uuid } from '@midscene/shared/utils'; import { describe, expect, it } from 'vitest'; import { z } from 'zod'; // @ts-ignore no types in es folder -import { reportHTMLContent, writeDumpReport } from '../../dist/es/utils'; // use modules from dist, otherwise we will miss the template file +import { reportHTMLContent, writeDumpReport, ReportMergingTool } from '../../dist/es/utils'; // use modules from dist, otherwise we will miss the template file import { getTmpDir, getTmpFile, @@ -148,90 +148,90 @@ describe('utils', () => { expect(fileContentC).toContain(uuid1); }); - it( - 'should handle multiple large reports correctly', - { timeout: 30000 }, - async () => { - const tmpFile = createTempHtmlFile(''); - - // 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(); - const heapTotalBefore = startMemory.heapTotal / 1024 / 1024; - const heapUsedBefore = startMemory.heapUsed / 1024 / 1024; - console.log( - 'Memory usage before test:', - `RSS: ${Math.round(startMemory.rss / 1024 / 1024)}MB, ` + - `Heap Total: ${heapTotalBefore}MB, ` + - `Heap Used: ${heapUsedBefore}MB`, - ); - - // Store start time - const startTime = Date.now(); - - // Generate 10 large reports (each ~100MB) - const numberOfReports = 10; - // Write the large reports - for (let i = 0; i < numberOfReports; i++) { - const reportPath = reportHTMLContent( - { - dumpString: generateLargeString(100, `large-report-${i + 1}`), - attributes: { - report_number: `${i + 1}`, - report_size: '100MB', - }, - }, - tmpFile, - true, - ); - 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(); - const rssAfter = endMemory.rss / 1024 / 1024; - const heapTotalAfter = endMemory.heapTotal / 1024 / 1024; - const heapUsedAfter = endMemory.heapUsed / 1024 / 1024; - console.log( - 'Memory usage after test:', - `RSS: ${Math.round(rssAfter)}MB, ` + - `Heap Total: ${heapTotalAfter}MB, ` + - `Heap Used: ${heapUsedAfter}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`); - - await new Promise((resolve) => setTimeout(resolve, 5000)); - - // We expect the file to be approximately 700MB plus template overhead - const expectedMinSize = 1000; // 10 reports × 100MB - expect(fileSizeInMB).toBeGreaterThan(expectedMinSize); - }, - ); + // it( + // 'should handle multiple large reports correctly', + // { timeout: 30000 }, + // async () => { + // const tmpFile = createTempHtmlFile(''); + + // // 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(); + // const heapTotalBefore = startMemory.heapTotal / 1024 / 1024; + // const heapUsedBefore = startMemory.heapUsed / 1024 / 1024; + // console.log( + // 'Memory usage before test:', + // `RSS: ${Math.round(startMemory.rss / 1024 / 1024)}MB, ` + + // `Heap Total: ${heapTotalBefore}MB, ` + + // `Heap Used: ${heapUsedBefore}MB`, + // ); + + // // Store start time + // const startTime = Date.now(); + + // // Generate 10 large reports (each ~100MB) + // const numberOfReports = 10; + // // Write the large reports + // for (let i = 0; i < numberOfReports; i++) { + // const reportPath = reportHTMLContent( + // { + // dumpString: generateLargeString(100, `large-report-${i + 1}`), + // attributes: { + // report_number: `${i + 1}`, + // report_size: '100MB', + // }, + // }, + // tmpFile, + // true, + // ); + // 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(); + // const rssAfter = endMemory.rss / 1024 / 1024; + // const heapTotalAfter = endMemory.heapTotal / 1024 / 1024; + // const heapUsedAfter = endMemory.heapUsed / 1024 / 1024; + // console.log( + // 'Memory usage after test:', + // `RSS: ${Math.round(rssAfter)}MB, ` + + // `Heap Total: ${heapTotalAfter}MB, ` + + // `Heap Used: ${heapUsedAfter}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`); + + // await new Promise((resolve) => setTimeout(resolve, 5000)); + + // // We expect the file to be approximately 700MB plus template overhead + // const expectedMinSize = 1000; // 10 reports × 100MB + // expect(fileSizeInMB).toBeGreaterThan(expectedMinSize); + // }, + // ); it('reportHTMLContent array with xss', () => { const reportContent = reportHTMLContent({ @@ -1542,3 +1542,66 @@ describe('loadActionParam and dumpActionParam integration', () => { `); }); }); + +describe('repotMergingTool', () => { + it('should merge 3 mocked reports', async () => { + const tool = new ReportMergingTool(); + let expectedContents = []; + for (let i = 0; i < 3; i++) { + // create report files + const content = `report content ${i}`; + expectedContents.push(content); + const reportPath = writeDumpReport(`report-to-merge-${i}`, { + dumpString: `report content ${i}`, + }); + // append report content and its relevant information to reportMergingTool + tool.append({ + reportFilePath: reportPath, reportAttributes: { + testDescription: `desc${i}`, + testDuration: 1, + testId: `${i}`, + testStatus: 'passed', + testTitle: `${i}` + } + }); + } + // execute merge operation + const mergedReportPath = tool.mergeReports(); + // assert merge success + const mergedReportContent = readFileSync(mergedReportPath!, 'utf-8'); + expectedContents.forEach(content => { + expect(mergedReportContent).contains(content); + }); + }); + + it('should merge 3 mocked reports, and delete original reports after that.', async () => { + const tool = new ReportMergingTool(); + let expectedContents = []; + for (let i = 0; i < 3; i++) { + const content = `report content ${i}`; + expectedContents.push(content); + const reportPath = writeDumpReport(`report-rm-to-merge-${i}`, { + dumpString: `report content ${i}, original report file deleted`, + }); + tool.append({ + reportFilePath: reportPath, reportAttributes: { + testDescription: `desc${i}`, + testDuration: 1, + testId: `${i}`, + testStatus: 'passed', + testTitle: `${i}` + } + }); + } + // assert merge success + const mergedReportPath: string = tool.mergeReports(true); + const mergedReportContent = readFileSync(mergedReportPath!, 'utf-8'); + expectedContents.forEach(content => { + expect(mergedReportContent).contains(content); + }); + // assert source report files deleted successfully + tool['reportInfos'].forEach((el: any) => { + expect(fs.existsSync(el.reportFilePath)).toBe(false); + }); + }); +}); \ No newline at end of file From d80174ca09075d892859d0938d962e649485ff33 Mon Sep 17 00:00:00 2001 From: tangjialei Date: Sun, 5 Oct 2025 13:43:11 +0800 Subject: [PATCH 3/3] chore(core): restore test item: utils -> should handle multiple large reports correctly --- packages/core/tests/unit-test/utils.test.ts | 168 ++++++++++---------- 1 file changed, 84 insertions(+), 84 deletions(-) diff --git a/packages/core/tests/unit-test/utils.test.ts b/packages/core/tests/unit-test/utils.test.ts index 4df8ddb50..7d561c3d6 100644 --- a/packages/core/tests/unit-test/utils.test.ts +++ b/packages/core/tests/unit-test/utils.test.ts @@ -148,90 +148,90 @@ describe('utils', () => { expect(fileContentC).toContain(uuid1); }); - // it( - // 'should handle multiple large reports correctly', - // { timeout: 30000 }, - // async () => { - // const tmpFile = createTempHtmlFile(''); - - // // 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(); - // const heapTotalBefore = startMemory.heapTotal / 1024 / 1024; - // const heapUsedBefore = startMemory.heapUsed / 1024 / 1024; - // console.log( - // 'Memory usage before test:', - // `RSS: ${Math.round(startMemory.rss / 1024 / 1024)}MB, ` + - // `Heap Total: ${heapTotalBefore}MB, ` + - // `Heap Used: ${heapUsedBefore}MB`, - // ); - - // // Store start time - // const startTime = Date.now(); - - // // Generate 10 large reports (each ~100MB) - // const numberOfReports = 10; - // // Write the large reports - // for (let i = 0; i < numberOfReports; i++) { - // const reportPath = reportHTMLContent( - // { - // dumpString: generateLargeString(100, `large-report-${i + 1}`), - // attributes: { - // report_number: `${i + 1}`, - // report_size: '100MB', - // }, - // }, - // tmpFile, - // true, - // ); - // 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(); - // const rssAfter = endMemory.rss / 1024 / 1024; - // const heapTotalAfter = endMemory.heapTotal / 1024 / 1024; - // const heapUsedAfter = endMemory.heapUsed / 1024 / 1024; - // console.log( - // 'Memory usage after test:', - // `RSS: ${Math.round(rssAfter)}MB, ` + - // `Heap Total: ${heapTotalAfter}MB, ` + - // `Heap Used: ${heapUsedAfter}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`); - - // await new Promise((resolve) => setTimeout(resolve, 5000)); - - // // We expect the file to be approximately 700MB plus template overhead - // const expectedMinSize = 1000; // 10 reports × 100MB - // expect(fileSizeInMB).toBeGreaterThan(expectedMinSize); - // }, - // ); + it( + 'should handle multiple large reports correctly', + { timeout: 30000 }, + async () => { + const tmpFile = createTempHtmlFile(''); + + // 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(); + const heapTotalBefore = startMemory.heapTotal / 1024 / 1024; + const heapUsedBefore = startMemory.heapUsed / 1024 / 1024; + console.log( + 'Memory usage before test:', + `RSS: ${Math.round(startMemory.rss / 1024 / 1024)}MB, ` + + `Heap Total: ${heapTotalBefore}MB, ` + + `Heap Used: ${heapUsedBefore}MB`, + ); + + // Store start time + const startTime = Date.now(); + + // Generate 10 large reports (each ~100MB) + const numberOfReports = 10; + // Write the large reports + for (let i = 0; i < numberOfReports; i++) { + const reportPath = reportHTMLContent( + { + dumpString: generateLargeString(100, `large-report-${i + 1}`), + attributes: { + report_number: `${i + 1}`, + report_size: '100MB', + }, + }, + tmpFile, + true, + ); + 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(); + const rssAfter = endMemory.rss / 1024 / 1024; + const heapTotalAfter = endMemory.heapTotal / 1024 / 1024; + const heapUsedAfter = endMemory.heapUsed / 1024 / 1024; + console.log( + 'Memory usage after test:', + `RSS: ${Math.round(rssAfter)}MB, ` + + `Heap Total: ${heapTotalAfter}MB, ` + + `Heap Used: ${heapUsedAfter}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`); + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + // We expect the file to be approximately 700MB plus template overhead + const expectedMinSize = 1000; // 10 reports × 100MB + expect(fileSizeInMB).toBeGreaterThan(expectedMinSize); + }, + ); it('reportHTMLContent array with xss', () => { const reportContent = reportHTMLContent({