From 3c877374c7f2e6d0d58a69b44d40fe3f21957cf3 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 12 Feb 2021 18:53:46 -0800 Subject: [PATCH] feat: add replay log (#5452) --- src/server/supplements/injected/recorder.ts | 5 +- src/server/supplements/inspectorController.ts | 12 +- .../supplements/recorder/recorderApp.ts | 31 ++-- .../supplements/recorder/recorderTypes.ts | 36 ++-- src/server/supplements/recorderSupplement.ts | 164 ++++++++++++------ src/web/common.css | 5 +- src/web/components/source.css | 2 +- src/web/components/source.tsx | 37 ++-- src/web/components/splitView.css | 42 +++++ src/web/components/splitView.tsx | 45 +++++ src/web/components/toolbar.css | 5 +- src/web/components/toolbarButton.css | 2 +- src/web/recorder/index.html | 2 +- src/web/recorder/recorder.css | 49 ++++++ src/web/recorder/recorder.tsx | 88 ++++++++-- src/web/recorder/webpack.config.js | 2 +- test/cli/cli-codegen-2.spec.ts | 6 +- test/cli/cli.fixtures.ts | 30 ++-- tsconfig.json | 3 +- 19 files changed, 418 insertions(+), 148 deletions(-) create mode 100644 src/web/components/splitView.css create mode 100644 src/web/components/splitView.tsx diff --git a/src/server/supplements/injected/recorder.ts b/src/server/supplements/injected/recorder.ts index a3a478d39e..c4313eeac6 100644 --- a/src/server/supplements/injected/recorder.ts +++ b/src/server/supplements/injected/recorder.ts @@ -27,7 +27,6 @@ declare global { _playwrightRecorderRecordAction: (action: actions.Action) => Promise; _playwrightRecorderCommitAction: () => Promise; _playwrightRecorderState: () => Promise; - _playwrightRecorderPrintSelector: (text: string) => Promise; _playwrightResume: () => Promise; } } @@ -226,10 +225,8 @@ export class Recorder { private _onClick(event: MouseEvent) { if (this._mode === 'inspecting') { - if (this._hoveredModel) { + if (this._hoveredModel) copy(this._hoveredModel.selector); - window._playwrightRecorderPrintSelector(this._hoveredModel.selector); - } } if (this._shouldIgnoreMouseEvent(event)) return; diff --git a/src/server/supplements/inspectorController.ts b/src/server/supplements/inspectorController.ts index a3741098ee..b73948e99b 100644 --- a/src/server/supplements/inspectorController.ts +++ b/src/server/supplements/inspectorController.ts @@ -49,20 +49,24 @@ export class InspectorController implements InstrumentationListener { } async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { - if (!sdkObject.attribution.page) + if (!sdkObject.attribution.context) return; const recorder = await this._recorders.get(sdkObject.attribution.context!); - await recorder?.onAfterCall(sdkObject, metadata); + await recorder?.onAfterCall(metadata); } async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { if (!sdkObject.attribution.page) return; const recorder = await this._recorders.get(sdkObject.attribution.context!); - await recorder?.onBeforeInputAction(sdkObject, metadata); + await recorder?.onBeforeInputAction(metadata); } - onCallLog(logName: string, message: string): void { + async onCallLog(logName: string, message: string, sdkObject: SdkObject, metadata: CallMetadata): Promise { debugLogger.log(logName as any, message); + if (!sdkObject.attribution.page) + return; + const recorder = await this._recorders.get(sdkObject.attribution.context!); + await recorder?.updateCallLog([metadata]); } } diff --git a/src/server/supplements/recorder/recorderApp.ts b/src/server/supplements/recorder/recorderApp.ts index 925f790042..0bb767f7bf 100644 --- a/src/server/supplements/recorder/recorderApp.ts +++ b/src/server/supplements/recorder/recorderApp.ts @@ -23,7 +23,7 @@ import { ProgressController } from '../../progress'; import { createPlaywright } from '../../playwright'; import { EventEmitter } from 'events'; import { internalCallMetadata } from '../../instrumentation'; -import type { EventData, Mode, PauseDetails, Source } from './recorderTypes'; +import type { CallLog, EventData, Mode, Source } from './recorderTypes'; import { BrowserContext } from '../../browserContext'; import { isUnderTest } from '../../../utils/utils'; @@ -32,8 +32,9 @@ const readFileAsync = util.promisify(fs.readFile); declare global { interface Window { playwrightSetMode: (mode: Mode) => void; - playwrightSetPaused: (details: PauseDetails | null) => void; - playwrightSetSource: (source: Source) => void; + playwrightSetPaused: (paused: boolean) => void; + playwrightSetSources: (sources: Source[]) => void; + playwrightUpdateLogs: (callLogs: CallLog[]) => void; dispatch(data: EventData): Promise; } } @@ -117,27 +118,33 @@ export class RecorderApp extends EventEmitter { }).toString(), true, mode, 'main').catch(() => {}); } - async setPaused(details: PauseDetails | null): Promise { - await this._page.mainFrame()._evaluateExpression(((details: PauseDetails | null) => { - window.playwrightSetPaused(details); - }).toString(), true, details, 'main').catch(() => {}); + async setPaused(paused: boolean): Promise { + await this._page.mainFrame()._evaluateExpression(((paused: boolean) => { + window.playwrightSetPaused(paused); + }).toString(), true, paused, 'main').catch(() => {}); } - async setSource(text: string, language: string, highlightedLine?: number): Promise { - await this._page.mainFrame()._evaluateExpression(((source: Source) => { - window.playwrightSetSource(source); - }).toString(), true, { text, language, highlightedLine }, 'main').catch(() => {}); + async setSources(sources: Source[]): Promise { + await this._page.mainFrame()._evaluateExpression(((sources: Source[]) => { + window.playwrightSetSources(sources); + }).toString(), true, sources, 'main').catch(() => {}); // Testing harness for runCLI mode. { if (process.env.PWCLI_EXIT_FOR_TEST) { process.stdout.write('\n-------------8<-------------\n'); - process.stdout.write(text); + process.stdout.write(sources[0].text); process.stdout.write('\n-------------8<-------------\n'); } } } + async updateCallLogs(callLogs: CallLog[]): Promise { + await this._page.mainFrame()._evaluateExpression(((callLogs: CallLog[]) => { + window.playwrightUpdateLogs(callLogs); + }).toString(), true, callLogs, 'main').catch(() => {}); + } + async bringToFront() { await this._page.bringToFront(); } diff --git a/src/server/supplements/recorder/recorderTypes.ts b/src/server/supplements/recorder/recorderTypes.ts index 6c5e8bceef..1a2308df31 100644 --- a/src/server/supplements/recorder/recorderTypes.ts +++ b/src/server/supplements/recorder/recorderTypes.ts @@ -19,18 +19,32 @@ import { Point } from '../../../common/types'; export type Mode = 'inspecting' | 'recording' | 'none'; export type EventData = { - event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode', - params: any + event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode'; + params: any; }; -export type PauseDetails = { - message: string; -}; - -export type Source = { text: string, language: string, highlightedLine?: number }; - export type UIState = { - mode: Mode, - actionPoint?: Point, - actionSelector?: string + mode: Mode; + actionPoint?: Point; + actionSelector?: string; +}; + +export type CallLog = { + id: number; + title: string; + messages: string[]; + status: 'in-progress' | 'done' | 'error' | 'paused'; +}; + +export type SourceHighlight = { + line: number; + type: 'running' | 'paused'; +}; + +export type Source = { + file: string; + text: string; + language: string; + highlight: SourceHighlight[]; + revealLine?: number; }; diff --git a/src/server/supplements/recorderSupplement.ts b/src/server/supplements/recorderSupplement.ts index 14f5eb0e6d..5c3cce3537 100644 --- a/src/server/supplements/recorderSupplement.ts +++ b/src/server/supplements/recorderSupplement.ts @@ -32,7 +32,7 @@ import { BufferedOutput, FileOutput, OutputMultiplexer, RecorderOutput } from '. import { RecorderApp } from './recorder/recorderApp'; import { CallMetadata, internalCallMetadata, SdkObject } from '../instrumentation'; import { Point } from '../../common/types'; -import { EventData, Mode, PauseDetails, UIState } from './recorder/recorderTypes'; +import { CallLog, EventData, Mode, Source, UIState } from './recorder/recorderTypes'; type BindingSource = { frame: Frame, page: Page }; @@ -45,18 +45,17 @@ export class RecorderSupplement { private _lastDialogOrdinal = 0; private _timers = new Set(); private _context: BrowserContext; - private _resumeCallback: (() => void) | null = null; private _mode: Mode; - private _pauseDetails: PauseDetails | null = null; private _output: OutputMultiplexer; private _bufferedOutput: BufferedOutput; private _recorderApp: RecorderApp | null = null; - private _highlighterType: string; private _params: channels.BrowserContextRecorderSupplementEnableParams; - private _callMetadata: CallMetadata | null = null; + private _currentCallsMetadata = new Set(); + private _pausedCallsMetadata = new Map void>(); private _pauseOnNextStatement = true; - private _sourceCache = new Map(); private _sdkObject: SdkObject | null = null; + private _recorderSource: Source; + private _userSources = new Map(); static getOrCreate(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { let recorderPromise = (context as any)[symbol] as Promise; @@ -73,7 +72,7 @@ export class RecorderSupplement { this._params = params; this._mode = params.startRecording ? 'recording' : 'none'; let languageGenerator: LanguageGenerator; - const language = params.language || context._options.sdkLanguage; + let language = params.language || context._options.sdkLanguage; switch (language) { case 'javascript': languageGenerator = new JavaScriptLanguageGenerator(); break; case 'csharp': languageGenerator = new CSharpLanguageGenerator(); break; @@ -81,14 +80,14 @@ export class RecorderSupplement { case 'python-async': languageGenerator = new PythonLanguageGenerator(params.language === 'python-async'); break; default: throw new Error(`Invalid target: '${params.language}'`); } - let highlighterType = language; - if (highlighterType === 'python-async') - highlighterType = 'python'; + if (language === 'python-async') + language = 'python'; - this._highlighterType = highlighterType; + this._recorderSource = { file: '', text: '', language, highlight: [] }; this._bufferedOutput = new BufferedOutput(async text => { - if (this._recorderApp) - this._recorderApp.setSource(text, highlighterType); + this._recorderSource.text = text; + this._recorderSource.revealLine = text.split('\n').length - 1; + this._pushAllSources(); }); const outputs: RecorderOutput[] = [ this._bufferedOutput ]; if (params.outputFile) @@ -136,8 +135,8 @@ export class RecorderSupplement { await Promise.all([ recorderApp.setMode(this._mode), - recorderApp.setPaused(this._pauseDetails), - recorderApp.setSource(this._bufferedOutput.buffer(), this._highlighterType) + recorderApp.setPaused(!!this._pausedCallsMetadata.size), + this._pushAllSources() ]); this._context.on(BrowserContext.Events.Page, page => this._onPage(page)); @@ -168,8 +167,11 @@ export class RecorderSupplement { let actionPoint: Point | undefined = undefined; let actionSelector: string | undefined = undefined; if (source.page === this._sdkObject?.attribution?.page) { - actionPoint = this._callMetadata?.point; - actionSelector = this._callMetadata?.params.selector; + if (this._currentCallsMetadata.size) { + const metadata = this._currentCallsMetadata.values().next().value; + actionPoint = metadata.values().next().value; + actionSelector = metadata.params.selector; + } } const uiState: UIState = { mode: this._mode, actionPoint, actionSelector }; return uiState; @@ -185,19 +187,26 @@ export class RecorderSupplement { (this._context as any).recorderAppForTest = recorderApp; } - async pause() { - this._pauseDetails = { message: 'paused' }; - this._recorderApp!.setPaused(this._pauseDetails); - return new Promise(f => this._resumeCallback = f); + async pause(metadata: CallMetadata) { + const result = new Promise(f => { + this._pausedCallsMetadata.set(metadata, f); + }); + this._recorderApp!.setPaused(true); + this._updateUserSources(); + this.updateCallLog([metadata]); + return result; } private async _resume(step: boolean) { this._pauseOnNextStatement = step; - if (this._resumeCallback) - this._resumeCallback(); - this._resumeCallback = null; - this._pauseDetails = null; - this._recorderApp?.setPaused(null); + + for (const callback of this._pausedCallsMetadata.values()) + callback(); + this._pausedCallsMetadata.clear(); + + this._recorderApp?.setPaused(false); + this._updateUserSources(); + this.updateCallLog([...this._currentCallsMetadata]); } private async _onPage(page: Page) { @@ -318,47 +327,90 @@ export class RecorderSupplement { async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { this._sdkObject = sdkObject; - this._callMetadata = metadata; - const { source, line } = this._source(metadata); - this._recorderApp?.setSource(source, 'javascript', line); + this._currentCallsMetadata.add(metadata); + this._updateUserSources(); + this.updateCallLog([metadata]); if (metadata.method === 'pause' || (this._pauseOnNextStatement && metadata.method === 'goto')) - await this.pause(); + await this.pause(metadata); } - async onAfterCall(sdkObject: SdkObject, metadata: CallMetadata): Promise { + async onAfterCall(metadata: CallMetadata): Promise { this._sdkObject = null; - this._callMetadata = null; + this._currentCallsMetadata.delete(metadata); + this._pausedCallsMetadata.delete(metadata); + this._updateUserSources(); + this.updateCallLog([metadata]); } - async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise { - if (this._pauseOnNextStatement) - await this.pause(); - } + private _updateUserSources() { + // Remove old decorations. + for (const source of this._userSources.values()) { + source.highlight = []; + source.revealLine = undefined; + } - private _source(metadata: CallMetadata): { source: string, line: number | undefined } { - let source = '// No source available'; - let line: number | undefined = undefined; - if (metadata.stack && metadata.stack.length) { - try { - source = this._readAndCacheSource(metadata.stack[0].file); - line = metadata.stack[0].line ? metadata.stack[0].line - 1 : undefined; - } catch (e) { - source = metadata.stack.join('\n'); + // Apply new decorations. + for (const metadata of this._currentCallsMetadata) { + if (!metadata.stack || !metadata.stack[0]) + continue; + const { file, line } = metadata.stack[0]; + let source = this._userSources.get(file); + if (!source) { + source = { file, text: this._readSource(file), highlight: [], language: languageForFile(file) }; + this._userSources.set(file, source); + } + if (line) { + const paused = this._pausedCallsMetadata.has(metadata); + source.highlight.push({ line, type: paused ? 'paused' : 'running' }); + if (paused) + source.revealLine = line; } } - return { source, line }; + this._pushAllSources(); } - private _readAndCacheSource(fileName: string): string { - let source = this._sourceCache.get(fileName); - if (source) - return source; - try { - source = fs.readFileSync(fileName, 'utf-8'); - } catch (e) { - source = '// No source available'; + private _pushAllSources() { + this._recorderApp?.setSources([this._recorderSource, ...this._userSources.values()]); + } + + async onBeforeInputAction(metadata: CallMetadata): Promise { + if (this._pauseOnNextStatement) + await this.pause(metadata); + } + + async updateCallLog(metadatas: CallMetadata[]): Promise { + const logs: CallLog[] = []; + for (const metadata of metadatas) { + if (!metadata.method) + continue; + const title = metadata.stack?.[0]?.function || metadata.method; + let status: 'done' | 'in-progress' | 'paused' | 'error' = 'done'; + if (this._currentCallsMetadata.has(metadata)) + status = 'in-progress'; + if (this._pausedCallsMetadata.has(metadata)) + status = 'paused'; + if (metadata.error) + status = 'error'; + logs.push({ id: metadata.id, messages: metadata.log, title, status }); + } + this._recorderApp?.updateCallLogs(logs); + } + + private _readSource(fileName: string): string { + try { + return fs.readFileSync(fileName, 'utf-8'); + } catch (e) { + return '// No source available'; } - this._sourceCache.set(fileName, source); - return source; } } + +function languageForFile(file: string) { + if (file.endsWith('.py')) + return 'python'; + if (file.endsWith('.java')) + return 'java'; + if (file.endsWith('.cs')) + return 'csharp'; + return 'javascript'; +} \ No newline at end of file diff --git a/src/web/common.css b/src/web/common.css index 94eb3be442..0144f67ac8 100644 --- a/src/web/common.css +++ b/src/web/common.css @@ -15,6 +15,9 @@ */ :root { + --toolbar-bg-color: #fafafa; + --toolbar-color: #777; + --light-background: #f3f2f1; --background: #edebe9; --active-background: #333333; @@ -79,7 +82,7 @@ body { } .codicon { - color: #C5C5C5; + color: var(--toolbar-color); } svg { diff --git a/src/web/components/source.css b/src/web/components/source.css index 6abc9d3505..c85639b4cf 100644 --- a/src/web/components/source.css +++ b/src/web/components/source.css @@ -44,7 +44,7 @@ flex: none; } -.source-line-highlighted { +.source-line-running { background-color: #6fa8dc7f; z-index: 2; } diff --git a/src/web/components/source.tsx b/src/web/components/source.tsx index 53c7b34743..a5984b3da9 100644 --- a/src/web/components/source.tsx +++ b/src/web/components/source.tsx @@ -19,18 +19,24 @@ import * as React from 'react'; import * as highlightjs from '../../third_party/highlightjs/highlightjs'; import '../../third_party/highlightjs/highlightjs/tomorrow.css'; +export type SourceHighlight = { + line: number; + type: 'running' | 'paused'; +}; + export interface SourceProps { - text: string, - language: string, - highlightedLine?: number, - paused?: boolean + text: string; + language: string; + // 1-based + highlight?: SourceHighlight[]; + revealLine?: number; } export const Source: React.FC = ({ text, language, - paused = false, - highlightedLine = -1 + highlight = [], + revealLine }) => { const lines = React.useMemo(() => { const result = []; @@ -43,20 +49,19 @@ export const Source: React.FC = ({ return result; }, [text]); - - const highlightedLineRef = React.createRef(); + const revealedLineRef = React.createRef(); React.useLayoutEffect(() => { - if (highlightedLine && highlightedLineRef.current) - highlightedLineRef.current.scrollIntoView({ block: 'center', inline: 'nearest' }); - }, [highlightedLineRef]); + if (typeof revealLine === 'number' && revealedLineRef.current) + revealedLineRef.current.scrollIntoView({ block: 'center', inline: 'nearest' }); + }, [revealedLineRef]); return
{ lines.map((markup, index) => { - const isHighlighted = index === highlightedLine; - const highlightType = paused && isHighlighted ? 'source-line-paused' : 'source-line-highlighted'; - const className = isHighlighted ? `source-line ${highlightType}` : 'source-line'; - return
-
{index + 1}
+ const lineNumber = index + 1; + const lineHighlight = highlight.find(h => h.line === lineNumber); + const lineClass = lineHighlight ? `source-line source-line-${lineHighlight.type}` : 'source-line'; + return
+
{lineNumber}
; }) diff --git a/src/web/components/splitView.css b/src/web/components/splitView.css new file mode 100644 index 0000000000..d3a8d1af02 --- /dev/null +++ b/src/web/components/splitView.css @@ -0,0 +1,42 @@ +/* + 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. +*/ + +.split-view { + display: flex; + flex: auto; + flex-direction: column; +} + +.split-view-main { + display: flex; + flex: auto; +} + +.split-view-sidebar { + display: flex; + flex: none; + border-top: 1px solid #ddd; +} + +.split-view-resizer { + position: absolute; + left: 0; + right: 0; + height: 12px; + cursor: resize; + cursor: ns-resize; + z-index: 100; +} diff --git a/src/web/components/splitView.tsx b/src/web/components/splitView.tsx new file mode 100644 index 0000000000..69202cb424 --- /dev/null +++ b/src/web/components/splitView.tsx @@ -0,0 +1,45 @@ +/* + 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 './splitView.css'; +import * as React from 'react'; + +export interface SplitViewProps { + sidebarSize: number, +} + +export const SplitView: React.FC = ({ + sidebarSize, + children +}) => { + let [size, setSize] = React.useState(sidebarSize); + const [resizing, setResizing] = React.useState<{ offsetY: number } | null>(null); + if (size < 50) + size = 50; + + const childrenArray = React.Children.toArray(children); + return
+
{childrenArray[0]}
+
{childrenArray[1]}
+
setResizing({ offsetY: event.clientY - (event.target as HTMLElement).getBoundingClientRect().y })} + onMouseUp={() => setResizing(null)} + onMouseMove={event => resizing ? setSize((event.target as HTMLElement).clientHeight - event.clientY + resizing.offsetY) : 0} + >
+
; +}; diff --git a/src/web/components/toolbar.css b/src/web/components/toolbar.css index f0b33f635a..cc1c398dec 100644 --- a/src/web/components/toolbar.css +++ b/src/web/components/toolbar.css @@ -16,12 +16,13 @@ .toolbar { display: flex; - box-shadow: rgba(0, 0, 0, 0.1) 0px -1px 0px 0px inset; - background: rgb(255, 255, 255); + box-shadow: var(--box-shadow); + background-color: var(--toolbar-bg-color); height: 40px; align-items: center; padding-right: 10px; flex: none; + z-index: 2; } .toolbar-linewrap { diff --git a/src/web/components/toolbarButton.css b/src/web/components/toolbarButton.css index 778acf1f42..abe35a7ef1 100644 --- a/src/web/components/toolbarButton.css +++ b/src/web/components/toolbarButton.css @@ -17,7 +17,7 @@ .toolbar-button { border: none; outline: none; - color: #777; + color: var(--toolbar-color); background: transparent; padding: 0; margin-left: 10px; diff --git a/src/web/recorder/index.html b/src/web/recorder/index.html index f49a3eaab2..dc369e7624 100644 --- a/src/web/recorder/index.html +++ b/src/web/recorder/index.html @@ -19,7 +19,7 @@ - Playwright Recorder + Playwright Inspector
diff --git a/src/web/recorder/recorder.css b/src/web/recorder/recorder.css index 83f01c778f..3c6ef63f7a 100644 --- a/src/web/recorder/recorder.css +++ b/src/web/recorder/recorder.css @@ -29,3 +29,52 @@ flex: none; white-space: nowrap; } + +.recorder-log { + display: flex; + flex-direction: column; + flex: auto; + line-height: 20px; + white-space: pre; + background: white; + overflow: auto; +} + +.recorder-log-message { + flex: none; + padding: 3px 12px; + display: flex; + align-items: center; +} + +.recorder-log-message-sub-level { + padding-left: 40px; +} + +.recorder-log-header { + color: var(--toolbar-color); + box-shadow: var(--box-shadow); + background-color: var(--toolbar-bg-color); + height: 32px; + display: flex; + align-items: center; + padding: 0 9px; + z-index: 10; +} + +.recorder-log-call { + color: var(--toolbar-color); + background-color: var(--toolbar-bg-color); + border-top: 1px solid #ddd; + border-bottom: 1px solid #eee; + height: 24px; + display: flex; + align-items: center; + padding: 0 9px; + margin-bottom: 3px; + z-index: 2; +} + +.recorder-log-call .codicon { + padding: 0 4px; +} diff --git a/src/web/recorder/recorder.tsx b/src/web/recorder/recorder.tsx index 200aebe093..be25087454 100644 --- a/src/web/recorder/recorder.tsx +++ b/src/web/recorder/recorder.tsx @@ -19,15 +19,17 @@ import * as React from 'react'; import { Toolbar } from '../components/toolbar'; import { ToolbarButton } from '../components/toolbarButton'; import { Source as SourceView } from '../components/source'; -import type { Mode, PauseDetails, Source } from '../../server/supplements/recorder/recorderTypes'; +import type { CallLog, Mode, Source } from '../../server/supplements/recorder/recorderTypes'; +import { SplitView } from '../components/splitView'; declare global { interface Window { playwrightSetMode: (mode: Mode) => void; - playwrightSetPaused: (details: PauseDetails | null) => void; - playwrightSetSource: (source: Source) => void; + playwrightSetPaused: (paused: boolean) => void; + playwrightSetSources: (sources: Source[]) => void; + playwrightUpdateLogs: (callLogs: CallLog[]) => void; dispatch(data: any): Promise; - playwrightSourceEchoForTest?: (text: string) => Promise; + playwrightSourceEchoForTest: string; } } @@ -36,42 +38,81 @@ export interface RecorderProps { export const Recorder: React.FC = ({ }) => { - const [source, setSource] = React.useState({ language: 'javascript', text: '' }); - const [paused, setPaused] = React.useState(null); + const [source, setSource] = React.useState({ file: '', language: 'javascript', text: '', highlight: [] }); + const [paused, setPaused] = React.useState(false); + const [log, setLog] = React.useState(new Map()); const [mode, setMode] = React.useState('none'); window.playwrightSetMode = setMode; - window.playwrightSetSource = setSource; + window.playwrightSetSources = sources => { + let s = sources.find(s => s.revealLine); + if (!s) + s = sources.find(s => s.file === source.file); + if (!s) + s = sources[0]; + setSource(s); + }; window.playwrightSetPaused = setPaused; - if (window.playwrightSourceEchoForTest) - window.playwrightSourceEchoForTest(source.text).catch(e => {}); + window.playwrightUpdateLogs = callLogs => { + const newLog = new Map(log); + for (const callLog of callLogs) + newLog.set(callLog.id, callLog); + setLog(newLog); + }; - return
+ window.playwrightSourceEchoForTest = source.text; + + const messagesEndRef = React.createRef(); + React.useLayoutEffect(() => { + messagesEndRef.current?.scrollIntoView({ block: 'center', inline: 'nearest' }); + }, [messagesEndRef]); + + return
- { + { window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' }}).catch(() => { }); }}> - { + { window.dispatch({ event: 'setMode', params: { mode: mode === 'inspecting' ? 'none' : 'inspecting' }}).catch(() => { }); }}> - { + { copy(source.text); }}> - { + { window.dispatch({ event: 'resume' }).catch(() => {}); }}> - { + { window.dispatch({ event: 'pause' }).catch(() => {}); }}> - { + { window.dispatch({ event: 'step' }).catch(() => {}); }}> -
- { +
+ { window.dispatch({ event: 'clear' }).catch(() => {}); }}>
- + + +
+
Log
+
+ {[...log.values()].map(callLog => { + return
+
+ { callLog.title } +
+ { callLog.messages.map((message, i) => { + return
+ { message } +
; + })} +
+ })} +
+
+
+
; }; @@ -85,3 +126,12 @@ function copy(text: string) { document.execCommand('copy'); textArea.remove(); } + +function iconClass(callLog: CallLog): string { + switch (callLog.status) { + case 'done': return 'codicon-check'; + case 'in-progress': return 'codicon-clock'; + case 'paused': return 'codicon-debug-pause'; + case 'error': return 'codicon-error'; + } +} \ No newline at end of file diff --git a/src/web/recorder/webpack.config.js b/src/web/recorder/webpack.config.js index 89a35bc76a..4c65a5724c 100644 --- a/src/web/recorder/webpack.config.js +++ b/src/web/recorder/webpack.config.js @@ -34,7 +34,7 @@ module.exports = { }, plugins: [ new HtmlWebPackPlugin({ - title: 'Playwright Recorder', + title: 'Playwright Inspector', template: path.join(__dirname, 'index.html'), }) ] diff --git a/test/cli/cli-codegen-2.spec.ts b/test/cli/cli-codegen-2.spec.ts index 11371c6693..25398262b7 100644 --- a/test/cli/cli-codegen-2.spec.ts +++ b/test/cli/cli-codegen-2.spec.ts @@ -21,12 +21,12 @@ import * as url from 'url'; const { it, describe, expect } = folio; describe('cli codegen', (suite, { mode, browserName, headful }) => { - suite.fixme(browserName === 'firefox' && headful, 'Focus is off'); + // suite.fixme(browserName === 'firefox' && headful, 'Focus is off'); suite.skip(mode !== 'default'); }, () => { it('should contain open page', async ({ recorder }) => { await recorder.setContentAndWait(``); - expect(recorder.output()).toContain(`const page = await context.newPage();`); + await recorder.waitForOutput(`const page = await context.newPage();`); }); it('should contain second page', async ({ context, recorder }) => { @@ -111,7 +111,7 @@ describe('cli codegen', (suite, { mode, browserName, headful }) => { }); it('should download files', (test, {browserName, headful}) => { - test.fixme(browserName === 'webkit', 'Generated page.waitForNavigation next to page.waitForEvent(download)'); + test.fixme(browserName === 'webkit' || browserName === 'firefox', 'Generated page.waitForNavigation next to page.waitForEvent(download)'); }, async ({ page, recorder, httpServer }) => { httpServer.setHandler((req: http.IncomingMessage, res: http.ServerResponse) => { const pathName = url.parse(req.url!).path; diff --git a/test/cli/cli.fixtures.ts b/test/cli/cli.fixtures.ts index 6524c5a8eb..3e61f10479 100644 --- a/test/cli/cli.fixtures.ts +++ b/test/cli/cli.fixtures.ts @@ -37,10 +37,7 @@ export const fixtures = baseFolio.extend(); fixtures.recorder.init(async ({ page, recorderFrame }, runTest) => { await (page.context() as any)._enableRecorder({ language: 'javascript', startRecording: true }); const recorderFrameInstance = await recorderFrame(); - const recorder = new Recorder(page, recorderFrameInstance); - await recorderFrameInstance._page.context().exposeBinding('playwrightSourceEchoForTest', false, - (_: any, text: string) => recorder.setText(text)); - await runTest(recorder); + await runTest(new Recorder(page, recorderFrameInstance)); }); fixtures.httpServer.init(async ({testWorkerIndex}, runTest) => { @@ -69,8 +66,7 @@ class Recorder { _actionReporterInstalled: boolean _actionPerformedCallback: Function recorderFrame: any; - private _text: string; - private _waiters = []; + private _text: string = ''; constructor(page: Page, recorderFrame: any) { this.page = page; @@ -101,16 +97,20 @@ class Recorder { ]); } - setText(text: string) { - this._text = text; - for (const waiter of this._waiters) { - if (text.includes(waiter.text)) - waiter.fulfill(); - } - } - async waitForOutput(text: string): Promise { - return new Promise(fulfill => this._waiters.push({ text, fulfill })); + this._text = await this.recorderFrame._evaluateExpression(((text: string) => { + const w = window as any; + return new Promise(f => { + const poll = () => { + if (w.playwrightSourceEchoForTest && w.playwrightSourceEchoForTest.includes(text)) { + f(w.playwrightSourceEchoForTest); + return; + } + setTimeout(poll, 300); + }; + setTimeout(poll); + }); + }).toString(), true, text, 'main'); } output(): string { diff --git a/tsconfig.json b/tsconfig.json index d68748a701..7bc45b7d90 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "strict": true, "allowJs": true, "declaration": false, - "jsx": "react" + "jsx": "react", + "downlevelIteration": true, }, "compileOnSave": true, "include": ["src/**/*.ts", "src/**/*.js"],