Skip to content

[pull] main from microsoft:main #115

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 31, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/src/api/class-clock.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,3 +340,8 @@ Time to be set.
- `time` <[string]|[Date]>

Time to be set.

## async method: Clock.uninstall
* since: v1.55

Uninstall fake clock. Note that any currently open page will be still affected by the fake clock, until it navigates away to a new document.
116 changes: 80 additions & 36 deletions packages/injected/src/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,47 @@ type AriaRef = {

let lastRef = 0;

export type AriaTreeOptions = { forAI?: boolean, refPrefix?: string, refs?: boolean, visibleOnly?: boolean };
export type AriaTreeOptions = {
mode: 'ai' | 'expect' | 'codegen' | 'autoexpect';
refPrefix?: string;
};

type InternalOptions = {
visibility: 'aria' | 'ariaOrVisible' | 'ariaAndVisible',
refs: 'all' | 'interactable' | 'none',
refPrefix?: string,
includeGenericRole?: boolean,
renderCursorPointer?: boolean,
renderActive?: boolean,
renderStringsAsRegex?: boolean,
};

export function generateAriaTree(rootElement: Element, options?: AriaTreeOptions): AriaSnapshot {
function toInternalOptions(options: AriaTreeOptions): InternalOptions {
if (options.mode === 'ai') {
// For AI consumption.
return {
visibility: 'ariaOrVisible',
refs: 'interactable',
refPrefix: options.refPrefix,
includeGenericRole: true,
renderActive: true,
renderCursorPointer: true,
};
}
if (options.mode === 'autoexpect') {
// To auto-generate assertions on visible elements.
return { visibility: 'ariaAndVisible', refs: 'all' };
}
if (options.mode === 'codegen') {
// To generate aria assertion with regex heurisitcs.
return { visibility: 'aria', refs: 'none', renderStringsAsRegex: true };
}
// To match aria snapshot.
return { visibility: 'aria', refs: 'none' };
}

export function generateAriaTree(rootElement: Element, publicOptions: AriaTreeOptions): AriaSnapshot {
const options = toInternalOptions(publicOptions);
const visited = new Set<Node>();

const snapshot: AriaSnapshot = {
Expand Down Expand Up @@ -79,8 +117,16 @@ export function generateAriaTree(rootElement: Element, options?: AriaTreeOptions
return;

const element = node as Element;
const isElementHiddenForAria = roleUtils.isElementHiddenForAria(element);
if (isElementHiddenForAria && !options?.forAI)
const isElementVisibleForAria = !roleUtils.isElementHiddenForAria(element);
let visible = isElementVisibleForAria;
if (options.visibility === 'ariaOrVisible')
visible = isElementVisibleForAria || isElementVisible(element);
if (options.visibility === 'ariaAndVisible')
visible = isElementVisibleForAria && isElementVisible(element);

// Optimization: if we only consider aria visibility, we can skip child elements because
// they will not be visible for aria as well.
if (options.visibility === 'aria' && !visible)
return;

const ariaChildren: Element[] = [];
Expand All @@ -93,7 +139,6 @@ export function generateAriaTree(rootElement: Element, options?: AriaTreeOptions
}
}

const visible = options?.visibleOnly ? isElementVisible(element) : !isElementHiddenForAria || isElementVisible(element);
const childAriaNode = visible ? toAriaNode(element, options) : null;
if (childAriaNode) {
if (childAriaNode.ref) {
Expand Down Expand Up @@ -157,36 +202,39 @@ export function generateAriaTree(rootElement: Element, options?: AriaTreeOptions
return snapshot;
}

function ariaRef(element: Element, role: string, name: string, options?: AriaTreeOptions): string | undefined {
if (!options?.forAI && !options?.refs)
return undefined;
function computeAriaRef(ariaNode: AriaNode, options: InternalOptions) {
if (options.refs === 'none')
return;
if (options.refs === 'interactable' && (!ariaNode.box.visible || !ariaNode.receivesPointerEvents))
return;

let ariaRef: AriaRef | undefined;
ariaRef = (element as any)._ariaRef;
if (!ariaRef || ariaRef.role !== role || ariaRef.name !== name) {
ariaRef = { role, name, ref: (options?.refPrefix ?? '') + 'e' + (++lastRef) };
(element as any)._ariaRef = ariaRef;
ariaRef = (ariaNode.element as any)._ariaRef;
if (!ariaRef || ariaRef.role !== ariaNode.role || ariaRef.name !== ariaNode.name) {
ariaRef = { role: ariaNode.role, name: ariaNode.name, ref: (options.refPrefix ?? '') + 'e' + (++lastRef) };
(ariaNode.element as any)._ariaRef = ariaRef;
}
return ariaRef.ref;
ariaNode.ref = ariaRef.ref;
}

function toAriaNode(element: Element, options?: AriaTreeOptions): AriaNode | null {
function toAriaNode(element: Element, options: InternalOptions): AriaNode | null {
const active = element.ownerDocument.activeElement === element;
if (element.nodeName === 'IFRAME') {
return {
const ariaNode: AriaNode = {
role: 'iframe',
name: '',
ref: ariaRef(element, 'iframe', '', options),
children: [],
props: {},
element,
box: box(element),
receivesPointerEvents: true,
active
};
computeAriaRef(ariaNode, options);
return ariaNode;
}

const defaultRole = options?.forAI ? 'generic' : null;
const defaultRole = options.includeGenericRole ? 'generic' : null;
const role = roleUtils.getAriaRole(element) ?? defaultRole;
if (!role || role === 'presentation' || role === 'none')
return null;
Expand All @@ -197,14 +245,14 @@ function toAriaNode(element: Element, options?: AriaTreeOptions): AriaNode | nul
const result: AriaNode = {
role,
name,
ref: ariaRef(element, role, name, options),
children: [],
props: {},
element,
box: box(element),
receivesPointerEvents,
active
};
computeAriaRef(result, options);

if (roleUtils.kAriaCheckedRoles.includes(role))
result.checked = roleUtils.getAriaChecked(element);
Expand Down Expand Up @@ -245,7 +293,7 @@ function normalizeGenericRoles(node: AriaNode) {
}

// Only remove generic that encloses one element, logical grouping still makes sense, even if it is not ref-able.
const removeSelf = node.role === 'generic' && result.length <= 1 && result.every(c => typeof c !== 'string' && receivesPointerEvents(c));
const removeSelf = node.role === 'generic' && result.length <= 1 && result.every(c => typeof c !== 'string' && !!c.ref);
if (removeSelf)
return result;
node.children = result;
Expand Down Expand Up @@ -308,20 +356,20 @@ export type MatcherReceived = {
regex: string;
};

export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } {
const snapshot = generateAriaTree(rootElement);
export function matchesExpectAriaTemplate(rootElement: Element, template: AriaTemplateNode): { matches: AriaNode[], received: MatcherReceived } {
const snapshot = generateAriaTree(rootElement, { mode: 'expect' });
const matches = matchesNodeDeep(snapshot.root, template, false, false);
return {
matches,
received: {
raw: renderAriaTree(snapshot, { mode: 'raw' }),
regex: renderAriaTree(snapshot, { mode: 'regex' }),
raw: renderAriaTree(snapshot, { mode: 'expect' }),
regex: renderAriaTree(snapshot, { mode: 'codegen' }),
}
};
}

export function getAllByAria(rootElement: Element, template: AriaTemplateNode): Element[] {
const root = generateAriaTree(rootElement).root;
export function getAllElementsMatchingExpectAriaTemplate(rootElement: Element, template: AriaTemplateNode): Element[] {
const root = generateAriaTree(rootElement, { mode: 'expect' }).root;
const matches = matchesNodeDeep(root, template, true, false);
return matches.map(n => n.element);
}
Expand Down Expand Up @@ -411,10 +459,11 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll:
return results;
}

export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'raw' | 'regex', forAI?: boolean, refs?: boolean }): string {
export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTreeOptions): string {
const options = toInternalOptions(publicOptions);
const lines: string[] = [];
const includeText = options?.mode === 'regex' ? textContributesInfo : () => true;
const renderString = options?.mode === 'regex' ? convertToBestGuessRegex : (str: string) => str;
const includeText = options.renderStringsAsRegex ? textContributesInfo : () => true;
const renderString = options.renderStringsAsRegex ? convertToBestGuessRegex : (str: string) => str;
const visit = (ariaNode: AriaNode | string, parentAriaNode: AriaNode | null, indent: string) => {
if (typeof ariaNode === 'string') {
if (parentAriaNode && !includeText(parentAriaNode, ariaNode))
Expand Down Expand Up @@ -442,7 +491,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r
key += ` [disabled]`;
if (ariaNode.expanded)
key += ` [expanded]`;
if (ariaNode.active && options?.forAI)
if (ariaNode.active && options.renderActive)
key += ` [active]`;
if (ariaNode.level)
key += ` [level=${ariaNode.level}]`;
Expand All @@ -453,10 +502,9 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, options?: { mode?: 'r
if (ariaNode.selected === true)
key += ` [selected]`;

const includeRef = (options?.forAI && receivesPointerEvents(ariaNode)) || options?.refs;
if (includeRef && ariaNode.ref) {
if (ariaNode.ref) {
key += ` [ref=${ariaNode.ref}]`;
if (hasPointerCursor(ariaNode))
if (options.renderCursorPointer && hasPointerCursor(ariaNode))
key += ' [cursor=pointer]';
}

Expand Down Expand Up @@ -548,10 +596,6 @@ function textContributesInfo(node: AriaNode, text: string): boolean {
return filtered.trim().length / text.length > 0.1;
}

function receivesPointerEvents(ariaNode: AriaNode): boolean {
return ariaNode.box.visible && ariaNode.receivesPointerEvents;
}

function hasPointerCursor(ariaNode: AriaNode): boolean {
return ariaNode.box.style?.cursor === 'pointer';
}
4 changes: 2 additions & 2 deletions packages/injected/src/consoleApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,8 @@ export class ConsoleAPI {
inspect: (selector: string) => this._inspect(selector),
selector: (element: Element) => this._selector(element),
generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language),
ariaSnapshot: (element?: Element, options?: { forAI?: boolean }) => {
return this._injectedScript.ariaSnapshot(element || this._injectedScript.document.body, options);
ariaSnapshot: (element?: Element) => {
return this._injectedScript.ariaSnapshot(element || this._injectedScript.document.body, { mode: 'expect' });
},
resume: () => this._resume(),
...new Locator(this._injectedScript, ''),
Expand Down
14 changes: 7 additions & 7 deletions packages/injected/src/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { asLocator } from '@isomorphic/locatorGenerators';
import { parseAttributeSelector, parseSelector, stringifySelector, visitAllSelectorParts } from '@isomorphic/selectorParser';
import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '@isomorphic/stringUtils';

import { generateAriaTree, getAllByAria, matchesAriaTree, renderAriaTree } from './ariaSnapshot';
import { generateAriaTree, getAllElementsMatchingExpectAriaTemplate, matchesExpectAriaTemplate, renderAriaTree } from './ariaSnapshot';
import { enclosingShadowRootOrDocument, isElementVisible, isInsideScope, parentElementOrShadowHost, setGlobalOptions } from './domUtils';
import { Highlight } from './highlight';
import { kLayoutSelectorNames, layoutSelectorScore } from './layoutSelectorUtils';
Expand Down Expand Up @@ -297,21 +297,21 @@ export class InjectedScript {
return new Set<Element>(result.map(r => r.element));
}

ariaSnapshot(node: Node, options?: { mode?: 'raw' | 'regex' } & AriaTreeOptions): string {
ariaSnapshot(node: Node, options: AriaTreeOptions): string {
if (node.nodeType !== Node.ELEMENT_NODE)
throw this.createStacklessError('Can only capture aria snapshot of Element nodes.');
this._lastAriaSnapshot = generateAriaTree(node as Element, options);
return renderAriaTree(this._lastAriaSnapshot, options);
}

ariaSnapshotForRecorder(): { ariaSnapshot: string, refs: Map<Element, string> } {
const tree = generateAriaTree(this.document.body, { forAI: true });
const ariaSnapshot = renderAriaTree(tree, { forAI: true });
const tree = generateAriaTree(this.document.body, { mode: 'ai' });
const ariaSnapshot = renderAriaTree(tree, { mode: 'ai' });
return { ariaSnapshot, refs: tree.refs };
}

getAllByAria(document: Document, template: AriaTemplateNode): Element[] {
return getAllByAria(document.documentElement, template);
getAllElementsMatchingExpectAriaTemplate(document: Document, template: AriaTemplateNode): Element[] {
return getAllElementsMatchingExpectAriaTemplate(document.documentElement, template);
}

querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
Expand Down Expand Up @@ -1469,7 +1469,7 @@ export class InjectedScript {

{
if (expression === 'to.match.aria') {
const result = matchesAriaTree(element, options.expectedValue);
const result = matchesExpectAriaTemplate(element, options.expectedValue);
return {
received: result.received,
matches: !!result.matches.length,
Expand Down
8 changes: 4 additions & 4 deletions packages/injected/src/recorder/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ class RecordActionTool implements RecorderTool {
private _captureAriaSnapshotForAction(action: actions.Action) {
const documentElement = this._recorder.injectedScript.document.documentElement;
if (documentElement)
action.ariaSnapshot = this._recorder.injectedScript.ariaSnapshot(documentElement, { mode: 'raw', refs: true, visibleOnly: true });
action.ariaSnapshot = this._recorder.injectedScript.ariaSnapshot(documentElement, { mode: 'autoexpect' });
}

private _recordAction(action: actions.Action) {
Expand Down Expand Up @@ -958,7 +958,7 @@ class TextAssertionTool implements RecorderTool {
name: 'assertSnapshot',
selector: this._hoverHighlight.selector,
signals: [],
ariaSnapshot: this._recorder.injectedScript.ariaSnapshot(target, { mode: 'regex' }),
ariaSnapshot: this._recorder.injectedScript.ariaSnapshot(target, { mode: 'codegen' }),
};
} else {
const generated = this._recorder.injectedScript.generateSelector(target, { testIdAttributeName: this._recorder.state.testIdAttributeName, forTextExpect: true });
Expand Down Expand Up @@ -1391,7 +1391,7 @@ export class Recorder {

const ariaTemplateJSON = JSON.stringify(state.ariaTemplate);
if (this._lastHighlightedAriaTemplateJSON !== ariaTemplateJSON) {
const elements = state.ariaTemplate ? this.injectedScript.getAllByAria(this.document, state.ariaTemplate) : [];
const elements = state.ariaTemplate ? this.injectedScript.getAllElementsMatchingExpectAriaTemplate(this.document, state.ariaTemplate) : [];
if (elements.length) {
const color = elements.length > 1 ? HighlightColors.multiple : HighlightColors.single;
highlight = elements.map(element => ({ element, color }));
Expand Down Expand Up @@ -1598,7 +1598,7 @@ export class Recorder {
}

elementPicked(selector: string, model: HighlightModel) {
const ariaSnapshot = this.injectedScript.ariaSnapshot(model.elements[0]);
const ariaSnapshot = this.injectedScript.ariaSnapshot(model.elements[0], { mode: 'expect' });
void this._delegate.elementPicked?.({ selector, ariaSnapshot });
}
}
Expand Down
28 changes: 25 additions & 3 deletions packages/injected/src/storageScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,13 +176,35 @@ export class StorageScript {
}));
}

async restore(originState: channels.SetOriginStorage) {
async restore(originState: channels.SetOriginStorage | undefined) {
// Clean Service Workers.
const registrations = this._global.navigator.serviceWorker ? await this._global.navigator.serviceWorker.getRegistrations() : [];
await Promise.all(registrations.map(async r => {
// Heuristic for service workers that stalled during main script fetch or importScripts:
// Waiting for them to finish unregistering takes ages so we do not await.
// However, they will unregister immediately after fetch finishes and should not affect next page load.
// Unfortunately, loading next page in Chromium still takes 5 seconds waiting for
// some operation on this bogus service worker to finish.
if (!r.installing && !r.waiting && !r.active)
r.unregister().catch(() => {});
else
await r.unregister().catch(() => {});
}));

try {
await Promise.all((originState.indexedDB ?? []).map(dbInfo => this._restoreDB(dbInfo)));
for (const db of await this._global.indexedDB.databases?.() || []) {
// Do not wait for the callback - it is called on timer in Chromium (slow).
if (db.name)
this._global.indexedDB.deleteDatabase(db.name!);
}
await Promise.all((originState?.indexedDB ?? []).map(dbInfo => this._restoreDB(dbInfo)));
} catch (e) {
throw new Error('Unable to restore IndexedDB: ' + e.message);
}
for (const { name, value } of (originState.localStorage || []))

this._global.sessionStorage.clear();
this._global.localStorage.clear();
for (const { name, value } of (originState?.localStorage || []))
this._global.localStorage.setItem(name, value);
}
}
6 changes: 6 additions & 0 deletions packages/playwright-client/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18798,6 +18798,12 @@ export interface Clock {
* @param time Time to be set in milliseconds.
*/
setSystemTime(time: number|string|Date): Promise<void>;

/**
* Uninstall fake clock. Note that any currently open page will be still affected by the fake clock, until it
* navigates away to a new document.
*/
uninstall(): Promise<void>;
}

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/playwright-core/src/client/clock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export class Clock implements api.Clock {
async setSystemTime(time: string | number | Date) {
await this._browserContext._channel.clockSetSystemTime(parseTime(time));
}

async uninstall() {
await this._browserContext._channel.clockUninstall();
}
}

function parseTime(time: string | number | Date): { timeNumber?: number, timeString?: string } {
Expand Down
Loading
Loading