diff --git a/packages/lynx-devtool-web/src/containers/TestBench/TestBench.scss b/packages/lynx-devtool-web/src/containers/TestBench/TestBench.scss new file mode 100644 index 000000000..37b7a225d --- /dev/null +++ b/packages/lynx-devtool-web/src/containers/TestBench/TestBench.scss @@ -0,0 +1,78 @@ +// Copyright 2024 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +.ldt-devtool-tabs { + background-color: white; + .session-item { + display: flex; + .session-url { + max-width: 150px; + min-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + margin-left: 5px; + } + } + + .ant-tabs-content { + height: calc(100vh - 103px) !important; + } + .ant-tabs-nav .ant-tabs-tab { + &:not(:last-of-type) { + margin-right: 12px; + } + } +} + +.devtool-card-debugged { + .ant-tabs-tab.ant-tabs-tab-active, + .ant-tabs-tab.ant-tabs-tab-active:hover { + border-bottom: 2px solid #faad14; + } +} + +.ldt-devtool-pannel { + position: relative; + width: 100%; + height: 100%; + + .tags { + display: flex; + position: absolute; + left: 5px; + bottom: 5px; + + .ant-tag { + margin: 3px; + } + } +} + +.ldt-devtool-pannel { + position: relative; + width: 100%; + height: 100%; + + .tags { + display: flex; + position: absolute; + left: 5px; + bottom: 5px; + + .ant-tag { + margin: 3px; + } + } +} + +.page-session-item-tooltips-content { + display: flex; + flex-direction: column; + align-items: center; +} + +.page-session-item-url-container { + max-height: 150px; + overflow-y: auto; +} diff --git a/packages/lynx-devtool-web/src/containers/TestBench/TestBench.tsx b/packages/lynx-devtool-web/src/containers/TestBench/TestBench.tsx new file mode 100644 index 000000000..7518341f5 --- /dev/null +++ b/packages/lynx-devtool-web/src/containers/TestBench/TestBench.tsx @@ -0,0 +1,327 @@ +// Copyright 2025 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/* eslint-disable max-len */ +/* eslint-disable max-lines-per-function */ +/* eslint-disable no-nested-ternary */ +// TODO: Optimize the devtool props.info type definition and delete this line +/* eslint-disable @typescript-eslint/ban-ts-comment */ + +import useConnection from '@/store/connection'; +import './TestBench.scss'; +import * as utils from '@/utils'; +import { Header } from 'antd/lib/layout/layout'; +import { Image } from "antd"; +import * as storeUtils from '@/utils/storeUtils'; +import * as switchUtils from '@/utils/switchUtils'; +import { showNotImplementedError, showTimeoutError } from '@/utils/notice'; +import debugDriver from '@/utils/debugDriver'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import useTestbench, { TestbenchStoreType } from '@/store/testbench'; +import { Button, Empty, notification, Popover, Table, Tabs, Tag, Tooltip } from 'antd'; +import QRCode from 'qrcode.react'; +import ButtonGroup from 'antd/lib/button/button-group'; +import { CheckOutlined, CloseOutlined, LoadingOutlined } from '@ant-design/icons'; +const tableHeight = document.body.clientHeight - 175; + +const TestBench = () => { + const { + selectedDevice, + deviceInfoMap, + setTestbenchStarting, + setTestbenchLoading, + connectRemoteDevice, + setTestbenchTimer + } = useConnection(); + + const { testbenchList, removeTestbench, removeAllTestBench } = useTestbench() as TestbenchStoreType; + + const { loading } = connectRemoteDevice as any; + + const { t } = useTranslation(); + + const onTestbenchAction = async (start: boolean) => { + let selectClientId = storeUtils.getSelectClientId(); + if (!selectClientId) { + if (selectedDevice.xdbOnline) { + // eslint-disable-next-line max-depth + try { + await connectRemoteDevice(true); + selectClientId = storeUtils.getSelectClientId(); + } catch (e) { + // Toast.error(t('connect_timeout_tips')!); + return; + } + } else { + // Toast.error(t('connect_device_first')!); + return; + } + } + + if (start) { + await switchUtils.openDevtool(true); + const debugModeResult = await switchUtils.openDebugMode(); + if (!debugModeResult) { + console.warn('testbench: debug-mode open failed'); + return; + } + + const startParams = { + method: 'Recording.start', + params: {} + }; + try { + const startRes = await debugDriver.sendCustomMessageAsync({ params: startParams }); + console.info('Recording.start', startRes); + if (startRes.error) { + setTestbenchStarting(selectClientId!!, false); + + // eslint-disable-next-line max-depth + if (startRes?.error?.message?.indexOf('Not implemented:') >= 0) { + showNotImplementedError('testbench'); + } else { + // Toast.error(startRes.error.message); + } + return; + } + // notification.success({ content: 'TestBench start record!', duration: 2 }); + setTestbenchStarting(selectClientId!!, true); + } catch (e) { + showTimeoutError(); + } + } else { + const endParams = { method: 'Recording.end', params: {} }; + try { + const endRes = await debugDriver.sendCustomMessageAsync({ params: endParams }); + console.info('Recording.end', endRes); + if (endRes.error) { + // Toast.error({ content: endRes.error.message, duration: 2 }); + setTestbenchStarting(selectClientId!!, false); + return; + } + // Toast.success({ content: 'TestBench stop record!', duration: 2 }); + setTestbenchStarting(selectClientId!!, false); + setTestbenchLoading(selectClientId!!, true); + const timer = setTimeout(() => { + console.warn('testbench timeout'); + if (storeUtils.getClientWithId(selectClientId!!)?.testbenchLoading) { + console.warn('testbench timeout toast'); + setTestbenchLoading(selectClientId!!, false); + // Toast.warning('Loading testbench data timeout, please try again later!'); + } + setTestbenchTimer(selectClientId!!, null); + }, 100000); + setTestbenchTimer(selectClientId!!, timer); + } catch (e) { + showTimeoutError(); + } + } + }; + + const handleCopy = (text: string) => { + // copyTextToClipboard(text); + // Toast.success('Link has copied to clipboard!'); + }; + + const renderTopBar = () => { + const deviceInfo = useMemo(() => { + if (selectedDevice.clientId) { + return deviceInfoMap[selectedDevice.clientId]; + } + }, [selectedDevice.clientId, deviceInfoMap]); + + const getBtnName = () => { + let btnName = 'Start'; + if (deviceInfo?.testbenchStarting) { + btnName = 'Stop'; + } else if (deviceInfo?.testbenchLoading) { + btnName = 'Loading'; + } + return btnName; + }; + + const showClearButton = () => { + return !(deviceInfo?.testbenchStarting || deviceInfo?.testbenchLoading || testbenchList.length === 0); + }; + + return ( + <> + + {showClearButton() ? ( + + ) : ( + + )} + + ); + }; + + const renderResultView = () => { + const introduction = '\nIf you had opened page before starting record,the possible reasons are as follows:\n'; + const aboveLynx2_11Hint = + '- If the page is loaded when the APP is started, you can try TestBench starting recording function;\n'; + const generalHint = '- Clear the APP cache, restart the APP and start recording;'; + + const lynxVersion = selectedDevice.info?.sdkVersion ?? 'unknown'; + const columns = [ + { + title: 'Id', + dataIndex: 'id', + key: 'id' + }, + { + title: 'Device', + dataIndex: 'deviceModel', + key: 'deviceModel' + }, + { + title: 'App', + dataIndex: 'appName', + key: 'appName' + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + render: (_: any, { isValid, message }: any) => { + if (isValid) { + return ( +
+ +
+ ); + } else { + let hint = message; + if (!(lynxVersion === 'unknown')) { + const indexForFirstDot = lynxVersion.indexOf('.'); + const indexForSecondDot = lynxVersion.indexOf('.', indexForFirstDot + 1); + const version = parseFloat(lynxVersion.substring(0, indexForSecondDot)); + if (version >= 2.2 && version < 2.11) { + hint = message + introduction + generalHint; + } else if (version >= 2.11) { + hint = message + introduction + aboveLynx2_11Hint + generalHint; + } + } + + return ( +
+ + {hint} +
+ ); + } + } + }, + { + title: 'Preview', + dataIndex: 'pic', + key: 'pic', + render: (_: any, { pic }: any) => { + return ( + + ); + } + }, + { + title: 'QRCode', + dataIndex: 'url', + key: 'url', + render: (_: any, { url }: any) => { + return ( + +
Use the LynxExample App to scan the QR code
+ + + } + > + +
+ ); + } + }, + { + title: 'Action', + key: 'action', + width: 280, + render: (_: any, record: any) => ( + + + + + + ) + } + ]; + return ( + + ); + }; + + return ( + <> +
{renderTopBar()}
+ {renderResultView()} + + ); +}; + +export default TestBench; diff --git a/packages/lynx-devtool-web/src/containers/TestBench/components/DebugButton.tsx b/packages/lynx-devtool-web/src/containers/TestBench/components/DebugButton.tsx new file mode 100644 index 000000000..771e26469 --- /dev/null +++ b/packages/lynx-devtool-web/src/containers/TestBench/components/DebugButton.tsx @@ -0,0 +1,69 @@ +// Copyright 2024 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import DebugSvg from '@/assets/icons/debug.svg'; +import { canUseCardDebug } from '@/services/device'; +import useConnection from '@/store/connection'; +import { IDeviceInfo } from '@/types/device'; +import { getDebugMode } from '@/utils/const'; +import { sendStatisticsEvent } from '@/utils/statisticsUtils'; +import { Button, message } from 'antd'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import './TestBenchButton.scss'; + +const DebugButton = (props: any) => { + const { selectedDevice, deviceInfoMap, setCardDebugMode } = useConnection(); + const [isLoading, setLoading] = useState(false); + const { t } = useTranslation(); + + const switchCardDebugMode = async (deviceInfo: IDeviceInfo) => { + if (isLoading || !selectedDevice.clientId) { + return; + } + const categories: any = {}; + const { isCardDebugMode } = deviceInfo; + categories.action = isCardDebugMode ? 'stop' : 'start'; + + if (!canUseCardDebug(selectedDevice)) { + message.warning(t('debug_mode_not_support')!); + categories.result = t('debug_mode_not_support'); + } else { + setLoading(true); + try { + await setCardDebugMode(selectedDevice.clientId, !isCardDebugMode, true); + categories.result = 'success'; + } catch (e: any) { + message.error(e.message); + categories.result = e.message; + } + setLoading(false); + } + + sendStatisticsEvent({ name: 'set_card_debug_mode', categories }); + }; + + if (getDebugMode() !== 'card') { + return null; + } + + if (selectedDevice.clientId) { + const deviceInfo = deviceInfoMap[selectedDevice.clientId]; + if (deviceInfo?.sessions) { + return ( + + + + + ); +}; + +export default DevToolAbnormal; diff --git a/packages/lynx-devtool-web/src/containers/TestBench/components/TestBenchButton.scss b/packages/lynx-devtool-web/src/containers/TestBench/components/TestBenchButton.scss new file mode 100644 index 000000000..23a423ade --- /dev/null +++ b/packages/lynx-devtool-web/src/containers/TestBench/components/TestBenchButton.scss @@ -0,0 +1,8 @@ +// Copyright 2024 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +.devtool-nav-debug { + width: 16px; + height: 16px; +} \ No newline at end of file diff --git a/packages/lynx-devtool-web/src/router.tsx b/packages/lynx-devtool-web/src/router.tsx index dd370950e..010e688e2 100644 --- a/packages/lynx-devtool-web/src/router.tsx +++ b/packages/lynx-devtool-web/src/router.tsx @@ -3,12 +3,13 @@ // LICENSE file in the root directory of this source tree. import DevToolSvg from '@/assets/icons/devtool.svg'; -import { ToolOutlined } from '@ant-design/icons'; +import { ToolOutlined, VideoCameraOutlined} from '@ant-design/icons'; import { t } from 'i18next'; import { Suspense, lazy, useMemo } from 'react'; import CacheRoutes, { RouterConfig } from './components/CacheRoutes/CacheRoutes'; const DevTool = lazy(() => import('./containers/DevTool/DevTool')); +const TestBench = lazy(()=> import('./containers/TestBench/TestBench')); // eslint-disable-next-line max-lines-per-function export function getRouters(): Array { @@ -26,6 +27,14 @@ export function getRouters(): Array { element: , isMenu: true, keepAlive: true + }, + { + title: 'Lynx TestBench', + icon: , + path: '/testbench', + element: , + isMenu: true, + keepAlive: true } ]; } diff --git a/packages/lynx-devtool-web/src/store/testbench.ts b/packages/lynx-devtool-web/src/store/testbench.ts new file mode 100644 index 000000000..f47dd6e1a --- /dev/null +++ b/packages/lynx-devtool-web/src/store/testbench.ts @@ -0,0 +1,48 @@ +import create from '../utils/flooks'; + +export interface TestbenchItem { + id: number; + pic: string; + url: string; + cdn: string; + appName?: string; + deviceModel?: string; + osType?: string; + isValid: boolean; + message: any; +} + +export type TestbenchStoreType = ReturnType; +const testbenchStore = (store: any) => ({ + starting: false, + loading: false, + testbenchList: [] as TestbenchItem[], + screenshotMap: {} as any, + setTestbenchList: (testbenchList: TestbenchItem[]) => { + store({ testbenchList: [...testbenchList] }); + }, + addTestbenchData: (testbench: TestbenchItem) => { + const { testbenchList } = store() as TestbenchStoreType; + testbenchList.unshift(testbench); + store({ testbenchList: [...testbenchList] }); + }, + removeTestbench: (testbenchId: number) => { + if (testbenchId) { + const { testbenchList } = store() as TestbenchStoreType; + store({ testbenchList: testbenchList.filter((testbench) => testbench.id !== testbenchId) }); + } + }, + addScreenshot: (sessionId: number, data: any) => { + if (sessionId && data) { + const { screenshotMap } = store() as TestbenchStoreType; + screenshotMap[sessionId.toString()] = data; + store({ screenshotMap: { ...screenshotMap } }); + } + }, + removeAllTestBench: () => { + store({ testbenchList: [] }); + } +}); + +const useTestbench = create(testbenchStore); +export default useTestbench; diff --git a/packages/lynx-devtool-web/src/utils/messageCenter.ts b/packages/lynx-devtool-web/src/utils/messageCenter.ts index bc4898021..8bca3969e 100644 --- a/packages/lynx-devtool-web/src/utils/messageCenter.ts +++ b/packages/lynx-devtool-web/src/utils/messageCenter.ts @@ -15,9 +15,11 @@ import { getStore } from './flooks'; import * as reduxUtils from './storeUtils'; import useUnattached, { UnattachedStoreType } from '@/store/unattached'; import envLogger from './envLogger'; +import { TestbenchStoreType } from '@/store/testbench'; class MessageCenter { clientAction: ConnectionStoreType | null = null; + testbenchAction: TestbenchStoreType | null = null; unattachedAction: UnattachedStoreType | null = null; // ********* recv from LDT server **********/ @@ -115,6 +117,9 @@ class MessageCenter { case LDT_CONST.TRACING_EVENT_COMPLETE: this.handleTraceComplete(msg, clientId); break; + case LDT_CONST.TESTBENCH_EVENT_COMPLETE: + this.handleTestbenchComplete(msg, clientId); + break; case LDT_CONST.MSG_ScreenshotCaptured: { const sessionId = content?.data?.session_id; this.handleScreenshotCaptured(msg, sessionId); @@ -129,6 +134,73 @@ class MessageCenter { return false; } + async handleTestbenchComplete(msg: any, clientId: number) { + console.log('has receive TestbenchComplete'); + const streams = msg?.params?.stream; + const sessionIds = msg?.params?.sessionIDs; + if (streams) { + const allStreams: Promise[] = []; + streams.forEach((streamId: number, index: number) => { + const session_id = sessionIds[index]; + if (session_id !== -1) { + console.log('start read testbench data:'); + allStreams.push( + this.readStreamDataPromise(streamId).then((dataChunks) => { + if (dataChunks) { + return this.handleTestbenchData(dataChunks, session_id); + } else { + console.warn('dataChunks is ' + dataChunks); + } + }) + ); + } else { + console.warn('invalid msg format: session_id:' + session_id); + } + }); + await Promise.all(allStreams); + this.clientAction?.setTestbenchLoading(clientId, false); + } else { + // 未返回有效数据 + this.clientAction?.setTestbenchLoading(clientId, false); + // Notification.warning({ + // content: React.createElement('div', null, t('testbench_notice')), + // duration: 6 + // }); + } + } + + async handleTestbenchData(buffers: Array, sessionId: number) { + const filename = `${utils.getFileName()}__${sessionId}.json`; + const buffer = Buffer.concat(buffers); + // const bufferWithInternalResource = await resourceUrlRedirect(buffer); + // const cdnRes = await uploadFileBufferTos([].slice.call(buffer), filename); + // if (!cdnRes?.url) { + // console.warn('cdnRes url is incorrect:' + cdnRes?.url); + // return; + // } + const currentClient = reduxUtils.getSelectClient(); + const appName = currentClient?.info?.App; + const deviceModel = currentClient?.info?.deviceModel; + const osType = currentClient?.info?.osType; + const PIC_URL_PREFIX = 'data:image/jpeg;base64,'; + const qr_url = `sslocal://arkview?url=$`; + // const testbenchState = reduxUtils.getCurrentTestbenchState() as any; + const pic = null; + const pic_src = pic ? PIC_URL_PREFIX + pic : unknowdScreenImg; + // const [isValid, message] = checkValid(buffer); + this.testbenchAction?.addTestbenchData({ + id: sessionId, + pic: pic_src, + url: qr_url, + cdn: qr_url, + appName, + deviceModel, + osType, + isValid: true, + message:null, + }); + } + async readStreamDataPromise(stream: number): Promise> { const dataChunks: Array = []; try {