chore: generate simple dom descriptions in codegen (#32333)
This commit is contained in:
parent
3f085d5689
commit
bc87467b25
|
|
@ -27,6 +27,7 @@ export type FrameDescription = {
|
|||
|
||||
export type ActionInContext = {
|
||||
frame: FrameDescription;
|
||||
description?: string;
|
||||
action: Action;
|
||||
committed?: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
[*]
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export type UIState = {
|
|||
language: Language;
|
||||
testIdAttributeName: string;
|
||||
overlay: OverlayState;
|
||||
generateSimpleDom: boolean;
|
||||
};
|
||||
|
||||
export type CallLogStatus = 'in-progress' | 'done' | 'error' | 'paused';
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue