diff --git a/packages/playwright-core/src/server/recorder/recorderApp.ts b/packages/playwright-core/src/server/recorder/recorderApp.ts index 8b55289730b15..a9184bfcc6183 100644 --- a/packages/playwright-core/src/server/recorder/recorderApp.ts +++ b/packages/playwright-core/src/server/recorder/recorderApp.ts @@ -31,9 +31,11 @@ import { BrowserContext } from '../browserContext'; import type { Page } from '../page'; import type * as actions from '@recorder/actions'; -import type { CallLog, ElementInfo, Mode, Source } from '@recorder/recorderTypes'; +import type { CallLog, ElementInfo, Mode, RecorderBackend, RecorderFrontend, Source } from '@recorder/recorderTypes'; import type { Language, LanguageGeneratorOptions } from '../codegen/types'; import type * as channels from '@protocol/channels'; +import type { Progress } from '../progress'; +import type { AriaTemplateNode } from '@isomorphic/ariaSnapshot'; export type RecorderAppParams = channels.BrowserContextEnableRecorderParams & { browserName: string; @@ -54,10 +56,12 @@ export class RecorderApp { private _recorderSources: Source[] = []; private _primaryGeneratorId: string; private _selectedGeneratorId: string; + private _frontend: RecorderFrontend; private constructor(recorder: Recorder, params: RecorderAppParams, page: Page, wsEndpointForTest: string | undefined) { this._page = page; this._recorder = recorder; + this._frontend = createRecorderFrontend(page); this.wsEndpointForTest = wsEndpointForTest; // Make a copy of options to modify them later. @@ -103,7 +107,7 @@ export class RecorderApp { }); }); - await this._page.exposeBinding(progress, 'dispatch', false, (_, data: any) => this._handleUIEvent(data)); + await this._createDispatcher(progress); this._page.once('close', () => { this._recorder.close(); @@ -116,61 +120,59 @@ export class RecorderApp { const url = this._recorder.url(); if (url) - this._onPageNavigated(url); - this._onModeChanged(this._recorder.mode()); - this._onPausedStateChanged(this._recorder.paused()); + this._frontend.pageNavigated({ url }); + this._frontend.modeChanged({ mode: this._recorder.mode() }); + this._frontend.pauseStateChanged({ paused: this._recorder.paused() }); this._updateActions('reveal'); // Update paused sources *after* generated ones, to reveal the currently paused source if any. this._onUserSourcesChanged(this._recorder.userSources(), this._recorder.pausedSourceId()); - this._onCallLogsUpdated(this._recorder.callLog()); + this._frontend.callLogsUpdated({ callLogs: this._recorder.callLog() }); this._wireListeners(this._recorder); } - private _handleUIEvent(data: any) { - if (data.event === 'clear') { - this._actions = []; - this._updateActions('reveal'); - this._recorder.clear(); - return; - } - if (data.event === 'fileChanged') { - const source = [...this._recorderSources, ...this._userSources].find(s => s.id === data.params.fileId); - if (source) { - if (source.isRecorded) - this._selectedGeneratorId = source.id; - this._recorder.setLanguage(source.language); - } - return; - } - if (data.event === 'setAutoExpect') { - this._languageGeneratorOptions.generateAutoExpect = data.params.autoExpect; - this._updateActions(); - return; - } - if (data.event === 'setMode') { - this._recorder.setMode(data.params.mode); - return; - } - if (data.event === 'resume') { - this._recorder.resume(); - return; - } - if (data.event === 'pause') { - this._recorder.pause(); - return; - } - if (data.event === 'step') { - this._recorder.step(); - return; - } - if (data.event === 'highlightRequested') { - if (data.params.selector) - this._recorder.setHighlightedSelector(data.params.selector); - if (data.params.ariaTemplate) - this._recorder.setHighlightedAriaTemplate(data.params.ariaTemplate); - return; - } - throw new Error(`Unknown event: ${data.event}`); + private async _createDispatcher(progress: Progress) { + const dispatcher: RecorderBackend = { + clear: async () => { + this._actions = []; + this._updateActions('reveal'); + this._recorder.clear(); + }, + fileChanged: async (params: { fileId: string }) => { + const source = [...this._recorderSources, ...this._userSources].find(s => s.id === params.fileId); + if (source) { + if (source.isRecorded) + this._selectedGeneratorId = source.id; + this._recorder.setLanguage(source.language); + } + }, + setAutoExpect: async (params: { autoExpect: boolean }) => { + this._languageGeneratorOptions.generateAutoExpect = params.autoExpect; + this._updateActions(); + }, + setMode: async (params: { mode: Mode }) => { + this._recorder.setMode(params.mode); + }, + resume: async () => { + this._recorder.resume(); + }, + pause: async () => { + this._recorder.pause(); + }, + step: async () => { + this._recorder.step(); + }, + highlightRequested: async (params: { selector?: string; ariaTemplate?: AriaTemplateNode }) => { + if (params.selector) + this._recorder.setHighlightedSelector(params.selector); + if (params.ariaTemplate) + this._recorder.setHighlightedAriaTemplate(params.ariaTemplate); + }, + }; + + await this._page.exposeBinding(progress, 'sendCommand', false, async (_, data: any) => { + const { method, params } = data as { method: string; params: any }; + return await (dispatcher as any)[method].call(dispatcher, params); + }); } static async show(context: BrowserContext, params: channels.BrowserContextEnableRecorderParams) { @@ -246,19 +248,20 @@ export class RecorderApp { }); recorder.on(RecorderEvent.PageNavigated, (url: string) => { - this._onPageNavigated(url); + this._frontend.pageNavigated({ url }); }); recorder.on(RecorderEvent.ContextClosed, () => { - this._onContextClosed(); + this._throttledOutputFile?.flush(); + this._page.browserContext.close({ reason: 'Recorder window closed' }).catch(() => {}); }); recorder.on(RecorderEvent.ModeChanged, (mode: Mode) => { - this._onModeChanged(mode); + this._frontend.modeChanged({ mode }); }); recorder.on(RecorderEvent.PausedStateChanged, (paused: boolean) => { - this._onPausedStateChanged(paused); + this._frontend.pauseStateChanged({ paused }); }); recorder.on(RecorderEvent.UserSourcesChanged, (sources: Source[], pausedSourceId?: string) => { @@ -266,11 +269,13 @@ export class RecorderApp { }); recorder.on(RecorderEvent.ElementPicked, (elementInfo: ElementInfo, userGesture?: boolean) => { - this._onElementPicked(elementInfo, userGesture); + if (userGesture) + this._page.bringToFront(); + this._frontend.elementPicked({ elementInfo, userGesture }); }); recorder.on(RecorderEvent.CallLogsUpdated, (callLogs: CallLog[]) => { - this._onCallLogsUpdated(callLogs); + this._frontend.callLogsUpdated({ callLogs }); }); } @@ -286,29 +291,6 @@ export class RecorderApp { this._updateActions(); } - private _onPageNavigated(url: string) { - this._page.mainFrame().evaluateExpression((({ url }: { url: string }) => { - window.playwrightSetPageURL(url); - }).toString(), { isFunction: true }, { url }).catch(() => {}); - } - - private _onContextClosed() { - this._throttledOutputFile?.flush(); - this._page.browserContext.close({ reason: 'Recorder window closed' }).catch(() => {}); - } - - private _onModeChanged(mode: Mode) { - this._page.mainFrame().evaluateExpression(((mode: Mode) => { - window.playwrightSetMode(mode); - }).toString(), { isFunction: true }, mode).catch(() => {}); - } - - private _onPausedStateChanged(paused: boolean) { - this._page.mainFrame().evaluateExpression(((paused: boolean) => { - window.playwrightSetPaused(paused); - }).toString(), { isFunction: true }, paused).catch(() => {}); - } - private _onUserSourcesChanged(sources: Source[], pausedSourceId: string | undefined) { if (!sources.length && !this._userSources.length) return; @@ -317,33 +299,15 @@ export class RecorderApp { this._revealSource(pausedSourceId); } - private _onElementPicked(elementInfo: ElementInfo, userGesture?: boolean) { - if (userGesture) - this._page.bringToFront(); - this._page.mainFrame().evaluateExpression(((param: { elementInfo: ElementInfo, userGesture?: boolean }) => { - window.playwrightElementPicked(param.elementInfo, param.userGesture); - }).toString(), { isFunction: true }, { elementInfo, userGesture }).catch(() => {}); - } - - private _onCallLogsUpdated(callLogs: CallLog[]) { - this._page.mainFrame().evaluateExpression(((callLogs: CallLog[]) => { - window.playwrightUpdateLogs(callLogs); - }).toString(), { isFunction: true }, callLogs).catch(() => {}); - } - private _pushAllSources() { const sources = [...this._userSources, ...this._recorderSources]; - this._page.mainFrame().evaluateExpression((({ sources }: { sources: Source[] }) => { - window.playwrightSetSources(sources); - }).toString(), { isFunction: true }, { sources }).catch(() => {}); + this._frontend.sourcesUpdated({ sources }); } private _revealSource(sourceId: string | undefined) { if (!sourceId) return; - this._page.mainFrame().evaluateExpression((({ sourceId }: { sourceId: string }) => { - window.playwrightSelectSource(sourceId); - }).toString(), { isFunction: true }, { sourceId }).catch(() => {}); + this._frontend.sourceRevealRequested({ sourceId }); } private _updateActions(reveal?: 'reveal') { @@ -426,4 +390,18 @@ function findPageByGuid(context: BrowserContext, guid: string) { return context.pages().find(p => p.guid === guid); } +function createRecorderFrontend(page: Page): RecorderFrontend { + return new Proxy({} as RecorderFrontend, { + get: (_target, prop: string | symbol) => { + if (typeof prop !== 'string') + return undefined; + return (params: any) => { + page.mainFrame().evaluateExpression(((event: { method: string, params?: any }) => { + window.dispatch(event); + }).toString(), { isFunction: true }, { method: prop, params }).catch(() => {}); + }; + }, + }); +} + const recorderAppSymbol = Symbol('recorderApp'); diff --git a/packages/recorder/src/index.tsx b/packages/recorder/src/index.tsx index 5956eab72502a..55935ec336d0b 100644 --- a/packages/recorder/src/index.tsx +++ b/packages/recorder/src/index.tsx @@ -18,9 +18,9 @@ import '@web/common.css'; import { applyTheme } from '@web/theme'; import '@web/third_party/vscode/codicon.css'; import * as ReactDOM from 'react-dom/client'; -import { Main } from './main'; +import { Recorder } from './recorder'; (async () => { applyTheme(); - ReactDOM.createRoot(document.querySelector('#root')!).render(
); + ReactDOM.createRoot(document.querySelector('#root')!).render(); })(); diff --git a/packages/recorder/src/main.tsx b/packages/recorder/src/main.tsx deleted file mode 100644 index d705b8e3ace6e..0000000000000 --- a/packages/recorder/src/main.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - 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 type { CallLog, Mode, Source } from './recorderTypes'; -import * as React from 'react'; -import { Recorder } from './recorder'; -import './recorder.css'; - -export const Main: React.FC = ({}) => { - const [sources, setSources] = React.useState([]); - const [paused, setPaused] = React.useState(false); - const [log, setLog] = React.useState(new Map()); - const [mode, setMode] = React.useState('none'); - - React.useLayoutEffect(() => { - window.playwrightSetMode = setMode; - window.playwrightSetSources = sources => { - setSources(sources); - window.playwrightSourcesEchoForTest = sources; - }; - window.playwrightSetPageURL = url => { - document.title = url - ? `Playwright Inspector - ${url}` - : `Playwright Inspector`; - }; - window.playwrightSetPaused = setPaused; - window.playwrightUpdateLogs = callLogs => { - setLog(log => { - const newLog = new Map(log); - for (const callLog of callLogs) { - callLog.reveal = !log.has(callLog.id); - newLog.set(callLog.id, callLog); - } - return newLog; - }); - }; - }, []); - - return ; -}; diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 1638eac194c46..548649d65bb4b 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import type { CallLog, ElementInfo, Mode, Source } from './recorderTypes'; +import type { CallLog, Mode, Source } from './recorderTypes'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import type { SourceHighlight } from '@web/components/codeMirrorWrapper'; import { SplitView } from '@web/components/splitView'; @@ -32,19 +32,13 @@ import yaml from 'yaml'; import { parseAriaSnapshot } from '@isomorphic/ariaSnapshot'; import { Dialog } from '@web/shared/dialog'; -export interface RecorderProps { - sources: Source[], - paused: boolean, - log: Map, - mode: Mode, -} +import type { RecorderBackend, RecorderFrontend } from './recorderTypes'; -export const Recorder: React.FC = ({ - sources, - paused, - log, - mode, -}) => { +export const Recorder: React.FC = ({}) => { + const [sources, setSources] = React.useState([]); + const [paused, setPaused] = React.useState(false); + const [log, setLog] = React.useState(new Map()); + const [mode, setMode] = React.useState('none'); const [selectedFileId, setSelectedFileId] = React.useState(); const [selectedTab, setSelectedTab] = useSetting('recorderPropertiesTab', 'log'); const [ariaSnapshot, setAriaSnapshot] = React.useState(); @@ -53,70 +47,98 @@ export const Recorder: React.FC = ({ const [theme, setTheme] = useThemeSetting(); const [autoExpect, setAutoExpect] = useSetting('autoExpect', false); const settingsButtonRef = React.useRef(null); - window.playwrightSelectSource = selectedSourceId => setSelectedFileId(selectedSourceId); - - React.useEffect(() => { - window.dispatch({ event: 'setAutoExpect', params: { autoExpect } }); - }, [autoExpect]); + const backend = React.useMemo(createRecorderBackend, []); + const [locator, setLocator] = React.useState(''); + const messagesEndRef = React.useRef(null); const source = React.useMemo(() => { const source = sources.find(s => s.id === selectedFileId); return source ?? emptySource(); }, [sources, selectedFileId]); - const [locator, setLocator] = React.useState(''); - window.playwrightElementPicked = (elementInfo: ElementInfo, userGesture?: boolean) => { - const language = source.language; - setLocator(asLocator(language, elementInfo.selector)); - setAriaSnapshot(elementInfo.ariaSnapshot); - setAriaSnapshotErrors([]); - if (userGesture && selectedTab !== 'locator' && selectedTab !== 'aria') - setSelectedTab('locator'); - - if (mode === 'inspecting' && selectedTab === 'aria') { - // Keep exploring aria. - } else { - window.dispatch({ event: 'setMode', params: { mode: mode === 'inspecting' ? 'standby' : 'recording' } }).catch(() => { }); - } - }; + React.useLayoutEffect(() => { + const dispatcher: RecorderFrontend = { + modeChanged: ({ mode }) => setMode(mode), + sourcesUpdated: ({ sources }) => { + setSources(sources); + window.playwrightSourcesEchoForTest = sources; + }, + pageNavigated: ({ url }) => { + document.title = url + ? `Playwright Inspector - ${url}` + : `Playwright Inspector`; + }, + pauseStateChanged: ({ paused }) => setPaused(paused), + callLogsUpdated: ({ callLogs }) => { + setLog(log => { + const newLog = new Map(log); + for (const callLog of callLogs) { + callLog.reveal = !log.has(callLog.id); + newLog.set(callLog.id, callLog); + } + return newLog; + }); + }, + sourceRevealRequested: ({ sourceId }) => setSelectedFileId(sourceId), + elementPicked: ({ elementInfo, userGesture }) => { + const language = source.language; + setLocator(asLocator(language, elementInfo.selector)); + setAriaSnapshot(elementInfo.ariaSnapshot); + setAriaSnapshotErrors([]); + if (userGesture && selectedTab !== 'locator' && selectedTab !== 'aria') + setSelectedTab('locator'); + + if (mode === 'inspecting' && selectedTab === 'aria') { + // Keep exploring aria. + } else { + backend.setMode({ mode: mode === 'inspecting' ? 'standby' : 'recording' }).catch(() => { }); + } + }, + }; + window.dispatch = (data: { method: string; params?: any }) => { + (dispatcher as any)[data.method].call(dispatcher, data.params); + }; + }, [backend, mode, selectedTab, setSelectedTab, source]); + + React.useEffect(() => { + backend.setAutoExpect({ autoExpect }); + }, [autoExpect, backend]); - const messagesEndRef = React.useRef(null); React.useLayoutEffect(() => { messagesEndRef.current?.scrollIntoView({ block: 'center', inline: 'nearest' }); }, [messagesEndRef]); - React.useLayoutEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { switch (event.key) { case 'F8': event.preventDefault(); if (paused) - window.dispatch({ event: 'resume' }); + backend.resume(); else - window.dispatch({ event: 'pause' }); + backend.pause(); break; case 'F10': event.preventDefault(); if (paused) - window.dispatch({ event: 'step' }); + backend.step(); break; } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [paused]); + }, [paused, backend]); const onEditorChange = React.useCallback((selector: string) => { if (mode === 'none' || mode === 'inspecting') - window.dispatch({ event: 'setMode', params: { mode: 'standby' } }); + backend.setMode({ mode: 'standby' }); setLocator(selector); - window.dispatch({ event: 'highlightRequested', params: { selector } }); - }, [mode]); + backend.highlightRequested({ selector }); + }, [mode, backend]); const onAriaEditorChange = React.useCallback((ariaSnapshot: string) => { if (mode === 'none' || mode === 'inspecting') - window.dispatch({ event: 'setMode', params: { mode: 'standby' } }); + backend.setMode({ mode: 'standby' }); const { fragment, errors } = parseAriaSnapshot(yaml, ariaSnapshot, { prettyErrors: false }); const highlights = errors.map(error => { const highlight: SourceHighlight = { @@ -130,19 +152,19 @@ export const Recorder: React.FC = ({ setAriaSnapshotErrors(highlights); setAriaSnapshot(ariaSnapshot); if (!errors.length) - window.dispatch({ event: 'highlightRequested', params: { ariaTemplate: fragment } }); - }, [mode]); + backend.highlightRequested({ ariaTemplate: fragment }); + }, [mode, backend]); const isRecording = mode === 'recording' || mode === 'recording-inspecting' || mode === 'assertingText' || mode === 'assertingVisibility'; return
{ - window.dispatch({ event: 'setMode', params: { mode: mode === 'none' || mode === 'standby' || mode === 'inspecting' ? 'recording' : 'standby' } }); + backend.setMode({ mode: mode === 'none' || mode === 'standby' || mode === 'inspecting' ? 'recording' : 'standby' }); }}>Record { - const newMode = { + const newMode: Mode = { 'inspecting': 'standby', 'none': 'inspecting', 'standby': 'inspecting', @@ -152,42 +174,42 @@ export const Recorder: React.FC = ({ 'assertingVisibility': 'recording-inspecting', 'assertingValue': 'recording-inspecting', 'assertingSnapshot': 'recording-inspecting', - }[mode]; - window.dispatch({ event: 'setMode', params: { mode: newMode } }).catch(() => { }); + }[mode] as Mode; + backend.setMode({ mode: newMode }).catch(() => { }); }}> { - window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility' } }); + backend.setMode({ mode: mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility' }); }}> { - window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingText' ? 'recording' : 'assertingText' } }); + backend.setMode({ mode: mode === 'assertingText' ? 'recording' : 'assertingText' }); }}> { - window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingValue' ? 'recording' : 'assertingValue' } }); + backend.setMode({ mode: mode === 'assertingValue' ? 'recording' : 'assertingValue' }); }}> { - window.dispatch({ event: 'setMode', params: { mode: mode === 'assertingSnapshot' ? 'recording' : 'assertingSnapshot' } }); + backend.setMode({ mode: mode === 'assertingSnapshot' ? 'recording' : 'assertingSnapshot' }); }}> { copy(source.text); }}> { - window.dispatch({ event: 'resume' }); + backend.resume(); }}> { - window.dispatch({ event: 'pause' }); + backend.pause(); }}> { - window.dispatch({ event: 'step' }); + backend.step(); }}>
Target:
{ setSelectedFileId(fileId); - window.dispatch({ event: 'fileChanged', params: { fileId } }); + backend.fileChanged({ fileId }); }} /> { - window.dispatch({ event: 'clear' }); + backend.clear(); }}> = ({
{ - window.dispatch({ event: 'setAutoExpect', params: { autoExpect: !autoExpect } }); + backend.setAutoExpect({ autoExpect: !autoExpect }); setAutoExpect(!autoExpect); }} /> @@ -248,3 +270,15 @@ export const Recorder: React.FC = ({ />
; }; + +function createRecorderBackend(): RecorderBackend { + return new Proxy({} as RecorderBackend, { + get: (_target, prop: string | symbol) => { + if (typeof prop !== 'string') + return undefined; + return (params?: any) => { + return window.sendCommand({ method: prop, params }); + }; + }, + }); +} diff --git a/packages/recorder/src/recorderTypes.d.ts b/packages/recorder/src/recorderTypes.d.ts index 2f5b0ffe4e817..0b65768d1b9c8 100644 --- a/packages/recorder/src/recorderTypes.d.ts +++ b/packages/recorder/src/recorderTypes.d.ts @@ -99,15 +99,29 @@ export type Source = { declare global { interface Window { - playwrightSetMode: (mode: Mode) => void; - playwrightSetPaused: (paused: boolean) => void; - playwrightSetSources: (sources: Source[]) => void; - playwrightSelectSource: (sourceId: string) => void; - playwrightSetPageURL: (url: string | undefined) => void; - playwrightSetOverlayVisible: (visible: boolean) => void; - playwrightUpdateLogs: (callLogs: CallLog[]) => void; - playwrightElementPicked: (elementInfo: ElementInfo, userGesture?: boolean) => void; playwrightSourcesEchoForTest: Source[]; - dispatch(data: any): Promise; + sendCommand(data: { method: string; params?: any }): Promise; + dispatch(data: { method: string; params?: any }): void; } } + +export interface RecorderBackend { + setMode(params: { mode: Mode }): Promise; + setAutoExpect(params: { autoExpect: boolean }): Promise; + resume(): Promise; + pause(): Promise; + step(): Promise; + highlightRequested(params: { selector?: string; ariaTemplate?: AriaTemplateNode }): Promise; + fileChanged(params: { fileId: string }): Promise; + clear(): Promise; +} + +export interface RecorderFrontend { + modeChanged: (params: { mode: Mode }) => void; + pauseStateChanged: (params: { paused: boolean }) => void; + sourcesUpdated: (params: { sources: Source[] }) => void; + sourceRevealRequested: (params: { sourceId: string }) => void; + pageNavigated: (params: { url: string | undefined }) => void; + callLogsUpdated: (params: { callLogs: CallLog[] }) => void; + elementPicked: (params: { elementInfo: ElementInfo, userGesture?: boolean }) => void; +}