diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index c78d8d4065..69fe959f81 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -34,7 +34,8 @@ import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } fr import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; import { cacheNormalizedWhitespaces, escapeHTML, escapeHTMLAttribute, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils'; -import { generateSimpleDom, generateSimpleDomNode, selectorForSimpleDomNodeId } from './simpleDom'; +import { selectorForSimpleDomNodeId, generateSimpleDomNode } from './simpleDom'; +import type { SimpleDomNode } from './simpleDom'; export type FrameExpectParams = Omit & { expectedValue?: any }; @@ -79,15 +80,12 @@ export class InjectedScript { endAriaCaches, escapeHTML, escapeHTMLAttribute, - generateSimpleDom: generateSimpleDom.bind(undefined, this), - generateSimpleDomNode: generateSimpleDomNode.bind(undefined, this), getAriaRole, getElementAccessibleDescription, getElementAccessibleName, isElementVisible, isInsideScope, normalizeWhiteSpace, - selectorForSimpleDomNodeId: selectorForSimpleDomNodeId.bind(undefined, this), }; // eslint-disable-next-line no-restricted-globals @@ -1314,6 +1312,17 @@ export class InjectedScript { } throw this.createStacklessError('Unknown expect matcher: ' + expression); } + + generateSimpleDomNode(selector: string): SimpleDomNode | undefined { + const element = this.querySelector(this.parseSelector(selector), this.document.documentElement, true); + if (!element) + return; + return generateSimpleDomNode(this, element); + } + + selectorForSimpleDomNodeId(nodeId: string) { + return selectorForSimpleDomNodeId(this, nodeId); + } } const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']); diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 95885e22d3..8cbf11964f 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -24,8 +24,8 @@ import clipPaths from './clipPaths'; import type { SimpleDomNode } from '../simpleDom'; interface RecorderDelegate { - performAction?(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode): Promise; - recordAction?(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise; + performAction?(action: actions.PerformOnRecordAction): Promise; + recordAction?(action: actions.Action): Promise; setSelector?(selector: string): Promise; setMode?(mode: Mode): Promise; setOverlayState?(state: OverlayState): Promise; @@ -931,7 +931,6 @@ export class Recorder { testIdAttributeName: 'data-testid', language: 'javascript', overlay: { offsetX: 0 }, - generateSimpleDom: false, }; readonly document: Document; private _delegate: RecorderDelegate = {}; @@ -1186,13 +1185,11 @@ export class Recorder { } async performAction(action: actions.PerformOnRecordAction) { - const simpleDomNode = this._generateSimpleDomNode(action); - await this._delegate.performAction?.(action, simpleDomNode).catch(() => {}); + await this._delegate.performAction?.(action).catch(() => {}); } recordAction(action: actions.Action) { - const simpleDomNode = this._generateSimpleDomNode(action); - void this._delegate.recordAction?.(action, simpleDomNode); + void this._delegate.recordAction?.(action); } setOverlayState(state: { offsetX: number; }) { @@ -1202,18 +1199,6 @@ export class Recorder { setSelector(selector: string) { void this._delegate.setSelector?.(selector); } - - private _generateSimpleDomNode(action: actions.Action): SimpleDomNode | undefined { - if (!this.state.generateSimpleDom) - return; - if (!('selector' in action)) - return; - - const element = this.injectedScript.querySelector(this.injectedScript.parseSelector(action.selector), this.document.documentElement, true); - if (!element) - return; - return this.injectedScript.utils.generateSimpleDomNode(element); - } } class Dialog { diff --git a/packages/playwright-core/src/server/injected/simpleDom.ts b/packages/playwright-core/src/server/injected/simpleDom.ts index 878b8021dd..c31862cd6c 100644 --- a/packages/playwright-core/src/server/injected/simpleDom.ts +++ b/packages/playwright-core/src/server/injected/simpleDom.ts @@ -77,10 +77,11 @@ function generate(injectedScript: InjectedScript, target?: Element): { dom: Simp const name = injectedScript.utils.getElementAccessibleName(element, false); const structuralId = String(++lastId); elements.set(structuralId, element); - const tag = renderTag(injectedScript, role, name, structuralId, { value }); - if (element === target) - resultTarget = { tag, id: structuralId }; - tokens.push(tag); + tokens.push(renderTag(injectedScript, role, name, structuralId, { value })); + if (element === target) { + const tagNoValue = renderTag(injectedScript, role, name, structuralId); + resultTarget = { tag: tagNoValue, id: structuralId }; + } return; } } diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index 79b1bde22e..97316c2f9e 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -72,7 +72,7 @@ export class Recorder implements InstrumentationListener { constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { this._mode = params.mode || 'none'; - this._contextRecorder = new ContextRecorder(context, params); + this._contextRecorder = new ContextRecorder(context, params, {}); this._context = context; this._omitCallTracking = !!params.omitCallTracking; this._debugger = context.debugger(); @@ -160,7 +160,6 @@ export class Recorder implements InstrumentationListener { language: this._currentLanguage, testIdAttributeName: this._contextRecorder.testIdAttributeName(), overlay: this._overlayState, - generateSimpleDom: false, }; return uiState; }); diff --git a/packages/playwright-core/src/server/recorder/contextRecorder.ts b/packages/playwright-core/src/server/recorder/contextRecorder.ts index 0d55a2bf32..17d2c2c130 100644 --- a/packages/playwright-core/src/server/recorder/contextRecorder.ts +++ b/packages/playwright-core/src/server/recorder/contextRecorder.ts @@ -25,7 +25,6 @@ import type { ActionInContext, FrameDescription, LanguageGeneratorOptions, Langu import { languageSet } from '../codegen/languages'; import type { Dialog } from '../dialog'; import { Frame } from '../frames'; -import type { SimpleDomNode } from '../injected/simpleDom'; import { Page } from '../page'; import type * as actions from './recorderActions'; import { performAction } from './recorderRunner'; @@ -35,6 +34,10 @@ import { generateCode } from '../codegen/language'; type BindingSource = { frame: Frame, page: Page }; +export interface ContextRecorderDelegate { + rewriteActionInContext?(pageAliases: Map, actionInContext: ActionInContext): Promise; +} + export class ContextRecorder extends EventEmitter { static Events = { Change: 'change' @@ -48,15 +51,17 @@ export class ContextRecorder extends EventEmitter { private _timers = new Set(); private _context: BrowserContext; private _params: channels.BrowserContextRecorderSupplementEnableParams; + private _delegate: ContextRecorderDelegate; private _recorderSources: Source[]; private _throttledOutputFile: ThrottledFile | null = null; private _orderedLanguages: LanguageGenerator[] = []; private _listeners: RegisteredListener[] = []; - constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { + constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams, delegate: ContextRecorderDelegate) { super(); this._context = context; this._params = params; + this._delegate = delegate; this._recorderSources = []; const language = params.language || context.attribution.playwright.options.sdkLanguage; this.setOutput(language, params.outputFile); @@ -134,11 +139,11 @@ export class ContextRecorder extends EventEmitter { // Input actions that potentially lead to navigation are intercepted on the page and are // performed by the Playwright. await this._context.exposeBinding('__pw_recorderPerformAction', false, - (source: BindingSource, action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) => this._performAction(source.frame, action, simpleDomNode)); + (source: BindingSource, action: actions.PerformOnRecordAction) => this._performAction(source.frame, action)); // Other non-essential actions are simply being recorded. await this._context.exposeBinding('__pw_recorderRecordAction', false, - (source: BindingSource, action: actions.Action, simpleDomNode?: SimpleDomNode) => this._recordAction(source.frame, action, simpleDomNode)); + (source: BindingSource, action: actions.Action) => this._recordAction(source.frame, action)); await this._context.extendInjectedScript(recorderSource.source); } @@ -218,7 +223,7 @@ export class ContextRecorder extends EventEmitter { return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid'; } - private async _performAction(frame: Frame, action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) { + private async _performAction(frame: Frame, action: actions.PerformOnRecordAction) { // Commit last action so that no further signals are added to it. this._collection.commitLastAction(); @@ -226,9 +231,11 @@ export class ContextRecorder extends EventEmitter { const actionInContext: ActionInContext = { frame: frameDescription, action, - description: undefined, // TODO: generate description based on simple dom node. + description: undefined, }; + await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); + this._collection.willPerformAction(actionInContext); const success = await performAction(this._pageAliases, actionInContext); if (success) { @@ -239,7 +246,7 @@ export class ContextRecorder extends EventEmitter { } } - private async _recordAction(frame: Frame, action: actions.Action, simpleDomNode?: SimpleDomNode) { + private async _recordAction(frame: Frame, action: actions.Action) { // Commit last action so that no further signals are added to it. this._collection.commitLastAction(); @@ -247,8 +254,11 @@ export class ContextRecorder extends EventEmitter { const actionInContext: ActionInContext = { frame: frameDescription, action, - description: undefined, // TODO: generate description based on simple dom node. + description: undefined, }; + + await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext); + this._setCommittedAfterTimeout(actionInContext); this._collection.addAction(actionInContext); } diff --git a/packages/playwright-core/src/server/recorder/recorderUtils.ts b/packages/playwright-core/src/server/recorder/recorderUtils.ts index b044da87ac..b4949115d2 100644 --- a/packages/playwright-core/src/server/recorder/recorderUtils.ts +++ b/packages/playwright-core/src/server/recorder/recorderUtils.ts @@ -16,6 +16,10 @@ import type { CallMetadata } from '../instrumentation'; import type { CallLog, CallLogStatus } from '@recorder/recorderTypes'; +import type { Page } from '../page'; +import type { ActionInContext } from '../codegen/types'; +import type { Frame } from '../frames'; +import type * as actions from './recorderActions'; export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog { let title = metadata.apiName || metadata.method; @@ -48,3 +52,23 @@ export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus) export function buildFullSelector(framePath: string[], selector: string) { return [...framePath, selector].join(' >> internal:control=enter-frame >> '); } + +export function mainFrameForAction(pageAliases: Map, actionInContext: ActionInContext): Frame { + const pageAlias = actionInContext.frame.pageAlias; + const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0]; + if (!page) + throw new Error('Internal error: page not found'); + return page.mainFrame(); +} + +export async function frameForAction(pageAliases: Map, actionInContext: ActionInContext, action: actions.ActionWithSelector): Promise { + const pageAlias = actionInContext.frame.pageAlias; + const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0]; + if (!page) + throw new Error('Internal error: page not found'); + const fullSelector = buildFullSelector(actionInContext.frame.framePath, action.selector); + const result = await page.mainFrame().selectors.resolveFrameForSelector(fullSelector); + if (!result) + throw new Error('Internal error: frame not found'); + return result.frame; +} diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index 09cb02e3e2..c56984ad6d 100644 --- a/packages/recorder/src/recorderTypes.ts +++ b/packages/recorder/src/recorderTypes.ts @@ -51,7 +51,6 @@ export type UIState = { language: Language; testIdAttributeName: string; overlay: OverlayState; - generateSimpleDom: boolean; }; export type CallLogStatus = 'in-progress' | 'done' | 'error' | 'paused'; diff --git a/packages/trace-viewer/src/ui/snapshotTab.tsx b/packages/trace-viewer/src/ui/snapshotTab.tsx index 578f787f3e..4faa668677 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -254,7 +254,6 @@ export const InspectModeController: React.FunctionComponent<{ language: sdkLanguage, testIdAttributeName, overlay: { offsetX: 0 }, - generateSimpleDom: false, }, { async setSelector(selector: string) { setHighlightedLocator(asLocator(sdkLanguage, frameSelector + selector));