diff --git a/.storybook/preview.js b/.storybook/preview.js index cf7ffaf154..2b0a6c869b 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -27,7 +27,7 @@ export const parameters = { addDecorator(storyFn => { applyTheme(); - return
+ return
{storyFn()}
}); diff --git a/src/server/browser.ts b/src/server/browser.ts index 285634fd32..5b61654f71 100644 --- a/src/server/browser.ts +++ b/src/server/browser.ts @@ -31,7 +31,8 @@ export interface BrowserProcess { } export type PlaywrightOptions = { - contextListeners: ContextListener[] + contextListeners: ContextListener[], + isInternal: boolean }; export type BrowserOptions = PlaywrightOptions & { diff --git a/src/server/browserContext.ts b/src/server/browserContext.ts index 179d0580ab..53dd515a06 100644 --- a/src/server/browserContext.ts +++ b/src/server/browserContext.ts @@ -244,8 +244,6 @@ export abstract class BrowserContext extends EventEmitter { async _loadDefaultContext(progress: Progress) { const pages = await this._loadDefaultContextAsIs(progress); - if (pages.length !== 1 || pages[0].mainFrame().url() !== 'about:blank') - throw new Error(`Arguments can not specify page to be opened (first url is ${pages[0].mainFrame().url()})`); if (this._options.isMobile || this._options.locale) { // Workaround for: // - chromium fails to change isMobile for existing page; diff --git a/src/server/chromium/chromium.ts b/src/server/chromium/chromium.ts index 490137af3b..d4c79ef7ac 100644 --- a/src/server/chromium/chromium.ts +++ b/src/server/chromium/chromium.ts @@ -122,7 +122,7 @@ export class Chromium extends BrowserType { } } -const DEFAULT_ARGS = [ +export const DEFAULT_ARGS = [ '--disable-background-networking', '--enable-features=NetworkService,NetworkServiceInProcess', '--disable-background-timer-throttling', diff --git a/src/server/chromium/crPage.ts b/src/server/chromium/crPage.ts index cb575c87ba..5aa3892b01 100644 --- a/src/server/chromium/crPage.ts +++ b/src/server/chromium/crPage.ts @@ -41,6 +41,7 @@ import { VideoRecorder } from './videoRecorder'; const UTILITY_WORLD_NAME = '__playwright_utility_world__'; +export type WindowBounds = { top?: number, left?: number, width?: number, height?: number }; export class CRPage implements PageDelegate { readonly _mainFrameSession: FrameSession; @@ -64,6 +65,11 @@ export class CRPage implements PageDelegate { // of new popup targets. readonly _nextWindowOpenPopupFeatures: string[][] = []; + static mainFrameSession(page: Page): FrameSession { + const crPage = page._delegate as CRPage; + return crPage._mainFrameSession; + } + constructor(client: CRSession, targetId: string, browserContext: CRBrowserContext, opener: CRPage | null, hasUIWindow: boolean) { this._targetId = targetId; this._opener = opener; @@ -380,8 +386,8 @@ class FrameSession { async _initialize(hasUIWindow: boolean) { if (hasUIWindow && - !this._crPage._browserContext._browser.isClank() && - !this._crPage._browserContext._options.noDefaultViewport) { + !this._crPage._browserContext._browser.isClank() && + !this._crPage._browserContext._options.noDefaultViewport) { const { windowId } = await this._client.send('Browser.getWindowForTarget'); this._windowId = windowId; } @@ -855,14 +861,28 @@ class FrameSession { else if (process.platform === 'darwin') insets = { width: 2, height: 80 }; } - promises.push(this._client.send('Browser.setWindowBounds', { - windowId: this._windowId, - bounds: { width: viewportSize.width + insets.width, height: viewportSize.height + insets.height } + promises.push(this.setWindowBounds({ + width: viewportSize.width + insets.width, + height: viewportSize.height + insets.height })); } await Promise.all(promises); } + async windowBounds(): Promise { + const { bounds } = await this._client.send('Browser.getWindowBounds', { + windowId: this._windowId! + }); + return bounds; + } + + async setWindowBounds(bounds: WindowBounds) { + return await this._client.send('Browser.setWindowBounds', { + windowId: this._windowId!, + bounds + }); + } + async _updateEmulateMedia(initial: boolean): Promise { if (this._crPage._browserContext._browser.isClank()) return; diff --git a/src/server/playwright.ts b/src/server/playwright.ts index aab14b89fd..9b1366684d 100644 --- a/src/server/playwright.ts +++ b/src/server/playwright.ts @@ -19,6 +19,7 @@ import { Tracer } from '../trace/tracer'; import * as browserPaths from '../utils/browserPaths'; import { Android } from './android/android'; import { AdbBackend } from './android/backendAdb'; +import { PlaywrightOptions } from './browser'; import { Chromium } from './chromium/chromium'; import { Electron } from './electron/electron'; import { Firefox } from './firefox/firefox'; @@ -34,15 +35,17 @@ export class Playwright { readonly electron: Electron; readonly firefox: Firefox; readonly webkit: WebKit; - readonly options = { - contextListeners: [ - new InspectorController(), - new Tracer(), - new HarTracer() - ] - }; + readonly options: PlaywrightOptions; - constructor(packagePath: string, browsers: browserPaths.BrowserDescriptor[]) { + constructor(isInternal: boolean, packagePath: string, browsers: browserPaths.BrowserDescriptor[]) { + this.options = { + isInternal, + contextListeners: isInternal ? [] : [ + new InspectorController(), + new Tracer(), + new HarTracer() + ] + }; const chromium = browsers.find(browser => browser.name === 'chromium'); this.chromium = new Chromium(packagePath, chromium!, this.options); @@ -57,6 +60,6 @@ export class Playwright { } } -export function createPlaywright() { - return new Playwright(path.join(__dirname, '..', '..'), require('../../browsers.json')['browsers']); +export function createPlaywright(isInternal = false) { + return new Playwright(isInternal, path.join(__dirname, '..', '..'), require('../../browsers.json')['browsers']); } diff --git a/src/server/supplements/injected/recorder.ts b/src/server/supplements/injected/recorder.ts index 60c1724a2f..f9fc47c91a 100644 --- a/src/server/supplements/injected/recorder.ts +++ b/src/server/supplements/injected/recorder.ts @@ -28,13 +28,11 @@ declare global { playwrightRecorderState: () => Promise; playwrightRecorderSetUIState: (state: SetUIState) => Promise; playwrightRecorderResume: () => Promise; - playwrightRecorderClearScript: () => Promise; + playwrightRecorderShowRecorderPage: () => Promise; } } const scriptSymbol = Symbol('scriptSymbol'); -const pressRecordMessageElement = html`Press to start recording`; -const performActionsMessageElement = html`Perform actions to record`; export class Recorder { private _injectedScript: InjectedScript; @@ -51,18 +49,12 @@ export class Recorder { private _expectProgrammaticKeyUp = false; private _pollRecorderModeTimer: NodeJS.Timeout | undefined; private _outerToolbarElement: HTMLElement; - private _outerDrawerElement: HTMLElement; private _toolbar: Element$; - private _drawer: Element$; - private _drawerTimeout: NodeJS.Timeout | undefined; private _state: State = { - codegenScript: '', canResume: false, uiState: { mode: 'none', - drawerVisible: false }, - isController: true, isPaused: false }; @@ -122,10 +114,14 @@ export class Recorder { this._toolbar = html` ${commonStyles()} - - - - + + + + + + + + @@ -140,77 +136,12 @@ export class Recorder { - - - - - `; this._outerToolbarElement = html``; const toolbarShadow = this._outerToolbarElement.attachShadow({ mode: 'open' }); toolbarShadow.appendChild(this._toolbar); - this._drawer = html` - - ${commonStyles()} - ${highlighterStyles()} - - ${pressRecordMessageElement} - - - - - - - - - - - - `; - this._outerDrawerElement = html``; - const drawerShadow = this._outerDrawerElement.attachShadow({ mode: 'open' }); - drawerShadow.appendChild(this._drawer); - this._hydrate(); this._refreshListenersIfNeeded(); setInterval(() => { @@ -218,7 +149,7 @@ export class Recorder { if ((window as any)._recorderScriptReadyForTest) (window as any)._recorderScriptReadyForTest(); }, 500); - this._pollRecorderMode(true).catch(e => {}); + this._pollRecorderMode(true).catch(e => console.log(e)); // eslint-disable-line no-console } private _hydrate() { @@ -237,25 +168,12 @@ export class Recorder { this._updateUIState({ mode: 'none' }); window.playwrightRecorderResume().catch(() => {}); }); - this._toolbar.$('#pw-button-drawer').addEventListener('click', () => { - if (this._toolbar.$('#pw-button-drawer').classList.contains('disabled')) + this._toolbar.$('#pw-button-playwright').addEventListener('click', () => { + if (this._toolbar.$('#pw-button-playwright').classList.contains('disabled')) return; - this._toolbar.$('#pw-button-drawer').classList.toggle('toggled'); - this._updateUIState({ drawerVisible: this._toolbar.$('#pw-button-drawer').classList.contains('toggled') }); + this._toolbar.$('#pw-button-playwright').classList.toggle('toggled'); + window.playwrightRecorderShowRecorderPage().catch(() => {}); }); - this._drawer.$('#pw-button-copy').addEventListener('click', () => { - if (this._drawer.$('#pw-button-copy').classList.contains('disabled')) - return; - copy(this._drawer.$('x-pw-code').textContent || ''); - }); - this._drawer.$('#pw-button-clear').addEventListener('click', () => { - window.playwrightRecorderClearScript().catch(() => {}); - }); - this._drawer.$('#pw-button-close').addEventListener('click', () => { - this._toolbar.$('#pw-button-drawer').classList.toggle('toggled', false); - this._updateUIState({ drawerVisible: false }); - }); - this._drawer.$('x-pw-code span').addEventListener('click', () => this._toggleRecording()); } private _refreshListenersIfNeeded() { @@ -280,7 +198,6 @@ export class Recorder { ]; document.documentElement.appendChild(this._outerGlassPaneElement); document.documentElement.appendChild(this._outerToolbarElement); - document.documentElement.appendChild(this._outerDrawerElement); } private _toggleRecording() { @@ -304,40 +221,15 @@ export class Recorder { return; } - const { canResume, isController, isPaused, uiState, codegenScript } = state; + const { canResume, isPaused, uiState } = state; if (uiState.mode !== this._state.uiState.mode) { this._state.uiState.mode = uiState.mode; this._toolbar.$('#pw-button-inspect').classList.toggle('toggled', uiState.mode === 'inspecting'); this._toolbar.$('#pw-button-record').classList.toggle('toggled', uiState.mode === 'recording'); this._toolbar.$('#pw-button-resume').classList.toggle('disabled', uiState.mode === 'recording'); - this._updateDrawerMessage(); this._clearHighlight(); } - if (isController !== this._state.isController) - this._toolbar.$('#pw-button-drawer-group').classList.toggle('hidden', !isController); - - if (isController && uiState.drawerVisible !== this._state.uiState.drawerVisible) { - this._state.uiState.drawerVisible = uiState.drawerVisible; - this._toolbar.$('#pw-button-drawer').classList.toggle('toggled', uiState.drawerVisible); - if (this._drawerTimeout) - clearTimeout(this._drawerTimeout); - if (uiState.drawerVisible) { - this._outerDrawerElement.style.display = 'flex'; - const show = () => this._outerDrawerElement.style.transform = 'translateX(0)'; - if (skipAnimations) - show(); - else - window.requestAnimationFrame(show); - } else { - this._outerDrawerElement.style.transform = 'translateX(400px)'; - if (!skipAnimations) { - this._drawerTimeout = setTimeout(() => { - this._outerDrawerElement.style.display = 'none'; - }, 300); - } - } - } if (isPaused !== this._state.isPaused) { this._state.isPaused = isPaused; this._toolbar.$('#pw-button-resume-group').classList.toggle('hidden', false); @@ -349,26 +241,10 @@ export class Recorder { this._toolbar.$('#pw-button-resume-group').classList.toggle('hidden', !canResume); } - if (codegenScript !== this._state.codegenScript) { - this._state.codegenScript = codegenScript; - this._updateDrawerMessage(); - } this._state = state; this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), 250); } - private _updateDrawerMessage() { - if (!this._state.codegenScript) { - this._drawer.$('x-pw-code').textContent = ''; - if (this._state.uiState.mode === 'recording') - this._drawer.$('x-pw-code').appendChild(performActionsMessageElement); - else - this._drawer.$('x-pw-code').appendChild(pressRecordMessageElement); - } else { - this._drawer.$('x-pw-code').innerHTML = this._state.codegenScript; - } - } - private _clearHighlight() { this._hoveredModel = null; this._activeModel = null; @@ -712,7 +588,7 @@ export class Recorder { private async _performAction(action: actions.Action) { this._performingAction = true; - await window.playwrightRecorderPerformAction(action).catch(e => {}); + await window.playwrightRecorderPerformAction(action).catch(() => {}); this._performingAction = false; // Action could have changed DOM, update hovered model selectors. @@ -820,7 +696,7 @@ x-pw-button-group { box-shadow: rgba(0, 0, 0, 0.1) 0px 0.25em 0.5em; margin: 4px 0px; } -x-pw-button-group.vertical { +x-pw-toolbar.vertical x-pw-button-group { flex-direction: column; } x-pw-button { @@ -899,51 +775,4 @@ x-pw-icon svg { `; } -function highlighterStyles() { - return html` -`; -} - export default Recorder; diff --git a/src/server/supplements/inspectorController.ts b/src/server/supplements/inspectorController.ts index bc17c40246..25a5779263 100644 --- a/src/server/supplements/inspectorController.ts +++ b/src/server/supplements/inspectorController.ts @@ -18,8 +18,6 @@ import { BrowserContext, ContextListener } from '../browserContext'; import { isDebugMode } from '../../utils/utils'; import { ConsoleApiSupplement } from './consoleApiSupplement'; import { RecorderSupplement } from './recorderSupplement'; -import { Page } from '../page'; -import { ConsoleMessage } from '../console'; export class InspectorController implements ContextListener { async onContextCreated(context: BrowserContext): Promise { @@ -30,9 +28,6 @@ export class InspectorController implements ContextListener { language: 'javascript', terminal: true, }); - context.on(BrowserContext.Events.Page, (page: Page) => { - page.on(Page.Events.Console, (message: ConsoleMessage) => context.emit(BrowserContext.Events.StdOut, message.text() + '\n')); - }); } } async onContextWillDestroy(context: BrowserContext): Promise {} diff --git a/src/server/supplements/recorder/outputs.ts b/src/server/supplements/recorder/outputs.ts index 47f4c0beee..7550dfd502 100644 --- a/src/server/supplements/recorder/outputs.ts +++ b/src/server/supplements/recorder/outputs.ts @@ -64,15 +64,16 @@ export class OutputMultiplexer implements RecorderOutput { export class BufferedOutput implements RecorderOutput { private _lines: string[] = []; private _buffer: string | null = null; - private _language: string | null = null; + private _onUpdate: ((text: string) => void); - constructor(language?: string) { - this._language = language || null; + constructor(onUpdate: (text: string) => void = () => {}) { + this._onUpdate = onUpdate; } printLn(text: string) { this._buffer = null; this._lines.push(...text.trimEnd().split('\n')); + this._onUpdate(this.buffer()); } popLn(text: string) { @@ -81,17 +82,15 @@ export class BufferedOutput implements RecorderOutput { } buffer(): string { - if (this._buffer === null) { + if (this._buffer === null) this._buffer = this._lines.join('\n'); - if (this._language) - this._buffer = hljs.highlight(this._language, this._buffer).value; - } return this._buffer; } clear() { this._lines = []; this._buffer = null; + this._onUpdate(this.buffer()); } flush() { diff --git a/src/server/supplements/recorder/recorderApp.ts b/src/server/supplements/recorder/recorderApp.ts new file mode 100644 index 0000000000..ea99c1f071 --- /dev/null +++ b/src/server/supplements/recorder/recorderApp.ts @@ -0,0 +1,126 @@ +/** + * 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 * as os from 'os'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as util from 'util'; +import { CRPage } from '../../chromium/crPage'; +import { Page } from '../../page'; +import { ProgressController } from '../../progress'; +import { createPlaywright } from '../../playwright'; +import { EventEmitter } from 'events'; +import { DEFAULT_ARGS } from '../../chromium/chromium'; + +const readFileAsync = util.promisify(fs.readFile); + +export class RecorderApp extends EventEmitter { + + private _page: Page; + + constructor(page: Page) { + super(); + this._page = page; + } + + private async _init() { + const icon = await readFileAsync(require.resolve('../../../../lib/web/recorder/app_icon.png')); + const crPopup = this._page._delegate as CRPage; + await crPopup._mainFrameSession._client.send('Browser.setDockTile', { + image: icon.toString('base64') + }); + + await this._page._setServerRequestInterceptor(async route => { + if (route.request().url().startsWith('https://playwright/')) { + const uri = route.request().url().substring('https://playwright/'.length); + const file = require.resolve('../../../../lib/web/recorder/' + uri); + const buffer = await readFileAsync(file); + await route.fulfill({ + status: 200, + headers: [ + { name: 'Content-Type', value: extensionToMime[path.extname(file)] } + ], + body: buffer.toString('base64'), + isBase64: true + }); + return; + } + await route.continue(); + }); + + await this._page.exposeBinding('playwrightClear', false, (_, text: string) => { + this.emit('clear'); + }); + + this._page.once('close', () => { + this.emit('close'); + this._page.context().close().catch(e => console.error(e)); + }); + + await this._page.mainFrame().goto(new ProgressController(), 'https://playwright/index.html'); + } + + static async open(inspectedPage: Page): Promise { + const bounds = await CRPage.mainFrameSession(inspectedPage).windowBounds(); + const recorderPlaywright = createPlaywright(true); + const context = await recorderPlaywright.chromium.launchPersistentContext('', { + ignoreAllDefaultArgs: true, + args: [ + ...DEFAULT_ARGS, + `--user-data-dir=${path.join(os.homedir(),'.playwright-recorder')}`, + '--remote-debugging-pipe', + '--app=data:text/html,', + `--window-size=300,${bounds.height}`, + `--window-position=${bounds.left! + bounds.width! + 1},${bounds.top!}` + ], + noDefaultViewport: true + }); + + const controller = new ProgressController(); + await controller.run(async progress => { + await context._browser._defaultContext!._loadDefaultContextAsIs(progress); + }); + + const [page] = context.pages(); + const result = new RecorderApp(page); + await result._init(); + await inspectedPage.bringToFront(); + return result; + } + + async setScript(text: string, language: string): Promise { + await this._page.mainFrame()._evaluateExpression(((param: { text: string, language: string }) => { + (window as any).playwrightSetSource(param); + }).toString(), true, { text, language }, 'main'); + } + + async bringToFront() { + await this._page.bringToFront(); + } +} + +const extensionToMime: { [key: string]: string } = { + '.css': 'text/css', + '.html': 'text/html', + '.jpeg': 'image/jpeg', + '.js': 'application/javascript', + '.png': 'image/png', + '.ttf': 'font/ttf', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; diff --git a/src/server/supplements/recorder/state.ts b/src/server/supplements/recorder/state.ts index c69ff615b5..ead711d854 100644 --- a/src/server/supplements/recorder/state.ts +++ b/src/server/supplements/recorder/state.ts @@ -16,18 +16,14 @@ export type UIState = { mode: 'inspecting' | 'recording' | 'none', - drawerVisible: boolean } export type SetUIState = { mode?: 'inspecting' | 'recording' | 'none', - drawerVisible?: boolean } export type State = { canResume: boolean, - isController: boolean, isPaused: boolean, - codegenScript: string, uiState: UIState, } diff --git a/src/server/supplements/recorderSupplement.ts b/src/server/supplements/recorderSupplement.ts index 40fc581913..e8bde2ea14 100644 --- a/src/server/supplements/recorderSupplement.ts +++ b/src/server/supplements/recorderSupplement.ts @@ -30,13 +30,13 @@ import * as recorderSource from '../../generated/recorderSource'; import * as consoleApiSource from '../../generated/consoleApiSource'; import { BufferedOutput, FileOutput, FlushingTerminalOutput, OutputMultiplexer, RecorderOutput, TerminalOutput, Writable } from './recorder/outputs'; import type { State, UIState } from './recorder/state'; +import { RecorderApp } from './recorder/recorderApp'; type BindingSource = { frame: Frame, page: Page }; type App = 'codegen' | 'debug' | 'pause'; const symbol = Symbol('RecorderSupplement'); - export class RecorderSupplement { private _generator: CodeGenerator; private _pageAliases = new Map(); @@ -50,6 +50,8 @@ export class RecorderSupplement { private _app: App; private _output: OutputMultiplexer; private _bufferedOutput: BufferedOutput; + private _recorderApp: Promise | null = null; + private _highlighterType: string; static getOrCreate(context: BrowserContext, app: App, params: channels.BrowserContextRecorderSupplementEnableParams): Promise { let recorderPromise = (context as any)[symbol] as Promise; @@ -66,7 +68,6 @@ export class RecorderSupplement { this._app = app; this._recorderUIState = { mode: app === 'codegen' ? 'recording' : 'none', - drawerVisible: false }; let languageGenerator: LanguageGenerator; switch (params.language) { @@ -84,7 +85,13 @@ export class RecorderSupplement { write: (text: string) => context.emit(BrowserContext.Events.StdOut, text) }; const outputs: RecorderOutput[] = [params.terminal ? new TerminalOutput(writable, highlighterType) : new FlushingTerminalOutput(writable)]; - this._bufferedOutput = new BufferedOutput(highlighterType); + this._highlighterType = highlighterType; + this._bufferedOutput = new BufferedOutput(async text => { + if (this._recorderApp) { + const app = await this._recorderApp; + await app.setScript(text, highlighterType).catch(e => {}); + } + }); outputs.push(this._bufferedOutput); if (params.outputFile) outputs.push(new FileOutput(params.outputFile)); @@ -120,33 +127,32 @@ export class RecorderSupplement { await this._context.exposeBinding('playwrightRecorderCommitAction', false, (source: BindingSource, action: actions.Action) => this._generator.commitLastAction()); - await this._context.exposeBinding('playwrightRecorderClearScript', false, - (source: BindingSource, action: actions.Action) => { - this._bufferedOutput.clear(); - this._generator.restart(); - if (this._app === 'codegen') { - for (const page of this._context.pages()) - this._onFrameNavigated(page.mainFrame(), page); - } + await this._context.exposeBinding('playwrightRecorderShowRecorderPage', false, ({ page }) => { + if (this._recorderApp) { + this._recorderApp.then(p => p.bringToFront()).catch(() => {}); + return; + } + this._recorderApp = RecorderApp.open(page); + this._recorderApp.then(app => { + app.once('close', () => { + this._recorderApp = null; }); + app.on('clear', () => this._clearScript()); + return app.setScript(this._bufferedOutput.buffer(), this._highlighterType); + }).catch(e => console.error(e)); + }); await this._context.exposeBinding('playwrightRecorderState', false, ({ page }) => { const state: State = { - isController: page === this._context.pages()[0], uiState: this._recorderUIState, canResume: this._app === 'pause', isPaused: this._paused, - codegenScript: this._bufferedOutput.buffer() }; return state; }); await this._context.exposeBinding('playwrightRecorderSetUIState', false, (source, state: UIState) => { - const isController = source.page === this._context.pages()[0]; - if (isController) - this._recorderUIState = { ...this._recorderUIState, ...state }; - else - this._recorderUIState = { ...this._recorderUIState, mode: state.mode }; + this._recorderUIState = { ...this._recorderUIState, ...state }; this._output.setEnabled(state.mode === 'recording'); }); @@ -164,7 +170,7 @@ export class RecorderSupplement { async pause() { this._paused = true; - return new Promise(f => this._resumeCallback = f); + return new Promise(f => this._resumeCallback = f); } private async _onPage(page: Page) { @@ -208,6 +214,15 @@ export class RecorderSupplement { } } + private _clearScript(): void { + this._bufferedOutput.clear(); + this._generator.restart(); + if (this._app === 'codegen') { + for (const page of this._context.pages()) + this._onFrameNavigated(page.mainFrame(), page); + } + } + private async _performAction(frame: Frame, action: actions.Action) { const page = frame._page; const controller = new ProgressController(); @@ -274,4 +289,3 @@ export class RecorderSupplement { this._generator.signal(pageAlias, page.mainFrame(), { name: 'dialog', dialogAlias: String(++this._lastDialogOrdinal) }); } } - diff --git a/src/web/components/source.stories.tsx b/src/web/components/source.stories.tsx index cd4de274e5..82d0760305 100644 --- a/src/web/components/source.stories.tsx +++ b/src/web/components/source.stories.tsx @@ -33,11 +33,13 @@ const Template: Story = args => ; export const Primary = Template.bind({}); Primary.args = { + language: 'javascript', text: exampleText() }; export const HighlightLine = Template.bind({}); HighlightLine.args = { + language: 'javascript', text: exampleText(), highlightedLine: 11 }; diff --git a/src/web/components/source.tsx b/src/web/components/source.tsx index 18590e3d7b..0dba2e1fbc 100644 --- a/src/web/components/source.tsx +++ b/src/web/components/source.tsx @@ -21,18 +21,20 @@ import '../../third_party/highlightjs/highlightjs/tomorrow.css'; export interface SourceProps { text: string, + language: string, highlightedLine?: number } export const Source: React.FC = ({ - text = '', + text, + language, highlightedLine = -1 }) => { const lines = React.useMemo(() => { const result = []; let continuation: any; for (const line of text.split('\n')) { - const highlighted = highlightjs.highlight('javascript', line, true, continuation); + const highlighted = highlightjs.highlight(language, line, true, continuation); continuation = highlighted.top; result.push(highlighted.value); } diff --git a/src/web/recorder/app_icon.png b/src/web/recorder/app_icon.png new file mode 100644 index 0000000000..1c89846649 Binary files /dev/null and b/src/web/recorder/app_icon.png differ diff --git a/src/web/recorder/index.tsx b/src/web/recorder/index.tsx index 2958bf0ba1..a84d0e3d2f 100644 --- a/src/web/recorder/index.tsx +++ b/src/web/recorder/index.tsx @@ -28,5 +28,5 @@ declare global { (async () => { applyTheme(); - ReactDOM.render(, document.querySelector('#root')); + ReactDOM.render(, document.querySelector('#root')); })(); diff --git a/src/web/recorder/recorder.stories.tsx b/src/web/recorder/recorder.stories.tsx index ae92f5bce2..0831f5ca65 100644 --- a/src/web/recorder/recorder.stories.tsx +++ b/src/web/recorder/recorder.stories.tsx @@ -17,7 +17,6 @@ import { Story, Meta } from '@storybook/react/types-6-0'; import React from 'react'; import { Recorder, RecorderProps } from './recorder'; -import { exampleText } from '../components/exampleText'; export default { title: 'Recorder/Recorder', @@ -33,5 +32,4 @@ const Template: Story = args => ; export const Primary = Template.bind({}); Primary.args = { - text: exampleText() }; diff --git a/src/web/recorder/recorder.tsx b/src/web/recorder/recorder.tsx index 82b1f50b42..8dea9fda03 100644 --- a/src/web/recorder/recorder.tsx +++ b/src/web/recorder/recorder.tsx @@ -20,20 +20,42 @@ import { Toolbar } from '../components/toolbar'; import { ToolbarButton } from '../components/toolbarButton'; import { Source } from '../components/source'; +declare global { + interface Window { + playwrightClear(): Promise + playwrightSetSource: (params: { text: string, language: string }) => void + } +} + export interface RecorderProps { - text: string } export const Recorder: React.FC = ({ - text }) => { + const [source, setSource] = React.useState({ language: 'javascript', text: '' }); + window.playwrightSetSource = setSource; + return
- {}}> - {}}> + { + copy(source.text); + }}> + { + window.playwrightClear().catch(e => console.error(e)); + }}>
- {}}>
- +
; }; + +function copy(text: string) { + const textArea = document.createElement('textarea'); + textArea.style.position = 'absolute'; + textArea.style.zIndex = '-1000'; + textArea.value = text; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + textArea.remove(); +} diff --git a/utils/build/build.js b/utils/build/build.js index 6d772ddb4a..6670fd82bd 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -122,4 +122,11 @@ onChanges.push({ script: 'utils/generate_types/index.js', }); +// Copy images. +steps.push({ + command: process.platform === 'win32' ? 'copy' : 'cp', + args: ['src/web/recorder/*.png'.replace(/\//g, path.sep), 'lib/web/recorder/'.replace(/\//g, path.sep)], + shell: true, +}); + watchMode ? runWatch() : runBuild(); diff --git a/utils/check_deps.js b/utils/check_deps.js index bacde9ed4f..d405c91dff 100644 --- a/utils/check_deps.js +++ b/utils/check_deps.js @@ -154,6 +154,8 @@ DEPS['src/service.ts'] = ['src/remote/']; // CLI should only use client-side features. DEPS['src/cli/'] = ['src/cli/**', 'src/client/**', 'src/install/**', 'src/generated/', 'src/server/injected/', 'src/debug/injected/', 'src/trace/**', 'src/utils/**']; +DEPS['src/server/supplements/recorder/recorderApp.ts'] = ['src/server/', 'src/server/chromium/'] + checkDeps().catch(e => { console.error(e && e.stack ? e.stack : e); process.exit(1);