chore: generate simple dom descriptions in codegen (#32333)

This commit is contained in:
Pavel Feldman 2024-08-27 11:52:14 -07:00 committed by GitHub
parent 3f085d5689
commit bc87467b25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 215 additions and 106 deletions

View file

@ -27,6 +27,7 @@ export type FrameDescription = {
export type ActionInContext = { export type ActionInContext = {
frame: FrameDescription; frame: FrameDescription;
description?: string;
action: Action; action: Action;
committed?: boolean; committed?: boolean;
}; };

View file

@ -65,7 +65,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
if (signals.download) if (signals.download)
formatter.add(`const download${signals.download.downloadAlias}Promise = ${pageAlias}.waitForEvent('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) if (signals.popup)
formatter.add(`const ${signals.popup.popupAlias} = await ${signals.popup.popupAlias}Promise;`); formatter.add(`const ${signals.popup.popupAlias} = await ${signals.popup.popupAlias}Promise;`);
@ -259,3 +259,9 @@ export class JavaScriptFormatter {
function quote(text: string) { function quote(text: string) {
return escapeWithQuotes(text, '\''); return escapeWithQuotes(text, '\'');
} }
function wrapWithStep(description: string | undefined, body: string) {
return description ? `await test.step(\`${description}\`, async () => {
${body}
});` : body;
}

View file

@ -800,7 +800,7 @@ export class Frame extends SdkObject {
const result = await resolved.injected.evaluateHandle((injected, { info, root }) => { const result = await resolved.injected.evaluateHandle((injected, { info, root }) => {
const elements = injected.querySelectorAll(info.parsed, root || document); const elements = injected.querySelectorAll(info.parsed, root || document);
const element: Element | undefined = elements[0]; const element: Element | undefined = elements[0];
const visible = element ? injected.isVisible(element) : false; const visible = element ? injected.utils.isElementVisible(element) : false;
let log = ''; let log = '';
if (elements.length > 1) { if (elements.length > 1) {
if (info.strict) if (info.strict)

View file

@ -1,10 +1,21 @@
const path = require('path');
module.exports = { module.exports = {
rules: { parser: "@typescript-eslint/parser",
"no-restricted-globals": [ plugins: ["@typescript-eslint", "notice"],
"error", parserOptions: {
{ "name": "window" }, ecmaVersion: 9,
{ "name": "document" }, sourceType: "module",
{ "name": "globalThis" }, 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,
},
}; };

View file

@ -216,7 +216,7 @@ export class ClockController {
const sinceLastSync = now - this._realTime!.lastSyncTicks; const sinceLastSync = now - this._realTime!.lastSyncTicks;
this._realTime!.lastSyncTicks = now; this._realTime!.lastSyncTicks = now;
// eslint-disable-next-line no-console // 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), }, callAt - this._now.ticks),
}; };
} }

View file

@ -29,11 +29,12 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser';
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator'; import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { Highlight } from './highlight'; 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 { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } 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<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any }; export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };
@ -66,7 +67,28 @@ export class InjectedScript {
// eslint-disable-next-line no-restricted-globals // eslint-disable-next-line no-restricted-globals
readonly window: Window & typeof globalThis; readonly window: Window & typeof globalThis;
readonly document: Document; 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 // 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 }[]) { 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); return new constrFunction(this, params);
} }
isVisible(element: Element): boolean {
return isElementVisible(element);
}
async viewportRatio(element: Element): Promise<number> { async viewportRatio(element: Element): Promise<number> {
return await new Promise(resolve => { return await new Promise(resolve => {
const observer = new IntersectionObserver(entries => { const observer = new IntersectionObserver(entries => {
@ -567,9 +585,9 @@ export class InjectedScript {
} }
if (state === 'visible') if (state === 'visible')
return this.isVisible(element); return isElementVisible(element);
if (state === 'hidden') if (state === 'hidden')
return !this.isVisible(element); return !isElementVisible(element);
const disabled = getAriaDisabled(element); const disabled = getAriaDisabled(element);
if (state === 'disabled') if (state === 'disabled')
@ -1296,18 +1314,6 @@ export class InjectedScript {
} }
throw this.createStacklessError('Unknown expect matcher: ' + expression); 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']); const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);

View file

@ -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 # 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. # module-level globals will be duplicated, which leads to subtle bugs.
[*] [*]

View file

@ -21,10 +21,11 @@ import type { Mode, OverlayState, UIState } from '@recorder/recorderTypes';
import type { ElementText } from '../selectorUtils'; import type { ElementText } from '../selectorUtils';
import type { Highlight, HighlightOptions } from '../highlight'; import type { Highlight, HighlightOptions } from '../highlight';
import clipPaths from './clipPaths'; import clipPaths from './clipPaths';
import type { SimpleDomNode } from '../simpleDom';
interface RecorderDelegate { interface RecorderDelegate {
performAction?(action: actions.PerformOnRecordAction): Promise<void>; performAction?(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode): Promise<void>;
recordAction?(action: actions.Action): Promise<void>; recordAction?(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise<void>;
setSelector?(selector: string): Promise<void>; setSelector?(selector: string): Promise<void>;
setMode?(mode: Mode): Promise<void>; setMode?(mode: Mode): Promise<void>;
setOverlayState?(state: OverlayState): Promise<void>; setOverlayState?(state: OverlayState): Promise<void>;
@ -168,7 +169,7 @@ class InspectTool implements RecorderTool {
if (this._hoveredModel?.tooltipListItemSelected) if (this._hoveredModel?.tooltipListItemSelected)
this._reset(true); this._reset(true);
else if (this._assertVisibility) 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) { private _commit(selector: string) {
if (this._assertVisibility) { if (this._assertVisibility) {
this._recorder.delegate.recordAction?.({ this._recorder.recordAction({
name: 'assertVisible', name: 'assertVisible',
selector, selector,
signals: [], signals: [],
}); });
this._recorder.delegate.setMode?.('recording'); this._recorder.setMode('recording');
this._recorder.overlay?.flashToolSucceeded('assertingVisibility'); this._recorder.overlay?.flashToolSucceeded('assertingVisibility');
} else { } else {
this._recorder.delegate.setSelector?.(selector); this._recorder.setSelector(selector);
} }
} }
@ -338,7 +339,7 @@ class RecordActionTool implements RecorderTool {
const target = this._recorder.deepEventTarget(event); const target = this._recorder.deepEventTarget(event);
if (target.nodeName === 'INPUT' && (target as HTMLInputElement).type.toLowerCase() === 'file') { if (target.nodeName === 'INPUT' && (target as HTMLInputElement).type.toLowerCase() === 'file') {
this._recorder.delegate.recordAction?.({ this._recorder.recordAction({
name: 'setInputFiles', name: 'setInputFiles',
selector: this._activeModel!.selector, selector: this._activeModel!.selector,
signals: [], signals: [],
@ -348,7 +349,7 @@ class RecordActionTool implements RecorderTool {
} }
if (isRangeInput(target)) { if (isRangeInput(target)) {
this._recorder.delegate.recordAction?.({ this._recorder.recordAction({
name: 'fill', name: 'fill',
// must use hoveredModel instead of activeModel for it to work in webkit // must use hoveredModel instead of activeModel for it to work in webkit
selector: this._hoveredModel!.selector, selector: this._hoveredModel!.selector,
@ -367,7 +368,7 @@ class RecordActionTool implements RecorderTool {
// Non-navigating actions are simply recorded by Playwright. // Non-navigating actions are simply recorded by Playwright.
if (this._consumedDueWrongTarget(event)) if (this._consumedDueWrongTarget(event))
return; return;
this._recorder.delegate.recordAction?.({ this._recorder.recordAction({
name: 'fill', name: 'fill',
selector: this._activeModel!.selector, selector: this._activeModel!.selector,
signals: [], signals: [],
@ -483,26 +484,27 @@ class RecordActionTool implements RecorderTool {
return true; return true;
} }
private async _performAction(action: actions.PerformOnRecordAction) { private _performAction(action: actions.PerformOnRecordAction) {
this._hoveredElement = null; this._hoveredElement = null;
this._hoveredModel = null; this._hoveredModel = null;
this._activeModel = null; this._activeModel = null;
this._recorder.updateHighlight(null, false); this._recorder.updateHighlight(null, false);
this._performingAction = true; this._performingAction = true;
await this._recorder.delegate.performAction?.(action).catch(() => {}); void this._recorder.performAction(action).then(() => {
this._performingAction = false; this._performingAction = false;
// If that was a keyboard action, it similarly requires new selectors for active model. // If that was a keyboard action, it similarly requires new selectors for active model.
this._onFocus(false); this._onFocus(false);
if (this._recorder.injectedScript.isUnderTest) { if (this._recorder.injectedScript.isUnderTest) {
// Serialize all to string as we cannot attribute console message to isolated world // Serialize all to string as we cannot attribute console message to isolated world
// in Firefox. // in Firefox.
console.error('Action performed for test: ' + JSON.stringify({ // eslint-disable-line no-console console.error('Action performed for test: ' + JSON.stringify({ // eslint-disable-line no-console
hovered: this._hoveredModel ? (this._hoveredModel as any).selector : null, hovered: this._hoveredModel ? (this._hoveredModel as any).selector : null,
active: this._activeModel ? (this._activeModel as any).selector : null, active: this._activeModel ? (this._activeModel as any).selector : null,
})); }));
} }
});
} }
private _shouldGenerateKeyPressFor(event: KeyboardEvent): boolean { private _shouldGenerateKeyPressFor(event: KeyboardEvent): boolean {
@ -613,7 +615,7 @@ class TextAssertionTool implements RecorderTool {
onKeyDown(event: KeyboardEvent) { onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') if (event.key === 'Escape')
this._recorder.delegate.setMode?.('recording'); this._recorder.setMode('recording');
consumeEvent(event); consumeEvent(event);
} }
@ -680,8 +682,8 @@ class TextAssertionTool implements RecorderTool {
if (!this._action || !this._dialog.isShowing()) if (!this._action || !this._dialog.isShowing())
return; return;
this._dialog.close(); this._dialog.close();
this._recorder.delegate.recordAction?.(this._action); this._recorder.recordAction(this._action);
this._recorder.delegate.setMode?.('recording'); this._recorder.setMode('recording');
} }
private _showDialog() { private _showDialog() {
@ -726,8 +728,8 @@ class TextAssertionTool implements RecorderTool {
const action = this._generateAction(); const action = this._generateAction();
if (!action) if (!action)
return; return;
this._recorder.delegate.recordAction?.(action); this._recorder.recordAction(action);
this._recorder.delegate.setMode?.('recording'); this._recorder.setMode('recording');
this._recorder.overlay?.flashToolSucceeded('assertingValue'); this._recorder.overlay?.flashToolSucceeded('assertingValue');
} }
} }
@ -799,7 +801,7 @@ class Overlay {
this._dragState = { offsetX: this._offsetX, dragStart: { x: (event as MouseEvent).clientX, y: 0 } }; this._dragState = { offsetX: this._offsetX, dragStart: { x: (event as MouseEvent).clientX, y: 0 } };
}), }),
addEventListener(this._recordToggle, 'click', () => { 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', () => { addEventListener(this._pickLocatorToggle, 'click', () => {
const newMode: Record<Mode, Mode> = { const newMode: Record<Mode, Mode> = {
@ -812,19 +814,19 @@ class Overlay {
'assertingVisibility': 'recording-inspecting', 'assertingVisibility': 'recording-inspecting',
'assertingValue': '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', () => { addEventListener(this._assertVisibilityToggle, 'click', () => {
if (!this._assertVisibilityToggle.classList.contains('disabled')) 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', () => { addEventListener(this._assertTextToggle, 'click', () => {
if (!this._assertTextToggle.classList.contains('disabled')) 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', () => { addEventListener(this._assertValuesToggle, 'click', () => {
if (!this._assertValuesToggle.classList.contains('disabled')) 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; const halfGapSize = (this._recorder.injectedScript.window.innerWidth - this._measure.width) / 2 - 10;
this._offsetX = Math.max(-halfGapSize, Math.min(halfGapSize, this._offsetX)); this._offsetX = Math.max(-halfGapSize, Math.min(halfGapSize, this._offsetX));
this._updateVisualPosition(); this._updateVisualPosition();
this._recorder.delegate.setOverlayState?.({ offsetX: this._offsetX }); this._recorder.setOverlayState({ offsetX: this._offsetX });
consumeEvent(event); consumeEvent(event);
return true; return true;
} }
@ -924,9 +926,15 @@ export class Recorder {
readonly highlight: Highlight; readonly highlight: Highlight;
readonly overlay: Overlay | undefined; readonly overlay: Overlay | undefined;
private _stylesheet: CSSStyleSheet; 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; readonly document: Document;
delegate: RecorderDelegate = {}; private _delegate: RecorderDelegate = {};
constructor(injectedScript: InjectedScript) { constructor(injectedScript: InjectedScript) {
this.document = injectedScript.document; this.document = injectedScript.document;
@ -994,7 +1002,7 @@ export class Recorder {
} }
setUIState(state: UIState, delegate: RecorderDelegate) { 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) { if (state.actionPoint && this.state.actionPoint && state.actionPoint.x === this.state.actionPoint.x && state.actionPoint.y === this.state.actionPoint.y) {
// All good. // All good.
@ -1155,7 +1163,7 @@ export class Recorder {
tooltipText = this.injectedScript.utils.asLocator(this.state.language, model.selector); tooltipText = this.injectedScript.utils.asLocator(this.state.language, model.selector);
this.highlight.updateHighlight(model?.elements || [], { ...model, tooltipText }); this.highlight.updateHighlight(model?.elements || [], { ...model, tooltipText });
if (userGesture) if (userGesture)
this.delegate.highlightUpdated?.(); this._delegate.highlightUpdated?.();
} }
private _ignoreOverlayEvent(event: Event) { private _ignoreOverlayEvent(event: Event) {
@ -1172,6 +1180,40 @@ export class Recorder {
} }
return event.composedPath()[0] as HTMLElement; 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 { class Dialog {
@ -1361,8 +1403,8 @@ function createSvgElement(doc: Document, { tagName, attrs, children }: SvgJson):
} }
interface Embedder { interface Embedder {
__pw_recorderPerformAction(action: actions.PerformOnRecordAction): Promise<void>; __pw_recorderPerformAction(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode): Promise<void>;
__pw_recorderRecordAction(action: actions.Action): Promise<void>; __pw_recorderRecordAction(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise<void>;
__pw_recorderState(): Promise<UIState>; __pw_recorderState(): Promise<UIState>;
__pw_recorderSetSelector(selector: string): Promise<void>; __pw_recorderSetSelector(selector: string): Promise<void>;
__pw_recorderSetMode(mode: Mode): Promise<void>; __pw_recorderSetMode(mode: Mode): Promise<void>;
@ -1407,12 +1449,12 @@ export class PollingRecorder implements RecorderDelegate {
this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod); this._pollRecorderModeTimer = this._recorder.injectedScript.builtinSetTimeout(() => this._pollRecorderMode(), pollPeriod);
} }
async performAction(action: actions.PerformOnRecordAction) { async performAction(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) {
await this._embedder.__pw_recorderPerformAction(action); await this._embedder.__pw_recorderPerformAction(action, simpleDomNode);
} }
async recordAction(action: actions.Action): Promise<void> { async recordAction(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise<void> {
await this._embedder.__pw_recorderRecordAction(action); await this._embedder.__pw_recorderRecordAction(action, simpleDomNode);
} }
async setSelector(selector: string): Promise<void> { async setSelector(selector: string): Promise<void> {

View file

@ -14,9 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { escapeHTMLAttribute, escapeHTML } from '@isomorphic/stringUtils'; import type { InjectedScript } from './injectedScript';
import { beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleName } from './roleUtils';
import { isElementVisible } from './domUtils';
const leafRoles = new Set([ const leafRoles = new Set([
'button', 'button',
@ -26,11 +24,40 @@ const leafRoles = new Set([
'textbox', 'textbox',
]); ]);
export function simpleDom(document: Document): { markup: string, elements: Map<string, Element> } { export type SimpleDom = {
markup: string;
elements: Map<string, Element>;
};
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 normalizeWhitespace = (text: string) => text.replace(/[\s\n]+/g, match => match.includes('\n') ? '\n' : ' ');
const tokens: string[] = []; const tokens: string[] = [];
const idMap = new Map<string, Element>(); const elements = new Map<string, Element>();
let lastId = 0; let lastId = 0;
let resultTarget: { tag: string, id: string } | undefined;
const visit = (node: Node) => { const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType === Node.TEXT_NODE) {
tokens.push(node.nodeValue!); tokens.push(node.nodeValue!);
@ -41,16 +68,19 @@ export function simpleDom(document: Document): { markup: string, elements: Map<s
const element = node as Element; const element = node as Element;
if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT') if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT')
return; return;
if (isElementVisible(element)) { if (injectedScript.utils.isElementVisible(element)) {
const role = getAriaRole(element) as string; const role = injectedScript.utils.getAriaRole(element) as string;
if (role && leafRoles.has(role)) { if (role && leafRoles.has(role)) {
let value: string | undefined; let value: string | undefined;
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
value = (element as HTMLInputElement | HTMLTextAreaElement).value; value = (element as HTMLInputElement | HTMLTextAreaElement).value;
const name = getElementAccessibleName(element, false); const name = injectedScript.utils.getElementAccessibleName(element, false);
const structuralId = String(++lastId); const structuralId = String(++lastId);
idMap.set(structuralId, element); elements.set(structuralId, element);
tokens.push(renderTag(role, name, structuralId, { value })); const tag = renderTag(injectedScript, role, name, structuralId, { value });
if (element === target)
resultTarget = { tag, id: structuralId };
tokens.push(tag);
return; return;
} }
} }
@ -58,21 +88,28 @@ export function simpleDom(document: Document): { markup: string, elements: Map<s
visit(child); visit(child);
} }
}; };
beginAriaCaches(); injectedScript.utils.beginAriaCaches();
try { try {
visit(document.body); visit(injectedScript.document.body);
} finally { } finally {
endAriaCaches(); injectedScript.utils.endAriaCaches();
} }
return { const dom = {
markup: normalizeWhitespace(tokens.join(' ')), markup: normalizeWhitespace(tokens.join(' ')),
elements: idMap elements
}; };
if (target && !resultTarget)
throw new Error('Target element is not in the simple DOM');
lastDom = dom;
return { dom, node: resultTarget ? { dom, ...resultTarget } : undefined };
} }
function renderTag(role: string, name: string, id: string, params?: { value?: string }): string { function renderTag(injectedScript: InjectedScript, role: string, name: string, id: string, params?: { value?: string }): string {
const escapedTextContent = escapeHTML(name); const escapedTextContent = injectedScript.utils.escapeHTML(name);
const escapedValue = escapeHTMLAttribute(params?.value || ''); const escapedValue = injectedScript.utils.escapeHTMLAttribute(params?.value || '');
switch (role) { switch (role) {
case 'button': return `<button id="${id}">${escapedTextContent}</button>`; case 'button': return `<button id="${id}">${escapedTextContent}</button>`;
case 'link': return `<a id="${id}">${escapedTextContent}</a>`; case 'link': return `<a id="${id}">${escapedTextContent}</a>`;

View file

@ -41,6 +41,7 @@ import { quoteCSSAttributeValue, eventsHelper, type RegisteredListener } from '.
import type { Dialog } from './dialog'; import type { Dialog } from './dialog';
import { performAction } from './recorderRunner'; import { performAction } from './recorderRunner';
import { languageSet } from './codegen/languages'; import { languageSet } from './codegen/languages';
import type { SimpleDomNode } from './injected/simpleDom';
type BindingSource = { frame: Frame, page: Page }; type BindingSource = { frame: Frame, page: Page };
@ -182,6 +183,7 @@ export class Recorder implements InstrumentationListener {
language: this._currentLanguage, language: this._currentLanguage,
testIdAttributeName: this._contextRecorder.testIdAttributeName(), testIdAttributeName: this._contextRecorder.testIdAttributeName(),
overlay: this._overlayState, overlay: this._overlayState,
generateSimpleDom: false,
}; };
return uiState; return uiState;
}); });
@ -448,11 +450,11 @@ class ContextRecorder extends EventEmitter {
// Input actions that potentially lead to navigation are intercepted on the page and are // Input actions that potentially lead to navigation are intercepted on the page and are
// performed by the Playwright. // performed by the Playwright.
await this._context.exposeBinding('__pw_recorderPerformAction', false, 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. // Other non-essential actions are simply being recorded.
await this._context.exposeBinding('__pw_recorderRecordAction', false, 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); await this._context.extendInjectedScript(recorderSource.source);
} }
@ -532,14 +534,15 @@ class ContextRecorder extends EventEmitter {
return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid'; 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. // Commit last action so that no further signals are added to it.
this._generator.commitLastAction(); this._generator.commitLastAction();
const frameDescription = await this._describeFrame(frame); const frameDescription = await this._describeFrame(frame);
const actionInContext: ActionInContext = { const actionInContext: ActionInContext = {
frame: frameDescription, frame: frameDescription,
action action,
description: undefined, // TODO: generate description based on simple dom node.
}; };
this._generator.willPerformAction(actionInContext); 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. // Commit last action so that no further signals are added to it.
this._generator.commitLastAction(); this._generator.commitLastAction();
const frameDescription = await this._describeFrame(frame); const frameDescription = await this._describeFrame(frame);
const actionInContext: ActionInContext = { const actionInContext: ActionInContext = {
frame: frameDescription, frame: frameDescription,
action action,
description: undefined, // TODO: generate description based on simple dom node.
}; };
this._setCommittedAfterTimeout(actionInContext); this._setCommittedAfterTimeout(actionInContext);
this._generator.addAction(actionInContext); this._generator.addAction(actionInContext);

View file

@ -51,6 +51,7 @@ export type UIState = {
language: Language; language: Language;
testIdAttributeName: string; testIdAttributeName: string;
overlay: OverlayState; overlay: OverlayState;
generateSimpleDom: boolean;
}; };
export type CallLogStatus = 'in-progress' | 'done' | 'error' | 'paused'; export type CallLogStatus = 'in-progress' | 'done' | 'error' | 'paused';

View file

@ -254,6 +254,7 @@ export const InspectModeController: React.FunctionComponent<{
language: sdkLanguage, language: sdkLanguage,
testIdAttributeName, testIdAttributeName,
overlay: { offsetX: 0 }, overlay: { offsetX: 0 },
generateSimpleDom: false,
}, { }, {
async setSelector(selector: string) { async setSelector(selector: string) {
setHighlightedLocator(asLocator(sdkLanguage, frameSelector + selector)); setHighlightedLocator(asLocator(sdkLanguage, frameSelector + selector));

View file

@ -22,8 +22,8 @@ test.skip(({ mode }) => mode !== 'default');
async function getNameAndRole(page: Page, selector: string) { async function getNameAndRole(page: Page, selector: string) {
return await page.$eval(selector, e => { return await page.$eval(selector, e => {
const name = (window as any).__injectedScript.getElementAccessibleName(e); const name = (window as any).__injectedScript.utils.getElementAccessibleName(e);
const role = (window as any).__injectedScript.getAriaRole(e); const role = (window as any).__injectedScript.utils.getAriaRole(e);
return { name, role }; return { name, role };
}); });
} }
@ -89,7 +89,7 @@ for (let range = 0; range <= ranges.length; range++) {
if (!element) if (!element)
throw new Error(`Unable to resolve "${step.selector}"`); throw new Error(`Unable to resolve "${step.selector}"`);
const injected = (window as any).__injectedScript; 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 }); result.push({ selector: step.selector, expected: step.value, received });
} }
return result; return result;
@ -152,7 +152,7 @@ test('wpt accname non-manual', async ({ page, asset, server }) => {
const injected = (window as any).__injectedScript; const injected = (window as any).__injectedScript;
const title = element.getAttribute('data-testname'); const title = element.getAttribute('data-testname');
const expected = element.getAttribute('data-expectedlabel'); const expected = element.getAttribute('data-expectedlabel');
const received = injected.getElementAccessibleName(element); const received = injected.utils.getElementAccessibleName(element);
result.push({ title, expected, received }); result.push({ title, expected, received });
} }
return result; return result;
@ -180,7 +180,7 @@ test('axe-core implicit-role', async ({ page, asset, server }) => {
const element = document.querySelector(selector); const element = document.querySelector(selector);
if (!element) if (!element)
throw new Error(`Unable to resolve "${selector}"`); throw new Error(`Unable to resolve "${selector}"`);
return (window as any).__injectedScript.getAriaRole(element); return (window as any).__injectedScript.utils.getAriaRole(element);
}, testCase.target); }, testCase.target);
expect.soft(received, `checking ${JSON.stringify(testCase)}`).toBe(testCase.role); 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); const element = injected.querySelector(injected.parseSelector('css=' + selector), document, false);
if (!element) if (!element)
throw new Error(`Unable to resolve "${selector}"`); throw new Error(`Unable to resolve "${selector}"`);
return injected.getElementAccessibleName(element); return injected.utils.getElementAccessibleName(element);
}); });
}, targets); }, targets);
expect.soft(received, `checking ${JSON.stringify(testCase)}`).toEqual(expected); expect.soft(received, `checking ${JSON.stringify(testCase)}`).toEqual(expected);