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 = {
frame: FrameDescription;
description?: string;
action: Action;
committed?: boolean;
};

View file

@ -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;
}

View file

@ -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)

View file

@ -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,
},
};

View file

@ -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),
};
}

View file

@ -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<channels.FrameExpectParams, 'expectedValue'> & { 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<number> {
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']);

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
# 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 { Highlight, HighlightOptions } from '../highlight';
import clipPaths from './clipPaths';
import type { SimpleDomNode } from '../simpleDom';
interface RecorderDelegate {
performAction?(action: actions.PerformOnRecordAction): Promise<void>;
recordAction?(action: actions.Action): Promise<void>;
performAction?(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode): Promise<void>;
recordAction?(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise<void>;
setSelector?(selector: string): Promise<void>;
setMode?(mode: Mode): Promise<void>;
setOverlayState?(state: OverlayState): Promise<void>;
@ -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;
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 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,
}));
}
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<Mode, Mode> = {
@ -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<void>;
__pw_recorderRecordAction(action: actions.Action): Promise<void>;
__pw_recorderPerformAction(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode): Promise<void>;
__pw_recorderRecordAction(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise<void>;
__pw_recorderState(): Promise<UIState>;
__pw_recorderSetSelector(selector: string): 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);
}
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<void> {
await this._embedder.__pw_recorderRecordAction(action);
async recordAction(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise<void> {
await this._embedder.__pw_recorderRecordAction(action, simpleDomNode);
}
async setSelector(selector: string): Promise<void> {

View file

@ -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<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 tokens: string[] = [];
const idMap = new Map<string, Element>();
const elements = new Map<string, Element>();
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<s
const element = node as Element;
if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT')
return;
if (isElementVisible(element)) {
const role = getAriaRole(element) as string;
if (injectedScript.utils.isElementVisible(element)) {
const role = injectedScript.utils.getAriaRole(element) as string;
if (role && leafRoles.has(role)) {
let value: string | undefined;
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
value = (element as HTMLInputElement | HTMLTextAreaElement).value;
const name = getElementAccessibleName(element, false);
const name = injectedScript.utils.getElementAccessibleName(element, false);
const structuralId = String(++lastId);
idMap.set(structuralId, element);
tokens.push(renderTag(role, name, structuralId, { value }));
elements.set(structuralId, element);
const tag = renderTag(injectedScript, role, name, structuralId, { value });
if (element === target)
resultTarget = { tag, id: structuralId };
tokens.push(tag);
return;
}
}
@ -58,21 +88,28 @@ export function simpleDom(document: Document): { markup: string, elements: Map<s
visit(child);
}
};
beginAriaCaches();
injectedScript.utils.beginAriaCaches();
try {
visit(document.body);
visit(injectedScript.document.body);
} finally {
endAriaCaches();
injectedScript.utils.endAriaCaches();
}
return {
const dom = {
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 {
const escapedTextContent = escapeHTML(name);
const escapedValue = escapeHTMLAttribute(params?.value || '');
function renderTag(injectedScript: InjectedScript, role: string, name: string, id: string, params?: { value?: string }): string {
const escapedTextContent = injectedScript.utils.escapeHTML(name);
const escapedValue = injectedScript.utils.escapeHTMLAttribute(params?.value || '');
switch (role) {
case 'button': return `<button id="${id}">${escapedTextContent}</button>`;
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 { 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);

View file

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

View file

@ -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));

View file

@ -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);