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"
|
"@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": {
|
"node_modules/@types/codemirror": {
|
||||||
"version": "5.60.15",
|
"version": "5.60.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
|
||||||
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw=="
|
"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": {
|
"node_modules/@types/formidable": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-2.0.6.tgz",
|
||||||
|
|
@ -1815,6 +1840,12 @@
|
||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
|
|
@ -4066,6 +4097,14 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/extract-zip": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz",
|
||||||
|
|
@ -8440,7 +8479,13 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/recorder": {
|
"packages/recorder": {
|
||||||
"version": "0.0.0"
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"events": "^3.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/chrome": "^0.0.266"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"packages/trace-viewer": {
|
"packages/trace-viewer": {
|
||||||
"version": "0.0.0"
|
"version": "0.0.0"
|
||||||
|
|
|
||||||
|
|
@ -208,11 +208,11 @@ class RecordActionTool implements RecorderTool {
|
||||||
private _hoveredElement: HTMLElement | null = null;
|
private _hoveredElement: HTMLElement | null = null;
|
||||||
private _activeModel: HighlightModel | null = null;
|
private _activeModel: HighlightModel | null = null;
|
||||||
private _expectProgrammaticKeyUp = false;
|
private _expectProgrammaticKeyUp = false;
|
||||||
private _performActions: boolean;
|
private _delegatePerform: boolean;
|
||||||
|
|
||||||
constructor(recorder: Recorder, performActions: boolean) {
|
constructor(recorder: Recorder, performActions: boolean) {
|
||||||
this._recorder = recorder;
|
this._recorder = recorder;
|
||||||
this._performActions = performActions;
|
this._delegatePerform = performActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor() {
|
cursor() {
|
||||||
|
|
@ -263,21 +263,21 @@ class RecordActionTool implements RecorderTool {
|
||||||
onPointerDown(event: PointerEvent) {
|
onPointerDown(event: PointerEvent) {
|
||||||
if (this._shouldIgnoreMouseEvent(event))
|
if (this._shouldIgnoreMouseEvent(event))
|
||||||
return;
|
return;
|
||||||
if (this._performActions && !this._performingAction)
|
if (this._delegatePerform && !this._performingAction)
|
||||||
consumeEvent(event);
|
consumeEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
onPointerUp(event: PointerEvent) {
|
onPointerUp(event: PointerEvent) {
|
||||||
if (this._shouldIgnoreMouseEvent(event))
|
if (this._shouldIgnoreMouseEvent(event))
|
||||||
return;
|
return;
|
||||||
if (this._performActions && !this._performingAction)
|
if (this._delegatePerform && !this._performingAction)
|
||||||
consumeEvent(event);
|
consumeEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
onMouseDown(event: MouseEvent) {
|
onMouseDown(event: MouseEvent) {
|
||||||
if (this._shouldIgnoreMouseEvent(event))
|
if (this._shouldIgnoreMouseEvent(event))
|
||||||
return;
|
return;
|
||||||
if (this._performActions && !this._performingAction)
|
if (this._delegatePerform && !this._performingAction)
|
||||||
consumeEvent(event);
|
consumeEvent(event);
|
||||||
this._activeModel = this._hoveredModel;
|
this._activeModel = this._hoveredModel;
|
||||||
}
|
}
|
||||||
|
|
@ -285,7 +285,7 @@ class RecordActionTool implements RecorderTool {
|
||||||
onMouseUp(event: MouseEvent) {
|
onMouseUp(event: MouseEvent) {
|
||||||
if (this._shouldIgnoreMouseEvent(event))
|
if (this._shouldIgnoreMouseEvent(event))
|
||||||
return;
|
return;
|
||||||
if (this._performActions && !this._performingAction)
|
if (this._delegatePerform && !this._performingAction)
|
||||||
consumeEvent(event);
|
consumeEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -400,7 +400,7 @@ class RecordActionTool implements RecorderTool {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Only allow programmatic keyups, ignore user input.
|
// Only allow programmatic keyups, ignore user input.
|
||||||
if (this._performActions && !this._expectProgrammaticKeyUp) {
|
if (this._delegatePerform && !this._expectProgrammaticKeyUp) {
|
||||||
consumeEvent(event);
|
consumeEvent(event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -441,7 +441,7 @@ class RecordActionTool implements RecorderTool {
|
||||||
if (this._performingAction)
|
if (this._performingAction)
|
||||||
return true;
|
return true;
|
||||||
// Consume as the first thing.
|
// Consume as the first thing.
|
||||||
if (this._performActions)
|
if (this._delegatePerform)
|
||||||
consumeEvent(event);
|
consumeEvent(event);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -449,7 +449,7 @@ class RecordActionTool implements RecorderTool {
|
||||||
private _consumedDueToNoModel(event: Event, model: HighlightModel | null): boolean {
|
private _consumedDueToNoModel(event: Event, model: HighlightModel | null): boolean {
|
||||||
if (model)
|
if (model)
|
||||||
return false;
|
return false;
|
||||||
if (this._performActions)
|
if (this._delegatePerform)
|
||||||
consumeEvent(event);
|
consumeEvent(event);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -457,13 +457,14 @@ class RecordActionTool implements RecorderTool {
|
||||||
private _consumedDueWrongTarget(event: Event): boolean {
|
private _consumedDueWrongTarget(event: Event): boolean {
|
||||||
if (this._activeModel && this._activeModel.elements[0] === this._recorder.deepEventTarget(event))
|
if (this._activeModel && this._activeModel.elements[0] === this._recorder.deepEventTarget(event))
|
||||||
return false;
|
return false;
|
||||||
if (this._performActions)
|
if (this._delegatePerform)
|
||||||
consumeEvent(event);
|
consumeEvent(event);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _performAction(action: actions.Action) {
|
private async _performAction(action: actions.Action) {
|
||||||
if (!this._performActions) {
|
if (!this._delegatePerform) {
|
||||||
|
// just record the action
|
||||||
await this._recorder.delegate.recordAction?.(action);
|
await this._recorder.delegate.recordAction?.(action);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -957,8 +958,7 @@ export class Recorder {
|
||||||
readonly document: Document;
|
readonly document: Document;
|
||||||
delegate: RecorderDelegate = {};
|
delegate: RecorderDelegate = {};
|
||||||
|
|
||||||
constructor(injectedScript: InjectedScript, performActions?: boolean) {
|
constructor(injectedScript: InjectedScript, delegatePerform: boolean = true) {
|
||||||
performActions = performActions === undefined || performActions === null ? true : performActions;
|
|
||||||
this.document = injectedScript.document;
|
this.document = injectedScript.document;
|
||||||
this.injectedScript = injectedScript;
|
this.injectedScript = injectedScript;
|
||||||
this.highlight = injectedScript.createHighlight();
|
this.highlight = injectedScript.createHighlight();
|
||||||
|
|
@ -966,7 +966,7 @@ export class Recorder {
|
||||||
'none': new NoneTool(),
|
'none': new NoneTool(),
|
||||||
'standby': new NoneTool(),
|
'standby': new NoneTool(),
|
||||||
'inspecting': new InspectTool(this, false),
|
'inspecting': new InspectTool(this, false),
|
||||||
'recording': new RecordActionTool(this, performActions),
|
'recording': new RecordActionTool(this, delegatePerform),
|
||||||
'recording-inspecting': new InspectTool(this, false),
|
'recording-inspecting': new InspectTool(this, false),
|
||||||
'assertingText': new TextAssertionTool(this, 'text'),
|
'assertingText': new TextAssertionTool(this, 'text'),
|
||||||
'assertingVisibility': new InspectTool(this, true),
|
'assertingVisibility': new InspectTool(this, true),
|
||||||
|
|
@ -1303,8 +1303,8 @@ export class PollingRecorder implements RecorderDelegate {
|
||||||
private _embedder: Embedder;
|
private _embedder: Embedder;
|
||||||
private _pollRecorderModeTimer: NodeJS.Timeout | undefined;
|
private _pollRecorderModeTimer: NodeJS.Timeout | undefined;
|
||||||
|
|
||||||
constructor(injectedScript: InjectedScript, performActions?: boolean) {
|
constructor(injectedScript: InjectedScript, delegatePerform?: boolean) {
|
||||||
this._recorder = new Recorder(injectedScript, performActions);
|
this._recorder = new Recorder(injectedScript, delegatePerform);
|
||||||
this._embedder = injectedScript.window as any;
|
this._embedder = injectedScript.window as any;
|
||||||
|
|
||||||
injectedScript.onGlobalListenersRemoved.add(() => this._recorder.installListeners());
|
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",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build && tsc",
|
"build": "vite build && tsc && node ./utils/generate_injected.js",
|
||||||
"preview": "vite preview"
|
"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 } from './recorder';
|
||||||
import './recorder.css';
|
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 = ({
|
export const Main: React.FC = ({
|
||||||
}) => {
|
}) => {
|
||||||
const [sources, setSources] = React.useState<Source[]>([]);
|
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": {
|
"paths": {
|
||||||
"@isomorphic/*": ["../playwright-core/src/utils/isomorphic/*"],
|
"@isomorphic/*": ["../playwright-core/src/utils/isomorphic/*"],
|
||||||
"@protocol/*": ["../protocol/src/*"],
|
"@protocol/*": ["../protocol/src/*"],
|
||||||
|
"@recorder/*": ["./src/*"],
|
||||||
|
"@trace/*": ["../trace/src/*"],
|
||||||
"@web/*": ["../web/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: {
|
alias: {
|
||||||
'@isomorphic': path.resolve(__dirname, '../playwright-core/src/utils/isomorphic'),
|
'@isomorphic': path.resolve(__dirname, '../playwright-core/src/utils/isomorphic'),
|
||||||
'@protocol': path.resolve(__dirname, '../protocol/src'),
|
'@protocol': path.resolve(__dirname, '../protocol/src'),
|
||||||
|
'@recorder/*': path.resolve(__dirname, './src'),
|
||||||
|
'@trace': path.resolve(__dirname, '../trace/src'),
|
||||||
'@web': path.resolve(__dirname, '../web/src'),
|
'@web': path.resolve(__dirname, '../web/src'),
|
||||||
|
'events': path.resolve(__dirname, '../../node_modules/events'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|
@ -36,6 +39,13 @@ export default defineConfig({
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks: undefined,
|
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