chrome-devtools-frontend
Version:
Chrome DevTools UI
790 lines (701 loc) • 28.8 kB
text/typescript
// Copyright 2023 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Common from '../../../core/common/common.js';
import * as Platform from '../../../core/platform/platform.js';
import * as SDK from '../../../core/sdk/sdk.js';
import * as UI from '../../../ui/legacy/legacy.js';
import * as Util from '../util/util.js';
// eslint-disable-next-line rulesdir/es_modules_import
import type * as ProtocolProxyApi from '../../../generated/protocol-proxy-api.js';
// eslint-disable-next-line rulesdir/es_modules_import
import type * as Protocol from '../../../generated/protocol.js';
import type * as Injected from '../injected/injected.js';
import {
AssertedEventType,
StepType,
type Key,
type ChangeStep,
type ClickStep,
type DoubleClickStep,
type FrameSelector,
type KeyDownStep,
type KeyUpStep,
type NavigationEvent,
type SelectorType,
type Step,
type Target,
type UserFlow,
} from './Schema.js';
import {areSelectorsEqual, createEmulateNetworkConditionsStep, createViewportStep} from './SchemaUtils.js';
import {evaluateInAllFrames, getTargetFrameContext} from './SDKUtils.js';
const formatAsJSLiteral = Platform.StringUtilities.formatAsJSLiteral;
type TargetInfoChangedEvent = {
type: 'targetInfoChanged',
event: Common.EventTarget.EventTargetEvent<Protocol.Target.TargetInfo>,
target: SDK.Target.Target,
};
type TargerCreatedRecorderEvent = {
type: 'targetCreated',
event: Common.EventTarget.EventTargetEvent<Protocol.Target.TargetInfo>,
target: SDK.Target.Target,
};
type TargetClosedRecorderEvent = {
type: 'targetClosed',
event: Common.EventTarget.EventTargetEvent<Protocol.Target.TargetID>,
target: SDK.Target.Target,
};
type BindingCalledRecorderEvent = {
type: 'bindingCalled',
event: Common.EventTarget.EventTargetEvent<Protocol.Runtime.BindingCalledEvent>,
target: SDK.Target.Target,
frameId: Protocol.Page.FrameId,
};
type RecorderEvent =
|TargetInfoChangedEvent|TargerCreatedRecorderEvent|TargetClosedRecorderEvent|BindingCalledRecorderEvent;
const unrelatedNavigationTypes = new Set([
'typed',
'address_bar',
'auto_bookmark',
'auto_subframe',
'generated',
'auto_toplevel',
'reload',
'keyword',
'keyword_generated',
]);
interface Shortcut {
meta: boolean;
ctrl: boolean;
shift: boolean;
alt: boolean;
keyCode: number;
}
const createShortcuts = (descriptors: number[][]): Shortcut[] => {
const shortcuts: Shortcut[] = [];
for (const shortcut of descriptors) {
for (const key of shortcut) {
const shortcutBase = {meta: false, ctrl: false, shift: false, alt: false, keyCode: -1};
const {keyCode, modifiers} = UI.KeyboardShortcut.KeyboardShortcut.keyCodeAndModifiersFromKey(key);
shortcutBase.keyCode = keyCode;
const modifiersMap = UI.KeyboardShortcut.Modifiers;
shortcutBase.ctrl = Boolean(modifiers & modifiersMap.Ctrl);
shortcutBase.meta = Boolean(modifiers & modifiersMap.Meta);
shortcutBase.shift = Boolean(modifiers & modifiersMap.Shift);
shortcutBase.shift = Boolean(modifiers & modifiersMap.Alt);
if (shortcutBase.keyCode !== -1) {
shortcuts.push(shortcutBase);
}
}
}
return shortcuts;
};
const evaluateInAllTargets =
async(worldName: string, targets: SDK.Target.Target[], expression: string): Promise<void> => {
await Promise.all(targets.map(target => evaluateInAllFrames(worldName, target, expression)));
};
const RecorderBinding = Object.freeze({
addStep: 'addStep',
stopShortcut: 'stopShortcut',
});
export class RecordingSession extends Common.ObjectWrapper.ObjectWrapper<EventTypes> {
readonly #target: SDK.Target.Target;
readonly #pageAgent: ProtocolProxyApi.PageApi;
readonly #targetAgent: ProtocolProxyApi.TargetApi;
readonly #networkManager: SDK.NetworkManager.MultitargetNetworkManager;
readonly #resourceTreeModel: SDK.ResourceTreeModel.ResourceTreeModel;
readonly #targets = new Map<string, SDK.Target.Target>();
readonly #lastNavigationEntryIdByTarget = new Map<string, number>();
readonly #lastNavigationHistoryByTarget = new Map<string, Array<number>>();
readonly #scriptIdentifiers = new Map<string, Protocol.Page.ScriptIdentifier>();
readonly #runtimeEventDescriptors = new Map<
SDK.Target.Target, Common.EventTarget.EventDescriptor<SDK.RuntimeModel.EventTypes, SDK.RuntimeModel.Events>[]>();
readonly #childTargetEventDescriptors = new Map<
SDK.Target.Target,
Common.EventTarget.EventDescriptor<SDK.ChildTargetManager.EventTypes, SDK.ChildTargetManager.Events>[]>();
readonly #mutex = new Common.Mutex.Mutex();
#userFlow: UserFlow;
#stepsPendingNavigationByTargetId: Map<string, Step> = new Map();
#started = false;
#selectorTypesToRecord: SelectorType[] = [];
constructor(target: SDK.Target.Target, opts: {
title: string,
selectorTypesToRecord: SelectorType[],
selectorAttribute?: string,
}) {
super();
this.#target = target;
this.#pageAgent = target.pageAgent();
this.#targetAgent = target.targetAgent();
this.#networkManager = SDK.NetworkManager.MultitargetNetworkManager.instance();
const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
if (!resourceTreeModel) {
throw new Error('ResourceTreeModel is missing for the target: ' + target.id());
}
this.#resourceTreeModel = resourceTreeModel;
this.#target = target;
this.#userFlow = {title: opts.title, selectorAttribute: opts.selectorAttribute, steps: []};
this.#selectorTypesToRecord = opts.selectorTypesToRecord;
}
/**
* @returns - A deep copy of the session's current user flow.
*/
cloneUserFlow(): UserFlow {
return structuredClone(this.#userFlow);
}
/**
* Overwrites the session's current user flow with the given one.
*
* This method will not dispatch an `recordingupdated` event.
*/
overwriteUserFlow(flow: Readonly<UserFlow>): void {
this.#userFlow = structuredClone(flow);
}
async start(): Promise<void> {
if (this.#started) {
throw new Error('The session has started');
}
this.#started = true;
await this.#pageAgent.invoke_setPrerenderingAllowed({
isAllowed: false,
});
this.#networkManager.addEventListener(
SDK.NetworkManager.MultitargetNetworkManager.Events.ConditionsChanged, this.#appendCurrentNetworkStep, this);
await this.#appendInitialSteps();
// Focus the target so that events can be captured without additional actions.
await this.#pageAgent.invoke_bringToFront();
await this.#setUpTarget(this.#target);
}
async stop(): Promise<void> {
await this.#pageAgent.invoke_setPrerenderingAllowed({
isAllowed: true,
});
// Wait for any remaining updates.
await this.#dispatchRecordingUpdate();
// Create a deadlock for the remaining events.
void this.#mutex.acquire();
await Promise.all([...this.#targets.values()].map(this.#tearDownTarget));
this.#networkManager.removeEventListener(
SDK.NetworkManager.MultitargetNetworkManager.Events.ConditionsChanged, this.#appendCurrentNetworkStep, this);
}
async #appendInitialSteps(): Promise<void> {
// Quick validation before doing anything.
const mainFrame = this.#resourceTreeModel.mainFrame;
if (!mainFrame) {
throw new Error('Could not find mainFrame.');
}
// Network step.
if (this.#networkManager.networkConditions() !== SDK.NetworkManager.NoThrottlingConditions) {
this.#appendCurrentNetworkStep();
}
// Viewport step.
const {cssLayoutViewport} = await this.#target.pageAgent().invoke_getLayoutMetrics();
this.#appendStep(createViewportStep(cssLayoutViewport));
// Navigation step.
const history = await this.#resourceTreeModel.navigationHistory();
if (history) {
const entry = history.entries[history.currentIndex];
this.#lastNavigationEntryIdByTarget.set(this.#target.id(), entry.id);
this.#lastNavigationHistoryByTarget.set(this.#target.id(), history.entries.map(entry => entry.id));
this.#userFlow.steps.push({
type: StepType.Navigate,
url: entry.url,
assertedEvents: [{type: AssertedEventType.Navigation, url: entry.url, title: entry.title}],
});
} else {
this.#userFlow.steps.push({
type: StepType.Navigate,
url: mainFrame.url,
assertedEvents: [
{type: AssertedEventType.Navigation, url: mainFrame.url, title: await this.#getDocumentTitle(this.#target)},
],
});
}
// Commit
void this.#dispatchRecordingUpdate();
}
async #getDocumentTitle(target: SDK.Target.Target): Promise<string> {
const response = await target.runtimeAgent().invoke_evaluate({expression: 'document.title'});
return response.result?.value || '';
}
#appendCurrentNetworkStep(): void {
const networkConditions = this.#networkManager.networkConditions();
this.#appendStep(createEmulateNetworkConditionsStep(networkConditions));
}
#updateTimeout?: number;
#updateListeners: Array<() => void> = [];
#dispatchRecordingUpdate(): Promise<void> {
if (this.#updateTimeout) {
clearTimeout(this.#updateTimeout);
}
this.#updateTimeout = setTimeout(() => {
// Making a copy to prevent mutations of this.userFlow by event consumers.
this.dispatchEventToListeners(Events.RecordingUpdated, structuredClone(this.#userFlow));
this.#updateTimeout = undefined;
for (const resolve of this.#updateListeners) {
resolve();
}
this.#updateListeners.length = 0;
}, 100) as unknown as number;
return new Promise<void>(resolve => {
this.#updateListeners.push(resolve);
});
}
get #previousStep(): Step|undefined {
return this.#userFlow.steps.slice(-1)[0];
}
/**
* Contains keys that are pressed related to a change step.
*/
#pressedChangeKeys = new Set<Key>();
/**
* Shift-reduces a given step into the user flow.
*/
#appendStep(step: Step): void {
switch (step.type) {
case 'doubleClick': {
for (let j = this.#userFlow.steps.length - 1; j > 0; j--) {
const previousStep = this.#userFlow.steps[j];
if (previousStep.type === 'click') {
step.selectors = previousStep.selectors;
this.#userFlow.steps.splice(j, 1);
break;
}
}
break;
}
case 'change': {
const previousStep = this.#previousStep;
if (!previousStep) {
break;
}
switch (previousStep.type) {
// Merging changes.
case 'change':
if (!areSelectorsEqual(step, previousStep)) {
break;
}
this.#userFlow.steps[this.#userFlow.steps.length - 1] = step;
void this.#dispatchRecordingUpdate();
return;
// Ignore key downs resulting in inputs.
case 'keyDown':
this.#pressedChangeKeys.add(previousStep.key);
this.#userFlow.steps.pop();
this.#appendStep(step);
return;
}
break;
}
case 'keyDown': {
// This can happen if there are successive keydown's from a repeated key
// for example.
if (this.#pressedChangeKeys.has(step.key)) {
return;
}
break;
}
case 'keyUp': {
// Ignore key ups coming from change inputs.
if (this.#pressedChangeKeys.has(step.key)) {
this.#pressedChangeKeys.delete(step.key);
return;
}
break;
}
}
this.#userFlow.steps.push(step);
void this.#dispatchRecordingUpdate();
}
#handleBeforeUnload(context: {frame: FrameSelector, target: Target}, sdkTarget: SDK.Target.Target): void {
const lastStep = this.#userFlow.steps[this.#userFlow.steps.length - 1];
if (lastStep && !lastStep.assertedEvents?.find(event => event.type === AssertedEventType.Navigation)) {
const target = context.target || 'main';
const frameSelector = (context.frame || []).join(',');
const lastStepTarget = lastStep.target || 'main';
const lastStepFrameSelector = (('frame' in lastStep ? lastStep.frame : []) || []).join(',');
if (target === lastStepTarget && frameSelector === lastStepFrameSelector) {
lastStep.assertedEvents = [{type: AssertedEventType.Navigation}];
this.#stepsPendingNavigationByTargetId.set(sdkTarget.id(), lastStep);
void this.#dispatchRecordingUpdate();
}
}
}
#replaceUnloadWithNavigation(target: SDK.Target.Target, event: NavigationEvent): void {
const stepPendingNavigation = this.#stepsPendingNavigationByTargetId.get(target.id());
if (!stepPendingNavigation) {
return;
}
const step = stepPendingNavigation;
if (!step.assertedEvents) {
return;
}
const navigationEvent = step.assertedEvents.find(event => event.type === AssertedEventType.Navigation);
if (!navigationEvent || navigationEvent.url) {
return;
}
navigationEvent.url = event.url;
navigationEvent.title = event.title;
void this.#dispatchRecordingUpdate();
}
#handleStopShortcutBinding(event: Common.EventTarget.EventTargetEvent<Protocol.Runtime.BindingCalledEvent>): void {
const shortcutLength = Number(event.data.payload);
// Look for one less step as the last one gets consumed before creating a step
for (let index = 0; index < shortcutLength - 1; index++) {
this.#userFlow.steps.pop();
}
this.dispatchEventToListeners(Events.RecordingStopped, structuredClone(this.#userFlow));
}
#receiveBindingCalled(
target: SDK.Target.Target,
event: Common.EventTarget.EventTargetEvent<Protocol.Runtime.BindingCalledEvent>): void {
switch (event.data.name) {
case RecorderBinding.stopShortcut:
this.#handleStopShortcutBinding(event);
return;
case RecorderBinding.addStep:
this.#handleAddStepBinding(target, event);
return;
default:
return;
}
}
#handleAddStepBinding(
target: SDK.Target.Target,
event: Common.EventTarget.EventTargetEvent<Protocol.Runtime.BindingCalledEvent>): void {
const executionContextId = event.data.executionContextId;
let frameId: Protocol.Page.FrameId|undefined;
const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
if (runtimeModel) {
for (const context of runtimeModel.executionContexts()) {
if (context.id === executionContextId) {
frameId = context.frameId;
break;
}
}
}
if (!frameId) {
throw new Error('No execution context found for the binding call + ' + JSON.stringify(event.data));
}
const step = JSON.parse(event.data.payload) as Injected.Step.Step;
const resourceTreeModel =
target.model(SDK.ResourceTreeModel.ResourceTreeModel) as SDK.ResourceTreeModel.ResourceTreeModel;
const frame = resourceTreeModel.frameForId(frameId);
if (!frame) {
throw new Error('Could not find frame.');
}
const context = getTargetFrameContext(target, frame);
if (step.type === 'beforeUnload') {
this.#handleBeforeUnload(context, target);
return;
}
// TODO: type-safe parsing from client steps to internal step format.
switch (step.type) {
case 'change': {
this.#appendStep({
type: 'change',
value: step.value,
selectors: step.selectors,
frame: context.frame.length ? context.frame : undefined,
target: context.target,
} as ChangeStep);
break;
}
case 'doubleClick': {
this.#appendStep({
type: 'doubleClick',
target: context.target,
selectors: step.selectors,
offsetY: step.offsetY,
offsetX: step.offsetX,
frame: context.frame.length ? context.frame : undefined,
deviceType: step.deviceType,
button: step.button,
} as DoubleClickStep);
break;
}
case 'click': {
this.#appendStep({
type: 'click',
target: context.target,
selectors: step.selectors,
offsetY: step.offsetY,
offsetX: step.offsetX,
frame: context.frame.length ? context.frame : undefined,
duration: step.duration,
deviceType: step.deviceType,
button: step.button,
} as ClickStep);
break;
}
case 'keyUp': {
this.#appendStep({
type: 'keyUp',
key: step.key,
frame: context.frame.length ? context.frame : undefined,
target: context.target,
} as KeyUpStep);
break;
}
case 'keyDown': {
this.#appendStep({
type: 'keyDown',
frame: context.frame.length ? context.frame : undefined,
target: context.target,
key: step.key,
} as KeyDownStep);
break;
}
default:
throw new Error('Unhandled client event');
}
}
#getStopShortcuts(): Shortcut[] {
const descriptors = UI.ShortcutRegistry.ShortcutRegistry.instance()
.shortcutsForAction('chrome_recorder.start-recording')
.map(key => key.descriptors.map(press => press.key));
return createShortcuts(descriptors);
}
static get #allowUntrustedEvents(): boolean {
try {
// This setting is set during the test to work around the fact that Puppeteer cannot
// send trusted change and input events.
Common.Settings.Settings.instance().settingForTest('untrustedRecorderEvents');
return true;
} catch {
}
return false;
}
#setUpTarget = async(target: SDK.Target.Target): Promise<void> => {
if (target.type() !== SDK.Target.Type.Frame) {
return;
}
this.#targets.set(target.id(), target);
const a11yModel = target.model(SDK.AccessibilityModel.AccessibilityModel);
Platform.assertNotNullOrUndefined(a11yModel);
await a11yModel.resumeModel();
await this.#addBindings(target);
await this.#injectApplicationScript(target);
const childTargetManager = target.model(SDK.ChildTargetManager.ChildTargetManager);
Platform.assertNotNullOrUndefined(childTargetManager);
this.#childTargetEventDescriptors.set(target, [
childTargetManager.addEventListener(
SDK.ChildTargetManager.Events.TargetCreated, this.#receiveTargetCreated.bind(this, target)),
childTargetManager.addEventListener(
SDK.ChildTargetManager.Events.TargetDestroyed, this.#receiveTargetClosed.bind(this, target)),
childTargetManager.addEventListener(
SDK.ChildTargetManager.Events.TargetInfoChanged, this.#receiveTargetInfoChanged.bind(this, target)),
]);
await Promise.all(childTargetManager.childTargets().map(this.#setUpTarget));
};
#tearDownTarget = async(target: SDK.Target.Target): Promise<void> => {
const descriptors = this.#childTargetEventDescriptors.get(target);
if (descriptors) {
Common.EventTarget.removeEventListeners(descriptors);
}
await this.#injectCleanUpScript(target);
await this.#removeBindings(target);
};
async #addBindings(target: SDK.Target.Target): Promise<void> {
const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel);
Platform.assertNotNullOrUndefined(runtimeModel);
this.#runtimeEventDescriptors.set(
target, [runtimeModel.addEventListener(
SDK.RuntimeModel.Events.BindingCalled, this.#receiveBindingCalled.bind(this, target))]);
await Promise.all(
Object.values(RecorderBinding)
.map(name => runtimeModel.addBinding({name, executionContextName: Util.DEVTOOLS_RECORDER_WORLD_NAME})));
}
async #removeBindings(target: SDK.Target.Target): Promise<void> {
await Promise.all(Object.values(RecorderBinding).map(name => target.runtimeAgent().invoke_removeBinding({name})));
const descriptors = this.#runtimeEventDescriptors.get(target);
if (descriptors) {
Common.EventTarget.removeEventListeners(descriptors);
}
}
async #injectApplicationScript(target: SDK.Target.Target): Promise<void> {
const injectedScript = await Util.InjectedScript.get();
const script = `
${injectedScript};DevToolsRecorder.startRecording({getAccessibleName, getAccessibleRole}, {
debug: ${Util.isDebugBuild},
allowUntrustedEvents: ${RecordingSession.#allowUntrustedEvents},
selectorTypesToRecord: ${JSON.stringify(this.#selectorTypesToRecord)},
selectorAttribute: ${
this.#userFlow.selectorAttribute ? formatAsJSLiteral(this.#userFlow.selectorAttribute) : undefined},
stopShortcuts: ${JSON.stringify(this.#getStopShortcuts())},
});
`;
const [{identifier}] = await Promise.all([
target.pageAgent().invoke_addScriptToEvaluateOnNewDocument(
{source: script, worldName: Util.DEVTOOLS_RECORDER_WORLD_NAME, includeCommandLineAPI: true}),
evaluateInAllFrames(Util.DEVTOOLS_RECORDER_WORLD_NAME, target, script),
]);
this.#scriptIdentifiers.set(target.id(), identifier);
}
async #injectCleanUpScript(target: SDK.Target.Target): Promise<void> {
const scriptId = this.#scriptIdentifiers.get(target.id());
if (!scriptId) {
return;
}
await target.pageAgent().invoke_removeScriptToEvaluateOnNewDocument({identifier: scriptId});
await evaluateInAllTargets(
Util.DEVTOOLS_RECORDER_WORLD_NAME, [...this.#targets.values()], 'DevToolsRecorder.stopRecording()');
}
#receiveTargetCreated(
target: SDK.Target.Target, event: Common.EventTarget.EventTargetEvent<Protocol.Target.TargetInfo>): void {
void this.#handleEvent({type: 'targetCreated', event, target});
}
#receiveTargetClosed(
eventTarget: SDK.Target.Target, event: Common.EventTarget.EventTargetEvent<Protocol.Target.TargetID>): void {
// TODO(alexrudenko): target here appears to be the parent target of the target that is closed.
// Therefore, we need to find the real target via the targets map.
const childTarget = this.#targets.get(event.data);
if (childTarget) {
void this.#handleEvent({type: 'targetClosed', event, target: childTarget});
}
}
#receiveTargetInfoChanged(
eventTarget: SDK.Target.Target, event: Common.EventTarget.EventTargetEvent<Protocol.Target.TargetInfo>): void {
const target = this.#targets.get(event.data.targetId) || eventTarget;
void this.#handleEvent({type: 'targetInfoChanged', event, target});
}
#handleEvent(event: RecorderEvent): Promise<void> {
return this.#mutex.run(async () => {
try {
if (Util.isDebugBuild) {
console.time(`Processing ${JSON.stringify(event)}`);
}
switch (event.type) {
case 'targetClosed':
await this.#handleTargetClosed(event);
break;
case 'targetCreated':
await this.#handleTargetCreated(event);
break;
case 'targetInfoChanged':
await this.#handleTargetInfoChanged(event);
break;
}
if (Util.isDebugBuild) {
console.timeEnd(`Processing ${JSON.stringify(event)}`);
}
} catch (err) {
console.error('Error happened while processing recording events: ', err.message, err.stack);
}
});
}
async #handleTargetCreated(event: TargerCreatedRecorderEvent): Promise<void> {
if (event.event.data.type !== 'page' && event.event.data.type !== 'iframe') {
return;
}
await this.#targetAgent.invoke_attachToTarget({targetId: event.event.data.targetId, flatten: true});
const target = SDK.TargetManager.TargetManager.instance().targets().find(t => t.id() === event.event.data.targetId);
if (!target) {
throw new Error('Could not find target.');
}
// Generally an new target implies all other targets are not waiting for something special in their event buffers, so we flush them here.
await this.#setUpTarget(target);
// Emitted for e2e tests.
window.dispatchEvent(new Event('recorderAttachedToTarget'));
}
async #handleTargetClosed(event: TargetClosedRecorderEvent): Promise<void> {
const stepPendingNavigation = this.#stepsPendingNavigationByTargetId.get(event.target.id());
if (stepPendingNavigation) {
delete stepPendingNavigation.assertedEvents;
this.#stepsPendingNavigationByTargetId.delete(event.target.id());
}
// TODO(alexrudenko): figure out how this works with sections
// TODO(alexrudenko): Ignore close events as they only matter for popups but cause more trouble than benefits
// const closeStep: CloseStep = {
// type: 'close',
// target: getTargetName(event.target),
// };
// this.appendStep(closeStep);
}
async #handlePageNavigation(resourceTreeModel: SDK.ResourceTreeModel.ResourceTreeModel, target: SDK.Target.Target):
Promise<boolean> {
const history = await resourceTreeModel.navigationHistory();
if (!history) {
return false;
}
const entry = history.entries[history.currentIndex];
const prevId = this.#lastNavigationEntryIdByTarget.get(target.id());
if (prevId === entry.id) {
return true;
}
this.#lastNavigationEntryIdByTarget.set(target.id(), entry.id);
const lastHistory = this.#lastNavigationHistoryByTarget.get(target.id()) || [];
this.#lastNavigationHistoryByTarget.set(target.id(), history.entries.map(entry => entry.id));
if (unrelatedNavigationTypes.has(entry.transitionType) || lastHistory.includes(entry.id)) {
const stepPendingNavigation = this.#stepsPendingNavigationByTargetId.get(target.id());
if (stepPendingNavigation) {
delete stepPendingNavigation.assertedEvents;
this.#stepsPendingNavigationByTargetId.delete(target.id());
}
this.#appendStep({
type: StepType.Navigate,
url: entry.url,
assertedEvents: [{type: AssertedEventType.Navigation, url: entry.url, title: entry.title}],
});
} else {
this.#replaceUnloadWithNavigation(
target, {type: AssertedEventType.Navigation, url: entry.url, title: entry.title});
}
return true;
}
async #handleTargetInfoChanged(event: TargetInfoChangedEvent): Promise<void> {
if (event.event.data.type !== 'page' && event.event.data.type !== 'iframe') {
return;
}
const target = event.target;
const resourceTreeModel = target.model(SDK.ResourceTreeModel.ResourceTreeModel);
if (!resourceTreeModel) {
throw new Error('ResourceTreeModel is missing in handleNavigation');
}
if (event.event.data.type === 'iframe') {
this.#replaceUnloadWithNavigation(
target,
{type: AssertedEventType.Navigation, url: event.event.data.url, title: await this.#getDocumentTitle(target)});
} else if (event.event.data.type === 'page') {
if (await this.#handlePageNavigation(resourceTreeModel, target)) {
return;
}
// Needed for #getDocumentTitle to return something meaningful.
await this.#waitForDOMContentLoadedWithTimeout(resourceTreeModel, 500);
this.#replaceUnloadWithNavigation(
target,
{type: AssertedEventType.Navigation, url: event.event.data.url, title: await this.#getDocumentTitle(target)});
}
}
async #waitForDOMContentLoadedWithTimeout(
resourceTreeModel: SDK.ResourceTreeModel.ResourceTreeModel, timeout: number): Promise<void> {
let resolver: (value: void|Promise<void>) => void = () => Promise.resolve();
const contentLoadedPromise = new Promise<void>(resolve => {
resolver = resolve;
});
const onDomContentLoaded = (): void => {
resourceTreeModel.removeEventListener(SDK.ResourceTreeModel.Events.DOMContentLoaded, onDomContentLoaded);
resolver();
};
resourceTreeModel.addEventListener(SDK.ResourceTreeModel.Events.DOMContentLoaded, onDomContentLoaded);
await Promise.any([
contentLoadedPromise,
new Promise<void>(
resolve => setTimeout(
() => {
resourceTreeModel.removeEventListener(
SDK.ResourceTreeModel.Events.DOMContentLoaded, onDomContentLoaded);
resolve();
},
timeout)),
]);
}
}
export const enum Events {
RecordingUpdated = 'recordingupdated',
RecordingStopped = 'recordingstopped',
}
type EventTypes = {
[Events.RecordingUpdated]: UserFlow,
[Events.RecordingStopped]: UserFlow,
};