2021-01-28 23:25:10 +01:00
/ *
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 .
* /
2024-10-23 01:36:03 +02:00
import type { CallLog , ElementInfo , Mode , Source } from './recorderTypes' ;
2022-11-03 17:55:23 +01:00
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper' ;
2024-11-08 16:43:01 +01:00
import type { SourceHighlight } from '@web/components/codeMirrorWrapper' ;
2022-03-25 22:12:00 +01:00
import { SplitView } from '@web/components/splitView' ;
2023-09-07 02:13:25 +02:00
import { TabbedPane } from '@web/components/tabbedPane' ;
2022-03-25 22:12:00 +01:00
import { Toolbar } from '@web/components/toolbar' ;
2024-09-24 04:13:45 +02:00
import { emptySource , SourceChooser } from '@web/components/sourceChooser' ;
2023-11-05 05:18:27 +01:00
import { ToolbarButton , ToolbarSeparator } from '@web/components/toolbarButton' ;
2021-01-28 23:25:10 +01:00
import * as React from 'react' ;
2021-02-17 23:05:41 +01:00
import { CallLogView } from './callLog' ;
2022-03-25 22:12:00 +01:00
import './recorder.css' ;
2022-10-19 22:05:52 +02:00
import { asLocator } from '@isomorphic/locatorGenerators' ;
2022-11-11 02:20:09 +01:00
import { toggleTheme } from '@web/theme' ;
2024-11-16 01:19:35 +01:00
import { copy , useSetting } from '@web/uiUtils' ;
2024-11-08 16:43:01 +01:00
import yaml from 'yaml' ;
import { parseAriaKey } from '@isomorphic/ariaSnapshot' ;
import type { AriaKeyError , ParsedYaml } from '@isomorphic/ariaSnapshot' ;
2021-02-05 23:24:27 +01:00
2021-01-28 23:25:10 +01:00
export interface RecorderProps {
2021-02-17 23:05:41 +01:00
sources : Source [ ] ,
paused : boolean ,
2021-04-23 18:28:18 +02:00
log : Map < string , CallLog > ,
2021-02-19 16:25:08 +01:00
mode : Mode ,
2021-01-28 23:25:10 +01:00
}
export const Recorder : React.FC < RecorderProps > = ( {
2021-02-17 23:05:41 +01:00
sources ,
paused ,
log ,
2021-02-19 16:25:08 +01:00
mode ,
2021-01-28 23:25:10 +01:00
} ) = > {
2024-10-14 23:04:24 +02:00
const [ selectedFileId , setSelectedFileId ] = React . useState < string | undefined > ( ) ;
const [ runningFileId , setRunningFileId ] = React . useState < string | undefined > ( ) ;
2024-11-16 01:19:35 +01:00
const [ selectedTab , setSelectedTab ] = useSetting < string > ( 'recorderPropertiesTab' , 'log' ) ;
2024-10-23 01:36:03 +02:00
const [ ariaSnapshot , setAriaSnapshot ] = React . useState < string | undefined > ( ) ;
2024-11-08 16:43:01 +01:00
const [ ariaSnapshotErrors , setAriaSnapshotErrors ] = React . useState < SourceHighlight [ ] > ( ) ;
2021-02-05 23:24:27 +01:00
2024-10-14 23:04:24 +02:00
const fileId = selectedFileId || runningFileId || sources [ 0 ] ? . id ;
2022-08-15 19:44:46 +02:00
2024-09-16 22:47:13 +02:00
const source = React . useMemo ( ( ) = > {
if ( fileId ) {
const source = sources . find ( s = > s . id === fileId ) ;
if ( source )
return source ;
}
2024-09-24 04:13:45 +02:00
return emptySource ( ) ;
2024-09-16 22:47:13 +02:00
} , [ sources , fileId ] ) ;
2022-11-03 17:55:23 +01:00
const [ locator , setLocator ] = React . useState ( '' ) ;
2024-10-23 01:36:03 +02:00
window . playwrightElementPicked = ( elementInfo : ElementInfo , userGesture? : boolean ) = > {
2022-11-03 17:55:23 +01:00
const language = source . language ;
2024-10-23 01:36:03 +02:00
setLocator ( asLocator ( language , elementInfo . selector ) ) ;
setAriaSnapshot ( elementInfo . ariaSnapshot ) ;
2024-11-15 22:43:00 +01:00
setAriaSnapshotErrors ( [ ] ) ;
2024-10-23 01:36:03 +02:00
if ( userGesture && selectedTab !== 'locator' && selectedTab !== 'aria' )
2023-11-08 04:36:12 +01:00
setSelectedTab ( 'locator' ) ;
2024-10-23 01:36:03 +02:00
if ( mode === 'inspecting' && selectedTab === 'aria' ) {
// Keep exploring aria.
} else {
window . dispatch ( { event : 'setMode' , params : { mode : mode === 'inspecting' ? 'standby' : 'recording' } } ) . catch ( ( ) = > { } ) ;
}
2022-11-03 17:55:23 +01:00
} ;
2024-10-14 23:04:24 +02:00
window . playwrightSetRunningFile = setRunningFileId ;
2021-02-13 03:53:46 +01:00
2023-03-10 04:34:05 +01:00
const messagesEndRef = React . useRef < HTMLDivElement > ( null ) ;
2021-02-13 03:53:46 +01:00
React . useLayoutEffect ( ( ) = > {
messagesEndRef . current ? . scrollIntoView ( { block : 'center' , inline : 'nearest' } ) ;
} , [ messagesEndRef ] ) ;
2021-02-19 16:25:08 +01:00
2022-07-07 20:25:48 +02:00
React . useEffect ( ( ) = > {
const handleKeyDown = ( event : KeyboardEvent ) = > {
switch ( event . key ) {
case 'F8' :
event . preventDefault ( ) ;
if ( paused )
window . dispatch ( { event : 'resume' } ) ;
else
window . dispatch ( { event : 'pause' } ) ;
break ;
case 'F10' :
event . preventDefault ( ) ;
if ( paused )
window . dispatch ( { event : 'step' } ) ;
break ;
}
} ;
document . addEventListener ( 'keydown' , handleKeyDown ) ;
return ( ) = > document . removeEventListener ( 'keydown' , handleKeyDown ) ;
} , [ paused ] ) ;
2023-10-22 13:02:14 +02:00
const onEditorChange = React . useCallback ( ( selector : string ) = > {
2024-10-23 01:36:03 +02:00
if ( mode === 'none' || mode === 'inspecting' )
2023-11-15 19:40:10 +01:00
window . dispatch ( { event : 'setMode' , params : { mode : 'standby' } } ) ;
2023-10-22 13:02:14 +02:00
setLocator ( selector ) ;
2024-11-08 16:43:01 +01:00
window . dispatch ( { event : 'highlightRequested' , params : { selector } } ) ;
} , [ mode ] ) ;
const onAriaEditorChange = React . useCallback ( ( ariaSnapshot : string ) = > {
if ( mode === 'none' || mode === 'inspecting' )
window . dispatch ( { event : 'setMode' , params : { mode : 'standby' } } ) ;
const { fragment , errors } = parseAriaSnapshot ( ariaSnapshot ) ;
setAriaSnapshotErrors ( errors ) ;
setAriaSnapshot ( ariaSnapshot ) ;
if ( ! errors . length )
2024-11-14 06:33:38 +01:00
window . dispatch ( { event : 'highlightRequested' , params : { ariaTemplate : fragment } } ) ;
2023-11-15 19:40:10 +01:00
} , [ mode ] ) ;
2023-04-21 21:38:39 +02:00
2021-02-13 03:53:46 +01:00
return < div className = 'recorder' >
2021-01-28 23:25:10 +01:00
< Toolbar >
2023-11-14 21:55:34 +01:00
< ToolbarButton icon = 'circle-large-filled' title = 'Record' toggled = { mode === 'recording' || mode === 'recording-inspecting' || mode === 'assertingText' || mode === 'assertingVisibility' } onClick = { ( ) = > {
window . dispatch ( { event : 'setMode' , params : { mode : mode === 'none' || mode === 'standby' || mode === 'inspecting' ? 'recording' : 'standby' } } ) ;
2021-02-18 02:28:02 +01:00
} } > Record < / ToolbarButton >
2023-11-05 05:18:27 +01:00
< ToolbarSeparator / >
< ToolbarButton icon = 'inspect' title = 'Pick locator' toggled = { mode === 'inspecting' || mode === 'recording-inspecting' } onClick = { ( ) = > {
const newMode = {
2023-11-14 01:39:14 +01:00
'inspecting' : 'standby' ,
2023-11-05 05:18:27 +01:00
'none' : 'inspecting' ,
2023-11-14 01:39:14 +01:00
'standby' : 'inspecting' ,
2023-11-05 05:18:27 +01:00
'recording' : 'recording-inspecting' ,
'recording-inspecting' : 'recording' ,
'assertingText' : 'recording-inspecting' ,
2023-11-14 21:55:34 +01:00
'assertingVisibility' : 'recording-inspecting' ,
2023-11-15 00:17:42 +01:00
'assertingValue' : 'recording-inspecting' ,
2024-10-15 22:38:55 +02:00
'assertingSnapshot' : 'recording-inspecting' ,
2023-11-05 05:18:27 +01:00
} [ mode ] ;
window . dispatch ( { event : 'setMode' , params : { mode : newMode } } ) . catch ( ( ) = > { } ) ;
2023-11-14 21:55:34 +01:00
} } > < / ToolbarButton >
< ToolbarButton icon = 'eye' title = 'Assert visibility' toggled = { mode === 'assertingVisibility' } disabled = { mode === 'none' || mode === 'standby' || mode === 'inspecting' } onClick = { ( ) = > {
window . dispatch ( { event : 'setMode' , params : { mode : mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility' } } ) ;
} } > < / ToolbarButton >
2023-11-15 00:17:42 +01:00
< ToolbarButton icon = 'whole-word' title = 'Assert text' toggled = { mode === 'assertingText' } disabled = { mode === 'none' || mode === 'standby' || mode === 'inspecting' } onClick = { ( ) = > {
2023-11-05 05:18:27 +01:00
window . dispatch ( { event : 'setMode' , params : { mode : mode === 'assertingText' ? 'recording' : 'assertingText' } } ) ;
2023-11-14 21:55:34 +01:00
} } > < / ToolbarButton >
2023-11-15 00:17:42 +01:00
< ToolbarButton icon = 'symbol-constant' title = 'Assert value' toggled = { mode === 'assertingValue' } disabled = { mode === 'none' || mode === 'standby' || mode === 'inspecting' } onClick = { ( ) = > {
window . dispatch ( { event : 'setMode' , params : { mode : mode === 'assertingValue' ? 'recording' : 'assertingValue' } } ) ;
} } > < / ToolbarButton >
2024-11-06 00:23:38 +01:00
< ToolbarButton icon = 'gist' title = 'Assert snapshot' toggled = { mode === 'assertingSnapshot' } disabled = { mode === 'none' || mode === 'standby' || mode === 'inspecting' } onClick = { ( ) = > {
window . dispatch ( { event : 'setMode' , params : { mode : mode === 'assertingSnapshot' ? 'recording' : 'assertingSnapshot' } } ) ;
} } > < / ToolbarButton >
2023-11-05 05:18:27 +01:00
< ToolbarSeparator / >
2021-02-17 23:05:41 +01:00
< ToolbarButton icon = 'files' title = 'Copy' disabled = { ! source || ! source . text } onClick = { ( ) = > {
2021-02-05 23:24:27 +01:00
copy ( source . text ) ;
2021-02-01 01:37:13 +01:00
} } > < / ToolbarButton >
2024-10-14 23:04:24 +02:00
< ToolbarButton icon = 'debug-continue' title = 'Resume (F8)' ariaLabel = 'Resume' disabled = { ! paused } onClick = { ( ) = > {
2021-03-01 21:20:04 +01:00
window . dispatch ( { event : 'resume' } ) ;
2021-02-12 19:11:30 +01:00
} } > < / ToolbarButton >
2024-10-14 23:04:24 +02:00
< ToolbarButton icon = 'debug-pause' title = 'Pause (F8)' ariaLabel = 'Pause' disabled = { paused } onClick = { ( ) = > {
2021-03-01 21:20:04 +01:00
window . dispatch ( { event : 'pause' } ) ;
2021-02-12 19:11:30 +01:00
} } > < / ToolbarButton >
2024-10-14 23:04:24 +02:00
< ToolbarButton icon = 'debug-step-over' title = 'Step over (F10)' ariaLabel = 'Step over' disabled = { ! paused } onClick = { ( ) = > {
2021-03-01 21:20:04 +01:00
window . dispatch ( { event : 'step' } ) ;
2021-02-12 19:11:30 +01:00
} } > < / ToolbarButton >
2021-09-27 18:58:08 +02:00
< div style = { { flex : 'auto' } } > < / div >
2021-06-11 01:52:59 +02:00
< div > Target : < / div >
2024-09-24 04:13:45 +02:00
< SourceChooser fileId = { fileId } sources = { sources } setFileId = { fileId = > {
2024-10-14 23:04:24 +02:00
setSelectedFileId ( fileId ) ;
2024-09-24 04:13:45 +02:00
window . dispatch ( { event : 'fileChanged' , params : { file : fileId } } ) ;
} } / >
2021-02-17 23:05:41 +01:00
< ToolbarButton icon = 'clear-all' title = 'Clear' disabled = { ! source || ! source . text } onClick = { ( ) = > {
2021-03-01 21:20:04 +01:00
window . dispatch ( { event : 'clear' } ) ;
2021-02-05 23:24:27 +01:00
} } > < / ToolbarButton >
2022-11-11 02:20:09 +01:00
< ToolbarButton icon = 'color-mode' title = 'Toggle color mode' toggled = { false } onClick = { ( ) = > toggleTheme ( ) } > < / ToolbarButton >
2021-01-28 23:25:10 +01:00
< / Toolbar >
2024-07-31 12:48:46 +02:00
< SplitView
sidebarSize = { 200 }
main = { < CodeMirrorWrapper text = { source . text } language = { source . language } highlight = { source . highlight } revealLine = { source . revealLine } readOnly = { true } lineNumbers = { true } / > }
sidebar = { < TabbedPane
2024-10-23 01:36:03 +02:00
rightToolbar = { selectedTab === 'locator' || selectedTab === 'aria' ? [ < ToolbarButton key = { 1 } icon = 'files' title = 'Copy' onClick = { ( ) = > copy ( ( selectedTab === 'locator' ? locator : ariaSnapshot ) || '' ) } / > ] : [ ] }
2023-09-07 02:13:25 +02:00
tabs = { [
{
id : 'locator' ,
title : 'Locator' ,
2024-11-16 01:19:35 +01:00
render : ( ) = > < CodeMirrorWrapper text = { locator } placeholder = 'Type locator to inspect' language = { source . language } focusOnChange = { true } onChange = { onEditorChange } wrapLines = { true } / >
2023-09-07 02:13:25 +02:00
} ,
{
id : 'log' ,
title : 'Log' ,
2024-07-31 12:48:46 +02:00
render : ( ) = > < CallLogView language = { source . language } log = { Array . from ( log . values ( ) ) } / >
2023-09-07 02:13:25 +02:00
} ,
2024-10-23 01:36:03 +02:00
{
id : 'aria' ,
2024-11-16 01:19:35 +01:00
title : 'Aria' ,
render : ( ) = > < CodeMirrorWrapper text = { ariaSnapshot || '' } placeholder = 'Type aria template to match' language = { 'yaml' } onChange = { onAriaEditorChange } highlight = { ariaSnapshotErrors } wrapLines = { true } / >
2024-10-23 01:36:03 +02:00
} ,
2023-09-07 02:13:25 +02:00
] }
selectedTab = { selectedTab }
setSelectedTab = { setSelectedTab }
2024-07-31 12:48:46 +02:00
/ > }
/ >
2021-01-28 23:25:10 +01:00
< / div > ;
} ;
2024-11-08 16:43:01 +01:00
function parseAriaSnapshot ( ariaSnapshot : string ) : { fragment? : ParsedYaml , errors : SourceHighlight [ ] } {
const lineCounter = new yaml . LineCounter ( ) ;
2024-11-09 02:18:51 +01:00
const yamlDoc = yaml . parseDocument ( ariaSnapshot , {
keepSourceTokens : true ,
lineCounter ,
prettyErrors : false ,
} ) ;
const errors : SourceHighlight [ ] = [ ] ;
for ( const error of yamlDoc . errors ) {
errors . push ( {
line : lineCounter.linePos ( error . pos [ 0 ] ) . line ,
2024-11-11 18:40:50 +01:00
type : 'subtle-error' ,
2024-11-09 02:18:51 +01:00
message : error.message ,
2024-11-08 16:43:01 +01:00
} ) ;
}
2024-11-09 02:18:51 +01:00
if ( yamlDoc . errors . length )
return { errors } ;
2024-11-08 16:43:01 +01:00
const handleKey = ( key : yaml.Scalar < string > ) = > {
try {
parseAriaKey ( key . value ) ;
} catch ( e ) {
const keyError = e as AriaKeyError ;
2024-11-11 18:40:50 +01:00
const linePos = lineCounter . linePos ( key . srcToken ! . offset + keyError . pos ) ;
2024-11-08 16:43:01 +01:00
errors . push ( {
2024-11-09 02:18:51 +01:00
message : keyError.shortMessage ,
2024-11-11 18:40:50 +01:00
line : linePos.line ,
column : linePos.col ,
type : 'subtle-error' ,
2024-11-08 16:43:01 +01:00
} ) ;
}
} ;
const visitSeq = ( seq : yaml.YAMLSeq ) = > {
for ( const item of seq . items ) {
if ( item instanceof yaml . YAMLMap ) {
const map = item as yaml . YAMLMap ;
for ( const entry of map . items ) {
if ( entry . key instanceof yaml . Scalar )
handleKey ( entry . key ) ;
if ( entry . value instanceof yaml . YAMLSeq )
visitSeq ( entry . value ) ;
}
continue ;
}
if ( item instanceof yaml . Scalar )
handleKey ( item ) ;
}
} ;
visitSeq ( yamlDoc . contents as yaml . YAMLSeq ) ;
return errors . length ? { errors } : { fragment : yamlDoc.toJSON ( ) , errors } ;
}