diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts index cbaa83e60b..5b7b6188da 100644 --- a/packages/playwright-core/src/server/injected/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder.ts @@ -25,6 +25,7 @@ interface RecorderDelegate { performAction?(action: actions.Action): Promise; recordAction?(action: actions.Action): Promise; setSelector?(selector: string): Promise; + highlightUpdated?(): void; } export class Recorder { @@ -41,16 +42,13 @@ export class Recorder { private _highlight: Highlight; private _testIdAttributeName: string = 'data-testid'; readonly document: Document; - private _delegate: RecorderDelegate; + private _delegate: RecorderDelegate = {}; - constructor(injectedScript: InjectedScript, delegate: RecorderDelegate) { + constructor(injectedScript: InjectedScript) { this.document = injectedScript.document; this._injectedScript = injectedScript; - this._delegate = delegate; this._highlight = new Highlight(injectedScript); - this.refreshListenersIfNeeded(); - if (injectedScript.isUnderTest) console.error('Recorder script ready for test'); // eslint-disable-line no-console } @@ -82,13 +80,20 @@ export class Recorder { this._highlight.install(); } - setUIState(state: UIState) { + setUIState(state: UIState, delegate: RecorderDelegate) { + this._delegate = delegate; + + if (state.mode !== 'none' || state.actionSelector) + this.refreshListenersIfNeeded(); + else + removeEventListeners(this._listeners); + const { mode, actionPoint, actionSelector, language, testIdAttributeName } = state; this._testIdAttributeName = testIdAttributeName; this._highlight.setLanguage(language); if (mode !== this._mode) { this._mode = mode; - this._clearHighlight(); + this.clearHighlight(); } if (actionPoint && this._actionPoint && actionPoint.x === this._actionPoint.x && actionPoint.y === this._actionPoint.y) { // All good. @@ -113,7 +118,7 @@ export class Recorder { } } - private _clearHighlight() { + clearHighlight() { this._hoveredModel = null; this._activeModel = null; this._updateHighlight(); @@ -267,6 +272,8 @@ export class Recorder { const elements = this._hoveredModel ? this._hoveredModel.elements : []; const selector = this._hoveredModel ? this._hoveredModel.selector : ''; this._highlight.updateHighlight(elements, selector, this._mode === 'recording'); + if (this._hoveredModel) + this._delegate.highlightUpdated?.(); } private _onInput(event: Event) { @@ -398,7 +405,7 @@ export class Recorder { } private async _performAction(action: actions.Action) { - this._clearHighlight(); + this.clearHighlight(); this._performingAction = true; await this._delegate.performAction?.(action).catch(() => {}); this._performingAction = false; @@ -512,7 +519,7 @@ export class PollingRecorder implements RecorderDelegate { private _pollRecorderModeTimer: NodeJS.Timeout | undefined; constructor(injectedScript: InjectedScript) { - this._recorder = new Recorder(injectedScript, this); + this._recorder = new Recorder(injectedScript); this._embedder = injectedScript.window as any; injectedScript.onGlobalListenersRemoved.add(() => this._recorder.refreshListenersIfNeeded()); @@ -533,7 +540,7 @@ export class PollingRecorder implements RecorderDelegate { this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod); return; } - this._recorder.setUIState(state); + this._recorder.setUIState(state, this); this._pollRecorderModeTimer = setTimeout(() => this._pollRecorderMode(), pollPeriod); } diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index c3d4919336..cbebb86c24 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -227,37 +227,54 @@ export const InspectModeController: React.FunctionComponent<{ iteration: number, }> = ({ iframe, isInspecting, sdkLanguage, testIdAttributeName, highlightedLocator, setHighlightedLocator, iteration }) => { React.useEffect(() => { - const win = iframe?.contentWindow as any; - let recorder: Recorder | undefined; + const recorders: { recorder: Recorder, frameSelector: string }[] = []; try { - if (!win) - return; - recorder = win._recorder; - if (!recorder && !isInspecting && !highlightedLocator) - return; + createRecorders(recorders, sdkLanguage, testIdAttributeName, '', iframe?.contentWindow); } catch { - // Potential cross-origin exception when accessing win._recorder. - return; + // Potential cross-origin exceptions. } - if (!recorder) { - const injectedScript = new InjectedScript(win, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []); - recorder = new Recorder(injectedScript, { + + for (const { recorder, frameSelector } of recorders) { + const actionSelector = locatorOrSelectorAsSelector(sdkLanguage, highlightedLocator, testIdAttributeName); + recorder.setUIState({ + mode: isInspecting ? 'inspecting' : 'none', + actionSelector, + language: sdkLanguage, + testIdAttributeName, + }, { async setSelector(selector: string) { - setHighlightedLocator(asLocator(sdkLanguage, selector, false /* isFrameLocator */, true /* playSafe */)); + setHighlightedLocator(asLocator(sdkLanguage, frameSelector + selector, false /* isFrameLocator */, true /* playSafe */)); + }, + highlightUpdated() { + for (const r of recorders) { + if (r.recorder !== recorder) + r.recorder.clearHighlight(); + } } }); - win._recorder = recorder; } - const actionSelector = locatorOrSelectorAsSelector(sdkLanguage, highlightedLocator, testIdAttributeName); - recorder.setUIState({ - mode: isInspecting ? 'inspecting' : 'none', - actionSelector, - language: sdkLanguage, - testIdAttributeName, - }); }, [iframe, isInspecting, highlightedLocator, setHighlightedLocator, sdkLanguage, testIdAttributeName, iteration]); return <>; }; +function createRecorders(recorders: { recorder: Recorder, frameSelector: string }[], sdkLanguage: Language, testIdAttributeName: string, parentFrameSelector: string, frameWindow: Window | null | undefined) { + if (!frameWindow) + return; + const win = frameWindow as any; + if (!win._recorder) { + const injectedScript = new InjectedScript(frameWindow as any, false, sdkLanguage, testIdAttributeName, 1, 'chromium', []); + 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]; + const frameSelector = childFrame.frameElement ? win._injectedScript.generateSelector(childFrame.frameElement, { omitInternalEngines: true, testIdAttributeName }) + ' >> internal:control=enter-frame >> ' : ''; + createRecorders(recorders, sdkLanguage, testIdAttributeName, parentFrameSelector + frameSelector, childFrame); + } +} + const kDefaultViewport = { width: 1280, height: 720 }; const kBlankSnapshotUrl = 'data:text/html,'; diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index d3632aa1d1..3f06591f24 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -996,3 +996,41 @@ test('should ignore 304 responses', async ({ page, server, runAndTrace }) => { const frame = await traceViewer.snapshotFrame('locator.click'); await expect(frame.locator('body')).toHaveCSS('background-color', 'rgb(123, 123, 123)'); }); + +test('should pick locator in iframe', async ({ page, runAndTrace, server }) => { + /* + iframe[id=frame1] + div Hello1 + iframe + div Hello2 + iframe[name=one] + div HelloNameOne + iframe[name=two] + dev HelloNameTwo + */ + const traceViewer = await runAndTrace(async () => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(`