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 d9616fdb8..c83a96f5a 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRenderer.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRenderer.ts @@ -1,5 +1,6 @@ import { Renderer } from 'valdi_core/src/Renderer'; import { UpdateAttributeDelegate, ValdiWebRendererDelegate } from './ValdiWebRendererDelegate'; +import { WebDebuggerBridge } from './debug/WebDebuggerBridge'; declare const require: (id: string) => any; @@ -8,18 +9,21 @@ require('./ValdiWebRuntime'); export class ValdiWebRenderer extends Renderer implements UpdateAttributeDelegate { delegate: InstanceType; + private readonly debuggerBridge: WebDebuggerBridge; constructor(htmlRoot: HTMLElement | ShadowRoot) { const delegate = new ValdiWebRendererDelegate(htmlRoot); super('valdi-web-renderer', ['view', 'label', 'layout', 'scroll', 'image', 'textfield', 'textview', 'spinner', 'custom-view', 'video', 'shape'], delegate); delegate.setAttributeDelegate(this); this.delegate = delegate; + this.debuggerBridge = new WebDebuggerBridge(htmlRoot, delegate, this); } updateAttribute(elementId: number, attributeName: string, attributeValue: any) { super.attributeUpdatedExternally(elementId, attributeName, attributeValue); } destroy() { + this.debuggerBridge.destroy(); this.delegate.onDestroyed(); } } diff --git a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts index 50e59f968..c9c33abbe 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts @@ -1,4 +1,6 @@ import { AnimationOptions } from 'valdi_core/src/AnimationOptions'; +import type { IRenderedElement } from 'valdi_core/src/IRenderedElement'; +import type { IRenderer } from 'valdi_core/src/IRenderer'; import { FrameObserver, IRendererDelegate, VisibilityObserver } from 'valdi_core/src/IRendererDelegate'; import { Style } from 'valdi_core/src/Style'; import { NativeNode } from 'valdi_tsx/src/NativeNode'; @@ -7,18 +9,73 @@ import { changeAttributeOnElement, createElement, createNodesRef, - destroyElement, makeElementRoot, moveElement, NodesRef, registerElements, setAllElementsAttributeDelegate, } from './HTMLRenderer'; +import type { WebValdiLayout } from './views/WebValdiLayout'; export interface UpdateAttributeDelegate { updateAttribute(elementId: number, attributeName: string, attributeValue: any): void; } +export interface WebRendererDebugElementSnapshot { + id: string; + tag: string; + element: { + id: number; + attributes: Record; + dom: { + attributes: Record; + tagName: string; + }; + }; + bounds: { + x: number; + y: number; + width: number; + height: number; + }; + children: WebRendererDebugElementSnapshot[]; + childrenTruncated?: boolean; +} + +export interface WebRendererDebugSnapshot { + tree: WebRendererDebugElementSnapshot | null; + viewport: { + width: number; + height: number; + }; +} + +interface DebugSerializationBudget { + remainingCharacters: number; +} + +interface DebugTreeTraversalBudget { + remainingChildLinks: number; + remainingNodes: number; + truncated: boolean; +} + +interface DebugSnapshotBudget extends DebugSerializationBudget { + readonly renderedTree: DebugTreeTraversalBudget; +} + +const MAX_DEBUG_DEPTH = 4; +const MAX_DEBUG_ENTRIES = 50; +const MAX_DEBUG_STRING_CHARACTERS = 65_536; +export const MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS = 262_144; +const MAX_DEBUG_TREE_CHILD_LINKS = 1_000; +const MAX_DEBUG_TREE_DEPTH = 64; +const MAX_DEBUG_TREE_NODES = 1_000; +const DEBUG_TRUNCATION_BUDGET_RESERVE = 192; +const DEBUG_TRUNCATION_MARKER = '... '; +const DEBUG_ACCESSOR_MARKER = ''; +const DEBUG_CIRCULAR_MARKER = ''; + export class ValdiWebRendererDelegate implements IRendererDelegate { private attributeDelegate?: UpdateAttributeDelegate; private frameObserver?: FrameObserver; @@ -27,6 +84,7 @@ export class ValdiWebRendererDelegate implements IRendererDelegate { // Owned per delegate (i.e. per renderer/page) so element ids can't collide // with another page's (github.com/Snapchat/Valdi#115). private nodesRef: NodesRef = createNodesRef(); + private rootElementId?: number; constructor(private htmlRoot: HTMLElement | ShadowRoot) { registerElements(); @@ -39,6 +97,7 @@ export class ValdiWebRendererDelegate implements IRendererDelegate { onElementBecameRoot(id: number): void { makeElementRoot(this.nodesRef, id, this.htmlRoot); + this.rootElementId = id; } onElementMoved(id: number, parentId: number, parentIndex: number): void { moveElement(this.nodesRef, id, parentId, parentIndex); @@ -53,10 +112,42 @@ export class ValdiWebRendererDelegate implements IRendererDelegate { } onElementDestroyed(id: number): void { const element = this.nodesRef.get(id); - if (element?.htmlElement) { - this.resizeObserver?.unobserve(element.htmlElement); + if (element === undefined) { + return; + } + + const nodesToDestroy: WebValdiLayout[] = []; + const visitedNodes = new Set(); + const pendingNodes = [element]; + while (pendingNodes.length > 0) { + const node = pendingNodes.pop()!; + if (visitedNodes.has(node) || this.nodesRef.get(node.id) !== node) { + continue; + } + visitedNodes.add(node); + nodesToDestroy.push(node); + for (const child of node.children) { + // A moved child can remain in an adversarially stale child array. Only + // purge links that still describe the live backing-node relationship. + if (child.parent === node && this.nodesRef.get(child.id) === child) { + pendingNodes.push(child); + } + } + } + + element.parent?.removeChild(element); + for (let index = nodesToDestroy.length - 1; index >= 0; index--) { + const node = nodesToDestroy[index]; + this.resizeObserver?.unobserve(node.htmlElement); + this.elementIdByHtmlElement.delete(node.htmlElement); + node.destroy(); + this.nodesRef.delete(node.id); + node.parent = null; + node.children = []; + if (this.rootElementId === node.id) { + this.rootElementId = undefined; + } } - destroyElement(this.nodesRef, id); } onElementAttributeChangeAny(id: number, attributeName: string, attributeValue: any): void { changeAttributeOnElement(this.nodesRef, id, attributeName, attributeValue); @@ -153,4 +244,535 @@ export class ValdiWebRendererDelegate implements IRendererDelegate { this.frameObserver = undefined; this.resizeObserver?.disconnect(); } + + getDebugNode(id: number): { htmlElement: HTMLElement; type: string } | undefined { + const node = this.nodesRef.get(id); + return node === undefined ? undefined : { htmlElement: node.htmlElement, type: node.type }; + } + + getDebugSnapshot(renderer: IRenderer, maximumSerializedCharacters: number): WebRendererDebugSnapshot { + const viewport = { + width: typeof window === 'undefined' ? 0 : window.innerWidth, + height: typeof window === 'undefined' ? 0 : window.innerHeight, + }; + const snapshotEnvelopeCharacters = JSON.stringify({ tree: null, viewport }).length - 'null'.length; + const snapshotCharacterLimit = Math.min( + MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS, + Math.max(0, maximumSerializedCharacters), + ); + const budget: DebugSnapshotBudget = { + remainingCharacters: Math.max(0, snapshotCharacterLimit - snapshotEnvelopeCharacters), + renderedTree: { + remainingChildLinks: MAX_DEBUG_TREE_CHILD_LINKS, + remainingNodes: MAX_DEBUG_TREE_NODES, + truncated: false, + }, + }; + const rootNode = this.rootElementId === undefined ? undefined : this.nodesRef.get(this.rootElementId); + const snapshot: WebRendererDebugSnapshot = { + tree: + rootNode === undefined + ? null + : (captureDebugElementSnapshot(rootNode, null, this.nodesRef, renderer, budget, 0, false) ?? null), + viewport, + }; + // The snapshot only contains fresh data objects and primitives, so this final + // serialization check cannot invoke getters from renderer-owned values. + return JSON.stringify(snapshot).length <= snapshotCharacterLimit + ? snapshot + : { tree: null, viewport }; + } +} + +function captureRenderedElementAttributes( + element: IRenderedElement, + budget: DebugSerializationBudget, +): Record { + const attributes: Record = {}; + let attributeNames: string[]; + try { + attributeNames = element.getAttributeNames(); + } catch (_error) { + addDebugTruncationProperty(attributes, '', budget); + return attributes; + } + const attributeCount = Math.min(attributeNames.length, MAX_DEBUG_ENTRIES); + let attributesTruncated = attributeNames.length > attributeCount; + for (let index = 0; index < attributeCount; index++) { + if (budget.remainingCharacters <= DEBUG_TRUNCATION_BUDGET_RESERVE) { + attributesTruncated = true; + break; + } + const attributeName = attributeNames[index]; + const name = String(attributeName); + if (!tryConsumeDebugPropertyPrefix(attributes, name, budget, 4)) { + attributesTruncated = true; + break; + } + try { + setDebugProperty( + attributes, + name, + toDebugValue(element.getAttribute(attributeName), 0, new Set(), budget), + ); + } catch (_error) { + setDebugProperty(attributes, name, captureDebugString('', budget, 0)); + } + } + if (attributesTruncated) { + addDebugTruncationProperty( + attributes, + `${attributeNames.length - Object.keys(attributes).length} more attributes`, + budget, + ); + } + return attributes; +} + +function captureDebugElementSnapshot( + node: WebValdiLayout, + expectedParent: WebValdiLayout | null, + nodesRef: NodesRef, + renderer: IRenderer, + budget: DebugSnapshotBudget, + depth: number, + hasPreviousSibling: boolean, +): WebRendererDebugElementSnapshot | undefined { + if (!isLiveDebugNode(node, expectedParent, nodesRef) || !tryConsumeDebugTreeNode(budget.renderedTree, depth)) { + return undefined; + } + const rect = node.htmlElement.getBoundingClientRect(); + if (!isLiveDebugNode(node, expectedParent, nodesRef)) { + return undefined; + } + const renderedElement = renderer.getElementForId(node.id); + if (!isLiveDebugNode(node, expectedParent, nodesRef)) { + return undefined; + } + const domAttributes: Record = {}; + const attributes: Record = {}; + const snapshot: WebRendererDebugElementSnapshot = { + id: String(node.id), + tag: node.type, + element: { + id: node.id, + attributes, + dom: { + attributes: domAttributes, + tagName: node.htmlElement.tagName.toLowerCase(), + }, + }, + bounds: { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }, + children: [], + }; + const structuralCharacters = JSON.stringify(snapshot).length + (hasPreviousSibling ? 1 : 0); + if (!tryConsumeDebugBudget(budget, structuralCharacters)) { + budget.renderedTree.truncated = true; + return undefined; + } + + if (renderedElement !== undefined) { + snapshot.element.attributes = captureRenderedElementAttributes(renderedElement, budget); + if (!isLiveDebugNode(node, expectedParent, nodesRef)) { + return undefined; + } + } + + const domAttributeCount = Math.min(node.htmlElement.attributes.length, MAX_DEBUG_ENTRIES); + let domAttributesTruncated = node.htmlElement.attributes.length > domAttributeCount; + for (let index = 0; index < domAttributeCount; index++) { + if (budget.remainingCharacters <= DEBUG_TRUNCATION_BUDGET_RESERVE) { + domAttributesTruncated = true; + break; + } + const attribute = node.htmlElement.attributes.item(index); + if (attribute !== null) { + if (!tryConsumeDebugPropertyPrefix(domAttributes, attribute.name, budget, 2)) { + domAttributesTruncated = true; + break; + } + setDebugProperty( + domAttributes, + attribute.name, + captureDebugString(attribute.value, budget, DEBUG_TRUNCATION_BUDGET_RESERVE), + ); + } + } + if (domAttributesTruncated) { + addDebugTruncationProperty(domAttributes, DEBUG_TRUNCATION_MARKER, budget); + } + if (!isLiveDebugNode(node, expectedParent, nodesRef)) { + return undefined; + } + + let childrenTruncated = false; + if (depth + 1 >= MAX_DEBUG_TREE_DEPTH) { + const hasChildren = node.children.length > 0; + if (!isLiveDebugNode(node, expectedParent, nodesRef)) { + return undefined; + } + if (hasChildren) { + childrenTruncated = true; + budget.renderedTree.truncated = true; + } + } else { + let childIndex = 0; + while (true) { + if (!isLiveDebugNode(node, expectedParent, nodesRef)) { + return undefined; + } + const currentChildCount = node.children.length; + if (!isLiveDebugNode(node, expectedParent, nodesRef)) { + return undefined; + } + if (childIndex >= currentChildCount) { + break; + } + if ( + budget.remainingCharacters <= 0 || + budget.renderedTree.remainingNodes <= 0 || + !tryConsumeDebugTreeChildLink(budget.renderedTree) + ) { + childrenTruncated = true; + budget.renderedTree.truncated = true; + break; + } + const child = node.children[childIndex]; + childIndex++; + if (!isLiveDebugNode(node, expectedParent, nodesRef)) { + return undefined; + } + if (child === undefined || !isLiveDebugNode(child, node, nodesRef)) { + continue; + } + const childSnapshot = captureDebugElementSnapshot( + child, + node, + nodesRef, + renderer, + budget, + depth + 1, + snapshot.children.length > 0, + ); + if (!isLiveDebugNode(node, expectedParent, nodesRef)) { + return undefined; + } + if (!isLiveDebugNode(child, node, nodesRef)) { + continue; + } + if (childSnapshot === undefined) { + childrenTruncated = true; + budget.renderedTree.truncated = true; + break; + } + snapshot.children.push(childSnapshot); + } + } + if (childrenTruncated && tryConsumeDebugPropertyPrefix(snapshot, 'childrenTruncated', budget, 4)) { + consumeDebugBudget(budget, 4); + snapshot.childrenTruncated = true; + } + return snapshot; +} + +function isLiveDebugNode( + node: WebValdiLayout, + expectedParent: WebValdiLayout | null, + nodesRef: NodesRef, +): boolean { + return node.parent === expectedParent && nodesRef.get(node.id) === node; +} + +function tryConsumeDebugTreeChildLink(budget: DebugTreeTraversalBudget): boolean { + if (budget.remainingChildLinks <= 0) { + budget.truncated = true; + return false; + } + budget.remainingChildLinks--; + return true; +} + +function tryConsumeDebugTreeNode(budget: DebugTreeTraversalBudget, depth: number): boolean { + if (depth >= MAX_DEBUG_TREE_DEPTH || budget.remainingNodes <= 0) { + budget.truncated = true; + return false; + } + budget.remainingNodes--; + return true; +} + +function toDebugValue( + value: unknown, + depth: number, + activePath: Set, + budget: DebugSerializationBudget, +): unknown { + // Depth counts edges from the renderer attribute value. Once the limit is + // reached, replace the value itself without inspecting or serializing it. + if (depth >= MAX_DEBUG_DEPTH) { + return captureDebugString(DEBUG_TRUNCATION_MARKER, budget, 0); + } + if (value === undefined) { + consumeDebugBudget(budget, 'null'.length); + return value; + } + if (value === null || typeof value === 'number' || typeof value === 'boolean') { + const serializedValue = JSON.stringify(value); + consumeDebugBudget(budget, serializedValue === undefined ? 'null'.length : serializedValue.length); + return value; + } + if (typeof value === 'string') { + return captureDebugString(value, budget, DEBUG_TRUNCATION_BUDGET_RESERVE); + } + if (typeof value === 'function') { + return captureDebugString('[function]', budget, DEBUG_TRUNCATION_BUDGET_RESERVE); + } + if (typeof value !== 'object') { + return captureDebugString(String(value), budget, DEBUG_TRUNCATION_BUDGET_RESERVE); + } + if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(value)) { + return captureDebugString('', budget, DEBUG_TRUNCATION_BUDGET_RESERVE); + } + if (activePath.has(value)) { + return captureDebugString(DEBUG_CIRCULAR_MARKER, budget, 0); + } + + activePath.add(value); + try { + if (Array.isArray(value)) { + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + const length = typeof lengthDescriptor?.value === 'number' ? lengthDescriptor.value : 0; + const itemCount = Math.min(length, MAX_DEBUG_ENTRIES); + const debugArray: unknown[] = []; + if (!tryConsumeDebugBudget(budget, 2)) { + return ''; + } + let inspectedItemCount = 0; + for (; inspectedItemCount < itemCount; inspectedItemCount++) { + if ( + budget.remainingCharacters <= DEBUG_TRUNCATION_BUDGET_RESERVE || + !tryConsumeDebugArrayItemPrefix(debugArray, budget, 4) + ) { + break; + } + const descriptor = Object.getOwnPropertyDescriptor(value, String(inspectedItemCount)); + debugArray.push( + descriptor === undefined + ? captureDebugString('', budget, DEBUG_TRUNCATION_BUDGET_RESERVE) + : descriptor.get || descriptor.set + ? captureDebugString(DEBUG_ACCESSOR_MARKER, budget, 0) + : toDebugValue(descriptor.value, depth + 1, activePath, budget), + ); + } + if (length > inspectedItemCount && tryConsumeDebugArrayItemPrefix(debugArray, budget, 2)) { + debugArray.push(captureDebugString(`${length - inspectedItemCount} more items`, budget, 0)); + } + return debugArray; + } + + const debugValue: Record = {}; + if (!tryConsumeDebugBudget(budget, 2)) { + return ''; + } + let entryCount = 0; + let fieldsOmitted = false; + // JavaScript has no resumable own-key iterator. A stoppable for-in loop avoids + // materializing every key/descriptor in user space, although engines and Proxy + // ownKeys traps may still enumerate the complete key set internally. + for (const key in value) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined) { + // Own enumerable keys are visited before inherited keys. + break; + } + if (!descriptor.enumerable) { + continue; + } + if ( + entryCount >= MAX_DEBUG_ENTRIES || + budget.remainingCharacters <= DEBUG_TRUNCATION_BUDGET_RESERVE || + !tryConsumeDebugPropertyPrefix(debugValue, key, budget, 4) + ) { + fieldsOmitted = true; + break; + } + setDebugProperty( + debugValue, + key, + descriptor.get || descriptor.set + ? captureDebugString(DEBUG_ACCESSOR_MARKER, budget, 0) + : toDebugValue(descriptor.value, depth + 1, activePath, budget), + ); + entryCount++; + } + if (fieldsOmitted) { + addDebugTruncationProperty(debugValue, 'more fields', budget); + } + return debugValue; + } catch (_error) { + return captureDebugString('', budget, 0); + } finally { + activePath.delete(value); + } +} + +function captureDebugString( + value: string, + budget: DebugSerializationBudget, + reservedCharacters: number, +): string { + const availableCharacters = Math.min( + budget.remainingCharacters, + Math.max(2, budget.remainingCharacters - reservedCharacters), + ); + const valueCharacterLimit = Math.min(value.length, MAX_DEBUG_STRING_CHARACTERS); + if ( + value.length <= MAX_DEBUG_STRING_CHARACTERS && + getJsonStringCharacterLength(value) <= availableCharacters + ) { + consumeDebugBudget(budget, getJsonStringCharacterLength(value)); + return value; + } + + const markerFits = getJsonStringCharacterLength(DEBUG_TRUNCATION_MARKER) <= availableCharacters; + const marker = markerFits ? DEBUG_TRUNCATION_MARKER : ''; + let low = 0; + let high = Math.max(0, valueCharacterLimit - marker.length); + let bestPrefixEnd = 0; + while (low <= high) { + const midpoint = Math.floor((low + high) / 2); + const prefixEnd = getUnicodeSafePrefixEnd(value, midpoint); + const candidate = `${value.slice(0, prefixEnd)}${marker}`; + if (getJsonStringCharacterLength(candidate) <= availableCharacters) { + bestPrefixEnd = prefixEnd; + low = midpoint + 1; + } else { + high = midpoint - 1; + } + } + const truncated = `${value.slice(0, bestPrefixEnd)}${marker}`; + consumeDebugBudget(budget, getJsonStringCharacterLength(truncated)); + return truncated; +} + +function getUnicodeSafePrefixEnd(value: string, requestedEnd: number): number { + const prefixEnd = Math.max(0, Math.min(value.length, requestedEnd)); + if (prefixEnd === 0 || prefixEnd === value.length) { + return prefixEnd; + } + const previousCharacter = value.charCodeAt(prefixEnd - 1); + const nextCharacter = value.charCodeAt(prefixEnd); + return previousCharacter >= 0xd800 && + previousCharacter <= 0xdbff && + nextCharacter >= 0xdc00 && + nextCharacter <= 0xdfff + ? prefixEnd - 1 + : prefixEnd; +} + +function addDebugTruncationProperty( + target: Record, + message: string, + budget: DebugSerializationBudget, +): void { + if ( + Object.prototype.hasOwnProperty.call(target, '__truncated__') || + !tryConsumeDebugPropertyPrefix(target, '__truncated__', budget, 2) + ) { + return; + } + setDebugProperty(target, '__truncated__', captureDebugString(message, budget, 0)); +} + +function setDebugProperty(target: Record, propertyName: string, value: T): void { + Object.defineProperty(target, propertyName, { + configurable: true, + enumerable: true, + value, + writable: true, + }); +} + +function tryConsumeDebugPropertyPrefix( + target: object, + propertyName: string, + budget: DebugSerializationBudget, + minimumValueCharacters: number, +): boolean { + const separatorCharacters = Object.keys(target).length === 0 ? 0 : 1; + const minimumPropertyPrefixCharacters = separatorCharacters + propertyName.length + 3; + if (minimumPropertyPrefixCharacters + minimumValueCharacters > budget.remainingCharacters) { + return false; + } + const propertyPrefixCharacters = separatorCharacters + getJsonStringCharacterLength(propertyName) + 1; + if (propertyPrefixCharacters + minimumValueCharacters > budget.remainingCharacters) { + return false; + } + consumeDebugBudget(budget, propertyPrefixCharacters); + return true; +} + +function tryConsumeDebugArrayItemPrefix( + target: unknown[], + budget: DebugSerializationBudget, + minimumValueCharacters: number, +): boolean { + const prefixCharacters = target.length === 0 ? 0 : 1; + if (prefixCharacters + minimumValueCharacters > budget.remainingCharacters) { + return false; + } + consumeDebugBudget(budget, prefixCharacters); + return true; +} + +function getJsonStringCharacterLength(value: string): number { + let characterCount = 2; + for (let index = 0; index < value.length; index++) { + const characterCode = value.charCodeAt(index); + if ( + characterCode === 0x22 || + characterCode === 0x5c || + characterCode === 0x08 || + characterCode === 0x09 || + characterCode === 0x0a || + characterCode === 0x0c || + characterCode === 0x0d + ) { + characterCount += 2; + } else if (characterCode < 0x20) { + characterCount += 6; + } else if (characterCode >= 0xd800 && characterCode <= 0xdbff) { + const nextCharacterCode = value.charCodeAt(index + 1); + if (nextCharacterCode >= 0xdc00 && nextCharacterCode <= 0xdfff) { + characterCount += 2; + index++; + } else { + characterCount += 6; + } + } else if (characterCode >= 0xdc00 && characterCode <= 0xdfff) { + characterCount += 6; + } else { + characterCount++; + } + } + return characterCount; +} + +function tryConsumeDebugBudget(budget: DebugSerializationBudget, characterCount: number): boolean { + if (characterCount > budget.remainingCharacters) { + return false; + } + consumeDebugBudget(budget, characterCount); + return true; +} + +function consumeDebugBudget(budget: DebugSerializationBudget, characterCount: number): void { + if (characterCount > 0) { + budget.remainingCharacters = Math.max(0, budget.remainingCharacters - characterCount); + } } diff --git a/src/valdi_modules/src/valdi/web_renderer/src/debug/WebDebuggerBridge.ts b/src/valdi_modules/src/valdi/web_renderer/src/debug/WebDebuggerBridge.ts new file mode 100644 index 000000000..d7697ea63 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/debug/WebDebuggerBridge.ts @@ -0,0 +1,275 @@ +import type { IRenderer } from 'valdi_core/src/IRenderer'; +import type { ValdiWebRendererDelegate, WebRendererDebugSnapshot } from '../ValdiWebRendererDelegate'; +import { MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS } from '../ValdiWebRendererDelegate'; +import { hasWebLocationQueryParameter } from '../utils/LocationQuery'; + +const WEB_DEBUGGER_CHANNEL = 'valdi-web-debugger'; +const WEB_DEBUGGER_QUERY_KEY = 'valdiDebugger'; +const WEB_DEBUGGER_QUERY_VALUE = '1'; +const DEVTOOLS_QUERY_KEY = 'valdiDevTools'; +const OWL_DEBUGGER_QUERY_KEY = 'valdiOwlDebugger'; +const MAX_SOURCE_METADATA_SERIALIZED_CHARACTERS = 16_384; +const SOURCE_METADATA_TRUNCATION_MARKER = '... '; + +export interface StandaloneWebDebuggerSnapshot { + channel: string; + source: { + title: string; + url: string; + }; + snapshot: WebRendererDebugSnapshot; + type: string; +} + +export interface StandaloneWebDebuggerRuntime { + clearHighlight?(): boolean; + getSnapshot(): StandaloneWebDebuggerSnapshot; + highlightNode?(nodeId: string): boolean; +} + +interface DebuggableWindow extends Window { + __VALDI_WEB_DEBUGGER__?: StandaloneWebDebuggerRuntime; +} + +const BRIDGE_CREATED_STANDALONE_RUNTIMES = new WeakSet(); +const LIVE_BRIDGE_STANDALONE_RUNTIMES = new WeakSet(); +const PREVIOUS_STANDALONE_RUNTIMES = new WeakMap< + StandaloneWebDebuggerRuntime, + StandaloneWebDebuggerRuntime | undefined +>(); + +export class WebDebuggerBridge { + private destroyed = false; + private readonly enabled: boolean; + private highlightedNode?: HTMLDivElement; + private standaloneRuntime?: StandaloneWebDebuggerRuntime; + private previousStandaloneRuntime?: StandaloneWebDebuggerRuntime; + + constructor( + _root: HTMLElement | ShadowRoot, + private readonly delegate: ValdiWebRendererDelegate, + private readonly renderer: IRenderer, + ) { + this.enabled = shouldEnableWebDebuggerBridge(); + if (this.enabled) { + this.registerStandaloneRuntime(); + } + } + + destroy(): void { + if (!this.enabled || this.destroyed) { + return; + } + this.removeHighlightOverlay(); + this.destroyed = true; + + const debuggableWindow = window as DebuggableWindow; + if (this.standaloneRuntime) { + LIVE_BRIDGE_STANDALONE_RUNTIMES.delete(this.standaloneRuntime); + } + if (this.standaloneRuntime && debuggableWindow.__VALDI_WEB_DEBUGGER__ === this.standaloneRuntime) { + const runtimeToRestore = getRestorableStandaloneRuntime(this.previousStandaloneRuntime); + if (runtimeToRestore) { + debuggableWindow.__VALDI_WEB_DEBUGGER__ = runtimeToRestore; + } else { + delete debuggableWindow.__VALDI_WEB_DEBUGGER__; + } + } + this.standaloneRuntime = undefined; + this.previousStandaloneRuntime = undefined; + } + + private getSnapshot(): StandaloneWebDebuggerSnapshot { + if (this.destroyed) { + throw new Error('Web debugger runtime has been destroyed.'); + } + const source = { + title: captureBoundedSourceMetadata(document.title), + url: captureBoundedSourceMetadata(window.location.href), + }; + const envelopeCharacters = + JSON.stringify({ + channel: WEB_DEBUGGER_CHANNEL, + source, + snapshot: null, + type: 'snapshot', + }).length - 'null'.length; + const snapshot = this.delegate.getDebugSnapshot( + this.renderer, + Math.max(0, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS - envelopeCharacters), + ); + const response: StandaloneWebDebuggerSnapshot = { + channel: WEB_DEBUGGER_CHANNEL, + source, + snapshot, + type: 'snapshot', + }; + if (JSON.stringify(response).length <= MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS) { + return response; + } + return { + ...response, + snapshot: { + tree: null, + viewport: { + width: 0, + height: 0, + }, + }, + }; + } + + private registerStandaloneRuntime(): void { + const debuggableWindow = window as DebuggableWindow; + this.previousStandaloneRuntime = debuggableWindow.__VALDI_WEB_DEBUGGER__; + const runtime: StandaloneWebDebuggerRuntime = { + clearHighlight: () => (this.destroyed ? false : this.removeHighlightOverlay()), + getSnapshot: () => this.getSnapshot(), + highlightNode: nodeId => this.highlightNode(nodeId), + }; + this.standaloneRuntime = runtime; + BRIDGE_CREATED_STANDALONE_RUNTIMES.add(runtime); + LIVE_BRIDGE_STANDALONE_RUNTIMES.add(runtime); + PREVIOUS_STANDALONE_RUNTIMES.set(runtime, this.previousStandaloneRuntime); + debuggableWindow.__VALDI_WEB_DEBUGGER__ = runtime; + } + + private highlightNode(nodeId: string): boolean { + if (this.destroyed || !/^[0-9]{1,16}$/.test(nodeId)) { + return false; + } + const numericNodeId = Number(nodeId); + if (!Number.isSafeInteger(numericNodeId) || numericNodeId < 0) { + return false; + } + const node = this.delegate.getDebugNode(numericNodeId); + if (!node || !document.body) { + return false; + } + + this.removeHighlightOverlay(); + const bounds = node.htmlElement.getBoundingClientRect(); + const overlay = document.createElement('div'); + overlay.setAttribute('aria-hidden', 'true'); + overlay.dataset['valdiDebuggerOverlay'] = nodeId; + Object.assign(overlay.style, { + backgroundColor: 'rgba(26, 115, 232, 0.16)', + border: '2px solid rgb(26, 115, 232)', + boxSizing: 'border-box', + height: `${bounds.height}px`, + left: `${bounds.left}px`, + pointerEvents: 'none', + position: 'fixed', + top: `${bounds.top}px`, + width: `${bounds.width}px`, + zIndex: '2147483647', + }); + + const label = document.createElement('div'); + label.textContent = `${node.type} ยท ${Math.round(bounds.width)} ร— ${Math.round(bounds.height)}`; + Object.assign(label.style, { + backgroundColor: 'rgb(26, 115, 232)', + borderRadius: '2px', + color: 'white', + font: '11px -apple-system, BlinkMacSystemFont, sans-serif', + left: '-2px', + padding: '3px 6px', + position: 'absolute', + top: bounds.top > 24 ? '-23px' : '0', + whiteSpace: 'nowrap', + }); + overlay.appendChild(label); + document.body.appendChild(overlay); + this.highlightedNode = overlay; + return true; + } + + private removeHighlightOverlay(): boolean { + if (!this.highlightedNode) { + return false; + } + this.highlightedNode.remove(); + this.highlightedNode = undefined; + return true; + } +} + +function captureBoundedSourceMetadata(value: string): string { + const maximumContentCharacters = MAX_SOURCE_METADATA_SERIALIZED_CHARACTERS - 2; + const markerCharacters = getJsonStringContentCharacterLength(SOURCE_METADATA_TRUNCATION_MARKER); + const contentCharacterLimit = maximumContentCharacters - markerCharacters; + const output: string[] = []; + let outputCharacters = 0; + let index = 0; + while (index < value.length) { + const characterCode = value.charCodeAt(index); + let inputCharacters = 1; + let serializedCharacters: number; + if (characterCode >= 0xd800 && characterCode <= 0xdbff) { + const nextCharacterCode = value.charCodeAt(index + 1); + if (nextCharacterCode >= 0xdc00 && nextCharacterCode <= 0xdfff) { + inputCharacters = 2; + serializedCharacters = 2; + } else { + serializedCharacters = 6; + } + } else if (characterCode >= 0xdc00 && characterCode <= 0xdfff) { + serializedCharacters = 6; + } else if ( + characterCode === 0x22 || + characterCode === 0x5c || + characterCode === 0x08 || + characterCode === 0x09 || + characterCode === 0x0a || + characterCode === 0x0c || + characterCode === 0x0d + ) { + serializedCharacters = 2; + } else { + serializedCharacters = characterCode < 0x20 ? 6 : 1; + } + if (outputCharacters + serializedCharacters > contentCharacterLimit) { + break; + } + output.push(value.slice(index, index + inputCharacters)); + outputCharacters += serializedCharacters; + index += inputCharacters; + } + return index === value.length ? output.join('') : `${output.join('')}${SOURCE_METADATA_TRUNCATION_MARKER}`; +} + +function getJsonStringContentCharacterLength(value: string): number { + return JSON.stringify(value).length - 2; +} + +function getRestorableStandaloneRuntime( + runtime: StandaloneWebDebuggerRuntime | undefined, +): StandaloneWebDebuggerRuntime | undefined { + const visited = new Set(); + let candidate = runtime; + while ( + candidate !== undefined && + BRIDGE_CREATED_STANDALONE_RUNTIMES.has(candidate) && + !LIVE_BRIDGE_STANDALONE_RUNTIMES.has(candidate) + ) { + if (visited.has(candidate)) { + return undefined; + } + visited.add(candidate); + candidate = PREVIOUS_STANDALONE_RUNTIMES.get(candidate); + } + return candidate; +} + +function shouldEnableWebDebuggerBridge(): boolean { + if (typeof window === 'undefined' || window.parent !== window) { + return false; + } + if (!hasWebLocationQueryParameter(window.location.search, WEB_DEBUGGER_QUERY_KEY, WEB_DEBUGGER_QUERY_VALUE)) { + return false; + } + return ( + hasWebLocationQueryParameter(window.location.search, DEVTOOLS_QUERY_KEY, WEB_DEBUGGER_QUERY_VALUE) || + hasWebLocationQueryParameter(window.location.search, OWL_DEBUGGER_QUERY_KEY, WEB_DEBUGGER_QUERY_VALUE) + ); +} 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 new file mode 100644 index 000000000..bcae6334c --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/src/utils/LocationQuery.ts @@ -0,0 +1,21 @@ +/** Match development query flags without requiring browser-only URL globals in the Valdi runtime. */ +export function hasWebLocationQueryParameter( + locationSearch: string | undefined, + parameterName: string, + expectedValue: string, +): boolean { + if (!locationSearch) { + return false; + } + + const query = locationSearch.charAt(0) === '?' ? locationSearch.slice(1) : locationSearch; + for (const parameter of query.split('&')) { + const separatorIndex = parameter.indexOf('='); + if (separatorIndex < 0 || parameter.slice(0, separatorIndex) !== parameterName) { + continue; + } + return parameter.slice(separatorIndex + 1) === expectedValue; + } + + return false; +} diff --git a/src/valdi_modules/src/valdi/web_renderer/test/LegacyWebDebuggerAdapter.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/LegacyWebDebuggerAdapter.spec.ts new file mode 100644 index 000000000..db8a5cb90 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/LegacyWebDebuggerAdapter.spec.ts @@ -0,0 +1,909 @@ +import 'jasmine/src/jasmine'; +import type { IRenderedElement } from 'valdi_core/src/IRenderedElement'; +import type { IRenderer } from 'valdi_core/src/IRenderer'; +import { + MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS, + ValdiWebRendererDelegate, +} from '../src/ValdiWebRendererDelegate'; +import type { WebValdiLayout } from '../src/views/WebValdiLayout'; + +interface InstalledDom { + createElement(tagName: string): HTMLElement; + restore(): void; +} + +function installDom(): InstalledDom { + const previousGlobals = new Map(); + const globalNames = [ + 'Document', + 'Element', + 'HTMLElement', + 'IntersectionObserver', + 'ResizeObserver', + 'ShadowRoot', + 'customElements', + 'document', + 'window', + ]; + for (const name of globalNames) { + previousGlobals.set(name, (globalThis as Record)[name]); + } + + class FakeElement { + parentElement: FakeHTMLElement | null = null; + } + + class FakeHTMLElement extends FakeElement { + readonly attributesByName = new Map(); + readonly children: FakeHTMLElement[] = []; + readonly childNodes = { item: (index: number) => this.children[index] ?? null }; + readonly classList = { + add: (_className: string) => {}, + remove: (_className: string) => {}, + }; + readonly dataset: Record = {}; + readonly style: Record = { + removeProperty: (_name: string) => {}, + setProperty: (_name: string, _value: string) => {}, + }; + readonly ownerDocument: FakeDocument; + readonly tagName: string; + scrollLeft = 0; + scrollTop = 0; + textContent: string | null = null; + rect = { left: 0, top: 0, width: 0, height: 0 }; + + constructor(tagName: string, ownerDocument: FakeDocument) { + super(); + this.tagName = tagName.toUpperCase(); + this.ownerDocument = ownerDocument; + } + + get attributes() { + const entries = [...this.attributesByName.entries()]; + return { + length: entries.length, + item: (index: number) => { + const entry = entries[index]; + return entry === undefined ? null : { name: entry[0], value: entry[1] }; + }, + }; + } + + get childElementCount(): number { + return this.children.length; + } + + addEventListener(): void {} + removeEventListener(): void {} + appendChild(child: FakeHTMLElement): FakeHTMLElement { + child.parentElement = this; + this.children.push(child); + return child; + } + contains(): boolean { + return false; + } + getAttribute(name: string): string | null { + return this.attributesByName.get(name) ?? null; + } + getBoundingClientRect() { + return { ...this.rect, x: this.rect.left, y: this.rect.top, right: 0, bottom: 0, toJSON: () => ({}) }; + } + getRootNode(): FakeDocument { + return this.ownerDocument; + } + insertBefore(child: FakeHTMLElement, reference: FakeHTMLElement | null): FakeHTMLElement { + child.parentElement = this; + const index = reference === null ? this.children.length : this.children.indexOf(reference); + this.children.splice(index < 0 ? this.children.length : index, 0, child); + return child; + } + remove(): void { + const parent = this.parentElement; + if (parent !== null) { + const index = parent.children.indexOf(this); + if (index >= 0) { + parent.children.splice(index, 1); + } + } + this.parentElement = null; + } + removeAttribute(name: string): void { + this.attributesByName.delete(name); + } + replaceChildren(...children: FakeHTMLElement[]): void { + this.children.splice(0, this.children.length, ...children); + for (const child of children) { + child.parentElement = this; + } + } + setAttribute(name: string, value: string): void { + this.attributesByName.set(name, value); + } + } + + class FakeDocument { + readonly head: FakeHTMLElement; + + constructor() { + this.head = new FakeHTMLElement('head', this); + } + + createElement(tagName: string): FakeHTMLElement { + return new FakeHTMLElement(tagName, this); + } + } + + class FakeShadowRoot extends FakeHTMLElement { + querySelector(): null { + return null; + } + } + + const document = new FakeDocument(); + (document.head as FakeHTMLElement & { querySelector?: () => null }).querySelector = () => null; + (globalThis as Record).Element = FakeElement; + (globalThis as Record).HTMLElement = FakeHTMLElement; + (globalThis as Record).Document = FakeDocument; + (globalThis as Record).ShadowRoot = FakeShadowRoot; + (globalThis as Record).document = document; + (globalThis as Record).window = { innerHeight: 768, innerWidth: 1024, setTimeout, clearTimeout }; + (globalThis as Record).customElements = { define: () => {}, get: () => undefined }; + (globalThis as Record).IntersectionObserver = function () { + return { disconnect: () => {}, observe: () => {}, unobserve: () => {} }; + }; + (globalThis as Record).ResizeObserver = function () { + return { disconnect: () => {}, observe: () => {}, unobserve: () => {} }; + }; + + return { + createElement: tagName => document.createElement(tagName) as unknown as HTMLElement, + restore: () => { + for (const [name, value] of previousGlobals) { + if (value === undefined) { + delete (globalThis as Record)[name]; + } else { + (globalThis as Record)[name] = value; + } + } + }, + }; +} + +function makeRenderedElement( + id: number, + tag: string, + attributes: Record, +): IRenderedElement { + return { + id, + tag, + getAttribute: (name: string) => attributes[name], + getAttributeNames: () => Object.keys(attributes), + } as unknown as IRenderedElement; +} + +function makeRenderer(elements: IRenderedElement[]): IRenderer { + const elementsById = new Map(elements.map(element => [element.id, element])); + return { + getElementForId: id => elementsById.get(id), + } as IRenderer; +} + +function captureSnapshot( + delegate: ValdiWebRendererDelegate, + elements: IRenderedElement[], +) { + return delegate.getDebugSnapshot(makeRenderer(elements), MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); +} + +function getBackingNode(delegate: ValdiWebRendererDelegate, id: number): WebValdiLayout | undefined { + return (delegate as unknown as { nodesRef: Map }).nodesRef.get(id); +} + +function makeMutationMetadata(mutation: () => void): Record { + let hasMutated = false; + return new Proxy( + { trigger: 'value' }, + { + ownKeys: target => { + if (!hasMutated) { + hasMutated = true; + mutation(); + } + return Reflect.ownKeys(target); + }, + }, + ); +} + +function observeIndexedChildReads( + children: WebValdiLayout[], + onIndexedRead: () => void, +): WebValdiLayout[] { + return new Proxy(children, { + get: (target, property, receiver) => { + if (typeof property === 'string' && /^(0|[1-9]\d*)$/.test(property)) { + onIndexedRead(); + } + return Reflect.get(target, property, receiver); + }, + }); +} + +function expectUnicodeSafeTruncation(value: unknown): void { + const marker = '... '; + const stringValue = String(value); + const prefix = stringValue.slice(0, -marker.length); + const lastPrefixCharacter = prefix.charCodeAt(prefix.length - 1); + expect(stringValue).toContain(marker); + expect(stringValue.length).toBeLessThanOrEqual(65_536); + expect(lastPrefixCharacter < 0xd800 || lastPrefixCharacter > 0xdbff).toBeTrue(); +} + +function makeZeroRect(): DOMRect { + return { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }; +} + +describe('ValdiWebRendererDelegate debugger adapter', () => { + let dom: InstalledDom; + + beforeEach(() => { + dom = installDom(); + }); + + afterEach(() => { + dom.restore(); + }); + + it('captures the legacy node tree with DOM bounds and rendered element attributes', () => { + const root = dom.createElement('main'); + const delegate = new ValdiWebRendererDelegate(root); + delegate.onElementCreated(1, 'layout'); + delegate.onElementCreated(2, 'label'); + delegate.onElementBecameRoot(1); + delegate.onElementMoved(2, 1, 0); + + const rootNode = delegate.getDebugNode(1)!; + const labelNode = delegate.getDebugNode(2)!; + expect(rootNode.htmlElement.tagName).toBe('DIV'); + expect(labelNode.htmlElement.tagName).toBe('SPAN'); + rootNode.htmlElement.setAttribute('role', 'main'); + labelNode.htmlElement.setAttribute('aria-label', 'Greeting'); + (rootNode.htmlElement as unknown as { rect: object }).rect = { left: 10, top: 20, width: 300, height: 200 }; + (labelNode.htmlElement as unknown as { rect: object }).rect = { left: 14, top: 28, width: 80, height: 24 }; + + const circular: Record = {}; + circular.self = circular; + const renderedElements = [ + makeRenderedElement(1, 'layout', { backgroundColor: '#fff' }), + makeRenderedElement(2, 'label', { onTap: () => {}, value: 'Hello', metadata: circular }), + ]; + + const snapshot = captureSnapshot(delegate, renderedElements); + + expect(snapshot.viewport).toEqual({ width: 1024, height: 768 }); + expect(snapshot.tree).toEqual( + jasmine.objectContaining({ + id: '1', + tag: 'layout', + bounds: { x: 10, y: 20, width: 300, height: 200 }, + element: jasmine.objectContaining({ + attributes: { backgroundColor: '#fff' }, + dom: { attributes: { role: 'main' }, tagName: 'div' }, + }), + }), + ); + expect(snapshot.tree?.children[0]).toEqual( + jasmine.objectContaining({ + id: '2', + tag: 'label', + bounds: { x: 14, y: 28, width: 80, height: 24 }, + element: jasmine.objectContaining({ + attributes: { onTap: '[function]', value: 'Hello', metadata: { self: '' } }, + dom: { attributes: { 'aria-label': 'Greeting' }, tagName: 'span' }, + }), + }), + ); + }); + + it('keeps debugger lookup scoped to this renderer and removes destroyed nodes', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(7, 'layout'); + + expect(delegate.getDebugNode(7)?.type).toBe('layout'); + + delegate.onElementDestroyed(7); + + expect(delegate.getDebugNode(7)).toBeUndefined(); + }); + + it('unlinks a destroyed child from snapshots and the highlight lookup', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementCreated(2, 'label'); + delegate.onElementBecameRoot(1); + delegate.onElementMoved(2, 1, 0); + + delegate.onElementDestroyed(2); + + const snapshot = captureSnapshot(delegate, [ + makeRenderedElement(1, 'layout', {}), + makeRenderedElement(2, 'label', {}), + ]); + expect(delegate.getDebugNode(2)).toBeUndefined(); + expect(snapshot.tree?.children).toEqual([]); + }); + + it('purges every live descendant when one subtree destroy notification is emitted', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + for (const id of [1, 2, 3, 4]) { + delegate.onElementCreated(id, id === 3 ? 'label' : 'layout'); + } + delegate.onElementBecameRoot(1); + delegate.onElementMoved(2, 1, 0); + delegate.onElementMoved(3, 2, 0); + delegate.onElementMoved(4, 1, 1); + + delegate.onElementDestroyed(2); + + const snapshot = captureSnapshot(delegate, [ + makeRenderedElement(1, 'layout', {}), + makeRenderedElement(2, 'layout', {}), + makeRenderedElement(3, 'label', {}), + makeRenderedElement(4, 'layout', {}), + ]); + expect(delegate.getDebugNode(2)).toBeUndefined(); + expect(delegate.getDebugNode(3)).toBeUndefined(); + expect(snapshot.tree?.children.map(child => child.id)).toEqual(['4']); + }); + + it('preserves a child reparented out of a subsequently destroyed subtree', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + for (const id of [1, 2, 3, 4]) { + delegate.onElementCreated(id, 'layout'); + } + delegate.onElementBecameRoot(1); + delegate.onElementMoved(2, 1, 0); + delegate.onElementMoved(3, 1, 1); + delegate.onElementMoved(4, 2, 0); + delegate.onElementMoved(4, 3, 0); + getBackingNode(delegate, 2)!.children.push(getBackingNode(delegate, 4)!); + + delegate.onElementDestroyed(2); + + const snapshot = captureSnapshot(delegate, [ + makeRenderedElement(1, 'layout', {}), + makeRenderedElement(3, 'layout', {}), + makeRenderedElement(4, 'layout', {}), + ]); + expect(delegate.getDebugNode(4)).toBeDefined(); + expect(snapshot.tree?.children.map(child => child.id)).toEqual(['3']); + expect(snapshot.tree?.children[0].children.map(child => child.id)).toEqual(['4']); + }); + + it('ignores adversarial stale child links for snapshots and highlight lookup', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementCreated(2, 'label'); + delegate.onElementBecameRoot(1); + delegate.onElementMoved(2, 1, 0); + const rootNode = getBackingNode(delegate, 1)!; + const staleChild = getBackingNode(delegate, 2)!; + + delegate.onElementDestroyed(2); + rootNode.children.push(staleChild); + staleChild.parent = rootNode; + const getElementForId = jasmine.createSpy('getElementForId').and.callFake((id: number) => + makeRenderedElement(id, id === 1 ? 'layout' : 'label', {}), + ); + const renderer = { getElementForId } as unknown as IRenderer; + + const snapshot = delegate.getDebugSnapshot(renderer, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); + + expect(delegate.getDebugNode(2)).toBeUndefined(); + expect(snapshot.tree?.children).toEqual([]); + expect(getElementForId.calls.count()).toBe(1); + expect(getElementForId).toHaveBeenCalledWith(1); + }); + + it('continues safely when metadata destroys a later sibling during capture', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + for (const id of [1, 2, 3, 4]) { + delegate.onElementCreated(id, 'layout'); + } + delegate.onElementBecameRoot(1); + delegate.onElementMoved(2, 1, 0); + delegate.onElementMoved(3, 1, 1); + delegate.onElementMoved(4, 1, 2); + const metadata = makeMutationMetadata(() => delegate.onElementDestroyed(3)); + + const snapshot = captureSnapshot(delegate, [ + makeRenderedElement(1, 'layout', {}), + makeRenderedElement(2, 'layout', { metadata }), + makeRenderedElement(3, 'layout', {}), + makeRenderedElement(4, 'layout', {}), + ]); + + expect(delegate.getDebugNode(3)).toBeUndefined(); + expect(snapshot.tree?.children.map(child => child.id)).toEqual(['2', '4']); + }); + + it('continues safely when metadata detaches a later sibling during capture', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + for (const id of [1, 2, 3, 4]) { + delegate.onElementCreated(id, 'layout'); + } + delegate.onElementBecameRoot(1); + delegate.onElementMoved(2, 1, 0); + delegate.onElementMoved(3, 1, 1); + delegate.onElementMoved(4, 1, 2); + const rootNode = getBackingNode(delegate, 1)!; + const detachedNode = getBackingNode(delegate, 3)!; + const metadata = makeMutationMetadata(() => { + rootNode.removeChild(detachedNode); + detachedNode.parent = null; + }); + + const snapshot = captureSnapshot(delegate, [ + makeRenderedElement(1, 'layout', {}), + makeRenderedElement(2, 'layout', { metadata }), + makeRenderedElement(3, 'layout', {}), + makeRenderedElement(4, 'layout', {}), + ]); + + expect(delegate.getDebugNode(3)).toBeDefined(); + expect(snapshot.tree?.children.map(child => child.id)).toEqual(['2', '4']); + }); + + it('continues safely when metadata reparents a later sibling during capture', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + for (const id of [1, 2, 3, 4]) { + delegate.onElementCreated(id, 'layout'); + } + delegate.onElementBecameRoot(1); + delegate.onElementMoved(2, 1, 0); + delegate.onElementMoved(3, 1, 1); + delegate.onElementMoved(4, 1, 2); + const metadata = makeMutationMetadata(() => delegate.onElementMoved(3, 4, 0)); + + const snapshot = captureSnapshot(delegate, [ + makeRenderedElement(1, 'layout', {}), + makeRenderedElement(2, 'layout', { metadata }), + makeRenderedElement(3, 'layout', {}), + makeRenderedElement(4, 'layout', {}), + ]); + + expect(snapshot.tree?.children.map(child => child.id)).toEqual(['2', '4']); + expect(snapshot.tree?.children[1].children.map(child => child.id)).toEqual(['3']); + }); + + it('bounds work for a wide array containing only stale child links', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementCreated(2, 'layout'); + delegate.onElementBecameRoot(1); + delegate.onElementMoved(2, 1, 0); + const rootNode = getBackingNode(delegate, 1)!; + const staleNode = getBackingNode(delegate, 2)!; + delegate.onElementDestroyed(2); + staleNode.parent = rootNode; + let indexedReads = 0; + rootNode.children = observeIndexedChildReads( + new Array(20_000).fill(staleNode), + () => indexedReads++, + ); + const getElementForId = jasmine.createSpy('getElementForId').and.callFake((id: number) => + makeRenderedElement(id, 'layout', {}), + ); + + const snapshot = delegate.getDebugSnapshot( + { getElementForId } as unknown as IRenderer, + MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS, + ); + + expect(indexedReads).toBe(1_000); + expect(getElementForId.calls.count()).toBe(1); + expect(snapshot.tree?.children).toEqual([]); + expect(snapshot.tree?.childrenTruncated).toBeTrue(); + }); + + it('includes mixed live and stale links at the work cap and truncates beyond it', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + for (const id of [1, 2, 3]) { + delegate.onElementCreated(id, 'layout'); + } + delegate.onElementBecameRoot(1); + delegate.onElementMoved(2, 1, 0); + delegate.onElementMoved(3, 1, 1); + const rootNode = getBackingNode(delegate, 1)!; + const liveNode = getBackingNode(delegate, 2)!; + const staleNode = getBackingNode(delegate, 3)!; + delegate.onElementDestroyed(3); + staleNode.parent = rootNode; + const renderer = makeRenderer([ + makeRenderedElement(1, 'layout', {}), + makeRenderedElement(2, 'layout', {}), + ]); + let atCapReads = 0; + rootNode.children = observeIndexedChildReads( + [...new Array(999).fill(staleNode), liveNode], + () => atCapReads++, + ); + + const atCapSnapshot = delegate.getDebugSnapshot(renderer, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); + + expect(atCapReads).toBe(1_000); + expect(atCapSnapshot.tree?.children.map(child => child.id)).toEqual(['2']); + expect(atCapSnapshot.tree?.childrenTruncated).toBeUndefined(); + + let overCapReads = 0; + rootNode.children = observeIndexedChildReads( + [...new Array(1_000).fill(staleNode), liveNode], + () => overCapReads++, + ); + + const overCapSnapshot = delegate.getDebugSnapshot(renderer, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); + + expect(overCapReads).toBe(1_000); + expect(overCapSnapshot.tree?.children).toEqual([]); + expect(overCapSnapshot.tree?.childrenTruncated).toBeTrue(); + }); + + it('does not invoke getters and distinguishes shared references from cycles', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + let getterCalls = 0; + const dangerous: Record = {}; + Object.defineProperty(dangerous, 'secret', { + enumerable: true, + get: () => { + getterCalls++; + return 'should not be read'; + }, + }); + const shared = { value: 'shared' }; + const metadata: Record = { dangerous, first: shared, second: shared }; + metadata.self = metadata; + Object.defineProperty(metadata, '__proto__', { enumerable: true, value: 'own property' }); + + const snapshot = captureSnapshot(delegate, [makeRenderedElement(1, 'layout', { metadata })]); + const debugMetadata = snapshot.tree?.element.attributes.metadata as Record; + + expect(getterCalls).toBe(0); + expect(debugMetadata.dangerous).toEqual({ secret: '' }); + expect(debugMetadata.first).toEqual({ value: 'shared' }); + expect(debugMetadata.second).toEqual({ value: 'shared' }); + expect(debugMetadata.self).toBe(''); + expect(Object.prototype.hasOwnProperty.call(debugMetadata, '__proto__')).toBeTrue(); + expect(debugMetadata['__proto__']).toBe('own property'); + }); + + it('inspects only the capped prefix of large sparse arrays without invoking getters', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + let getterCalls = 0; + const sparseArray: unknown[] = []; + sparseArray.length = 1_000_000; + for (const index of [0, 49, 50]) { + Object.defineProperty(sparseArray, String(index), { + enumerable: true, + get: () => { + getterCalls++; + return `secret-${index}`; + }, + }); + } + const inspectedProperties: string[] = []; + const inspectedArray = new Proxy(sparseArray, { + getOwnPropertyDescriptor: (target, property) => { + inspectedProperties.push(String(property)); + return Reflect.getOwnPropertyDescriptor(target, property); + }, + }); + + const snapshot = captureSnapshot(delegate, [ + makeRenderedElement(1, 'layout', { items: inspectedArray }), + ]); + const debugItems = snapshot.tree?.element.attributes.items as unknown[]; + + expect(getterCalls).toBe(0); + expect(inspectedProperties).toEqual([ + 'length', + ...Array.from({ length: 50 }, (_value, index) => String(index)), + ]); + expect(inspectedProperties).not.toContain('50'); + expect(debugItems[0]).toBe(''); + expect(debugItems[49]).toBe(''); + expect(debugItems[50]).toBe('999950 more items'); + }); + + it('caps attribute count, individual strings, and the aggregate snapshot budget', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + const attributes: Record = {}; + for (let index = 0; index < 60; index++) { + attributes[`value${index}`] = 'x'.repeat(100_000); + } + + const snapshot = captureSnapshot(delegate, [makeRenderedElement(1, 'layout', attributes)]); + const debugAttributes = snapshot.tree?.element.attributes ?? {}; + + expect(String(debugAttributes.value0).length).toBeLessThanOrEqual(65_536); + expect(String(debugAttributes.value0)).toContain(''); + expect(debugAttributes.__truncated__).toBeDefined(); + expect(JSON.stringify(debugAttributes).length).toBeLessThan(264_000); + }); + + it('truncates rendered, nested, and DOM attribute strings at Unicode boundaries', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + const unicodeBoundaryValue = `${'a'.repeat(65_520)}${'๐Ÿ˜€'.repeat(10_000)}`; + delegate.getDebugNode(1)!.htmlElement.setAttribute('data-emoji', unicodeBoundaryValue); + + const snapshot = captureSnapshot(delegate, [ + makeRenderedElement(1, 'layout', { + metadata: { nested: unicodeBoundaryValue }, + value: unicodeBoundaryValue, + }), + ]); + const attributes = snapshot.tree!.element.attributes; + const nested = attributes.metadata as Record; + + expectUnicodeSafeTruncation(attributes.value); + expectUnicodeSafeTruncation(nested.nested); + expectUnicodeSafeTruncation(snapshot.tree!.element.dom.attributes['data-emoji']); + expect(JSON.stringify(snapshot).length).toBeLessThanOrEqual(MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); + }); + + it('honors a smaller serialized-character budget assigned by the standalone envelope', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + const renderer = makeRenderer([ + makeRenderedElement(1, 'layout', { payload: 'x'.repeat(100_000) }), + ]); + + const snapshot = delegate.getDebugSnapshot(renderer, 8_192); + + expect(JSON.stringify(snapshot).length).toBeLessThanOrEqual(8_192); + expect(String(snapshot.tree?.element.attributes.payload)).toContain(''); + }); + + it('bounds the complete serialized snapshot and omits over-budget property names before insertion', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + const hugeKey = `key-${'k'.repeat(2_000_000)}`; + const hugeValue = `value-${'v'.repeat(2_000_000)}`; + let getterCalls = 0; + const metadata: Record = {}; + Object.defineProperty(metadata, hugeKey, { + enumerable: true, + get: () => { + getterCalls++; + return 'secret'; + }, + }); + const attributeReads: string[] = []; + const element = { + id: 1, + tag: 'layout', + getAttribute: (name: string) => { + attributeReads.push(name); + return name === 'hugeValue' ? hugeValue : metadata; + }, + getAttributeNames: () => ['hugeValue', 'metadata', hugeKey], + } as unknown as IRenderedElement; + delegate.getDebugNode(1)!.htmlElement.setAttribute(hugeKey, hugeValue); + + const snapshot = captureSnapshot(delegate, [element]); + const serializedSnapshot = JSON.stringify(snapshot); + const debugAttributes = snapshot.tree?.element.attributes ?? {}; + + expect(serializedSnapshot.length).toBeLessThanOrEqual(MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); + expect(String(debugAttributes.hugeValue).length).toBeLessThanOrEqual(65_536); + expect(String(debugAttributes.hugeValue)).toContain(''); + expect((debugAttributes.metadata as Record).__truncated__).toBeDefined(); + expect(Object.prototype.hasOwnProperty.call(debugAttributes, hugeKey)).toBeFalse(); + expect(Object.prototype.hasOwnProperty.call(snapshot.tree?.element.dom.attributes ?? {}, hugeKey)).toBeFalse(); + expect(attributeReads).not.toContain(hugeKey); + expect(getterCalls).toBe(0); + }); + + it('caps nested collection entries and depth', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + const properties: Record = {}; + for (let index = 0; index < 52; index++) { + properties[`property${index}`] = index; + } + let tooDeepGetterCalls = 0; + const tooDeepObject: Record = {}; + Object.defineProperty(tooDeepObject, 'secret', { + enumerable: true, + get: () => { + tooDeepGetterCalls++; + return 'should not be read'; + }, + }); + const nested = { + level1: { + level2: { + boundary: 'visible', + level3: { + level4: 'too-deep', + objectAtDepthLimit: tooDeepObject, + }, + }, + }, + }; + + const snapshot = captureSnapshot(delegate, [ + makeRenderedElement(1, 'layout', { nested, properties }), + ]); + const debugAttributes = snapshot.tree?.element.attributes ?? {}; + const debugProperties = debugAttributes.properties as Record; + + expect(debugProperties.property49).toBe(49); + expect(debugProperties.property50).toBeUndefined(); + expect(debugProperties.__truncated__).toBe('more fields'); + expect(debugAttributes.nested).toEqual({ + level1: { + level2: { + boundary: 'visible', + level3: { + level4: '... ', + objectAtDepthLimit: '... ', + }, + }, + }, + }); + expect(tooDeepGetterCalls).toBe(0); + }); + + it('bounds wide backing-tree work without materializing eager renderer children', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + let renderedNodeReads = 0; + let eagerElementChildrenReads = 0; + let eagerVirtualChildrenReads = 0; + const attributeReads = jasmine.createSpy('attributeReads').and.returnValue([]); + const renderedElement = { + id: 1, + get children(): IRenderedElement[] { + eagerElementChildrenReads++; + return new Array(20_000).fill(undefined); + }, + getAttribute: () => undefined, + getAttributeNames: attributeReads, + } as unknown as IRenderedElement; + const adversarialRootVirtualNode = {} as Record; + Object.defineProperty(adversarialRootVirtualNode, 'children', { + get: () => { + eagerVirtualChildrenReads++; + return new Array(20_000).fill(adversarialRootVirtualNode); + }, + }); + const getElementForId = jasmine.createSpy('getElementForId').and.returnValue(renderedElement); + const getRootVirtualNode = jasmine + .createSpy('getRootVirtualNode') + .and.returnValue(adversarialRootVirtualNode); + const renderer = { getElementForId, getRootVirtualNode } as unknown as IRenderer; + + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + delegate.getDebugNode(1)!.htmlElement.getBoundingClientRect = () => { + renderedNodeReads++; + return makeZeroRect(); + }; + for (let id = 2; id <= 1_101; id++) { + delegate.onElementCreated(id, 'label'); + delegate.onElementMoved(id, 1, id - 2); + delegate.getDebugNode(id)!.htmlElement.getBoundingClientRect = () => { + renderedNodeReads++; + return makeZeroRect(); + }; + } + + const snapshot = delegate.getDebugSnapshot(renderer, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); + + expect(renderedNodeReads).toBe(1_000); + expect(getElementForId.calls.count()).toBe(1_000); + expect(attributeReads.calls.count()).toBe(1_000); + expect(getRootVirtualNode).not.toHaveBeenCalled(); + expect(eagerElementChildrenReads).toBe(0); + expect(eagerVirtualChildrenReads).toBe(0); + expect(snapshot.tree?.children.length).toBe(999); + expect(snapshot.tree?.childrenTruncated).toBeTrue(); + expect(JSON.stringify(snapshot).length).toBeLessThanOrEqual(MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); + }); + + it('stops deeply nested backing trees without consulting recursive virtual children', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + for (let id = 2; id <= 100; id++) { + delegate.onElementCreated(id, 'layout'); + delegate.onElementMoved(id, id - 1, 0); + } + + let eagerChildrenReads = 0; + const renderedElement = makeRenderedElement(1, 'layout', {}); + Object.defineProperty(renderedElement, 'children', { + get: () => { + eagerChildrenReads++; + return new Array(20_000).fill(renderedElement); + }, + }); + const getElementForId = jasmine.createSpy('getElementForId').and.returnValue(renderedElement); + const renderer = { getElementForId } as unknown as IRenderer; + const snapshot = delegate.getDebugSnapshot(renderer, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); + let capturedDepth = 0; + let deepestNode = snapshot.tree; + while (deepestNode !== null && deepestNode !== undefined) { + capturedDepth++; + if (deepestNode.children.length === 0) { + break; + } + deepestNode = deepestNode.children[0]; + } + + expect(capturedDepth).toBe(64); + expect(getElementForId.calls.count()).toBe(64); + expect(eagerChildrenReads).toBe(0); + expect(deepestNode?.id).toBe('64'); + expect(deepestNode?.childrenTruncated).toBeTrue(); + }); + + it('does not visit descendants after the aggregate character budget is exhausted', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementCreated(2, 'label'); + delegate.onElementBecameRoot(1); + delegate.onElementMoved(2, 1, 0); + const attributes: Record = {}; + for (let index = 0; index < 5; index++) { + attributes[`value${index}`] = 'x'.repeat(100_000); + } + const rootElement = makeRenderedElement(1, 'layout', attributes); + const childElement = makeRenderedElement(2, 'label', {}); + const childAttributeReads = jasmine.createSpy('childAttributeReads').and.returnValue([]); + childElement.getAttributeNames = childAttributeReads; + + const snapshot = captureSnapshot(delegate, [rootElement, childElement]); + + expect(childAttributeReads).not.toHaveBeenCalled(); + expect(snapshot.tree?.children).toEqual([]); + expect(snapshot.tree?.childrenTruncated).toBeTrue(); + }); + + it('returns an empty snapshot until a root is mounted and after it is destroyed', () => { + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + const renderer = makeRenderer([]); + + expect(delegate.getDebugSnapshot(renderer, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS).tree).toBeNull(); + + delegate.onElementBecameRoot(1); + expect(delegate.getDebugSnapshot(renderer, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS).tree?.id).toBe('1'); + + delegate.onElementDestroyed(1); + expect(delegate.getDebugSnapshot(renderer, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS).tree).toBeNull(); + }); +}); 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 new file mode 100644 index 000000000..1cc5363c2 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/LocationQuery.spec.ts @@ -0,0 +1,30 @@ +import 'jasmine/src/jasmine'; +import { hasWebLocationQueryParameter } from '../src/utils/LocationQuery'; + +describe('hasWebLocationQueryParameter', () => { + it('matches an explicitly enabled development flag', () => { + expect(hasWebLocationQueryParameter('?valdiDebugger=1', 'valdiDebugger', '1')).toBeTrue(); + }); + + it('finds a development flag among other application parameters', () => { + expect(hasWebLocationQueryParameter('?fixture=Text_0&valdiTrace=chrome', 'valdiTrace', 'chrome')).toBeTrue(); + }); + + it('does not enable missing or differently valued flags', () => { + expect(hasWebLocationQueryParameter(undefined, 'valdiTrace', 'chrome')).toBeFalse(); + expect(hasWebLocationQueryParameter('?valdiTrace=disabled', 'valdiTrace', 'chrome')).toBeFalse(); + expect(hasWebLocationQueryParameter('?fixture=Text_0', 'valdiTrace', 'chrome')).toBeFalse(); + }); + + it('uses the first occurrence of a query flag', () => { + expect(hasWebLocationQueryParameter('?valdiDebugger=0&valdiDebugger=1', 'valdiDebugger', '1')).toBeFalse(); + }); + + it('matches query strings without a leading question mark', () => { + expect(hasWebLocationQueryParameter('valdiOwlDebugger=1', 'valdiOwlDebugger', '1')).toBeTrue(); + }); + + it('does not confuse a parameter prefix for an exact query flag', () => { + expect(hasWebLocationQueryParameter('?valdiDebuggerExtra=1', 'valdiDebugger', '1')).toBeFalse(); + }); +}); diff --git a/src/valdi_modules/src/valdi/web_renderer/test/WebDebuggerBridge.spec.ts b/src/valdi_modules/src/valdi/web_renderer/test/WebDebuggerBridge.spec.ts new file mode 100644 index 000000000..c8c097d59 --- /dev/null +++ b/src/valdi_modules/src/valdi/web_renderer/test/WebDebuggerBridge.spec.ts @@ -0,0 +1,314 @@ +import 'jasmine/src/jasmine'; +import type { IRenderer } from 'valdi_core/src/IRenderer'; +import type { ValdiWebRendererDelegate } from '../src/ValdiWebRendererDelegate'; +import { MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS } from '../src/ValdiWebRendererDelegate'; +import type { StandaloneWebDebuggerRuntime } from '../src/debug/WebDebuggerBridge'; +import { WebDebuggerBridge } from '../src/debug/WebDebuggerBridge'; + +interface FakeDebuggerWindow { + __VALDI_WEB_DEBUGGER__?: StandaloneWebDebuggerRuntime; + addEventListener: jasmine.Spy; + location: { + href: string; + search: string; + }; + parent: FakeDebuggerWindow | { postMessage: jasmine.Spy }; + removeEventListener: jasmine.Spy; +} + +describe('WebDebuggerBridge legacy renderer adapter', () => { + let previousWindow: unknown; + let previousDocument: unknown; + let previousElement: unknown; + let fakeWindow: FakeDebuggerWindow; + let delegate: ValdiWebRendererDelegate; + let renderer: IRenderer; + let appendedOverlays: FakeElement[]; + + class FakeElement { + readonly children: FakeElement[] = []; + readonly dataset: Record = {}; + readonly style: Record = {}; + parentElement: FakeElement | null = null; + removed = false; + textContent = ''; + + appendChild(child: FakeElement): FakeElement { + child.parentElement = this; + this.children.push(child); + return child; + } + + getBoundingClientRect() { + return { left: 11, top: 22, width: 120, height: 44 }; + } + + remove(): void { + this.removed = true; + } + + setAttribute(): void {} + } + + beforeEach(() => { + previousWindow = (globalThis as { window?: unknown }).window; + previousDocument = (globalThis as { document?: unknown }).document; + previousElement = (globalThis as { Element?: unknown }).Element; + appendedOverlays = []; + fakeWindow = { + addEventListener: jasmine.createSpy('addEventListener'), + location: { + href: 'http://127.0.0.1:54321/?valdiDebugger=1&valdiOwlDebugger=1', + search: '?valdiDebugger=1&valdiOwlDebugger=1', + }, + parent: undefined as unknown as FakeDebuggerWindow, + removeEventListener: jasmine.createSpy('removeEventListener'), + }; + fakeWindow.parent = fakeWindow; + (globalThis as { Element?: unknown }).Element = FakeElement; + (globalThis as { window?: unknown }).window = fakeWindow; + (globalThis as { document?: unknown }).document = { + body: { + appendChild: (element: FakeElement) => appendedOverlays.push(element), + }, + createElement: () => new FakeElement(), + title: 'Valdi Owl sample', + }; + delegate = { + getDebugNode: () => undefined, + getDebugSnapshot: () => ({ + tree: null, + viewport: { width: 640, height: 480 }, + }), + } as unknown as ValdiWebRendererDelegate; + renderer = { + getElementForId: () => undefined, + getRootVirtualNode: jasmine + .createSpy('getRootVirtualNode') + .and.throwError('The debugger must not materialize virtual children.'), + } as unknown as IRenderer; + }); + + afterEach(() => { + restoreGlobal('window', previousWindow); + restoreGlobal('document', previousDocument); + restoreGlobal('Element', previousElement); + }); + + it('exposes the real top-level Owl renderer only after explicit debugger opt-in', () => { + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + + expect(fakeWindow.__VALDI_WEB_DEBUGGER__?.getSnapshot()).toEqual({ + channel: 'valdi-web-debugger', + source: { title: 'Valdi Owl sample', url: fakeWindow.location.href }, + snapshot: { tree: null, viewport: { width: 640, height: 480 } }, + type: 'snapshot', + }); + + bridge.destroy(); + expect(fakeWindow.__VALDI_WEB_DEBUGGER__).toBeUndefined(); + }); + + it('bounds Unicode source metadata and the complete standalone envelope', () => { + const metadataMarker = '... '; + const fakeDocument = (globalThis as unknown as { document: { title: string } }).document; + fakeDocument.title = '๐Ÿ˜€'.repeat(100_000); + fakeWindow.location.href = `http://127.0.0.1:54321/?title=${'๐Ÿฆ‰'.repeat(100_000)}`; + const getDebugSnapshot = jasmine.createSpy('getDebugSnapshot').and.returnValue({ + tree: null, + viewport: { width: 640, height: 480 }, + }); + delegate.getDebugSnapshot = getDebugSnapshot; + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + + const response = fakeWindow.__VALDI_WEB_DEBUGGER__!.getSnapshot(); + const titlePrefix = response.source.title.slice(0, -metadataMarker.length); + const urlPrefix = response.source.url.slice(0, -metadataMarker.length); + + expect(JSON.stringify(response).length).toBeLessThanOrEqual(MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); + expect(JSON.stringify(response.source.title).length).toBeLessThanOrEqual(16_384); + expect(JSON.stringify(response.source.url).length).toBeLessThanOrEqual(16_384); + expect(response.source.title).toContain(metadataMarker); + expect(response.source.url).toContain(metadataMarker); + const lastTitleCharacter = titlePrefix.charCodeAt(titlePrefix.length - 1); + const lastUrlCharacter = urlPrefix.charCodeAt(urlPrefix.length - 1); + expect(lastTitleCharacter < 0xd800 || lastTitleCharacter > 0xdbff).toBeTrue(); + expect(lastUrlCharacter < 0xd800 || lastUrlCharacter > 0xdbff).toBeTrue(); + expect(getDebugSnapshot).toHaveBeenCalledWith(renderer, jasmine.any(Number)); + expect(getDebugSnapshot.calls.mostRecent().args[1]).toBeLessThan(MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); + expect(renderer.getRootVirtualNode as unknown as jasmine.Spy).not.toHaveBeenCalled(); + + bridge.destroy(); + }); + + it('enforces the final envelope ceiling when a delegate exceeds its assigned snapshot budget', () => { + delegate.getDebugSnapshot = jasmine.createSpy('getDebugSnapshot').and.returnValue({ + tree: { + id: '1', + tag: 'layout', + element: { + id: 1, + attributes: { payload: 'x'.repeat(300_000) }, + dom: { attributes: {}, tagName: 'div' }, + }, + bounds: { x: 0, y: 0, width: 0, height: 0 }, + children: [], + }, + viewport: { width: 640, height: 480 }, + }); + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + + const response = fakeWindow.__VALDI_WEB_DEBUGGER__!.getSnapshot(); + + expect(response.snapshot.tree).toBeNull(); + expect(JSON.stringify(response).length).toBeLessThanOrEqual(MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); + expect(renderer.getRootVirtualNode as unknown as jasmine.Spy).not.toHaveBeenCalled(); + bridge.destroy(); + }); + + it('supports the exact top-level Chromium DevTools CLI flag contract', () => { + fakeWindow.location.href = 'http://127.0.0.1:54321/?valdiDebugger=1&valdiDevTools=1'; + fakeWindow.location.search = '?valdiDebugger=1&valdiDevTools=1'; + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + + expect(fakeWindow.__VALDI_WEB_DEBUGGER__?.getSnapshot()).toEqual( + jasmine.objectContaining({ channel: 'valdi-web-debugger' }), + ); + + bridge.destroy(); + expect(fakeWindow.__VALDI_WEB_DEBUGGER__).toBeUndefined(); + }); + + it('does not expose standalone inspection without both debugger and host opt-ins', () => { + for (const search of ['?valdiDebugger=1', '?valdiOwlDebugger=1', '?valdiDevTools=1']) { + fakeWindow.location.search = search; + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + expect(fakeWindow.__VALDI_WEB_DEBUGGER__).toBeUndefined(); + bridge.destroy(); + } + expect(fakeWindow.addEventListener).not.toHaveBeenCalled(); + }); + + it('disables debugger exposure and messaging entirely in embedded frames', () => { + const parent = { postMessage: jasmine.createSpy('postMessage') }; + fakeWindow.parent = parent; + fakeWindow.location.search = '?valdiDebugger=1&valdiDevTools=1'; + + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + + expect(fakeWindow.__VALDI_WEB_DEBUGGER__).toBeUndefined(); + expect(fakeWindow.addEventListener).not.toHaveBeenCalled(); + expect(parent.postMessage).not.toHaveBeenCalled(); + bridge.destroy(); + expect(fakeWindow.removeEventListener).not.toHaveBeenCalled(); + }); + + it('highlights only safe legacy renderer node ids through the standalone runtime', () => { + const htmlElement = new FakeElement(); + const getDebugNode = jasmine.createSpy('getDebugNode').and.callFake((id: number) => + id === 9 ? ({ htmlElement: htmlElement as unknown as HTMLElement, type: 'label' } as const) : undefined, + ); + delegate.getDebugNode = getDebugNode; + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + const runtime = fakeWindow.__VALDI_WEB_DEBUGGER__!; + + for (const invalidNodeId of ['', ' 9 ', '+9', '-1', '1.5', '1e1', '9007199254740992']) { + expect(runtime.highlightNode?.(invalidNodeId)).toBeFalse(); + } + expect(getDebugNode).not.toHaveBeenCalled(); + expect(appendedOverlays.length).toBe(0); + + expect(runtime.highlightNode?.('9')).toBeTrue(); + expect(getDebugNode).toHaveBeenCalledWith(9); + expect(getDebugNode.calls.count()).toBe(1); + expect(appendedOverlays.length).toBe(1); + expect(appendedOverlays[0].dataset['valdiDebuggerOverlay']).toBe('9'); + expect(appendedOverlays[0].children[0].textContent).toBe('label ยท 120 ร— 44'); + expect(runtime.highlightNode?.('missing')).toBeFalse(); + expect(runtime.clearHighlight?.()).toBeTrue(); + expect(appendedOverlays[0].removed).toBeTrue(); + expect(runtime.clearHighlight?.()).toBeFalse(); + bridge.destroy(); + }); + + it('makes retained runtime handles inert after bridge destruction', () => { + const htmlElement = new FakeElement(); + const getDebugNode = jasmine.createSpy('getDebugNode').and.returnValue({ + htmlElement: htmlElement as unknown as HTMLElement, + type: 'label', + }); + const getDebugSnapshot = jasmine.createSpy('getDebugSnapshot').and.returnValue({ + tree: null, + viewport: { width: 640, height: 480 }, + }); + delegate.getDebugNode = getDebugNode; + delegate.getDebugSnapshot = getDebugSnapshot; + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + const retainedRuntime = fakeWindow.__VALDI_WEB_DEBUGGER__!; + expect(retainedRuntime.highlightNode?.('9')).toBeTrue(); + + bridge.destroy(); + + expect(appendedOverlays[0].removed).toBeTrue(); + expect(() => retainedRuntime.getSnapshot()).toThrowError('Web debugger runtime has been destroyed.'); + expect(retainedRuntime.highlightNode?.('9')).toBeFalse(); + expect(retainedRuntime.clearHighlight?.()).toBeFalse(); + expect(getDebugSnapshot).not.toHaveBeenCalled(); + expect(getDebugNode.calls.count()).toBe(1); + expect(appendedOverlays.length).toBe(1); + }); + + it('restores prior ownership and leaves newer runtimes intact', () => { + const previousRuntime = makeRuntime('Previous renderer'); + fakeWindow.__VALDI_WEB_DEBUGGER__ = previousRuntime; + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + expect(fakeWindow.__VALDI_WEB_DEBUGGER__).not.toBe(previousRuntime); + + bridge.destroy(); + + expect(fakeWindow.__VALDI_WEB_DEBUGGER__).toBe(previousRuntime); + + const secondBridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + const replacement = makeRuntime('Replacement renderer'); + fakeWindow.__VALDI_WEB_DEBUGGER__ = replacement; + secondBridge.destroy(); + expect(fakeWindow.__VALDI_WEB_DEBUGGER__).toBe(replacement); + }); + + it('does not restore a destroyed bridge runtime when nested bridges tear down out of order', () => { + const firstBridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + const firstRuntime = fakeWindow.__VALDI_WEB_DEBUGGER__; + const secondBridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + const secondRuntime = fakeWindow.__VALDI_WEB_DEBUGGER__; + + expect(firstRuntime).toBeDefined(); + expect(secondRuntime).toBeDefined(); + expect(secondRuntime).not.toBe(firstRuntime); + + firstBridge.destroy(); + expect(fakeWindow.__VALDI_WEB_DEBUGGER__).toBe(secondRuntime); + expect(() => firstRuntime!.getSnapshot()).toThrowError('Web debugger runtime has been destroyed.'); + + secondBridge.destroy(); + expect(fakeWindow.__VALDI_WEB_DEBUGGER__).toBeUndefined(); + }); + + function makeRuntime(title: string): StandaloneWebDebuggerRuntime { + return { + getSnapshot: () => ({ + channel: 'valdi-web-debugger', + source: { title, url: fakeWindow.location.href }, + snapshot: { tree: null, viewport: { width: 1, height: 1 } }, + type: 'snapshot', + }), + }; + } +}); + +function restoreGlobal(name: string, value: unknown): void { + if (value === undefined) { + delete (globalThis as Record)[name]; + } else { + (globalThis as Record)[name] = value; + } +}