mirror of
https://github.com/web-infra-dev/midscene.git
synced 2025-12-27 15:10:20 +00:00
feat(report): optimize report content (#55)
Co-authored-by: zhouxiao.shaw <zhouxiao.shaw@bytedance.com>
This commit is contained in:
parent
49d19371b3
commit
9c5a7a123c
@ -1,6 +1,6 @@
|
||||
---
|
||||
pageType: custom
|
||||
---
|
||||
import Visualizer from '@midscene/visualizer';
|
||||
import { Visualizer } from '@midscene/visualizer';
|
||||
|
||||
<Visualizer hideLogo />
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
pageType: custom
|
||||
---
|
||||
import Visualizer from '@midscene/visualizer';
|
||||
import { Visualizer } from '@midscene/visualizer';
|
||||
|
||||
<Visualizer hideLogo />
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"querystring": "0.2.1",
|
||||
"rspress": "^1.24.0",
|
||||
"rspress": "1.26.2",
|
||||
"@midscene/visualizer": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -50,5 +50,14 @@ export default defineConfig({
|
||||
description: 'Midscene.js',
|
||||
},
|
||||
],
|
||||
builderConfig: {
|
||||
tools: {
|
||||
rspack: {
|
||||
watchOptions: {
|
||||
ignored: /node_modules/,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
lang: 'en',
|
||||
});
|
||||
|
||||
@ -18,10 +18,9 @@
|
||||
"pre-commit": "npx nano-staged"
|
||||
},
|
||||
"nano-staged": {
|
||||
"*.{md,mdx,json,css,less,scss}": "prettier --write",
|
||||
"*.{md,mdx,json,css,less,scss}": "npx biome check . --diagnostic-level=warn --no-errors-on-unmatched --fix",
|
||||
"*.{js,jsx,ts,tsx,mjs,cjs}": [
|
||||
"biome check --changed --write --formatter-enabled=false --linter-enabled=false --no-errors-on-unmatched",
|
||||
"prettier --write"
|
||||
"npx biome check . --diagnostic-level=warn --no-errors-on-unmatched --fix"
|
||||
],
|
||||
"package.json": "pnpm run check-dependency-version"
|
||||
},
|
||||
|
||||
1
packages/midscene/.gitignore
vendored
1
packages/midscene/.gitignore
vendored
@ -1,3 +1,4 @@
|
||||
|
||||
# midscene.js
|
||||
midscene_run/
|
||||
report/
|
||||
@ -6,7 +6,7 @@
|
||||
"main": "./dist/lib/index.js",
|
||||
"module": "./dist/es/index.js",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"files": ["dist", "README.md"],
|
||||
"files": ["dist", "report", "README.md"],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
|
||||
@ -134,7 +134,7 @@ export class Executor {
|
||||
task.error = e?.message || 'error-without-message';
|
||||
task.errorStack = e.stack;
|
||||
|
||||
task.status = 'fail';
|
||||
task.status = 'failed';
|
||||
task.timing.end = Date.now();
|
||||
task.timing.cost = task.timing.end - task.timing.start;
|
||||
break;
|
||||
@ -166,7 +166,7 @@ export class Executor {
|
||||
return null;
|
||||
}
|
||||
const errorTaskIndex = this.tasks.findIndex(
|
||||
(task) => task.status === 'fail',
|
||||
(task) => task.status === 'failed',
|
||||
);
|
||||
if (errorTaskIndex >= 0) {
|
||||
return this.tasks[errorTaskIndex];
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { Executor } from './action/executor';
|
||||
import Insight from './insight';
|
||||
import { getElement, getSection } from './query';
|
||||
import { setDumpDir } from './utils';
|
||||
import { setLogDir } from './utils';
|
||||
|
||||
export { plan } from './ai-model';
|
||||
|
||||
export * from './types';
|
||||
export default Insight;
|
||||
export { getElement, getSection, Executor, setDumpDir };
|
||||
export { getElement, getSection, Executor, setLogDir };
|
||||
|
||||
@ -16,11 +16,11 @@ import type {
|
||||
UISection,
|
||||
} from '@/types';
|
||||
import {
|
||||
getDumpDir,
|
||||
getLogDir,
|
||||
getPkgInfo,
|
||||
insightDumpFileExt,
|
||||
stringifyDumpData,
|
||||
writeDumpFile,
|
||||
writeLogFile,
|
||||
} from '@/utils';
|
||||
|
||||
let logFileName = '';
|
||||
@ -34,7 +34,7 @@ export function writeInsightDump(
|
||||
logId?: string,
|
||||
dumpSubscriber?: DumpSubscriber,
|
||||
): string {
|
||||
const logDir = getDumpDir();
|
||||
const logDir = getLogDir();
|
||||
assert(logDir, 'logDir should be set before writing dump file');
|
||||
|
||||
const id = logId || randomUUID();
|
||||
@ -64,10 +64,11 @@ export function writeInsightDump(
|
||||
const length = logContent.push(dataString);
|
||||
logIdIndexMap[id] = length - 1;
|
||||
}
|
||||
writeDumpFile({
|
||||
writeLogFile({
|
||||
fileName: logFileName,
|
||||
fileExt: logFileExt,
|
||||
fileContent: `[\n${logContent.join(',\n')}\n]`,
|
||||
type: 'dump',
|
||||
});
|
||||
|
||||
return id;
|
||||
|
||||
@ -123,6 +123,11 @@ export interface DumpMeta {
|
||||
logTime: number;
|
||||
}
|
||||
|
||||
export interface ReportDumpWithAttributes {
|
||||
dumpString: string;
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface InsightDump extends DumpMeta {
|
||||
type: 'locate' | 'extract' | 'assert';
|
||||
logId: string;
|
||||
@ -288,7 +293,7 @@ export type ExecutionTask<
|
||||
? TaskLog
|
||||
: unknown
|
||||
> & {
|
||||
status: 'pending' | 'running' | 'success' | 'fail' | 'cancelled';
|
||||
status: 'pending' | 'running' | 'success' | 'failed' | 'cancelled';
|
||||
error?: string;
|
||||
errorStack?: string;
|
||||
timing?: {
|
||||
@ -396,5 +401,6 @@ Grouped dump
|
||||
*/
|
||||
export interface GroupedActionDump {
|
||||
groupName: string;
|
||||
groupDescription?: string;
|
||||
executions: ExecutionDump[];
|
||||
}
|
||||
|
||||
@ -8,12 +8,13 @@ import {
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import type { Rect } from './types';
|
||||
import path, { basename, join } from 'node:path';
|
||||
import type { Rect, ReportDumpWithAttributes } from './types';
|
||||
|
||||
interface PkgInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
dir: string;
|
||||
}
|
||||
|
||||
let pkg: PkgInfo | undefined;
|
||||
@ -22,21 +23,19 @@ export function getPkgInfo(): PkgInfo {
|
||||
return pkg;
|
||||
}
|
||||
|
||||
let pkgJsonFile = '';
|
||||
if (existsSync(join(__dirname, '../package.json'))) {
|
||||
pkgJsonFile = join(__dirname, '../package.json');
|
||||
} else if (existsSync(join(__dirname, '../../../package.json'))) {
|
||||
pkgJsonFile = join(__dirname, '../../../package.json');
|
||||
}
|
||||
const pkgDir = findNearestPackageJson(__dirname);
|
||||
assert(pkgDir, 'package.json not found');
|
||||
const pkgJsonFile = join(pkgDir, 'package.json');
|
||||
|
||||
if (pkgJsonFile) {
|
||||
const { name, version } = JSON.parse(readFileSync(pkgJsonFile, 'utf-8'));
|
||||
pkg = { name, version };
|
||||
pkg = { name, version, dir: pkgDir };
|
||||
return pkg;
|
||||
}
|
||||
return {
|
||||
name: 'midscene-unknown-page-name',
|
||||
version: '0.0.0',
|
||||
dir: pkgDir,
|
||||
};
|
||||
}
|
||||
|
||||
@ -45,29 +44,61 @@ let logEnvReady = false;
|
||||
export const insightDumpFileExt = 'insight-dump.json';
|
||||
export const groupedActionDumpFileExt = 'web-dump.json';
|
||||
|
||||
export function getDumpDir() {
|
||||
export function getLogDir() {
|
||||
return logDir;
|
||||
}
|
||||
|
||||
export function setDumpDir(dir: string) {
|
||||
export function setLogDir(dir: string) {
|
||||
logDir = dir;
|
||||
}
|
||||
|
||||
export function getDumpDirPath(type: 'dump' | 'cache') {
|
||||
return join(getDumpDir(), type);
|
||||
export function getLogDirByType(type: 'dump' | 'cache' | 'report') {
|
||||
const dir = join(getLogDir(), type);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function writeDumpFile(opts: {
|
||||
export function writeDumpReport(
|
||||
fileName: string,
|
||||
dumpData: string | ReportDumpWithAttributes[],
|
||||
) {
|
||||
const { dir } = getPkgInfo();
|
||||
const reportTplPath = join(dir, './report/index.html');
|
||||
existsSync(reportTplPath) ||
|
||||
assert(false, `report template not found: ${reportTplPath}`);
|
||||
const reportPath = join(getLogDirByType('report'), `${fileName}.html`);
|
||||
const tpl = readFileSync(reportTplPath, 'utf-8');
|
||||
let reportContent: string;
|
||||
if (typeof dumpData === 'string') {
|
||||
reportContent = tpl.replace(
|
||||
'{{dump}}',
|
||||
`<script type="midscene_web_dump" type="application/json">${dumpData}</script>`,
|
||||
);
|
||||
} else {
|
||||
const dumps = dumpData.map(({ dumpString, attributes }) => {
|
||||
const attributesArr = Object.keys(attributes || {}).map((key) => {
|
||||
return `${key}="${encodeURIComponent(attributes![key])}"`;
|
||||
});
|
||||
return `<script type="midscene_web_dump" type="application/json" ${attributesArr.join(' ')}>${dumpString}</script>`;
|
||||
});
|
||||
reportContent = tpl.replace('{{dump}}', dumps.join('\n'));
|
||||
}
|
||||
writeFileSync(reportPath, reportContent);
|
||||
|
||||
return reportPath;
|
||||
}
|
||||
|
||||
export function writeLogFile(opts: {
|
||||
fileName: string;
|
||||
fileExt: string;
|
||||
fileContent: string;
|
||||
type?: 'dump' | 'cache';
|
||||
type: 'dump' | 'cache' | 'report';
|
||||
generateReport?: boolean;
|
||||
}) {
|
||||
const { fileName, fileExt, fileContent, type = 'dump' } = opts;
|
||||
const targetDir = getDumpDirPath(type);
|
||||
if (!existsSync(targetDir)) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
const targetDir = getLogDirByType(type);
|
||||
// Ensure directory exists
|
||||
if (!logEnvReady) {
|
||||
assert(targetDir, 'logDir should be set before writing dump file');
|
||||
@ -94,8 +125,8 @@ export function writeDumpFile(opts: {
|
||||
const filePath = join(targetDir, `${fileName}.${fileExt}`);
|
||||
writeFileSync(filePath, fileContent);
|
||||
|
||||
if (type === 'dump') {
|
||||
copyFileSync(filePath, join(targetDir, `latest.${fileExt}`));
|
||||
if (opts?.generateReport) {
|
||||
return writeDumpReport(fileName, fileContent);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
@ -141,3 +172,25 @@ export function replacerForPageObject(key: string, value: any) {
|
||||
export function stringifyDumpData(data: any, indents?: number) {
|
||||
return JSON.stringify(data, replacerForPageObject, indents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest package.json file recursively
|
||||
* @param {string} dir - Home directory
|
||||
* @returns {string|null} - The most recent package.json file path or null
|
||||
*/
|
||||
export function findNearestPackageJson(dir: string): string | null {
|
||||
const packageJsonPath = path.join(dir, 'package.json');
|
||||
|
||||
if (existsSync(packageJsonPath)) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
const parentDir = path.dirname(dir);
|
||||
|
||||
// Return null if the root directory has been reached
|
||||
if (parentDir === dir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return findNearestPackageJson(parentDir);
|
||||
}
|
||||
|
||||
@ -169,7 +169,7 @@ describe('executor', () => {
|
||||
const tasks = executor.tasks as ExecutionTaskInsightLocate[];
|
||||
|
||||
expect(tasks.length).toBe(2);
|
||||
expect(tasks[0].status).toBe('fail');
|
||||
expect(tasks[0].status).toBe('failed');
|
||||
expect(tasks[0].error).toBeTruthy();
|
||||
expect(tasks[0].timing!.end).toBeTruthy();
|
||||
expect(tasks[1].status).toBe('cancelled');
|
||||
@ -228,7 +228,7 @@ describe('executor', () => {
|
||||
const tasks = executor.tasks as ExecutionTaskInsightLocate[];
|
||||
|
||||
expect(tasks.length).toBe(2);
|
||||
expect(tasks[0].status).toBe('fail');
|
||||
expect(tasks[0].status).toBe('failed');
|
||||
expect(tasks[0].error).toBeTruthy();
|
||||
expect(tasks[0].timing!.end).toBeTruthy();
|
||||
expect(tasks[1].status).toBe('cancelled');
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
getDumpDir,
|
||||
getLogDir,
|
||||
getTmpDir,
|
||||
getTmpFile,
|
||||
overlapped,
|
||||
setDumpDir,
|
||||
setLogDir,
|
||||
writeDumpReport,
|
||||
} from '@/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@ -17,15 +20,41 @@ describe('utils', () => {
|
||||
expect(testFile.endsWith('.txt')).toBe(true);
|
||||
});
|
||||
|
||||
it('dump dir', () => {
|
||||
const dumpDir = getDumpDir();
|
||||
it('log dir', () => {
|
||||
const dumpDir = getLogDir();
|
||||
expect(dumpDir).toBeTruthy();
|
||||
|
||||
setDumpDir(tmpdir());
|
||||
const dumpDir2 = getDumpDir();
|
||||
setLogDir(tmpdir());
|
||||
const dumpDir2 = getLogDir();
|
||||
expect(dumpDir2).toBe(tmpdir());
|
||||
});
|
||||
|
||||
it('write report file', () => {
|
||||
const content = randomUUID();
|
||||
const reportPath = writeDumpReport('test', content);
|
||||
expect(reportPath).toBeTruthy();
|
||||
const reportContent = readFileSync(reportPath, 'utf-8');
|
||||
expect(reportContent).contains(content);
|
||||
});
|
||||
|
||||
it('write report file with attributes', () => {
|
||||
const content = randomUUID();
|
||||
const reportPath = writeDumpReport('test', [
|
||||
{
|
||||
dumpString: content,
|
||||
attributes: {
|
||||
foo: 'bar',
|
||||
hello: 'world',
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(reportPath).toBeTruthy();
|
||||
const reportContent = readFileSync(reportPath, 'utf-8');
|
||||
expect(reportContent).contains(content);
|
||||
expect(reportContent).contains('foo="bar"');
|
||||
expect(reportContent).contains('hello="world"');
|
||||
});
|
||||
|
||||
it('overlapped', () => {
|
||||
const container = { left: 100, top: 100, width: 100, height: 100 };
|
||||
const target = { left: 150, top: 150, width: 100, height: 100 };
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
chrome >= 51
|
||||
edge >= 15
|
||||
firefox >= 54
|
||||
safari >= 10
|
||||
ios_saf >= 10
|
||||
@ -1,7 +0,0 @@
|
||||
## Documentation
|
||||
|
||||
See https://midscenejs.com/ for details.
|
||||
|
||||
## License
|
||||
|
||||
Midscene is MIT licensed.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,40 +0,0 @@
|
||||
{
|
||||
"test-list": [
|
||||
{
|
||||
"testId": "45161835cecba6378a04-b2821fd5751102caa08c",
|
||||
"title": "ai todo",
|
||||
"status": "passed",
|
||||
"duration": 22204,
|
||||
"location": {
|
||||
"file": "/Users/bytedance/github/midscene/packages/web-integration/tests/e2e/ai-auto-todo.spec.ts",
|
||||
"line": 8,
|
||||
"column": 5
|
||||
},
|
||||
"dumpPath": "/Users/bytedance/github/midscene/packages/web-integration/midscene_run/playwright-45161835cecba6378a04-b2821fd5751102caa08c.web-dump.json"
|
||||
},
|
||||
{
|
||||
"testId": "31de72c0afc13db9dc09-50c9ddc9a1d0c466547f",
|
||||
"title": "ai order2",
|
||||
"status": "passed",
|
||||
"duration": 40848,
|
||||
"location": {
|
||||
"file": "/Users/bytedance/github/midscene/packages/web-integration/tests/e2e/ai-xicha.spec.ts",
|
||||
"line": 36,
|
||||
"column": 5
|
||||
},
|
||||
"dumpPath": "/Users/bytedance/github/midscene/packages/web-integration/midscene_run/playwright-31de72c0afc13db9dc09-50c9ddc9a1d0c466547f.web-dump.json"
|
||||
},
|
||||
{
|
||||
"testId": "31de72c0afc13db9dc09-00e11f768b63da0c779a",
|
||||
"title": "ai order",
|
||||
"status": "passed",
|
||||
"duration": 51045,
|
||||
"location": {
|
||||
"file": "/Users/bytedance/github/midscene/packages/web-integration/tests/e2e/ai-xicha.spec.ts",
|
||||
"line": 9,
|
||||
"column": 5
|
||||
},
|
||||
"dumpPath": "/Users/bytedance/github/midscene/packages/web-integration/midscene_run/playwright-31de72c0afc13db9dc09-00e11f768b63da0c779a.web-dump.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { appTools, defineConfig } from '@modern-js/app-tools';
|
||||
|
||||
// https://modernjs.dev/en/configure/app/usage
|
||||
export default defineConfig({
|
||||
source: {
|
||||
// Prevent pnpm workspace from causing dev dependencies on npm to take effect
|
||||
alias: {
|
||||
react: path.resolve(__dirname, 'node_modules/react'),
|
||||
'react-dom': path.resolve(__dirname, 'node_modules/react-dom'),
|
||||
},
|
||||
mainEntryName: 'index',
|
||||
},
|
||||
runtime: {
|
||||
router: true,
|
||||
},
|
||||
html: {
|
||||
disableHtmlFolder: true,
|
||||
},
|
||||
output: {
|
||||
disableSourceMap: false,
|
||||
distPath: {
|
||||
html: '.',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
appTools({
|
||||
bundler: 'experimental-rspack',
|
||||
}),
|
||||
],
|
||||
});
|
||||
@ -1,46 +0,0 @@
|
||||
{
|
||||
"name": "@midscene/visualizer-report",
|
||||
"version": "0.2.2",
|
||||
"scripts": {
|
||||
"reset": "npx rimraf ./**/node_modules",
|
||||
"dev": "modern dev",
|
||||
"build": "modern build",
|
||||
"start": "modern start",
|
||||
"serve": "modern serve",
|
||||
"new": "modern new",
|
||||
"lint": "modern lint",
|
||||
"upgrade": "modern upgrade"
|
||||
},
|
||||
"files": ["dist", "README.md"],
|
||||
"engines": {
|
||||
"node": ">=16.18.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,mjs,cjs}": [
|
||||
"node --max_old_space_size=8192 ./node_modules/eslint/bin/eslint.js --fix --color --cache --quiet"
|
||||
]
|
||||
},
|
||||
"eslintIgnore": ["node_modules/", "dist/"],
|
||||
"dependencies": {
|
||||
"@modern-js/runtime": "^2.56.2",
|
||||
"@midscene/visualizer": "workspace:*",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"antd": "5.19.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modern-js/app-tools": "2.56.2",
|
||||
"@modern-js/eslint-config": "2.56.2",
|
||||
"@modern-js/tsconfig": "2.56.2",
|
||||
"@modern-js-app/eslint-config": "2.56.2",
|
||||
"typescript": "~5.0.4",
|
||||
"@types/jest": "~29.2.4",
|
||||
"@types/node": "^18.0.0",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"lint-staged": "~13.1.0",
|
||||
"prettier": "^3.3.3",
|
||||
"husky": "~8.0.1",
|
||||
"rimraf": "~3.0.2"
|
||||
}
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { BrowserRouter, Route, Routes } from '@modern-js/runtime/router';
|
||||
import { Home } from './pages/Home';
|
||||
import { Report } from './pages/Report';
|
||||
|
||||
export default () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="report" element={<Report />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
@ -1,3 +0,0 @@
|
||||
/// <reference types='@modern-js/app-tools/types' />
|
||||
/// <reference types='@modern-js/runtime/types' />
|
||||
/// <reference types='@modern-js/runtime/types/router' />
|
||||
@ -1,3 +0,0 @@
|
||||
import { defineRuntimeConfig } from '@modern-js/runtime';
|
||||
|
||||
export default defineRuntimeConfig({});
|
||||
@ -1,43 +0,0 @@
|
||||
.nav {
|
||||
/* display: flex;
|
||||
justify-content: center; */
|
||||
max-width: 680px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
|
||||
.test-details {
|
||||
margin-top: -1px;
|
||||
cursor: pointer;
|
||||
padding: 20px;
|
||||
border-top: 1px solid #ccc;
|
||||
}
|
||||
.test-details:hover {
|
||||
background-color: #d3d3d3;
|
||||
}
|
||||
|
||||
.test-info {
|
||||
display: flex;
|
||||
}
|
||||
.test-name{
|
||||
flex-grow: 1;
|
||||
font-weight: bold;
|
||||
font-size: medium;
|
||||
}
|
||||
|
||||
.failed{
|
||||
font-size: small;
|
||||
}
|
||||
.test-file-path {
|
||||
color: #6e7781;
|
||||
margin-top: 10px;
|
||||
}
|
||||
@ -1,230 +0,0 @@
|
||||
import { useNavigate } from '@modern-js/runtime/router';
|
||||
import { Collapse, Menu } from 'antd';
|
||||
import type { CollapseProps, MenuProps } from 'antd';
|
||||
import type React from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import styeld from './Home.module.css';
|
||||
import './TestResult.css';
|
||||
|
||||
type TestStatus = 'passed' | 'failed' | 'flaky' | 'skipped';
|
||||
|
||||
type TestData = {
|
||||
testId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
location: {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
};
|
||||
dumpPath: string;
|
||||
};
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
export type Stats = {
|
||||
total: number;
|
||||
passed: number;
|
||||
failed: number;
|
||||
flaky: number;
|
||||
skipped: number;
|
||||
ok: boolean;
|
||||
};
|
||||
|
||||
const statusIcon = (status: TestStatus) => {
|
||||
switch (status) {
|
||||
case 'failed':
|
||||
return <span className="failed">❌</span>;
|
||||
case 'flaky':
|
||||
return <span className="flaky">⚠️</span>;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const TestResult = (props: {
|
||||
status: string;
|
||||
statusDataList: {
|
||||
[status: string]: TestData[];
|
||||
};
|
||||
}) => {
|
||||
const navigator = useNavigate();
|
||||
const onChange = (key: string | string[]) => {
|
||||
console.log(key);
|
||||
};
|
||||
|
||||
const testDataList =
|
||||
props.status === 'all'
|
||||
? Object.keys(props.statusDataList).reduce((res, status) => {
|
||||
res.push(...props.statusDataList[status]);
|
||||
return res;
|
||||
}, [] as TestData[])
|
||||
: props.statusDataList[props.status];
|
||||
const groupTestDataWithFileName =
|
||||
testDataList?.reduce(
|
||||
(res, next) => {
|
||||
if (!res[next.location.file]) {
|
||||
res[next.location.file] = [];
|
||||
}
|
||||
res[next.location.file].push(next);
|
||||
return res;
|
||||
},
|
||||
{} as {
|
||||
[fileName: string]: TestData[];
|
||||
},
|
||||
) || {};
|
||||
|
||||
const items: CollapseProps['items'] = Object.keys(
|
||||
groupTestDataWithFileName,
|
||||
).map((fileName, key) => {
|
||||
return {
|
||||
key,
|
||||
label: fileName,
|
||||
children: groupTestDataWithFileName[fileName].map((testData, key) => {
|
||||
const timeMinutes = Math.floor(testData.duration / 1000 / 60);
|
||||
const timeSeconds = Math.floor((testData.duration / 1000) % 60);
|
||||
return (
|
||||
<div
|
||||
className={styeld['test-details']}
|
||||
key={key}
|
||||
onClick={() => {
|
||||
navigator(`/report?dumpId=${testData.dumpPath.split('/').pop()}`);
|
||||
}}
|
||||
>
|
||||
<div className={styeld['test-info']}>
|
||||
<span className={styeld['test-name']}>
|
||||
{statusIcon(testData.status as TestStatus)}
|
||||
{testData.title}
|
||||
</span>
|
||||
<span>
|
||||
duration: {timeMinutes !== 0 && `${timeMinutes}m`}{' '}
|
||||
{timeSeconds && `${timeSeconds}s`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styeld['test-file-path']}>
|
||||
{testData.location.file}:{testData.location.line}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
};
|
||||
});
|
||||
return (
|
||||
<Collapse
|
||||
className={styeld['test-result']}
|
||||
activeKey={[...Array(items.length).keys()]}
|
||||
items={items}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatsNavView: React.FC<{
|
||||
stats: Stats;
|
||||
statusDataList: {
|
||||
[stats: string]: TestData[];
|
||||
};
|
||||
}> = ({ stats, statusDataList }) => {
|
||||
// eslint-disable-next-line node/prefer-global/url-search-params
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const navigate = useNavigate();
|
||||
const q = searchParams.get('status')?.toString() || '';
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
label: `All (${stats.total - stats.skipped})`,
|
||||
key: 'all',
|
||||
},
|
||||
{
|
||||
label: `Passed (${stats.passed})`,
|
||||
key: 'passed',
|
||||
},
|
||||
{
|
||||
label: `Failed (${stats.failed})`,
|
||||
key: 'failed',
|
||||
icon: statusIcon('failed'),
|
||||
},
|
||||
{
|
||||
label: `Flaky (${stats.flaky})`,
|
||||
key: 'flaky',
|
||||
icon: statusIcon('flaky'),
|
||||
},
|
||||
{
|
||||
label: `Skipped (${stats.skipped})`,
|
||||
key: 'skipped',
|
||||
icon: statusIcon('skipped'),
|
||||
},
|
||||
];
|
||||
|
||||
const [status, setStatus] = useState(q || 'all');
|
||||
|
||||
const onClick: MenuProps['onClick'] = (e) => {
|
||||
navigate(`?status=${e.key}`);
|
||||
setStatus(e.key);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styeld.nav}>
|
||||
<Menu
|
||||
onClick={onClick}
|
||||
selectedKeys={[status]}
|
||||
mode="horizontal"
|
||||
items={items}
|
||||
/>
|
||||
</div>
|
||||
<TestResult status={status} statusDataList={statusDataList} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function Home() {
|
||||
const [testDataList, setTestDataJson] = useState<Array<TestData>>([]);
|
||||
const [isLoading, setLoading] = useState<any>(true);
|
||||
useEffect(() => {
|
||||
fetch('/public/test-data-list.json')
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setTestDataJson(data['test-list']);
|
||||
console.log('data', data, data['test-list']); // 在此处处理 JSON 数据
|
||||
})
|
||||
.catch((error) => console.error('Error:', error))
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
function TestResultReport(props: { testDataList: TestData[] }) {
|
||||
const { testDataList } = props;
|
||||
const statusDataList = testDataList?.reduce(
|
||||
(res, next) => {
|
||||
res[next.status] = [...(res[next.status] || []), next];
|
||||
return res;
|
||||
},
|
||||
{} as {
|
||||
[stats: string]: Array<TestData>;
|
||||
},
|
||||
);
|
||||
console.log('statusDataList', testDataList, statusDataList);
|
||||
const total = testDataList.length;
|
||||
const passed = statusDataList.passed?.length || 0;
|
||||
const failed = statusDataList.failed?.length || 0;
|
||||
const flaky = statusDataList.flaky?.length || 0;
|
||||
const skipped = statusDataList.skipped?.length || 0;
|
||||
const ok = Boolean(total === passed);
|
||||
|
||||
return (
|
||||
<div className={styeld.container}>
|
||||
<StatsNavView
|
||||
stats={{ total, passed, failed, flaky, skipped, ok }}
|
||||
statusDataList={statusDataList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <>{!isLoading && <TestResultReport testDataList={testDataList} />}</>;
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
import { Visualizer } from '@midscene/visualizer';
|
||||
import { useNavigate } from '@modern-js/runtime/router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
declare module '@midscene/visualizer' {
|
||||
export function Visualizer(dumpInfo: any): any;
|
||||
}
|
||||
|
||||
export function Report() {
|
||||
const navigation = useNavigate();
|
||||
const [dumpJson, setDumpJson] = useState<any>(null);
|
||||
const [isLoading, setLoading] = useState<any>(true);
|
||||
// eslint-disable-next-line node/prefer-global/url-search-params
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const dumpId = searchParams.get('dumpId')?.toString() || '';
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/public/${dumpId}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setDumpJson(data);
|
||||
console.log('data', data); // 在此处处理 JSON 数据
|
||||
})
|
||||
.catch((error) => console.error('Error:', error))
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [dumpId]);
|
||||
return (
|
||||
<div className="container-box">
|
||||
<div>
|
||||
<main>
|
||||
{!isLoading && (
|
||||
<Visualizer
|
||||
dump={dumpJson}
|
||||
logoAction={() => {
|
||||
navigation('/');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
.test-result {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 8px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.test-summary {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.test-summary div {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.test-project,
|
||||
.test-file,
|
||||
.test-status,
|
||||
.test-time {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.test-status.failed {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.test-details {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.test-name {
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.test-duration {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 0!important;
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"extends": "@modern-js/tsconfig/base",
|
||||
"compilerOptions": {
|
||||
"declaration": false,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["./shared/*"]
|
||||
},
|
||||
"types": ["react"]
|
||||
},
|
||||
"include": ["src", "shared", "config", "modern.config.ts"],
|
||||
"exclude": ["**/node_modules"]
|
||||
}
|
||||
29
packages/visualizer/html/tpl.html
Normal file
29
packages/visualizer/html/tpl.html
Normal file
@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" />
|
||||
<link rel="icon" type="image/png" sizes="32x32"
|
||||
href="https://lf3-static.bytednsdoc.com/obj/eden-cn/vhaeh7vhabf/favicon-32x32.png">
|
||||
|
||||
<title>Midscene Visualizer</title>
|
||||
{{css}}
|
||||
|
||||
{{js}}
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- it should be replaced by the actual content -->
|
||||
{{dump}}
|
||||
|
||||
<div id="app" style="width: 100vw; height: 100vh;"></div>
|
||||
<script>
|
||||
console.log(midSceneVisualizer);
|
||||
midSceneVisualizer.default.mount('app');
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,20 +1,39 @@
|
||||
import { defineConfig, moduleTools } from '@modern-js/module-tools';
|
||||
import { modulePluginDoc } from '@modern-js/plugin-module-doc';
|
||||
|
||||
export default defineConfig({
|
||||
buildConfig: {
|
||||
asset: {
|
||||
svgr: true,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
moduleTools(),
|
||||
modulePluginDoc({
|
||||
doc: {
|
||||
sidebar: false,
|
||||
hideNavbar: true,
|
||||
buildConfig: [
|
||||
{
|
||||
asset: {
|
||||
svgr: true,
|
||||
},
|
||||
}),
|
||||
format: 'umd',
|
||||
umdModuleName: 'midSceneVisualizer',
|
||||
autoExternal: false,
|
||||
externals: [],
|
||||
dts: false,
|
||||
platform: 'browser',
|
||||
outDir: 'dist/report',
|
||||
minify: {
|
||||
compress: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
asset: {
|
||||
svgr: true,
|
||||
},
|
||||
format: 'esm',
|
||||
input: {
|
||||
index: 'src/index.tsx',
|
||||
},
|
||||
autoExternal: false,
|
||||
externals: [],
|
||||
dts: false,
|
||||
platform: 'browser',
|
||||
minify: {
|
||||
compress: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: [moduleTools()],
|
||||
buildPreset: 'npm-component',
|
||||
});
|
||||
|
||||
@ -2,30 +2,36 @@
|
||||
"name": "@midscene/visualizer",
|
||||
"version": "0.2.2",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"jsnext:source": "./src/index.ts",
|
||||
"main": "./dist/lib/index.js",
|
||||
"module": "./dist/es/index.js",
|
||||
"files": ["dist", "README.md"],
|
||||
"files": ["dist", "html", "README.md"],
|
||||
"watch": {
|
||||
"build": {
|
||||
"patterns": ["src"],
|
||||
"extensions": "tsx,less,scss,css,js,jsx,ts",
|
||||
"quiet": false
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "modern dev",
|
||||
"build": "modern build",
|
||||
"dev": "npm run build && npm-watch",
|
||||
"build": "modern build && npx ts-node scripts/build-html.ts",
|
||||
"build:watch": "modern build -w",
|
||||
"serve": "http-server ./dist -p 3000",
|
||||
"new": "modern new",
|
||||
"upgrade": "modern upgrade"
|
||||
},
|
||||
"dependencies": {
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"npm-watch": "0.13.0",
|
||||
"ts-node": "10.9.2",
|
||||
"@ant-design/icons": "5.3.7",
|
||||
"@midscene/core": "workspace:*",
|
||||
"@modern-js/runtime": "^2.56.2",
|
||||
"antd": "5.19.3",
|
||||
"dayjs": "1.11.11",
|
||||
"pixi.js": "8.1.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable-panels": "2.0.22",
|
||||
"zustand": "4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"zustand": "4.5.2",
|
||||
"@modern-js/module-tools": "^2.56.1",
|
||||
"@modern-js/plugin-module-doc": "^2.33.1",
|
||||
"@types/react": "18.3.3",
|
||||
|
||||
89
packages/visualizer/scripts/build-html.ts
Normal file
89
packages/visualizer/scripts/build-html.ts
Normal file
@ -0,0 +1,89 @@
|
||||
/* this is a builder for HTML files
|
||||
Step:
|
||||
* Read the HTML tpl from './html/tpl.html'
|
||||
* Replace the placeholders with the actual values
|
||||
* {{css}} --> {{./dist/index.css}}
|
||||
* {{js}} --> {{./dist/index.js}}
|
||||
* Write the result to './dist/index.html'
|
||||
*
|
||||
*/
|
||||
|
||||
import { strict as assert } from 'node:assert';
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const htmlPath = join(__dirname, '../html/tpl.html');
|
||||
const cssPath = join(__dirname, '../dist/report/index.css');
|
||||
const jsPath = join(__dirname, '../dist/report/index.js');
|
||||
const demoPath = join(__dirname, './fixture/demo-dump.json');
|
||||
const multiEntrySegment = join(__dirname, './fixture/multi-entries.html');
|
||||
const outputHTML = join(__dirname, '../dist/report/index.html');
|
||||
const outputDemoHTML = join(__dirname, '../dist/report/demo.html');
|
||||
const outputMultiEntriesHTML = join(__dirname, '../dist/report/multi.html');
|
||||
|
||||
function ensureDirectoryExistence(filePath: string) {
|
||||
const directoryPath = dirname(filePath);
|
||||
|
||||
if (existsSync(directoryPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
mkdirSync(directoryPath, { recursive: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
function tplReplacer(tpl: string, obj: Record<string, string>) {
|
||||
return tpl.replace(/{{\s*(\w+)\s*}}/g, (_, key) => {
|
||||
return obj[key] || `{{${key}}}`; // keep the placeholder if not found
|
||||
});
|
||||
}
|
||||
|
||||
function copyToCore() {
|
||||
const corePath = join(__dirname, '../../midscene/report/index.html');
|
||||
ensureDirectoryExistence(corePath);
|
||||
copyFileSync(outputHTML, corePath);
|
||||
console.log(`HTML file copied to core successfully: ${corePath}`);
|
||||
}
|
||||
|
||||
function build() {
|
||||
const html = readFileSync(htmlPath, 'utf-8');
|
||||
const css = readFileSync(cssPath, 'utf-8');
|
||||
const js = readFileSync(jsPath, 'utf-8');
|
||||
|
||||
const result = tplReplacer(html, {
|
||||
css: `<style>\n${css}\n</style>\n`,
|
||||
js: `<script>\n${js}\n</script>`,
|
||||
});
|
||||
|
||||
assert(result.length >= 1000);
|
||||
writeFileSync(outputHTML, result);
|
||||
console.log(`HTML file generated successfully: ${outputHTML}`);
|
||||
|
||||
const demoData = readFileSync(demoPath, 'utf-8');
|
||||
const resultWithDemo = tplReplacer(html, {
|
||||
css: `<style>\n${css}\n</style>\n`,
|
||||
js: `<script>\n${js}\n</script>`,
|
||||
dump: `<script type="midscene_web_dump" type="application/json">${demoData}</script>`,
|
||||
});
|
||||
writeFileSync(outputDemoHTML, resultWithDemo);
|
||||
console.log(`HTML file generated successfully: ${outputDemoHTML}`);
|
||||
|
||||
const multiEntriesData = readFileSync(multiEntrySegment, 'utf-8');
|
||||
const resultWithMultiEntries = tplReplacer(html, {
|
||||
css: `<style>\n${css}\n</style>\n`,
|
||||
js: `<script>\n${js}\n</script>`,
|
||||
dump: multiEntriesData,
|
||||
});
|
||||
writeFileSync(outputMultiEntriesHTML, resultWithMultiEntries);
|
||||
console.log(`HTML file generated successfully: ${outputMultiEntriesHTML}`);
|
||||
|
||||
copyToCore();
|
||||
}
|
||||
|
||||
build();
|
||||
4971
packages/visualizer/scripts/fixture/demo-dump.json
Normal file
4971
packages/visualizer/scripts/fixture/demo-dump.json
Normal file
File diff suppressed because one or more lines are too long
23801
packages/visualizer/scripts/fixture/multi-entries.html
Normal file
23801
packages/visualizer/scripts/fixture/multi-entries.html
Normal file
File diff suppressed because one or more lines are too long
13
packages/visualizer/scripts/tsconfig.json
Normal file
13
packages/visualizer/scripts/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
BIN
packages/visualizer/src/component/assets/logo-plain.png
Normal file
BIN
packages/visualizer/src/component/assets/logo-plain.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
@ -30,6 +30,13 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.screenshot-item-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.screenshot-item {
|
||||
margin-bottom: @layout-space;
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@ const DetailPanel = (): JSX.Element => {
|
||||
} else if (viewType === VIEW_TYPE_SCREENSHOT) {
|
||||
if (activeTask.recorder?.length) {
|
||||
content = (
|
||||
<div>
|
||||
<div className="screenshot-item-wrapper">
|
||||
{activeTask.recorder
|
||||
.filter((item) => item.screenshot)
|
||||
.map((item, index) => {
|
||||
|
||||
@ -1,3 +1,12 @@
|
||||
import {
|
||||
ArrowRightOutlined,
|
||||
CheckCircleFilled,
|
||||
ClockCircleFilled,
|
||||
CloseCircleFilled,
|
||||
LogoutOutlined,
|
||||
MinusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
export function timeCostStrElement(timeCost?: number) {
|
||||
let str: string;
|
||||
if (typeof timeCost !== 'number') {
|
||||
@ -18,3 +27,34 @@ export function timeCostStrElement(timeCost?: number) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// playwright status: 'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted';
|
||||
|
||||
export const iconForStatus = (status: string): JSX.Element => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
case 'passed':
|
||||
return (
|
||||
<span style={{ color: '#2B8243' }}>
|
||||
<CheckCircleFilled />
|
||||
</span>
|
||||
);
|
||||
case 'failed':
|
||||
case 'timedOut':
|
||||
case 'interrupted':
|
||||
return (
|
||||
<span style={{ color: '#FF0A0A' }}>
|
||||
<CloseCircleFilled />
|
||||
</span>
|
||||
);
|
||||
case 'pending':
|
||||
return <ClockCircleFilled />;
|
||||
case 'cancelled':
|
||||
case 'skipped':
|
||||
return <LogoutOutlined />;
|
||||
case 'running':
|
||||
return <ArrowRightOutlined />;
|
||||
default:
|
||||
return <MinusOutlined />;
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
@import './common.less';
|
||||
|
||||
.task-list-name {
|
||||
padding: 2px @side-horizontal-padding;
|
||||
font-weight: bold;
|
||||
|
||||
.panel-title {
|
||||
background: @title-bg;
|
||||
border-top: 1px solid @border-color;
|
||||
border-bottom: 1px solid @border-color;
|
||||
margin-top: -1px;
|
||||
}
|
||||
padding: 2px @side-horizontal-padding;
|
||||
.task-list-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
import './panel-title.less';
|
||||
|
||||
const PanelTitle = (props: { title: string }): JSX.Element => {
|
||||
const PanelTitle = (props: {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
}): JSX.Element => {
|
||||
const subTitleEl = props.subTitle ? (
|
||||
<div className="task-list-sub-name">{props.subTitle}</div>
|
||||
) : null;
|
||||
return (
|
||||
<div>
|
||||
<div className="panel-title">
|
||||
<div className="task-list-name">{props.title}</div>
|
||||
{subTitleEl}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -9,21 +9,7 @@
|
||||
border-right: 1px solid @border-color;
|
||||
overflow: auto;
|
||||
background: @side-bg;
|
||||
|
||||
.brand {
|
||||
padding: @side-horizontal-padding 5px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
// margin-bottom: 10px;
|
||||
|
||||
// .head_name {
|
||||
// display: inline-block;
|
||||
// margin-left: 12px;
|
||||
// margin-right: 30px;
|
||||
// cursor: pointer;
|
||||
// font-weight: bold;
|
||||
// }
|
||||
}
|
||||
box-sizing: border-box;
|
||||
|
||||
.task-meta-section {
|
||||
margin-top: 6px;
|
||||
@ -99,14 +85,6 @@
|
||||
top: 50%;
|
||||
margin-top: -5px;
|
||||
}
|
||||
|
||||
.status-icon-success {
|
||||
color:#2B8243;
|
||||
}
|
||||
|
||||
.status-icon-fail {
|
||||
color: rgb(255, 10, 10);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: @weak-text;
|
||||
|
||||
@ -1,20 +1,12 @@
|
||||
import './sidebar.less';
|
||||
import { useAllCurrentTasks, useExecutionDump } from '@/component/store';
|
||||
import { typeStr } from '@/utils';
|
||||
import {
|
||||
ArrowRightOutlined,
|
||||
CheckCircleFilled,
|
||||
ClockCircleFilled,
|
||||
CloseCircleFilled,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
MinusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ExecutionTask, ExecutionTaskInsightQuery } from '@midscene/core';
|
||||
import { MessageOutlined } from '@ant-design/icons';
|
||||
import type { ExecutionTask } from '@midscene/core';
|
||||
import { Button } from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
import Logo from './assets/logo-plain2.svg';
|
||||
import { timeCostStrElement } from './misc';
|
||||
// import Logo from './assets/logo-plain2.svg';
|
||||
import { iconForStatus, timeCostStrElement } from './misc';
|
||||
import PanelTitle from './panel-title';
|
||||
|
||||
const SideItem = (props: {
|
||||
@ -26,19 +18,6 @@ const SideItem = (props: {
|
||||
const { task, onClick, selected } = props;
|
||||
|
||||
const selectedClass = selected ? 'selected' : '';
|
||||
let statusIcon = <MinusOutlined />;
|
||||
if (task.status === 'success') {
|
||||
statusIcon = <CheckCircleFilled />;
|
||||
} else if (task.status === 'fail') {
|
||||
statusIcon = <CloseCircleFilled />;
|
||||
} else if (task.status === 'pending') {
|
||||
statusIcon = <ClockCircleFilled />;
|
||||
} else if (task.status === 'cancelled') {
|
||||
statusIcon = <LogoutOutlined />;
|
||||
} else if (task.status === 'running') {
|
||||
statusIcon = <ArrowRightOutlined />;
|
||||
}
|
||||
|
||||
let statusText: JSX.Element | string = task.status;
|
||||
if (task.timing?.cost) {
|
||||
statusText = timeCostStrElement(task.timing.cost);
|
||||
@ -61,9 +40,7 @@ const SideItem = (props: {
|
||||
>
|
||||
{' '}
|
||||
<div className={'side-item-name'}>
|
||||
<span className={`status-icon status-icon-${task.status}`}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
<span className="status-icon">{iconForStatus(task.status)}</span>
|
||||
<div className="title">{typeStr(task)}</div>
|
||||
<div className="status-text">{statusText}</div>
|
||||
</div>
|
||||
@ -72,11 +49,8 @@ const SideItem = (props: {
|
||||
);
|
||||
};
|
||||
|
||||
const Sidebar = (props: {
|
||||
hideLogo?: boolean;
|
||||
logoAction?: () => void;
|
||||
}): JSX.Element => {
|
||||
const groupedDumps = useExecutionDump((store) => store.dump);
|
||||
const Sidebar = (props: { logoAction?: () => void }): JSX.Element => {
|
||||
const groupedDump = useExecutionDump((store) => store.dump);
|
||||
const setActiveTask = useExecutionDump((store) => store.setActiveTask);
|
||||
const activeTask = useExecutionDump((store) => store.activeTask);
|
||||
const setHoverTask = useExecutionDump((store) => store.setHoverTask);
|
||||
@ -123,8 +97,8 @@ const Sidebar = (props: {
|
||||
};
|
||||
}, [currentSelectedIndex, allTasks, setActiveTask]);
|
||||
|
||||
const sideList = groupedDumps?.length ? (
|
||||
groupedDumps.map((group, groupIndex) => {
|
||||
const sideList = groupedDump ? (
|
||||
[groupedDump].map((group, groupIndex) => {
|
||||
const executions = group.executions.map((execution, indexOfExecution) => {
|
||||
const { tasks } = execution;
|
||||
const taskList = tasks.map((task, index) => {
|
||||
@ -191,22 +165,6 @@ const Sidebar = (props: {
|
||||
return (
|
||||
<div className="side-bar">
|
||||
<div className="top-controls">
|
||||
<div
|
||||
className="brand"
|
||||
onClick={reset}
|
||||
style={{ display: props?.hideLogo ? 'none' : 'flex' }}
|
||||
>
|
||||
<Logo
|
||||
style={{ width: 70, height: 70, margin: 'auto' }}
|
||||
onClick={() => {
|
||||
if (props.logoAction) {
|
||||
props.logoAction();
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="task-list">{sideList}</div>
|
||||
<div className="side-seperator side-seperator-line side-seperator-space-up" />
|
||||
<div className="task-meta-section">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import * as Z from 'zustand';
|
||||
// import { createStore } from 'zustand/vanilla';
|
||||
import type {
|
||||
BaseElement,
|
||||
ExecutionTask,
|
||||
@ -7,6 +8,7 @@ import type {
|
||||
InsightDump,
|
||||
} from '../../../midscene/dist/types';
|
||||
|
||||
const { create } = Z;
|
||||
export const useBlackboardPreference = create<{
|
||||
bgVisible: boolean;
|
||||
elementsVisible: boolean;
|
||||
@ -24,8 +26,8 @@ export const useBlackboardPreference = create<{
|
||||
}));
|
||||
|
||||
export const useExecutionDump = create<{
|
||||
dump: GroupedActionDump[] | null;
|
||||
setGroupedDump: (dump: GroupedActionDump[]) => void;
|
||||
dump: GroupedActionDump | null;
|
||||
setGroupedDump: (dump: GroupedActionDump) => void;
|
||||
activeTask: ExecutionTask | null;
|
||||
setActiveTask: (task: ExecutionTask) => void;
|
||||
hoverTask: ExecutionTask | null;
|
||||
@ -55,18 +57,17 @@ export const useExecutionDump = create<{
|
||||
|
||||
return {
|
||||
...initData,
|
||||
setGroupedDump: (dump: GroupedActionDump[]) => {
|
||||
setGroupedDump: (dump: GroupedActionDump) => {
|
||||
console.log('will set ExecutionDump', dump);
|
||||
set({
|
||||
...initData,
|
||||
dump,
|
||||
});
|
||||
resetInsightDump();
|
||||
|
||||
// set the first one as selected
|
||||
for (const item of dump) {
|
||||
if (item.executions.length > 0 && item.executions[0].tasks.length > 0) {
|
||||
get().setActiveTask(item.executions[0].tasks[0]);
|
||||
break;
|
||||
}
|
||||
// set the first task as selected
|
||||
if (dump.executions.length > 0 && dump.executions[0].tasks.length > 0) {
|
||||
get().setActiveTask(dump.executions[0].tasks[0]);
|
||||
}
|
||||
},
|
||||
setActiveTask(task: ExecutionTask) {
|
||||
@ -101,19 +102,15 @@ export const useExecutionDump = create<{
|
||||
});
|
||||
|
||||
export const useAllCurrentTasks = (): ExecutionTask[] => {
|
||||
const groupedDumps = useExecutionDump((store) => store.dump);
|
||||
const groupedDump = useExecutionDump((store) => store.dump);
|
||||
if (!groupedDump) return [];
|
||||
|
||||
const allTasks =
|
||||
groupedDumps?.reduce<ExecutionTask[]>((acc, group) => {
|
||||
const tasksInside = group.executions.reduce<ExecutionTask[]>(
|
||||
(acc2, execution) => acc2.concat(execution.tasks),
|
||||
[],
|
||||
);
|
||||
const tasksInside = groupedDump.executions.reduce<ExecutionTask[]>(
|
||||
(acc2, execution) => acc2.concat(execution.tasks),
|
||||
[],
|
||||
);
|
||||
|
||||
return acc.concat(tasksInside);
|
||||
}, []) || [];
|
||||
|
||||
return allTasks;
|
||||
return tasksInside;
|
||||
};
|
||||
|
||||
export const useInsightDump = create<{
|
||||
|
||||
File diff suppressed because one or more lines are too long
4
packages/visualizer/src/global.d.ts
vendored
4
packages/visualizer/src/global.d.ts
vendored
@ -9,6 +9,10 @@ declare module '*.svg' {
|
||||
export default ReactComponent;
|
||||
}
|
||||
|
||||
declare module '*.png' {
|
||||
export default string;
|
||||
}
|
||||
|
||||
declare module '*.svg?react' {
|
||||
const ReactComponent: React.FunctionComponent<
|
||||
React.SVGProps<SVGSVGElement> & {
|
||||
|
||||
@ -6,8 +6,12 @@ html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
// font-size: 3.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.rspress-nav {
|
||||
transition: .2s;
|
||||
}
|
||||
@ -42,6 +46,11 @@ footer.mt-8{
|
||||
// ----------
|
||||
|
||||
.page-container {
|
||||
|
||||
blockquote, dl, dd, h1, h2, h3, h4, h5, h6, hr, figure, p, pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@ -50,6 +59,35 @@ footer.mt-8{
|
||||
font-size: 14px;
|
||||
border-top: 1px solid @border-color;
|
||||
border-bottom: 1px solid @border-color;
|
||||
font-synthesis: style;
|
||||
text-rendering: optimizelegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.page-nav {
|
||||
height: 30px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid @border-color;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.logo img {
|
||||
height: 20px;
|
||||
line-height: 30px;
|
||||
vertical-align: baseline;
|
||||
vertical-align: -webkit-baseline-middle;
|
||||
}
|
||||
|
||||
.playwright-case-selector {
|
||||
margin-left: 20px;
|
||||
line-height: 30px;
|
||||
// width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.cost-str {
|
||||
color: @weak-text;
|
||||
}
|
||||
|
||||
.ant-layout {
|
||||
|
||||
@ -2,28 +2,33 @@ import './index.less';
|
||||
import DetailSide from '@/component/detail-side';
|
||||
import Sidebar from '@/component/sidebar';
|
||||
import { useExecutionDump } from '@/component/store';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
import type { GroupedActionDump } from '@midscene/core';
|
||||
import { Helmet } from '@modern-js/runtime/head';
|
||||
import { Button, ConfigProvider, Upload, message } from 'antd';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ConfigProvider, Dropdown, Select, Upload, message } from 'antd';
|
||||
import type { MenuProps, UploadProps } from 'antd';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import Logo from './component/assets/logo-plain.svg';
|
||||
import logo from './component/assets/logo-plain.png';
|
||||
import DetailPanel from './component/detail-panel';
|
||||
import GlobalHoverPreview from './component/global-hover-preview';
|
||||
import { iconForStatus, timeCostStrElement } from './component/misc';
|
||||
import Timeline from './component/timeline';
|
||||
import demoDump from './demo-dump.json';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
|
||||
let globalRenderCount = 1;
|
||||
|
||||
interface ExecutionDumpWithPlaywrightAttributes extends GroupedActionDump {
|
||||
attributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export function Visualizer(props: {
|
||||
hideLogo?: boolean;
|
||||
logoAction?: () => void;
|
||||
dump?: GroupedActionDump[];
|
||||
hideLogo?: boolean;
|
||||
dumps?: ExecutionDumpWithPlaywrightAttributes[];
|
||||
}): JSX.Element {
|
||||
const { dump } = props;
|
||||
const { dumps, hideLogo = false } = props;
|
||||
|
||||
const executionDump = useExecutionDump((store) => store.dump);
|
||||
const setGroupedDump = useExecutionDump((store) => store.setGroupedDump);
|
||||
@ -32,8 +37,8 @@ export function Visualizer(props: {
|
||||
const mainLayoutChangedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (dump) {
|
||||
setGroupedDump(dump);
|
||||
if (dumps) {
|
||||
setGroupedDump(dumps[0]);
|
||||
}
|
||||
return () => {
|
||||
reset();
|
||||
@ -60,8 +65,6 @@ export function Visualizer(props: {
|
||||
},
|
||||
beforeUpload(file) {
|
||||
const ifValidFile = file.name.endsWith('web-dump.json'); // || file.name.endsWith('.insight.json');
|
||||
// const ifActionFile =
|
||||
// file.name.endsWith('.actions.json') || /_force_regard_as_action_file/.test(location.href);
|
||||
if (!ifValidFile) {
|
||||
message.error('invalid file extension');
|
||||
return false;
|
||||
@ -73,12 +76,7 @@ export function Visualizer(props: {
|
||||
if (typeof result === 'string') {
|
||||
try {
|
||||
const data = JSON.parse(result);
|
||||
// setMainLayoutChangeFlag((prev) => prev + 1);
|
||||
setGroupedDump(data);
|
||||
// if (ifActionFile) {
|
||||
// } else {
|
||||
// loadInsightDump(data);
|
||||
// }
|
||||
setGroupedDump(data[0]);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
message.error('failed to parse dump data', e.message);
|
||||
@ -91,17 +89,17 @@ export function Visualizer(props: {
|
||||
},
|
||||
};
|
||||
|
||||
const loadDemoDump = () => {
|
||||
setGroupedDump(demoDump as any);
|
||||
};
|
||||
|
||||
let mainContent: JSX.Element;
|
||||
if (!executionDump) {
|
||||
mainContent = (
|
||||
<div className="main-right uploader-wrapper">
|
||||
<Dragger className="uploader" {...uploadProps}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<Logo style={{ width: 100, height: 100, margin: 'auto' }} />
|
||||
<img
|
||||
alt="Midscene_logo"
|
||||
style={{ width: 80, margin: 'auto' }}
|
||||
src={logo}
|
||||
/>
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
Click or drag the{' '}
|
||||
@ -125,11 +123,11 @@ export function Visualizer(props: {
|
||||
sent to the server.
|
||||
</p>
|
||||
</Dragger>
|
||||
<div className="demo-loader">
|
||||
{/* <div className="demo-loader">
|
||||
<Button type="link" onClick={loadDemoDump}>
|
||||
Load Demo
|
||||
</Button>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -146,7 +144,7 @@ export function Visualizer(props: {
|
||||
}}
|
||||
>
|
||||
<Panel maxSize={95} defaultSize={20}>
|
||||
<Sidebar hideLogo={props?.hideLogo} logoAction={props?.logoAction} />
|
||||
<Sidebar logoAction={props?.logoAction} />
|
||||
</Panel>
|
||||
<PanelResizeHandle
|
||||
onDragging={(isChanging) => {
|
||||
@ -218,6 +216,28 @@ export function Visualizer(props: {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const selectOptions = dumps?.map((dump, index) => ({
|
||||
value: index,
|
||||
label: `${dump.groupName} - ${dump.groupDescription}`,
|
||||
groupName: dump.groupName,
|
||||
groupDescription: dump.groupDescription,
|
||||
}));
|
||||
|
||||
const selectWidget =
|
||||
selectOptions && selectOptions.length > 1 ? (
|
||||
<Select
|
||||
options={selectOptions}
|
||||
defaultValue={0}
|
||||
// labelRender={labelRender}
|
||||
onChange={(value) => {
|
||||
const dump = dumps![value];
|
||||
setGroupedDump(dump);
|
||||
}}
|
||||
defaultOpen
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
@ -232,13 +252,32 @@ export function Visualizer(props: {
|
||||
}}
|
||||
>
|
||||
<Helmet>
|
||||
<title>Midscene.js - Visualization Tool</title>
|
||||
<title>Visualization - Midscene.js</title>
|
||||
</Helmet>
|
||||
<div
|
||||
className="page-container"
|
||||
key={`render-${globalRenderCount}`}
|
||||
style={{ height: containerHeight }}
|
||||
>
|
||||
{hideLogo ? null : (
|
||||
<div className="page-nav">
|
||||
<div className="logo">
|
||||
<img
|
||||
alt="Midscene_logo"
|
||||
src="https://lf3-static.bytednsdoc.com/obj/eden-cn/vhaeh7vhabf/logo-light-with-text.png"
|
||||
/>
|
||||
</div>
|
||||
{/* <div className="dump-selector">{selectWidget}</div> */}
|
||||
<PlaywrightCaseSelector
|
||||
dumps={props.dumps}
|
||||
selected={executionDump}
|
||||
onSelect={(dump) => {
|
||||
setGroupedDump(dump);
|
||||
}}
|
||||
/>
|
||||
{/* <div className="title">Midscene.js</div> */}
|
||||
</div>
|
||||
)}
|
||||
{mainContent}
|
||||
</div>
|
||||
<GlobalHoverPreview />
|
||||
@ -246,4 +285,110 @@ export function Visualizer(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export default Visualizer;
|
||||
function PlaywrightCaseSelector(props: {
|
||||
dumps?: ExecutionDumpWithPlaywrightAttributes[];
|
||||
selected?: GroupedActionDump | null;
|
||||
onSelect?: (dump: GroupedActionDump) => void;
|
||||
}) {
|
||||
if (!props.dumps || props.dumps.length <= 1) return null;
|
||||
|
||||
const nameForDump = (dump: GroupedActionDump) =>
|
||||
`${dump.groupName} - ${dump.groupDescription}`;
|
||||
const items = (props.dumps || []).map((dump, index) => {
|
||||
const status = iconForStatus(dump.attributes?.playwright_test_status);
|
||||
const costStr = dump.attributes?.playwright_test_duration;
|
||||
const cost = costStr ? (
|
||||
<span key={index} className="cost-str">
|
||||
{' '}
|
||||
({timeCostStrElement(Number.parseInt(costStr, 10))})
|
||||
</span>
|
||||
) : null;
|
||||
return {
|
||||
key: index,
|
||||
label: (
|
||||
<a
|
||||
// biome-ignore lint/a11y/useValidAnchor: <explanation>
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (props.onSelect) {
|
||||
props.onSelect(dump);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{status}
|
||||
{' '}
|
||||
{nameForDump(dump)}
|
||||
{cost}
|
||||
</div>
|
||||
</a>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const btnName = props.selected
|
||||
? nameForDump(props.selected)
|
||||
: 'Select a case';
|
||||
|
||||
return (
|
||||
<div className="playwright-case-selector">
|
||||
<Dropdown menu={{ items }}>
|
||||
{/* biome-ignore lint/a11y/useValidAnchor: <explanation> */}
|
||||
<a onClick={(e) => e.preventDefault()}>
|
||||
{btnName}
|
||||
<DownOutlined />
|
||||
</a>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mount(id: string) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) {
|
||||
throw new Error(`failed to get element for id: ${id}`);
|
||||
}
|
||||
const root = ReactDOM.createRoot(element);
|
||||
|
||||
const dumpElements = document.querySelectorAll(
|
||||
'script[type="midscene_web_dump"]',
|
||||
);
|
||||
const reportDump: ExecutionDumpWithPlaywrightAttributes[] = [];
|
||||
Array.from(dumpElements)
|
||||
.filter((el) => {
|
||||
const textContent = el.textContent;
|
||||
if (!textContent) {
|
||||
console.warn('empty content in script tag', el);
|
||||
}
|
||||
return !!textContent;
|
||||
})
|
||||
.forEach((el) => {
|
||||
const attributes: Record<string, any> = {};
|
||||
Array.from(el.attributes).forEach((attr) => {
|
||||
const { name, value } = attr;
|
||||
const valueDecoded = decodeURIComponent(value);
|
||||
if (name.startsWith('playwright_')) {
|
||||
attributes[attr.name] = valueDecoded;
|
||||
}
|
||||
});
|
||||
|
||||
const content = el.textContent;
|
||||
let jsonContent: ExecutionDumpWithPlaywrightAttributes;
|
||||
try {
|
||||
jsonContent = JSON.parse(content!);
|
||||
jsonContent.attributes = attributes;
|
||||
reportDump.push(jsonContent);
|
||||
} catch (e) {
|
||||
console.error(el);
|
||||
console.error('failed to parse json content', e);
|
||||
}
|
||||
});
|
||||
|
||||
// console.log('reportDump', reportDump);
|
||||
root.render(<Visualizer dumps={reportDump} />);
|
||||
}
|
||||
|
||||
export default {
|
||||
mount,
|
||||
Visualizer,
|
||||
};
|
||||
|
||||
@ -16,7 +16,7 @@ export function insightDumpToExecutionDump(
|
||||
const task: ExecutionTaskInsightLocate = {
|
||||
type: 'Insight',
|
||||
subType: insightDump.type === 'locate' ? 'Locate' : 'Query',
|
||||
status: insightDump.error ? 'fail' : 'success',
|
||||
status: insightDump.error ? 'failed' : 'success',
|
||||
param: {
|
||||
...(insightDump.userQuery.element
|
||||
? { query: insightDump.userQuery }
|
||||
|
||||
@ -19,5 +19,5 @@
|
||||
"types": ["react"]
|
||||
},
|
||||
"exclude": ["**/node_modules"],
|
||||
"include": ["src"]
|
||||
"include": ["src", "builder"]
|
||||
}
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "modern dev",
|
||||
"build": "npm run build:pkg && npm run build:script && node ./report-script.mjs",
|
||||
"build": "npm run build:pkg && npm run build:script",
|
||||
"build:pkg": "modern build -c ./modern.config.ts",
|
||||
"build:script": "modern build -c ./modern.inspect.config.ts",
|
||||
"build:watch": "modern build -w -c ./modern.config.ts & modern build -w -c ./modern.inspect.config.ts",
|
||||
@ -57,8 +57,7 @@
|
||||
"openai": "4.47.1",
|
||||
"sharp": "0.33.3",
|
||||
"inquirer": "10.1.5",
|
||||
"@midscene/core": "workspace:*",
|
||||
"@midscene/visualizer-report": "workspace:*"
|
||||
"@midscene/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modern-js/module-tools": "^2.56.1",
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fsExtra from 'fs-extra';
|
||||
|
||||
const projectDir = process.cwd();
|
||||
const reportHtmlDir = path.join(
|
||||
projectDir,
|
||||
'node_modules/@midscene/visualizer-report/dist',
|
||||
);
|
||||
const distPath = path.join(projectDir, 'dist/visualizer-report');
|
||||
const distPublicPath = path.join(projectDir, 'dist/visualizer-report/public');
|
||||
|
||||
const tempDir = path.join(os.tmpdir(), 'temp-folder');
|
||||
|
||||
// First copy to the temporary directory
|
||||
fsExtra.copySync(reportHtmlDir, tempDir);
|
||||
// Then move the contents of the temporary directory to the destination directory
|
||||
fsExtra.moveSync(tempDir, distPath, { overwrite: true });
|
||||
fsExtra.emptyDirSync(distPublicPath);
|
||||
@ -3,55 +3,86 @@ import type { ExecutionDump, GroupedActionDump } from '@midscene/core';
|
||||
import {
|
||||
groupedActionDumpFileExt,
|
||||
stringifyDumpData,
|
||||
writeDumpFile,
|
||||
writeLogFile,
|
||||
} from '@midscene/core/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { PageTaskExecutor } from '../common/tasks';
|
||||
import type { AiTaskCache } from './task-cache';
|
||||
import { printReportMsg, reportFileName } from './utils';
|
||||
|
||||
export interface PageAgentOpt {
|
||||
testId?: string;
|
||||
groupName?: string;
|
||||
groupDescription?: string;
|
||||
cache?: AiTaskCache;
|
||||
/* if auto generate report, default true */
|
||||
generateReport?: boolean;
|
||||
}
|
||||
|
||||
export class PageAgent {
|
||||
page: WebPage;
|
||||
|
||||
dumps: GroupedActionDump[];
|
||||
dump: GroupedActionDump;
|
||||
|
||||
testId: string;
|
||||
reportFile?: string;
|
||||
|
||||
dumpFile?: string;
|
||||
reportFileName?: string;
|
||||
|
||||
taskExecutor: PageTaskExecutor;
|
||||
|
||||
constructor(
|
||||
page: WebPage,
|
||||
opts?: { testId?: string; taskFile?: string; cache?: AiTaskCache },
|
||||
) {
|
||||
opts: PageAgentOpt;
|
||||
|
||||
constructor(page: WebPage, opts?: PageAgentOpt) {
|
||||
this.page = page;
|
||||
this.dumps = [
|
||||
this.opts = Object.assign(
|
||||
{
|
||||
groupName: opts?.taskFile || 'unnamed',
|
||||
executions: [],
|
||||
generateReport: true,
|
||||
groupName: 'Midscene Report',
|
||||
groupDescription: '',
|
||||
},
|
||||
];
|
||||
this.testId = opts?.testId || String(process.pid);
|
||||
opts || {},
|
||||
);
|
||||
this.dump = {
|
||||
groupName: this.opts.groupName!,
|
||||
groupDescription: this.opts.groupDescription,
|
||||
executions: [],
|
||||
};
|
||||
this.taskExecutor = new PageTaskExecutor(this.page, {
|
||||
cache: opts?.cache || { aiTasks: [] },
|
||||
});
|
||||
this.reportFileName = reportFileName(opts?.testId || 'web');
|
||||
}
|
||||
|
||||
appendDump(execution: ExecutionDump) {
|
||||
const currentDump = this.dumps[0];
|
||||
appendExecutionDump(execution: ExecutionDump) {
|
||||
const currentDump = this.dump;
|
||||
currentDump.executions.push(execution);
|
||||
}
|
||||
|
||||
dumpDataString() {
|
||||
// update dump info
|
||||
this.dump.groupName = this.opts.groupName!;
|
||||
this.dump.groupDescription = this.opts.groupDescription;
|
||||
return stringifyDumpData(this.dump);
|
||||
}
|
||||
|
||||
writeOutActionDumps() {
|
||||
this.dumpFile = writeDumpFile({
|
||||
fileName: `run-${this.testId}`,
|
||||
const generateReport = this.opts.generateReport;
|
||||
this.reportFile = writeLogFile({
|
||||
fileName: this.reportFileName!,
|
||||
fileExt: groupedActionDumpFileExt,
|
||||
fileContent: stringifyDumpData(this.dumps),
|
||||
fileContent: this.dumpDataString(),
|
||||
type: 'dump',
|
||||
generateReport,
|
||||
});
|
||||
|
||||
if (generateReport) {
|
||||
printReportMsg(this.reportFile);
|
||||
}
|
||||
}
|
||||
|
||||
async aiAction(taskPrompt: string) {
|
||||
const { executor } = await this.taskExecutor.action(taskPrompt);
|
||||
this.appendDump(executor.dump());
|
||||
this.appendExecutionDump(executor.dump());
|
||||
this.writeOutActionDumps();
|
||||
|
||||
if (executor.isInErrorState()) {
|
||||
@ -62,7 +93,7 @@ export class PageAgent {
|
||||
|
||||
async aiQuery(demand: any) {
|
||||
const { output, executor } = await this.taskExecutor.query(demand);
|
||||
this.appendDump(executor.dump());
|
||||
this.appendExecutionDump(executor.dump());
|
||||
this.writeOutActionDumps();
|
||||
|
||||
if (executor.isInErrorState()) {
|
||||
@ -74,7 +105,7 @@ export class PageAgent {
|
||||
|
||||
async aiAssert(assertion: string, msg?: string) {
|
||||
const { output, executor } = await this.taskExecutor.assert(assertion);
|
||||
this.appendDump(executor.dump());
|
||||
this.appendExecutionDump(executor.dump());
|
||||
this.writeOutActionDumps();
|
||||
|
||||
if (!output?.pass) {
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
imageInfoOfBase64,
|
||||
} from '@midscene/core/image';
|
||||
import { getTmpFile } from '@midscene/core/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { WebElementInfo, type WebElementInfoType } from '../web-element';
|
||||
import type { WebPage } from './page';
|
||||
|
||||
@ -107,3 +108,12 @@ export function findNearestPackageJson(dir: string): string | null {
|
||||
|
||||
return findNearestPackageJson(parentDir);
|
||||
}
|
||||
|
||||
export function reportFileName(tag = 'web') {
|
||||
const dateTimeInFileName = dayjs().format('YYYY-MM-DD_HH-mm-ss-SSS');
|
||||
return `${tag}-${dateTimeInFileName}`;
|
||||
}
|
||||
|
||||
export function printReportMsg(filepath: string) {
|
||||
console.log('Midscene - report file updated:', filepath);
|
||||
}
|
||||
|
||||
@ -3,9 +3,9 @@ import path, { join } from 'node:path';
|
||||
import type { AiTaskCache } from '@/common/task-cache';
|
||||
import { findNearestPackageJson } from '@/common/utils';
|
||||
import {
|
||||
getDumpDirPath,
|
||||
getLogDirByType,
|
||||
stringifyDumpData,
|
||||
writeDumpFile,
|
||||
writeLogFile,
|
||||
} from '@midscene/core/utils';
|
||||
|
||||
export function writeTestCache(
|
||||
@ -14,7 +14,7 @@ export function writeTestCache(
|
||||
taskCacheJson: AiTaskCache,
|
||||
) {
|
||||
const packageJson = getPkgInfo();
|
||||
writeDumpFile({
|
||||
writeLogFile({
|
||||
fileName: `${taskFile}(${taskTitle})`,
|
||||
fileExt: 'json',
|
||||
fileContent: stringifyDumpData(
|
||||
@ -33,7 +33,7 @@ export function writeTestCache(
|
||||
|
||||
export function readTestCache(taskFile: string, taskTitle: string) {
|
||||
const cacheFile = join(
|
||||
getDumpDirPath('cache'),
|
||||
getLogDirByType('cache'),
|
||||
`${taskFile}(${taskTitle}).json`,
|
||||
);
|
||||
const pkgInfo = getPkgInfo();
|
||||
|
||||
@ -27,28 +27,48 @@ const groupAndCaseForTest = (testInfo: TestInfo) => {
|
||||
};
|
||||
|
||||
const midsceneAgentKeyId = '_midsceneAgentId';
|
||||
export const midsceneDumpAnnotationId = 'MIDSCENE_DUMP_ANNOTATION';
|
||||
export const PlaywrightAiFixture = () => {
|
||||
const pageAgentMap: Record<string, PageAgent> = {};
|
||||
const agentForPage = (
|
||||
page: WebPage,
|
||||
opts: { testId: string; taskFile: string; taskTitle: string },
|
||||
testInfo: TestInfo, // { testId: string; taskFile: string; taskTitle: string },
|
||||
) => {
|
||||
let idForPage = (page as any)[midsceneAgentKeyId];
|
||||
if (!idForPage) {
|
||||
idForPage = randomUUID();
|
||||
(page as any)[midsceneAgentKeyId] = idForPage;
|
||||
const testCase = readTestCache(opts.taskFile, opts.taskTitle) || {
|
||||
const { testId } = testInfo;
|
||||
const { taskFile, taskTitle } = groupAndCaseForTest(testInfo);
|
||||
const testCase = readTestCache(taskFile, taskTitle) || {
|
||||
aiTasks: [],
|
||||
};
|
||||
|
||||
pageAgentMap[idForPage] = new PageAgent(page, {
|
||||
testId: `${opts.testId}-${idForPage}`,
|
||||
taskFile: opts.taskFile,
|
||||
testId: `playwright-${testId}-${idForPage}`,
|
||||
groupName: taskTitle,
|
||||
groupDescription: taskFile,
|
||||
cache: testCase,
|
||||
generateReport: false, // we will generate it in the reporter
|
||||
});
|
||||
}
|
||||
return pageAgentMap[idForPage];
|
||||
};
|
||||
|
||||
const updateDumpAnnotation = (test: TestInfo, dump: string) => {
|
||||
const currentAnnotation = test.annotations.find((item) => {
|
||||
return item.type === midsceneDumpAnnotationId;
|
||||
});
|
||||
if (currentAnnotation) {
|
||||
currentAnnotation.description = dump;
|
||||
} else {
|
||||
test.annotations.push({
|
||||
type: midsceneDumpAnnotationId,
|
||||
description: dump,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ai: async (
|
||||
{ page }: { page: PlaywrightPage },
|
||||
@ -56,11 +76,7 @@ export const PlaywrightAiFixture = () => {
|
||||
testInfo: TestInfo,
|
||||
) => {
|
||||
const { taskFile, taskTitle } = groupAndCaseForTest(testInfo);
|
||||
const agent = agentForPage(page, {
|
||||
testId: testInfo.testId,
|
||||
taskFile,
|
||||
taskTitle,
|
||||
});
|
||||
const agent = agentForPage(page, testInfo);
|
||||
await use(
|
||||
async (taskPrompt: string, opts?: { type?: 'action' | 'query' }) => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
@ -71,15 +87,7 @@ export const PlaywrightAiFixture = () => {
|
||||
);
|
||||
const taskCacheJson = agent.taskExecutor.taskCache.generateTaskCache();
|
||||
writeTestCache(taskFile, taskTitle, taskCacheJson);
|
||||
if (agent.dumpFile) {
|
||||
testInfo.annotations.push({
|
||||
type: 'MIDSCENE_AI_ACTION',
|
||||
description: JSON.stringify({
|
||||
testId: testInfo.testId,
|
||||
dumpPath: agent.dumpFile,
|
||||
}),
|
||||
});
|
||||
}
|
||||
updateDumpAnnotation(testInfo, agent.dumpDataString());
|
||||
},
|
||||
aiAction: async (
|
||||
{ page }: { page: PlaywrightPage },
|
||||
@ -87,75 +95,38 @@ export const PlaywrightAiFixture = () => {
|
||||
testInfo: TestInfo,
|
||||
) => {
|
||||
const { taskFile, taskTitle } = groupAndCaseForTest(testInfo);
|
||||
const agent = agentForPage(page, {
|
||||
testId: testInfo.testId,
|
||||
taskFile,
|
||||
taskTitle,
|
||||
});
|
||||
const agent = agentForPage(page, testInfo);
|
||||
await use(async (taskPrompt: string) => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await agent.aiAction(taskPrompt);
|
||||
});
|
||||
if (agent.dumpFile) {
|
||||
testInfo.annotations.push({
|
||||
type: 'MIDSCENE_AI_ACTION',
|
||||
description: JSON.stringify({
|
||||
testId: testInfo.testId,
|
||||
dumpPath: agent.dumpFile,
|
||||
}),
|
||||
});
|
||||
}
|
||||
// Why there's no cache here ?
|
||||
updateDumpAnnotation(testInfo, agent.dumpDataString());
|
||||
},
|
||||
aiQuery: async (
|
||||
{ page }: { page: PlaywrightPage },
|
||||
use: any,
|
||||
testInfo: TestInfo,
|
||||
) => {
|
||||
const { taskFile, taskTitle } = groupAndCaseForTest(testInfo);
|
||||
const agent = agentForPage(page, {
|
||||
testId: testInfo.testId,
|
||||
taskFile,
|
||||
taskTitle,
|
||||
});
|
||||
const agent = agentForPage(page, testInfo);
|
||||
await use(async (demand: any) => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
const result = await agent.aiQuery(demand);
|
||||
return result;
|
||||
});
|
||||
if (agent.dumpFile) {
|
||||
testInfo.annotations.push({
|
||||
type: 'MIDSCENE_AI_ACTION',
|
||||
description: JSON.stringify({
|
||||
testId: testInfo.testId,
|
||||
dumpPath: agent.dumpFile,
|
||||
}),
|
||||
});
|
||||
}
|
||||
updateDumpAnnotation(testInfo, agent.dumpDataString());
|
||||
},
|
||||
aiAssert: async (
|
||||
{ page }: { page: PlaywrightPage },
|
||||
use: any,
|
||||
testInfo: TestInfo,
|
||||
) => {
|
||||
const { taskFile, taskTitle } = groupAndCaseForTest(testInfo);
|
||||
const agent = agentForPage(page, {
|
||||
testId: testInfo.testId,
|
||||
taskFile,
|
||||
taskTitle,
|
||||
});
|
||||
const agent = agentForPage(page, testInfo);
|
||||
await use(async (assertion: string, errorMsg?: string) => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await agent.aiAssert(assertion, errorMsg);
|
||||
});
|
||||
if (agent.dumpFile) {
|
||||
testInfo.annotations.push({
|
||||
type: 'MIDSCENE_AI_ACTION',
|
||||
description: JSON.stringify({
|
||||
testId: testInfo.testId,
|
||||
dumpPath: agent.dumpFile,
|
||||
}),
|
||||
});
|
||||
}
|
||||
updateDumpAnnotation(testInfo, agent.dumpDataString());
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import { printReportMsg, reportFileName } from '@/common/utils';
|
||||
import type { ReportDumpWithAttributes } from '@midscene/core/.';
|
||||
import { writeDumpReport } from '@midscene/core/utils';
|
||||
import type {
|
||||
FullConfig,
|
||||
FullResult,
|
||||
@ -6,10 +9,6 @@ import type {
|
||||
TestCase,
|
||||
TestResult,
|
||||
} from '@playwright/test/reporter';
|
||||
import type { TestData } from './type';
|
||||
import { generateTestData } from './util';
|
||||
|
||||
const testDataList: Array<TestData> = [];
|
||||
|
||||
function logger(...message: any[]) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
@ -17,49 +16,48 @@ function logger(...message: any[]) {
|
||||
}
|
||||
}
|
||||
|
||||
const testDataList: Array<ReportDumpWithAttributes> = [];
|
||||
let filename: string;
|
||||
function updateReport() {
|
||||
const reportPath = writeDumpReport(filename, testDataList);
|
||||
writeDumpReport('latest-playwright-report', testDataList);
|
||||
printReportMsg(reportPath);
|
||||
}
|
||||
|
||||
class MidsceneReporter implements Reporter {
|
||||
async onBegin(config: FullConfig, suite: Suite) {
|
||||
const suites = suite.allTests();
|
||||
logger(`Starting the run with ${suites.length} tests`);
|
||||
if (!filename) {
|
||||
filename = reportFileName('playwright-merged');
|
||||
}
|
||||
// const suites = suite.allTests();
|
||||
// logger(`Starting the run with ${suites.length} tests`);
|
||||
}
|
||||
|
||||
onTestBegin(test: TestCase, _result: TestResult) {
|
||||
logger(`Starting test ${test.title}`);
|
||||
// logger(`Starting test ${test.title}`);
|
||||
}
|
||||
|
||||
onTestEnd(test: TestCase, result: TestResult) {
|
||||
const aiActionTestData = test.annotations.filter((annotation) => {
|
||||
if (annotation.type === 'MIDSCENE_AI_ACTION') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
const dumpAnnotation = test.annotations.find((annotation) => {
|
||||
return annotation.type === 'MIDSCENE_DUMP_ANNOTATION';
|
||||
});
|
||||
aiActionTestData.forEach((testData) => {
|
||||
const parseData = JSON.parse(testData?.description || '{}');
|
||||
if (
|
||||
parseData.testId === test.id &&
|
||||
!testDataList.find((item) => item.testId === test.id)
|
||||
) {
|
||||
testDataList.push({
|
||||
testId: test.id,
|
||||
title: test.title,
|
||||
status: result.status,
|
||||
duration: result.duration,
|
||||
location: test.location,
|
||||
dumpPath: parseData.dumpPath,
|
||||
});
|
||||
}
|
||||
if (!dumpAnnotation?.description) return;
|
||||
testDataList.push({
|
||||
dumpString: dumpAnnotation.description,
|
||||
attributes: {
|
||||
playwright_test_title: test.title,
|
||||
playwright_test_status: result.status,
|
||||
playwright_test_duration: result.duration,
|
||||
},
|
||||
});
|
||||
logger(`Finished test ${test.title}: ${result.status}`);
|
||||
|
||||
updateReport();
|
||||
}
|
||||
|
||||
onEnd(result: FullResult) {
|
||||
updateReport();
|
||||
|
||||
logger(`Finished the run: ${result.status}`);
|
||||
generateTestData(testDataList);
|
||||
console.log(
|
||||
'\x1b[32m%s\x1b[0m',
|
||||
`Midscene report has been generated.\nRun "npx http-server ./midscene_run/report -o -s -c-1" to view.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import type { Location } from '@playwright/test/reporter';
|
||||
|
||||
export type TestData = {
|
||||
testId: string;
|
||||
title: string;
|
||||
status: 'passed' | 'failed' | 'timedOut' | 'skipped' | 'interrupted';
|
||||
/**
|
||||
* Running time in milliseconds.
|
||||
*/
|
||||
duration: number;
|
||||
/**
|
||||
* Optional location in the source where the step is defined.
|
||||
*/
|
||||
location?: Location;
|
||||
dumpPath?: string;
|
||||
};
|
||||
@ -1,120 +0,0 @@
|
||||
import assert from 'node:assert';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { findNearestPackageJson } from '@/common/utils';
|
||||
import fsExtra from 'fs-extra';
|
||||
import type { TestData } from './type';
|
||||
|
||||
export function generateTestData(testDataList: Array<TestData>) {
|
||||
const filterDataList = testDataList.reduce(
|
||||
(res, testData) => {
|
||||
if (res.find((item) => item.testId === testData.testId)) {
|
||||
return res;
|
||||
}
|
||||
// biome-ignore lint/performance/noAccumulatingSpread: <explanation>
|
||||
return [...res, testData];
|
||||
},
|
||||
[] as Array<TestData>,
|
||||
);
|
||||
const reportDir = findNearestPackageJson(__dirname);
|
||||
assert(reportDir, `can't get reportDir from ${__dirname}`);
|
||||
|
||||
const targetReportDir = path.join(process.cwd(), 'midscene_run', 'report');
|
||||
|
||||
// Copy the contents of the report html folder to the report folder
|
||||
const reportHtmlDir = path.join(reportDir, 'dist/visualizer-report');
|
||||
const tempDir = path.join(os.tmpdir(), 'temp-folder');
|
||||
try {
|
||||
// First copy to the temporary directory
|
||||
fsExtra.copySync(reportHtmlDir, tempDir);
|
||||
// Then move the contents of the temporary directory to the destination directory
|
||||
fsExtra.moveSync(tempDir, targetReportDir, { overwrite: true });
|
||||
} catch (err) {
|
||||
console.error('An error occurred while copying the folder.', err);
|
||||
}
|
||||
|
||||
try {
|
||||
fsExtra.removeSync(path.join(targetReportDir, 'public'));
|
||||
} catch (err) {
|
||||
console.error('An error occurred while deleting the folder.', err);
|
||||
}
|
||||
|
||||
for (const testData of filterDataList) {
|
||||
const { dumpPath } = testData;
|
||||
if (dumpPath) {
|
||||
const srcFile = dumpPath.split('/').pop();
|
||||
assert(srcFile, `Failed to get source file name from ${dumpPath}`);
|
||||
const destFile = path.join(targetReportDir, 'public', srcFile);
|
||||
fsExtra.copySync(dumpPath, destFile);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fsExtra.outputFileSync(
|
||||
path.join(targetReportDir, 'public', 'test-data-list.json'),
|
||||
JSON.stringify({ 'test-list': filterDataList }),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('An error occurred while writing to the file.', err);
|
||||
}
|
||||
|
||||
// modify log content
|
||||
// try {
|
||||
// const filePath = path.join(targetReportDir, 'index.js'); // File path
|
||||
// const searchValue = 'Server is listening on http://[::]:'; // The content to be replaced can be a string or a regular expression
|
||||
// const replaceValue = 'The report has been generated on http://127.0.0.1:'; // The replaced content
|
||||
// // Read file contents
|
||||
// let fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// // Replace file contents
|
||||
// fileContent = fileContent.replace(searchValue, replaceValue);
|
||||
// fileContent = fileContent.replaceAll('8080', '9988');
|
||||
|
||||
// // Writes the modified content to the file
|
||||
// fsExtra.outputFileSync(filePath, fileContent);
|
||||
// } catch (err) {
|
||||
// console.error('An error occurred:', err);
|
||||
// }
|
||||
|
||||
// close log
|
||||
// try {
|
||||
// const filePath = path.join(targetReportDir, 'node_modules/@modern-js/prod-server/dist/cjs/apply.js'); // File path
|
||||
// let fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
// fileContent = fileContent.replace('(0, import_server_core.logPlugin)(),', '');
|
||||
|
||||
// // Writes the modified content to the file
|
||||
// fsExtra.outputFileSync(filePath, fileContent);
|
||||
// } catch (err) {
|
||||
// console.error('An error occurred:', err);
|
||||
// }
|
||||
|
||||
// add static data
|
||||
// modifyRoutesJson(targetReportDir, testDataList);
|
||||
}
|
||||
|
||||
// function modifyRoutesJson(targetReportDir: string, testDataList: Array<TestData>) {
|
||||
// const filePath = path.join(targetReportDir, 'route.json');
|
||||
// try {
|
||||
// const data = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// const newPaths = testDataList.map((testData) => {
|
||||
// const fileName = testData.dumpPath?.split('/').pop();
|
||||
// return {
|
||||
// urlPath: `/${fileName}`,
|
||||
// isSPA: true,
|
||||
// isSSR: false,
|
||||
// entryPath: `public/${fileName}`,
|
||||
// };
|
||||
// });
|
||||
|
||||
// const jsonData = JSON.parse(data);
|
||||
|
||||
// // Insert the new path data into the js, OS and n structure
|
||||
// jsonData.routes.push(...newPaths);
|
||||
|
||||
// // Write the updated js on data back to the file
|
||||
// fs.writeFileSync(filePath, JSON.stringify(jsonData, null, 2), 'utf8');
|
||||
// } catch (err) {
|
||||
// console.error('modifyRoutesJson fail:', err);
|
||||
// }
|
||||
// }
|
||||
@ -16,9 +16,8 @@ test('ai todo', async ({ ai, aiQuery }) => {
|
||||
await ai(
|
||||
'Enter "Learning AI the day after tomorrow" in the task box, then press Enter to create',
|
||||
);
|
||||
await ai(
|
||||
'Move your mouse over the second item in the task list and click the Delete button to the right of the second task',
|
||||
);
|
||||
await ai('Move your mouse over the second item in the task list');
|
||||
await ai('Click the delete button to the right of the second task');
|
||||
await ai('Click the check button to the left of the second task');
|
||||
await ai('Click the completed Status button below the task list');
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@ describe('puppeteer integration', () => {
|
||||
console.log('Github service status', result);
|
||||
|
||||
expect(async () => {
|
||||
// // there is no food delivery service on Github
|
||||
// there is no food delivery service on Github
|
||||
await mid.aiAssert(
|
||||
'there is a "food delivery" service on page and is in normal state',
|
||||
);
|
||||
|
||||
4149
pnpm-lock.yaml
generated
4149
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -4,5 +4,4 @@ packages:
|
||||
- packages/midscene
|
||||
- packages/playwright-demo
|
||||
- packages/visualizer
|
||||
- packages/visualizer-report
|
||||
- packages/web-integration
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user