diff --git a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts index 6eaa36345b..ae860e9047 100644 --- a/packages/playwright-core/src/server/trace/viewer/traceViewer.ts +++ b/packages/playwright-core/src/server/trace/viewer/traceViewer.ts @@ -26,10 +26,10 @@ import { createPlaywright } from '../../playwright'; import { ProgressController } from '../../progress'; import type { Page } from '../../page'; -type Options = { headless?: boolean, host?: string, port?: number, watchMode?: boolean }; +type Options = { app?: string, headless?: boolean, host?: string, port?: number }; export async function showTraceViewer(traceUrls: string[], browserName: string, options?: Options): Promise { - const { headless = false, host, port, watchMode } = options || {}; + const { headless = false, host, port, app } = options || {}; for (const traceUrl of traceUrls) { if (!traceUrl.startsWith('http://') && !traceUrl.startsWith('https://') && !fs.existsSync(traceUrl)) { // eslint-disable-next-line no-console @@ -89,8 +89,6 @@ export async function showTraceViewer(traceUrls: string[], browserName: string, await syncLocalStorageWithSettings(page, 'traceviewer'); const params = traceUrls.map(t => `trace=${t}`); - if (watchMode) - params.push('watchMode=true'); if (isUnderTest()) { params.push('isUnderTest=true'); page.on('close', () => context.close(serverSideCallMetadata()).catch(() => {})); @@ -99,6 +97,6 @@ export async function showTraceViewer(traceUrls: string[], browserName: string, } const searchQuery = params.length ? '?' + params.join('&') : ''; - await page.mainFrame().goto(serverSideCallMetadata(), urlPrefix + `/trace/index.html${searchQuery}`); + await page.mainFrame().goto(serverSideCallMetadata(), urlPrefix + `/trace/${app || 'index.html'}${searchQuery}`); return page; } diff --git a/packages/playwright-test/src/reporters/html.ts b/packages/playwright-test/src/reporters/html.ts index 9b7ba809b0..f435bb5aff 100644 --- a/packages/playwright-test/src/reporters/html.ts +++ b/packages/playwright-test/src/reporters/html.ts @@ -281,12 +281,18 @@ class HtmlBuilder { if (this._hasTraces) { const traceViewerFolder = path.join(require.resolve('playwright-core'), '..', 'lib', 'webpack', 'traceViewer'); const traceViewerTargetFolder = path.join(this._reportFolder, 'trace'); - fs.mkdirSync(traceViewerTargetFolder, { recursive: true }); + const traceViewerAssetsTargetFolder = path.join(traceViewerTargetFolder, 'assets'); + fs.mkdirSync(traceViewerAssetsTargetFolder, { recursive: true }); for (const file of fs.readdirSync(traceViewerFolder)) { - if (file.endsWith('.map')) + if (file.endsWith('.map') || file.includes('watch') || file.includes('assets')) continue; await copyFileAndMakeWritable(path.join(traceViewerFolder, file), path.join(traceViewerTargetFolder, file)); } + for (const file of fs.readdirSync(path.join(traceViewerFolder, 'assets'))) { + if (file.endsWith('.map') || file.includes('xTermModule')) + continue; + await copyFileAndMakeWritable(path.join(traceViewerFolder, 'assets', file), path.join(traceViewerAssetsTargetFolder, file)); + } } // Inline report data. diff --git a/packages/playwright-test/src/runner/uiMode.ts b/packages/playwright-test/src/runner/uiMode.ts index e611897478..3ee6fd6c8d 100644 --- a/packages/playwright-test/src/runner/uiMode.ts +++ b/packages/playwright-test/src/runner/uiMode.ts @@ -65,7 +65,7 @@ class UIMode { } async showUI() { - this._page = await showTraceViewer([], 'chromium', { watchMode: true }); + this._page = await showTraceViewer([], 'chromium', { app: 'watch.html' }); const exitPromise = new ManualPromise(); this._page.on('close', () => exitPromise.resolve()); this._page.exposeBinding('sendMessage', false, async (source, data) => { diff --git a/packages/trace-viewer/public/watch.webmanifest b/packages/trace-viewer/public/watch.webmanifest new file mode 100644 index 0000000000..ba5434d67b --- /dev/null +++ b/packages/trace-viewer/public/watch.webmanifest @@ -0,0 +1,30 @@ +{ + "theme_color": "#000", + "background_color": "#fff", + "display": "browser", + "start_url": "watch.html", + "name": "Playwright Test", + "short_name": "Trace Viewer", + "icons": [ + { + "src": "icon-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icon-256x256.png", + "sizes": "256x256", + "type": "image/png" + }, + { + "src": "icon-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "icon-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/packages/trace-viewer/src/index.tsx b/packages/trace-viewer/src/index.tsx index dc586dd785..28ee7876af 100644 --- a/packages/trace-viewer/src/index.tsx +++ b/packages/trace-viewer/src/index.tsx @@ -19,17 +19,8 @@ import { applyTheme } from '@web/theme'; import '@web/third_party/vscode/codicon.css'; import React from 'react'; import * as ReactDOM from 'react-dom'; -import { WatchModeView } from './ui/watchMode'; import { WorkbenchLoader } from './ui/workbench'; -export const RootView: React.FC<{}> = ({ -}) => { - if (window.location.href.includes('watchMode=true')) - return ; - else - return ; -}; - (async () => { applyTheme(); if (window.location.protocol !== 'file:') { @@ -46,5 +37,5 @@ export const RootView: React.FC<{}> = ({ setInterval(function() { fetch('ping'); }, 10000); } - ReactDOM.render(, document.querySelector('#root')); + ReactDOM.render(, document.querySelector('#root')); })(); diff --git a/packages/trace-viewer/src/watch.tsx b/packages/trace-viewer/src/watch.tsx new file mode 100644 index 0000000000..ec84ff32e0 --- /dev/null +++ b/packages/trace-viewer/src/watch.tsx @@ -0,0 +1,41 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import '@web/common.css'; +import { applyTheme } from '@web/theme'; +import '@web/third_party/vscode/codicon.css'; +import * as ReactDOM from 'react-dom'; +import { WatchModeView } from './ui/watchMode'; + +(async () => { + applyTheme(); + if (window.location.protocol !== 'file:') { + if (window.location.href.includes('isUnderTest=true')) + await new Promise(f => setTimeout(f, 1000)); + navigator.serviceWorker.register('sw.bundle.js'); + if (!navigator.serviceWorker.controller) { + await new Promise(f => { + navigator.serviceWorker.oncontrollerchange = () => f(); + }); + } + + // Keep SW running. + setInterval(function() { fetch('ping'); }, 10000); + } + + ReactDOM.render(, document.querySelector('#root')); +})(); diff --git a/packages/trace-viewer/vite.config.ts b/packages/trace-viewer/vite.config.ts index 79b7dcb962..d6e09ea78a 100644 --- a/packages/trace-viewer/vite.config.ts +++ b/packages/trace-viewer/vite.config.ts @@ -43,6 +43,7 @@ export default defineConfig({ rollupOptions: { input: { index: path.resolve(__dirname, 'index.html'), + watch: path.resolve(__dirname, 'watch.html'), popout: path.resolve(__dirname, 'popout.html'), }, output: { diff --git a/packages/trace-viewer/watch.html b/packages/trace-viewer/watch.html new file mode 100644 index 0000000000..3b6862994e --- /dev/null +++ b/packages/trace-viewer/watch.html @@ -0,0 +1,30 @@ + + + + + + + + + + Playwright Test + + +
+ + + diff --git a/packages/web/src/components/codeMirrorModule.tsx b/packages/web/src/components/codeMirrorModule.tsx new file mode 100644 index 0000000000..0c169e7eaf --- /dev/null +++ b/packages/web/src/components/codeMirrorModule.tsx @@ -0,0 +1,24 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import codemirror from 'codemirror'; +import 'codemirror/lib/codemirror.css'; +import 'codemirror/mode/javascript/javascript'; +import 'codemirror/mode/python/python'; +import 'codemirror/mode/clike/clike'; + +export type CodeMirror = typeof codemirror; +export default codemirror; diff --git a/packages/web/src/components/codeMirrorWrapper.tsx b/packages/web/src/components/codeMirrorWrapper.tsx index bc67f22ab9..38b606634f 100644 --- a/packages/web/src/components/codeMirrorWrapper.tsx +++ b/packages/web/src/components/codeMirrorWrapper.tsx @@ -16,11 +16,7 @@ import './source.css'; import * as React from 'react'; -import CodeMirror from 'codemirror'; -import 'codemirror/mode/javascript/javascript'; -import 'codemirror/mode/python/python'; -import 'codemirror/mode/clike/clike'; -import 'codemirror/lib/codemirror.css'; +import type { CodeMirror } from './codeMirrorModule'; export type SourceHighlight = { line: number; @@ -54,42 +50,53 @@ export const CodeMirrorWrapper: React.FC = ({ onChange, }) => { const codemirrorElement = React.createRef(); + const [modulePromise] = React.useState>(import('./codeMirrorModule').then(m => m.default)); const [codemirror, setCodemirror] = React.useState(); React.useEffect(() => { - let mode; - if (language === 'javascript') - mode = 'javascript'; - if (language === 'python') - mode = 'python'; - if (language === 'java') - mode = 'text/x-java'; - if (language === 'csharp') - mode = 'text/x-csharp'; + (async () => { + // Always load the module first. + const CodeMirror = await modulePromise; - if (codemirror && codemirror.getOption('mode') === mode && codemirror.isReadOnly() === readOnly) - return; + const element = codemirrorElement.current; + if (!element) + return; - if (!codemirrorElement.current) - return; - if (codemirror) - codemirror.getWrapperElement().remove(); + let mode = 'javascript'; + if (language === 'python') + mode = 'python'; + if (language === 'java') + mode = 'text/x-java'; + if (language === 'csharp') + mode = 'text/x-csharp'; - const cm = CodeMirror(codemirrorElement.current, { - value: '', - mode, - readOnly, - lineNumbers, - lineWrapping: wrapLines, - }); - if (onChange) - cm.on('change', () => onChange(cm.getValue())); - setCodemirror(cm); - updateEditor(cm, text, highlight, revealLine, focusOnChange); - }, [codemirror, codemirrorElement, text, language, highlight, revealLine, focusOnChange, lineNumbers, wrapLines, readOnly, onChange]); + if (codemirror + && mode === codemirror.getOption('mode') + && readOnly === codemirror.getOption('readOnly') + && lineNumbers === codemirror.getOption('lineNumbers') + && wrapLines === codemirror.getOption('lineWrapping')) { + updateEditor(codemirror, text, highlight, revealLine, focusOnChange); + return; + } - if (codemirror) - updateEditor(codemirror, text, highlight, revealLine, focusOnChange); + // Either configuration is different or we don't have a codemirror yet. + if (codemirror) + codemirror.getWrapperElement().remove(); + + const cm = CodeMirror(element, { + value: '', + mode, + readOnly, + lineNumbers, + lineWrapping: wrapLines, + }); + setCodemirror(cm); + if (onChange) + cm.on('change', () => onChange(cm.getValue())); + updateEditor(cm, text, highlight, revealLine, focusOnChange); + return cm; + })(); + }, [modulePromise, codemirror, codemirrorElement, text, language, highlight, revealLine, focusOnChange, lineNumbers, wrapLines, readOnly, onChange]); return
; }; diff --git a/packages/web/src/components/xTermModule.tsx b/packages/web/src/components/xTermModule.tsx new file mode 100644 index 0000000000..28dbd017c4 --- /dev/null +++ b/packages/web/src/components/xTermModule.tsx @@ -0,0 +1,27 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import 'xterm/css/xterm.css'; + +import { Terminal } from 'xterm'; +import { FitAddon } from 'xterm-addon-fit'; + +export type XTermModule = { + Terminal: typeof Terminal; + FitAddon: typeof FitAddon; +}; + +export default { Terminal, FitAddon }; diff --git a/packages/web/src/components/xtermWrapper.tsx b/packages/web/src/components/xtermWrapper.tsx index 80b2b459ca..b0b2a4e686 100644 --- a/packages/web/src/components/xtermWrapper.tsx +++ b/packages/web/src/components/xtermWrapper.tsx @@ -15,10 +15,9 @@ */ import * as React from 'react'; -import { Terminal } from 'xterm'; -import { FitAddon } from 'xterm-addon-fit'; -import 'xterm/css/xterm.css'; import './xtermWrapper.css'; +import type { Terminal } from 'xterm'; +import type { XTermModule } from './xtermModule'; export type XTermDataSource = { pending: (string | Uint8Array)[]; @@ -30,29 +29,37 @@ export const XTermWrapper: React.FC<{ source: XTermDataSource }> = ({ source }) => { const xtermElement = React.createRef(); + const [modulePromise] = React.useState>(import('./xTermModule').then(m => m.default)); const [terminal, setTerminal] = React.useState(); React.useEffect(() => { - if (terminal) - return; - if (!xtermElement.current) - return; - const newTerminal = new Terminal({ convertEol: true }); - const fitAddon = new FitAddon(); - newTerminal.loadAddon(fitAddon); - for (const p of source.pending) - newTerminal.write(p); - source.write = (data => { - newTerminal.write(data); - }); - newTerminal.open(xtermElement.current); - setTerminal(newTerminal); - fitAddon.fit(); - const resizeObserver = new ResizeObserver(() => { - source.resize(newTerminal.cols, newTerminal.rows); + (async () => { + // Always load the module first. + const { Terminal, FitAddon } = await modulePromise; + const element = xtermElement.current; + if (!element) + return; + + if (terminal) + return; + + const newTerminal = new Terminal({ convertEol: true }); + const fitAddon = new FitAddon(); + newTerminal.loadAddon(fitAddon); + for (const p of source.pending) + newTerminal.write(p); + source.write = (data => { + newTerminal.write(data); + }); + newTerminal.open(element); fitAddon.fit(); - }); - resizeObserver.observe(xtermElement.current); - }, [terminal, xtermElement, source]); + setTerminal(newTerminal); + const resizeObserver = new ResizeObserver(() => { + source.resize(newTerminal.cols, newTerminal.rows); + fitAddon.fit(); + }); + resizeObserver.observe(element); + })(); + }, [modulePromise, terminal, xtermElement, source]); return
; };