From 75f8f96ba24c00a945d5d2bdc767174ebec131cc Mon Sep 17 00:00:00 2001 From: Rui Figueira Date: Sun, 7 Apr 2024 23:07:13 +0100 Subject: [PATCH] recorder webextension PoC of chromium extension and firefox addon --- package-lock.json | 47 +- .../src/server/injected/recorder/recorder.ts | 32 +- .../src/server/recorder/DEPS.list | 9 - packages/recorder/package.json | 8 +- packages/recorder/public/icon-16x16.png | Bin 0 -> 593 bytes packages/recorder/public/icon-192x192.png | Bin 0 -> 16906 bytes packages/recorder/public/icon-32x32.png | Bin 0 -> 1330 bytes packages/recorder/public/icon-48x48.png | Bin 0 -> 5962 bytes packages/recorder/public/manifest.json | 25 + packages/recorder/src/main.tsx | 18 + packages/recorder/src/webextension/DEPS.list | 4 + .../recorder/src/webextension/background.ts | 53 ++ .../src/webextension/browserContext.ts | 126 +++++ packages/recorder/src/webextension/frame.ts | 60 +++ .../src/webextension/injected/DEPS.list | 2 + .../src/webextension/injected/recorder.ts | 46 ++ packages/recorder/src/webextension/page.ts | 74 +++ .../src/webextension/recorder/DEPS.list | 7 + .../src/webextension/recorder/recorder.ts | 455 ++++++++++++++++++ .../src/webextension/recorder/recorderApp.ts | 153 ++++++ packages/recorder/src/webextension/utils.ts | 57 +++ packages/recorder/tsconfig.json | 2 + packages/recorder/utils/generate_injected.js | 69 +++ packages/recorder/vite.config.ts | 10 + 24 files changed, 1230 insertions(+), 27 deletions(-) delete mode 100644 packages/playwright-core/src/server/recorder/DEPS.list create mode 100644 packages/recorder/public/icon-16x16.png create mode 100644 packages/recorder/public/icon-192x192.png create mode 100644 packages/recorder/public/icon-32x32.png create mode 100644 packages/recorder/public/icon-48x48.png create mode 100644 packages/recorder/public/manifest.json create mode 100644 packages/recorder/src/webextension/DEPS.list create mode 100644 packages/recorder/src/webextension/background.ts create mode 100644 packages/recorder/src/webextension/browserContext.ts create mode 100644 packages/recorder/src/webextension/frame.ts create mode 100644 packages/recorder/src/webextension/injected/DEPS.list create mode 100644 packages/recorder/src/webextension/injected/recorder.ts create mode 100644 packages/recorder/src/webextension/page.ts create mode 100644 packages/recorder/src/webextension/recorder/DEPS.list create mode 100644 packages/recorder/src/webextension/recorder/recorder.ts create mode 100644 packages/recorder/src/webextension/recorder/recorderApp.ts create mode 100644 packages/recorder/src/webextension/utils.ts create mode 100644 packages/recorder/utils/generate_injected.js diff --git a/package-lock.json b/package-lock.json index a683d38e0a..4e247d5af1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1792,6 +1792,16 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chrome": { + "version": "0.0.266", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.266.tgz", + "integrity": "sha512-QSQWJTL7NjZElvq/6/E5C1+pHgEP8UAJzwoz7M4vSJ7AECt6NNehJ+tU6snnvuTqZOBjFCivvitYo5+8tNPmhg==", + "dev": true, + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, "node_modules/@types/codemirror": { "version": "5.60.15", "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz", @@ -1806,6 +1816,21 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true + }, "node_modules/@types/formidable": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-2.0.6.tgz", @@ -1815,6 +1840,12 @@ "@types/node": "*" } }, + "node_modules/@types/har-format": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.15.tgz", + "integrity": "sha512-RpQH4rXLuvTXKR0zqHq3go0RVXYv/YVqv4TnPH95VbwUxZdQlK1EtcMvQvMpDngHbt13Csh9Z4qT9AbkiQH5BA==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4066,6 +4097,14 @@ "node": ">=0.10.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/extract-zip": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", @@ -8440,7 +8479,13 @@ } }, "packages/recorder": { - "version": "0.0.0" + "version": "0.0.0", + "dependencies": { + "events": "^3.3.0" + }, + "devDependencies": { + "@types/chrome": "^0.0.266" + } }, "packages/trace-viewer": { "version": "0.0.0" diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 8a62c0f4f0..b2c747f3f7 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -208,11 +208,11 @@ class RecordActionTool implements RecorderTool { private _hoveredElement: HTMLElement | null = null; private _activeModel: HighlightModel | null = null; private _expectProgrammaticKeyUp = false; - private _performActions: boolean; + private _delegatePerform: boolean; constructor(recorder: Recorder, performActions: boolean) { this._recorder = recorder; - this._performActions = performActions; + this._delegatePerform = performActions; } cursor() { @@ -263,21 +263,21 @@ class RecordActionTool implements RecorderTool { onPointerDown(event: PointerEvent) { if (this._shouldIgnoreMouseEvent(event)) return; - if (this._performActions && !this._performingAction) + if (this._delegatePerform && !this._performingAction) consumeEvent(event); } onPointerUp(event: PointerEvent) { if (this._shouldIgnoreMouseEvent(event)) return; - if (this._performActions && !this._performingAction) + if (this._delegatePerform && !this._performingAction) consumeEvent(event); } onMouseDown(event: MouseEvent) { if (this._shouldIgnoreMouseEvent(event)) return; - if (this._performActions && !this._performingAction) + if (this._delegatePerform && !this._performingAction) consumeEvent(event); this._activeModel = this._hoveredModel; } @@ -285,7 +285,7 @@ class RecordActionTool implements RecorderTool { onMouseUp(event: MouseEvent) { if (this._shouldIgnoreMouseEvent(event)) return; - if (this._performActions && !this._performingAction) + if (this._delegatePerform && !this._performingAction) consumeEvent(event); } @@ -400,7 +400,7 @@ class RecordActionTool implements RecorderTool { return; // Only allow programmatic keyups, ignore user input. - if (this._performActions && !this._expectProgrammaticKeyUp) { + if (this._delegatePerform && !this._expectProgrammaticKeyUp) { consumeEvent(event); return; } @@ -441,7 +441,7 @@ class RecordActionTool implements RecorderTool { if (this._performingAction) return true; // Consume as the first thing. - if (this._performActions) + if (this._delegatePerform) consumeEvent(event); return false; } @@ -449,7 +449,7 @@ class RecordActionTool implements RecorderTool { private _consumedDueToNoModel(event: Event, model: HighlightModel | null): boolean { if (model) return false; - if (this._performActions) + if (this._delegatePerform) consumeEvent(event); return true; } @@ -457,13 +457,14 @@ class RecordActionTool implements RecorderTool { private _consumedDueWrongTarget(event: Event): boolean { if (this._activeModel && this._activeModel.elements[0] === this._recorder.deepEventTarget(event)) return false; - if (this._performActions) + if (this._delegatePerform) consumeEvent(event); return true; } private async _performAction(action: actions.Action) { - if (!this._performActions) { + if (!this._delegatePerform) { + // just record the action await this._recorder.delegate.recordAction?.(action); return; } @@ -957,8 +958,7 @@ export class Recorder { readonly document: Document; delegate: RecorderDelegate = {}; - constructor(injectedScript: InjectedScript, performActions?: boolean) { - performActions = performActions === undefined || performActions === null ? true : performActions; + constructor(injectedScript: InjectedScript, delegatePerform: boolean = true) { this.document = injectedScript.document; this.injectedScript = injectedScript; this.highlight = injectedScript.createHighlight(); @@ -966,7 +966,7 @@ export class Recorder { 'none': new NoneTool(), 'standby': new NoneTool(), 'inspecting': new InspectTool(this, false), - 'recording': new RecordActionTool(this, performActions), + 'recording': new RecordActionTool(this, delegatePerform), 'recording-inspecting': new InspectTool(this, false), 'assertingText': new TextAssertionTool(this, 'text'), 'assertingVisibility': new InspectTool(this, true), @@ -1303,8 +1303,8 @@ export class PollingRecorder implements RecorderDelegate { private _embedder: Embedder; private _pollRecorderModeTimer: NodeJS.Timeout | undefined; - constructor(injectedScript: InjectedScript, performActions?: boolean) { - this._recorder = new Recorder(injectedScript, performActions); + constructor(injectedScript: InjectedScript, delegatePerform?: boolean) { + this._recorder = new Recorder(injectedScript, delegatePerform); this._embedder = injectedScript.window as any; injectedScript.onGlobalListenersRemoved.add(() => this._recorder.installListeners()); diff --git a/packages/playwright-core/src/server/recorder/DEPS.list b/packages/playwright-core/src/server/recorder/DEPS.list deleted file mode 100644 index 69c4226c68..0000000000 --- a/packages/playwright-core/src/server/recorder/DEPS.list +++ /dev/null @@ -1,9 +0,0 @@ -[*] -../ -../isomorphic/** -../registry/** -../../common/ -../../protocol/ -../../utils/** -../../utilsBundle.ts -../../zipBundle.ts diff --git a/packages/recorder/package.json b/packages/recorder/package.json index 0641d13367..65d7adc26b 100644 --- a/packages/recorder/package.json +++ b/packages/recorder/package.json @@ -4,7 +4,13 @@ "version": "0.0.0", "scripts": { "dev": "vite", - "build": "vite build && tsc", + "build": "vite build && tsc && node ./utils/generate_injected.js", "preview": "vite preview" + }, + "dependencies": { + "events": "^3.3.0" + }, + "devDependencies": { + "@types/chrome": "^0.0.266" } } diff --git a/packages/recorder/public/icon-16x16.png b/packages/recorder/public/icon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..1f6c4d6c1ead36390e1eee792c099f775d6ae63a GIT binary patch literal 593 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
    lLHJ()41d&{U>xoi_N7N-ji<=}AzvA$ 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 PxC4WytDnm{r-UW|Yd!P- literal 0 HcmV?d00001 diff --git a/packages/recorder/public/icon-32x32.png b/packages/recorder/public/icon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..650be13340f8945fdcaad3b5fdc736d2c0665a2e GIT binary patch literal 1330 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"] +} diff --git a/packages/recorder/src/main.tsx b/packages/recorder/src/main.tsx index 5980716d1e..6b4f7817da 100644 --- a/packages/recorder/src/main.tsx +++ b/packages/recorder/src/main.tsx @@ -19,6 +19,24 @@ import * as React from 'react'; import { Recorder } from './recorder'; import './recorder.css'; +if (typeof chrome !== 'undefined' && chrome.runtime) { + + window.dispatch = async function _onDispatch(data: any) { + await chrome.runtime.sendMessage(data); + }; + + chrome.runtime.onMessage.addListener((msg: any) => { + switch (msg.method) { + case 'setPaused': window.playwrightSetPaused(msg.paused); break; + case 'setMode': window.playwrightSetMode(msg.mode); break; + case 'setSources': window.playwrightSetSources(msg.sources); break; + case 'updateCallLogs': window.playwrightUpdateLogs(msg.callLogs); break; + case 'setSelector': window.playwrightSetSelector(msg.selector, msg.userGesture); break; + case 'setFileIfNeeded': window.playwrightSetFileIfNeeded(msg.file); break; + } + }); +} + export const Main: React.FC = ({ }) => { const [sources, setSources] = React.useState([]); diff --git a/packages/recorder/src/webextension/DEPS.list b/packages/recorder/src/webextension/DEPS.list new file mode 100644 index 0000000000..e571ed08af --- /dev/null +++ b/packages/recorder/src/webextension/DEPS.list @@ -0,0 +1,4 @@ +[*] +@web/** +@isomorphic/** +recorder/ diff --git a/packages/recorder/src/webextension/background.ts b/packages/recorder/src/webextension/background.ts new file mode 100644 index 0000000000..c844802943 --- /dev/null +++ b/packages/recorder/src/webextension/background.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) Rui Figueira. + * + * 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 { BrowserContext } from './browserContext'; +import { Recorder } from './recorder/recorder'; +import { BrowserRecorderApp } from './recorder/recorderApp'; +import { Page } from './page'; + +async function record(tab: chrome.tabs.Tab) { + if (!tab?.id) + return; + + // ensure one attachment at a time + chrome.action.disable(); + + const browserContext = new BrowserContext(); + const page = new Page(tab.id, tab.url); + browserContext.addPage(page); + + Recorder.setAppFactory(async recorder => { + const recorderApp = await BrowserRecorderApp.open(recorder); + + const close = async () => { + await recorderApp.close(); + // wait 1 second to ensure __pw_recorderSetMode binding is called + await new Promise(resolve => setTimeout(resolve, 1000)); + browserContext.dispose(); + await chrome.action.enable(); + }; + + page.on(Page.Events.Close, close); + recorderApp.on(BrowserRecorderApp.Events.Close, close); + + return recorderApp; + }); + + await Recorder.show(browserContext, { mode: 'recording' }); +} + +chrome.action.onClicked.addListener(record); diff --git a/packages/recorder/src/webextension/browserContext.ts b/packages/recorder/src/webextension/browserContext.ts new file mode 100644 index 0000000000..7fddd09687 --- /dev/null +++ b/packages/recorder/src/webextension/browserContext.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 { EventEmitter } from 'events'; +import type { Frame } from './frame'; +import { Page } from './page'; +import { eventsHelper, type RegisteredListener } from './utils'; + +export type FunctionWithSource = (source: { context: BrowserContext, page: Page, frame: Frame}, ...args: any) => any; + +export class BrowserContext extends EventEmitter { + + static Events = { + Close: 'close', + Page: 'page', + }; + + private _listeners: RegisteredListener[]; + private _bindings: Map = new Map(); + private _pages: Map = new Map(); + + constructor() { + super(); + this._listeners = [ + eventsHelper.addEventListener(chrome.tabs.onCreated, this._onCreated.bind(this)), + eventsHelper.addEventListener(chrome.tabs.onRemoved, this._onRemoved.bind(this)), + eventsHelper.addEventListener(chrome.runtime.onMessage, this._onMessage.bind(this)), + eventsHelper.addEventListener(chrome.webNavigation.onCompleted, this._onCompleted.bind(this)), + ]; + } + + async exposeBinding(bindingName: string, _needsHandle: boolean, func: FunctionWithSource) { + this._bindings.set(bindingName, func); + } + + addPage(page: Page) { + this._pages.set(page._tabId, page); + } + + pages(): Page[] { + return [...this._pages.values()]; + } + + async extendInjectedScript(source: string) { + const installInFrame = (frame: Frame) => { + chrome.scripting.executeScript({ + target: { tabId: frame._page._tabId, allFrames: false, frameIds: [frame._frameId] }, + files: [source], + }).catch(() => {}); + }; + const installInPage = async (page: Page) => { + page.on(Page.Events.InternalFrameNavigatedToNewDocument, installInFrame); + await chrome.scripting.executeScript({ + target: { tabId: page._tabId, allFrames: true }, + files: [source], + }).catch(() => {}); + }; + this.on(BrowserContext.Events.Page, installInPage); + await Promise.all(this.pages().map(installInPage)); + } + + dispose() { + eventsHelper.removeEventListeners(this._listeners); + } + + private _onMessage({ bindingName, args }: any, { tab, frameId }: chrome.runtime.MessageSender, sendResponse: (response: any) => void) { + const tabId = tab?.id; + // frameId may be 0! + if (!bindingName || !tabId || typeof frameId !== 'number') + return; + const frame = this._pages.get(tabId)?._frames.get(frameId); + if (!frame) + return; + + const func = this._bindings.get(bindingName); + if (!func) + throw new Error(`No binding for ${bindingName}`); + + (async () => { + // handles both sync and async functions + const result = await func({ context: this, page: frame._page, frame }, ...args); + sendResponse(result); + })(); + + // needed for asynchronous responses: + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onMessage#sendresponse + return true; + } + + private _onCreated({ id: tabId, openerTabId }: chrome.tabs.Tab) { + // for now, it only handles pages with a opener that belongs to browserContext + if (!tabId || !openerTabId || !this._pages.has(openerTabId)) + return; + const openerPage = this._pageFor(openerTabId); + const page = this._pageFor(tabId); + page._opener = openerPage; + this.emit(BrowserContext.Events.Page, page); + } + + private _onRemoved(tabId: number) { + const page = this._pages.get(tabId); + this._pages.delete(tabId); + page?.emit(Page.Events.Close); + } + + private _onCompleted(event: chrome.webNavigation.WebNavigationFramedCallbackDetails) { + this._pageFor(event.tabId)._onCompleted(event); + } + + _pageFor(tabId: number) { + return this._pages.get(tabId) ?? new Page(tabId); + } +} diff --git a/packages/recorder/src/webextension/frame.ts b/packages/recorder/src/webextension/frame.ts new file mode 100644 index 0000000000..d941ae78ac --- /dev/null +++ b/packages/recorder/src/webextension/frame.ts @@ -0,0 +1,60 @@ +/** + * 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 { EventEmitter } from 'events'; +import type { Page } from './page'; + +export class Frame extends EventEmitter { + + static Events = { + InternalNavigation: 'internalnavigation', + }; + + readonly _page: Page; + readonly _frameId: number; + _parentFrame: Frame | null = null; + _url?: string; + + constructor(page: Page, frameId: number, url?: string) { + super(); + this._page = page; + this._frameId = frameId; + this._url = url; + } + + _reset() { + this._url = undefined; + } + + parentFrame(): Frame | null { + return this._parentFrame; + } + + url() { + return this._url!; + } + + name() { + // TODO + return ''; + } + + async evaluateExpression(func: (...args: any[]) => any, ...args: any[]) { + const target = { tabId: this._page._tabId, frameIds: [this._frameId] }; + const [{ result }] = await chrome.scripting.executeScript({ target, func, args }); + return result; + } +} diff --git a/packages/recorder/src/webextension/injected/DEPS.list b/packages/recorder/src/webextension/injected/DEPS.list new file mode 100644 index 0000000000..2217ddbff7 --- /dev/null +++ b/packages/recorder/src/webextension/injected/DEPS.list @@ -0,0 +1,2 @@ +[*] +../../../../playwright-core/src/server/injected/** diff --git a/packages/recorder/src/webextension/injected/recorder.ts b/packages/recorder/src/webextension/injected/recorder.ts new file mode 100644 index 0000000000..712fc428f3 --- /dev/null +++ b/packages/recorder/src/webextension/injected/recorder.ts @@ -0,0 +1,46 @@ +/** + * 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 { InjectedScript } from '../../../../playwright-core/src/server/injected/injectedScript'; +import { PollingRecorder } from '../../../../playwright-core/src/server/injected/recorder/recorder'; + +const wnd = window as any; + +if (!wnd['__pw_injectedScript']) { + const bindingNames = [ + '__pw_recorderPerformAction', + '__pw_recorderRecordAction', + '__pw_recorderState', + '__pw_recorderSetSelector', + '__pw_recorderSetMode', + '__pw_recorderSetOverlayState', + '__pw_refreshOverlay', + ]; + + for (const bindingName of bindingNames) { + wnd[bindingName] = async (...args: any[]) => { + return await chrome.runtime.sendMessage({ + bindingName, + args, + }); + }; + } + + const injectedScript = new InjectedScript(window, false, 'javascript', 'data-testid', 1, 'chrome', []); + new PollingRecorder(injectedScript, false); + + wnd['__pw_injectedScript'] = injectedScript; +} diff --git a/packages/recorder/src/webextension/page.ts b/packages/recorder/src/webextension/page.ts new file mode 100644 index 0000000000..9099e49240 --- /dev/null +++ b/packages/recorder/src/webextension/page.ts @@ -0,0 +1,74 @@ +/** + * 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 { EventEmitter } from 'events'; +import { Frame } from './frame'; + +export class Page extends EventEmitter { + + static Events = { + Close: 'close', + InternalFrameNavigatedToNewDocument: 'internalframenavigatedtonewdocument', + }; + + readonly _tabId: number; + private _mainFrame: Frame; + readonly _frames: Map = new Map(); + _opener: Page | undefined; + + constructor(tabId: number, url?: string) { + super(); + this._tabId = tabId; + // main frame is always frameId = 0 + this._mainFrame = this._frameFor(0); + this._mainFrame._url = url; + } + + async _onCompleted({ url, frameId }: chrome.webNavigation.WebNavigationFramedCallbackDetails) { + const frame = this._frameFor(frameId); + frame._url = url; + const page = frame._page; + if (frame === this._mainFrame) { + // remove all except main frame + this._frames.clear(); + this._frames.set(frame._frameId, frame); + } + + frame.emit(Frame.Events.InternalNavigation, { url, isPublic: true }); + page.emit(Page.Events.InternalFrameNavigatedToNewDocument, frame); + } + + opener() { + return this._opener; + } + + mainFrame() { + return this._mainFrame; + } + + async bringToFront() { + await chrome.tabs.update(this._tabId, { active: true }); + } + + private _frameFor(frameId: number) { + let frame = this._frames.get(frameId); + if (!frame) { + frame = new Frame(this, frameId); + this._frames.set(frameId, frame); + } + return frame; + } +} diff --git a/packages/recorder/src/webextension/recorder/DEPS.list b/packages/recorder/src/webextension/recorder/DEPS.list new file mode 100644 index 0000000000..6389095dd7 --- /dev/null +++ b/packages/recorder/src/webextension/recorder/DEPS.list @@ -0,0 +1,7 @@ +[*] +../ +../../recorderTypes +@web/** +@isomorphic/** +../../../../playwright-core/src/server/recorder/** +../../../../playwright-core/src/utils/** diff --git a/packages/recorder/src/webextension/recorder/recorder.ts b/packages/recorder/src/webextension/recorder/recorder.ts new file mode 100644 index 0000000000..8b0239abe1 --- /dev/null +++ b/packages/recorder/src/webextension/recorder/recorder.ts @@ -0,0 +1,455 @@ +/** + * 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 * as actions from '../../../../playwright-core/src/server/recorder/recorderActions'; +import type * as channels from '@protocol/channels'; +import { JavaLanguageGenerator } from '../../../../playwright-core/src/server/recorder/java'; +import { JavaScriptLanguageGenerator } from '../../../../playwright-core/src/server/recorder/javascript'; +import { JsonlLanguageGenerator } from '../../../../playwright-core/src/server/recorder/jsonl'; +import { CSharpLanguageGenerator } from '../../../../playwright-core/src/server/recorder/csharp'; +import { PythonLanguageGenerator } from '../../../../playwright-core/src/server/recorder/python'; +import type { EventData, Mode, OverlayState, Source, UIState } from '../../recorderTypes'; +import { EventEmitter } from 'events'; +import { raceAgainstDeadline, monotonicTime } from '../utils'; +import type { Language, LanguageGenerator } from '../../../../playwright-core/src/server/recorder/language'; +import { locatorOrSelectorAsSelector } from '@isomorphic/locatorParser'; +import { quoteCSSAttributeValue } from '@isomorphic/stringUtils'; +import { eventsHelper, type RegisteredListener } from '../../../../playwright-core/src/utils/eventsHelper'; +import type { IRecorderApp } from '../../../../playwright-core/src/server/recorder/recorderApp'; +import { BrowserContext } from '../browserContext'; +import { Page } from '../page'; +import { Frame } from '../frame'; +import { CodeGenerator } from '../../../../playwright-core/src/server/recorder/codeGenerator'; +import type { ActionInContext } from '../../../../playwright-core/src/server/recorder/codeGenerator'; +import { EmptyRecorderApp } from './recorderApp'; + +type BindingSource = { frame: Frame, page: Page }; + +const recorderSymbol = Symbol('recorderSymbol'); + +const recorderSource = { source: 'webextension/injected/recorder.js' }; + +export class Recorder { + private _context: BrowserContext; + private _mode: Mode; + private _highlightedSelector = ''; + private _overlayState: OverlayState = { offsetX: 0 }; + private _recorderApp: IRecorderApp | null = null; + private _recorderSources: Source[] = []; + private _userSources = new Map(); + private _contextRecorder: ContextRecorder; + private _currentLanguage: Language; + + private static recorderAppFactory: ((recorder: Recorder) => Promise) | undefined; + + static setAppFactory(recorderAppFactory: ((recorder: Recorder) => Promise) | undefined) { + Recorder.recorderAppFactory = recorderAppFactory; + } + + static showInspector(context: BrowserContext) { + const params: channels.BrowserContextRecorderSupplementEnableParams = {}; + Recorder.show(context, params).catch(() => {}); + } + + static show(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams = {}): Promise { + let recorderPromise = (context as any)[recorderSymbol] as Promise; + if (!recorderPromise) { + const recorder = new Recorder(context, params); + recorderPromise = recorder.install().then(() => recorder); + (context as any)[recorderSymbol] = recorderPromise; + } + return recorderPromise; + } + + constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { + this._mode = params.mode || 'none'; + this._contextRecorder = new ContextRecorder(context, params); + this._context = context; + this._currentLanguage = this._contextRecorder.languageName(); + } + + private static async defaultRecorderAppFactory(_recorder: Recorder) { + return new EmptyRecorderApp(); + } + + async install() { + const recorderApp = await (Recorder.recorderAppFactory || Recorder.defaultRecorderAppFactory)(this); + this._recorderApp = recorderApp; + recorderApp.once('close', () => { + this._recorderApp = null; + }); + recorderApp.on('event', (data: EventData) => { + if (data.event === 'setMode') { + this.setMode(data.params.mode); + return; + } + if (data.event === 'selectorUpdated') { + this.setHighlightedSelector(this._currentLanguage, data.params.selector); + return; + } + if (data.event === 'fileChanged') { + this._currentLanguage = this._contextRecorder.languageName(data.params.file); + this._refreshOverlay(); + return; + } + if (data.event === 'clear') { + this._contextRecorder.clearScript(); + return; + } + }); + + await Promise.all([ + recorderApp.setMode(this._mode), + this._pushAllSources() + ]); + + this._context.once(BrowserContext.Events.Close, () => { + this._contextRecorder.dispose(); + recorderApp.close().catch(() => {}); + }); + this._contextRecorder.on(ContextRecorder.Events.Change, (data: { sources: Source[], primaryFileName: string }) => { + this._recorderSources = data.sources; + this._pushAllSources(); + this._recorderApp?.setFileIfNeeded(data.primaryFileName); + }); + this.setOutput(this._currentLanguage); + + await this._context.exposeBinding('__pw_recorderState', false, source => { + let actionSelector = ''; + actionSelector = this._highlightedSelector; + + const uiState: UIState = { + mode: this._mode, + actionSelector, + language: this._currentLanguage, + testIdAttributeName: this._contextRecorder.testIdAttributeName(), + overlay: this._overlayState, + }; + return uiState; + }); + + await this._context.exposeBinding('__pw_recorderSetSelector', false, async ({ frame }, selector: string) => { + const selectorPromises: Promise[] = []; + let currentFrame: Frame | null = frame; + while (currentFrame) { + selectorPromises.push(findFrameSelector(currentFrame)); + currentFrame = currentFrame.parentFrame(); + } + const fullSelector = (await Promise.all(selectorPromises)).filter(Boolean); + fullSelector.push(selector); + await this._recorderApp?.setSelector(fullSelector.join(' >> internal:control=enter-frame >> '), true); + }); + + await this._context.exposeBinding('__pw_recorderSetMode', false, async ({ frame }, mode: Mode) => { + if (frame.parentFrame()) + return; + this.setMode(mode); + }); + + await this._context.exposeBinding('__pw_recorderSetOverlayState', false, async ({ frame }, state: OverlayState) => { + if (frame.parentFrame()) + return; + this._overlayState = state; + }); + + await this._contextRecorder.install(); + } + + setMode(mode: Mode) { + if (this._mode === mode) + return; + this._highlightedSelector = ''; + this._mode = mode; + this._recorderApp?.setMode(this._mode); + this._contextRecorder.setEnabled(this._mode === 'recording' || this._mode === 'assertingText' || this._mode === 'assertingVisibility' || this._mode === 'assertingValue'); + if (this._mode !== 'none' && this._mode !== 'standby' && this._context.pages().length === 1) + this._context.pages()[0].bringToFront().catch(() => {}); + this._refreshOverlay(); + } + + mode() { + return this._mode; + } + + setHighlightedSelector(language: Language, selector: string) { + this._highlightedSelector = locatorOrSelectorAsSelector(language, selector, 'data-testid'); + this._refreshOverlay(); + } + + hideHighlightedSelector() { + this._highlightedSelector = ''; + this._refreshOverlay(); + } + + setOutput(codegenId: string) { + this._contextRecorder.setOutput(codegenId); + } + + private _refreshOverlay() { + for (const page of this._context.pages()) + page.mainFrame().evaluateExpression(() => (window as any).__pw_refreshOverlay?.()).catch(() => {}); + } + + private _pushAllSources() { + this._recorderApp?.setSources([...this._recorderSources, ...this._userSources.values()]); + } +} + +class ContextRecorder extends EventEmitter { + static Events = { + Change: 'change' + }; + + private _generator: CodeGenerator; + private _pageAliases = new Map(); + private _lastPopupOrdinal = 0; + private _timers = new Set(); + private _context: BrowserContext; + private _params: channels.BrowserContextRecorderSupplementEnableParams; + private _recorderSources: Source[]; + private _orderedLanguages: LanguageGenerator[] = []; + private _listeners: RegisteredListener[] = []; + + constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { + super(); + this._context = context; + this._params = params; + this._recorderSources = []; + const language = params.language || 'javascript'; + this.setOutput(language); + const generator = new CodeGenerator('chrome', params.mode === 'recording', params.launchOptions || {}, params.contextOptions || {}, params.device, params.saveStorage); + generator.on('change', () => { + this._recorderSources = []; + for (const languageGenerator of this._orderedLanguages) { + const { header, footer, actions, text } = generator.generateStructure(languageGenerator); + const source: Source = { + isRecorded: true, + label: languageGenerator.name, + group: languageGenerator.groupName, + id: languageGenerator.id, + text, + header, + footer, + actions, + language: languageGenerator.highlighter, + highlight: [] + }; + source.revealLine = text.split('\n').length - 1; + this._recorderSources.push(source); + } + this.emit(ContextRecorder.Events.Change, { + sources: this._recorderSources, + primaryFileName: this._orderedLanguages[0].id + }); + }); + this._generator = generator; + } + + setOutput(codegenId: string) { + const languages = new Set([ + new JavaLanguageGenerator('junit'), + new JavaLanguageGenerator('library'), + new JavaScriptLanguageGenerator(/* isPlaywrightTest */false), + new JavaScriptLanguageGenerator(/* isPlaywrightTest */true), + new PythonLanguageGenerator(/* isAsync */false, /* isPytest */true), + new PythonLanguageGenerator(/* isAsync */false, /* isPytest */false), + new PythonLanguageGenerator(/* isAsync */true, /* isPytest */false), + new CSharpLanguageGenerator('mstest'), + new CSharpLanguageGenerator('nunit'), + new CSharpLanguageGenerator('library'), + new JsonlLanguageGenerator(), + ]); + const primaryLanguage = [...languages].find(l => l.id === codegenId); + if (!primaryLanguage) + throw new Error(`\n===============================\nUnsupported language: '${codegenId}'\n===============================\n`); + languages.delete(primaryLanguage); + this._orderedLanguages = [primaryLanguage, ...languages]; + this._generator?.restart(); + } + + languageName(id?: string): Language { + for (const lang of this._orderedLanguages) { + if (!id || lang.id === id) + return lang.highlighter; + } + return 'javascript'; + } + + async install() { + this._context.on(BrowserContext.Events.Page, (page: Page) => this._onPage(page)); + for (const page of this._context.pages()) + this._onPage(page); + + // Other non-essential actions are simply being recorded. + await this._context.exposeBinding('__pw_recorderRecordAction', false, + (source: BindingSource, action: actions.Action) => this._recordAction(source.frame, action)); + + await this._context.extendInjectedScript(recorderSource.source); + } + + setEnabled(enabled: boolean) { + this._generator.setEnabled(enabled); + } + + dispose() { + for (const timer of this._timers) + clearTimeout(timer); + this._timers.clear(); + eventsHelper.removeEventListeners(this._listeners); + } + + private async _onPage(page: Page) { + // First page is called page, others are called popup1, popup2, etc. + const frame = page.mainFrame(); + page.on(Page.Events.Close, () => { + this._generator.addAction({ + frame: this._describeMainFrame(page), + committed: true, + action: { + name: 'closePage', + signals: [], + } + }); + this._pageAliases.delete(page); + }); + frame.on(Frame.Events.InternalNavigation, event => { + if (event.isPublic) + this._onFrameNavigated(frame, page); + }); + const suffix = this._pageAliases.size ? String(++this._lastPopupOrdinal) : ''; + const pageAlias = 'page' + suffix; + this._pageAliases.set(page, pageAlias); + + if (page.opener()) { + this._onPopup(page.opener()!, page); + } else { + this._generator.addAction({ + frame: this._describeMainFrame(page), + committed: true, + action: { + name: 'openPage', + url: page.mainFrame().url(), + signals: [], + } + }); + } + } + + clearScript(): void { + this._generator.restart(); + if (this._params.mode === 'recording') { + for (const page of this._context.pages()) + this._onFrameNavigated(page.mainFrame(), page); + } + } + + private _describeMainFrame(page: Page): actions.FrameDescription { + return { + pageAlias: this._pageAliases.get(page)!, + isMainFrame: true, + }; + } + + private async _describeFrame(frame: Frame): Promise { + const page = frame._page; + const pageAlias = this._pageAliases.get(page)!; + const chain: Frame[] = []; + for (let ancestor: Frame | null = frame; ancestor; ancestor = ancestor.parentFrame()) + chain.push(ancestor); + chain.reverse(); + + if (chain.length === 1) + return this._describeMainFrame(page); + + const selectorPromises: Promise[] = []; + for (let i = 0; i < chain.length - 1; i++) + selectorPromises.push(findFrameSelector(chain[i + 1])); + + const result = await raceAgainstDeadline(() => Promise.all(selectorPromises), monotonicTime() + 2000); + if (!result.timedOut && result.result.every(selector => !!selector)) { + return { + pageAlias, + isMainFrame: false, + selectorsChain: result.result as string[], + }; + } + // Best effort to find a selector for the frame. + const selectorsChain = []; + for (let i = 0; i < chain.length - 1; i++) { + if (chain[i].name()) + selectorsChain.push(`iframe[name=${quoteCSSAttributeValue(chain[i].name())}]`); + else + selectorsChain.push(`iframe[src=${quoteCSSAttributeValue(chain[i].url())}]`); + } + return { + pageAlias, + isMainFrame: false, + selectorsChain, + }; + } + + testIdAttributeName(): string { + return this._params.testIdAttributeName || 'data-testid'; + } + + private async _recordAction(frame: Frame, action: actions.Action) { + // Commit last action so that no further signals are added to it. + this._generator.commitLastAction(); + + const frameDescription = await this._describeFrame(frame); + const actionInContext: ActionInContext = { + frame: frameDescription, + action + }; + this._setCommittedAfterTimeout(actionInContext); + this._generator.addAction(actionInContext); + } + + private _setCommittedAfterTimeout(actionInContext: ActionInContext) { + const timer = setTimeout(() => { + // Commit the action after 5 seconds so that no further signals are added to it. + actionInContext.committed = true; + this._timers.delete(timer); + }, 5000); + this._timers.add(timer); + } + + private _onFrameNavigated(frame: Frame, page: Page) { + if (frame._page.mainFrame() !== frame) + return; + const pageAlias = this._pageAliases.get(page); + this._generator.signal(pageAlias!, { name: 'navigation', url: frame.url() }); + } + + private _onPopup(page: Page, popup: Page) { + const pageAlias = this._pageAliases.get(page)!; + const popupAlias = this._pageAliases.get(popup)!; + this._generator.signal(pageAlias, { name: 'popup', popupAlias }); + } +} + +async function findFrameSelector(frame: Frame): Promise { + try { + const parent = frame.parentFrame(); + if (!parent) + return; + return await frame.evaluateExpression(options => { + if (window.frameElement) + return (window as any)['__pw_injectedScript'].generateSelectorSimple(window.frameElement, options); + }, { testIdAttributeName: '', omitInternalEngines: true }); + } catch (e) { + } +} diff --git a/packages/recorder/src/webextension/recorder/recorderApp.ts b/packages/recorder/src/webextension/recorder/recorderApp.ts new file mode 100644 index 0000000000..2b71eac4c1 --- /dev/null +++ b/packages/recorder/src/webextension/recorder/recorderApp.ts @@ -0,0 +1,153 @@ +/** + * Copyright (c) Rui Figueira. + * + * 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 { EventEmitter } from 'events'; +import type { IRecorderApp } from '../../../../playwright-core/src/server/recorder/recorderApp'; +import type { Recorder } from './recorder'; + +export type RecorderMessage = + | { method: 'updateCallLogs', callLogs: CallLog[] } + | { method: 'setPaused', paused: boolean } + | { method: 'setMode', mode: Mode } + | { method: 'setSources', sources: Source[] } + | { method: 'setFileIfNeeded', file: string } + | { method: 'setSelector', selector: string, userGesture?: boolean }; + +export class EmptyRecorderApp extends EventEmitter implements IRecorderApp { + async close(): Promise {} + async setPaused(_paused: boolean): Promise {} + async setMode(_mode: Mode): Promise {} + async setFileIfNeeded(_file: string): Promise {} + async setSelector(_selector: string, _userGesture?: boolean): Promise {} + async updateCallLogs(_callLogs: CallLog[]): Promise {} + async setSources(_sources: Source[]): Promise {} +} + +export class BrowserRecorderApp extends EventEmitter implements IRecorderApp { + + static Events = { + Close: 'close', + }; + + static async open(recorder: Recorder) { + const recorderApp = new BrowserRecorderApp(recorder); + await recorderApp._initialize(); + return recorderApp; + } + + private _recorder: Recorder; + private _windowId?: number; + private _tabId?: number; + _mode: Mode = 'none'; + + private constructor(recorder: Recorder) { + super(); + this._recorder = recorder; + chrome.windows.onRemoved.addListener(window => { + if (this._windowId === window) + this.close(); + }); + chrome.runtime.onMessage.addListener((msg, { tab }) => { + if (!this._tabId || this._tabId !== tab?.id) + return; + this._onMessage(msg); + }); + } + + private async _initialize() { + if (!this._windowId) { + const { id: windowId, tabs } = await chrome.windows.create({ type: 'popup', url: 'index.html' }); + this._windowId = windowId; + this._tabId = tabs?.[0]?.id; + await new Promise(resolve => { + const onUpdated = (tabId: number, { status }: chrome.tabs.TabChangeInfo) => { + if (this._tabId !== tabId || status !== 'complete') + return; + chrome.tabs.onUpdated.removeListener(onUpdated); + resolve(tabId); + }; + chrome.tabs.onUpdated.addListener(onUpdated); + }); + } + + const mode = this._recorder.mode(); + + // set in recorder + this._onMessage({ event: 'clear', params: {} }); + this._onMessage({ event: 'fileChanged', params: { file: 'javascript' } }); + this._onMessage({ event: 'setMode', params: { mode } }); + + // set in UI + this.setMode(mode); + } + + async close() { + const windowId = this._windowId; + if (!windowId) + return; + + this._windowId = undefined; + this._tabId = undefined; + + this._recorder.setMode('none'); + await chrome.windows.remove(windowId).catch(() => {}); + this.emit(BrowserRecorderApp.Events.Close); + } + + async setPaused(paused: boolean) { + await this._sendMessage({ method: 'setPaused', paused }); + } + + async setMode(mode: Mode) { + await this._sendMessage({ method: 'setMode', mode }); + } + + async setFileIfNeeded(file: string) { + await this._sendMessage({ method: 'setFileIfNeeded', file }); + } + + async setSelector(selector: string, userGesture?: boolean) { + if (userGesture) { + if (this._recorder.mode() === 'inspecting') { + this._recorder.setMode('standby'); + if (this._windowId) + chrome.windows.update(this._windowId, { focused: true, drawAttention: true }); + } else { + this._recorder.setMode('recording'); + } + } + await this._sendMessage({ method: 'setSelector', selector, userGesture }); + } + + async updateCallLogs(callLogs: CallLog[]) { + await this._sendMessage({ method: 'updateCallLogs', callLogs }); + } + + async setSources(sources: Source[]) { + await this._sendMessage({ method: 'setSources', sources }); + } + + private _onMessage({ event, params }: any) { + this.emit('event', { event, params }); + } + + private async _sendMessage(msg: RecorderMessage) { + if (!this._tabId) + return; + return await chrome.tabs.sendMessage(this._tabId, { ...msg }).catch(() => {}); + } +} diff --git a/packages/recorder/src/webextension/utils.ts b/packages/recorder/src/webextension/utils.ts new file mode 100644 index 0000000000..07ff135a61 --- /dev/null +++ b/packages/recorder/src/webextension/utils.ts @@ -0,0 +1,57 @@ +/** + * 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. + */ + +export type RegisteredListener = { + emitter: chrome.events.Event; + handler: (...args: any[]) => void; +}; + +class EventsHelper { + static addEventListener any>(emitter: chrome.events.Event, handler: T) { + emitter.addListener(handler); + return { emitter, handler }; + } + + static removeEventListeners any>(listeners: { emitter: chrome.events.Event; handler: T}[]) { + for (const { emitter, handler } of listeners) + emitter.removeListener(handler); + + } +} + +export const eventsHelper = EventsHelper; + +const initialTime = Date.now(); + +export function monotonicTime(): number { + return Date.now() - initialTime; +} + +export async function raceAgainstDeadline(cb: () => Promise, deadline: number): Promise<{ result: T, timedOut: false } | { timedOut: true }> { + let timer: NodeJS.Timeout | undefined; + return Promise.race([ + cb().then(result => { + return { result, timedOut: false }; + }), + new Promise<{ timedOut: true }>(resolve => { + const kMaxDeadline = 2147483647; // 2^31-1 + const timeout = (deadline || kMaxDeadline) - monotonicTime(); + timer = setTimeout(() => resolve({ timedOut: true }), timeout); + }), + ]).finally(() => { + clearTimeout(timer); + }); +} diff --git a/packages/recorder/tsconfig.json b/packages/recorder/tsconfig.json index 10f4bfcf2f..ff12d7c905 100644 --- a/packages/recorder/tsconfig.json +++ b/packages/recorder/tsconfig.json @@ -19,6 +19,8 @@ "paths": { "@isomorphic/*": ["../playwright-core/src/utils/isomorphic/*"], "@protocol/*": ["../protocol/src/*"], + "@recorder/*": ["./src/*"], + "@trace/*": ["../trace/src/*"], "@web/*": ["../web/src/*"], } }, diff --git a/packages/recorder/utils/generate_injected.js b/packages/recorder/utils/generate_injected.js new file mode 100644 index 0000000000..3e3392c5c8 --- /dev/null +++ b/packages/recorder/utils/generate_injected.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * Copyright 2019 Google Inc. All rights reserved. + * Modifications 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. + */ + +// @ts-check + +const fs = require('fs'); +const path = require('path'); +const ROOT = path.join(__dirname, '..', '..', '..'); +const esbuild = require('esbuild'); + +/** + * @type {[string, string][]} + */ +const injectedScripts = [ + [ + path.join(ROOT, 'packages', 'recorder', 'src', 'webextension', 'injected', 'recorder.ts'), + path.join(ROOT, 'packages', 'playwright-core', 'lib', 'vite', 'recorder', 'webextension', 'injected'), + ], +]; + +const inlineCSSPlugin = { + name: 'inlineCSSPlugin', + setup(build) { + build.onLoad({ filter: /\.css$/ }, async (args) => { + const f = await fs.promises.readFile(args.path) + const css = await esbuild.transform(f, { loader: 'css', minify: true }); + return { loader: 'text', contents: css.code }; + }); + }, +}; + +(async () => { + for (const [injected, outdir] of injectedScripts) { + const buildOutput = await esbuild.build({ + entryPoints: [injected], + bundle: true, + outdir, + format: 'cjs', + platform: 'browser', + target: 'ES2019', + plugins: [inlineCSSPlugin], + }); + for (const message of [...buildOutput.errors, ...buildOutput.warnings]) + console.log(message.text); + const baseName = path.basename(injected); + const outFileJs = path.join(outdir, baseName.replace('.ts', '.js')); + let content = await fs.promises.readFile(outFileJs, 'utf-8'); + // to ensure window is not poluted + content = `(() => { + ${content} + })()`; + await fs.promises.writeFile(outFileJs, content, 'utf-8'); + } +})(); diff --git a/packages/recorder/vite.config.ts b/packages/recorder/vite.config.ts index a72a43f203..1ad32eb136 100644 --- a/packages/recorder/vite.config.ts +++ b/packages/recorder/vite.config.ts @@ -27,7 +27,10 @@ export default defineConfig({ alias: { '@isomorphic': path.resolve(__dirname, '../playwright-core/src/utils/isomorphic'), '@protocol': path.resolve(__dirname, '../protocol/src'), + '@recorder/*': path.resolve(__dirname, './src'), + '@trace': path.resolve(__dirname, '../trace/src'), '@web': path.resolve(__dirname, '../web/src'), + 'events': path.resolve(__dirname, '../../node_modules/events'), }, }, build: { @@ -36,6 +39,13 @@ export default defineConfig({ rollupOptions: { output: { manualChunks: undefined, + entryFileNames: '[name].js', + chunkFileNames: '[name].js', + assetFileNames: '[name].[ext]', + }, + input: { + 'index': path.resolve(__dirname, 'index.html'), + 'webextension/background': path.resolve(__dirname, 'src/webextension/background.ts'), }, }, }