2024-07-23 16:25:11 +08:00
|
|
|
import './index.less';
|
2024-08-04 08:28:19 +08:00
|
|
|
import DetailSide from '@/component/detail-side';
|
|
|
|
import Sidebar from '@/component/sidebar';
|
|
|
|
import { useExecutionDump } from '@/component/store';
|
2024-08-15 17:59:43 +08:00
|
|
|
import { DownOutlined } from '@ant-design/icons';
|
2024-08-04 08:28:19 +08:00
|
|
|
import type { GroupedActionDump } from '@midscene/core';
|
|
|
|
import { Helmet } from '@modern-js/runtime/head';
|
2024-08-15 17:59:43 +08:00
|
|
|
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';
|
2024-07-25 10:47:02 +08:00
|
|
|
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
2024-08-15 17:59:43 +08:00
|
|
|
import logo from './component/assets/logo-plain.png';
|
2024-08-04 08:28:19 +08:00
|
|
|
import DetailPanel from './component/detail-panel';
|
2024-07-25 10:47:02 +08:00
|
|
|
import GlobalHoverPreview from './component/global-hover-preview';
|
2024-08-15 17:59:43 +08:00
|
|
|
import { iconForStatus, timeCostStrElement } from './component/misc';
|
2024-08-04 08:28:19 +08:00
|
|
|
import Timeline from './component/timeline';
|
2024-07-23 16:25:11 +08:00
|
|
|
|
|
|
|
const { Dragger } = Upload;
|
2024-08-01 16:07:58 +08:00
|
|
|
let globalRenderCount = 1;
|
|
|
|
|
2024-08-15 17:59:43 +08:00
|
|
|
interface ExecutionDumpWithPlaywrightAttributes extends GroupedActionDump {
|
|
|
|
attributes: Record<string, any>;
|
|
|
|
}
|
|
|
|
|
2024-07-28 17:24:09 +08:00
|
|
|
export function Visualizer(props: {
|
|
|
|
logoAction?: () => void;
|
2024-08-15 17:59:43 +08:00
|
|
|
hideLogo?: boolean;
|
|
|
|
dumps?: ExecutionDumpWithPlaywrightAttributes[];
|
2024-07-28 17:24:09 +08:00
|
|
|
}): JSX.Element {
|
2024-08-15 17:59:43 +08:00
|
|
|
const { dumps, hideLogo = false } = props;
|
2024-07-28 17:24:09 +08:00
|
|
|
|
2024-07-23 16:25:11 +08:00
|
|
|
const executionDump = useExecutionDump((store) => store.dump);
|
|
|
|
const setGroupedDump = useExecutionDump((store) => store.setGroupedDump);
|
|
|
|
const reset = useExecutionDump((store) => store.reset);
|
2024-07-25 10:47:02 +08:00
|
|
|
const [mainLayoutChangeFlag, setMainLayoutChangeFlag] = useState(0);
|
|
|
|
const mainLayoutChangedRef = useRef(false);
|
2024-07-23 16:25:11 +08:00
|
|
|
|
|
|
|
useEffect(() => {
|
2024-08-15 17:59:43 +08:00
|
|
|
if (dumps) {
|
|
|
|
setGroupedDump(dumps[0]);
|
2024-07-28 17:24:09 +08:00
|
|
|
}
|
2024-07-23 16:25:11 +08:00
|
|
|
return () => {
|
|
|
|
reset();
|
|
|
|
};
|
|
|
|
}, []);
|
|
|
|
|
2024-07-25 10:47:02 +08:00
|
|
|
useEffect(() => {
|
|
|
|
const onResize = () => {
|
|
|
|
setMainLayoutChangeFlag((prev) => prev + 1);
|
|
|
|
};
|
|
|
|
window.addEventListener('resize', onResize);
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
window.removeEventListener('resize', onResize);
|
|
|
|
};
|
|
|
|
}, []);
|
|
|
|
|
2024-07-23 16:25:11 +08:00
|
|
|
const uploadProps: UploadProps = {
|
|
|
|
name: 'file',
|
|
|
|
multiple: false,
|
2024-07-24 10:32:27 +08:00
|
|
|
capture: false,
|
2024-07-23 16:25:11 +08:00
|
|
|
customRequest: () => {
|
|
|
|
// noop
|
|
|
|
},
|
|
|
|
beforeUpload(file) {
|
2024-07-25 13:40:46 +08:00
|
|
|
const ifValidFile = file.name.endsWith('web-dump.json'); // || file.name.endsWith('.insight.json');
|
2024-07-23 16:25:11 +08:00
|
|
|
if (!ifValidFile) {
|
|
|
|
message.error('invalid file extension');
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
|
|
reader.readAsText(file);
|
|
|
|
reader.onload = (e) => {
|
|
|
|
const result = e.target?.result;
|
|
|
|
if (typeof result === 'string') {
|
|
|
|
try {
|
|
|
|
const data = JSON.parse(result);
|
2024-08-15 17:59:43 +08:00
|
|
|
setGroupedDump(data[0]);
|
2024-07-23 16:25:11 +08:00
|
|
|
} catch (e: any) {
|
|
|
|
console.error(e);
|
|
|
|
message.error('failed to parse dump data', e.message);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
message.error('Invalid dump file');
|
|
|
|
}
|
|
|
|
};
|
|
|
|
return false;
|
|
|
|
},
|
|
|
|
};
|
|
|
|
|
|
|
|
let mainContent: JSX.Element;
|
|
|
|
if (!executionDump) {
|
|
|
|
mainContent = (
|
|
|
|
<div className="main-right uploader-wrapper">
|
|
|
|
<Dragger className="uploader" {...uploadProps}>
|
|
|
|
<p className="ant-upload-drag-icon">
|
2024-08-15 17:59:43 +08:00
|
|
|
<img
|
|
|
|
alt="Midscene_logo"
|
|
|
|
style={{ width: 80, margin: 'auto' }}
|
|
|
|
src={logo}
|
|
|
|
/>
|
2024-07-23 16:25:11 +08:00
|
|
|
</p>
|
|
|
|
<p className="ant-upload-text">
|
|
|
|
Click or drag the{' '}
|
|
|
|
<b>
|
2024-07-25 13:40:46 +08:00
|
|
|
<i>.web-dump.json</i>
|
2024-07-23 16:25:11 +08:00
|
|
|
</b>{' '}
|
2024-07-25 10:47:02 +08:00
|
|
|
{/* or{' '}
|
2024-07-23 16:25:11 +08:00
|
|
|
<b>
|
|
|
|
<i>.actions.json</i>
|
2024-07-25 10:47:02 +08:00
|
|
|
</b>{' '} */}
|
2024-07-23 16:25:11 +08:00
|
|
|
file into this area.
|
|
|
|
</p>
|
|
|
|
<p className="ant-upload-text">
|
|
|
|
The latest dump file is usually placed in{' '}
|
|
|
|
<b>
|
2024-08-02 16:05:53 +08:00
|
|
|
<i>./midscene_run/report</i>
|
2024-07-23 16:25:11 +08:00
|
|
|
</b>
|
|
|
|
</p>
|
|
|
|
<p className="ant-upload-text">
|
2024-08-04 08:28:19 +08:00
|
|
|
All data will be processed locally by the browser. No data will be
|
|
|
|
sent to the server.
|
2024-07-23 16:25:11 +08:00
|
|
|
</p>
|
|
|
|
</Dragger>
|
2024-08-15 17:59:43 +08:00
|
|
|
{/* <div className="demo-loader">
|
2024-08-02 16:05:53 +08:00
|
|
|
<Button type="link" onClick={loadDemoDump}>
|
|
|
|
Load Demo
|
2024-07-23 16:25:11 +08:00
|
|
|
</Button>
|
2024-08-15 17:59:43 +08:00
|
|
|
</div> */}
|
2024-07-23 16:25:11 +08:00
|
|
|
</div>
|
|
|
|
);
|
|
|
|
|
|
|
|
// dump
|
|
|
|
} else {
|
|
|
|
mainContent = (
|
2024-07-25 10:47:02 +08:00
|
|
|
<PanelGroup
|
|
|
|
autoSaveId="main-page-layout"
|
|
|
|
direction="horizontal"
|
|
|
|
onLayout={() => {
|
|
|
|
if (!mainLayoutChangedRef.current) {
|
|
|
|
setMainLayoutChangeFlag((prev) => prev + 1);
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
>
|
2024-07-28 08:49:57 +08:00
|
|
|
<Panel maxSize={95} defaultSize={20}>
|
2024-08-15 17:59:43 +08:00
|
|
|
<Sidebar logoAction={props?.logoAction} />
|
2024-07-25 10:47:02 +08:00
|
|
|
</Panel>
|
|
|
|
<PanelResizeHandle
|
|
|
|
onDragging={(isChanging) => {
|
|
|
|
if (mainLayoutChangedRef.current && !isChanging) {
|
|
|
|
// not changing anymore
|
|
|
|
setMainLayoutChangeFlag((prev) => prev + 1);
|
|
|
|
}
|
|
|
|
mainLayoutChangedRef.current = isChanging;
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
<Panel defaultSize={80} maxSize={95}>
|
|
|
|
<div className="main-right">
|
|
|
|
<Timeline key={mainLayoutChangeFlag} />
|
|
|
|
<div className="main-content">
|
2024-08-04 08:28:19 +08:00
|
|
|
<PanelGroup
|
|
|
|
autoSaveId="page-detail-layout"
|
|
|
|
direction="horizontal"
|
|
|
|
>
|
2024-07-25 10:47:02 +08:00
|
|
|
<Panel maxSize={95}>
|
|
|
|
<div className="main-side">
|
|
|
|
<DetailSide />
|
|
|
|
</div>
|
|
|
|
</Panel>
|
|
|
|
<PanelResizeHandle />
|
2024-07-23 16:25:11 +08:00
|
|
|
|
2024-07-25 10:47:02 +08:00
|
|
|
<Panel defaultSize={75} maxSize={95}>
|
|
|
|
<div className="main-canvas-container">
|
|
|
|
<DetailPanel />
|
|
|
|
</div>
|
|
|
|
</Panel>
|
|
|
|
</PanelGroup>
|
|
|
|
</div>
|
2024-07-23 16:25:11 +08:00
|
|
|
</div>
|
2024-07-25 10:47:02 +08:00
|
|
|
</Panel>
|
|
|
|
</PanelGroup>
|
2024-07-23 16:25:11 +08:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2024-08-01 16:07:58 +08:00
|
|
|
const [containerHeight, setContainerHeight] = useState('100%');
|
|
|
|
useEffect(() => {
|
|
|
|
const ifInRspressPage = document.querySelector('.rspress-nav');
|
|
|
|
|
|
|
|
// modify rspress theme
|
|
|
|
const navHeightKey = '--rp-nav-height';
|
2024-08-04 08:28:19 +08:00
|
|
|
const originalNavHeight = getComputedStyle(
|
|
|
|
document.documentElement,
|
|
|
|
).getPropertyValue(navHeightKey);
|
2024-08-01 16:07:58 +08:00
|
|
|
|
|
|
|
if (ifInRspressPage) {
|
2024-08-02 10:11:29 +08:00
|
|
|
const newNavHeight = '42px';
|
|
|
|
setContainerHeight(`calc(100vh - ${newNavHeight})`);
|
|
|
|
document.documentElement.style.setProperty(navHeightKey, newNavHeight);
|
2024-08-01 16:07:58 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
// Cleanup function to revert the change
|
|
|
|
return () => {
|
|
|
|
if (ifInRspressPage) {
|
2024-08-04 08:28:19 +08:00
|
|
|
document.documentElement.style.setProperty(
|
|
|
|
navHeightKey,
|
|
|
|
originalNavHeight,
|
|
|
|
);
|
2024-08-01 16:07:58 +08:00
|
|
|
}
|
|
|
|
};
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
return () => {
|
|
|
|
globalRenderCount += 1;
|
|
|
|
};
|
|
|
|
}, []);
|
|
|
|
|
2024-08-15 17:59:43 +08:00
|
|
|
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;
|
|
|
|
|
2024-07-23 16:25:11 +08:00
|
|
|
return (
|
|
|
|
<ConfigProvider
|
|
|
|
theme={{
|
|
|
|
components: {
|
|
|
|
Layout: {
|
|
|
|
headerHeight: 60,
|
|
|
|
headerPadding: '0 30px',
|
|
|
|
headerBg: '#FFF',
|
|
|
|
bodyBg: '#FFF',
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<Helmet>
|
2024-08-15 17:59:43 +08:00
|
|
|
<title>Visualization - Midscene.js</title>
|
2024-07-23 16:25:11 +08:00
|
|
|
</Helmet>
|
2024-08-04 08:28:19 +08:00
|
|
|
<div
|
|
|
|
className="page-container"
|
|
|
|
key={`render-${globalRenderCount}`}
|
|
|
|
style={{ height: containerHeight }}
|
|
|
|
>
|
2024-08-15 17:59:43 +08:00
|
|
|
{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>
|
|
|
|
)}
|
2024-08-01 16:07:58 +08:00
|
|
|
{mainContent}
|
|
|
|
</div>
|
2024-07-25 10:47:02 +08:00
|
|
|
<GlobalHoverPreview />
|
2024-07-23 16:25:11 +08:00
|
|
|
</ConfigProvider>
|
|
|
|
);
|
2024-07-28 17:24:09 +08:00
|
|
|
}
|
2024-07-23 16:25:11 +08:00
|
|
|
|
2024-08-15 17:59:43 +08:00
|
|
|
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,
|
|
|
|
};
|