diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md index 515cbb16..649e6003 100644 --- a/npm_modules/cli/debugger/README.md +++ b/npm_modules/cli/debugger/README.md @@ -129,9 +129,10 @@ node dist/index.js debugger \ Start Owl/Chromium with the printed `--remote-debugging-port` and `--load-extension` values, then open the exact opted-in preview URL printed by -the command. Target matching removes only the injected `valdiDebugger` and -`valdiDevTools` parameters, then requires the same origin, pathname, and -remaining query parameters. +the command. Target matching ignores the reserved development parameters +`valdiDebugger`, `valdiDevTools`, and the optional, explicitly requested +`valdiTrace` parameter, then requires the same origin, pathname, and remaining +query parameters. The debugger command does not enable tracing by default. The synthetic native-tree preview never auto-loads projected HTTP(S) image, video, CSS background, or WebView resources. Only `data:` and `blob:` media are diff --git a/npm_modules/cli/src/debugger/server.spec.ts b/npm_modules/cli/src/debugger/server.spec.ts index 7116e362..dc64505e 100644 --- a/npm_modules/cli/src/debugger/server.spec.ts +++ b/npm_modules/cli/src/debugger/server.spec.ts @@ -727,7 +727,7 @@ describe('debugger server', () => { const matching = await request( new URL( - `/api/devtools/target?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDebugger%3D1%26mode%3Ddev%26valdiDevTools%3D1%26tenant%3Dalpha&targetNonce=${WEB_PREVIEW_NONCE}`, + `/api/devtools/target?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDebugger%3D1%26mode%3Ddev%26valdiDevTools%3D1%26tenant%3Dalpha%26valdiTrace%3Dchrome&targetNonce=${WEB_PREVIEW_NONCE}`, debuggerServer.url, ).toString(), GET_REQUEST_OPTIONS, diff --git a/npm_modules/cli/src/utils/owlCdpClient.spec.ts b/npm_modules/cli/src/utils/owlCdpClient.spec.ts index aef7f90a..ff2450ee 100644 --- a/npm_modules/cli/src/utils/owlCdpClient.spec.ts +++ b/npm_modules/cli/src/utils/owlCdpClient.spec.ts @@ -447,14 +447,14 @@ describe('owlCdpClient', () => { ).toBeRejectedWithError(/No running Owl Chromium page matches/); }); - it('removes only injected Valdi parameters while matching the remaining query exactly', async () => { + it('removes only injected Valdi parameters, including tracing, while matching the remaining query exactly', async () => { discovery.setDiscoveryBody( JSON.stringify([ { id: 'owl-page', title: 'Valdi Owl', type: 'page', - url: 'http://127.0.0.1:54321/index.html?valdiDevTools=1&mode=dev&tenant=alpha&valdiDebugger=1', + url: 'http://127.0.0.1:54321/index.html?valdiDevTools=1&mode=dev&tenant=alpha&valdiDebugger=1&valdiTrace=chrome', webSocketDebuggerUrl: `ws://127.0.0.1:${discovery.port}/devtools/page/owl-page`, }, ]), diff --git a/npm_modules/cli/src/utils/owlCdpClient.ts b/npm_modules/cli/src/utils/owlCdpClient.ts index 10f2eabc..1a6c2255 100644 --- a/npm_modules/cli/src/utils/owlCdpClient.ts +++ b/npm_modules/cli/src/utils/owlCdpClient.ts @@ -7,7 +7,7 @@ const CHROMIUM_DISCOVERY_TIMEOUT_MS = 3000; const CHROMIUM_COMMAND_TIMEOUT_MS = 8000; const MAX_CHROMIUM_DISCOVERY_BYTES = 1024 * 1024; const MAX_CHROMIUM_DISCOVERY_TARGETS = 256; -const VALDI_INJECTED_QUERY_PARAMETERS = ['valdiDebugger', 'valdiDevTools']; +const VALDI_INJECTED_QUERY_PARAMETERS = ['valdiDebugger', 'valdiDevTools', 'valdiTrace']; const FATAL_UTF8_DECODER = new TextDecoder('utf8', { fatal: true }); const GUARDED_TARGET_MATCH_PROPERTY = '__valdiDevToolsTargetMatched'; export const OWL_DEVTOOLS_TARGET_NONCE_PROPERTY = '__VALDI_DEVTOOLS_TARGET_NONCE__'; diff --git a/npm_modules/cli/src/utils/owlDevToolsExtension.spec.ts b/npm_modules/cli/src/utils/owlDevToolsExtension.spec.ts index 671cd03f..f182e532 100644 --- a/npm_modules/cli/src/utils/owlDevToolsExtension.spec.ts +++ b/npm_modules/cli/src/utils/owlDevToolsExtension.spec.ts @@ -100,7 +100,7 @@ describe('owlDevToolsExtension', () => { let clearedHighlights = 0; const secondPage: Record = { __VALDI_WEB_DEBUGGER__: { clearHighlight: () => (clearedHighlights += 1) }, - location: { href: 'http://127.0.0.1:54321/after-navigation.html?valdiDebugger=1' }, + location: { href: 'http://127.0.0.1:54321/after-navigation.html?valdiDebugger=1&valdiTrace=chrome' }, }; let inspectedPage = firstPage; let navigationListener: (() => void) | null = null; @@ -152,7 +152,7 @@ describe('owlDevToolsExtension', () => { navigationListener!(); expect(secondPage[OWL_DEVTOOLS_TARGET_NONCE_PROPERTY]).toBe(nonce); expect(new URL(frame.src).searchParams.get('inspectedUrl')).toBe( - 'http://127.0.0.1:54321/after-navigation.html?valdiDebugger=1', + 'http://127.0.0.1:54321/after-navigation.html?valdiDebugger=1&valdiTrace=chrome', ); unloadListener!(); diff --git a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRenderer.ts b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRenderer.ts index c83a96f5..b7712a92 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRenderer.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRenderer.ts @@ -1,6 +1,8 @@ import { Renderer } from 'valdi_core/src/Renderer'; import { UpdateAttributeDelegate, ValdiWebRendererDelegate } from './ValdiWebRendererDelegate'; import { WebDebuggerBridge } from './debug/WebDebuggerBridge'; +import { isValdiWebTracingEnabled } from './tracing/ValdiWebTracing'; +import { configureValdiWebTracingFromLocation } from './tracing/WebTracingConfiguration'; declare const require: (id: string) => any; @@ -12,8 +14,10 @@ export class ValdiWebRenderer extends Renderer implements UpdateAttributeDelegat private readonly debuggerBridge: WebDebuggerBridge; constructor(htmlRoot: HTMLElement | ShadowRoot) { + configureValdiWebTracingFromLocation(typeof window === 'undefined' ? undefined : window.location?.search); const delegate = new ValdiWebRendererDelegate(htmlRoot); super('valdi-web-renderer', ['view', 'label', 'layout', 'scroll', 'image', 'textfield', 'textview', 'spinner', 'custom-view', 'video', 'shape'], delegate); + this.setTracingEnabled(isValdiWebTracingEnabled()); delegate.setAttributeDelegate(this); this.delegate = delegate; this.debuggerBridge = new WebDebuggerBridge(htmlRoot, delegate, this); diff --git a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRuntime.ts b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRuntime.ts index c2523e66..035ba170 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRuntime.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRuntime.ts @@ -1,3 +1,5 @@ +import { beginValdiWebTrace, endValdiWebTrace, makeValdiWebTraceProxy } from './tracing/ValdiWebTracing'; + // Declare webpack require.context declare const require: { (id: string): any; @@ -309,12 +311,16 @@ class Runtime { } trace(tag: string, callback: Function) { - return callback(); + const handle = beginValdiWebTrace(tag); + try { + return callback(); + } finally { + endValdiWebTrace(handle); + } } makeTraceProxy(tag: string, callback: Function) { - // Return callback directly to avoid adding stack frames - return callback; + return makeValdiWebTraceProxy(tag, callback); } startTraceRecording() { diff --git a/src/valdi_modules/src/valdi/web_renderer/src/tracing/ChromeDevToolsTracing.ts b/src/valdi_modules/src/valdi/web_renderer/src/tracing/ChromeDevToolsTracing.ts new file mode 100644 index 00000000..504ebb17 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/tracing/ChromeDevToolsTracing.ts @@ -0,0 +1,86 @@ +import { ValdiWebTracing, valdiWebTraceArguments, valdiWebTraceName } from './ValdiWebTracing'; + +type DevToolsColor = + | 'primary' + | 'primary-light' + | 'primary-dark' + | 'secondary' + | 'secondary-light' + | 'secondary-dark' + | 'tertiary' + | 'tertiary-light' + | 'tertiary-dark' + | 'error'; + +declare global { + interface Console { + timeStamp( + label: string, + start: string | number, + end: string | number | undefined, + trackName: string, + trackGroup: string, + color: DevToolsColor, + data?: Record, + ): void; + } +} + +interface ActiveTrace { + readonly tag: string; + readonly startTime: number; +} + +export interface WebTracingClock { + now(): number; +} + +const TRACK_NAME = 'Valdi JS'; +const TRACK_GROUP = 'Valdi'; +const TRACK_COLOR: DevToolsColor = 'primary'; +const BROWSER_WEB_TRACING_CLOCK: WebTracingClock = { + now: () => globalThis.performance.now(), +}; + +export class ChromeDevToolsTracing implements ValdiWebTracing { + private readonly activeTraces: ActiveTrace[] = []; + + constructor(private readonly clock: WebTracingClock) {} + + beginTrace(tag: string): void { + this.activeTraces.push({ tag, startTime: this.clock.now() }); + } + + endTrace(): void { + const activeTrace = this.activeTraces.pop(); + if (!activeTrace) { + return; + } + + console.timeStamp( + valdiWebTraceName(activeTrace.tag), + activeTrace.startTime, + this.clock.now(), + TRACK_NAME, + TRACK_GROUP, + TRACK_COLOR, + ); + } + + instantTrace(tag: string, args: readonly unknown[] | undefined): void { + const timestamp = this.clock.now(); + console.timeStamp( + valdiWebTraceName(tag), + timestamp, + timestamp, + TRACK_NAME, + TRACK_GROUP, + TRACK_COLOR, + valdiWebTraceArguments(args), + ); + } +} + +export function createBrowserChromeDevToolsTracing(): ChromeDevToolsTracing { + return new ChromeDevToolsTracing(BROWSER_WEB_TRACING_CLOCK); +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/tracing/ValdiWebTracing.ts b/src/valdi_modules/src/valdi/web_renderer/src/tracing/ValdiWebTracing.ts new file mode 100644 index 00000000..11763320 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/tracing/ValdiWebTracing.ts @@ -0,0 +1,93 @@ +export interface ValdiWebTracing { + beginTrace(tag: string): void; + endTrace(): void; + instantTrace(tag: string, args: readonly unknown[] | undefined): void; +} + +const TRACE_NAME_PREFIX = 'Valdi.'; + +let currentTracing: ValdiWebTracing | undefined; + +export function setValdiWebTracing(tracing: ValdiWebTracing | undefined): void { + currentTracing = tracing; +} + +export function isValdiWebTracingEnabled(): boolean { + return currentTracing !== undefined; +} + +export function beginValdiWebTrace(tag: string): ValdiWebTracing | undefined { + const tracing = currentTracing; + if (!tracing) { + return undefined; + } + + try { + tracing.beginTrace(tag); + return tracing; + } catch (error) { + logTracingError('begin', tag, error); + return undefined; + } +} + +export function endValdiWebTrace(tracing: ValdiWebTracing | undefined): void { + if (!tracing) { + return; + } + + try { + tracing.endTrace(); + } catch (error) { + console.error('[ValdiWebTracing] Failed to end trace', error); + } +} + +export function instantValdiWebTrace(tag: string, args: readonly unknown[] | undefined): void { + const tracing = currentTracing; + if (!tracing) { + return; + } + + try { + tracing.instantTrace(tag, args); + } catch (error) { + logTracingError('emit instant', tag, error); + } +} + +export function makeValdiWebTraceProxy(tag: string, callback: Function): (...parameters: any[]) => any { + return function (this: unknown, ...parameters: any[]) { + const handle = beginValdiWebTrace(tag); + try { + return callback.apply(this, parameters); + } finally { + endValdiWebTrace(handle); + } + }; +} + +export function valdiWebTraceName(tag: string): string { + return `${TRACE_NAME_PREFIX}${tag}`; +} + +export function valdiWebTraceArguments(args: readonly unknown[] | undefined): Record | undefined { + if (!args || args.length === 0) { + return undefined; + } + + const traceArguments: Record = {}; + for (let index = 0; index < args.length; index += 2) { + Object.defineProperty(traceArguments, String(args[index]), { + configurable: true, + enumerable: true, + value: args[index + 1], + writable: true, + }); + } + return traceArguments; +} + +function logTracingError(operation: string, tag: string, error: unknown): void { + console.error(`[ValdiWebTracing] Failed to ${operation} trace '${tag}'`, error); +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/tracing/WebTracingConfiguration.ts b/src/valdi_modules/src/valdi/web_renderer/src/tracing/WebTracingConfiguration.ts new file mode 100644 index 00000000..b48b9089 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/tracing/WebTracingConfiguration.ts @@ -0,0 +1,25 @@ +import { hasSingleWebLocationQueryParameter } from '../utils/LocationQuery'; +import { createBrowserChromeDevToolsTracing } from './ChromeDevToolsTracing'; +import { isValdiWebTracingEnabled, setValdiWebTracing } from './ValdiWebTracing'; + +const VALDI_TRACE_QUERY_KEY = 'valdiTrace'; +const CHROMIUM_TRACE_QUERY_VALUE = 'chrome'; +const DEVTOOLS_QUERY_KEY = 'valdiDevTools'; +const DEVTOOLS_QUERY_VALUE = '1'; + +/** Enable Chromium tracing only when the development host explicitly requests it. */ +export function configureValdiWebTracingFromLocation(locationSearch: string | undefined): boolean { + if (locationSearch === undefined || isValdiWebTracingEnabled()) { + return false; + } + + if ( + !hasSingleWebLocationQueryParameter(locationSearch, DEVTOOLS_QUERY_KEY, DEVTOOLS_QUERY_VALUE) || + !hasSingleWebLocationQueryParameter(locationSearch, VALDI_TRACE_QUERY_KEY, CHROMIUM_TRACE_QUERY_VALUE) + ) { + return false; + } + + setValdiWebTracing(createBrowserChromeDevToolsTracing()); + return true; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/src/utils/LocationQuery.ts b/src/valdi_modules/src/valdi/web_renderer/src/utils/LocationQuery.ts index bcae6334..37279203 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/utils/LocationQuery.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/LocationQuery.ts @@ -19,3 +19,44 @@ export function hasWebLocationQueryParameter( return false; } + +function decodeWebLocationQueryComponent(component: string): string | undefined { + try { + return decodeURIComponent(component.replace(/\+/g, ' ')); + } catch { + return undefined; + } +} + +/** Match one literal development flag, rejecting encoded aliases, malformed escapes, and duplicates. */ +export function hasSingleWebLocationQueryParameter( + locationSearch: string | undefined, + parameterName: string, + expectedValue: string, +): boolean { + if (!locationSearch) { + return false; + } + + const query = locationSearch.charAt(0) === '?' ? locationSearch.slice(1) : locationSearch; + let matchCount = 0; + for (const parameter of query.split('&')) { + const separatorIndex = parameter.indexOf('='); + const rawName = separatorIndex < 0 ? parameter : parameter.slice(0, separatorIndex); + const rawValue = separatorIndex < 0 ? undefined : parameter.slice(separatorIndex + 1); + const decodedName = decodeWebLocationQueryComponent(rawName); + const decodedValue = rawValue === undefined ? undefined : decodeWebLocationQueryComponent(rawValue); + if (decodedName === undefined || (rawValue !== undefined && decodedValue === undefined)) { + return false; + } + if (decodedName !== parameterName) { + continue; + } + matchCount++; + if (rawName !== parameterName || rawValue !== expectedValue) { + return false; + } + } + + return matchCount === 1; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/test/ChromeDevToolsTracing.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/ChromeDevToolsTracing.spec.ts new file mode 100644 index 00000000..116d9bcc --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/ChromeDevToolsTracing.spec.ts @@ -0,0 +1,64 @@ +import 'jasmine/src/jasmine'; +import { ChromeDevToolsTracing, WebTracingClock } from '../src/tracing/ChromeDevToolsTracing'; + +class SequenceClock implements WebTracingClock { + constructor(private readonly values: number[]) {} + + now(): number { + const value = this.values.shift(); + if (value === undefined) { + throw new Error('The deterministic tracing clock is exhausted.'); + } + return value; + } +} + +describe('ChromeDevToolsTracing', () => { + let timeStampSpy: any; + let originalTimeStamp: typeof console.timeStamp | undefined; + + beforeEach(() => { + originalTimeStamp = console.timeStamp; + timeStampSpy = jasmine.createSpy('timeStamp'); + Object.defineProperty(console, 'timeStamp', { configurable: true, value: timeStampSpy }); + }); + + afterEach(() => { + if (originalTimeStamp) { + Object.defineProperty(console, 'timeStamp', { configurable: true, value: originalTimeStamp }); + } else { + Reflect.deleteProperty(console, 'timeStamp'); + } + }); + + it('emits nested duration traces on the Valdi custom track', () => { + const tracing = new ChromeDevToolsTracing(new SequenceClock([10, 20, 30, 40])); + + tracing.beginTrace('outer'); + tracing.beginTrace('inner'); + tracing.endTrace(); + tracing.endTrace(); + + expect(timeStampSpy.calls.allArgs()).toEqual([ + ['Valdi.inner', 20, 30, 'Valdi JS', 'Valdi', 'primary'], + ['Valdi.outer', 10, 40, 'Valdi JS', 'Valdi', 'primary'], + ]); + }); + + it('emits instant traces with arguments', () => { + const tracing = new ChromeDevToolsTracing(new SequenceClock([25])); + + tracing.instantTrace('event', ['nodeId', 12, 'attributeName', 'opacity']); + + expect(timeStampSpy).toHaveBeenCalledWith('Valdi.event', 25, 25, 'Valdi JS', 'Valdi', 'primary', { + nodeId: 12, + attributeName: 'opacity', + }); + }); + + it('ignores unmatched trace ends', () => { + new ChromeDevToolsTracing(new SequenceClock([])).endTrace(); + + expect(timeStampSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/LocationQuery.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/LocationQuery.spec.ts index 1cc5363c..cd4fbf6a 100644 --- a/src/valdi_modules/src/valdi/web_renderer/test/LocationQuery.spec.ts +++ b/src/valdi_modules/src/valdi/web_renderer/test/LocationQuery.spec.ts @@ -1,5 +1,5 @@ import 'jasmine/src/jasmine'; -import { hasWebLocationQueryParameter } from '../src/utils/LocationQuery'; +import { hasSingleWebLocationQueryParameter, hasWebLocationQueryParameter } from '../src/utils/LocationQuery'; describe('hasWebLocationQueryParameter', () => { it('matches an explicitly enabled development flag', () => { @@ -28,3 +28,41 @@ describe('hasWebLocationQueryParameter', () => { expect(hasWebLocationQueryParameter('?valdiDebuggerExtra=1', 'valdiDebugger', '1')).toBeFalse(); }); }); + +describe('hasSingleWebLocationQueryParameter', () => { + it('matches exactly one explicitly valued flag', () => { + expect(hasSingleWebLocationQueryParameter('?fixture=Text_0&valdiTrace=chrome', 'valdiTrace', 'chrome')).toBeTrue(); + }); + + it('rejects missing values and literal duplicate flags in either order', () => { + expect(hasSingleWebLocationQueryParameter('?valdiTrace', 'valdiTrace', 'chrome')).toBeFalse(); + expect( + hasSingleWebLocationQueryParameter('?valdiTrace=chrome&valdiTrace=chrome', 'valdiTrace', 'chrome'), + ).toBeFalse(); + expect( + hasSingleWebLocationQueryParameter('?valdiTrace=chrome&valdiTrace=disabled', 'valdiTrace', 'chrome'), + ).toBeFalse(); + expect( + hasSingleWebLocationQueryParameter('?valdiTrace=disabled&valdiTrace=chrome', 'valdiTrace', 'chrome'), + ).toBeFalse(); + }); + + it('rejects encoded aliases before or after the literal flag', () => { + expect( + hasSingleWebLocationQueryParameter('?valdi%54race=disabled&valdiTrace=chrome', 'valdiTrace', 'chrome'), + ).toBeFalse(); + expect( + hasSingleWebLocationQueryParameter('?valdiTrace=chrome&valdi%54race=disabled', 'valdiTrace', 'chrome'), + ).toBeFalse(); + }); + + it('rejects encoded reserved keys and values even without duplicates', () => { + expect(hasSingleWebLocationQueryParameter('?valdi%54race=chrome', 'valdiTrace', 'chrome')).toBeFalse(); + expect(hasSingleWebLocationQueryParameter('?valdiTrace=chr%6Fme', 'valdiTrace', 'chrome')).toBeFalse(); + }); + + it('rejects malformed percent escapes in names and values', () => { + expect(hasSingleWebLocationQueryParameter('?valdi%Trace=chrome', 'valdiTrace', 'chrome')).toBeFalse(); + expect(hasSingleWebLocationQueryParameter('?valdiTrace=chrome%', 'valdiTrace', 'chrome')).toBeFalse(); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/ValdiWebTracing.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/ValdiWebTracing.spec.ts new file mode 100644 index 00000000..7fe5acb5 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/ValdiWebTracing.spec.ts @@ -0,0 +1,237 @@ +import 'jasmine/src/jasmine'; +import { + ValdiWebTracing, + beginValdiWebTrace, + endValdiWebTrace, + instantValdiWebTrace, + isValdiWebTracingEnabled, + makeValdiWebTraceProxy, + setValdiWebTracing, + valdiWebTraceArguments, +} from '../src/tracing/ValdiWebTracing'; +import { configureValdiWebTracingFromLocation } from '../src/tracing/WebTracingConfiguration'; + +class RecordingTracing implements ValdiWebTracing { + readonly events: string[] = []; + + beginTrace(tag: string): void { + this.events.push(`begin:${tag}`); + } + + endTrace(): void { + this.events.push('end'); + } + + instantTrace(tag: string, args: readonly unknown[] | undefined): void { + this.events.push(`instant:${tag}:${JSON.stringify(args)}`); + } +} + +describe('ValdiWebTracing', () => { + afterEach(() => { + setValdiWebTracing(undefined); + }); + + it('is disabled by default', () => { + setValdiWebTracing(undefined); + + expect(isValdiWebTracingEnabled()).toBeFalse(); + expect(() => { + const handle = beginValdiWebTrace('disabled'); + instantValdiWebTrace('disabled', undefined); + endValdiWebTrace(handle); + }).not.toThrow(); + }); + + it('enables Chromium tracing only through both explicit development query flags', () => { + setValdiWebTracing(undefined); + + expect(configureValdiWebTracingFromLocation('?valdiDevTools=1&valdiTrace=chrome')).toBeTrue(); + expect(isValdiWebTracingEnabled()).toBeTrue(); + }); + + it('leaves normal, malformed, and ambiguous web pages uninstrumented', () => { + const rejectedQueries = [ + undefined, + '?fixture=Text_0', + '?valdiTrace=unsupported', + '?valdiTrace=chrome', + '?valdiDevTools=1', + '?valdiDevTools&valdiTrace=chrome', + '?valdiDevTools=1&valdiTrace', + '?valdiDevTools=1&valdiTrace=chrome&valdiTrace=chrome', + '?valdiDevTools=1&valdiDevTools=1&valdiTrace=chrome', + '?valdi%44evTools=1&valdiTrace=chrome', + '?valdiDevTools=1&valdiTrace=chrome&valdi%54race=disabled', + '?valdiDevTools=1&valdi%54race=disabled&valdiTrace=chrome', + '?valdiDevTools=1&valdiTrace=chr%6Fme', + '?valdiDevTools=1&valdiTrace=chrome%', + ]; + + for (const query of rejectedQueries) { + setValdiWebTracing(undefined); + expect(configureValdiWebTracingFromLocation(query)).toBeFalse(); + expect(isValdiWebTracingEnabled()).toBeFalse(); + } + }); + + it('does not replace an application-provided tracing implementation', () => { + const tracing = new RecordingTracing(); + setValdiWebTracing(tracing); + + expect(configureValdiWebTracingFromLocation('?valdiDevTools=1&valdiTrace=chrome')).toBeFalse(); + const handle = beginValdiWebTrace('existing'); + endValdiWebTrace(handle); + + expect(tracing.events).toEqual(['begin:existing', 'end']); + }); + + it('forwards duration and instant traces to the configured implementation', () => { + const tracing = new RecordingTracing(); + setValdiWebTracing(tracing); + + const handle = beginValdiWebTrace('work'); + instantValdiWebTrace('event', ['key', 42]); + endValdiWebTrace(handle); + + expect(tracing.events).toEqual(['begin:work', 'instant:event:["key",42]', 'end']); + }); + + it('stops forwarding traces when disabled', () => { + const tracing = new RecordingTracing(); + + setValdiWebTracing(tracing); + const enabledHandle = beginValdiWebTrace('enabled'); + endValdiWebTrace(enabledHandle); + setValdiWebTracing(undefined); + const disabledHandle = beginValdiWebTrace('disabled'); + endValdiWebTrace(disabledHandle); + + expect(tracing.events).toEqual(['begin:enabled', 'end']); + }); + + it('logs tracing implementation errors without changing control flow', () => { + const errorSpy = spyOn(console, 'error'); + const tracing: ValdiWebTracing = { + beginTrace: () => { + throw new Error('begin failure'); + }, + endTrace: () => { + throw new Error('end failure'); + }, + instantTrace: () => { + throw new Error('instant failure'); + }, + }; + + setValdiWebTracing(tracing); + let failedHandle: ReturnType = undefined; + expect(() => { + failedHandle = beginValdiWebTrace('begin'); + }).not.toThrow(); + expect(failedHandle).toBeUndefined(); + expect(() => instantValdiWebTrace('instant', undefined)).not.toThrow(); + + tracing.beginTrace = () => {}; + const successfulHandle = beginValdiWebTrace('end'); + expect(() => endValdiWebTrace(successfulHandle)).not.toThrow(); + + expect(errorSpy).toHaveBeenCalledTimes(3); + }); + + it('activates runtime trace proxies created before tracing was configured', () => { + setValdiWebTracing(undefined); + const wrapped = makeValdiWebTraceProxy('Proxy.call', function (this: { base: number }, value: number) { + return this.base + value; + }); + const tracing = new RecordingTracing(); + setValdiWebTracing(tracing); + + expect(wrapped.call({ base: 4 }, 3)).toBe(7); + expect(tracing.events).toEqual(['begin:Proxy.call', 'end']); + }); + + it('ends a proxied trace when the wrapped function throws', () => { + const wrapped = makeValdiWebTraceProxy('Proxy.throw', () => { + throw new Error('callback failure'); + }); + const tracing = new RecordingTracing(); + setValdiWebTracing(tracing); + + expect(() => wrapped()).toThrowError('callback failure'); + expect(tracing.events).toEqual(['begin:Proxy.throw', 'end']); + }); + + it('does not close an outer trace when a nested begin fails', () => { + const events: string[] = []; + const activeTags: string[] = []; + const errorSpy = spyOn(console, 'error'); + const tracing: ValdiWebTracing = { + beginTrace: tag => { + events.push(`begin:${tag}`); + if (tag === 'inner') { + throw new Error('inner begin failed'); + } + activeTags.push(tag); + }, + endTrace: () => { + events.push(`end:${activeTags.pop()}`); + }, + instantTrace: () => {}, + }; + const inner = makeValdiWebTraceProxy('inner', () => { + events.push('callback:inner'); + return 3; + }); + const outer = makeValdiWebTraceProxy('outer', () => inner() + 4); + setValdiWebTracing(tracing); + + expect(outer()).toBe(7); + expect(events).toEqual(['begin:outer', 'begin:inner', 'callback:inner', 'end:outer']); + expect(activeTags).toEqual([]); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it('ends the original tracer when a callback replaces the active tracer', () => { + const originalTracing = new RecordingTracing(); + const replacementTracing = new RecordingTracing(); + const wrapped = makeValdiWebTraceProxy('swap', () => { + setValdiWebTracing(replacementTracing); + return 'result'; + }); + setValdiWebTracing(originalTracing); + + expect(wrapped()).toBe('result'); + expect(originalTracing.events).toEqual(['begin:swap', 'end']); + expect(replacementTracing.events).toEqual([]); + }); + + it('preserves hostile argument keys as own serializable data with last-write semantics', () => { + const argumentsObject = valdiWebTraceArguments([ + '__proto__', + { valdiTracePolluted: true }, + 'constructor', + 'first', + '__proto__', + 'safe', + 'constructor', + 'last', + ]); + if (!argumentsObject) { + throw new Error('Expected trace arguments.'); + } + + expect(Object.getPrototypeOf(argumentsObject)).toBe(Object.prototype); + expect(Object.keys(argumentsObject)).toEqual(['__proto__', 'constructor']); + expect(Object.prototype.hasOwnProperty.call(argumentsObject, '__proto__')).toBeTrue(); + expect(Object.prototype.hasOwnProperty.call(argumentsObject, 'constructor')).toBeTrue(); + expect(argumentsObject['__proto__']).toBe('safe'); + expect(argumentsObject['constructor']).toBe('last'); + expect(Object.prototype.hasOwnProperty.call(Object.prototype, 'valdiTracePolluted')).toBeFalse(); + + const serialized = JSON.parse(JSON.stringify(argumentsObject)) as Record; + expect(Object.keys(serialized)).toEqual(['__proto__', 'constructor']); + expect(serialized['__proto__']).toBe('safe'); + expect(serialized['constructor']).toBe('last'); + }); +});