chore: allow recorder rewrite annotations (#32381)
This commit is contained in:
parent
6763d5ab6b
commit
74a8e59096
|
|
@ -34,7 +34,8 @@ import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } fr
|
|||
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
|
||||
import type { Language } from '../../utils/isomorphic/locatorGenerators';
|
||||
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 };
|
||||
|
||||
|
|
@ -79,15 +80,12 @@ export class InjectedScript {
|
|||
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
|
||||
|
|
@ -1314,6 +1312,17 @@ export class InjectedScript {
|
|||
}
|
||||
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']);
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ import clipPaths from './clipPaths';
|
|||
import type { SimpleDomNode } from '../simpleDom';
|
||||
|
||||
interface RecorderDelegate {
|
||||
performAction?(action: actions.PerformOnRecordAction, simpleDomNode?: SimpleDomNode): Promise<void>;
|
||||
recordAction?(action: actions.Action, simpleDomNode?: SimpleDomNode): Promise<void>;
|
||||
performAction?(action: actions.PerformOnRecordAction): Promise<void>;
|
||||
recordAction?(action: actions.Action): Promise<void>;
|
||||
setSelector?(selector: string): Promise<void>;
|
||||
setMode?(mode: Mode): Promise<void>;
|
||||
setOverlayState?(state: OverlayState): Promise<void>;
|
||||
|
|
@ -931,7 +931,6 @@ export class Recorder {
|
|||
testIdAttributeName: 'data-testid',
|
||||
language: 'javascript',
|
||||
overlay: { offsetX: 0 },
|
||||
generateSimpleDom: false,
|
||||
};
|
||||
readonly document: Document;
|
||||
private _delegate: RecorderDelegate = {};
|
||||
|
|
@ -1186,13 +1185,11 @@ export class Recorder {
|
|||
}
|
||||
|
||||
async performAction(action: actions.PerformOnRecordAction) {
|
||||
const simpleDomNode = this._generateSimpleDomNode(action);
|
||||
await this._delegate.performAction?.(action, simpleDomNode).catch(() => {});
|
||||
await this._delegate.performAction?.(action).catch(() => {});
|
||||
}
|
||||
|
||||
recordAction(action: actions.Action) {
|
||||
const simpleDomNode = this._generateSimpleDomNode(action);
|
||||
void this._delegate.recordAction?.(action, simpleDomNode);
|
||||
void this._delegate.recordAction?.(action);
|
||||
}
|
||||
|
||||
setOverlayState(state: { offsetX: number; }) {
|
||||
|
|
@ -1202,18 +1199,6 @@ export class Recorder {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -77,10 +77,11 @@ function generate(injectedScript: InjectedScript, target?: Element): { dom: Simp
|
|||
const name = injectedScript.utils.getElementAccessibleName(element, false);
|
||||
const structuralId = String(++lastId);
|
||||
elements.set(structuralId, element);
|
||||
const tag = renderTag(injectedScript, role, name, structuralId, { value });
|
||||
if (element === target)
|
||||
resultTarget = { tag, id: structuralId };
|
||||
tokens.push(tag);
|
||||
tokens.push(renderTag(injectedScript, role, name, structuralId, { value }));
|
||||
if (element === target) {
|
||||
const tagNoValue = renderTag(injectedScript, role, name, structuralId);
|
||||
resultTarget = { tag: tagNoValue, id: structuralId };
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export class Recorder implements InstrumentationListener {
|
|||
|
||||
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
|
||||
this._mode = params.mode || 'none';
|
||||
this._contextRecorder = new ContextRecorder(context, params);
|
||||
this._contextRecorder = new ContextRecorder(context, params, {});
|
||||
this._context = context;
|
||||
this._omitCallTracking = !!params.omitCallTracking;
|
||||
this._debugger = context.debugger();
|
||||
|
|
@ -160,7 +160,6 @@ export class Recorder implements InstrumentationListener {
|
|||
language: this._currentLanguage,
|
||||
testIdAttributeName: this._contextRecorder.testIdAttributeName(),
|
||||
overlay: this._overlayState,
|
||||
generateSimpleDom: false,
|
||||
};
|
||||
return uiState;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import type { ActionInContext, FrameDescription, LanguageGeneratorOptions, Langu
|
|||
import { languageSet } from '../codegen/languages';
|
||||
import type { Dialog } from '../dialog';
|
||||
import { Frame } from '../frames';
|
||||
import type { SimpleDomNode } from '../injected/simpleDom';
|
||||
import { Page } from '../page';
|
||||
import type * as actions from './recorderActions';
|
||||
import { performAction } from './recorderRunner';
|
||||
|
|
@ -35,6 +34,10 @@ import { generateCode } from '../codegen/language';
|
|||
|
||||
type BindingSource = { frame: Frame, page: Page };
|
||||
|
||||
export interface ContextRecorderDelegate {
|
||||
rewriteActionInContext?(pageAliases: Map<Page, string>, actionInContext: ActionInContext): Promise<void>;
|
||||
}
|
||||
|
||||
export class ContextRecorder extends EventEmitter {
|
||||
static Events = {
|
||||
Change: 'change'
|
||||
|
|
@ -48,15 +51,17 @@ export class ContextRecorder extends EventEmitter {
|
|||
private _timers = new Set<NodeJS.Timeout>();
|
||||
private _context: BrowserContext;
|
||||
private _params: channels.BrowserContextRecorderSupplementEnableParams;
|
||||
private _delegate: ContextRecorderDelegate;
|
||||
private _recorderSources: Source[];
|
||||
private _throttledOutputFile: ThrottledFile | null = null;
|
||||
private _orderedLanguages: LanguageGenerator[] = [];
|
||||
private _listeners: RegisteredListener[] = [];
|
||||
|
||||
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
|
||||
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams, delegate: ContextRecorderDelegate) {
|
||||
super();
|
||||
this._context = context;
|
||||
this._params = params;
|
||||
this._delegate = delegate;
|
||||
this._recorderSources = [];
|
||||
const language = params.language || context.attribution.playwright.options.sdkLanguage;
|
||||
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
|
||||
// performed by the Playwright.
|
||||
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.
|
||||
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);
|
||||
}
|
||||
|
|
@ -218,7 +223,7 @@ export class ContextRecorder extends EventEmitter {
|
|||
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.
|
||||
this._collection.commitLastAction();
|
||||
|
||||
|
|
@ -226,9 +231,11 @@ export class ContextRecorder extends EventEmitter {
|
|||
const actionInContext: ActionInContext = {
|
||||
frame: frameDescription,
|
||||
action,
|
||||
description: undefined, // TODO: generate description based on simple dom node.
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext);
|
||||
|
||||
this._collection.willPerformAction(actionInContext);
|
||||
const success = await performAction(this._pageAliases, actionInContext);
|
||||
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.
|
||||
this._collection.commitLastAction();
|
||||
|
||||
|
|
@ -247,8 +254,11 @@ export class ContextRecorder extends EventEmitter {
|
|||
const actionInContext: ActionInContext = {
|
||||
frame: frameDescription,
|
||||
action,
|
||||
description: undefined, // TODO: generate description based on simple dom node.
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
await this._delegate.rewriteActionInContext?.(this._pageAliases, actionInContext);
|
||||
|
||||
this._setCommittedAfterTimeout(actionInContext);
|
||||
this._collection.addAction(actionInContext);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@
|
|||
|
||||
import type { CallMetadata } from '../instrumentation';
|
||||
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 {
|
||||
let title = metadata.apiName || metadata.method;
|
||||
|
|
@ -48,3 +52,23 @@ export function metadataToCallLog(metadata: CallMetadata, status: CallLogStatus)
|
|||
export function buildFullSelector(framePath: string[], selector: string) {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ export type UIState = {
|
|||
language: Language;
|
||||
testIdAttributeName: string;
|
||||
overlay: OverlayState;
|
||||
generateSimpleDom: boolean;
|
||||
};
|
||||
|
||||
export type CallLogStatus = 'in-progress' | 'done' | 'error' | 'paused';
|
||||
|
|
|
|||
|
|
@ -254,7 +254,6 @@ export const InspectModeController: React.FunctionComponent<{
|
|||
language: sdkLanguage,
|
||||
testIdAttributeName,
|
||||
overlay: { offsetX: 0 },
|
||||
generateSimpleDom: false,
|
||||
}, {
|
||||
async setSelector(selector: string) {
|
||||
setHighlightedLocator(asLocator(sdkLanguage, frameSelector + selector));
|
||||
|
|
|
|||
Loading…
Reference in a new issue