recorder webextension

PoC of chromium extension and firefox addon
This commit is contained in:
Rui Figueira 2024-04-07 23:07:13 +01:00
parent e3e5355876
commit 75f8f96ba2
24 changed files with 1230 additions and 27 deletions

47
package-lock.json generated
View file

@ -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"

View file

@ -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());

View file

@ -1,9 +0,0 @@
[*]
../
../isomorphic/**
../registry/**
../../common/
../../protocol/
../../utils/**
../../utilsBundle.ts
../../zipBundle.ts

View file

@ -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"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View 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>"]
}

View file

@ -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[]>([]);

View file

@ -0,0 +1,4 @@
[*]
@web/**
@isomorphic/**
recorder/

View 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);

View 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);
}
}

View 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;
}
}

View file

@ -0,0 +1,2 @@
[*]
../../../../playwright-core/src/server/injected/**

View 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;
}

View 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;
}
}

View file

@ -0,0 +1,7 @@
[*]
../
../../recorderTypes
@web/**
@isomorphic/**
../../../../playwright-core/src/server/recorder/**
../../../../playwright-core/src/utils/**

View 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) {
}
}

View 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(() => {});
}
}

View 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);
});
}

View file

@ -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/*"],
} }
}, },

View 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');
}
})();

View file

@ -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'),
}, },
}, },
} }