From bc87467b2558505941ed6fb6b6f28251e5f29685 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 27 Aug 2024 11:52:14 -0700 Subject: [PATCH] chore: generate simple dom descriptions in codegen (#32333) --- .../src/server/codegen/codeGenerator.ts | 1 + .../src/server/codegen/javascript.ts | 8 +- packages/playwright-core/src/server/frames.ts | 2 +- .../src/server/injected/.eslintrc.js | 27 ++-- .../src/server/injected/clock.ts | 2 +- .../src/server/injected/injectedScript.ts | 48 ++++--- .../src/server/injected/recorder/DEPS.list | 2 +- .../src/server/injected/recorder/recorder.ts | 132 ++++++++++++------ .../src/server/injected/simpleDom.ts | 73 +++++++--- .../playwright-core/src/server/recorder.ts | 16 ++- packages/recorder/src/recorderTypes.ts | 1 + packages/trace-viewer/src/ui/snapshotTab.tsx | 1 + tests/library/role-utils.spec.ts | 12 +- 13 files changed, 217 insertions(+), 108 deletions(-) diff --git a/packages/playwright-core/src/server/codegen/codeGenerator.ts b/packages/playwright-core/src/server/codegen/codeGenerator.ts index bfc640b38efce..0818b247e1889 100644 --- a/packages/playwright-core/src/server/codegen/codeGenerator.ts +++ b/packages/playwright-core/src/server/codegen/codeGenerator.ts @@ -27,6 +27,7 @@ export type FrameDescription = { export type ActionInContext = { frame: FrameDescription; + description?: string; action: Action; committed?: boolean; }; diff --git a/packages/playwright-core/src/server/codegen/javascript.ts b/packages/playwright-core/src/server/codegen/javascript.ts index bc0f20e97ae38..1d1f82bae0bac 100644 --- a/packages/playwright-core/src/server/codegen/javascript.ts +++ b/packages/playwright-core/src/server/codegen/javascript.ts @@ -65,7 +65,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator { if (signals.download) formatter.add(`const download${signals.download.downloadAlias}Promise = ${pageAlias}.waitForEvent('download');`); - formatter.add(this._generateActionCall(subject, actionInContext)); + formatter.add(wrapWithStep(actionInContext.description, this._generateActionCall(subject, actionInContext))); if (signals.popup) formatter.add(`const ${signals.popup.popupAlias} = await ${signals.popup.popupAlias}Promise;`); @@ -259,3 +259,9 @@ export class JavaScriptFormatter { function quote(text: string) { return escapeWithQuotes(text, '\''); } + +function wrapWithStep(description: string | undefined, body: string) { + return description ? `await test.step(\`${description}\`, async () => { +${body} +});` : body; +} diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 3a60e796c49e7..9ae6560a5f846 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -800,7 +800,7 @@ export class Frame extends SdkObject { const result = await resolved.injected.evaluateHandle((injected, { info, root }) => { const elements = injected.querySelectorAll(info.parsed, root || document); const element: Element | undefined = elements[0]; - const visible = element ? injected.isVisible(element) : false; + const visible = element ? injected.utils.isElementVisible(element) : false; let log = ''; if (elements.length > 1) { if (info.strict) diff --git a/packages/playwright-core/src/server/injected/.eslintrc.js b/packages/playwright-core/src/server/injected/.eslintrc.js index e96e2a9f804d2..eccd5b787d4dd 100644 --- a/packages/playwright-core/src/server/injected/.eslintrc.js +++ b/packages/playwright-core/src/server/injected/.eslintrc.js @@ -1,10 +1,21 @@ +const path = require('path'); + module.exports = { - rules: { - "no-restricted-globals": [ - "error", - { "name": "window" }, - { "name": "document" }, - { "name": "globalThis" }, - ] - } + parser: "@typescript-eslint/parser", + plugins: ["@typescript-eslint", "notice"], + parserOptions: { + ecmaVersion: 9, + sourceType: "module", + project: path.join(__dirname, '../../../../../tsconfig.json'), + }, + rules: { + "no-restricted-globals": [ + "error", + { "name": "window" }, + { "name": "document" }, + { "name": "globalThis" }, + ], + '@typescript-eslint/no-floating-promises': 'error', + "@typescript-eslint/no-unnecessary-boolean-literal-compare": 2, + }, }; diff --git a/packages/playwright-core/src/server/injected/clock.ts b/packages/playwright-core/src/server/injected/clock.ts index 414d23b9584b3..b2daf190f387c 100644 --- a/packages/playwright-core/src/server/injected/clock.ts +++ b/packages/playwright-core/src/server/injected/clock.ts @@ -216,7 +216,7 @@ export class ClockController { const sinceLastSync = now - this._realTime!.lastSyncTicks; this._realTime!.lastSyncTicks = now; // eslint-disable-next-line no-console - this._runTo(shiftTicks(this._now.ticks, sinceLastSync)).catch(e => console.error(e)).then(() => this._updateRealTimeTimer()); + void this._runTo(shiftTicks(this._now.ticks, sinceLastSync)).catch(e => console.error(e)).then(() => this._updateRealTimeTimer()); }, callAt - this._now.ticks), }; } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 2323648bae379..c78d8d40653c6 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -29,11 +29,12 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser'; import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator'; import type * as channels from '@protocol/channels'; import { Highlight } from './highlight'; -import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription } from './roleUtils'; +import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, beginAriaCaches, endAriaCaches } from './roleUtils'; import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; -import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils'; +import { cacheNormalizedWhitespaces, escapeHTML, escapeHTMLAttribute, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils'; +import { generateSimpleDom, generateSimpleDomNode, selectorForSimpleDomNodeId } from './simpleDom'; export type FrameExpectParams = Omit & { expectedValue?: any }; @@ -66,7 +67,28 @@ export class InjectedScript { // eslint-disable-next-line no-restricted-globals readonly window: Window & typeof globalThis; readonly document: Document; - readonly utils = { isInsideScope, elementText, asLocator, normalizeWhiteSpace, cacheNormalizedWhitespaces }; + + // Recorder must use any external dependencies through InjectedScript. + // Otherwise it will end up with a copy of all modules it uses, and any + // module-level globals will be duplicated, which leads to subtle bugs. + readonly utils = { + asLocator, + beginAriaCaches, + cacheNormalizedWhitespaces, + elementText, + 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 constructor(window: Window & typeof globalThis, isUnderTest: boolean, sdkLanguage: Language, testIdAttributeNameForStrictErrorAndConsoleCodegen: string, stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine }[]) { @@ -426,10 +448,6 @@ export class InjectedScript { return new constrFunction(this, params); } - isVisible(element: Element): boolean { - return isElementVisible(element); - } - async viewportRatio(element: Element): Promise { return await new Promise(resolve => { const observer = new IntersectionObserver(entries => { @@ -567,9 +585,9 @@ export class InjectedScript { } if (state === 'visible') - return this.isVisible(element); + return isElementVisible(element); if (state === 'hidden') - return !this.isVisible(element); + return !isElementVisible(element); const disabled = getAriaDisabled(element); if (state === 'disabled') @@ -1296,18 +1314,6 @@ export class InjectedScript { } throw this.createStacklessError('Unknown expect matcher: ' + expression); } - - getElementAccessibleName(element: Element, includeHidden?: boolean): string { - return getElementAccessibleName(element, !!includeHidden); - } - - getElementAccessibleDescription(element: Element, includeHidden?: boolean): string { - return getElementAccessibleDescription(element, !!includeHidden); - } - - getAriaRole(element: Element) { - return getAriaRole(element); - } } 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/DEPS.list b/packages/playwright-core/src/server/injected/recorder/DEPS.list index ee39467feafb8..1f58b3d5d0b66 100644 --- a/packages/playwright-core/src/server/injected/recorder/DEPS.list +++ b/packages/playwright-core/src/server/injected/recorder/DEPS.list @@ -1,4 +1,4 @@ -# Recorder must use any external dependencies through InjectedScript. +# Recorder must use any external dependencies through injectedScript.utils. # Otherwise it will end up with a copy of all modules it uses, and any # module-level globals will be duplicated, which leads to subtle bugs. [*] diff --git a/packages/playwright-core/src/server/injected/recorder/recorder.ts b/packages/playwright-core/src/server/injected/recorder/recorder.ts index 6e573c3c5a08c..95885e22d3673 100644 --- a/packages/playwright-core/src/server/injected/recorder/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder/recorder.ts @@ -21,10 +21,11 @@ import type { Mode, OverlayState, UIState } from '@recorder/recorderTypes'; import type { ElementText } from '../selectorUtils'; import type { Highlight, HighlightOptions } from '../highlight'; import clipPaths from './clipPaths'; +import type { SimpleDomNode } from '../simpleDom'; interface RecorderDelegate { - performAction?(action: actions.PerformOnRecordAction): Promise; - recordAction?(action: actions.Action): Promise; + performAction?(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode): Promise; + recordAction?(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise; setSelector?(selector: string): Promise; setMode?(mode: Mode): Promise; setOverlayState?(state: OverlayState): Promise; @@ -168,7 +169,7 @@ class InspectTool implements RecorderTool { if (this._hoveredModel?.tooltipListItemSelected) this._reset(true); else if (this._assertVisibility) - this._recorder.delegate.setMode?.('recording'); + this._recorder.setMode('recording'); } } @@ -182,15 +183,15 @@ class InspectTool implements RecorderTool { private _commit(selector: string) { if (this._assertVisibility) { - this._recorder.delegate.recordAction?.({ + this._recorder.recordAction({ name: 'assertVisible', selector, signals: [], }); - this._recorder.delegate.setMode?.('recording'); + this._recorder.setMode('recording'); this._recorder.overlay?.flashToolSucceeded('assertingVisibility'); } else { - this._recorder.delegate.setSelector?.(selector); + this._recorder.setSelector(selector); } } @@ -338,7 +339,7 @@ class RecordActionTool implements RecorderTool { const target = this._recorder.deepEventTarget(event); if (target.nodeName === 'INPUT' && (target as HTMLInputElement).type.toLowerCase() === 'file') { - this._recorder.delegate.recordAction?.({ + this._recorder.recordAction({ name: 'setInputFiles', selector: this._activeModel!.selector, signals: [], @@ -348,7 +349,7 @@ class RecordActionTool implements RecorderTool { } if (isRangeInput(target)) { - this._recorder.delegate.recordAction?.({ + this._recorder.recordAction({ name: 'fill', // must use hoveredModel instead of activeModel for it to work in webkit selector: this._hoveredModel!.selector, @@ -367,7 +368,7 @@ class RecordActionTool implements RecorderTool { // Non-navigating actions are simply recorded by Playwright. if (this._consumedDueWrongTarget(event)) return; - this._recorder.delegate.recordAction?.({ + this._recorder.recordAction({ name: 'fill', selector: this._activeModel!.selector, signals: [], @@ -483,26 +484,27 @@ class RecordActionTool implements RecorderTool { return true; } - private async _performAction(action: actions.PerformOnRecordAction) { + private _performAction(action: actions.PerformOnRecordAction) { this._hoveredElement = null; this._hoveredModel = null; this._activeModel = null; this._recorder.updateHighlight(null, false); this._performingAction = true; - await this._recorder.delegate.performAction?.(action).catch(() => {}); - this._performingAction = false; - - // If that was a keyboard action, it similarly requires new selectors for active model. - this._onFocus(false); - - if (this._recorder.injectedScript.isUnderTest) { - // Serialize all to string as we cannot attribute console message to isolated world - // in Firefox. - console.error('Action performed for test: ' + JSON.stringify({ // eslint-disable-line no-console - hovered: this._hoveredModel ? (this._hoveredModel as any).selector : null, - active: this._activeModel ? (this._activeModel as any).selector : null, - })); - } + void this._recorder.performAction(action).then(() => { + this._performingAction = false; + + // If that was a keyboard action, it similarly requires new selectors for active model. + this._onFocus(false); + + if (this._recorder.injectedScript.isUnderTest) { + // Serialize all to string as we cannot attribute console message to isolated world + // in Firefox. + console.error('Action performed for test: ' + JSON.stringify({ // eslint-disable-line no-console + hovered: this._hoveredModel ? (this._hoveredModel as any).selector : null, + active: this._activeModel ? (this._activeModel as any).selector : null, + })); + } + }); } private _shouldGenerateKeyPressFor(event: KeyboardEvent): boolean { @@ -613,7 +615,7 @@ class TextAssertionTool implements RecorderTool { onKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') - this._recorder.delegate.setMode?.('recording'); + this._recorder.setMode('recording'); consumeEvent(event); } @@ -680,8 +682,8 @@ class TextAssertionTool implements RecorderTool { if (!this._action || !this._dialog.isShowing()) return; this._dialog.close(); - this._recorder.delegate.recordAction?.(this._action); - this._recorder.delegate.setMode?.('recording'); + this._recorder.recordAction(this._action); + this._recorder.setMode('recording'); } private _showDialog() { @@ -726,8 +728,8 @@ class TextAssertionTool implements RecorderTool { const action = this._generateAction(); if (!action) return; - this._recorder.delegate.recordAction?.(action); - this._recorder.delegate.setMode?.('recording'); + this._recorder.recordAction(action); + this._recorder.setMode('recording'); this._recorder.overlay?.flashToolSucceeded('assertingValue'); } } @@ -799,7 +801,7 @@ class Overlay { this._dragState = { offsetX: this._offsetX, dragStart: { x: (event as MouseEvent).clientX, y: 0 } }; }), addEventListener(this._recordToggle, 'click', () => { - this._recorder.delegate.setMode?.(this._recorder.state.mode === 'none' || this._recorder.state.mode === 'standby' || this._recorder.state.mode === 'inspecting' ? 'recording' : 'standby'); + this._recorder.setMode(this._recorder.state.mode === 'none' || this._recorder.state.mode === 'standby' || this._recorder.state.mode === 'inspecting' ? 'recording' : 'standby'); }), addEventListener(this._pickLocatorToggle, 'click', () => { const newMode: Record = { @@ -812,19 +814,19 @@ class Overlay { 'assertingVisibility': 'recording-inspecting', 'assertingValue': 'recording-inspecting', }; - this._recorder.delegate.setMode?.(newMode[this._recorder.state.mode]); + this._recorder.setMode(newMode[this._recorder.state.mode]); }), addEventListener(this._assertVisibilityToggle, 'click', () => { if (!this._assertVisibilityToggle.classList.contains('disabled')) - this._recorder.delegate.setMode?.(this._recorder.state.mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility'); + this._recorder.setMode(this._recorder.state.mode === 'assertingVisibility' ? 'recording' : 'assertingVisibility'); }), addEventListener(this._assertTextToggle, 'click', () => { if (!this._assertTextToggle.classList.contains('disabled')) - this._recorder.delegate.setMode?.(this._recorder.state.mode === 'assertingText' ? 'recording' : 'assertingText'); + this._recorder.setMode(this._recorder.state.mode === 'assertingText' ? 'recording' : 'assertingText'); }), addEventListener(this._assertValuesToggle, 'click', () => { if (!this._assertValuesToggle.classList.contains('disabled')) - this._recorder.delegate.setMode?.(this._recorder.state.mode === 'assertingValue' ? 'recording' : 'assertingValue'); + this._recorder.setMode(this._recorder.state.mode === 'assertingValue' ? 'recording' : 'assertingValue'); }), ]; } @@ -890,7 +892,7 @@ class Overlay { const halfGapSize = (this._recorder.injectedScript.window.innerWidth - this._measure.width) / 2 - 10; this._offsetX = Math.max(-halfGapSize, Math.min(halfGapSize, this._offsetX)); this._updateVisualPosition(); - this._recorder.delegate.setOverlayState?.({ offsetX: this._offsetX }); + this._recorder.setOverlayState({ offsetX: this._offsetX }); consumeEvent(event); return true; } @@ -924,9 +926,15 @@ export class Recorder { readonly highlight: Highlight; readonly overlay: Overlay | undefined; private _stylesheet: CSSStyleSheet; - state: UIState = { mode: 'none', testIdAttributeName: 'data-testid', language: 'javascript', overlay: { offsetX: 0 } }; + state: UIState = { + mode: 'none', + testIdAttributeName: 'data-testid', + language: 'javascript', + overlay: { offsetX: 0 }, + generateSimpleDom: false, + }; readonly document: Document; - delegate: RecorderDelegate = {}; + private _delegate: RecorderDelegate = {}; constructor(injectedScript: InjectedScript) { this.document = injectedScript.document; @@ -994,7 +1002,7 @@ export class Recorder { } setUIState(state: UIState, delegate: RecorderDelegate) { - this.delegate = delegate; + this._delegate = delegate; if (state.actionPoint && this.state.actionPoint && state.actionPoint.x === this.state.actionPoint.x && state.actionPoint.y === this.state.actionPoint.y) { // All good. @@ -1155,7 +1163,7 @@ export class Recorder { tooltipText = this.injectedScript.utils.asLocator(this.state.language, model.selector); this.highlight.updateHighlight(model?.elements || [], { ...model, tooltipText }); if (userGesture) - this.delegate.highlightUpdated?.(); + this._delegate.highlightUpdated?.(); } private _ignoreOverlayEvent(event: Event) { @@ -1172,6 +1180,40 @@ export class Recorder { } return event.composedPath()[0] as HTMLElement; } + + setMode(mode: Mode) { + void this._delegate.setMode?.(mode); + } + + async performAction(action: actions.PerformOnRecordAction) { + const simpleDomNode = this._generateSimpleDomNode(action); + await this._delegate.performAction?.(action, simpleDomNode).catch(() => {}); + } + + recordAction(action: actions.Action) { + const simpleDomNode = this._generateSimpleDomNode(action); + void this._delegate.recordAction?.(action, simpleDomNode); + } + + setOverlayState(state: { offsetX: number; }) { + void this._delegate.setOverlayState?.(state); + } + + 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 { @@ -1361,8 +1403,8 @@ function createSvgElement(doc: Document, { tagName, attrs, children }: SvgJson): } interface Embedder { - __pw_recorderPerformAction(action: actions.PerformOnRecordAction): Promise; - __pw_recorderRecordAction(action: actions.Action): Promise; + __pw_recorderPerformAction(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode): Promise; + __pw_recorderRecordAction(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise; __pw_recorderState(): Promise; __pw_recorderSetSelector(selector: string): Promise; __pw_recorderSetMode(mode: Mode): Promise; @@ -1407,12 +1449,12 @@ export class PollingRecorder implements RecorderDelegate { this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod); } - async performAction(action: actions.PerformOnRecordAction) { - await this._embedder.__pw_recorderPerformAction(action); + async performAction(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) { + await this._embedder.__pw_recorderPerformAction(action, simpleDomNode); } - async recordAction(action: actions.Action): Promise { - await this._embedder.__pw_recorderRecordAction(action); + async recordAction(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise { + await this._embedder.__pw_recorderRecordAction(action, simpleDomNode); } async setSelector(selector: string): Promise { diff --git a/packages/playwright-core/src/server/injected/simpleDom.ts b/packages/playwright-core/src/server/injected/simpleDom.ts index 0538dabc1e21a..878b8021dd6db 100644 --- a/packages/playwright-core/src/server/injected/simpleDom.ts +++ b/packages/playwright-core/src/server/injected/simpleDom.ts @@ -14,9 +14,7 @@ * limitations under the License. */ -import { escapeHTMLAttribute, escapeHTML } from '@isomorphic/stringUtils'; -import { beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleName } from './roleUtils'; -import { isElementVisible } from './domUtils'; +import type { InjectedScript } from './injectedScript'; const leafRoles = new Set([ 'button', @@ -26,11 +24,40 @@ const leafRoles = new Set([ 'textbox', ]); -export function simpleDom(document: Document): { markup: string, elements: Map } { +export type SimpleDom = { + markup: string; + elements: Map; +}; + +export type SimpleDomNode = { + dom: SimpleDom; + id: string; + tag: string; +}; + +let lastDom: SimpleDom | undefined; + +export function generateSimpleDom(injectedScript: InjectedScript): SimpleDom { + return generate(injectedScript).dom; +} + +export function generateSimpleDomNode(injectedScript: InjectedScript, target: Element): SimpleDomNode { + return generate(injectedScript, target).node!; +} + +export function selectorForSimpleDomNodeId(injectedScript: InjectedScript, id: string): string { + const element = lastDom?.elements.get(id); + if (!element) + throw new Error(`Internal error: element with id "${id}" not found`); + return injectedScript.generateSelectorSimple(element); +} + +function generate(injectedScript: InjectedScript, target?: Element): { dom: SimpleDom, node?: SimpleDomNode } { const normalizeWhitespace = (text: string) => text.replace(/[\s\n]+/g, match => match.includes('\n') ? '\n' : ' '); const tokens: string[] = []; - const idMap = new Map(); + const elements = new Map(); let lastId = 0; + let resultTarget: { tag: string, id: string } | undefined; const visit = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { tokens.push(node.nodeValue!); @@ -41,16 +68,19 @@ export function simpleDom(document: Document): { markup: string, elements: Map${escapedTextContent}`; case 'link': return `${escapedTextContent}`; diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts index e4e26b0e982e4..23c68b7297668 100644 --- a/packages/playwright-core/src/server/recorder.ts +++ b/packages/playwright-core/src/server/recorder.ts @@ -41,6 +41,7 @@ import { quoteCSSAttributeValue, eventsHelper, type RegisteredListener } from '. import type { Dialog } from './dialog'; import { performAction } from './recorderRunner'; import { languageSet } from './codegen/languages'; +import type { SimpleDomNode } from './injected/simpleDom'; type BindingSource = { frame: Frame, page: Page }; @@ -182,6 +183,7 @@ export class Recorder implements InstrumentationListener { language: this._currentLanguage, testIdAttributeName: this._contextRecorder.testIdAttributeName(), overlay: this._overlayState, + generateSimpleDom: false, }; return uiState; }); @@ -448,11 +450,11 @@ 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) => this._performAction(source.frame, action)); + (source: BindingSource, action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) => this._performAction(source.frame, action, simpleDomNode)); // Other non-essential actions are simply being recorded. await this._context.exposeBinding('__pw_recorderRecordAction', false, - (source: BindingSource, action: actions.Action) => this._recordAction(source.frame, action)); + (source: BindingSource, action: actions.Action, simpleDomNode?: SimpleDomNode) => this._recordAction(source.frame, action, simpleDomNode)); await this._context.extendInjectedScript(recorderSource.source); } @@ -532,14 +534,15 @@ class ContextRecorder extends EventEmitter { return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid'; } - private async _performAction(frame: Frame, action: actions.PerformOnRecordAction) { + private async _performAction(frame: Frame, action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) { // Commit last action so that no further signals are added to it. this._generator.commitLastAction(); const frameDescription = await this._describeFrame(frame); const actionInContext: ActionInContext = { frame: frameDescription, - action + action, + description: undefined, // TODO: generate description based on simple dom node. }; this._generator.willPerformAction(actionInContext); @@ -552,14 +555,15 @@ class ContextRecorder extends EventEmitter { } } - private async _recordAction(frame: Frame, action: actions.Action) { + private async _recordAction(frame: Frame, action: actions.Action, simpleDomNode?: SimpleDomNode) { // Commit last action so that no further signals are added to it. this._generator.commitLastAction(); const frameDescription = await this._describeFrame(frame); const actionInContext: ActionInContext = { frame: frameDescription, - action + action, + description: undefined, // TODO: generate description based on simple dom node. }; this._setCommittedAfterTimeout(actionInContext); this._generator.addAction(actionInContext); diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts index c56984ad6d60b..09cb02e3e2ff9 100644 --- a/packages/recorder/src/recorderTypes.ts +++ b/packages/recorder/src/recorderTypes.ts @@ -51,6 +51,7 @@ 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 4faa6686777aa..578f787f3eaa4 100644 --- a/packages/trace-viewer/src/ui/snapshotTab.tsx +++ b/packages/trace-viewer/src/ui/snapshotTab.tsx @@ -254,6 +254,7 @@ export const InspectModeController: React.FunctionComponent<{ language: sdkLanguage, testIdAttributeName, overlay: { offsetX: 0 }, + generateSimpleDom: false, }, { async setSelector(selector: string) { setHighlightedLocator(asLocator(sdkLanguage, frameSelector + selector)); diff --git a/tests/library/role-utils.spec.ts b/tests/library/role-utils.spec.ts index 6c45686f6820a..a02680ce86ac3 100644 --- a/tests/library/role-utils.spec.ts +++ b/tests/library/role-utils.spec.ts @@ -22,8 +22,8 @@ test.skip(({ mode }) => mode !== 'default'); async function getNameAndRole(page: Page, selector: string) { return await page.$eval(selector, e => { - const name = (window as any).__injectedScript.getElementAccessibleName(e); - const role = (window as any).__injectedScript.getAriaRole(e); + const name = (window as any).__injectedScript.utils.getElementAccessibleName(e); + const role = (window as any).__injectedScript.utils.getAriaRole(e); return { name, role }; }); } @@ -89,7 +89,7 @@ for (let range = 0; range <= ranges.length; range++) { if (!element) throw new Error(`Unable to resolve "${step.selector}"`); const injected = (window as any).__injectedScript; - const received = step.property === 'name' ? injected.getElementAccessibleName(element) : injected.getElementAccessibleDescription(element); + const received = step.property === 'name' ? injected.utils.getElementAccessibleName(element) : injected.utils.getElementAccessibleDescription(element); result.push({ selector: step.selector, expected: step.value, received }); } return result; @@ -152,7 +152,7 @@ test('wpt accname non-manual', async ({ page, asset, server }) => { const injected = (window as any).__injectedScript; const title = element.getAttribute('data-testname'); const expected = element.getAttribute('data-expectedlabel'); - const received = injected.getElementAccessibleName(element); + const received = injected.utils.getElementAccessibleName(element); result.push({ title, expected, received }); } return result; @@ -180,7 +180,7 @@ test('axe-core implicit-role', async ({ page, asset, server }) => { const element = document.querySelector(selector); if (!element) throw new Error(`Unable to resolve "${selector}"`); - return (window as any).__injectedScript.getAriaRole(element); + return (window as any).__injectedScript.utils.getAriaRole(element); }, testCase.target); expect.soft(received, `checking ${JSON.stringify(testCase)}`).toBe(testCase.role); }); @@ -213,7 +213,7 @@ test('axe-core accessible-text', async ({ page, asset, server }) => { const element = injected.querySelector(injected.parseSelector('css=' + selector), document, false); if (!element) throw new Error(`Unable to resolve "${selector}"`); - return injected.getElementAccessibleName(element); + return injected.utils.getElementAccessibleName(element); }); }, targets); expect.soft(received, `checking ${JSON.stringify(testCase)}`).toEqual(expected);