chore: allow recorder rewrite annotations (#32381)

This commit is contained in:
Pavel Feldman 2024-08-29 14:16:01 -07:00 committed by GitHub
parent 6763d5ab6b
commit 74a8e59096
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 65 additions and 39 deletions

View file

@ -34,7 +34,8 @@ import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } fr
import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators';
import { cacheNormalizedWhitespaces, escapeHTML, escapeHTMLAttribute, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils'; import { cacheNormalizedWhitespaces, escapeHTML, escapeHTMLAttribute, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils';
import { generateSimpleDom, generateSimpleDomNode, selectorForSimpleDomNodeId } from './simpleDom'; import { selectorForSimpleDomNodeId, generateSimpleDomNode } from './simpleDom';
import type { SimpleDomNode } from './simpleDom';
export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any }; export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };
@ -79,15 +80,12 @@ export class InjectedScript {
endAriaCaches, endAriaCaches,
escapeHTML, escapeHTML,
escapeHTMLAttribute, escapeHTMLAttribute,
generateSimpleDom: generateSimpleDom.bind(undefined, this),
generateSimpleDomNode: generateSimpleDomNode.bind(undefined, this),
getAriaRole, getAriaRole,
getElementAccessibleDescription, getElementAccessibleDescription,
getElementAccessibleName, getElementAccessibleName,
isElementVisible, isElementVisible,
isInsideScope, isInsideScope,
normalizeWhiteSpace, normalizeWhiteSpace,
selectorForSimpleDomNodeId: selectorForSimpleDomNodeId.bind(undefined, this),
}; };
// eslint-disable-next-line no-restricted-globals // eslint-disable-next-line no-restricted-globals
@ -1314,6 +1312,17 @@ export class InjectedScript {
} }
throw this.createStacklessError('Unknown expect matcher: ' + expression); throw this.createStacklessError('Unknown expect matcher: ' + expression);
} }
generateSimpleDomNode(selector: string): SimpleDomNode | undefined {
const element = this.querySelector(this.parseSelector(selector), this.document.documentElement, true);
if (!element)
return;
return generateSimpleDomNode(this, element);
}
selectorForSimpleDomNodeId(nodeId: string) {
return selectorForSimpleDomNodeId(this, nodeId);
}
} }
const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']); const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);

View file

@ -24,8 +24,8 @@ import clipPaths from './clipPaths';
import type { SimpleDomNode } from '../simpleDom'; import type { SimpleDomNode } from '../simpleDom';
interface RecorderDelegate { interface RecorderDelegate {
performAction?(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode): Promise<void>; performAction?(action: actions.PerformOnRecordAction): Promise<void>;
recordAction?(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise<void>; recordAction?(action: actions.Action): Promise<void>;
setSelector?(selector: string): Promise<void>; setSelector?(selector: string): Promise<void>;
setMode?(mode: Mode): Promise<void>; setMode?(mode: Mode): Promise<void>;
setOverlayState?(state: OverlayState): Promise<void>; setOverlayState?(state: OverlayState): Promise<void>;
@ -931,7 +931,6 @@ export class Recorder {
testIdAttributeName: 'data-testid', testIdAttributeName: 'data-testid',
language: 'javascript', language: 'javascript',
overlay: { offsetX: 0 }, overlay: { offsetX: 0 },
generateSimpleDom: false,
}; };
readonly document: Document; readonly document: Document;
private _delegate: RecorderDelegate = {}; private _delegate: RecorderDelegate = {};
@ -1186,13 +1185,11 @@ export class Recorder {
} }
async performAction(action: actions.PerformOnRecordAction) { async performAction(action: actions.PerformOnRecordAction) {
const simpleDomNode = this._generateSimpleDomNode(action); await this._delegate.performAction?.(action).catch(() => {});
await this._delegate.performAction?.(action, simpleDomNode).catch(() => {});
} }
recordAction(action: actions.Action) { recordAction(action: actions.Action) {
const simpleDomNode = this._generateSimpleDomNode(action); void this._delegate.recordAction?.(action);
void this._delegate.recordAction?.(action, simpleDomNode);
} }
setOverlayState(state: { offsetX: number; }) { setOverlayState(state: { offsetX: number; }) {
@ -1202,18 +1199,6 @@ export class Recorder {
setSelector(selector: string) { setSelector(selector: string) {
void this._delegate.setSelector?.(selector); 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 { class Dialog {

View file

@ -77,10 +77,11 @@ function generate(injectedScript: InjectedScript, target?: Element): { dom: Simp
const name = injectedScript.utils.getElementAccessibleName(element, false); const name = injectedScript.utils.getElementAccessibleName(element, false);
const structuralId = String(++lastId); const structuralId = String(++lastId);
elements.set(structuralId, element); elements.set(structuralId, element);
const tag = renderTag(injectedScript, role, name, structuralId, { value }); tokens.push(renderTag(injectedScript, role, name, structuralId, { value }));
if (element === target) if (element === target) {
resultTarget = { tag, id: structuralId }; const tagNoValue = renderTag(injectedScript, role, name, structuralId);
tokens.push(tag); resultTarget = { tag: tagNoValue, id: structuralId };
}
return; return;
} }
} }

View file

@ -72,7 +72,7 @@ export class Recorder implements InstrumentationListener {
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
this._mode = params.mode || 'none'; this._mode = params.mode || 'none';
this._contextRecorder = new ContextRecorder(context, params); this._contextRecorder = new ContextRecorder(context, params, {});
this._context = context; this._context = context;
this._omitCallTracking = !!params.omitCallTracking; this._omitCallTracking = !!params.omitCallTracking;
this._debugger = context.debugger(); this._debugger = context.debugger();
@ -160,7 +160,6 @@ export class Recorder implements InstrumentationListener {
language: this._currentLanguage, language: this._currentLanguage,
testIdAttributeName: this._contextRecorder.testIdAttributeName(), testIdAttributeName: this._contextRecorder.testIdAttributeName(),
overlay: this._overlayState, overlay: this._overlayState,
generateSimpleDom: false,
}; };
return uiState; return uiState;
}); });

View file

@ -25,7 +25,6 @@ import type { ActionInContext, FrameDescription, LanguageGeneratorOptions, Langu
import { languageSet } from '../codegen/languages'; import { languageSet } from '../codegen/languages';
import type { Dialog } from '../dialog'; import type { Dialog } from '../dialog';
import { Frame } from '../frames'; import { Frame } from '../frames';
import type { SimpleDomNode } from '../injected/simpleDom';
import { Page } from '../page'; import { Page } from '../page';
import type * as actions from './recorderActions'; import type * as actions from './recorderActions';
import { performAction } from './recorderRunner'; import { performAction } from './recorderRunner';
@ -35,6 +34,10 @@ import { generateCode } from '../codegen/language';
type BindingSource = { frame: Frame, page: Page }; type BindingSource = { frame: Frame, page: Page };
export interface ContextRecorderDelegate {
rewriteActionInContext?(pageAliases: Map<Page, string>, actionInContext: ActionInContext): Promise<void>;
}
export class ContextRecorder extends EventEmitter { export class ContextRecorder extends EventEmitter {
static Events = { static Events = {
Change: 'change' Change: 'change'
@ -48,15 +51,17 @@ export class ContextRecorder extends EventEmitter {
private _timers = new Set<NodeJS.Timeout>(); private _timers = new Set<NodeJS.Timeout>();
private _context: BrowserContext; private _context: BrowserContext;
private _params: channels.BrowserContextRecorderSupplementEnableParams; private _params: channels.BrowserContextRecorderSupplementEnableParams;
private _delegate: ContextRecorderDelegate;
private _recorderSources: Source[]; private _recorderSources: Source[];
private _throttledOutputFile: ThrottledFile | null = null; private _throttledOutputFile: ThrottledFile | null = null;
private _orderedLanguages: LanguageGenerator[] = []; private _orderedLanguages: LanguageGenerator[] = [];
private _listeners: RegisteredListener[] = []; private _listeners: RegisteredListener[] = [];
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) { constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams, delegate: ContextRecorderDelegate) {
super(); super();
this._context = context; this._context = context;
this._params = params; this._params = params;
this._delegate = delegate;
this._recorderSources = []; this._recorderSources = [];
const language = params.language || context.attribution.playwright.options.sdkLanguage; const language = params.language || context.attribution.playwright.options.sdkLanguage;
this.setOutput(language, params.outputFile); this.setOutput(language, params.outputFile);
@ -134,11 +139,11 @@ export class ContextRecorder extends EventEmitter {
// Input actions that potentially lead to navigation are intercepted on the page and are // Input actions that potentially lead to navigation are intercepted on the page and are
// performed by the Playwright. // performed by the Playwright.
await this._context.exposeBinding('__pw_recorderPerformAction', false, await this._context.exposeBinding('__pw_recorderPerformAction', false,
(source: BindingSource, action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) => this._performAction(source.frame, action, simpleDomNode)); (source: BindingSource, action: actions.PerformOnRecordAction) => this._performAction(source.frame, action));
// Other non-essential actions are simply being recorded. // Other non-essential actions are simply being recorded.
await this._context.exposeBinding('__pw_recorderRecordAction', false, await this._context.exposeBinding('__pw_recorderRecordAction', false,
(source: BindingSource, action: actions.Action, simpleDomNode?: SimpleDomNode) => this._recordAction(source.frame, action, simpleDomNode)); (source: BindingSource, action: actions.Action) => this._recordAction(source.frame, action));
await this._context.extendInjectedScript(recorderSource.source); await this._context.extendInjectedScript(recorderSource.source);
} }
@ -218,7 +223,7 @@ export class ContextRecorder extends EventEmitter {
return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid'; return this._params.testIdAttributeName || this._context.selectors().testIdAttributeName() || 'data-testid';
} }
private async _performAction(frame: Frame, action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode) { private async _performAction(frame: Frame, action: actions.PerformOnRecordAction) {
// Commit last action so that no further signals are added to it. // Commit last action so that no further signals are added to it.
this._collection.commitLastAction(); this._collection.commitLastAction();
@ -226,9 +231,11 @@ export class ContextRecorder extends EventEmitter {
const actionInContext: ActionInContext = { const actionInContext: ActionInContext = {
frame: frameDescription, frame: frameDescription,
action, action,
description: undefined, // TODO: generate description based on simple dom node. description: undefined,
}; };
await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext);
this._collection.willPerformAction(actionInContext); this._collection.willPerformAction(actionInContext);
const success = await performAction(this._pageAliases, actionInContext); const success = await performAction(this._pageAliases, actionInContext);
if (success) { if (success) {
@ -239,7 +246,7 @@ export class ContextRecorder extends EventEmitter {
} }
} }
private async _recordAction(frame: Frame, action: actions.Action, simpleDomNode?: SimpleDomNode) { private async _recordAction(frame: Frame, action: actions.Action) {
// Commit last action so that no further signals are added to it. // Commit last action so that no further signals are added to it.
this._collection.commitLastAction(); this._collection.commitLastAction();
@ -247,8 +254,11 @@ export class ContextRecorder extends EventEmitter {
const actionInContext: ActionInContext = { const actionInContext: ActionInContext = {
frame: frameDescription, frame: frameDescription,
action, action,
description: undefined, // TODO: generate description based on simple dom node. description: undefined,
}; };
await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext);
this._setCommittedAfterTimeout(actionInContext); this._setCommittedAfterTimeout(actionInContext);
this._collection.addAction(actionInContext); this._collection.addAction(actionInContext);
} }

View file

@ -16,6 +16,10 @@
import type { CallMetadata } from '../instrumentation'; import type { CallMetadata } from '../instrumentation';
import type { CallLog, CallLogStatus } from '@recorder/recorderTypes'; import type { CallLog, CallLogStatus } from '@recorder/recorderTypes';
import type { Page } from '../page';
import type { ActionInContext } from '../codegen/types';
import type { Frame } from '../frames';
import type * as actions from './recorderActions';
export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog { export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus): CallLog {
let title = metadata.apiName || metadata.method; let title = metadata.apiName || metadata.method;
@ -48,3 +52,23 @@ export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus)
export function buildFullSelector(framePath: string[], selector: string) { export function buildFullSelector(framePath: string[], selector: string) {
return [...framePath, selector].join(' >> internal:control=enter-frame >> '); return [...framePath, selector].join(' >> internal:control=enter-frame >> ');
} }
export function mainFrameForAction(pageAliases: Map<Page, string>, actionInContext: ActionInContext): Frame {
const pageAlias = actionInContext.frame.pageAlias;
const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0];
if (!page)
throw new Error('Internal error: page not found');
return page.mainFrame();
}
export async function frameForAction(pageAliases: Map<Page, string>, actionInContext: ActionInContext, action: actions.ActionWithSelector): Promise<Frame> {
const pageAlias = actionInContext.frame.pageAlias;
const page = [...pageAliases.entries()].find(([, alias]) => pageAlias === alias)?.[0];
if (!page)
throw new Error('Internal error: page not found');
const fullSelector = buildFullSelector(actionInContext.frame.framePath, action.selector);
const result = await page.mainFrame().selectors.resolveFrameForSelector(fullSelector);
if (!result)
throw new Error('Internal error: frame not found');
return result.frame;
}

View file

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

View file

@ -254,7 +254,6 @@ export const InspectModeController: React.FunctionComponent<{
language: sdkLanguage, language: sdkLanguage,
testIdAttributeName, testIdAttributeName,
overlay: { offsetX: 0 }, overlay: { offsetX: 0 },
generateSimpleDom: false,
}, { }, {
async setSelector(selector: string) { async setSelector(selector: string) {
setHighlightedLocator(asLocator(sdkLanguage, frameSelector + selector)); setHighlightedLocator(asLocator(sdkLanguage, frameSelector + selector));