recorder webextension
PoC of chromium extension and firefox addon
This commit is contained in:
parent
e3e5355876
commit
75f8f96ba2
47
package-lock.json
generated
47
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
[*]
|
||||
../
|
||||
../isomorphic/**
|
||||
../registry/**
|
||||
../../common/
|
||||
../../protocol/
|
||||
../../utils/**
|
||||
../../utilsBundle.ts
|
||||
../../zipBundle.ts
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
packages/recorder/public/icon-16x16.png
Normal file
BIN
packages/recorder/public/icon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 593 B |
BIN
packages/recorder/public/icon-192x192.png
Normal file
BIN
packages/recorder/public/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
packages/recorder/public/icon-32x32.png
Normal file
BIN
packages/recorder/public/icon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
packages/recorder/public/icon-48x48.png
Normal file
BIN
packages/recorder/public/icon-48x48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
25
packages/recorder/public/manifest.json
Normal file
25
packages/recorder/public/manifest.json
Normal file
|
|
@ -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": ["<all_urls>"]
|
||||
}
|
||||
|
|
@ -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<Source[]>([]);
|
||||
|
|
|
|||
4
packages/recorder/src/webextension/DEPS.list
Normal file
4
packages/recorder/src/webextension/DEPS.list
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[*]
|
||||
@web/**
|
||||
@isomorphic/**
|
||||
recorder/
|
||||
53
packages/recorder/src/webextension/background.ts
Normal file
53
packages/recorder/src/webextension/background.ts
Normal file
|
|
@ -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);
|
||||
126
packages/recorder/src/webextension/browserContext.ts
Normal file
126
packages/recorder/src/webextension/browserContext.ts
Normal file
|
|
@ -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<string, FunctionWithSource> = new Map();
|
||||
private _pages: Map<number, Page> = 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);
|
||||
}
|
||||
}
|
||||
60
packages/recorder/src/webextension/frame.ts
Normal file
60
packages/recorder/src/webextension/frame.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
2
packages/recorder/src/webextension/injected/DEPS.list
Normal file
2
packages/recorder/src/webextension/injected/DEPS.list
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[*]
|
||||
../../../../playwright-core/src/server/injected/**
|
||||
46
packages/recorder/src/webextension/injected/recorder.ts
Normal file
46
packages/recorder/src/webextension/injected/recorder.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
74
packages/recorder/src/webextension/page.ts
Normal file
74
packages/recorder/src/webextension/page.ts
Normal file
|
|
@ -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<number, Frame> = 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;
|
||||
}
|
||||
}
|
||||
7
packages/recorder/src/webextension/recorder/DEPS.list
Normal file
7
packages/recorder/src/webextension/recorder/DEPS.list
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[*]
|
||||
../
|
||||
../../recorderTypes
|
||||
@web/**
|
||||
@isomorphic/**
|
||||
../../../../playwright-core/src/server/recorder/**
|
||||
../../../../playwright-core/src/utils/**
|
||||
455
packages/recorder/src/webextension/recorder/recorder.ts
Normal file
455
packages/recorder/src/webextension/recorder/recorder.ts
Normal file
|
|
@ -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<string, Source>();
|
||||
private _contextRecorder: ContextRecorder;
|
||||
private _currentLanguage: Language;
|
||||
|
||||
private static recorderAppFactory: ((recorder: Recorder) => Promise<IRecorderApp>) | undefined;
|
||||
|
||||
static setAppFactory(recorderAppFactory: ((recorder: Recorder) => Promise<IRecorderApp>) | 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<Recorder> {
|
||||
let recorderPromise = (context as any)[recorderSymbol] as Promise<Recorder>;
|
||||
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<string | undefined>[] = [];
|
||||
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<Page, string>();
|
||||
private _lastPopupOrdinal = 0;
|
||||
private _timers = new Set<NodeJS.Timeout>();
|
||||
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<actions.FrameDescription> {
|
||||
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<string | undefined>[] = [];
|
||||
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<string | undefined> {
|
||||
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) {
|
||||
}
|
||||
}
|
||||
153
packages/recorder/src/webextension/recorder/recorderApp.ts
Normal file
153
packages/recorder/src/webextension/recorder/recorderApp.ts
Normal file
|
|
@ -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<void> {}
|
||||
async setPaused(_paused: boolean): Promise<void> {}
|
||||
async setMode(_mode: Mode): Promise<void> {}
|
||||
async setFileIfNeeded(_file: string): Promise<void> {}
|
||||
async setSelector(_selector: string, _userGesture?: boolean): Promise<void> {}
|
||||
async updateCallLogs(_callLogs: CallLog[]): Promise<void> {}
|
||||
async setSources(_sources: Source[]): Promise<void> {}
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
57
packages/recorder/src/webextension/utils.ts
Normal file
57
packages/recorder/src/webextension/utils.ts
Normal file
|
|
@ -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<any>;
|
||||
handler: (...args: any[]) => void;
|
||||
};
|
||||
|
||||
class EventsHelper {
|
||||
static addEventListener<T extends(...args: any[]) => any>(emitter: chrome.events.Event<T>, handler: T) {
|
||||
emitter.addListener(handler);
|
||||
return { emitter, handler };
|
||||
}
|
||||
|
||||
static removeEventListeners<T extends(...args: any[]) => any>(listeners: { emitter: chrome.events.Event<T>; 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<T>(cb: () => Promise<T>, 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@
|
|||
"paths": {
|
||||
"@isomorphic/*": ["../playwright-core/src/utils/isomorphic/*"],
|
||||
"@protocol/*": ["../protocol/src/*"],
|
||||
"@recorder/*": ["./src/*"],
|
||||
"@trace/*": ["../trace/src/*"],
|
||||
"@web/*": ["../web/src/*"],
|
||||
}
|
||||
},
|
||||
|
|
|
|||
69
packages/recorder/utils/generate_injected.js
Normal file
69
packages/recorder/utils/generate_injected.js
Normal file
|
|
@ -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');
|
||||
}
|
||||
})();
|
||||
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue