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 { 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']);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue