feat(inspector): selector input (#5502)

This commit is contained in:
Dmitry Gozman 2021-02-19 07:25:08 -08:00 committed by GitHub
parent a9faa9c941
commit 8a9048c2b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 172 additions and 89 deletions

View file

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

View file

@ -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[] } {

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@
display: flex;
flex: auto;
flex-direction: column;
position: relative;
}
.split-view-main {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -55,3 +55,7 @@
.recorder .toolbar-button:not([disabled]):hover .codicon-debug-step-over {
color: #41ca1e;
}
.recorder .selector-input {
flex: auto;
}

View file

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

View file

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