feat(report): optimize report content (#55)

Co-authored-by: zhouxiao.shaw <zhouxiao.shaw@bytedance.com>
This commit is contained in:
yuyutaotao 2024-08-15 17:59:43 +08:00 committed by GitHub
parent 49d19371b3
commit 9c5a7a123c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 30088 additions and 4692 deletions

View File

@ -1,6 +1,6 @@
---
pageType: custom
---
import Visualizer from '@midscene/visualizer';
import { Visualizer } from '@midscene/visualizer';
<Visualizer hideLogo />

View File

@ -1,6 +1,6 @@
---
pageType: custom
---
import Visualizer from '@midscene/visualizer';
import { Visualizer } from '@midscene/visualizer';
<Visualizer hideLogo />

View File

@ -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": {

View File

@ -50,5 +50,14 @@ export default defineConfig({
description: 'Midscene.js',
},
],
builderConfig: {
tools: {
rspack: {
watchOptions: {
ignored: /node_modules/,
},
},
},
},
lang: 'en',
});

View File

@ -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"
},

View File

@ -1,3 +1,4 @@
# midscene.js
midscene_run/
report/

View File

@ -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",

View File

@ -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];

View File

@ -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 };

View File

@ -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;

View File

@ -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[];
}

View File

@ -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);
}

View File

@ -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');

View File

@ -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 };

View File

@ -1,5 +0,0 @@
chrome >= 51
edge >= 15
firefox >= 54
safari >= 10
ios_saf >= 10

View File

@ -1,7 +0,0 @@
## Documentation
See https://midscenejs.com/ for details.
## License
Midscene is MIT licensed.

View File

@ -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"
}
]
}

View File

@ -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',
}),
],
});

View File

@ -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"
}
}

View File

@ -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>
);
};

View File

@ -1,3 +0,0 @@
/// <reference types='@modern-js/app-tools/types' />
/// <reference types='@modern-js/runtime/types' />
/// <reference types='@modern-js/runtime/types/router' />

View File

@ -1,3 +0,0 @@
import { defineRuntimeConfig } from '@modern-js/runtime';
export default defineRuntimeConfig({});

View File

@ -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;
}

View File

@ -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} />}</>;
}

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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"]
}

View 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>

View File

@ -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',
});

View File

@ -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",

View 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();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@ -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;

View File

@ -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) => {

View File

@ -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 />;
}
};

View File

@ -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;
}
}

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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">

View File

@ -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

View File

@ -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> & {

View File

@ -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 {

View File

@ -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}&nbsp;
<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,
};

View File

@ -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 }

View File

@ -19,5 +19,5 @@
"types": ["react"]
},
"exclude": ["**/node_modules"],
"include": ["src"]
"include": ["src", "builder"]
}

View File

@ -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",

View File

@ -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);

View File

@ -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) {

View File

@ -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);
}

View File

@ -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();

View File

@ -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());
},
};
};

View File

@ -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.`,
);
}
}

View File

@ -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;
};

View File

@ -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);
// }
// }

View File

@ -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');

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -4,5 +4,4 @@ packages:
- packages/midscene
- packages/playwright-demo
- packages/visualizer
- packages/visualizer-report
- packages/web-integration