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 0000000000..1f6c4d6c1e Binary files /dev/null and b/packages/recorder/public/icon-16x16.png differ diff --git a/packages/recorder/public/icon-192x192.png b/packages/recorder/public/icon-192x192.png new file mode 100644 index 0000000000..a4f0dbfa96 Binary files /dev/null and b/packages/recorder/public/icon-192x192.png differ diff --git a/packages/recorder/public/icon-32x32.png b/packages/recorder/public/icon-32x32.png new file mode 100644 index 0000000000..650be13340 Binary files /dev/null and b/packages/recorder/public/icon-32x32.png differ diff --git a/packages/recorder/public/icon-48x48.png b/packages/recorder/public/icon-48x48.png new file mode 100644 index 0000000000..2abbb660e1 Binary files /dev/null and b/packages/recorder/public/icon-48x48.png differ diff --git a/packages/recorder/public/manifest.json b/packages/recorder/public/manifest.json new file mode 100644 index 0000000000..5b57d3368a --- /dev/null +++ b/packages/recorder/public/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "Playwright", + "version": "0.0.1", + "manifest_version": 3, + "icons": { + "16": "icon-16x16.png", + "32": "icon-32x32.png", + "48": "icon-48x48.png", + "128": "icon-192x192.png" + }, + "background": { + "service_worker": "webextension/background.js", + "type": "module", + "scripts": ["webextension/background.js"] + }, + "action": { + "default_icon": { + "16": "icon-16x16.png", + "32": "icon-32x32.png" + }, + "default_title": "Record" + }, + "permissions": ["tabs", "scripting", "webNavigation"], + "host_permissions": [""] +} 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'), }, }, }