Skip to content

Commit 2b07f25

Browse files
authored
chore: move auto-generated expect computation to injected code (microsoft#36894)
1 parent 1beca69 commit 2b07f25

File tree

8 files changed

+97
-137
lines changed

8 files changed

+97
-137
lines changed

packages/injected/src/ariaSnapshot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ function toInternalOptions(options: AriaTreeOptions): InternalOptions {
7777
}
7878
if (options.mode === 'autoexpect') {
7979
// To auto-generate assertions on visible elements.
80-
return { visibility: 'ariaAndVisible', refs: 'all' };
80+
return { visibility: 'ariaAndVisible', refs: 'none' };
8181
}
8282
if (options.mode === 'codegen') {
8383
// To generate aria assertion with regex heurisitcs.

packages/injected/src/injectedScript.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export class InjectedScript {
107107
isInsideScope,
108108
normalizeWhiteSpace,
109109
parseAriaSnapshot,
110+
generateAriaTree,
110111
// Builtins protect injected code from clock emulation.
111112
builtins: null as unknown as Builtins,
112113
};

packages/injected/src/recorder/recorder.ts

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type { ElementText } from '../selectorUtils';
2323
import type * as actions from '@recorder/actions';
2424
import type { ElementInfo, Mode, OverlayState, UIState } from '@recorder/recorderTypes';
2525
import type { Language } from '@isomorphic/locatorGenerators';
26+
import type { AriaNode, AriaSnapshot } from '@injected/ariaSnapshot';
2627

2728
const HighlightColors = {
2829
multiple: '#f6b26b7f',
@@ -543,21 +544,13 @@ class RecordActionTool implements RecorderTool {
543544
consumeEvent(event);
544545
}
545546

546-
private _captureAriaSnapshotForAction(action: actions.Action) {
547-
const documentElement = this._recorder.injectedScript.document.documentElement;
548-
if (documentElement)
549-
action.ariaSnapshot = this._recorder.injectedScript.ariaSnapshot(documentElement, { mode: 'autoexpect' });
550-
}
551-
552547
private _recordAction(action: actions.Action) {
553-
this._captureAriaSnapshotForAction(action);
554548
this._recorder.recordAction(action);
555549
}
556550

557551
private _performAction(action: actions.PerformOnRecordAction) {
558552
this._recorder.updateHighlight(null, false);
559553

560-
this._captureAriaSnapshotForAction(action);
561554
this._performingActions.add(action);
562555

563556
const promise = this._recorder.performAction(action).then(() => {
@@ -1267,6 +1260,7 @@ export class Recorder {
12671260
private _tools: Record<Mode, RecorderTool>;
12681261
private _lastHighlightedSelector: string | undefined = undefined;
12691262
private _lastHighlightedAriaTemplateJSON: string = 'undefined';
1263+
private _lastActionAutoexpectSnapshot: AriaSnapshot | undefined;
12701264
readonly highlight: Highlight;
12711265
readonly overlay: Overlay | undefined;
12721266
private _stylesheet: CSSStyleSheet;
@@ -1585,11 +1579,25 @@ export class Recorder {
15851579
void this._delegate.setMode?.(mode);
15861580
}
15871581

1582+
private _captureAutoExpectSnapshot() {
1583+
const documentElement = this.injectedScript.document.documentElement;
1584+
return documentElement ? this.injectedScript.utils.generateAriaTree(documentElement, { mode: 'autoexpect' }) : undefined;
1585+
}
1586+
15881587
async performAction(action: actions.PerformOnRecordAction) {
1588+
const previousSnapshot = this._lastActionAutoexpectSnapshot;
1589+
this._lastActionAutoexpectSnapshot = this._captureAutoExpectSnapshot();
1590+
if (!isAssertAction(action) && this._lastActionAutoexpectSnapshot) {
1591+
const element = findNewElement(previousSnapshot, this._lastActionAutoexpectSnapshot);
1592+
action.preconditionSelector = element ? this.injectedScript.generateSelector(element, { testIdAttributeName: this.state.testIdAttributeName }).selector : undefined;
1593+
if (action.preconditionSelector === action.selector)
1594+
action.preconditionSelector = undefined;
1595+
}
15891596
await this._delegate.performAction?.(action).catch(() => {});
15901597
}
15911598

15921599
recordAction(action: actions.Action) {
1600+
this._lastActionAutoexpectSnapshot = this._captureAutoExpectSnapshot();
15931601
void this._delegate.recordAction?.(action);
15941602
}
15951603

@@ -1794,3 +1802,56 @@ function createSvgElement(doc: Document, { tagName, attrs, children }: SvgJson):
17941802

17951803
return elem;
17961804
}
1805+
1806+
function isAssertAction(action: actions.Action): action is actions.AssertAction {
1807+
return action.name.startsWith('assert');
1808+
}
1809+
1810+
function findNewElement(from: AriaSnapshot | undefined, to: AriaSnapshot): Element | undefined {
1811+
type ByRoleAndName = Map<string, Map<string, { node: AriaNode, sizeAndPosition: number }>>;
1812+
1813+
function fillMap(root: AriaNode, map: ByRoleAndName, position: number) {
1814+
let size = 1;
1815+
let childPosition = position + size;
1816+
for (const child of root.children || []) {
1817+
if (typeof child === 'string') {
1818+
size++;
1819+
childPosition++;
1820+
} else {
1821+
size += fillMap(child, map, childPosition);
1822+
childPosition += size;
1823+
}
1824+
}
1825+
if (!['none', 'presentation', 'fragment', 'iframe', 'generic'].includes(root.role) && root.name) {
1826+
let byRole = map.get(root.role);
1827+
if (!byRole) {
1828+
byRole = new Map();
1829+
map.set(root.role, byRole);
1830+
}
1831+
const existing = byRole.get(root.name);
1832+
// This heuristic prioritizes elements at the top of the page, even if somewhat smaller.
1833+
const sizeAndPosition = size * 100 - position;
1834+
if (!existing || existing.sizeAndPosition < sizeAndPosition)
1835+
byRole.set(root.name, { node: root, sizeAndPosition });
1836+
}
1837+
return size;
1838+
}
1839+
1840+
const fromMap: ByRoleAndName = new Map();
1841+
if (from)
1842+
fillMap(from.root, fromMap, 0);
1843+
1844+
const toMap: ByRoleAndName = new Map();
1845+
fillMap(to.root, toMap, 0);
1846+
1847+
const result: { node: AriaNode, sizeAndPosition: number }[] = [];
1848+
for (const [role, byRole] of toMap) {
1849+
for (const [name, byName] of byRole) {
1850+
const inFrom = fromMap.get(role)?.get(name);
1851+
if (!inFrom)
1852+
result.push(byName);
1853+
}
1854+
}
1855+
result.sort((a, b) => b.sizeAndPosition - a.sizeAndPosition);
1856+
return result[0]?.node.element;
1857+
}

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,33 @@ import type * as actions from '@recorder/actions';
2222
export function generateCode(actions: actions.ActionInContext[], languageGenerator: LanguageGenerator, options: LanguageGeneratorOptions) {
2323
const header = languageGenerator.generateHeader(options);
2424
const footer = languageGenerator.generateFooter(options.saveStorage);
25-
const actionTexts = actions.map(a => languageGenerator.generateAction(a)).filter(Boolean);
25+
const actionTexts = actions.map(a => generateActionText(languageGenerator, a)).filter(Boolean) as string[];
2626
const text = [header, ...actionTexts, footer].join('\n');
2727
return { header, footer, actionTexts, text };
2828
}
2929

30+
function generateActionText(generator: LanguageGenerator, action: actions.ActionInContext): string | undefined {
31+
let text = generator.generateAction(action);
32+
if (!text)
33+
return;
34+
if (action.action.preconditionSelector) {
35+
const expectAction: actions.ActionInContext = {
36+
frame: action.frame,
37+
startTime: action.startTime,
38+
endTime: action.startTime,
39+
action: {
40+
name: 'assertVisible',
41+
selector: action.action.preconditionSelector,
42+
signals: [],
43+
},
44+
};
45+
const expectText = generator.generateAction(expectAction);
46+
if (expectText)
47+
text = expectText + '\n' + text;
48+
}
49+
return text;
50+
}
51+
3052
export function sanitizeDeviceOptions(device: any, options: BrowserContextOptions): BrowserContextOptions {
3153
// Filter out all the properties from the device descriptor.
3254
const cleanedOptions: Record<string, any> = {};

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

Lines changed: 1 addition & 63 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, isAssertAction, metadataToCallLog, shouldMergeAction } from './recorder/recorderUtils';
23+
import { buildFullSelector, generateFrameSelector, metadataToCallLog } from './recorder/recorderUtils';
2424
import { locatorOrSelectorAsSelector } from '../utils/isomorphic/locatorParser';
2525
import { stringifySelector } from '../utils/isomorphic/selectorParser';
2626
import { ProgressController } from './progress';
@@ -31,8 +31,6 @@ 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';
3634

3735
import type { Language } from './codegen/types';
3836
import type { CallMetadata, InstrumentationListener, SdkObject } from './instrumentation';
@@ -522,60 +520,9 @@ export class Recorder extends EventEmitter<RecorderEventMap> implements Instrume
522520
return actionInContext;
523521
}
524522

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-
572523
private async _performAction(frame: Frame, action: actions.PerformOnRecordAction) {
573524
const actionInContext = await this._createActionInContext(frame, action);
574-
const assertActionInContext = await this._maybeGenerateAssertAction(frame, actionInContext);
575-
if (assertActionInContext)
576-
this._signalProcessor.addAction(assertActionInContext);
577525
this._signalProcessor.addAction(actionInContext);
578-
setLastFrameAction(frame, actionInContext);
579526
if (actionInContext.action.name !== 'openPage' && actionInContext.action.name !== 'closePage')
580527
await performAction(this._pageAliases, actionInContext);
581528
actionInContext.endTime = monotonicTime();
@@ -584,7 +531,6 @@ export class Recorder extends EventEmitter<RecorderEventMap> implements Instrume
584531
private async _recordAction(frame: Frame, action: actions.Action) {
585532
const actionInContext = await this._createActionInContext(frame, action);
586533
this._signalProcessor.addAction(actionInContext);
587-
setLastFrameAction(frame, actionInContext);
588534
}
589535

590536
private _onFrameNavigated(frame: Frame, page: Page) {
@@ -624,11 +570,3 @@ function languageForFile(file: string) {
624570
return 'csharp';
625571
return 'javascript';
626572
}
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: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,6 @@ 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-
8076
function isSameAction(a: actions.ActionInContext, b: actions.ActionInContext): boolean {
8177
return a.action.name === b.action.name && a.frame.pageAlias === b.frame.pageAlias && a.frame.framePath.join('|') === b.frame.framePath.join('|');
8278
}

packages/playwright-core/src/utils/isomorphic/ariaSnapshot.ts

Lines changed: 1 addition & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ export type AriaProps = {
3232
level?: number;
3333
pressed?: boolean | 'mixed';
3434
selected?: boolean;
35-
ref?: string;
3635
};
3736

3837
// We pass parsed template between worlds using JSON, make it easy.
@@ -65,7 +64,7 @@ type YamlLibrary = {
6564
};
6665

6766
type ParsedYamlPosition = { line: number; col: number; };
68-
type ParsingOptions = yamlTypes.ParseOptions & { allowRef?: boolean, allowUnknownAttributes?: boolean };
67+
type ParsingOptions = yamlTypes.ParseOptions;
6968

7069
export type ParsedYamlError = {
7170
message: string;
@@ -434,10 +433,6 @@ export class KeyParser {
434433
}
435434

436435
private _applyAttribute(node: AriaTemplateRoleNode, key: string, value: string, errorPos: number) {
437-
if (this._options.allowRef && key === 'ref') {
438-
node.ref = value;
439-
return;
440-
}
441436
if (key === 'checked') {
442437
this._assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "checked\" attribute must be a boolean or "mixed"', errorPos);
443438
node.checked = value === 'true' ? true : value === 'false' ? false : 'mixed';
@@ -473,8 +468,6 @@ export class KeyParser {
473468
node.selected = value === 'true';
474469
return;
475470
}
476-
if (this._options.allowUnknownAttributes)
477-
return;
478471
this._assert(false, `Unsupported attribute [${key}]`, errorPos);
479472
}
480473

@@ -492,55 +485,3 @@ export class ParserError extends Error {
492485
this.pos = pos;
493486
}
494487
}
495-
496-
export function findNewElementRef(yaml: YamlLibrary, fromSnapshot: string, toSnapshot: string): string | undefined {
497-
type ByRoleAndName = Map<string, Map<string, { node: AriaTemplateRoleNode, sizeAndPosition: number }>>;
498-
499-
function fillMap(root: AriaTemplateRoleNode, map: ByRoleAndName, position: number) {
500-
let size = 1;
501-
let childPosition = position + size;
502-
for (const child of root.children || []) {
503-
if (child.kind === 'role') {
504-
size += fillMap(child, map, childPosition);
505-
childPosition += size;
506-
} else {
507-
size++;
508-
childPosition++;
509-
}
510-
}
511-
if (!['none', 'presentation', 'fragment', 'iframe', 'generic'].includes(root.role) && typeof root.name === 'string' && root.name) {
512-
let byRole = map.get(root.role);
513-
if (!byRole) {
514-
byRole = new Map();
515-
map.set(root.role, byRole);
516-
}
517-
const existing = byRole.get(root.name);
518-
// This heuristic prioritizes elements at the top of the page, even if somewhat smaller.
519-
const sizeAndPosition = size * 100 - position;
520-
if (!existing || existing.sizeAndPosition < sizeAndPosition)
521-
byRole.set(root.name, { node: root, sizeAndPosition });
522-
}
523-
return size;
524-
}
525-
526-
const fromMap: ByRoleAndName = new Map();
527-
const from = parseAriaSnapshotUnsafe(yaml, fromSnapshot, { allowRef: true, allowUnknownAttributes: true });
528-
if (from.kind === 'role')
529-
fillMap(from, fromMap, 0);
530-
531-
const toMap: ByRoleAndName = new Map();
532-
const to = parseAriaSnapshotUnsafe(yaml, toSnapshot, { allowRef: true, allowUnknownAttributes: true });
533-
if (to.kind === 'role')
534-
fillMap(to, toMap, 0);
535-
536-
const result: { node: AriaTemplateRoleNode, sizeAndPosition: number }[] = [];
537-
for (const [role, byRole] of toMap) {
538-
for (const [name, byName] of byRole) {
539-
const inFrom = fromMap.get(role)?.get(name);
540-
if (!inFrom)
541-
result.push(byName);
542-
}
543-
}
544-
result.sort((a, b) => b.sizeAndPosition - a.sizeAndPosition);
545-
return result.find(r => r.node.ref)?.node.ref;
546-
}

packages/recorder/src/actions.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type ActionBase = {
3737
name: ActionName,
3838
signals: Signal[],
3939
ariaSnapshot?: string,
40+
preconditionSelector?: string,
4041
};
4142

4243
export type ActionWithSelector = ActionBase & {

0 commit comments

Comments
 (0)