import './index.less'; import DetailSide from '@/component/detail-side'; import Sidebar from '@/component/sidebar'; import { useExecutionDump } from '@/component/store'; import { CaretRightOutlined, DownOutlined } from '@ant-design/icons'; import type { GroupedActionDump } from '@midscene/core'; import { Helmet } from '@modern-js/runtime/head'; import { Alert, Button, ConfigProvider, Dropdown, Empty, Upload, message, } from 'antd'; import type { 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 logoImg from './component/assets/logo-plain.png'; import DetailPanel from './component/detail-panel'; import GlobalHoverPreview from './component/global-hover-preview'; import Logo from './component/logo'; import { iconForStatus, timeCostStrElement } from './component/misc'; import Player from './component/player'; import Timeline from './component/timeline'; const { Dragger } = Upload; let globalRenderCount = 1; interface ExecutionDumpWithPlaywrightAttributes extends GroupedActionDump { attributes: Record; } export function Visualizer(props: { logoAction?: () => void; dumps?: ExecutionDumpWithPlaywrightAttributes[]; }): JSX.Element { const { dumps } = props; const executionDump = useExecutionDump((store) => store.dump); const executionDumpLoadId = useExecutionDump( (store) => store._executionDumpLoadId, ); const replayAllMode = useExecutionDump((store) => store.replayAllMode); const setReplayAllMode = useExecutionDump((store) => store.setReplayAllMode); const replayAllScripts = useExecutionDump( (store) => store.allExecutionAnimation, ); const insightWidth = useExecutionDump((store) => store.insightWidth); const insightHeight = useExecutionDump((store) => store.insightHeight); const setGroupedDump = useExecutionDump((store) => store.setGroupedDump); const reset = useExecutionDump((store) => store.reset); const [mainLayoutChangeFlag, setMainLayoutChangeFlag] = useState(0); const mainLayoutChangedRef = useRef(false); const dump = useExecutionDump((store) => store.dump); useEffect(() => { if (dumps) { setGroupedDump(dumps[0]); } return () => { reset(); }; }, []); useEffect(() => { const onResize = () => { setMainLayoutChangeFlag((prev) => prev + 1); }; window.addEventListener('resize', onResize); return () => { window.removeEventListener('resize', onResize); }; }, []); const uploadProps: UploadProps = { name: 'file', multiple: false, capture: false, customRequest: () => { // noop }, beforeUpload(file) { const ifValidFile = file.name.endsWith('web-dump.json'); // || file.name.endsWith('.insight.json'); 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); setGroupedDump(data[0]); } 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 (dump && dump.executions.length === 0) { mainContent = (
); } else if (!executionDump) { mainContent = (

Midscene_logo

Click or drag the{' '} .web-dump.json {' '} {/* or{' '} .actions.json {' '} */} file into this area.

The latest dump file is usually placed in{' '} ./midscene_run/report

All data will be processed locally by the browser. No data will be sent to the server.

); // dump } else { const content = replayAllMode ? (
) : (
); mainContent = ( { if (!mainLayoutChangedRef.current) { setMainLayoutChangeFlag((prev) => prev + 1); } }} >
{ if (mainLayoutChangedRef.current && !isChanging) { // not changing anymore setMainLayoutChangeFlag((prev) => prev + 1); } mainLayoutChangedRef.current = isChanging; }} />
{content}
); } const [containerHeight, setContainerHeight] = useState('100%'); useEffect(() => { const ifInRspressPage = document.querySelector('.rspress-nav'); // modify rspress theme const navHeightKey = '--rp-nav-height'; const originalNavHeight = getComputedStyle( document.documentElement, ).getPropertyValue(navHeightKey); if (ifInRspressPage) { const newNavHeight = '42px'; setContainerHeight(`calc(100vh - ${newNavHeight})`); document.documentElement.style.setProperty(navHeightKey, newNavHeight); } // Cleanup function to revert the change return () => { if (ifInRspressPage) { document.documentElement.style.setProperty( navHeightKey, originalNavHeight, ); } }; }, []); useEffect(() => { return () => { globalRenderCount += 1; }; }, []); return ( Report - Midscene.js
{ setGroupedDump(dump); }} />
{mainContent}
); } 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 ? ( {' '} ({timeCostStrElement(Number.parseInt(costStr, 10))}) ) : null; return { key: index, label: ( { e.preventDefault(); if (props.onSelect) { props.onSelect(dump); } }} >
{status} {' '} {nameForDump(dump)} {cost}
), }; }); const btnName = props.selected ? nameForDump(props.selected) : 'Select a case'; return (
e.preventDefault()}> {btnName} 
); } 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"]', ); if (dumpElements.length === 1 && dumpElements[0].textContent?.trim() === '') { const errorPanel = (
); return root.render(errorPanel); } 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 = {}; 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); } }); root.render(); } export default { mount, Visualizer, };