chore(trace-viewer): ide mode

This commit is contained in:
Rui Figueira 2024-09-15 07:46:23 +01:00
parent b487297460
commit a55a2d933c
7 changed files with 91 additions and 64 deletions

View file

@ -114,6 +114,7 @@ function addTestServerCommand(program: Command) {
command.option('-c, --config <file>', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`); command.option('-c, --config <file>', `Configuration file, or a test directory with optional "playwright.config.{m,c}?{js,ts}"`);
command.option('--host <host>', 'Host to start the server on', 'localhost'); command.option('--host <host>', 'Host to start the server on', 'localhost');
command.option('--port <port>', 'Port to start the server on', '0'); command.option('--port <port>', 'Port to start the server on', '0');
command.option('--ide-mode', 'IDE node');
command.action(opts => runTestServer(opts)); command.action(opts => runTestServer(opts));
} }
@ -227,7 +228,8 @@ async function runTests(args: string[], opts: { [key: string]: any }) {
async function runTestServer(opts: { [key: string]: any }) { async function runTestServer(opts: { [key: string]: any }) {
const host = opts.host || 'localhost'; const host = opts.host || 'localhost';
const port = opts.port ? +opts.port : 0; const port = opts.port ? +opts.port : 0;
const status = await testServer.runTestServer(opts.config, { host, port }); const ideMode = !!opts.ideMode;
const status = await testServer.runTestServer(opts.config, { host, port, ideMode });
if (status === 'restarted') if (status === 'restarted')
return; return;
const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1); const exitCode = status === 'interrupted' ? 130 : (status === 'passed' ? 0 : 1);

View file

@ -479,15 +479,17 @@ export async function runUIMode(configFile: string | undefined, options: TraceVi
}); });
} }
export async function runTestServer(configFile: string | undefined, options: { host?: string, port?: number }): Promise<reporterTypes.FullResult['status'] | 'restarted'> { export async function runTestServer(configFile: string | undefined, options: { host?: string, port?: number, ideMode?: boolean }): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
const configLocation = resolveConfigLocation(configFile); const configLocation = resolveConfigLocation(configFile);
return await innerRunTestServer(configLocation, options, async server => { return await innerRunTestServer(configLocation, options, async server => {
if (options.ideMode)
await installRootRedirect(server, [], { ...options, webApp: 'ideMode.html' });
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('Listening on ' + server.urlPrefix('precise').replace('http:', 'ws:') + '/' + server.wsGuid()); console.log('Listening on ' + server.urlPrefix('precise').replace('http:', 'ws:') + '/' + server.wsGuid());
}); });
} }
async function innerRunTestServer(configLocation: ConfigLocation, options: { host?: string, port?: number }, openUI: (server: HttpServer, cancelPromise: ManualPromise<void>, configLocation: ConfigLocation) => Promise<void>): Promise<reporterTypes.FullResult['status'] | 'restarted'> { async function innerRunTestServer(configLocation: ConfigLocation, options: { host?: string, port?: number, ideMode?: boolean }, openUI: (server: HttpServer, cancelPromise: ManualPromise<void>, configLocation: ConfigLocation) => Promise<void>): Promise<reporterTypes.FullResult['status'] | 'restarted'> {
if (restartWithExperimentalTsEsm(undefined, true)) if (restartWithExperimentalTsEsm(undefined, true))
return 'restarted'; return 'restarted';
const testServer = new TestServer(configLocation); const testServer = new TestServer(configLocation);

View file

@ -18,10 +18,11 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playwright Trace Viewer for VS Code</title> <link rel="icon" href="/playwright-logo.svg" type="image/svg+xml">
<title>Playwright Trace Viewer</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/embedded.tsx"></script> <script type="module" src="/src/ideMode.tsx"></script>
</body> </body>
</html> </html>

View file

@ -18,30 +18,10 @@ import '@web/common.css';
import { applyTheme } from '@web/theme'; import { applyTheme } from '@web/theme';
import '@web/third_party/vscode/codicon.css'; import '@web/third_party/vscode/codicon.css';
import * as ReactDOM from 'react-dom/client'; import * as ReactDOM from 'react-dom/client';
import { EmbeddedWorkbenchLoader } from './ui/embeddedWorkbenchLoader'; import { IDEModeView } from './ui/ideModeView';
(async () => { (async () => {
applyTheme(); applyTheme();
// workaround to send keystrokes back to vscode webview to keep triggering key bindings there
const handleKeyEvent = (e: KeyboardEvent) => {
if (!e.isTrusted)
return;
window.parent?.postMessage({
type: e.type,
key: e.key,
keyCode: e.keyCode,
code: e.code,
shiftKey: e.shiftKey,
altKey: e.altKey,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
repeat: e.repeat,
}, '*');
};
window.addEventListener('keydown', handleKeyEvent);
window.addEventListener('keyup', handleKeyEvent);
if (window.location.protocol !== 'file:') { if (window.location.protocol !== 'file:') {
if (!navigator.serviceWorker) if (!navigator.serviceWorker)
throw new Error(`Service workers are not supported.\nMake sure to serve the Trace Viewer (${window.location}) via HTTPS or localhost.`); throw new Error(`Service workers are not supported.\nMake sure to serve the Trace Viewer (${window.location}) via HTTPS or localhost.`);
@ -56,5 +36,5 @@ import { EmbeddedWorkbenchLoader } from './ui/embeddedWorkbenchLoader';
setInterval(function() { fetch('ping'); }, 10000); setInterval(function() { fetch('ping'); }, 10000);
} }
ReactDOM.createRoot(document.querySelector('#root')!).render(<EmbeddedWorkbenchLoader />); ReactDOM.createRoot(document.querySelector('#root')!).render(<IDEModeView />);
})(); })();

View file

@ -48,6 +48,7 @@ body.dark-mode .empty-state {
flex: none; flex: none;
width: 100%; width: 100%;
height: 3px; height: 3px;
margin-top: -3px;
z-index: 10; z-index: 10;
} }
@ -56,10 +57,51 @@ body.dark-mode .empty-state {
height: 100%; height: 100%;
} }
.workbench-loader { .header {
display: flex;
background-color: #000;
flex: none;
flex-basis: 48px;
line-height: 48px;
font-size: 16px;
color: #cccccc;
}
.ide-mode {
contain: size; contain: size;
} }
.ide-mode .header .toolbar-button {
margin: 12px;
padding: 8px 4px;
}
.ide-mode .logo {
margin-left: 16px;
display: flex;
align-items: center;
}
.ide-mode .logo img {
height: 32px;
width: 32px;
pointer-events: none;
flex: none;
}
.ide-mode .product {
font-weight: 600;
margin-left: 16px;
flex: none;
}
.ide-mode .header .title {
margin-left: 16px;
overflow: hidden;
text-overflow: ellipsis;
text-wrap: nowrap;
}
/* Limit to a reasonable minimum viewport */ /* Limit to a reasonable minimum viewport */
html, body { html, body {
min-width: 550px; min-width: 550px;

View file

@ -14,42 +14,28 @@
limitations under the License. limitations under the License.
*/ */
import { TestServerConnection, WebSocketTestServerTransport } from '@testIsomorphic/testServerConnection';
import { ToolbarButton } from '@web/components/toolbarButton';
import { toggleTheme } from '@web/theme';
import * as React from 'react'; import * as React from 'react';
import type { ContextEntry } from '../entries'; import type { ContextEntry } from '../entries';
import './ideModeView.css';
import type { ActionTraceEventInContext } from './modelUtil';
import { MultiTraceModel } from './modelUtil'; import { MultiTraceModel } from './modelUtil';
import './embeddedWorkbenchLoader.css';
import { Workbench } from './workbench'; import { Workbench } from './workbench';
import { currentTheme, toggleTheme } from '@web/theme';
import type { SourceLocation } from './modelUtil';
function openPage(url: string, target?: string) { export const IDEModeView: React.FunctionComponent = () => {
if (url)
window.parent!.postMessage({ method: 'openExternal', params: { url, target } }, '*');
}
function openSourceLocation({ file, line, column }: SourceLocation) {
window.parent!.postMessage({ method: 'openSourceLocation', params: { file, line, column } }, '*');
}
export const EmbeddedWorkbenchLoader: React.FunctionComponent = () => {
const [traceURLs, setTraceURLs] = React.useState<string[]>([]); const [traceURLs, setTraceURLs] = React.useState<string[]>([]);
const [model, setModel] = React.useState<MultiTraceModel>(emptyModel); const [model, setModel] = React.useState<MultiTraceModel>(emptyModel);
const [progress, setProgress] = React.useState<{ done: number, total: number }>({ done: 0, total: 0 }); const [progress, setProgress] = React.useState<{ done: number, total: number }>({ done: 0, total: 0 });
const [processingErrorMessage, setProcessingErrorMessage] = React.useState<string | null>(null); const [testServerConnection, setTestServerConnection] = React.useState<TestServerConnection>();
React.useEffect(() => { const selectionChanged = React.useCallback((action: ActionTraceEventInContext) => {
window.addEventListener('message', async ({ data: { method, params } }) => { if (!testServerConnection || !action?.stack || action.stack.length === 0)
if (method === 'loadTraceRequested') { return;
setTraceURLs(params.traceUrl ? [params.traceUrl] : []); const [{ file, line, column }] = action.stack;
setProcessingErrorMessage(null); testServerConnection.dispatchTraceViewerEventNoReply({ method: 'openSourceLocation', params: { file, line, column } });
} else if (method === 'applyTheme') { }, [testServerConnection]);
if (currentTheme() !== params.theme)
toggleTheme();
}
});
// notify vscode that it is now listening to its messages
window.parent!.postMessage({ type: 'loaded' }, '*');
}, []);
React.useEffect(() => { React.useEffect(() => {
(async () => { (async () => {
@ -66,10 +52,8 @@ export const EmbeddedWorkbenchLoader: React.FunctionComponent = () => {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('trace', url); params.set('trace', url);
const response = await fetch(`contexts?${params.toString()}`); const response = await fetch(`contexts?${params.toString()}`);
if (!response.ok) { if (!response.ok)
setProcessingErrorMessage((await response.json()).error);
return; return;
}
contextEntries.push(...(await response.json())); contextEntries.push(...(await response.json()));
} }
navigator.serviceWorker.removeEventListener('message', swListener); navigator.serviceWorker.removeEventListener('message', swListener);
@ -83,15 +67,31 @@ export const EmbeddedWorkbenchLoader: React.FunctionComponent = () => {
}, [traceURLs]); }, [traceURLs]);
React.useEffect(() => { React.useEffect(() => {
if (processingErrorMessage) const guid = new URLSearchParams(window.location.search).get('ws');
window.parent?.postMessage({ method: 'showErrorMessage', params: { message: processingErrorMessage } }, '*'); const wsURL = new URL(`../${guid}`, window.location.toString());
}, [processingErrorMessage]); wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
const testServerConnection = new TestServerConnection(new WebSocketTestServerTransport(wsURL));
testServerConnection.onLoadTraceRequested(async params => {
setTraceURLs(params.traceUrl ? [params.traceUrl] : []);
});
testServerConnection.dispatchTraceViewerEventNoReply({ method: 'loaded', params: {} });
setTestServerConnection(testServerConnection);
}, []);
return <div className='vbox workbench-loader'> return <div className='vbox ide-mode'>
<div className='hbox header'>
<div className='logo'>
<img src='playwright-logo.svg' alt='Playwright logo' />
</div>
<div className='product'>Playwright</div>
{model.title && <div className='title'>{model.title}</div>}
<div className='spacer'></div>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
</div>
<div className='progress'> <div className='progress'>
<div className='inner-progress' style={{ width: progress.total ? (100 * progress.done / progress.total) + '%' : 0 }}></div> <div className='inner-progress' style={{ width: progress.total ? (100 * progress.done / progress.total) + '%' : 0 }}></div>
</div> </div>
<Workbench model={model} openPage={openPage} onOpenExternally={openSourceLocation} showSettings /> <Workbench model={model} onSelectionChanged={selectionChanged} showSettings />
{!traceURLs.length && <div className='empty-state'> {!traceURLs.length && <div className='empty-state'>
<div className='title'>Select test to see the trace</div> <div className='title'>Select test to see the trace</div>
</div>} </div>}

View file

@ -44,7 +44,7 @@ export default defineConfig({
input: { input: {
index: path.resolve(__dirname, 'index.html'), index: path.resolve(__dirname, 'index.html'),
uiMode: path.resolve(__dirname, 'uiMode.html'), uiMode: path.resolve(__dirname, 'uiMode.html'),
embedded: path.resolve(__dirname, 'embedded.html'), ideMode: path.resolve(__dirname, 'ideMode.html'),
recorder: path.resolve(__dirname, 'recorder.html'), recorder: path.resolve(__dirname, 'recorder.html'),
snapshot: path.resolve(__dirname, 'snapshot.html'), snapshot: path.resolve(__dirname, 'snapshot.html'),
}, },