chrome-devtools-frontend
Version:
Chrome DevTools UI
776 lines (692 loc) • 26.1 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 {assertNotNullOrUndefined} from '../../core/platform/platform.js';
import type {Loggable} from './Loggable.js';
import {type LoggingConfig, VisualElements} from './LoggingConfig.js';
import {getLoggingState, type LoggingState} from './LoggingState.js';
let veDebuggingEnabled = false;
let debugPopover: HTMLElement|null = null;
let highlightedElement: HTMLElement|null = null;
const nonDomDebugElements = new WeakMap<Loggable, HTMLElement>();
let onInspect: ((query: string) => void)|undefined = undefined;
export function setVeDebuggingEnabled(enabled: boolean, inspect?: (query: string) => void): void {
veDebuggingEnabled = enabled;
if (enabled && !debugPopover) {
debugPopover = document.createElement('div');
debugPopover.classList.add('ve-debug');
debugPopover.style.position = 'absolute';
debugPopover.style.background = 'var(--sys-color-cdt-base-container)';
debugPopover.style.borderRadius = '2px';
debugPopover.style.padding = '8px';
debugPopover.style.boxShadow = 'var(--drop-shadow)';
debugPopover.style.zIndex = '100000';
document.body.appendChild(debugPopover);
}
onInspect = inspect;
if (!enabled && highlightedElement) {
highlightedElement.style.backgroundColor = '';
highlightedElement.style.outline = '';
}
}
// @ts-expect-error
globalThis.setVeDebuggingEnabled = setVeDebuggingEnabled;
export function processForDebugging(loggable: Loggable): void {
const loggingState = getLoggingState(loggable);
if (!veDebuggingEnabled || !loggingState || loggingState.processedForDebugging) {
return;
}
if (loggable instanceof HTMLElement) {
processElementForDebugging(loggable, loggingState);
} else {
processNonDomLoggableForDebugging(loggable, loggingState);
}
}
function showDebugPopover(content: string, rect?: DOMRect): void {
if (!debugPopover) {
return;
}
// Set these first so we get the correct information from
// getBoundingClientRect
debugPopover.style.display = 'block';
debugPopover.textContent = content;
if (rect) {
const debugPopoverReact = debugPopover.getBoundingClientRect();
// If there is no space under the element
// render render it above the element
if (window.innerHeight < rect.bottom + debugPopoverReact.height + 8) {
debugPopover.style.top = `${rect.top - debugPopoverReact.height - 8}px`;
} else {
debugPopover.style.top = `${rect.bottom + 8}px`;
}
// If the element will go outside the viewport on the right
// render it with it's and at the viewport end.
if (window.innerWidth < rect.left + debugPopoverReact.width) {
debugPopover.style.right = '0px';
debugPopover.style.left = '';
} else {
debugPopover.style.right = '';
debugPopover.style.left = `${rect.left}px`;
}
}
}
function processElementForDebugging(element: HTMLElement, loggingState: LoggingState): void {
if (element.tagName === 'OPTION') {
if (loggingState.parent?.selectOpen && debugPopover) {
debugPopover.innerHTML += '<br>' + debugString(loggingState.config);
loggingState.processedForDebugging = true;
}
} else {
element.addEventListener('mousedown', event => {
if (event.currentTarget === highlightedElement && onInspect && debugPopover && veDebuggingEnabled) {
onInspect(debugPopover.textContent || '');
event.stopImmediatePropagation();
event.preventDefault();
}
}, {capture: true});
element.addEventListener('mouseenter', () => {
if (!veDebuggingEnabled) {
return;
}
if (highlightedElement) {
highlightedElement.style.backgroundColor = '';
highlightedElement.style.outline = '';
}
element.style.backgroundColor = '#A7C3E4';
element.style.outline = 'dashed 1px #7327C6';
highlightedElement = element;
assertNotNullOrUndefined(debugPopover);
const pathToRoot = [loggingState];
let ancestor = loggingState.parent;
while (ancestor) {
pathToRoot.unshift(ancestor);
ancestor = ancestor.parent;
}
showDebugPopover(pathToRoot.map(s => elementKey(s.config)).join(' > '), element.getBoundingClientRect());
}, {capture: true});
element.addEventListener('mouseleave', () => {
element.style.backgroundColor = '';
element.style.outline = '';
assertNotNullOrUndefined(debugPopover);
debugPopover.style.display = 'none';
}, {capture: true});
loggingState.processedForDebugging = true;
}
}
type EventType = 'Click'|'Drag'|'Hover'|'Change'|'KeyDown'|'Resize'|'SettingAccess'|'FunctionCall';
export function processEventForDebugging(
event: EventType, state: LoggingState|null, extraInfo?: EventAttributes): void {
const format = localStorage.getItem('veDebugLoggingEnabled');
if (!format) {
return;
}
switch (format) {
case DebugLoggingFormat.INTUITIVE:
processEventForIntuitiveDebugging(event, state, extraInfo);
break;
case DebugLoggingFormat.TEST:
processEventForTestDebugging(event, state, extraInfo);
break;
case DebugLoggingFormat.AD_HOC_ANALYSIS:
processEventForAdHocAnalysisDebugging(event, state, extraInfo);
break;
}
}
export function processEventForIntuitiveDebugging(
event: EventType, state: LoggingState|null, extraInfo?: EventAttributes): void {
const entry: IntuitiveLogEntry = {
event,
ve: state ? VisualElements[state?.config.ve] : undefined,
veid: state?.veid,
context: state?.config.context,
time: Date.now() - sessionStartTime,
...extraInfo,
};
deleteUndefinedFields(entry);
maybeLogDebugEvent(entry);
}
export function processEventForTestDebugging(
event: EventType, state: LoggingState|null, _extraInfo?: EventAttributes): void {
if (event !== 'SettingAccess' && event !== 'FunctionCall') {
lastImpressionLogEntry = null;
}
maybeLogDebugEvent(
{interaction: `${event}: ${veTestKeys.get(state?.veid || 0) || (state?.veid ? '<UNKNOWN>' : '')}`});
checkPendingEventExpectation();
}
export function processEventForAdHocAnalysisDebugging(
event: EventType, state: LoggingState|null, extraInfo?: EventAttributes): void {
const ve = state ? adHocAnalysisEntries.get(state.veid) : null;
if (ve) {
const interaction: AdHocAnalysisInteraction = {time: Date.now() - sessionStartTime, type: event, ...extraInfo};
deleteUndefinedFields(interaction);
ve.interactions.push(interaction);
}
}
function deleteUndefinedFields<T>(entry: T): void {
for (const stringKey in entry) {
const key = stringKey as keyof T;
if (typeof entry[key] === 'undefined') {
delete entry[key];
}
}
}
export interface EventAttributes {
context?: string;
width?: number;
height?: number;
mouseButton?: number;
doubleClick?: boolean;
name?: string;
numericValue?: number;
stringValue?: string;
}
interface VisualElementAttributes {
ve: string;
veid: number;
context?: string;
width?: number;
height?: number;
}
type IntuitiveLogEntry = {
event?: EventType|'Impression'|'SessionStart',
children?: IntuitiveLogEntry[],
parent?: number,
time?: number,
}&Partial<VisualElementAttributes>;
type AdHocAnalysisVisualElement = VisualElementAttributes&{
parent?: AdHocAnalysisVisualElement,
};
type AdHocAnalysisInteraction = {
type: EventType,
time: number,
}&EventAttributes;
type AdHocAnalysisLogEntry = AdHocAnalysisVisualElement&{
time: number,
interactions: AdHocAnalysisInteraction[],
};
type TestLogEntry = {
impressions: string[],
}|{
interaction: string,
};
export function processImpressionsForDebugging(states: LoggingState[]): void {
const format = localStorage.getItem('veDebugLoggingEnabled');
switch (format) {
case DebugLoggingFormat.INTUITIVE:
processImpressionsForIntuitiveDebugLog(states);
break;
case DebugLoggingFormat.TEST:
processImpressionsForTestDebugLog(states);
break;
case DebugLoggingFormat.AD_HOC_ANALYSIS:
processImpressionsForAdHocAnalysisDebugLog(states);
break;
default:
}
}
function processImpressionsForIntuitiveDebugLog(states: LoggingState[]): void {
const impressions = new Map<number, IntuitiveLogEntry>();
for (const state of states) {
const entry: IntuitiveLogEntry = {
event: 'Impression',
ve: VisualElements[state.config.ve],
context: state?.config.context,
width: state.size.width,
height: state.size.height,
veid: state.veid,
};
deleteUndefinedFields(entry);
impressions.set(state.veid, entry);
if (!state.parent || !impressions.has(state.parent?.veid)) {
entry.parent = state.parent?.veid;
} else {
const parent = impressions.get(state.parent?.veid) as IntuitiveLogEntry;
parent.children = parent.children || [];
parent.children.push(entry);
}
}
const entries = [...impressions.values()].filter(i => 'parent' in i);
if (entries.length === 1) {
entries[0].time = Date.now() - sessionStartTime;
maybeLogDebugEvent(entries[0]);
} else {
maybeLogDebugEvent({event: 'Impression', children: entries, time: Date.now() - sessionStartTime});
}
}
const veTestKeys = new Map<number, string>();
let lastImpressionLogEntry: {impressions: string[]}|null = null;
function processImpressionsForTestDebugLog(states: LoggingState[]): void {
if (!lastImpressionLogEntry) {
lastImpressionLogEntry = {impressions: []};
veDebugEventsLog.push(lastImpressionLogEntry);
}
for (const state of states) {
let key = '';
if (state.parent) {
key = (veTestKeys.get(state.parent.veid) || '<UNKNOWN>') + ' > ';
}
key += VisualElements[state.config.ve];
if (state.config.context) {
key += ': ' + state.config.context;
}
veTestKeys.set(state.veid, key);
lastImpressionLogEntry.impressions.push(key);
}
checkPendingEventExpectation();
}
const adHocAnalysisEntries = new Map<number, AdHocAnalysisLogEntry>();
function processImpressionsForAdHocAnalysisDebugLog(states: LoggingState[]): void {
for (const state of states) {
const buildVe = (state: LoggingState): AdHocAnalysisVisualElement => {
const ve: AdHocAnalysisVisualElement = {
ve: VisualElements[state.config.ve],
veid: state.veid,
width: state.size?.width,
height: state.size?.height,
context: state.config.context,
};
deleteUndefinedFields(ve);
if (state.parent) {
ve.parent = buildVe(state.parent);
}
return ve;
};
const entry = {...buildVe(state), interactions: [], time: Date.now() - sessionStartTime};
adHocAnalysisEntries.set(state.veid, entry);
maybeLogDebugEvent(entry);
}
}
function processNonDomLoggableForDebugging(loggable: Loggable, loggingState: LoggingState): void {
let debugElement = nonDomDebugElements.get(loggable);
if (!debugElement) {
debugElement = document.createElement('div');
debugElement.classList.add('ve-debug');
debugElement.style.background = 'black';
debugElement.style.color = 'white';
debugElement.style.zIndex = '100000';
debugElement.textContent = debugString(loggingState.config);
nonDomDebugElements.set(loggable, debugElement);
setTimeout(() => {
if (!loggingState.size?.width || !loggingState.size?.height) {
debugElement?.parentElement?.removeChild(debugElement);
nonDomDebugElements.delete(loggable);
}
}, 10000);
}
const parentDebugElement =
parent instanceof HTMLElement ? parent : nonDomDebugElements.get(parent as Loggable) || debugPopover;
assertNotNullOrUndefined(parentDebugElement);
if (!parentDebugElement.classList.contains('ve-debug')) {
debugElement.style.position = 'absolute';
parentDebugElement.insertBefore(debugElement, parentDebugElement.firstChild);
} else {
debugElement.style.marginLeft = '10px';
parentDebugElement.appendChild(debugElement);
}
}
function elementKey(config: LoggingConfig): string {
return `${VisualElements[config.ve]}${config.context ? `: ${config.context}` : ''}`;
}
export function debugString(config: LoggingConfig): string {
const components = [VisualElements[config.ve]];
if (config.context) {
components.push(`context: ${config.context}`);
}
if (config.parent) {
components.push(`parent: ${config.parent}`);
}
if (config.track) {
components.push(`track: ${
Object.entries(config.track)
.map(([key, value]) => `${key}${typeof value === 'string' ? `: ${value}` : ''}`)
.join(', ')}`);
}
return components.join('; ');
}
const veDebugEventsLog: Array<IntuitiveLogEntry|AdHocAnalysisLogEntry|TestLogEntry> = [];
function maybeLogDebugEvent(entry: IntuitiveLogEntry|AdHocAnalysisLogEntry|TestLogEntry): void {
const format = localStorage.getItem('veDebugLoggingEnabled');
if (!format) {
return;
}
veDebugEventsLog.push(entry);
if (format === DebugLoggingFormat.INTUITIVE) {
// eslint-disable-next-line no-console
console.info('VE Debug:', entry);
}
}
export const enum DebugLoggingFormat {
INTUITIVE = 'Intuitive',
TEST = 'Test',
AD_HOC_ANALYSIS = 'AdHocAnalysis',
}
export function setVeDebugLoggingEnabled(enabled: boolean, format = DebugLoggingFormat.INTUITIVE): void {
if (enabled) {
localStorage.setItem('veDebugLoggingEnabled', format);
} else {
localStorage.removeItem('veDebugLoggingEnabled');
}
}
function findVeDebugImpression(veid: number, includeAncestorChain?: boolean): IntuitiveLogEntry|undefined {
const findImpression = (entry: IntuitiveLogEntry): IntuitiveLogEntry|undefined => {
if (entry.event === 'Impression' && entry.veid === veid) {
return entry;
}
let i = 0;
for (const childEntry of entry.children || []) {
const matchingEntry = findImpression(childEntry);
if (matchingEntry) {
if (includeAncestorChain) {
const children = [];
children[i] = matchingEntry;
return {...entry, children};
}
return matchingEntry;
}
++i;
}
return undefined;
};
return findImpression({children: veDebugEventsLog as IntuitiveLogEntry[]});
}
function fieldValuesForSql<T>(
obj: T,
fields: {strings: ReadonlyArray<keyof T>, numerics: ReadonlyArray<keyof T>, booleans: ReadonlyArray<keyof T>}):
string {
return [
...fields.strings.map(f => obj[f] ? `"${obj[f]}"` : '$NullString'),
...fields.numerics.map(f => obj[f] ?? 'null'),
...fields.booleans.map(f => obj[f] ?? '$NullBool'),
].join(', ');
}
function exportAdHocAnalysisLogForSql(): void {
const VE_FIELDS = {
strings: ['ve', 'context'] as const,
numerics: ['veid', 'width', 'height'] as const,
booleans: [] as const,
};
const INTERACTION_FIELDS = {
strings: ['type', 'context'] as const,
numerics: ['width', 'height', 'mouseButton', 'time'] as const,
booleans: ['width', 'height', 'mouseButton', 'time'] as const,
};
const fieldsDefsForSql = (fields: string[]): string => fields.map((f, i) => `$${i + 1} as ${f}`).join(', ');
const veForSql = (e: AdHocAnalysisVisualElement): string =>
`$VeFields(${fieldValuesForSql(e, VE_FIELDS)}, ${e.parent ? `STRUCT(${veForSql(e.parent)})` : null})`;
const interactionForSql = (i: AdHocAnalysisInteraction): string =>
`$Interaction(${fieldValuesForSql(i, INTERACTION_FIELDS)})`;
const entryForSql = (e: AdHocAnalysisLogEntry): string =>
`$Entry(${veForSql(e)}, ([${e.interactions.map(interactionForSql).join(', ')}]), ${e.time})`;
const entries = veDebugEventsLog as AdHocAnalysisLogEntry[];
// eslint-disable-next-line no-console
console.log(`
DEFINE MACRO NullString CAST(null AS STRING);
DEFINE MACRO NullBool CAST(null AS BOOL);
DEFINE MACRO VeFields ${fieldsDefsForSql([
...VE_FIELDS.strings,
...VE_FIELDS.numerics,
'parent',
])};
DEFINE MACRO Interaction STRUCT(${
fieldsDefsForSql([
...INTERACTION_FIELDS.strings,
...INTERACTION_FIELDS.numerics,
...INTERACTION_FIELDS.booleans,
])});
DEFINE MACRO Entry STRUCT($1, $2 AS interactions, $3 AS time);
// This fake entry put first fixes nested struct fields names being lost
DEFINE MACRO FakeVeFields $VeFields("", $NullString, 0, 0, 0, $1);
DEFINE MACRO FakeVe STRUCT($FakeVeFields($1));
DEFINE MACRO FakeEntry $Entry($FakeVeFields($FakeVe($FakeVe($FakeVe($FakeVe($FakeVe($FakeVe($FakeVe(null)))))))), ([]), 0);
WITH
processed_logs AS (
SELECT * FROM UNNEST([
$FakeEntry,
${entries.map(entryForSql).join(', \n')}
])
)
SELECT * FROM processed_logs;`);
}
type StateFlowNode = {
type: 'Session',
children: StateFlowNode[],
}|({type: 'Impression', children: StateFlowNode[], time: number}&AdHocAnalysisVisualElement)|AdHocAnalysisInteraction;
type StateFlowMutation = (AdHocAnalysisLogEntry|(AdHocAnalysisInteraction&{veid: number}));
function getStateFlowMutations(): StateFlowMutation[] {
const mutations: StateFlowMutation[] = [];
for (const entry of (veDebugEventsLog as AdHocAnalysisLogEntry[])) {
mutations.push(entry);
const veid = entry.veid;
for (const interaction of entry.interactions) {
mutations.push({...interaction, veid});
}
}
mutations.sort((e1, e2) => e1.time - e2.time);
return mutations;
}
class StateFlowElementsByArea {
#data = new Map<number, AdHocAnalysisVisualElement>();
add(e: AdHocAnalysisVisualElement): void {
this.#data.set(e.veid, e);
}
get(veid: number): AdHocAnalysisVisualElement|undefined {
return this.#data.get(veid);
}
getArea(e: AdHocAnalysisVisualElement): number {
let area = (e.width || 0) * (e.height || 0);
const parent = e.parent ? this.#data.get(e.parent?.veid) : null;
if (!parent) {
return area;
}
const parentArea = this.getArea(parent);
if (area > parentArea) {
area = parentArea;
}
return area;
}
get data(): readonly AdHocAnalysisVisualElement[] {
return [...this.#data.values()].filter(e => this.getArea(e)).sort((e1, e2) => this.getArea(e2) - this.getArea(e1));
}
}
function updateStateFlowTree(
rootNode: StateFlowNode, elements: StateFlowElementsByArea, time: number,
interactions: AdHocAnalysisInteraction[]): void {
let node = rootNode;
for (const element of elements.data) {
if (!('children' in node)) {
return;
}
let nextNode = node.children[node.children.length - 1];
const nextNodeId = nextNode?.type === 'Impression' ? nextNode.veid : null;
if (nextNodeId !== element.veid) {
node.children.push(...interactions);
interactions.length = 0;
nextNode = {type: 'Impression', ve: element.ve, veid: element.veid, context: element.context, time, children: []};
node.children.push(nextNode);
}
node = nextNode;
}
}
function normalizeNode(node: StateFlowNode): void {
if (node.type !== 'Impression') {
return;
}
while (node.children.length === 1) {
if (node.children[0].type === 'Impression') {
node.children = node.children[0].children;
}
}
for (const child of node.children) {
normalizeNode(child);
}
}
function buildStateFlow(): StateFlowNode {
const mutations = getStateFlowMutations();
const elements = new StateFlowElementsByArea();
const rootNode: StateFlowNode = {type: 'Session', children: []};
let time = mutations[0].time;
const interactions: AdHocAnalysisInteraction[] = [];
for (const mutation of mutations) {
if (mutation.time > time + 1000) {
updateStateFlowTree(rootNode, elements, time, interactions);
interactions.length = 0;
}
if (!('type' in mutation)) {
elements.add(mutation);
} else if (mutation.type === 'Resize') {
const element = elements.get(mutation.veid);
if (!element) {
continue;
}
const oldArea = elements.getArea(element);
element.width = mutation.width;
element.height = mutation.height;
if (elements.getArea(element) !== 0 && oldArea !== 0) {
interactions.push(mutation);
}
} else {
interactions.push(mutation);
}
time = mutation.time;
}
updateStateFlowTree(rootNode, elements, time, interactions);
normalizeNode(rootNode);
return rootNode;
}
let sessionStartTime: number = Date.now();
export function processStartLoggingForDebugging(): void {
sessionStartTime = Date.now();
if (localStorage.getItem('veDebugLoggingEnabled') === DebugLoggingFormat.INTUITIVE) {
maybeLogDebugEvent({event: 'SessionStart'});
}
}
// Compares the 'actual' log entry against the 'expected'.
// For impressions events to match, all expected impressions need to be present
// in the actual event. Unexpected impressions in the actual event are ignored.
// Interaction events need to match exactly.
function compareVeEvents(actual: TestLogEntry, expected: TestLogEntry): boolean {
if ('interaction' in expected && 'interaction' in actual) {
return expected.interaction === actual.interaction;
}
if ('impressions' in expected && 'impressions' in actual) {
const actualSet = new Set(actual.impressions);
const expectedSet = new Set(expected.impressions);
const missing = [...expectedSet].filter(k => !actualSet.has(k));
return !Boolean(missing.length);
}
return false;
}
interface PendingEventExpectation {
expectedEvents: TestLogEntry[];
missingEvents?: TestLogEntry[];
unmatchingEvents: TestLogEntry[];
success: () => void;
fail: (arg0: Error) => void;
}
let pendingEventExpectation: PendingEventExpectation|null = null;
function formatImpressions(impressions: string[]): string {
const result: string[] = [];
let lastImpression = '';
for (const impression of impressions.sort()) {
if (impression === lastImpression) {
continue;
}
while (!impression.startsWith(lastImpression)) {
lastImpression = lastImpression.substr(0, lastImpression.lastIndexOf(' > '));
}
result.push(' '.repeat(lastImpression.length) + impression.substr(lastImpression.length));
lastImpression = impression;
}
return result.join('\n');
}
const EVENT_EXPECTATION_TIMEOUT = 5000;
function formatVeEvents(events: TestLogEntry[]): string {
return events.map(e => 'interaction' in e ? e.interaction : formatImpressions(e.impressions)).join('\n');
}
// Verifies that VE events contains all the expected events in given order.
// Unexpected VE events are ignored.
export async function expectVeEvents(expectedEvents: TestLogEntry[]): Promise<void> {
if (pendingEventExpectation) {
throw new Error('VE events expectation already set. Cannot set another one until the previous is resolved');
}
const {promise, resolve: success, reject: fail} = Promise.withResolvers<void>();
pendingEventExpectation = {expectedEvents, success, fail, unmatchingEvents: []};
checkPendingEventExpectation();
const timeout = setTimeout(() => {
if (pendingEventExpectation?.missingEvents) {
pendingEventExpectation.fail(new Error(
'\nMissing VE Events:\n' + formatVeEvents(pendingEventExpectation.missingEvents) +
'\nUnmatched VE Events:\n' + formatVeEvents(pendingEventExpectation.unmatchingEvents)));
}
}, EVENT_EXPECTATION_TIMEOUT);
return await promise.finally(() => {
clearTimeout(timeout);
});
}
let numMatchedEvents = 0;
function checkPendingEventExpectation(): void {
if (!pendingEventExpectation) {
return;
}
const actualEvents = [...veDebugEventsLog] as TestLogEntry[];
let partialMatch = false;
const matchedImpressions = new Set<string>();
pendingEventExpectation.unmatchingEvents = [];
for (let i = 0; i < pendingEventExpectation.expectedEvents.length; ++i) {
const expectedEvent = pendingEventExpectation.expectedEvents[i];
while (true) {
if (actualEvents.length <= i) {
pendingEventExpectation.missingEvents = pendingEventExpectation.expectedEvents.slice(i);
for (const event of pendingEventExpectation.missingEvents) {
if ('impressions' in event) {
event.impressions = event.impressions.filter(impression => !matchedImpressions.has(impression));
}
}
return;
}
if (!compareVeEvents(actualEvents[i], expectedEvent)) {
if (partialMatch) {
const unmatching = {...actualEvents[i]};
if ('impressions' in unmatching && 'impressions' in expectedEvent) {
unmatching.impressions = unmatching.impressions.filter(impression => {
const matched = expectedEvent.impressions.includes(impression);
if (matched) {
matchedImpressions.add(impression);
}
return !matched;
});
}
pendingEventExpectation.unmatchingEvents.push(unmatching);
}
actualEvents.splice(i, 1);
} else {
partialMatch = true;
break;
}
}
}
numMatchedEvents = veDebugEventsLog.length - actualEvents.length + pendingEventExpectation.expectedEvents.length;
pendingEventExpectation.success();
pendingEventExpectation = null;
}
function getUnmatchedVeEvents(): string {
console.error(numMatchedEvents);
return (veDebugEventsLog.slice(numMatchedEvents) as TestLogEntry[])
.map(e => 'interaction' in e ? e.interaction : formatImpressions(e.impressions))
.join('\n');
}
// @ts-expect-error
globalThis.setVeDebugLoggingEnabled = setVeDebugLoggingEnabled;
// @ts-expect-error
globalThis.getUnmatchedVeEvents = getUnmatchedVeEvents;
// @ts-expect-error
globalThis.veDebugEventsLog = veDebugEventsLog;
// @ts-expect-error
globalThis.findVeDebugImpression = findVeDebugImpression;
// @ts-expect-error
globalThis.exportAdHocAnalysisLogForSql = exportAdHocAnalysisLogForSql;
// @ts-expect-error
globalThis.buildStateFlow = buildStateFlow;
// @ts-expect-error
globalThis.expectVeEvents = expectVeEvents;