feat(inspector): selector input (#5502)
This commit is contained in:
parent
a9faa9c941
commit
8a9048c2b5
|
|
@ -28,6 +28,7 @@ declare global {
|
|||
_playwrightRecorderCommitAction: () => Promise<void>;
|
||||
_playwrightRecorderState: () => Promise<UIState>;
|
||||
_playwrightResume: () => Promise<void>;
|
||||
_playwrightRecorderSetSelector: (selector: string) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -226,10 +227,8 @@ export class Recorder {
|
|||
}
|
||||
|
||||
private _onClick(event: MouseEvent) {
|
||||
if (this._mode === 'inspecting') {
|
||||
if (this._hoveredModel)
|
||||
copy(this._hoveredModel.selector);
|
||||
}
|
||||
if (this._mode === 'inspecting')
|
||||
window._playwrightRecorderSetSelector(this._hoveredModel ? this._hoveredModel.selector : '');
|
||||
if (this._shouldIgnoreMouseEvent(event))
|
||||
return;
|
||||
if (this._actionInProgress(event))
|
||||
|
|
@ -603,13 +602,4 @@ function removeEventListeners(listeners: (() => void)[]) {
|
|||
listeners.splice(0, listeners.length);
|
||||
}
|
||||
|
||||
function copy(text: string) {
|
||||
const input = html`<textarea style="position: absolute; z-index: -1000;"></textarea>` as any as HTMLInputElement;
|
||||
input.value = text;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
input.remove();
|
||||
}
|
||||
|
||||
export default Recorder;
|
||||
|
|
|
|||
|
|
@ -27,11 +27,18 @@ const cacheAllowText = new Map<Element, SelectorToken[] | null>();
|
|||
const cacheDisallowText = new Map<Element, SelectorToken[] | null>();
|
||||
|
||||
export function querySelector(injectedScript: InjectedScript, selector: string, ownerDocument: Document): { selector: string, elements: Element[] } {
|
||||
const parsedSelector = injectedScript.parseSelector(selector);
|
||||
return {
|
||||
selector,
|
||||
elements: injectedScript.querySelectorAll(parsedSelector, ownerDocument)
|
||||
};
|
||||
try {
|
||||
const parsedSelector = injectedScript.parseSelector(selector);
|
||||
return {
|
||||
selector,
|
||||
elements: injectedScript.querySelectorAll(parsedSelector, ownerDocument)
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
selector,
|
||||
elements: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function generateSelector(injectedScript: InjectedScript, targetElement: Element): { selector: string, elements: Element[] } {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ declare global {
|
|||
playwrightSetMode: (mode: Mode) => void;
|
||||
playwrightSetPaused: (paused: boolean) => void;
|
||||
playwrightSetSources: (sources: Source[]) => void;
|
||||
playwrightSetSelector: (selector: string, focus?: boolean) => void;
|
||||
playwrightUpdateLogs: (callLogs: CallLog[]) => void;
|
||||
dispatch(data: EventData): Promise<void>;
|
||||
}
|
||||
|
|
@ -151,6 +152,12 @@ export class RecorderApp extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
async setSelector(selector: string, focus?: boolean): Promise<void> {
|
||||
await this._page.mainFrame()._evaluateExpression(((arg: any) => {
|
||||
window.playwrightSetSelector(arg.selector, arg.focus);
|
||||
}).toString(), true, { selector, focus }, 'main').catch(() => {});
|
||||
}
|
||||
|
||||
async updateCallLogs(callLogs: CallLog[]): Promise<void> {
|
||||
await this._page.mainFrame()._evaluateExpression(((callLogs: CallLog[]) => {
|
||||
window.playwrightUpdateLogs(callLogs);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { Point } from '../../../common/types';
|
|||
export type Mode = 'inspecting' | 'recording' | 'none';
|
||||
|
||||
export type EventData = {
|
||||
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode';
|
||||
event: 'clear' | 'resume' | 'step' | 'pause' | 'setMode' | 'selectorUpdated';
|
||||
params: any;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export class RecorderSupplement {
|
|||
private _timers = new Set<NodeJS.Timeout>();
|
||||
private _context: BrowserContext;
|
||||
private _mode: Mode;
|
||||
private _highlightedSelector = '';
|
||||
private _recorderApp: RecorderApp | null = null;
|
||||
private _params: channels.BrowserContextRecorderSupplementEnableParams;
|
||||
private _currentCallsMetadata = new Map<CallMetadata, SdkObject>();
|
||||
|
|
@ -127,11 +128,11 @@ export class RecorderSupplement {
|
|||
});
|
||||
recorderApp.on('event', (data: EventData) => {
|
||||
if (data.event === 'setMode') {
|
||||
this._mode = data.params.mode;
|
||||
recorderApp.setMode(this._mode);
|
||||
this._generator.setEnabled(this._mode === 'recording');
|
||||
if (this._mode !== 'none')
|
||||
this._context.pages()[0].bringToFront().catch(() => {});
|
||||
this._setMode(data.params.mode);
|
||||
return;
|
||||
}
|
||||
if (data.event === 'selectorUpdated') {
|
||||
this._highlightedSelector = data.params.selector;
|
||||
return;
|
||||
}
|
||||
if (data.event === 'step') {
|
||||
|
|
@ -191,10 +192,16 @@ export class RecorderSupplement {
|
|||
actionSelector = metadata.params.selector || actionSelector;
|
||||
}
|
||||
}
|
||||
const uiState: UIState = { mode: this._mode, actionPoint, actionSelector };
|
||||
const uiState: UIState = { mode: this._mode, actionPoint, actionSelector: this._highlightedSelector || actionSelector };
|
||||
return uiState;
|
||||
});
|
||||
|
||||
await this._context.exposeBinding('_playwrightRecorderSetSelector', false, async (_, selector: string) => {
|
||||
this._setMode('none');
|
||||
await this._recorderApp?.setSelector(selector, true);
|
||||
await this._recorderApp?.bringToFront();
|
||||
});
|
||||
|
||||
await this._context.exposeBinding('_playwrightResume', false, () => {
|
||||
this._resume(false).catch(() => {});
|
||||
});
|
||||
|
|
@ -216,6 +223,14 @@ export class RecorderSupplement {
|
|||
return result;
|
||||
}
|
||||
|
||||
private _setMode(mode: Mode) {
|
||||
this._mode = mode;
|
||||
this._recorderApp?.setMode(this._mode);
|
||||
this._generator.setEnabled(this._mode === 'recording');
|
||||
if (this._mode !== 'none')
|
||||
this._context.pages()[0].bringToFront().catch(() => {});
|
||||
}
|
||||
|
||||
private async _resume(step: boolean) {
|
||||
this._pauseOnNextStatement = step;
|
||||
this._recorderApp?.setPaused(false);
|
||||
|
|
@ -354,6 +369,10 @@ export class RecorderSupplement {
|
|||
this.updateCallLog([metadata]);
|
||||
if (metadata.method === 'pause' || (this._pauseOnNextStatement && metadata.method === 'goto'))
|
||||
await this.pause(metadata);
|
||||
if (metadata.params && metadata.params.selector) {
|
||||
this._highlightedSelector = metadata.params.selector;
|
||||
await this._recorderApp?.setSelector(this._highlightedSelector);
|
||||
}
|
||||
}
|
||||
|
||||
async onAfterCall(metadata: CallMetadata): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
display: flex;
|
||||
flex: auto;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.split-view-main {
|
||||
|
|
|
|||
|
|
@ -21,25 +21,28 @@ export interface SplitViewProps {
|
|||
sidebarSize: number,
|
||||
}
|
||||
|
||||
const kMinSidebarSize = 50;
|
||||
|
||||
export const SplitView: React.FC<SplitViewProps> = ({
|
||||
sidebarSize,
|
||||
children
|
||||
}) => {
|
||||
let [size, setSize] = React.useState<number>(sidebarSize);
|
||||
const [resizing, setResizing] = React.useState<{ offsetY: number } | null>(null);
|
||||
if (size < 50)
|
||||
size = 50;
|
||||
let [size, setSize] = React.useState<number>(Math.max(kMinSidebarSize, sidebarSize));
|
||||
const [resizing, setResizing] = React.useState<{ offsetY: number, size: number } | null>(null);
|
||||
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
return <div className='split-view'>
|
||||
<div className='split-view-main'>{childrenArray[0]}</div>
|
||||
<div style={{flexBasis: size}} className='split-view-sidebar'>{childrenArray[1]}</div>
|
||||
<div
|
||||
style={{bottom: resizing ? 0 : size - 32, top: resizing ? 0 : undefined, height: resizing ? 'initial' : 32 }}
|
||||
style={{bottom: resizing ? 0 : size - 4, top: resizing ? 0 : undefined, height: resizing ? 'initial' : 8 }}
|
||||
className='split-view-resizer'
|
||||
onMouseDown={event => setResizing({ offsetY: event.clientY - (event.target as HTMLElement).getBoundingClientRect().y })}
|
||||
onMouseDown={event => setResizing({ offsetY: event.clientY, size })}
|
||||
onMouseUp={() => setResizing(null)}
|
||||
onMouseMove={event => resizing ? setSize((event.target as HTMLElement).clientHeight - event.clientY + resizing.offsetY) : 0}
|
||||
onMouseMove={event => {
|
||||
if (resizing)
|
||||
setSize(Math.max(kMinSidebarSize, resizing.size - event.clientY + resizing.offsetY));
|
||||
}}
|
||||
></div>
|
||||
</div>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,10 +22,28 @@
|
|||
align-items: center;
|
||||
padding-right: 10px;
|
||||
flex: none;
|
||||
z-index: 2;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.toolbar-linewrap {
|
||||
display: block;
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.toolbar input {
|
||||
border: 1px solid #ddd;
|
||||
padding: 0 10px;
|
||||
border-radius: 14px;
|
||||
line-height: 24px;
|
||||
background: white;
|
||||
outline: none;
|
||||
margin-left: 10px;
|
||||
color: var(--toolbar-color);
|
||||
}
|
||||
|
||||
.toolbar select {
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
color: var(--toolbar-color);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,17 +31,6 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.call-log-header {
|
||||
color: var(--toolbar-color);
|
||||
box-shadow: var(--box-shadow);
|
||||
background-color: var(--toolbar-bg-color);
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.call-log-call {
|
||||
display: flex;
|
||||
flex: none;
|
||||
|
|
|
|||
|
|
@ -33,37 +33,34 @@ export const CallLogView: React.FC<CallLogProps> = ({
|
|||
messagesEndRef.current?.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
}, [messagesEndRef]);
|
||||
|
||||
return <div className='vbox'>
|
||||
<div className='call-log-header' style={{flex: 'none'}}>Log</div>
|
||||
<div className='call-log' style={{flex: 'auto'}}>
|
||||
{log.map(callLog => {
|
||||
const expandOverride = expandOverrides.get(callLog.id);
|
||||
const isExpanded = typeof expandOverride === 'boolean' ? expandOverride : callLog.status !== 'done';
|
||||
return <div className={`call-log-call ${callLog.status}`} key={callLog.id}>
|
||||
<div className='call-log-call-header'>
|
||||
<span className={`codicon codicon-chevron-${isExpanded ? 'down' : 'right'}`} style={{ cursor: 'pointer' }}onClick={() => {
|
||||
const newOverrides = new Map(expandOverrides);
|
||||
newOverrides.set(callLog.id, !isExpanded);
|
||||
setExpandOverrides(newOverrides);
|
||||
}}></span>
|
||||
{ callLog.title }
|
||||
{ callLog.params.url ? <span>(<span className='call-log-url'>{callLog.params.url}</span>)</span> : undefined }
|
||||
{ callLog.params.selector ? <span>(<span className='call-log-selector'>{callLog.params.selector}</span>)</span> : undefined }
|
||||
<span className={'codicon ' + iconClass(callLog)}></span>
|
||||
{ typeof callLog.duration === 'number' ? <span className='call-log-time'>— {msToString(callLog.duration)}</span> : undefined}
|
||||
</div>
|
||||
{ (isExpanded ? callLog.messages : []).map((message, i) => {
|
||||
return <div className='call-log-message' key={i}>
|
||||
{ message.trim() }
|
||||
</div>;
|
||||
})}
|
||||
{ callLog.error ? <div className='call-log-message error' hidden={!isExpanded}>
|
||||
{ callLog.error }
|
||||
</div> : undefined }
|
||||
return <div className='call-log' style={{flex: 'auto'}}>
|
||||
{log.map(callLog => {
|
||||
const expandOverride = expandOverrides.get(callLog.id);
|
||||
const isExpanded = typeof expandOverride === 'boolean' ? expandOverride : callLog.status !== 'done';
|
||||
return <div className={`call-log-call ${callLog.status}`} key={callLog.id}>
|
||||
<div className='call-log-call-header'>
|
||||
<span className={`codicon codicon-chevron-${isExpanded ? 'down' : 'right'}`} style={{ cursor: 'pointer' }}onClick={() => {
|
||||
const newOverrides = new Map(expandOverrides);
|
||||
newOverrides.set(callLog.id, !isExpanded);
|
||||
setExpandOverrides(newOverrides);
|
||||
}}></span>
|
||||
{ callLog.title }
|
||||
{ callLog.params.url ? <span>(<span className='call-log-url'>{callLog.params.url}</span>)</span> : undefined }
|
||||
{ callLog.params.selector ? <span>(<span className='call-log-selector'>{callLog.params.selector}</span>)</span> : undefined }
|
||||
<span className={'codicon ' + iconClass(callLog)}></span>
|
||||
{ typeof callLog.duration === 'number' ? <span className='call-log-time'>— {msToString(callLog.duration)}</span> : undefined}
|
||||
</div>
|
||||
})}
|
||||
<div ref={messagesEndRef}></div>
|
||||
</div>
|
||||
{ (isExpanded ? callLog.messages : []).map((message, i) => {
|
||||
return <div className='call-log-message' key={i}>
|
||||
{ message.trim() }
|
||||
</div>;
|
||||
})}
|
||||
{ callLog.error ? <div className='call-log-message error' hidden={!isExpanded}>
|
||||
{ callLog.error }
|
||||
</div> : undefined }
|
||||
</div>
|
||||
})}
|
||||
<div ref={messagesEndRef}></div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -21,11 +21,6 @@ import { applyTheme } from '../theme';
|
|||
import '../common.css';
|
||||
import { Main } from './main';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
applyTheme();
|
||||
ReactDOM.render(<Main/>, document.querySelector('#root'));
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ declare global {
|
|||
playwrightSetPaused: (paused: boolean) => void;
|
||||
playwrightSetSources: (sources: Source[]) => void;
|
||||
playwrightUpdateLogs: (callLogs: CallLog[]) => void;
|
||||
dispatch(data: any): Promise<void>;
|
||||
playwrightSourcesEchoForTest: Source[];
|
||||
}
|
||||
}
|
||||
|
|
@ -36,6 +35,7 @@ export const Main: React.FC = ({
|
|||
const [paused, setPaused] = React.useState(false);
|
||||
const [log, setLog] = React.useState(new Map<number, CallLog>());
|
||||
const [mode, setMode] = React.useState<Mode>('none');
|
||||
const [selector, setSelector] = React.useState('');
|
||||
|
||||
window.playwrightSetMode = setMode;
|
||||
window.playwrightSetSources = setSources;
|
||||
|
|
|
|||
|
|
@ -55,3 +55,7 @@
|
|||
.recorder .toolbar-button:not([disabled]):hover .codicon-debug-step-over {
|
||||
color: #41ca1e;
|
||||
}
|
||||
|
||||
.recorder .selector-input {
|
||||
flex: auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ OneSource.args = {
|
|||
file: '<one>',
|
||||
text: '// Text One',
|
||||
language: 'javascript',
|
||||
highlight: [],
|
||||
highlight: [],
|
||||
},
|
||||
],
|
||||
paused: false,
|
||||
|
|
@ -61,13 +61,13 @@ TwoSources.args = {
|
|||
file: '<one>',
|
||||
text: '// Text One',
|
||||
language: 'javascript',
|
||||
highlight: [],
|
||||
highlight: [],
|
||||
},
|
||||
{
|
||||
file: '<two>',
|
||||
text: '// Text Two',
|
||||
language: 'javascript',
|
||||
highlight: [],
|
||||
highlight: [],
|
||||
},
|
||||
],
|
||||
paused: false,
|
||||
|
|
@ -83,3 +83,27 @@ WithLog.args = {
|
|||
log: exampleCallLog(),
|
||||
mode: 'none'
|
||||
};
|
||||
|
||||
export const Inspecting = Template.bind({});
|
||||
Inspecting.args = {
|
||||
sources: [],
|
||||
paused: false,
|
||||
log: [],
|
||||
mode: 'inspecting',
|
||||
initialSelector: 'text=Find me'
|
||||
};
|
||||
|
||||
export const Recording = Template.bind({});
|
||||
Recording.args = {
|
||||
sources: [
|
||||
{
|
||||
file: '<javascript>',
|
||||
text: `await page.click('button');\n\nawait page.click('button');\n`,
|
||||
language: 'javascript',
|
||||
highlight: [],
|
||||
},
|
||||
],
|
||||
paused: false,
|
||||
log: [],
|
||||
mode: 'recording',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import { CallLogView } from './callLog';
|
|||
declare global {
|
||||
interface Window {
|
||||
playwrightSetFile: (file: string) => void;
|
||||
playwrightSetSelector: (selector: string, focus?: boolean) => void;
|
||||
dispatch(data: any): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -33,15 +35,24 @@ export interface RecorderProps {
|
|||
sources: Source[],
|
||||
paused: boolean,
|
||||
log: Map<number, CallLog>,
|
||||
mode: Mode
|
||||
mode: Mode,
|
||||
initialSelector?: string,
|
||||
}
|
||||
|
||||
export const Recorder: React.FC<RecorderProps> = ({
|
||||
sources,
|
||||
paused,
|
||||
log,
|
||||
mode
|
||||
mode,
|
||||
initialSelector,
|
||||
}) => {
|
||||
const [selector, setSelector] = React.useState(initialSelector || '');
|
||||
const [focusSelectorInput, setFocusSelectorInput] = React.useState(false);
|
||||
window.playwrightSetSelector = (selector: string, focus?: boolean) => {
|
||||
setSelector(selector);
|
||||
setFocusSelectorInput(!!focus);
|
||||
};
|
||||
|
||||
const [f, setFile] = React.useState<string | undefined>();
|
||||
window.playwrightSetFile = setFile;
|
||||
const file = f || sources[0]?.file;
|
||||
|
|
@ -57,14 +68,21 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
React.useLayoutEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ block: 'center', inline: 'nearest' });
|
||||
}, [messagesEndRef]);
|
||||
|
||||
const selectorInputRef = React.createRef<HTMLInputElement>();
|
||||
React.useLayoutEffect(() => {
|
||||
if (focusSelectorInput && selectorInputRef.current) {
|
||||
selectorInputRef.current.select();
|
||||
selectorInputRef.current.focus();
|
||||
setFocusSelectorInput(false);
|
||||
}
|
||||
}, [focusSelectorInput, selectorInputRef]);
|
||||
|
||||
return <div className='recorder'>
|
||||
<Toolbar>
|
||||
<ToolbarButton icon='record' title='Record' toggled={mode == 'recording'} onClick={() => {
|
||||
window.dispatch({ event: 'setMode', params: { mode: mode === 'recording' ? 'none' : 'recording' }}).catch(() => { });
|
||||
}}>Record</ToolbarButton>
|
||||
<ToolbarButton icon='question' title='Explore' toggled={mode == 'inspecting'} onClick={() => {
|
||||
window.dispatch({ event: 'setMode', params: { mode: mode === 'inspecting' ? 'none' : 'inspecting' }}).catch(() => { });
|
||||
}}>Explore</ToolbarButton>
|
||||
<ToolbarButton icon='files' title='Copy' disabled={!source || !source.text} onClick={() => {
|
||||
copy(source.text);
|
||||
}}></ToolbarButton>
|
||||
|
|
@ -93,7 +111,18 @@ export const Recorder: React.FC<RecorderProps> = ({
|
|||
</Toolbar>
|
||||
<SplitView sidebarSize={200}>
|
||||
<SourceView text={source.text} language={source.language} highlight={source.highlight} revealLine={source.revealLine}></SourceView>
|
||||
<CallLogView log={[...log.values()]}/>
|
||||
<div className='vbox'>
|
||||
<Toolbar>
|
||||
<ToolbarButton icon='question' title='Explore' toggled={mode == 'inspecting'} onClick={() => {
|
||||
window.dispatch({ event: 'setMode', params: { mode: mode === 'inspecting' ? 'none' : 'inspecting' }}).catch(() => { });
|
||||
}}>Explore</ToolbarButton>
|
||||
<input ref={selectorInputRef} className='selector-input' placeholder='Playwright Selector' value={selector} disabled={mode !== 'none'} onChange={event => {
|
||||
setSelector(event.target.value);
|
||||
window.dispatch({ event: 'selectorUpdated', params: { selector: event.target.value } });
|
||||
}} />
|
||||
</Toolbar>
|
||||
<CallLogView log={[...log.values()]}/>
|
||||
</div>
|
||||
</SplitView>
|
||||
</div>;
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue