Skip to content

Commit f3666e1

Browse files
authored
feat: auto-generate toBeVisible assertion while recording (microsoft#36812)
1 parent f512687 commit f3666e1

File tree

10 files changed

+230
-28
lines changed

10 files changed

+230
-28
lines changed

packages/injected/src/ariaSnapshot.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ type AriaRef = {
4848

4949
let lastRef = 0;
5050

51-
export function generateAriaTree(rootElement: Element, options?: { forAI?: boolean, refPrefix?: string }): AriaSnapshot {
51+
export type AriaTreeOptions = { forAI?: boolean, refPrefix?: string, refs?: boolean, visibleOnly?: boolean };
52+
53+
export function generateAriaTree(rootElement: Element, options?: AriaTreeOptions): AriaSnapshot {
5254
const visited = new Set<Node>();
5355

5456
const snapshot: AriaSnapshot = {
@@ -91,7 +93,7 @@ export function generateAriaTree(rootElement: Element, options?: { forAI?: boole
9193
}
9294
}
9395

94-
const visible = !isElementHiddenForAria || isElementVisible(element);
96+
const visible = options?.visibleOnly ? isElementVisible(element) : !isElementHiddenForAria || isElementVisible(element);
9597
const childAriaNode = visible ? toAriaNode(element, options) : null;
9698
if (childAriaNode) {
9799
if (childAriaNode.ref) {
@@ -155,8 +157,8 @@ export function generateAriaTree(rootElement: Element, options?: { forAI?: boole
155157
return snapshot;
156158
}
157159

158-
function ariaRef(element: Element, role: string, name: string, options?: { forAI?: boolean, refPrefix?: string }): string | undefined {
159-
if (!options?.forAI)
160+
function ariaRef(element: Element, role: string, name: string, options?: AriaTreeOptions): string | undefined {
161+
if (!options?.forAI && !options?.refs)
160162
return undefined;
161163

162164
let ariaRef: AriaRef | undefined;
@@ -168,7 +170,7 @@ function ariaRef(element: Element, role: string, name: string, options?: { forAI
168170
return ariaRef.ref;
169171
}
170172

171-
function toAriaNode(element: Element, options?: { forAI?: boolean, refPrefix?: string }): AriaNode | null {
173+
function toAriaNode(element: Element, options?: AriaTreeOptions): AriaNode | null {
172174
const active = element.ownerDocument.activeElement === element;
173175
if (element.nodeName === 'IFRAME') {
174176
return {
@@ -409,7 +411,7 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll:
409411
return results;
410412
}
411413

412-
export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'raw' | 'regex', forAI?: boolean }): string {
414+
export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'raw' | 'regex', forAI?: boolean, refs?: boolean }): string {
413415
const lines: string[] = [];
414416
const includeText = options?.mode === 'regex' ? textContributesInfo : () => true;
415417
const renderString = options?.mode === 'regex' ? convertToBestGuessRegex : (str: string) => str;
@@ -450,11 +452,12 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r
450452
key += ` [pressed]`;
451453
if (ariaNode.selected === true)
452454
key += ` [selected]`;
453-
if (options?.forAI && receivesPointerEvents(ariaNode)) {
454-
const ref = ariaNode.ref;
455-
const cursor = hasPointerCursor(ariaNode) ? ' [cursor=pointer]' : '';
456-
if (ref)
457-
key += ` [ref=${ref}]${cursor}`;
455+
456+
const includeRef = (options?.forAI && receivesPointerEvents(ariaNode)) || options?.refs;
457+
if (includeRef && ariaNode.ref) {
458+
key += ` [ref=${ariaNode.ref}]`;
459+
if (hasPointerCursor(ariaNode))
460+
key += ' [cursor=pointer]';
458461
}
459462

460463
const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded(key);

packages/injected/src/injectedScript.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import type { CSSComplexSelectorList } from '@isomorphic/cssParser';
3939
import type { Language } from '@isomorphic/locatorGenerators';
4040
import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '@isomorphic/selectorParser';
4141
import type * as channels from '@protocol/channels';
42-
import type { AriaSnapshot } from './ariaSnapshot';
42+
import type { AriaSnapshot, AriaTreeOptions } from './ariaSnapshot';
4343
import type { LayoutSelectorName } from './layoutSelectorUtils';
4444
import type { SelectorEngine, SelectorRoot } from './selectorEngine';
4545
import type { GenerateSelectorOptions } from './selectorGenerator';
@@ -297,7 +297,7 @@ export class InjectedScript {
297297
return new Set<Element>(result.map(r => r.element));
298298
}
299299

300-
ariaSnapshot(node: Node, options?: { mode?: 'raw' | 'regex', forAI?: boolean, refPrefix?: string }): string {
300+
ariaSnapshot(node: Node, options?: { mode?: 'raw' | 'regex' } & AriaTreeOptions): string {
301301
if (node.nodeType !== Node.ELEMENT_NODE)
302302
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
303303
this._lastAriaSnapshot = generateAriaTree(node as Element, options);

packages/injected/src/recorder/recorder.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,7 @@ class RecordActionTool implements RecorderTool {
383383
const target = this._recorder.deepEventTarget(event);
384384

385385
if (target.nodeName === 'INPUT' && (target as HTMLInputElement).type.toLowerCase() === 'file') {
386-
this._recorder.recordAction({
386+
this._recordAction({
387387
name: 'setInputFiles',
388388
selector: this._activeModel!.selector,
389389
signals: [],
@@ -393,7 +393,7 @@ class RecordActionTool implements RecorderTool {
393393
}
394394

395395
if (isRangeInput(target)) {
396-
this._recorder.recordAction({
396+
this._recordAction({
397397
name: 'fill',
398398
// must use hoveredModel instead of activeModel for it to work in webkit
399399
selector: this._hoveredModel!.selector,
@@ -412,7 +412,7 @@ class RecordActionTool implements RecorderTool {
412412
// Non-navigating actions are simply recorded by Playwright.
413413
if (this._consumedDueWrongTarget(event))
414414
return;
415-
this._recorder.recordAction({
415+
this._recordAction({
416416
name: 'fill',
417417
selector: this._activeModel!.selector,
418418
signals: [],
@@ -545,9 +545,21 @@ class RecordActionTool implements RecorderTool {
545545
consumeEvent(event);
546546
}
547547

548+
private _captureAriaSnapshotForAction(action: actions.Action) {
549+
const documentElement = this._recorder.injectedScript.document.documentElement;
550+
if (documentElement)
551+
action.ariaSnapshot = this._recorder.injectedScript.ariaSnapshot(documentElement, { mode: 'raw', refs: true, visibleOnly: true });
552+
}
553+
554+
private _recordAction(action: actions.Action) {
555+
this._captureAriaSnapshotForAction(action);
556+
this._recorder.recordAction(action);
557+
}
558+
548559
private _performAction(action: actions.PerformOnRecordAction) {
549560
this._recorder.updateHighlight(null, false);
550561

562+
this._captureAriaSnapshotForAction(action);
551563
this._performingActions.add(action);
552564

553565
const promise = this._recorder.performAction(action).then(() => {

packages/playwright-core/src/server/codegen/jsonl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export class JsonlLanguageGenerator implements LanguageGenerator {
3131
...actionInContext.action,
3232
...actionInContext.frame,
3333
locator,
34+
ariaSnapshot: undefined,
3435
};
3536
return JSON.stringify(entry);
3637
}

packages/playwright-core/src/server/frameSelectors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export class FrameSelectors {
4949
return this.frame._page.browserContext.selectors().parseSelector(selector, strict);
5050
}
5151

52-
async query(selector: string, options?: types.StrictOptions, scope?: ElementHandle): Promise<ElementHandle<Element> | null> {
52+
async query(selector: string, options?: types.StrictOptions & { mainWorld?: boolean }, scope?: ElementHandle): Promise<ElementHandle<Element> | null> {
5353
const resolved = await this.resolveInjectedForSelector(selector, options, scope);
5454
// Be careful, |this.frame| can be different from |resolved.frame|.
5555
if (!resolved)

packages/playwright-core/src/server/frames.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,8 +1179,8 @@ export class Frame extends SdkObject {
11791179
dom.assertDone(await this._retryWithProgressIfNotConnected(progress, selector, options.strict, true /* performActionPreChecks */, handle => handle._blur(progress)));
11801180
}
11811181

1182-
async resolveSelector(progress: Progress, selector: string): Promise<{ resolvedSelector: string }> {
1183-
const element = await progress.race(this.selectors.query(selector));
1182+
async resolveSelector(progress: Progress, selector: string, options: { mainWorld?: boolean } = {}): Promise<{ resolvedSelector: string }> {
1183+
const element = await progress.race(this.selectors.query(selector, options));
11841184
if (!element)
11851185
throw new Error(`No element matching ${selector}`);
11861186

packages/playwright-core/src/server/recorder.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import fs from 'fs';
2020
import { isUnderTest } from '../utils';
2121
import { BrowserContext } from './browserContext';
2222
import { Debugger } from './debugger';
23-
import { buildFullSelector, generateFrameSelector, metadataToCallLog } from './recorder/recorderUtils';
23+
import { buildFullSelector, generateFrameSelector, isAssertAction, metadataToCallLog, shouldMergeAction } from './recorder/recorderUtils';
2424
import { locatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
2525
import { stringifySelector } from '../utils/isomorphic/selectorParser';
2626
import { ProgressController } from './progress';
@@ -31,6 +31,8 @@ import { eventsHelper, monotonicTime } from './../utils';
3131
import { Frame } from './frames';
3232
import { Page } from './page';
3333
import { performAction } from './recorder/recorderRunner';
34+
import { findNewElementRef } from '../utils/isomorphic/ariaSnapshot';
35+
import { yaml } from '../utilsBundle';
3436

3537
import type { Language } from './codegen/types';
3638
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
@@ -520,16 +522,69 @@ export class Recorder extends EventEmitter<RecorderEventMap> implements Instrume
520522
return actionInContext;
521523
}
522524

525+
private async _maybeGenerateAssertAction(frame: Frame, actionInContext: actions.ActionInContext) {
526+
const lastAction = getLastFrameAction(frame);
527+
if (isAssertAction(actionInContext.action))
528+
return;
529+
if (lastAction && (isAssertAction(lastAction.action) || shouldMergeAction(actionInContext, lastAction)))
530+
return;
531+
const newSnapshot = actionInContext.action.ariaSnapshot;
532+
if (!newSnapshot)
533+
return;
534+
const lastSnapshot = lastAction?.action.ariaSnapshot || `- document [ref=e1]\n`;
535+
if (!lastSnapshot)
536+
return;
537+
const callMetadata = serverSideCallMetadata();
538+
const controller = new ProgressController(callMetadata, frame);
539+
const selector = await controller.run(async progress => {
540+
const ref = findNewElementRef(yaml, lastSnapshot, newSnapshot);
541+
if (!ref)
542+
return;
543+
// Note: recorder runs in the main world, so we need to resolve the ref in the main world.
544+
// Note: resolveSelector always returns a |page|-based selector, not |frame|-based.
545+
const { resolvedSelector } = await frame.resolveSelector(progress, `aria-ref=${ref}`, { mainWorld: true }).catch(() => ({ resolvedSelector: undefined }));
546+
if (!resolvedSelector)
547+
return;
548+
const isVisible = await frame._page.mainFrame().isVisible(progress, resolvedSelector, { strict: true }).catch(() => false);
549+
return isVisible ? resolvedSelector : undefined;
550+
}).catch(() => undefined);
551+
if (!selector)
552+
return;
553+
if (!actionInContext.frame.framePath.length && 'selector' in actionInContext.action && actionInContext.action.selector === selector)
554+
return; // Do not assert the action target, auto-waiting takes care of it.
555+
const assertActionInContext: actions.ActionInContext = {
556+
frame: {
557+
pageGuid: actionInContext.frame.pageGuid,
558+
pageAlias: actionInContext.frame.pageAlias,
559+
framePath: [],
560+
},
561+
action: {
562+
name: 'assertVisible',
563+
selector,
564+
signals: [],
565+
},
566+
startTime: actionInContext.startTime,
567+
endTime: actionInContext.startTime,
568+
};
569+
return assertActionInContext;
570+
}
571+
523572
private async _performAction(frame: Frame, action: actions.PerformOnRecordAction) {
524573
const actionInContext = await this._createActionInContext(frame, action);
574+
const assertActionInContext = await this._maybeGenerateAssertAction(frame, actionInContext);
575+
if (assertActionInContext)
576+
this._signalProcessor.addAction(assertActionInContext);
525577
this._signalProcessor.addAction(actionInContext);
578+
setLastFrameAction(frame, actionInContext);
526579
if (actionInContext.action.name !== 'openPage' && actionInContext.action.name !== 'closePage')
527580
await performAction(this._pageAliases, actionInContext);
528581
actionInContext.endTime = monotonicTime();
529582
}
530583

531584
private async _recordAction(frame: Frame, action: actions.Action) {
532-
this._signalProcessor.addAction(await this._createActionInContext(frame, action));
585+
const actionInContext = await this._createActionInContext(frame, action);
586+
this._signalProcessor.addAction(actionInContext);
587+
setLastFrameAction(frame, actionInContext);
533588
}
534589

535590
private _onFrameNavigated(frame: Frame, page: Page) {
@@ -569,3 +624,11 @@ function languageForFile(file: string) {
569624
return 'csharp';
570625
return 'javascript';
571626
}
627+
628+
const kLastFrameActionSymbol = Symbol('lastFrameAction');
629+
function getLastFrameAction(frame: Frame): actions.ActionInContext | undefined {
630+
return (frame as any)[kLastFrameActionSymbol];
631+
}
632+
function setLastFrameAction(frame: Frame, action: actions.ActionInContext | undefined) {
633+
(frame as any)[kLastFrameActionSymbol] = action;
634+
}

packages/playwright-core/src/server/recorder/recorderUtils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ export async function frameForAction(pageAliases: Map<Page, string>, actionInCon
7373
return result.frame;
7474
}
7575

76+
export function isAssertAction(action: actions.Action): action is actions.AssertAction {
77+
return action.name.startsWith('assert');
78+
}
79+
7680
function isSameAction(a: actions.ActionInContext, b: actions.ActionInContext): boolean {
7781
return a.action.name === b.action.name && a.frame.pageAlias === b.frame.pageAlias && a.frame.framePath.join('|') === b.frame.framePath.join('|');
7882
}

0 commit comments

Comments
 (0)