mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(recorder): remove recorder overlay toolbar (#5334)
This commit is contained in:
parent
9c0609b0ec
commit
c0610ccef4
@ -17,8 +17,12 @@
|
||||
import type * as actions from '../recorder/recorderActions';
|
||||
import type InjectedScript from '../../injected/injectedScript';
|
||||
import { generateSelector } from './selectorGenerator';
|
||||
import { Element$, html } from './html';
|
||||
import type { State, SetUIState } from '../recorder/state';
|
||||
import { html } from './html';
|
||||
|
||||
type Mode = 'inspecting' | 'recording' | 'none';
|
||||
type State = {
|
||||
mode: Mode,
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@ -26,8 +30,6 @@ declare global {
|
||||
_playwrightRecorderRecordAction: (action: actions.Action) => Promise<void>;
|
||||
_playwrightRecorderCommitAction: () => Promise<void>;
|
||||
_playwrightRecorderState: () => Promise<State>;
|
||||
_playwrightRecorderSetUIState: (state: SetUIState) => Promise<void>;
|
||||
_playwrightRecorderShowRecorderPage: () => Promise<void>;
|
||||
_playwrightRecorderPrintSelector: (text: string) => Promise<void>;
|
||||
_playwrightResume: () => Promise<void>;
|
||||
}
|
||||
@ -49,14 +51,7 @@ export class Recorder {
|
||||
private _activeModel: HighlightModel | null = null;
|
||||
private _expectProgrammaticKeyUp = false;
|
||||
private _pollRecorderModeTimer: NodeJS.Timeout | undefined;
|
||||
private _outerToolbarElement: HTMLElement;
|
||||
private _toolbar: Element$;
|
||||
private _state: State = {
|
||||
uiState: {
|
||||
mode: 'none',
|
||||
},
|
||||
isPaused: false
|
||||
};
|
||||
private _mode: 'none' | 'inspecting' | 'recording' = 'none';
|
||||
|
||||
constructor(injectedScript: InjectedScript) {
|
||||
this._injectedScript = injectedScript;
|
||||
@ -110,101 +105,18 @@ export class Recorder {
|
||||
}
|
||||
</style>
|
||||
`);
|
||||
|
||||
this._toolbar = html`
|
||||
<x-pw-toolbar class="vertical">
|
||||
${commonStyles()}
|
||||
<x-pw-button-group>
|
||||
<x-pw-button id="pw-button-playwright" tabIndex=0 title="Playwright">
|
||||
<x-pw-icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" fill="none"><path d="M136 222c-12 3-21 10-26 16 5-5 12-9 22-12 10-2 18-2 25-1v-6c-6 0-13 0-21 3zm-27-46l-48 12 3 3 40-10s0 7-5 14c9-7 10-19 10-19zm40 112C82 306 46 228 35 188a227 227 0 01-7-45c-4 1-6 2-5 8 0 9 2 23 7 42 11 40 47 118 114 100 15-4 26-11 34-20-7 7-17 12-29 15zm13-160v5h26l-2-5h-24z" fill="#2D4552"/><path d="M194 168c12 3 18 11 21 19l14 3s-2-25-25-32c-22-6-36 12-37 14 6-4 15-8 27-4zm105 19c-21-6-35 12-36 14 6-4 15-8 27-5 12 4 18 12 21 19l14 4s-2-26-26-32zm-13 68l-110-31s1 6 6 14l93 26 11-9zm-76 66c-87-23-77-134-63-187 6-22 12-38 17-49-3 0-5 1-8 6-5 11-12 28-18 52-14 53-25 164 62 188 41 11 73-6 97-32a90 90 0 01-87 22z" fill="#2D4552"/><path d="M162 262v-22l-63 18s5-27 37-36c10-3 19-3 26-2v-92h31l-10-24c-4-9-9-3-19 6-8 6-27 19-55 27-29 8-52 6-61 4-14-2-21-5-20 5 0 9 2 23 7 42 11 40 47 118 114 100 18-4 30-14 39-26h-26zM61 188l48-12s-1 18-19 23-29-11-29-11z" fill="#E2574C"/><path d="M342 129c-13 2-43 5-79-5-37-10-62-27-71-35-14-12-20-20-26-8-5 11-12 29-19 53-14 53-24 164 63 187s134-78 148-131c6-24 9-42 10-54 1-14-9-10-26-7zm-176 44s14-22 38-15c23 7 25 32 25 32l-63-17zm57 96c-41-12-47-45-47-45l110 31s-22 26-63 14zm39-68s14-21 37-14c24 6 26 32 26 32l-63-18z" fill="#2EAD33"/><path d="M140 246l-41 12s5-26 35-36l-23-86-2 1c-29 8-52 6-61 4-14-2-21-5-20 5 0 9 2 23 7 42 11 40 47 118 114 100h2l-11-42zm-79-58l48-12s-1 18-19 23-29-11-29-11z" fill="#D65348"/><path d="M225 269h-2c-41-12-47-45-47-45l57 16 30-116c-37-10-62-27-71-35-14-12-20-20-26-8-5 11-12 29-19 53-14 53-24 164 63 187l2 1 13-53zm-59-96s14-22 38-15c23 7 25 32 25 32l-63-17z" fill="#1D8D22"/><path d="M142 245l-11 4c3 14 7 28 14 40l4-1 9-3c-8-12-13-25-16-40zm-4-102c-6 21-11 51-10 81l8-2 2-1a273 273 0 0114-103l-8 5-6 20z" fill="#C04B41"/></svg>
|
||||
</x-pw-icon>
|
||||
</x-pw-button>
|
||||
</x-pw-button-group>
|
||||
<x-pw-button-group>
|
||||
<x-pw-button id="pw-button-inspect" tabIndex=0 title="Inspect selectors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm8.94 3c-.46-4.17-3.77-7.48-7.94-7.94V1h-2v2.06C6.83 3.52 3.52 6.83 3.06 11H1v2h2.06c.46 4.17 3.77 7.48 7.94 7.94V23h2v-2.06c4.17-.46 7.48-3.77 7.94-7.94H23v-2h-2.06zM12 19c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z"/></svg>
|
||||
</x-pw-button>
|
||||
<x-pw-button id="pw-button-record" class="record" tabIndex=0 title="Record script">
|
||||
<div class="record-button">
|
||||
<div class="record-button-glow"></div>
|
||||
</div>
|
||||
</x-pw-button>
|
||||
</x-pw-button-group>
|
||||
<x-pw-button-group id="pw-button-resume-group" class="hidden" title="Resume execution">
|
||||
<x-pw-button id="pw-button-resume" tabIndex=0>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></svg>
|
||||
</x-pw-button>
|
||||
</x-pw-button-group>
|
||||
</x-pw-toolbar>`;
|
||||
|
||||
this._outerToolbarElement = html`<x-pw-div style="position: fixed; top: 100px; left: 10px; flex-direction: column; z-index: 2147483646;"></x-pw-div>`;
|
||||
const toolbarShadow = this._outerToolbarElement.attachShadow({ mode: 'open' });
|
||||
toolbarShadow.appendChild(this._toolbar);
|
||||
|
||||
this._hydrate();
|
||||
this._refreshListenersIfNeeded();
|
||||
setInterval(() => {
|
||||
this._refreshListenersIfNeeded();
|
||||
if ((window as any)._recorderScriptReadyForTest)
|
||||
(window as any)._recorderScriptReadyForTest();
|
||||
}, 500);
|
||||
this._pollRecorderMode(true).catch(e => console.log(e)); // eslint-disable-line no-console
|
||||
this._pollRecorderMode().catch(e => console.log(e)); // eslint-disable-line no-console
|
||||
}
|
||||
|
||||
private _hydrate() {
|
||||
this._toolbar.addEventListener('mousedown', e => {
|
||||
if (e.target !== this._toolbar)
|
||||
return;
|
||||
this._outerGlassPaneElement.style.pointerEvents = 'initial';
|
||||
this._outerGlassPaneElement.style.cursor = 'grab';
|
||||
this._outerGlassPaneElement.setAttribute('tabIndex', '0');
|
||||
const offsetLeft = e.pageX - this._outerToolbarElement.offsetLeft;
|
||||
const offsetTop = e.pageY - this._outerToolbarElement.offsetTop;
|
||||
const toolbarWidth = this._outerToolbarElement.offsetWidth;
|
||||
const toolbarHeight = this._outerToolbarElement.offsetHeight;
|
||||
const glassWidth = this._outerGlassPaneElement.offsetWidth;
|
||||
const glassHeight = this._outerGlassPaneElement.offsetHeight;
|
||||
const maxX = glassWidth - toolbarWidth;
|
||||
const maxY = glassHeight - toolbarHeight;
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
this._outerToolbarElement.style.top = Math.min(maxY, Math.max(e.pageY - offsetTop, 0)) + 'px';
|
||||
this._outerToolbarElement.style.left = Math.min(maxX, Math.max(e.pageX - offsetLeft, 0)) + 'px';
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
this._outerGlassPaneElement.removeEventListener('mousemove', onMouseMove);
|
||||
this._outerGlassPaneElement.removeEventListener('mouseup', onMouseUp);
|
||||
this._outerGlassPaneElement.removeEventListener('blur', onMouseUp);
|
||||
this._outerGlassPaneElement.style.pointerEvents = 'none';
|
||||
this._outerGlassPaneElement.style.background = 'initial';
|
||||
this._outerGlassPaneElement.removeAttribute('tabIndex');
|
||||
this._outerGlassPaneElement.style.cursor = 'initial';
|
||||
};
|
||||
this._outerGlassPaneElement.addEventListener('mousemove', onMouseMove);
|
||||
this._outerGlassPaneElement.addEventListener('mouseup', onMouseUp);
|
||||
this._outerGlassPaneElement.addEventListener('blur', onMouseUp);
|
||||
});
|
||||
this._toolbar.$('#pw-button-inspect').addEventListener('click', () => {
|
||||
if (this._toolbar.$('#pw-button-inspect').classList.contains('disabled'))
|
||||
return;
|
||||
this._toolbar.$('#pw-button-inspect').classList.toggle('toggled');
|
||||
this._updateUIState({
|
||||
mode: this._toolbar.$('#pw-button-inspect').classList.contains('toggled') ? 'inspecting' : 'none'
|
||||
});
|
||||
});
|
||||
this._toolbar.$('#pw-button-record').addEventListener('click', () => this._toggleRecording());
|
||||
this._toolbar.$('#pw-button-resume').addEventListener('click', () => {
|
||||
if (this._toolbar.$('#pw-button-resume').classList.contains('disabled'))
|
||||
return;
|
||||
this._updateUIState({ mode: 'none' });
|
||||
window._playwrightResume().catch(() => {});
|
||||
});
|
||||
this._toolbar.$('#pw-button-playwright').addEventListener('click', () => {
|
||||
if (this._toolbar.$('#pw-button-playwright').classList.contains('disabled'))
|
||||
return;
|
||||
this._toolbar.$('#pw-button-playwright').classList.toggle('toggled');
|
||||
window._playwrightRecorderShowRecorderPage().catch(() => {});
|
||||
});
|
||||
private _setMode(mode: Mode): void {
|
||||
this._clearHighlight();
|
||||
this._mode = mode;
|
||||
}
|
||||
|
||||
private _refreshListenersIfNeeded() {
|
||||
@ -228,61 +140,24 @@ export class Recorder {
|
||||
}, true),
|
||||
];
|
||||
document.documentElement.appendChild(this._outerGlassPaneElement);
|
||||
if (window.top === window) {
|
||||
let moveCount = 0;
|
||||
this._listeners.push(addEventListener(document, 'mousedown', e => moveCount = 0));
|
||||
this._listeners.push(addEventListener(document, 'mousemove', e => {
|
||||
++moveCount;
|
||||
if (++moveCount === 10)
|
||||
this._ensureToolbarVisible();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private _ensureToolbarVisible() {
|
||||
if (!this._outerToolbarElement.parentElement)
|
||||
document.documentElement.appendChild(this._outerToolbarElement);
|
||||
}
|
||||
|
||||
private _toggleRecording() {
|
||||
this._toolbar.$('#pw-button-record').classList.toggle('toggled');
|
||||
this._updateUIState({
|
||||
...this._state.uiState,
|
||||
mode: this._toolbar.$('#pw-button-record').classList.contains('toggled') ? 'recording' : 'none',
|
||||
});
|
||||
}
|
||||
|
||||
private async _updateUIState(uiState: SetUIState) {
|
||||
window._playwrightRecorderSetUIState(uiState).then(() => this._pollRecorderMode());
|
||||
}
|
||||
|
||||
private async _pollRecorderMode(skipAnimations: boolean = false) {
|
||||
private async _pollRecorderMode() {
|
||||
const pollPeriod = 250;
|
||||
if (this._pollRecorderModeTimer)
|
||||
clearTimeout(this._pollRecorderModeTimer);
|
||||
const state = await window._playwrightRecorderState().catch(e => null);
|
||||
if (!state) {
|
||||
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), 250);
|
||||
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod);
|
||||
return;
|
||||
}
|
||||
|
||||
const { isPaused, uiState } = state;
|
||||
if (uiState.mode !== this._state.uiState.mode) {
|
||||
this._state.uiState.mode = uiState.mode;
|
||||
this._toolbar.$('#pw-button-inspect').classList.toggle('toggled', uiState.mode === 'inspecting');
|
||||
this._toolbar.$('#pw-button-record').classList.toggle('toggled', uiState.mode === 'recording');
|
||||
this._toolbar.$('#pw-button-resume').classList.toggle('disabled', uiState.mode === 'recording');
|
||||
const { mode } = state;
|
||||
if (mode !== this._mode) {
|
||||
this._mode = mode;
|
||||
this._clearHighlight();
|
||||
}
|
||||
|
||||
if (isPaused !== this._state.isPaused) {
|
||||
this._state.isPaused = isPaused;
|
||||
if (isPaused)
|
||||
this._ensureToolbarVisible();
|
||||
this._toolbar.$('#pw-button-resume-group').classList.toggle('hidden', !isPaused);
|
||||
}
|
||||
|
||||
this._state = state;
|
||||
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), 250);
|
||||
this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod);
|
||||
}
|
||||
|
||||
private _clearHighlight() {
|
||||
@ -315,7 +190,7 @@ export class Recorder {
|
||||
}
|
||||
|
||||
private _onClick(event: MouseEvent) {
|
||||
if (this._state.uiState.mode === 'inspecting' && !this._isInToolbar(event.target as HTMLElement)) {
|
||||
if (this._mode === 'inspecting') {
|
||||
if (this._hoveredModel) {
|
||||
copy(this._hoveredModel.selector);
|
||||
window._playwrightRecorderPrintSelector(this._hoveredModel.selector);
|
||||
@ -349,19 +224,11 @@ export class Recorder {
|
||||
});
|
||||
}
|
||||
|
||||
private _isInToolbar(element: Element | undefined | null): boolean {
|
||||
if (element && element.parentElement && element.parentElement.nodeName.toLowerCase().startsWith('x-pw-'))
|
||||
return true;
|
||||
return !!element && element.nodeName.toLowerCase().startsWith('x-pw-');
|
||||
}
|
||||
|
||||
private _shouldIgnoreMouseEvent(event: MouseEvent): boolean {
|
||||
const target = this._deepEventTarget(event);
|
||||
if (this._isInToolbar(target))
|
||||
if (this._mode === 'none')
|
||||
return true;
|
||||
if (this._state.uiState.mode === 'none')
|
||||
return true;
|
||||
if (this._state.uiState.mode === 'inspecting') {
|
||||
if (this._mode === 'inspecting') {
|
||||
consumeEvent(event);
|
||||
return true;
|
||||
}
|
||||
@ -389,11 +256,9 @@ export class Recorder {
|
||||
}
|
||||
|
||||
private _onMouseMove(event: MouseEvent) {
|
||||
if (this._state.uiState.mode === 'none')
|
||||
if (this._mode === 'none')
|
||||
return;
|
||||
const target = this._deepEventTarget(event);
|
||||
if (this._isInToolbar(target))
|
||||
return;
|
||||
if (this._hoveredElement === target)
|
||||
return;
|
||||
this._hoveredElement = target;
|
||||
@ -479,7 +344,8 @@ export class Recorder {
|
||||
this._highlightElements = [];
|
||||
for (const box of boxes) {
|
||||
const highlightElement = pool.length ? pool.shift()! : this._createHighlightElement();
|
||||
highlightElement.style.backgroundColor = this._highlightElements.length ? '#f6b26b7f' : '#6fa8dc7f';
|
||||
const color = this._mode === 'recording' ? '#dc6f6f7f' : '#6fa8dc7f';
|
||||
highlightElement.style.backgroundColor = this._highlightElements.length ? '#f6b26b7f' : color;
|
||||
highlightElement.style.left = box.x + 'px';
|
||||
highlightElement.style.top = box.y + 'px';
|
||||
highlightElement.style.width = box.width + 'px';
|
||||
@ -509,7 +375,7 @@ export class Recorder {
|
||||
}
|
||||
|
||||
private _onInput(event: Event) {
|
||||
if (this._state.uiState.mode !== 'recording')
|
||||
if (this._mode !== 'recording')
|
||||
return true;
|
||||
const target = this._deepEventTarget(event);
|
||||
if (['INPUT', 'TEXTAREA'].includes(target.nodeName)) {
|
||||
@ -580,11 +446,11 @@ export class Recorder {
|
||||
}
|
||||
|
||||
private _onKeyDown(event: KeyboardEvent) {
|
||||
if (this._state.uiState.mode === 'inspecting') {
|
||||
if (this._mode === 'inspecting') {
|
||||
consumeEvent(event);
|
||||
return;
|
||||
}
|
||||
if (this._state.uiState.mode !== 'recording')
|
||||
if (this._mode !== 'recording')
|
||||
return true;
|
||||
if (!this._shouldGenerateKeyPressFor(event))
|
||||
return;
|
||||
@ -712,111 +578,4 @@ function copy(text: string) {
|
||||
input.remove();
|
||||
}
|
||||
|
||||
function commonStyles() {
|
||||
return html`
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
x-pw-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
fill: #333;
|
||||
flex: none;
|
||||
padding-top: 10px;
|
||||
cursor: grab;
|
||||
}
|
||||
x-pw-toolbar.vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
x-pw-button-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #ffffffe6;
|
||||
padding: 4px;
|
||||
border-radius: 22px;
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 0.25em 0.5em;
|
||||
margin: 4px 0px;
|
||||
}
|
||||
x-pw-toolbar.vertical x-pw-button-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
x-pw-button {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
x-pw-button:hover:not(.disabled) {
|
||||
background-color: #f2f2f2;
|
||||
}
|
||||
x-pw-toolbar.dark x-pw-button {
|
||||
fill: #ccc;
|
||||
}
|
||||
x-pw-toolbar.dark x-pw-button:hover:not(.disabled) {
|
||||
background-color: inherit;
|
||||
}
|
||||
x-pw-toolbar.dark x-pw-button:hover:not(.disabled) {
|
||||
fill: #eee;
|
||||
}
|
||||
x-pw-toolbar.dark x-pw-button:active:not(.disabled) {
|
||||
fill: #fff;
|
||||
}
|
||||
x-pw-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
x-pw-button.toggled {
|
||||
fill: #468fd2;
|
||||
}
|
||||
.record-button {
|
||||
position: relative;
|
||||
background: #333;
|
||||
border-radius: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.record-button-glow {
|
||||
opacity: 0;
|
||||
background: red;
|
||||
border-radius: 9px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -1px;
|
||||
}
|
||||
x-pw-button.record.toggled .record-button {
|
||||
background: red;
|
||||
}
|
||||
x-pw-button.record.toggled .record-button-glow {
|
||||
transition: opacity 0.3s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
x-pw-button.disabled {
|
||||
fill: #777777 !important;
|
||||
cursor: inherit;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
x-pw-button svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
x-pw-icon svg {
|
||||
transform: scale(0.08);
|
||||
margin-left: -182px;
|
||||
margin-top: -182px;
|
||||
}
|
||||
</style>`;
|
||||
}
|
||||
|
||||
export default Recorder;
|
||||
|
||||
@ -27,6 +27,21 @@ import { DEFAULT_ARGS } from '../../chromium/chromium';
|
||||
|
||||
const readFileAsync = util.promisify(fs.readFile);
|
||||
|
||||
export type Mode = 'inspecting' | 'recording' | 'none';
|
||||
export type EventData = {
|
||||
event: 'clear' | 'resume' | 'setMode',
|
||||
params: any
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
playwrightSetMode: (mode: Mode) => void;
|
||||
playwrightSetPaused: (paused: boolean) => void;
|
||||
playwrightSetSource: (params: { text: string, language: string }) => void;
|
||||
dispatch(data: EventData): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
export class RecorderApp extends EventEmitter {
|
||||
private _page: Page;
|
||||
|
||||
@ -36,6 +51,10 @@ export class RecorderApp extends EventEmitter {
|
||||
this._page = page;
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this._page.context().close();
|
||||
}
|
||||
|
||||
private async _init() {
|
||||
const icon = await readFileAsync(require.resolve('../../../../lib/web/recorder/app_icon.png'));
|
||||
const crPopup = this._page._delegate as CRPage;
|
||||
@ -61,9 +80,7 @@ export class RecorderApp extends EventEmitter {
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await this._page.exposeBinding('_playwrightClear', false, (_, text: string) => {
|
||||
this.emit('clear');
|
||||
});
|
||||
await this._page.exposeBinding('dispatch', false, (_, data: any) => this.emit('event', data));
|
||||
|
||||
this._page.once('close', () => {
|
||||
this.emit('close');
|
||||
@ -73,18 +90,16 @@ export class RecorderApp extends EventEmitter {
|
||||
await this._page.mainFrame().goto(new ProgressController(), 'https://playwright/index.html');
|
||||
}
|
||||
|
||||
static async open(inspectedPage: Page): Promise<RecorderApp> {
|
||||
const bounds = await CRPage.mainFrameSession(inspectedPage).windowBounds();
|
||||
static async open(): Promise<RecorderApp> {
|
||||
const recorderPlaywright = createPlaywright(true);
|
||||
const context = await recorderPlaywright.chromium.launchPersistentContext('', {
|
||||
ignoreAllDefaultArgs: true,
|
||||
args: [
|
||||
...DEFAULT_ARGS,
|
||||
`--user-data-dir=${path.join(os.homedir(),'.playwright-recorder')}`,
|
||||
`--user-data-dir=${path.join(os.homedir(),'.playwright-app')}`,
|
||||
'--remote-debugging-pipe',
|
||||
'--app=data:text/html,',
|
||||
`--window-size=300,${bounds.height}`,
|
||||
`--window-position=${bounds.left! + bounds.width! + 1},${bounds.top!}`
|
||||
`--window-size=300,800`,
|
||||
],
|
||||
noDefaultViewport: true
|
||||
});
|
||||
@ -97,14 +112,25 @@ export class RecorderApp extends EventEmitter {
|
||||
const [page] = context.pages();
|
||||
const result = new RecorderApp(page);
|
||||
await result._init();
|
||||
await inspectedPage.bringToFront();
|
||||
return result;
|
||||
}
|
||||
|
||||
async setScript(text: string, language: string): Promise<void> {
|
||||
async setMode(mode: 'none' | 'recording' | 'inspecting'): Promise<void> {
|
||||
await this._page.mainFrame()._evaluateExpression(((mode: Mode) => {
|
||||
window.playwrightSetMode(mode);
|
||||
}).toString(), true, mode, 'main').catch(() => {});
|
||||
}
|
||||
|
||||
async setPaused(paused: boolean): Promise<void> {
|
||||
await this._page.mainFrame()._evaluateExpression(((paused: boolean) => {
|
||||
window.playwrightSetPaused(paused);
|
||||
}).toString(), true, paused, 'main').catch(() => {});
|
||||
}
|
||||
|
||||
async setSource(text: string, language: string): Promise<void> {
|
||||
await this._page.mainFrame()._evaluateExpression(((param: { text: string, language: string }) => {
|
||||
(window as any)._playwrightSetSource(param);
|
||||
}).toString(), true, { text, language }, 'main');
|
||||
window.playwrightSetSource(param);
|
||||
}).toString(), true, { text, language }, 'main').catch(() => {});
|
||||
}
|
||||
|
||||
async bringToFront() {
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
/**
|
||||
* 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 UIState = {
|
||||
mode: 'inspecting' | 'recording' | 'none',
|
||||
}
|
||||
|
||||
export type SetUIState = {
|
||||
mode?: 'inspecting' | 'recording' | 'none',
|
||||
}
|
||||
|
||||
export type State = {
|
||||
isPaused: boolean,
|
||||
uiState: UIState,
|
||||
}
|
||||
@ -29,11 +29,9 @@ import { ProgressController } from '../progress';
|
||||
import * as recorderSource from '../../generated/recorderSource';
|
||||
import * as consoleApiSource from '../../generated/consoleApiSource';
|
||||
import { BufferedOutput, FileOutput, FlushingTerminalOutput, OutputMultiplexer, RecorderOutput, TerminalOutput, Writable } from './recorder/outputs';
|
||||
import type { State, UIState } from './recorder/state';
|
||||
import { RecorderApp } from './recorder/recorderApp';
|
||||
import { EventData, Mode, RecorderApp } from './recorder/recorderApp';
|
||||
|
||||
type BindingSource = { frame: Frame, page: Page };
|
||||
type App = 'codegen' | 'debug' | 'pause';
|
||||
|
||||
const symbol = Symbol('RecorderSupplement');
|
||||
|
||||
@ -45,11 +43,11 @@ export class RecorderSupplement {
|
||||
private _timers = new Set<NodeJS.Timeout>();
|
||||
private _context: BrowserContext;
|
||||
private _resumeCallback: (() => void) | null = null;
|
||||
private _recorderUIState: UIState;
|
||||
private _mode: Mode;
|
||||
private _paused = false;
|
||||
private _output: OutputMultiplexer;
|
||||
private _bufferedOutput: BufferedOutput;
|
||||
private _recorderApp: Promise<RecorderApp> | null = null;
|
||||
private _recorderApp: RecorderApp | null = null;
|
||||
private _highlighterType: string;
|
||||
private _params: channels.BrowserContextRecorderSupplementEnableParams;
|
||||
|
||||
@ -66,9 +64,7 @@ export class RecorderSupplement {
|
||||
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
|
||||
this._context = context;
|
||||
this._params = params;
|
||||
this._recorderUIState = {
|
||||
mode: params.startRecording ? 'recording' : 'none',
|
||||
};
|
||||
this._mode = params.startRecording ? 'recording' : 'none';
|
||||
let languageGenerator: LanguageGenerator;
|
||||
switch (params.language) {
|
||||
case 'javascript': languageGenerator = new JavaScriptLanguageGenerator(); break;
|
||||
@ -88,10 +84,8 @@ export class RecorderSupplement {
|
||||
const outputs: RecorderOutput[] = [params.terminal ? new TerminalOutput(writable, highlighterType) : new FlushingTerminalOutput(writable)];
|
||||
this._highlighterType = highlighterType;
|
||||
this._bufferedOutput = new BufferedOutput(async text => {
|
||||
if (this._recorderApp) {
|
||||
const app = await this._recorderApp;
|
||||
await app.setScript(text, highlighterType).catch(e => {});
|
||||
}
|
||||
if (this._recorderApp)
|
||||
this._recorderApp.setSource(text, highlighterType);
|
||||
});
|
||||
outputs.push(this._bufferedOutput);
|
||||
if (params.outputFile)
|
||||
@ -105,14 +99,45 @@ export class RecorderSupplement {
|
||||
}
|
||||
|
||||
async install() {
|
||||
this._context.on('page', page => this._onPage(page));
|
||||
const recorderApp = await RecorderApp.open();
|
||||
this._recorderApp = recorderApp;
|
||||
recorderApp.once('close', () => {
|
||||
this._recorderApp = null;
|
||||
});
|
||||
recorderApp.on('event', (data: EventData) => {
|
||||
if (data.event === 'setMode') {
|
||||
this._mode = data.params.mode;
|
||||
recorderApp.setMode(this._mode);
|
||||
this._output.setEnabled(this._mode === 'recording');
|
||||
if (this._mode !== 'none')
|
||||
this._context.pages()[0].bringToFront().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (data.event === 'resume') {
|
||||
this._resume();
|
||||
return;
|
||||
}
|
||||
if (data.event === 'clear') {
|
||||
this._clearScript();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
recorderApp.setMode(this._mode),
|
||||
recorderApp.setPaused(this._paused),
|
||||
recorderApp.setSource(this._bufferedOutput.buffer(), this._highlighterType)
|
||||
]);
|
||||
|
||||
this._context.on(BrowserContext.Events.Page, page => this._onPage(page));
|
||||
for (const page of this._context.pages())
|
||||
this._onPage(page);
|
||||
|
||||
this._context.once('close', () => {
|
||||
this._context.once(BrowserContext.Events.Close, () => {
|
||||
for (const timer of this._timers)
|
||||
clearTimeout(timer);
|
||||
this._timers.clear();
|
||||
recorderApp.close().catch(() => {});
|
||||
});
|
||||
|
||||
// Input actions that potentially lead to navigation are intercepted on the page and are
|
||||
@ -128,55 +153,39 @@ export class RecorderSupplement {
|
||||
await this._context.exposeBinding('_playwrightRecorderCommitAction', false,
|
||||
(source: BindingSource, action: actions.Action) => this._generator.commitLastAction());
|
||||
|
||||
await this._context.exposeBinding('_playwrightRecorderShowRecorderPage', false, ({ page }) => {
|
||||
if (this._recorderApp) {
|
||||
this._recorderApp.then(p => p.bringToFront()).catch(() => {});
|
||||
return;
|
||||
}
|
||||
this._recorderApp = RecorderApp.open(page);
|
||||
this._recorderApp.then(app => {
|
||||
app.once('close', () => {
|
||||
this._recorderApp = null;
|
||||
});
|
||||
app.on('clear', () => this._clearScript());
|
||||
return app.setScript(this._bufferedOutput.buffer(), this._highlighterType);
|
||||
}).catch(e => console.error(e));
|
||||
});
|
||||
|
||||
await this._context.exposeBinding('_playwrightRecorderPrintSelector', false, (_, text) => {
|
||||
this._context.emit(BrowserContext.Events.StdOut, `Selector: \x1b[38;5;130m${text}\x1b[0m\n`);
|
||||
});
|
||||
|
||||
await this._context.exposeBinding('_playwrightRecorderState', false, () => {
|
||||
const state: State = {
|
||||
uiState: this._recorderUIState,
|
||||
isPaused: this._paused,
|
||||
};
|
||||
return state;
|
||||
});
|
||||
|
||||
await this._context.exposeBinding('_playwrightRecorderSetUIState', false, (source, state: UIState) => {
|
||||
this._recorderUIState = { ...this._recorderUIState, ...state };
|
||||
this._output.setEnabled(state.mode === 'recording');
|
||||
return { mode: this._mode };
|
||||
});
|
||||
|
||||
await this._context.exposeBinding('_playwrightResume', false, () => {
|
||||
if (this._resumeCallback) {
|
||||
this._resumeCallback();
|
||||
this._resumeCallback = null;
|
||||
}
|
||||
this._paused = false;
|
||||
this._resume().catch(() => {});
|
||||
});
|
||||
|
||||
await this._context.extendInjectedScript(recorderSource.source);
|
||||
await this._context.extendInjectedScript(consoleApiSource.source);
|
||||
|
||||
(this._context as any).recorderAppForTest = recorderApp;
|
||||
}
|
||||
|
||||
async pause() {
|
||||
this._paused = true;
|
||||
this._recorderApp!.setPaused(true);
|
||||
return new Promise<void>(f => this._resumeCallback = f);
|
||||
}
|
||||
|
||||
private async _resume() {
|
||||
if (this._resumeCallback)
|
||||
this._resumeCallback();
|
||||
this._resumeCallback = null;
|
||||
this._paused = false;
|
||||
if (this._recorderApp)
|
||||
this._recorderApp.setPaused(this._paused);
|
||||
}
|
||||
|
||||
private async _onPage(page: Page) {
|
||||
// First page is called page, others are called popup1, popup2, etc.
|
||||
const frame = page.mainFrame();
|
||||
|
||||
@ -112,3 +112,8 @@ svg {
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: var(--monospace-font);
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
@ -17,14 +17,34 @@
|
||||
.toolbar-button {
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #999;
|
||||
color: #777;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin-left: 10px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
.toolbar-button:disabled {
|
||||
color: #bbb !important;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.toolbar-button:not(.disabled):hover {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.toolbar-button.toggled {
|
||||
color: #1ea7fd;
|
||||
}
|
||||
|
||||
.toolbar-button.codicon-record.toggled {
|
||||
color: #fd1e1e;
|
||||
}
|
||||
|
||||
.toolbar-button.codicon-run {
|
||||
color: #4bfd1e;
|
||||
}
|
||||
|
||||
.toolbar-button.codicon-run:hover {
|
||||
color: #0f0;
|
||||
}
|
||||
|
||||
@ -21,14 +21,20 @@ import * as React from 'react';
|
||||
export interface ToolbarButtonProps {
|
||||
title: string,
|
||||
icon: string,
|
||||
disabled?: boolean,
|
||||
toggled?: boolean,
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export const ToolbarButton: React.FC<ToolbarButtonProps> = ({
|
||||
title = '',
|
||||
icon = '',
|
||||
disabled = false,
|
||||
toggled = false,
|
||||
onClick = () => {},
|
||||
}) => {
|
||||
const className = `toolbar-button codicon codicon-${icon}`;
|
||||
return <button className={className} onClick={onClick} title={title}></button>;
|
||||
let className = `toolbar-button codicon codicon-${icon}`;
|
||||
if (toggled)
|
||||
className += ' toggled';
|
||||
return <button className={className} onClick={onClick} title={title} disabled={!!disabled}></button>;
|
||||
};
|
||||
|
||||
@ -19,3 +19,11 @@
|
||||
flex-direction: column;
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.recorder-paused-infobar {
|
||||
display: flex;
|
||||
color: #eee;
|
||||
background-color: #333;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@ -20,10 +20,14 @@ import { Toolbar } from '../components/toolbar';
|
||||
import { ToolbarButton } from '../components/toolbarButton';
|
||||
import { Source } from '../components/source';
|
||||
|
||||
type Mode = 'inspecting' | 'recording' | 'none';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_playwrightClear(): Promise<void>
|
||||
_playwrightSetSource: (params: { text: string, language: string }) => void
|
||||
playwrightSetMode: (mode: Mode) => void;
|
||||
playwrightSetPaused: (paused: boolean) => void;
|
||||
playwrightSetSource: (params: { text: string, language: string }) => void;
|
||||
dispatch(data: any): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,18 +37,36 @@ export interface RecorderProps {
|
||||
export const Recorder: React.FC<RecorderProps> = ({
|
||||
}) => {
|
||||
const [source, setSource] = React.useState({ language: 'javascript', text: '' });
|
||||
window._playwrightSetSource = setSource;
|
||||
const [paused, setPaused] = React.useState(false);
|
||||
const [mode, setMode] = React.useState<Mode>('none');
|
||||
|
||||
window.playwrightSetMode = setMode;
|
||||
window.playwrightSetSource = setSource;
|
||||
window.playwrightSetPaused = setPaused;
|
||||
|
||||
return <div className="recorder">
|
||||
<Toolbar>
|
||||
<ToolbarButton icon="clone" title="Copy" onClick={() => {
|
||||
<ToolbarButton icon="record" title="Record" toggled={mode == 'recording'} onClick={() => {
|
||||
window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' }}).catch(() => { });
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon="question" title="Inspect" toggled={mode == 'inspecting'} onClick={() => {
|
||||
window.dispatch({ event: 'setMode', params: { mode: mode === 'inspecting' ? 'none' : 'inspecting' }}).catch(() => { });
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon="files" title="Copy" disabled={!source.text} onClick={() => {
|
||||
copy(source.text);
|
||||
}}></ToolbarButton>
|
||||
<ToolbarButton icon="trashcan" title="Clear" onClick={() => {
|
||||
window._playwrightClear().catch(e => console.error(e));
|
||||
}}></ToolbarButton>
|
||||
<div style={{flex: "auto"}}></div>
|
||||
<ToolbarButton icon="clear-all" title="Clear" disabled={!source.text} onClick={() => {
|
||||
window.dispatch({ event: 'clear' }).catch(() => {});
|
||||
}}></ToolbarButton>
|
||||
</Toolbar>
|
||||
<div className="recorder-paused-infobar" hidden={!paused}>
|
||||
<ToolbarButton icon="run" title="Resume" disabled={!paused} onClick={() => {
|
||||
window.dispatch({ event: 'resume' }).catch(() => {});
|
||||
}}></ToolbarButton>
|
||||
<span style={{paddingLeft: 10}}>Paused due to <span className="code">page.pause()</span></span>
|
||||
<div style={{flex: "auto"}}></div>
|
||||
</div>
|
||||
<Source text={source.text} language={source.language}></Source>
|
||||
</div>;
|
||||
};
|
||||
|
||||
@ -13,56 +13,73 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { folio } from './fixtures';
|
||||
const extended = folio.extend();
|
||||
extended.browserOptions.override(({browserOptions}, runTest) => {
|
||||
return runTest({
|
||||
import { ProgressController } from '../lib/server/progress';
|
||||
|
||||
const extended = folio.extend<{
|
||||
recorderFrame: () => Promise<any>,
|
||||
recorderClick: (selector: string) => Promise<void>
|
||||
}>();
|
||||
|
||||
extended.browserOptions.override(async ({browserOptions}, runTest) => {
|
||||
await runTest({
|
||||
...browserOptions,
|
||||
headless: false,
|
||||
});
|
||||
});
|
||||
const {it, expect } = extended.build();
|
||||
|
||||
it('should pause and resume the script', async ({page}) => {
|
||||
let resolved = false;
|
||||
const resumePromise = (page as any).pause().then(() => resolved = true);
|
||||
await new Promise(x => setTimeout(x, 0));
|
||||
expect(resolved).toBe(false);
|
||||
await page.click('#pw-button-resume');
|
||||
await resumePromise;
|
||||
expect(resolved).toBe(true);
|
||||
extended.recorderFrame.init(async ({context, toImpl}, runTest) => {
|
||||
await runTest(async () => {
|
||||
while (!toImpl(context).recorderAppForTest)
|
||||
await new Promise(f => setTimeout(f, 100));
|
||||
return toImpl(context).recorderAppForTest._page.mainFrame();
|
||||
});
|
||||
});
|
||||
|
||||
it('should resume from console', async ({page}) => {
|
||||
let resolved = false;
|
||||
const resumePromise = (page as any).pause().then(() => resolved = true);
|
||||
await new Promise(x => setTimeout(x, 0));
|
||||
expect(resolved).toBe(false);
|
||||
await page.waitForFunction(() => !!(window as any).playwright.resume);
|
||||
await page.evaluate('window.playwright.resume()');
|
||||
await resumePromise;
|
||||
expect(resolved).toBe(true);
|
||||
extended.recorderClick.init(async ({ recorderFrame }, runTest) => {
|
||||
await runTest(async (selector: string) => {
|
||||
const frame = await recorderFrame();
|
||||
frame.click(new ProgressController(), selector, {});
|
||||
});
|
||||
});
|
||||
|
||||
it('should pause through a navigation', async ({page, server}) => {
|
||||
let resolved = false;
|
||||
const resumePromise = (page as any).pause().then(() => resolved = true);
|
||||
await new Promise(x => setTimeout(x, 0));
|
||||
expect(resolved).toBe(false);
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
await page.click('#pw-button-resume');
|
||||
await resumePromise;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
const {it, expect, describe} = extended.build();
|
||||
|
||||
it('should pause after a navigation', async ({page, server}) => {
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
describe('pause', (suite, { mode }) => {
|
||||
suite.skip(mode !== 'default');
|
||||
}, () => {
|
||||
it('should pause and resume the script', async ({ page, recorderClick }) => {
|
||||
await Promise.all([
|
||||
page.pause(),
|
||||
recorderClick('[title=Resume]')
|
||||
]);
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
const resumePromise = (page as any).pause().then(() => resolved = true);
|
||||
await new Promise(x => setTimeout(x, 0));
|
||||
expect(resolved).toBe(false);
|
||||
await page.click('#pw-button-resume');
|
||||
await resumePromise;
|
||||
expect(resolved).toBe(true);
|
||||
it('should resume from console', async ({page}) => {
|
||||
await Promise.all([
|
||||
page.pause(),
|
||||
page.waitForFunction(() => (window as any).playwright && (window as any).playwright.resume).then(() => {
|
||||
return page.evaluate('window.playwright.resume()');
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it('should pause through a navigation', async ({page, server, recorderClick}) => {
|
||||
let resolved = false;
|
||||
const resumePromise = page.pause().then(() => resolved = true);
|
||||
expect(resolved).toBe(false);
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
await recorderClick('[title=Resume]');
|
||||
await resumePromise;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it('should pause after a navigation', async ({page, server, recorderClick}) => {
|
||||
await page.goto(server.EMPTY_PAGE);
|
||||
await Promise.all([
|
||||
page.pause(),
|
||||
recorderClick('[title=Resume]')
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user