2021-02-18 02:51:57 +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 .
* /
import './snapshotTab.css' ;
import * as React from 'react' ;
2022-09-21 03:41:51 +02:00
import type { ActionTraceEvent } from '@trace/trace' ;
2024-09-06 16:24:33 +02:00
import { context , type MultiTraceModel , pageForAction , prevInList } from './modelUtil' ;
2023-02-17 20:19:53 +01:00
import { Toolbar } from '@web/components/toolbar' ;
import { ToolbarButton } from '@web/components/toolbarButton' ;
2024-09-06 16:24:33 +02:00
import { clsx , useMeasure , useSetting } from '@web/uiUtils' ;
2023-02-17 20:19:53 +01:00
import { InjectedScript } from '@injected/injectedScript' ;
2024-01-23 20:29:40 +01:00
import { Recorder } from '@injected/recorder/recorder' ;
2023-05-23 21:17:26 +02:00
import ConsoleAPI from '@injected/consoleApi' ;
2023-02-17 20:19:53 +01:00
import { asLocator } from '@isomorphic/locatorGenerators' ;
import type { Language } from '@isomorphic/locatorGenerators' ;
import { locatorOrSelectorAsSelector } from '@isomorphic/locatorParser' ;
import { TabbedPaneTab } from '@web/components/tabbedPane' ;
2023-08-19 02:53:03 +02:00
import { BrowserFrame } from './browserFrame' ;
2021-02-18 02:51:57 +01:00
2024-09-06 16:24:33 +02:00
function findClosest < T extends { timestamp : number } > ( items : T [ ] , target : number ) {
return items . find ( ( item , index ) = > {
if ( index === items . length - 1 )
return true ;
const next = items [ index + 1 ] ;
return Math . abs ( item . timestamp - target ) < Math . abs ( next . timestamp - target ) ;
} ) ;
}
2021-02-18 02:51:57 +01:00
export const SnapshotTab : React.FunctionComponent < {
2021-05-14 05:41:32 +02:00
action : ActionTraceEvent | undefined ,
2024-09-06 16:24:33 +02:00
model? : MultiTraceModel ,
2023-02-17 20:19:53 +01:00
sdkLanguage : Language ,
testIdAttributeName : string ,
2023-08-19 02:53:03 +02:00
isInspecting : boolean ,
setIsInspecting : ( isInspecting : boolean ) = > void ,
highlightedLocator : string ,
setHighlightedLocator : ( locator : string ) = > void ,
2024-06-28 19:36:11 +02:00
openPage ? : ( url : string , target? : string ) = > Window | any ,
2024-09-06 16:24:33 +02:00
} > = ( { action , model , sdkLanguage , testIdAttributeName , isInspecting , setIsInspecting , highlightedLocator , setHighlightedLocator , openPage } ) = > {
2021-02-18 02:51:57 +01:00
const [ measure , ref ] = useMeasure < HTMLDivElement > ( ) ;
2023-03-18 04:20:35 +01:00
const [ snapshotTab , setSnapshotTab ] = React . useState < 'action' | 'before' | 'after' > ( 'action' ) ;
2024-09-06 16:24:33 +02:00
const [ showScreenshotInsteadOfSnapshot ] = useSetting ( 'screenshot-instead-of-snapshot' , false ) ;
2021-02-18 02:51:57 +01:00
2023-12-07 15:27:49 +01:00
type Snapshot = { action : ActionTraceEvent , snapshotName : string , point ? : { x : number , y : number } } ;
2023-03-18 04:20:35 +01:00
const { snapshots } = React . useMemo ( ( ) = > {
if ( ! action )
return { snapshots : { } } ;
2023-03-16 06:33:40 +01:00
2023-03-18 04:20:35 +01:00
// if the action has no beforeSnapshot, use the last available afterSnapshot.
2023-09-01 01:52:54 +02:00
let beforeSnapshot : Snapshot | undefined = action . beforeSnapshot ? { action , snapshotName : action.beforeSnapshot } : undefined ;
2023-03-18 04:20:35 +01:00
let a = action ;
while ( ! beforeSnapshot && a ) {
a = prevInList ( a ) ;
beforeSnapshot = a ? . afterSnapshot ? { action : a , snapshotName : a?.afterSnapshot } : undefined ;
2021-08-11 02:06:14 +02:00
}
2023-09-01 01:52:54 +02:00
const afterSnapshot : Snapshot | undefined = action . afterSnapshot ? { action , snapshotName : action.afterSnapshot } : beforeSnapshot ;
2023-12-07 15:27:49 +01:00
const actionSnapshot : Snapshot | undefined = action . inputSnapshot ? { action , snapshotName : action.inputSnapshot } : afterSnapshot ;
if ( actionSnapshot )
actionSnapshot . point = action . point ;
2023-03-18 04:20:35 +01:00
return { snapshots : { action : actionSnapshot , before : beforeSnapshot , after : afterSnapshot } } ;
} , [ action ] ) ;
2021-08-11 02:06:14 +02:00
2023-09-01 01:52:54 +02:00
const { snapshotInfoUrl , snapshotUrl , popoutUrl } = React . useMemo ( ( ) = > {
2023-03-18 04:20:35 +01:00
const snapshot = snapshots [ snapshotTab ] ;
if ( ! snapshot )
return { snapshotUrl : kBlankSnapshotUrl } ;
const params = new URLSearchParams ( ) ;
params . set ( 'trace' , context ( snapshot . action ) . traceUrl ) ;
params . set ( 'name' , snapshot . snapshotName ) ;
2023-12-07 15:27:49 +01:00
if ( snapshot . point ) {
params . set ( 'pointX' , String ( snapshot . point . x ) ) ;
params . set ( 'pointY' , String ( snapshot . point . y ) ) ;
}
2023-03-18 04:20:35 +01:00
const snapshotUrl = new URL ( ` snapshot/ ${ snapshot . action . pageId } ? ${ params . toString ( ) } ` , window . location . href ) . toString ( ) ;
const snapshotInfoUrl = new URL ( ` snapshotInfo/ ${ snapshot . action . pageId } ? ${ params . toString ( ) } ` , window . location . href ) . toString ( ) ;
const popoutParams = new URLSearchParams ( ) ;
popoutParams . set ( 'r' , snapshotUrl ) ;
popoutParams . set ( 'trace' , context ( snapshot . action ) . traceUrl ) ;
2023-12-07 15:27:49 +01:00
if ( snapshot . point ) {
popoutParams . set ( 'pointX' , String ( snapshot . point . x ) ) ;
popoutParams . set ( 'pointY' , String ( snapshot . point . y ) ) ;
}
2023-06-21 21:10:50 +02:00
const popoutUrl = new URL ( ` snapshot.html? ${ popoutParams . toString ( ) } ` , window . location . href ) . toString ( ) ;
2023-09-01 01:52:54 +02:00
return { snapshots , snapshotInfoUrl , snapshotUrl , popoutUrl } ;
2023-03-18 04:20:35 +01:00
} , [ snapshots , snapshotTab ] ) ;
2021-02-18 02:51:57 +01:00
2023-03-21 15:40:54 +01:00
const iframeRef0 = React . useRef < HTMLIFrameElement > ( null ) ;
const iframeRef1 = React . useRef < HTMLIFrameElement > ( null ) ;
2024-09-06 16:24:33 +02:00
const [ snapshotInfo , setSnapshotInfo ] = React . useState < { viewport : typeof kDefaultViewport , url : string , timestamp? : number } > ( { viewport : kDefaultViewport , url : '' , timestamp : undefined } ) ;
2023-03-21 15:40:54 +01:00
const loadingRef = React . useRef ( { iteration : 0 , visibleIframe : 0 } ) ;
2021-02-18 02:51:57 +01:00
React . useEffect ( ( ) = > {
2021-08-11 02:06:14 +02:00
( async ( ) = > {
2023-03-21 15:40:54 +01:00
const thisIteration = loadingRef . current . iteration + 1 ;
const newVisibleIframe = 1 - loadingRef . current . visibleIframe ;
loadingRef . current . iteration = thisIteration ;
2024-09-06 16:24:33 +02:00
const newSnapshotInfo = { url : '' , viewport : kDefaultViewport , timestamp : undefined } ;
2021-11-03 01:35:23 +01:00
if ( snapshotInfoUrl ) {
const response = await fetch ( snapshotInfoUrl ) ;
const info = await response . json ( ) ;
2023-03-21 15:40:54 +01:00
if ( ! info . error ) {
newSnapshotInfo . url = info . url ;
newSnapshotInfo . viewport = info . viewport ;
2024-09-06 16:24:33 +02:00
newSnapshotInfo . timestamp = info . timestamp ;
2023-03-21 15:40:54 +01:00
}
2021-03-10 20:43:26 +01:00
}
2023-03-21 15:40:54 +01:00
// Interrupted by another load - bail out.
if ( loadingRef . current . iteration !== thisIteration )
2021-08-11 02:06:14 +02:00
return ;
2023-03-21 15:40:54 +01:00
const iframe = [ iframeRef0 , iframeRef1 ] [ newVisibleIframe ] . current ;
if ( iframe ) {
let loadedCallback = ( ) = > { } ;
const loadedPromise = new Promise < void > ( f = > loadedCallback = f ) ;
try {
iframe . addEventListener ( 'load' , loadedCallback ) ;
iframe . addEventListener ( 'error' , loadedCallback ) ;
// Try preventing history entry from being created.
if ( iframe . contentWindow )
2023-09-01 01:52:54 +02:00
iframe . contentWindow . location . replace ( snapshotUrl ) ;
2023-03-21 15:40:54 +01:00
else
2023-09-01 01:52:54 +02:00
iframe . src = snapshotUrl ;
2023-03-21 15:40:54 +01:00
await loadedPromise ;
} catch {
} finally {
iframe . removeEventListener ( 'load' , loadedCallback ) ;
iframe . removeEventListener ( 'error' , loadedCallback ) ;
}
2021-08-11 02:06:14 +02:00
}
2023-03-21 15:40:54 +01:00
// Interrupted by another load - bail out.
if ( loadingRef . current . iteration !== thisIteration )
return ;
loadingRef . current . visibleIframe = newVisibleIframe ;
setSnapshotInfo ( newSnapshotInfo ) ;
2021-08-11 02:06:14 +02:00
} ) ( ) ;
2023-09-01 01:52:54 +02:00
} , [ snapshotUrl , snapshotInfoUrl ] ) ;
2021-08-06 01:04:09 +02:00
2022-11-22 17:41:52 +01:00
const windowHeaderHeight = 40 ;
2023-01-30 23:32:45 +01:00
const snapshotContainerSize = {
width : snapshotInfo.viewport.width ,
height : snapshotInfo.viewport.height + windowHeaderHeight ,
} ;
const scale = Math . min ( measure . width / snapshotContainerSize . width , measure . height / snapshotContainerSize . height , 1 ) ;
const translate = {
x : ( measure . width - snapshotContainerSize . width ) / 2 ,
y : ( measure . height - snapshotContainerSize . height ) / 2 ,
2021-03-11 20:22:59 +01:00
} ;
2023-02-17 20:19:53 +01:00
2024-09-06 16:24:33 +02:00
const page = action ? pageForAction ( action ) : undefined ;
const screencastFrame = React . useMemo (
( ) = > {
if ( snapshotInfo . timestamp && page ? . screencastFrames )
return findClosest ( page . screencastFrames , snapshotInfo . timestamp ) ;
} ,
[ page ? . screencastFrames , snapshotInfo . timestamp ]
) ;
2021-04-19 23:09:50 +02:00
return < div
className = 'snapshot-tab'
tabIndex = { 0 }
onKeyDown = { event = > {
2023-02-18 02:25:47 +01:00
if ( event . key === 'Escape' ) {
if ( isInspecting )
setIsInspecting ( false ) ;
}
2021-04-19 23:09:50 +02:00
} }
2023-02-17 20:19:53 +01:00
>
2023-02-18 02:25:47 +01:00
< InspectModeController
isInspecting = { isInspecting }
sdkLanguage = { sdkLanguage }
testIdAttributeName = { testIdAttributeName }
highlightedLocator = { highlightedLocator }
setHighlightedLocator = { setHighlightedLocator }
2023-06-20 20:39:21 +02:00
iframe = { iframeRef0 . current }
iteration = { loadingRef . current . iteration } / >
2023-03-21 15:40:54 +01:00
< InspectModeController
isInspecting = { isInspecting }
sdkLanguage = { sdkLanguage }
testIdAttributeName = { testIdAttributeName }
highlightedLocator = { highlightedLocator }
setHighlightedLocator = { setHighlightedLocator }
2023-06-20 20:39:21 +02:00
iframe = { iframeRef1 . current }
iteration = { loadingRef . current . iteration } / >
2023-02-17 20:19:53 +01:00
< Toolbar >
2024-09-06 16:24:33 +02:00
< ToolbarButton className = 'pick-locator' title = { showScreenshotInsteadOfSnapshot ? 'Disable "screenshots instead of snapshots" to pick a locator' : 'Pick locator' } icon = 'target' toggled = { isInspecting } onClick = { ( ) = > setIsInspecting ( ! isInspecting ) } disabled = { showScreenshotInsteadOfSnapshot } / >
2023-03-18 04:20:35 +01:00
{ [ 'action' , 'before' , 'after' ] . map ( tab = > {
2023-02-17 20:19:53 +01:00
return < TabbedPaneTab
2024-08-20 14:16:28 +02:00
key = { tab }
2023-03-18 04:20:35 +01:00
id = { tab }
title = { renderTitle ( tab ) }
selected = { snapshotTab === tab }
onSelect = { ( ) = > setSnapshotTab ( tab as 'action' | 'before' | 'after' ) }
2023-02-17 20:19:53 +01:00
> < / TabbedPaneTab > ;
2021-04-20 04:50:11 +02:00
} ) }
2023-02-17 20:19:53 +01:00
< div style = { { flex : 'auto' } } > < / div >
2024-09-06 16:24:33 +02:00
< ToolbarButton icon = 'link-external' title = { showScreenshotInsteadOfSnapshot ? 'Not available when showing screenshot' : 'Open snapshot in a new tab' } disabled = { ! popoutUrl || showScreenshotInsteadOfSnapshot } onClick = { ( ) = > {
2024-06-28 19:36:11 +02:00
if ( ! openPage )
openPage = window . open ;
const win = openPage ( popoutUrl || '' , '_blank' ) ;
2023-05-23 21:17:26 +02:00
win ? . addEventListener ( 'DOMContentLoaded' , ( ) = > {
const injectedScript = new InjectedScript ( win as any , false , sdkLanguage , testIdAttributeName , 1 , 'chromium' , [ ] ) ;
new ConsoleAPI ( injectedScript ) ;
} ) ;
2023-02-17 20:19:53 +01:00
} } > < / ToolbarButton >
< / Toolbar >
2021-02-18 02:51:57 +01:00
< div ref = { ref } className = 'snapshot-wrapper' >
2023-03-18 04:20:35 +01:00
< div className = 'snapshot-container' style = { {
2023-01-30 23:32:45 +01:00
width : snapshotContainerSize.width + 'px' ,
height : snapshotContainerSize.height + 'px' ,
transform : ` translate( ${ translate . x } px, ${ translate . y } px) scale( ${ scale } ) ` ,
2021-02-18 02:51:57 +01:00
} } >
2023-08-19 02:53:03 +02:00
< BrowserFrame url = { snapshotInfo . url } / >
2024-09-06 16:24:33 +02:00
{ ( showScreenshotInsteadOfSnapshot && screencastFrame ) && < img alt = { ` Screenshot of ${ action ? . apiName } > ${ renderTitle ( snapshotTab ) } ` } src = { ` sha1/ ${ screencastFrame . sha1 } ` } width = { screencastFrame . width } height = { screencastFrame . height } / > }
< div className = 'snapshot-switcher' style = { showScreenshotInsteadOfSnapshot ? { display : 'none' } : undefined } >
2024-07-31 12:12:06 +02:00
< iframe ref = { iframeRef0 } name = 'snapshot' title = 'DOM Snapshot' className = { clsx ( loadingRef . current . visibleIframe === 0 && 'snapshot-visible' ) } > < / iframe >
< iframe ref = { iframeRef1 } name = 'snapshot' title = 'DOM Snapshot' className = { clsx ( loadingRef . current . visibleIframe === 1 && 'snapshot-visible' ) } > < / iframe >
2023-03-21 15:40:54 +01:00
< / div >
2023-03-18 04:20:35 +01:00
< / div >
2021-02-18 02:51:57 +01:00
< / div >
< / div > ;
} ;
2021-04-20 04:50:11 +02:00
function renderTitle ( snapshotTitle : string ) : string {
if ( snapshotTitle === 'before' )
return 'Before' ;
if ( snapshotTitle === 'after' )
return 'After' ;
if ( snapshotTitle === 'action' )
return 'Action' ;
return snapshotTitle ;
}
2021-11-23 20:36:18 +01:00
2023-02-18 02:25:47 +01:00
export const InspectModeController : React.FunctionComponent < {
iframe : HTMLIFrameElement | null ,
isInspecting : boolean ,
sdkLanguage : Language ,
testIdAttributeName : string ,
highlightedLocator : string ,
setHighlightedLocator : ( locator : string ) = > void ,
2023-06-20 20:39:21 +02:00
iteration : number ,
} > = ( { iframe , isInspecting , sdkLanguage , testIdAttributeName , highlightedLocator , setHighlightedLocator , iteration } ) = > {
2023-02-18 02:25:47 +01:00
React . useEffect ( ( ) = > {
2023-09-06 18:44:47 +02:00
const recorders : { recorder : Recorder , frameSelector : string } [ ] = [ ] ;
2023-09-07 01:14:40 +02:00
const isUnderTest = new URLSearchParams ( window . location . search ) . get ( 'isUnderTest' ) === 'true' ;
2023-03-18 04:20:35 +01:00
try {
2023-09-07 01:14:40 +02:00
createRecorders ( recorders , sdkLanguage , testIdAttributeName , isUnderTest , '' , iframe ? . contentWindow ) ;
2023-03-18 04:20:35 +01:00
} catch {
2023-09-06 18:44:47 +02:00
// Potential cross-origin exceptions.
2023-03-18 04:20:35 +01:00
}
2023-09-06 18:44:47 +02:00
for ( const { recorder , frameSelector } of recorders ) {
const actionSelector = locatorOrSelectorAsSelector ( sdkLanguage , highlightedLocator , testIdAttributeName ) ;
recorder . setUIState ( {
mode : isInspecting ? 'inspecting' : 'none' ,
2023-09-07 01:14:40 +02:00
actionSelector : actionSelector.startsWith ( frameSelector ) ? actionSelector . substring ( frameSelector . length ) . trim ( ) : undefined ,
2023-09-06 18:44:47 +02:00
language : sdkLanguage ,
testIdAttributeName ,
2023-11-07 21:58:41 +01:00
overlay : { offsetX : 0 } ,
2023-09-06 18:44:47 +02:00
} , {
2023-02-18 02:25:47 +01:00
async setSelector ( selector : string ) {
2023-11-17 01:31:34 +01:00
setHighlightedLocator ( asLocator ( sdkLanguage , frameSelector + selector ) ) ;
2023-09-06 18:44:47 +02:00
} ,
highlightUpdated() {
for ( const r of recorders ) {
if ( r . recorder !== recorder )
r . recorder . clearHighlight ( ) ;
}
2023-02-18 02:25:47 +01:00
}
} ) ;
}
2023-06-20 20:39:21 +02:00
} , [ iframe , isInspecting , highlightedLocator , setHighlightedLocator , sdkLanguage , testIdAttributeName , iteration ] ) ;
2023-02-18 02:25:47 +01:00
return < > < / > ;
} ;
2023-09-07 01:14:40 +02:00
function createRecorders ( recorders : { recorder : Recorder , frameSelector : string } [ ] , sdkLanguage : Language , testIdAttributeName : string , isUnderTest : boolean , parentFrameSelector : string , frameWindow : Window | null | undefined ) {
2023-09-06 18:44:47 +02:00
if ( ! frameWindow )
return ;
const win = frameWindow as any ;
if ( ! win . _recorder ) {
2023-09-07 01:14:40 +02:00
const injectedScript = new InjectedScript ( frameWindow as any , isUnderTest , sdkLanguage , testIdAttributeName , 1 , 'chromium' , [ ] ) ;
2023-09-06 18:44:47 +02:00
const recorder = new Recorder ( injectedScript ) ;
win . _injectedScript = injectedScript ;
win . _recorder = { recorder , frameSelector : parentFrameSelector } ;
}
recorders . push ( win . _recorder ) ;
for ( let i = 0 ; i < frameWindow . frames . length ; ++ i ) {
const childFrame = frameWindow . frames [ i ] ;
2024-01-23 20:29:40 +01:00
const frameSelector = childFrame . frameElement ? win . _injectedScript . generateSelectorSimple ( childFrame . frameElement , { omitInternalEngines : true , testIdAttributeName } ) + ' >> internal:control=enter-frame >> ' : '' ;
2023-09-07 01:14:40 +02:00
createRecorders ( recorders , sdkLanguage , testIdAttributeName , isUnderTest , parentFrameSelector + frameSelector , childFrame ) ;
2023-09-06 18:44:47 +02:00
}
}
2021-11-23 20:36:18 +01:00
const kDefaultViewport = { width : 1280 , height : 720 } ;
2023-03-18 04:20:35 +01:00
const kBlankSnapshotUrl = 'data:text/html,<body style="background: #ddd"></body>' ;