From 2dd01e3c87ad14bd6c28a7e25b31d471bed587f3 Mon Sep 17 00:00:00 2001 From: Ben Dodson Date: Wed, 26 Aug 2026 06:30:57 -0700 Subject: [PATCH] feat(debugger): add exact component property edit protocol --- npm_modules/cli/src/debugger/server.spec.ts | 282 +++++- npm_modules/cli/src/debugger/server.ts | 132 ++- .../cli/src/debugger/targetRegistry.spec.ts | 7 +- .../cli/src/debugger/targetRegistry.ts | 1 + .../src/valdi/valdi_core/src/IRenderer.ts | 14 + .../src/valdi/valdi_core/src/Renderer.ts | 363 ++++++- .../valdi/valdi_test/test/Renderer.spec.ts | 929 ++++++++++++++++++ .../src/ValdiWebRendererDelegate.ts | 21 +- .../src/debug/ComponentHierarchySnapshot.ts | 213 +++- .../src/debug/WebDebuggerBridge.ts | 379 ++++++- .../test/LegacyWebDebuggerAdapter.spec.ts | 214 ++++ .../test/WebDebuggerBridge.spec.ts | 706 ++++++++++++- 12 files changed, 3234 insertions(+), 27 deletions(-) diff --git a/npm_modules/cli/src/debugger/server.spec.ts b/npm_modules/cli/src/debugger/server.spec.ts index 07e8e241..cd4e07f0 100644 --- a/npm_modules/cli/src/debugger/server.spec.ts +++ b/npm_modules/cli/src/debugger/server.spec.ts @@ -53,6 +53,7 @@ interface MockDaemon { interface MockChromiumConsoleServer { close: () => Promise; debuggerSockets: Set; + expressions: string[]; methods: string[]; port: number; runtimeEnableReceived: Promise; @@ -63,10 +64,13 @@ interface MockChromiumConsoleServer { interface MockChromiumConsoleServerOptions { closeOnTracingEnd?: boolean; + componentPropertyValue?: string; dropTracingStartResponse?: boolean; holdRuntimeEnable: boolean; rejectIdentityAfterTracingStart?: boolean; rejectTracingStart?: boolean; + componentPropertyEditingAvailable?: boolean; + componentPropertyEditResult?: boolean; } function encodeChromiumServerMessage(payload: Record): Buffer { @@ -119,6 +123,7 @@ async function startMockChromiumConsoleServer( const debuggerSockets = new Set(); const pendingRuntimeEnableResponses: Array<() => void> = []; const methods: string[] = []; + const expressions: string[] = []; let currentInspectedUrl = `${applicationUrl}${applicationUrl.includes('?') ? '&' : '?'}valdiDevTools=1`; let currentTargetNonce = targetNonce; let resolveRuntimeEnableReceived: (() => void) | null = null; @@ -180,6 +185,7 @@ async function startMockChromiumConsoleServer( switch (method) { case 'Runtime.evaluate': { const expression = typeof params?.['expression'] === 'string' ? params['expression'] : ''; + expressions.push(expression); const guarded = expression.includes('__valdiDevToolsTargetMatched'); const matched = guarded && @@ -188,17 +194,36 @@ async function startMockChromiumConsoleServer( !(options.rejectIdentityAfterTracingStart === true && tracingStarted); let value: unknown; if (guarded) { + const componentPropertyValue = options.componentPropertyValue; const guardedValue = expression.includes('__VALDI_WEB_DEBUGGER__?.getSnapshot()') ? { channel: 'valdi-web-debugger', + componentPropertyEditingAvailable: options.componentPropertyEditingAvailable !== false, selectedNodeId: 'web-root', snapshot: { - tree: { children: [], id: 'web-root', tag: 'WebRoot' }, + tree: + componentPropertyValue === undefined + ? { children: [], id: 'web-root', tag: 'WebRoot' } + : { + children: [], + component: { + key: 'root', + name: 'WebRoot', + properties: { title: componentPropertyValue }, + propertyEdits: { + title: { componentToken: 'a'.repeat(32), snapshotRevision: 3 }, + }, + }, + id: 'component:[null,"root"]', + tag: 'WebRoot', + }, viewport: { height: 800, width: 1200 }, }, type: 'snapshot', } - : true; + : expression.includes('__VALDI_WEB_DEBUGGER__?.editComponentProperty?.') + ? options.componentPropertyEditResult !== false + : true; value = { __valdiDevToolsTargetMatched: matched, ...(matched ? { value: guardedValue } : {}) }; } else if (expression === 'String(globalThis.location.href)') { value = currentInspectedUrl; @@ -349,6 +374,7 @@ async function startMockChromiumConsoleServer( await closeServer(server); }, debuggerSockets, + expressions, methods, port: address.port, releaseRuntimeEnable(): void { @@ -736,7 +762,7 @@ describe('debugger server', () => { children: sparseChildren, id: 'root', metadata: sparseMetadata, - oversized: 'x'.repeat(60_000), + oversized: 'x'.repeat(70_000), tag: 'root', }; @@ -755,7 +781,7 @@ describe('debugger server', () => { expect(projection.nodes[1]?.sourceChildIndex).toBe(10_000_000); expect(metadata.$length).toBe(10_000_001); expect(metadata.$truncated).toBe('sparse-array'); - expect((projection.nodes[0]?.data['oversized'] as string).length).toBe(50_000); + expect((projection.nodes[0]?.data['oversized'] as string).length).toBe(65_536); expect(metadata.$entries).toContain(jasmine.objectContaining({ $index: 10_000_000, value: 'far-value' })); expect(metadata.$entries).toContain( jasmine.objectContaining({ @@ -1337,14 +1363,262 @@ describe('debugger server', () => { transport: 'chromium-cdp', }), ); + expect((JSON.parse(target.body) as { target: { capabilities: string[] } }).target.capabilities).not.toContain( + 'component-property-edit', + ); expect(snapshot.statusCode).withContext(snapshot.body).toBe(200); expect(snapshotBody.target['identityMode']).toBe('inspected-page'); + expect(snapshotBody.target['capabilities']).toContain('component-property-edit'); expect(snapshotBody.tree.nodes[0]?.data['id']).toBe('web-root'); } finally { await chromium.close(); } }); + it('downgrades snapshot capabilities when the web bridge cannot issue secure edit tokens', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + componentPropertyEditingAvailable: false, + holdRuntimeEnable: false, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + chromiumDebuggingPort: chromium.port, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + }); + const snapshotUrl = new URL('/api/devtools/snapshot', debuggerServer.url); + snapshotUrl.searchParams.set('inspectedUrl', `${applicationUrl}?valdiDevTools=1`); + snapshotUrl.searchParams.set('sessionId', 'web-preview'); + snapshotUrl.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + + const snapshot = await request(snapshotUrl.toString(), GET_REQUEST_OPTIONS); + const target = (JSON.parse(snapshot.body) as { target: { capabilities: string[] } }).target; + + expect(snapshot.statusCode).toBe(200); + expect(target.capabilities).toContain('component-properties'); + expect(target.capabilities).not.toContain('component-property-edit'); + } finally { + await chromium.close(); + } + }); + + it('preserves an editable scalar exactly through projection before a no-op update', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const inspectedUrl = `${applicationUrl}?valdiDevTools=1`; + const originalValue = 'x'.repeat(60_000); + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + componentPropertyValue: originalValue, + holdRuntimeEnable: false, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + chromiumDebuggingPort: chromium.port, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + }); + const snapshotUrl = new URL('/api/devtools/snapshot', debuggerServer.url); + snapshotUrl.searchParams.set('inspectedUrl', inspectedUrl); + snapshotUrl.searchParams.set('sessionId', 'web-preview'); + snapshotUrl.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + + const snapshot = await request(snapshotUrl.toString(), GET_REQUEST_OPTIONS); + const snapshotBody = JSON.parse(snapshot.body) as { + tree: { + nodes: Array<{ + data: { + component?: { + properties?: { title?: unknown }; + propertyEdits?: { + title?: { componentToken: unknown; snapshotRevision: unknown }; + }; + }; + }; + }>; + }; + }; + const component = snapshotBody.tree.nodes[0]?.data.component; + const projectedValue = component?.properties?.title; + const metadata = component?.propertyEdits?.title; + if ( + typeof projectedValue !== 'string' || + typeof metadata?.componentToken !== 'string' || + typeof metadata.snapshotRevision !== 'number' + ) { + throw new TypeError('Expected exact projected edit metadata.'); + } + + expect(snapshot.statusCode).withContext(snapshot.body).toBe(200); + expect(projectedValue).toBe(originalValue); + const editBody = { + componentId: 'component:[null,"root"]', + componentToken: metadata.componentToken, + inspectedUrl, + propertyName: 'title', + sessionId: 'web-preview', + snapshotRevision: metadata.snapshotRevision, + targetNonce: WEB_PREVIEW_NONCE, + value: projectedValue, + }; + const edit = await request(new URL('/api/devtools/component-property', debuggerServer.url).toString(), { + body: JSON.stringify(editBody), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const editExpression = chromium.expressions.find(expression => expression.includes('editComponentProperty')); + + expect(edit.statusCode).withContext(edit.body).toBe(200); + expect(editExpression).toContain( + `globalThis.__VALDI_WEB_DEBUGGER__?.editComponentProperty?.(${JSON.stringify({ + componentId: editBody.componentId, + componentToken: editBody.componentToken, + propertyName: editBody.propertyName, + snapshotRevision: editBody.snapshotRevision, + value: originalValue, + })})`, + ); + expect(editExpression).not.toContain('…[truncated]'); + } finally { + await chromium.close(); + } + }); + + it('accepts only the exact web-preview component-property edit tuple', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const inspectedUrl = `${applicationUrl}?valdiDevTools=1`; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + holdRuntimeEnable: false, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + chromiumDebuggingPort: chromium.port, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + }); + const exactBody = { + componentId: 'component:[null,"root"]', + componentToken: 'a'.repeat(32), + inspectedUrl, + propertyName: 'title', + sessionId: 'web-preview', + snapshotRevision: 3, + targetNonce: WEB_PREVIEW_NONCE, + value: 'updated', + }; + const post = (body: unknown, contentType = 'application/json') => + request(new URL('/api/devtools/component-property', debuggerServer?.url).toString(), { + body: JSON.stringify(body), + headers: { 'Content-Type': contentType }, + method: 'POST', + }); + + const success = await post(exactBody); + expect(success.statusCode).withContext(success.body).toBe(200); + expect(JSON.parse(success.body)).toEqual({ updated: true }); + const editExpression = chromium.expressions.find(expression => expression.includes('editComponentProperty')); + expect(editExpression).toContain( + `globalThis.__VALDI_WEB_DEBUGGER__?.editComponentProperty?.(${JSON.stringify({ + componentId: exactBody.componentId, + componentToken: exactBody.componentToken, + propertyName: exactBody.propertyName, + snapshotRevision: exactBody.snapshotRevision, + value: exactBody.value, + })})`, + ); + + for (const body of [ + { ...exactBody, extra: true }, + Object.fromEntries(Object.entries(exactBody).filter(([key]) => key !== 'componentToken')), + { ...exactBody, componentToken: 'A'.repeat(32) }, + { ...exactBody, snapshotRevision: 0 }, + { ...exactBody, propertyName: ' ' }, + { ...exactBody, propertyName: 'children' }, + { ...exactBody, value: Number.POSITIVE_INFINITY }, + { ...exactBody, targetId: 'native-target' }, + ]) { + const result = await post(body); + expect(result.statusCode).withContext(JSON.stringify(body)).toBe(400); + } + const negativeZeroBody = JSON.stringify({ ...exactBody, value: 'NEGATIVE_ZERO' }).replace( + '"NEGATIVE_ZERO"', + '-0', + ); + const negativeZero = await request(new URL('/api/devtools/component-property', debuggerServer.url).toString(), { + body: negativeZeroBody, + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + const wrongMethod = await request( + new URL('/api/devtools/component-property', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const wrongContentType = await post(exactBody, 'text/plain'); + const queryIdentityUrl = new URL('/api/devtools/component-property', debuggerServer.url); + queryIdentityUrl.searchParams.set('targetId', 'native-target'); + const queryIdentity = await request(queryIdentityUrl.toString(), { + body: JSON.stringify(exactBody), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + expect(wrongMethod.statusCode).toBe(405); + expect(wrongContentType.statusCode).toBe(415); + expect(negativeZero.statusCode).toBe(400); + expect(queryIdentity.statusCode).toBe(400); + } finally { + await chromium.close(); + } + }); + + it('returns only a generic conflict when the exact bridge mutation is rejected', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const inspectedUrl = `${applicationUrl}?valdiDevTools=1`; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + componentPropertyEditResult: false, + holdRuntimeEnable: false, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + chromiumDebuggingPort: chromium.port, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + }); + + const response = await request(new URL('/api/devtools/component-property', debuggerServer.url).toString(), { + body: JSON.stringify({ + componentId: 'component:[null,"root"]', + componentToken: 'a'.repeat(32), + inspectedUrl, + propertyName: 'title', + sessionId: 'web-preview', + snapshotRevision: 3, + targetNonce: WEB_PREVIEW_NONCE, + value: 'updated', + }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + + expect(response.statusCode).toBe(409); + expect(JSON.parse(response.body)).toEqual({ + error: 'The component property edit is stale or invalid.', + }); + } finally { + await chromium.close(); + } + }); + it('lists proxy-only target IDs but rejects them as non-native attachments', async () => { debuggerServer = await startDebuggerServer({ assetRoot, diff --git a/npm_modules/cli/src/debugger/server.ts b/npm_modules/cli/src/debugger/server.ts index ac6b0b33..a6060ea6 100644 --- a/npm_modules/cli/src/debugger/server.ts +++ b/npm_modules/cli/src/debugger/server.ts @@ -56,10 +56,19 @@ const PORT_SEARCH_LIMIT = 50; const MAX_RUNTIME_LOG_READ_BYTES = 1024 * 1024; const FATAL_JSON_UTF8_DECODER = new TextDecoder('utf8', { fatal: true }); const WEB_PREVIEW_NONCE_PATTERN = /^[\w-]{16,128}$/; +const COMPONENT_PROPERTY_TOKEN_PATTERN = /^[\da-f]{32}$/; +const COMPONENT_PROPERTY_EDIT_ERROR = 'The component property edit is stale or invalid.'; +const MAX_COMPONENT_PROPERTY_NAME_CHARACTERS = 256; +const MAX_COMPONENT_PROPERTY_STRING_BYTES = 65_536; +const MAX_COMPONENT_ID_CHARACTERS = 4096; +const FORBIDDEN_COMPONENT_PROPERTY_NAMES = new Set(['__proto__', 'children', 'constructor', 'prototype']); const MAX_DEBUGGER_TREE_NODES = 25_000; const MAX_DEBUGGER_PROJECTION_VALUES = 250_000; const MAX_DEBUGGER_PROJECTION_DEPTH = 64; -const MAX_DEBUGGER_PROJECTION_STRING_LENGTH = 50_000; +// An editable UTF-8 string cannot contain more characters than bytes. Keeping +// this projection limit aligned with the edit boundary prevents the server +// from presenting a shortened value alongside a token for the exact original. +const MAX_DEBUGGER_PROJECTION_STRING_LENGTH = MAX_COMPONENT_PROPERTY_STRING_BYTES; const DEFAULT_TRACE_CAPTURE_DURATION_MS = 5000; const MIN_TRACE_CAPTURE_DURATION_MS = 100; const MAX_TRACE_CAPTURE_DURATION_MS = 15_000; @@ -1542,18 +1551,130 @@ async function inspectWebPreviewSnapshot(searchParams: URLSearchParams): Promise if (typeof tree !== 'object' || tree === null || Array.isArray(tree)) { throw new Error('The running web preview has not mounted a Valdi renderer.'); } + let snapshotTarget = webPreviewTargetPayload(target); + if (bridgePayload['componentPropertyEditingAvailable'] === true) { + snapshotTarget = { + ...snapshotTarget, + capabilities: snapshotTarget.capabilities.flatMap(capability => + capability === DebuggerTargetCapability.ComponentProperties + ? [capability, DebuggerTargetCapability.ComponentPropertyEdit] + : [capability], + ), + }; + } return { issues: [], logs: [], selectedNodeId: bridgePayload['selectedNodeId'], source: 'owl', - target: { ...webPreviewTargetPayload(target), state: 'attached' }, - targets: [webPreviewTargetPayload(target)], + target: { ...snapshotTarget, state: 'attached' }, + targets: [snapshotTarget], tree: projectDebuggerTreeForJson(tree), viewport: snapshot['viewport'], }; } +interface ComponentPropertyEditRequest { + readonly componentId: string; + readonly componentToken: string; + readonly inspectedUrl: string; + readonly propertyName: string; + readonly sessionId: string; + readonly snapshotRevision: number; + readonly targetNonce: string; + readonly value: boolean | number | string; +} + +function readComponentPropertyEditRequest(body: Record): ComponentPropertyEditRequest { + const expectedKeys = [ + 'componentId', + 'componentToken', + 'inspectedUrl', + 'propertyName', + 'sessionId', + 'snapshotRevision', + 'targetNonce', + 'value', + ]; + const keys = Object.keys(body).sort(); + if (keys.length !== expectedKeys.length || keys.some((key, index) => key !== expectedKeys[index])) { + throw new ApiRequestError(400, 'Component property edits require the exact debugger identity and edit tuple.'); + } + const componentId = readDebuggerExactString(body, 'componentId', MAX_COMPONENT_ID_CHARACTERS); + const componentToken = readDebuggerExactString(body, 'componentToken', 32); + const inspectedUrl = readDebuggerExactString(body, 'inspectedUrl', MAX_WEB_PREVIEW_URL_BYTES); + const propertyName = readDebuggerExactString(body, 'propertyName', MAX_COMPONENT_PROPERTY_NAME_CHARACTERS); + const sessionId = readDebuggerExactString(body, 'sessionId', 256); + const targetNonce = readDebuggerExactString(body, 'targetNonce', 128); + const snapshotRevision = body['snapshotRevision']; + const value = body['value']; + if (!COMPONENT_PROPERTY_TOKEN_PATTERN.test(componentToken)) { + throw new ApiRequestError(400, 'componentToken must be a 128-bit lowercase hexadecimal token.'); + } + if (!Number.isSafeInteger(snapshotRevision) || (snapshotRevision as number) <= 0) { + throw new ApiRequestError(400, 'snapshotRevision must be a positive safe integer.'); + } + if (FORBIDDEN_COMPONENT_PROPERTY_NAMES.has(propertyName)) { + throw new ApiRequestError(400, 'propertyName is not editable.'); + } + if ( + !( + typeof value === 'boolean' || + (typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= MAX_COMPONENT_PROPERTY_STRING_BYTES) || + (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) + ) + ) { + throw new ApiRequestError(400, 'value must be a bounded string, boolean, or finite non-negative-zero number.'); + } + return { + componentId, + componentToken, + inspectedUrl, + propertyName, + sessionId, + snapshotRevision: snapshotRevision as number, + targetNonce, + value, + }; +} + +async function editWebPreviewComponentProperty( + request: IncomingMessage, + searchParams: URLSearchParams, +): Promise> { + if (request.method !== 'POST') { + throw new ApiRequestError(405, 'Component property edits require POST.'); + } + if (Array.from(searchParams).length > 0) { + throw new ApiRequestError(400, 'Component property edits accept identity only in the exact request body.'); + } + const editRequest = readComponentPropertyEditRequest(await readJsonBody(request)); + const target = resolveWebPreviewDebuggerTarget(editRequest.sessionId); + if (editRequest.sessionId !== target.sessionId) { + throw new ApiRequestError(404, 'The inspected web preview session is no longer available.'); + } + const context = resolveInspectedWebPreviewContext(target, editRequest.inspectedUrl, editRequest.targetNonce); + const bridgeRequest = { + componentId: editRequest.componentId, + componentToken: editRequest.componentToken, + propertyName: editRequest.propertyName, + snapshotRevision: editRequest.snapshotRevision, + value: editRequest.value, + }; + try { + const updated = await evaluateOwlApplicationExpression( + target.debuggingPort, + target.applicationUrl, + context.targetNonce, + `globalThis.__VALDI_WEB_DEBUGGER__?.editComponentProperty?.(${JSON.stringify(bridgeRequest)})`, + ); + if (updated !== true) throw new Error(COMPONENT_PROPERTY_EDIT_ERROR); + } catch { + throw new ApiRequestError(409, COMPONENT_PROPERTY_EDIT_ERROR); + } + return { updated: true }; +} + async function evaluateWebPreviewConsole(params: Record): Promise> { const target = resolveWebPreviewDebuggerTarget(readRecordString(params, 'sessionId')); const context = resolveInspectedWebPreviewContext( @@ -3861,6 +3982,11 @@ async function handleApi(request: IncomingMessage, response: ServerResponse, url return; } + if (url.pathname === '/api/devtools/component-property') { + sendJson(response, 200, await editWebPreviewComponentProperty(request, url.searchParams)); + return; + } + if (url.pathname === '/api/devtools/evaluate' || url.pathname === '/api/devtools/highlight') { if (request.method !== 'POST') { sendJson(response, 405, { error: 'Valdi DevTools runtime actions require POST.' }); diff --git a/npm_modules/cli/src/debugger/targetRegistry.spec.ts b/npm_modules/cli/src/debugger/targetRegistry.spec.ts index 783537fa..d5011351 100644 --- a/npm_modules/cli/src/debugger/targetRegistry.spec.ts +++ b/npm_modules/cli/src/debugger/targetRegistry.spec.ts @@ -51,6 +51,7 @@ function webPreviewTarget(): DebuggerTargetDescriptor { capabilities: [ DebuggerTargetCapability.Components, DebuggerTargetCapability.ComponentProperties, + DebuggerTargetCapability.ComponentPropertyEdit, DebuggerTargetCapability.Snapshot, DebuggerTargetCapability.Console, ], @@ -132,10 +133,14 @@ describe('debugger target registry', () => { expect(replacement.id).not.toBe(native.id); expect(native.capabilities).toEqual([DebuggerTargetCapability.Components, DebuggerTargetCapability.Snapshot]); expect(native.capabilities).not.toContain(DebuggerTargetCapability.ComponentProperties); + expect(native.capabilities).not.toContain(DebuggerTargetCapability.ComponentPropertyEdit); expect(native.identityMode).toBe(DebuggerTargetIdentityMode.TargetId); expect(first).toContain( jasmine.objectContaining({ - capabilities: jasmine.arrayContaining([DebuggerTargetCapability.ComponentProperties]), + capabilities: jasmine.arrayContaining([ + DebuggerTargetCapability.ComponentProperties, + DebuggerTargetCapability.ComponentPropertyEdit, + ]), id: 'owl:web-preview', identityMode: DebuggerTargetIdentityMode.InspectedPage, }), diff --git a/npm_modules/cli/src/debugger/targetRegistry.ts b/npm_modules/cli/src/debugger/targetRegistry.ts index 8f8be705..6f975586 100644 --- a/npm_modules/cli/src/debugger/targetRegistry.ts +++ b/npm_modules/cli/src/debugger/targetRegistry.ts @@ -23,6 +23,7 @@ export enum DebuggerTargetTransport { /** Capability names are serialized so frontends can hide unsupported tools. */ export enum DebuggerTargetCapability { + ComponentPropertyEdit = 'component-property-edit', ComponentProperties = 'component-properties', Components = 'components', Console = 'console', diff --git a/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts b/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts index 315d26f1..61781294 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/IRenderer.ts @@ -34,6 +34,19 @@ export interface RendererDebugVirtualNodeSnapshot { readonly traversedLinkCount: number; } +export type RendererDebugEditableScalar = boolean | number | string; + +/** Internal, web-debugger-only request for replacing one exact ViewModel value. */ +export interface RendererDebugComponentPropertyEdit { + readonly component: IComponent; + readonly expectedDescriptor: PropertyDescriptor; + readonly expectedViewModel: object; + readonly expectedViewModelExtensible: boolean; + readonly newValue: RendererDebugEditableScalar; + readonly node: IRenderedVirtualNode; + readonly propertyName: string; +} + export interface IRenderer { contextId: string; renderComponent(component: IComponent, properties: any | undefined): void; @@ -52,6 +65,7 @@ export interface IRenderer { maximumChildLinks: number, maximumTraversalLinks: number, ): RendererDebugVirtualNodeSnapshot | undefined; + editDebugComponentProperty?(request: RendererDebugComponentPropertyEdit): boolean; /** * Registers a function which will be called right after the component is destroyed. diff --git a/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts b/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts index 676ae790..1554da7b 100644 --- a/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts +++ b/src/valdi_modules/src/valdi/valdi_core/src/Renderer.ts @@ -17,7 +17,14 @@ import { ConsoleRepresentable } from './ConsoleRepresentable'; import { ComponentConstructor, IComponent } from './IComponent'; import { IRenderedElement } from './IRenderedElement'; import { IRenderedVirtualNode } from './IRenderedVirtualNode'; -import { ComponentDisposable, IRenderer, RendererDebugVirtualNodeSnapshot, RendererObserver } from './IRenderer'; +import { + ComponentDisposable, + IRenderer, + RendererDebugComponentPropertyEdit, + RendererDebugEditableScalar, + RendererDebugVirtualNodeSnapshot, + RendererObserver, +} from './IRenderer'; import { IRendererDelegate } from './IRendererDelegate'; import { IRendererEventListener } from './IRendererEventListener'; import { NodePrototype } from './NodePrototype'; @@ -32,6 +39,224 @@ import { isTracingSupported, trace } from './utils/Trace'; const EMPTY_OBJECT = Object.freeze({}); const EMPTY_ARRAY = Object.freeze([]) as []; +const DEBUG_FORBIDDEN_VIEW_MODEL_PROPERTIES = new Set(['__proto__', 'children', 'constructor', 'prototype']); +const DEBUG_VIEW_MODEL_MAX_OWN_KEYS = 1_000; +const DEBUG_PROPERTY_DESCRIPTOR_FIELDS: Array = [ + 'configurable', + 'enumerable', + 'get', + 'set', + 'value', + 'writable', +]; + +interface DebugViewModelShape { + readonly descriptors: ReadonlyMap; + readonly extensible: boolean; + readonly keys: readonly (string | symbol)[]; + readonly prototype: object | null; +} + +interface DebugViewModelOverlayState { + readonly overrides: ReadonlyMap; + readonly shape: DebugViewModelShape; + readonly sourceViewModel: object; +} + +const DEBUG_VIEW_MODEL_OVERLAYS = new WeakMap(); + +function isDebugEditableScalar(value: unknown): value is RendererDebugEditableScalar { + return ( + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) + ); +} + +function sameDebugPropertyDescriptor(left: PropertyDescriptor, right: PropertyDescriptor): boolean { + for (const field of DEBUG_PROPERTY_DESCRIPTOR_FIELDS) { + const leftField = Object.getOwnPropertyDescriptor(left, field); + const rightField = Object.getOwnPropertyDescriptor(right, field); + if (leftField === undefined || rightField === undefined) { + if (leftField !== rightField) return false; + continue; + } + if ( + !Object.prototype.hasOwnProperty.call(leftField, 'value') || + !Object.prototype.hasOwnProperty.call(rightField, 'value') || + !Object.is(leftField.value, rightField.value) + ) { + return false; + } + } + return true; +} + +function isDebugDataDescriptor(descriptor: PropertyDescriptor): boolean { + const value = Object.getOwnPropertyDescriptor(descriptor, 'value'); + return value !== undefined && Object.prototype.hasOwnProperty.call(value, 'value'); +} + +function sameDebugDataDescriptor(left: PropertyDescriptor, right: PropertyDescriptor): boolean { + return isDebugDataDescriptor(left) && isDebugDataDescriptor(right) && sameDebugPropertyDescriptor(left, right); +} + +function captureDebugViewModelShape(viewModel: object): DebugViewModelShape | undefined { + const keys = Reflect.ownKeys(viewModel); + if (keys.length > DEBUG_VIEW_MODEL_MAX_OWN_KEYS) return undefined; + const descriptors = new Map(); + for (const key of keys) { + const descriptor = Reflect.getOwnPropertyDescriptor(viewModel, key); + if (descriptor === undefined) return undefined; + if (!isDebugDataDescriptor(descriptor) || typeof descriptor.value === 'function') return undefined; + descriptors.set(key, descriptor); + } + const prototype = Reflect.getPrototypeOf(viewModel); + if (prototype !== Object.prototype && prototype !== null) return undefined; + return { + descriptors, + extensible: Reflect.isExtensible(viewModel), + keys, + prototype, + }; +} + +function sameDebugViewModelShape(left: DebugViewModelShape, right: DebugViewModelShape): boolean { + if ( + left.prototype !== right.prototype || + left.extensible !== right.extensible || + left.keys.length !== right.keys.length + ) { + return false; + } + for (let index = 0; index < left.keys.length; index++) { + const leftKey = left.keys[index]; + const rightKey = right.keys[index]; + const leftDescriptor = left.descriptors.get(leftKey); + const rightDescriptor = right.descriptors.get(rightKey); + if ( + leftKey !== rightKey || + leftDescriptor === undefined || + rightDescriptor === undefined || + !sameDebugPropertyDescriptor(leftDescriptor, rightDescriptor) + ) { + return false; + } + } + return true; +} + +function captureStableDebugViewModelShape(viewModel: object): DebugViewModelShape | undefined { + const captured = captureDebugViewModelShape(viewModel); + const verified = captureDebugViewModelShape(viewModel); + return captured !== undefined && verified !== undefined && sameDebugViewModelShape(captured, verified) + ? verified + : undefined; +} + +function debugViewModelShapeMatchesOverlay(captured: DebugViewModelShape, state: DebugViewModelOverlayState): boolean { + if ( + captured.prototype !== state.shape.prototype || + captured.extensible !== state.shape.extensible || + captured.keys.length !== state.shape.keys.length + ) { + return false; + } + for (let index = 0; index < captured.keys.length; index++) { + const capturedKey = captured.keys[index]; + const shapeKey = state.shape.keys[index]; + const capturedDescriptor = captured.descriptors.get(capturedKey); + const shapeDescriptor = state.shape.descriptors.get(shapeKey); + if (capturedKey !== shapeKey || capturedDescriptor === undefined || shapeDescriptor === undefined) { + return false; + } + const override = typeof shapeKey === 'string' ? state.overrides.get(shapeKey) : undefined; + const expectedDescriptor = override === undefined ? shapeDescriptor : { ...shapeDescriptor, value: override }; + if (!sameDebugPropertyDescriptor(capturedDescriptor, expectedDescriptor)) return false; + } + return true; +} + +function prepareDebugViewModelOverlay( + viewModel: object, + capturedShape: DebugViewModelShape, + propertyName: string, + newValue: RendererDebugEditableScalar, +): DebugViewModelOverlayState | undefined { + const priorState = DEBUG_VIEW_MODEL_OVERLAYS.get(viewModel); + if (priorState !== undefined && !debugViewModelShapeMatchesOverlay(capturedShape, priorState)) return undefined; + const overrides = new Map(priorState?.overrides ?? []); + const currentDescriptor = capturedShape.descriptors.get(propertyName); + if (currentDescriptor === undefined || !isDebugDataDescriptor(currentDescriptor)) return undefined; + overrides.set(propertyName, newValue); + return { + overrides, + shape: priorState?.shape ?? capturedShape, + sourceViewModel: priorState?.sourceViewModel ?? viewModel, + }; +} + +function createDebugViewModelOverlay(state: DebugViewModelOverlayState): object | undefined { + const shadow = Object.create(state.shape.prototype) as object; + for (const key of state.shape.keys) { + const baseDescriptor = state.shape.descriptors.get(key); + if (baseDescriptor === undefined) return undefined; + const override = typeof key === 'string' ? state.overrides.get(key) : undefined; + const descriptor = override === undefined ? baseDescriptor : { ...baseDescriptor, value: override }; + if (!Reflect.defineProperty(shadow, key, descriptor)) return undefined; + } + if (!state.shape.extensible && !Reflect.preventExtensions(shadow)) return undefined; + const boundFallbacks = new Map(); + + const overlay = new Proxy(shadow, { + defineProperty() { + return false; + }, + deleteProperty() { + return false; + }, + get(target, key, receiver) { + const override = typeof key === 'string' ? state.overrides.get(key) : undefined; + if (override !== undefined) return override; + if ( + !state.shape.descriptors.has(key) && + state.shape.prototype !== null && + Reflect.getOwnPropertyDescriptor(state.shape.prototype, key) !== undefined + ) { + boundFallbacks.delete(key); + return Reflect.get(target, key, receiver); + } + const sourceValue = Reflect.get(state.sourceViewModel, key, state.sourceViewModel) as unknown; + if (typeof sourceValue !== 'function') { + boundFallbacks.delete(key); + return sourceValue; + } + const existing = boundFallbacks.get(key); + if (existing?.sourceValue === sourceValue) return existing.boundValue; + const boundValue = Function.prototype.bind.call(sourceValue, state.sourceViewModel) as unknown; + boundFallbacks.set(key, { boundValue, sourceValue }); + return boundValue; + }, + has(target, key) { + const sourceHasKey = Reflect.has(state.sourceViewModel, key); + return Reflect.getOwnPropertyDescriptor(target, key) !== undefined || sourceHasKey; + }, + ownKeys() { + return [...state.shape.keys]; + }, + preventExtensions() { + return false; + }, + set() { + return false; + }, + setPrototypeOf() { + return false; + }, + }); + DEBUG_VIEW_MODEL_OVERLAYS.set(overlay, state); + return overlay; +} interface RememberSlot { value: T; @@ -136,6 +361,11 @@ interface RenderedComponent { componentRef: IRenderedComponentHolder | undefined; } +interface DebugComponentPropertyViewModelUpdate { + readonly renderedComponent: RenderedComponent; + readonly viewModel: object; +} + interface ComponentSlotData { // The node key for the slot. nodeKey: string; @@ -662,6 +892,7 @@ export class Renderer implements IRenderer { private componentRerendersCount = 0; private rootRendersCount = 0; private beginCount = 0; + private uncaughtErrorGeneration = 0; private observers: RendererObserver[] = []; private nextAnimationCancelToken: CancelToken = 0; private eventListener: IRendererEventListener | undefined = undefined; @@ -1713,6 +1944,7 @@ export class Renderer implements IRenderer { } onUncaughtError(message: string, error: Error, sourceComponent?: IComponent) { + this.uncaughtErrorGeneration++; let lastRenderedNode: IRenderedVirtualNode | undefined; let componentCatchingError: IComponent | undefined; @@ -2760,6 +2992,135 @@ export class Renderer implements IRenderer { return node.getDebugSnapshot(maximumChildLinks, maximumTraversalLinks); } + editDebugComponentProperty(request: RendererDebugComponentPropertyEdit): boolean { + if ( + !(request.node instanceof VirtualNodeBridge) || + request.node.renderer !== this || + DEBUG_FORBIDDEN_VIEW_MODEL_PROPERTIES.has(request.propertyName) || + !isDebugEditableScalar(request.newValue) || + !this.isDebugComponentPropertyDescriptorCurrent(request) || + this.resolveDebugComponentPropertyTarget(request) === undefined + ) { + return false; + } + + return this.renderDebugComponentWithFullViewModel(request); + } + + private resolveDebugComponentPropertyTarget( + request: RendererDebugComponentPropertyEdit, + ): RenderedComponent | undefined { + if (this.currentNode !== undefined) return undefined; + try { + const renderedComponent = this.resolveRenderedComponent(request.component); + return getVirtualNodeBridge(this, renderedComponent.virtualNode) === request.node && + renderedComponent.instance === request.component && + renderedComponent.viewModel === request.expectedViewModel + ? renderedComponent + : undefined; + } catch (_error) { + return undefined; + } + } + + private isDebugComponentPropertyDescriptorCurrent(request: RendererDebugComponentPropertyEdit): boolean { + try { + const descriptor = Object.getOwnPropertyDescriptor(request.expectedViewModel, request.propertyName); + return ( + descriptor !== undefined && + descriptor.enumerable === true && + sameDebugDataDescriptor(descriptor, request.expectedDescriptor) && + isDebugEditableScalar(descriptor.value) && + typeof descriptor.value === typeof request.newValue && + Object.isExtensible(request.expectedViewModel) === request.expectedViewModelExtensible + ); + } catch (_error) { + return false; + } + } + + private createDebugComponentPropertyViewModelUpdate( + request: RendererDebugComponentPropertyEdit, + ): DebugComponentPropertyViewModelUpdate | undefined { + try { + const capturedShape = captureStableDebugViewModelShape(request.expectedViewModel); + const currentDescriptor = + capturedShape === undefined ? undefined : capturedShape.descriptors.get(request.propertyName); + if ( + capturedShape === undefined || + currentDescriptor === undefined || + currentDescriptor.enumerable !== true || + !sameDebugDataDescriptor(currentDescriptor, request.expectedDescriptor) || + !isDebugEditableScalar(currentDescriptor.value) || + typeof currentDescriptor.value !== typeof request.newValue || + capturedShape.extensible !== request.expectedViewModelExtensible + ) { + return undefined; + } + const verifiedShape = captureStableDebugViewModelShape(request.expectedViewModel); + if (verifiedShape === undefined || !sameDebugViewModelShape(verifiedShape, capturedShape)) { + return undefined; + } + const overlayState = prepareDebugViewModelOverlay( + request.expectedViewModel, + capturedShape, + request.propertyName, + request.newValue, + ); + if (overlayState === undefined) return undefined; + const currentShape = captureStableDebugViewModelShape(request.expectedViewModel); + const renderedComponent = this.resolveDebugComponentPropertyTarget(request); + if ( + currentShape === undefined || + !sameDebugViewModelShape(currentShape, capturedShape) || + renderedComponent === undefined + ) { + return undefined; + } + const viewModel = createDebugViewModelOverlay(overlayState); + if (viewModel === undefined) return undefined; + return { + renderedComponent, + viewModel, + }; + } catch (_error) { + return undefined; + } + } + + private renderDebugComponentWithFullViewModel(request: RendererDebugComponentPropertyEdit): boolean { + let renderedComponent = this.resolveDebugComponentPropertyTarget(request); + if (renderedComponent === undefined) return false; + let rendered = true; + const errorGeneration = this.uncaughtErrorGeneration; + + this.doBegin(); + try { + trace(`renderComponent.${renderedComponent.ctr.name}`, () => { + for (const observer of this.observers) { + observer.onComponentWillRerender?.(request.component); + } + const update = this.createDebugComponentPropertyViewModelUpdate(request); + if (update === undefined) { + rendered = false; + return; + } + renderedComponent = update.renderedComponent; + this.componentRerendersCount++; + renderedComponent.needRendering = true; + this.pushVirtualNode(renderedComponent.virtualNode); + this.setViewModelFull(update.viewModel as PropertyList); + this.endComponent(); + }); + } catch (error) { + rendered = false; + console.warn('Valdi debugger could not apply a component property edit.', error); + } finally { + this.doEnd(); + } + return rendered && this.uncaughtErrorGeneration === errorGeneration; + } + getComponentVirtualNode(component: IComponent): IRenderedVirtualNode { const renderedComponent = this.resolveRenderedComponent(component); const virtualNodeBridge = getVirtualNodeBridge(this, renderedComponent.virtualNode); diff --git a/src/valdi_modules/src/valdi/valdi_test/test/Renderer.spec.ts b/src/valdi_modules/src/valdi/valdi_test/test/Renderer.spec.ts index 0ff88040..1286377a 100644 --- a/src/valdi_modules/src/valdi/valdi_test/test/Renderer.spec.ts +++ b/src/valdi_modules/src/valdi/valdi_test/test/Renderer.spec.ts @@ -2369,6 +2369,935 @@ describe('Renderer', () => { expect(renderer.getDebugVirtualNodeSnapshot(rootVirtualNode, 10, 100)).toBeUndefined(); }); + it('applies exact debugger scalar edits through a descriptor-preserving full ViewModel rerender', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + const label = makeNodeProtoype('label'); + + class EditableComponent extends TestComponent { + onRender() { + renderer.beginElement(label); + renderer.setAttribute('value', this.viewModel.title); + renderer.endElement(); + } + } + + const viewModel = Object.create(null); + Object.defineProperties(viewModel, { + hidden: { configurable: false, enumerable: false, value: 'preserved', writable: false }, + title: { configurable: false, enumerable: true, value: 'before', writable: true }, + }); + renderer.begin(); + renderer.beginComponent(EditableComponent, makeComponentPrototype()); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + output.clear(); + + const component = renderer.getRootComponent() as EditableComponent; + const node = renderer.getRootVirtualNode()!; + const titleDescriptor = Object.getOwnPropertyDescriptor(viewModel, 'title')!; + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: titleDescriptor, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 'after', + node, + propertyName: 'title', + }), + ).toBeTrue(); + + expect(component.viewModel === viewModel).toBeFalse(); + expect(Object.getPrototypeOf(component.viewModel)).toBeNull(); + expect(Object.getOwnPropertyDescriptor(component.viewModel, 'title')).toEqual({ + configurable: false, + enumerable: true, + value: 'after', + writable: true, + }); + expect(Object.getOwnPropertyDescriptor(component.viewModel, 'hidden')).toEqual({ + configurable: false, + enumerable: false, + value: 'preserved', + writable: false, + }); + expect(output.requests).toEqual([ + { + entries: [ + { + type: RawRenderRequestEntryType.setElementAttribute, + id: 1, + name: 'value', + value: 'after', + }, + ], + }, + ]); + }); + + it('preserves an unusual property name and exact string on a no-op debugger edit', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + class EditableComponent extends TestComponent {} + const propertyName = ' line\r\n\0\uD800"[] '; + const value = 'first\r\nsecond\0\uD800"\\last'; + const viewModel = Object.create(null) as Record; + Object.defineProperty(viewModel, propertyName, { + configurable: true, + enumerable: true, + value, + writable: true, + }); + renderer.begin(); + renderer.beginComponent(EditableComponent, makeComponentPrototype()); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + + const component = renderer.getRootComponent() as EditableComponent; + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: Object.getOwnPropertyDescriptor(viewModel, propertyName)!, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: value, + node: renderer.getRootVirtualNode()!, + propertyName, + }), + ).toBeTrue(); + expect(component.viewModel[propertyName]).toBe(value); + expect(Object.getOwnPropertyDescriptor(component.viewModel, propertyName)?.value).toBe(value); + expect(viewModel[propertyName]).toBe(value); + }); + + it('keeps custom-prototype ViewModels read only before receiver-sensitive rerendering', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + class CustomViewModel { + readonly #secret = 'private'; + title = 'before'; + + get secret(): string { + return this.#secret; + } + } + class EditableComponent extends TestComponent { + onRender(): void { + void this.viewModel.secret; + } + } + const viewModel = new CustomViewModel(); + renderer.begin(); + renderer.beginComponent(EditableComponent, makeComponentPrototype()); + renderer.setViewModelFull(viewModel as unknown as PropertyList); + renderer.endComponent(); + renderer.end(); + const component = renderer.getRootComponent() as EditableComponent; + + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: Object.getOwnPropertyDescriptor(viewModel, 'title')!, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 'after', + node: renderer.getRootVirtualNode()!, + propertyName: 'title', + }), + ).toBeFalse(); + expect(component.viewModel).toBe(viewModel); + expect(component.viewModel.secret).toBe('private'); + expect(component.viewModel.title).toBe('before'); + }); + + it('keeps accessor-bearing ViewModels read only without invoking receiver-sensitive getters', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + const receiverSecrets = new WeakMap(); + const readSecret = function (this: object): string { + return receiverSecrets.get(this) ?? 'wrong receiver'; + }; + let viewModel: { readSecret(): string; title: string }; + const readSecretGetter = jasmine.createSpy('readSecretGetter').and.callFake(function (this: object) { + if (this !== viewModel) throw new Error('The exact ViewModel must remain the accessor receiver.'); + return readSecret; + }); + viewModel = Object.create(null) as { readSecret(): string; title: string }; + Object.defineProperties(viewModel, { + readSecret: { configurable: true, enumerable: false, get: readSecretGetter }, + title: { configurable: true, enumerable: true, value: 'before', writable: true }, + }); + receiverSecrets.set(viewModel, 'private'); + class EditableComponent extends TestComponent { + renderedSecret?: string; + + onRender(): void { + this.renderedSecret = this.viewModel.readSecret(); + } + } + renderer.begin(); + renderer.beginComponent(EditableComponent, makeComponentPrototype()); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + const component = renderer.getRootComponent() as EditableComponent; + expect(component.renderedSecret).toBe('private'); + readSecretGetter.calls.reset(); + + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: Object.getOwnPropertyDescriptor(viewModel, 'title')!, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 'after', + node: renderer.getRootVirtualNode()!, + propertyName: 'title', + }), + ).toBeFalse(); + expect(readSecretGetter).not.toHaveBeenCalled(); + expect(component.viewModel).toBe(viewModel); + expect(component.renderedSecret).toBe('private'); + expect(component.viewModel.title).toBe('before'); + }); + + it('keeps ViewModels with receiver-sensitive own methods read only', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + const receiverSecrets = new WeakMap(); + const viewModel = Object.create(null) as { + readSecret(): string; + title: string; + }; + Object.defineProperties(viewModel, { + readSecret: { + configurable: true, + enumerable: false, + value(this: object) { + const secret = receiverSecrets.get(this); + if (secret === undefined) throw new Error('The exact ViewModel must remain the method receiver.'); + return secret; + }, + writable: true, + }, + title: { configurable: true, enumerable: true, value: 'before', writable: true }, + }); + receiverSecrets.set(viewModel, 'private'); + class EditableComponent extends TestComponent { + renderedSecret?: string; + + onRender(): void { + this.renderedSecret = this.viewModel.readSecret(); + } + } + renderer.begin(); + renderer.beginComponent(EditableComponent, makeComponentPrototype()); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + const component = renderer.getRootComponent() as EditableComponent; + + expect(component.renderedSecret).toBe('private'); + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: Object.getOwnPropertyDescriptor(viewModel, 'title')!, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 'after', + node: renderer.getRootVirtualNode()!, + propertyName: 'title', + }), + ).toBeFalse(); + expect(component.viewModel).toBe(viewModel); + expect(component.viewModel.readSecret()).toBe('private'); + expect(component.viewModel.title).toBe('before'); + }); + + it('preserves inherited Object methods without letting them mutate the source ViewModel', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + class EditableComponent extends TestComponent {} + const viewModel = { title: 'before' }; + renderer.begin(); + renderer.beginComponent(EditableComponent, makeComponentPrototype()); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + const component = renderer.getRootComponent() as EditableComponent; + + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: Object.getOwnPropertyDescriptor(viewModel, 'title')!, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 'after', + node: renderer.getRootVirtualNode()!, + propertyName: 'title', + }), + ).toBeTrue(); + const overlay = component.viewModel as unknown as { + __defineGetter__(propertyName: PropertyKey, getter: () => unknown): void; + __defineSetter__(propertyName: PropertyKey, setter: (value: unknown) => void): void; + constructor: ObjectConstructor; + }; + const objectPrototype = Object.prototype as unknown as { + __defineGetter__: typeof overlay.__defineGetter__; + __defineSetter__: typeof overlay.__defineSetter__; + }; + expect(overlay.constructor).toBe(Object); + expect(overlay.__defineGetter__).toBe(objectPrototype.__defineGetter__); + expect(overlay.__defineSetter__).toBe(objectPrototype.__defineSetter__); + expect(() => overlay.__defineGetter__('injectedGetter', () => 'value')).toThrowError(TypeError); + expect(() => overlay.__defineSetter__('injectedSetter', () => {})).toThrowError(TypeError); + expect(Object.getOwnPropertyDescriptor(viewModel, 'injectedGetter')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(viewModel, 'injectedSetter')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(overlay, 'injectedGetter')).toBeUndefined(); + expect(Object.getOwnPropertyDescriptor(overlay, 'injectedSetter')).toBeUndefined(); + }); + + it('does not report success when the debugger full-ViewModel rerender fails', () => { + const output = new RendererTestDelegate(); + const uncaughtError = spyOn(output, 'onUncaughtError').and.callFake(() => {}); + const renderer = makeRenderer(output); + const viewModel = Object.create(null) as { title: string }; + Object.defineProperty(viewModel, 'title', { + configurable: true, + enumerable: true, + value: 'before', + writable: true, + }); + class EditableComponent extends TestComponent { + onRender(): void { + if (this.viewModel.title === 'after') throw new Error('debug rerender failed'); + } + } + renderer.begin(); + renderer.beginComponent(EditableComponent, makeComponentPrototype()); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + const component = renderer.getRootComponent() as EditableComponent; + + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: Object.getOwnPropertyDescriptor(viewModel, 'title')!, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 'after', + node: renderer.getRootVirtualNode()!, + propertyName: 'title', + }), + ).toBeFalse(); + expect(uncaughtError).toHaveBeenCalledWith( + "Failed to render component 'EditableComponent'", + jasmine.objectContaining({ message: 'debug rerender failed' }), + ); + }); + + it('preserves sealed and frozen ViewModels while editing only stable shadow targets', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + const componentPrototype = makeComponentPrototype(); + class EditableComponent extends TestComponent {} + const render = (viewModel: PropertyList) => { + renderer.begin(); + renderer.beginComponent(EditableComponent, componentPrototype); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + }; + + const sealedViewModel = Object.seal({ title: 'before' }); + render(sealedViewModel); + const component = renderer.getRootComponent() as EditableComponent; + const node = renderer.getRootVirtualNode()!; + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: Object.getOwnPropertyDescriptor(sealedViewModel, 'title')!, + expectedViewModel: sealedViewModel, + expectedViewModelExtensible: false, + newValue: 'after', + node, + propertyName: 'title', + }), + ).toBeTrue(); + expect(Object.isExtensible(component.viewModel)).toBeFalse(); + expect(Object.getOwnPropertyDescriptor(component.viewModel, 'title')).toEqual({ + configurable: false, + enumerable: true, + value: 'after', + writable: true, + }); + expect(sealedViewModel.title).toBe('before'); + + const frozenViewModel = Object.freeze({ title: 'frozen' }); + render(frozenViewModel); + const frozenComponent = renderer.getRootComponent() as EditableComponent; + expect( + renderer.editDebugComponentProperty({ + component: frozenComponent, + expectedDescriptor: Object.getOwnPropertyDescriptor(frozenViewModel, 'title')!, + expectedViewModel: frozenViewModel, + expectedViewModelExtensible: false, + newValue: 'after freeze', + node: renderer.getRootVirtualNode()!, + propertyName: 'title', + }), + ).toBeTrue(); + expect(frozenComponent.viewModel).not.toBe(frozenViewModel); + expect(Object.isExtensible(frozenComponent.viewModel)).toBeFalse(); + expect(Object.getOwnPropertyDescriptor(frozenComponent.viewModel, 'title')).toEqual({ + configurable: false, + enumerable: true, + value: 'after freeze', + writable: false, + }); + expect(frozenViewModel.title).toBe('frozen'); + }); + + it('retains source receiver and Proxy semantics while flattening stable shadow overlays', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + const trapCalls = { descriptor: 0, extensible: 0, get: 0, has: 0, ownKeys: 0, prototype: 0 }; + const source = { + first: 'one', + readSecret: 'descriptor callable', + receiverSecret: 'descriptor secret', + second: 'two', + } as unknown as { + first: string; + readonly readSecret: () => string; + readonly receiverSecret: string; + second: string; + }; + const receiverSecrets = new WeakMap(); + const readSecret = function (this: object): string { + return receiverSecrets.get(this) ?? 'wrong receiver'; + }; + let viewModel: typeof source; + viewModel = new Proxy(source, { + get(target, key, receiver) { + trapCalls.get++; + if (receiver !== viewModel) throw new Error('The inner Proxy received a foreign receiver.'); + if (key === 'readSecret') return readSecret; + if (key === 'receiverSecret') return receiverSecrets.get(receiver) ?? 'wrong receiver'; + return Reflect.get(target, key, receiver); + }, + getOwnPropertyDescriptor(target, key) { + trapCalls.descriptor++; + return Reflect.getOwnPropertyDescriptor(target, key); + }, + getPrototypeOf(target) { + trapCalls.prototype++; + return Reflect.getPrototypeOf(target); + }, + has(target, key) { + trapCalls.has++; + if (key === 'second') return false; + if (key === 'virtual') return true; + return Reflect.has(target, key); + }, + isExtensible(target) { + trapCalls.extensible++; + return Reflect.isExtensible(target); + }, + ownKeys() { + trapCalls.ownKeys++; + return ['second', 'receiverSecret', 'readSecret', 'first']; + }, + }); + receiverSecrets.set(viewModel, 'receiver preserved'); + class EditableComponent extends TestComponent { + readonly previousViewModels: unknown[] = []; + renderedSecret?: string; + renderedMethodSecret?: string; + + onRender(): void { + this.renderedSecret = this.viewModel.receiverSecret; + this.renderedMethodSecret = this.viewModel.readSecret(); + } + + onViewModelUpdate(previousViewModel?: unknown): void { + super.onViewModelUpdate(); + this.previousViewModels.push(previousViewModel); + } + } + const componentPrototype = makeComponentPrototype(); + const render = (nextViewModel: PropertyList) => { + renderer.begin(); + renderer.beginComponent(EditableComponent, componentPrototype); + renderer.setViewModelFull(nextViewModel); + renderer.endComponent(); + renderer.end(); + }; + render(viewModel); + const component = renderer.getRootComponent() as EditableComponent; + const node = renderer.getRootVirtualNode()!; + expect(component.previousViewModels).toEqual([undefined]); + component.previousViewModels.length = 0; + component.onViewModelUpdateCount = 0; + + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: Object.getOwnPropertyDescriptor(viewModel, 'first')!, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 'ONE', + node, + propertyName: 'first', + }), + ).toBeTrue(); + const firstOverlay = component.viewModel; + expect(component.previousViewModels).toEqual([viewModel]); + expect(component.renderedSecret).toBe('receiver preserved'); + expect(component.renderedMethodSecret).toBe('receiver preserved'); + const callsBeforeReadChecks = { ...trapCalls }; + expect(firstOverlay.second).toBe('two'); + expect(firstOverlay.receiverSecret).toBe('receiver preserved'); + const firstBoundCallable = firstOverlay.readSecret; + expect(firstBoundCallable).toBe(firstOverlay.readSecret); + expect(firstBoundCallable()).toBe('receiver preserved'); + expect('second' in firstOverlay).toBeTrue(); + expect('virtual' in firstOverlay).toBeTrue(); + expect(Reflect.ownKeys(firstOverlay)).toEqual(['second', 'receiverSecret', 'readSecret', 'first']); + expect(Object.getOwnPropertyDescriptor(firstOverlay, 'second')?.value).toBe('two'); + expect(Object.getPrototypeOf(firstOverlay)).toBe(Object.prototype); + expect(Object.isExtensible(firstOverlay)).toBeTrue(); + expect(trapCalls.get).toBeGreaterThan(callsBeforeReadChecks.get); + expect(trapCalls.has).toBeGreaterThan(callsBeforeReadChecks.has); + expect(trapCalls.ownKeys).toBe(callsBeforeReadChecks.ownKeys); + expect(trapCalls.descriptor).toBe(callsBeforeReadChecks.descriptor); + expect(trapCalls.prototype).toBe(callsBeforeReadChecks.prototype); + expect(trapCalls.extensible).toBe(callsBeforeReadChecks.extensible); + + expect(Reflect.set(firstOverlay, 'second', 'mutated')).toBeFalse(); + expect(Reflect.defineProperty(firstOverlay, 'third', { value: 'mutated' })).toBeFalse(); + expect(Reflect.deleteProperty(firstOverlay, 'second')).toBeFalse(); + expect(Reflect.setPrototypeOf(firstOverlay, null)).toBeFalse(); + expect(Reflect.preventExtensions(firstOverlay)).toBeFalse(); + expect(source.first).toBe('one'); + expect(source.second).toBe('two'); + expect(Object.getPrototypeOf(source)).toBe(Object.prototype); + expect(Object.isExtensible(source)).toBeTrue(); + + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: Object.getOwnPropertyDescriptor(firstOverlay, 'second')!, + expectedViewModel: firstOverlay, + expectedViewModelExtensible: true, + newValue: 'TWO', + node, + propertyName: 'second', + }), + ).toBeTrue(); + expect(component.viewModel).not.toBe(firstOverlay); + expect(component.viewModel.first).toBe('ONE'); + expect(component.viewModel.second).toBe('TWO'); + expect(component.viewModel.receiverSecret).toBe('receiver preserved'); + expect(component.viewModel.readSecret()).toBe('receiver preserved'); + expect(firstOverlay.first).toBe('ONE'); + expect(firstOverlay.second).toBe('two'); + expect(source.first).toBe('one'); + expect(source.second).toBe('two'); + expect(component.onViewModelUpdateCount).toBe(2); + expect(component.previousViewModels).toEqual([viewModel, firstOverlay]); + + const parentReplacement = { + first: 'parent', + readSecret: () => 'parent', + receiverSecret: 'parent', + second: 'replacement', + }; + const secondOverlay = component.viewModel; + Object.freeze(viewModel); + const ownKeyCallsAfterSourceFreeze = trapCalls.ownKeys; + expect(Object.isExtensible(firstOverlay)).toBeTrue(); + expect(Object.getPrototypeOf(firstOverlay)).toBe(Object.prototype); + expect(Object.getOwnPropertyDescriptor(firstOverlay, 'first')).toEqual({ + configurable: true, + enumerable: true, + value: 'ONE', + writable: true, + }); + expect(Reflect.ownKeys(firstOverlay)).toEqual(['second', 'receiverSecret', 'readSecret', 'first']); + expect(Object.getOwnPropertyDescriptor(secondOverlay, 'second')?.value).toBe('TWO'); + expect(trapCalls.ownKeys).toBe(ownKeyCallsAfterSourceFreeze); + + render(parentReplacement); + expect(renderer.getRootComponent()).toBe(component); + expect(component.viewModel).toBe(parentReplacement); + expect(component.previousViewModels).toEqual([viewModel, firstOverlay, secondOverlay]); + }); + + it('rejects an edit when a rerender observer changes ViewModel extensibility', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + class EditableComponent extends TestComponent {} + const viewModel = { title: 'before' }; + renderer.begin(); + renderer.beginComponent(EditableComponent, makeComponentPrototype()); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + const component = renderer.getRootComponent() as EditableComponent; + const node = renderer.getRootVirtualNode()!; + renderer.addObserver({ + onComponentWillRerender() { + Object.preventExtensions(viewModel); + }, + }); + + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: Object.getOwnPropertyDescriptor(viewModel, 'title')!, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 'debugger', + node, + propertyName: 'title', + }), + ).toBeFalse(); + expect(Object.isExtensible(viewModel)).toBeFalse(); + expect(component.viewModel).toBe(viewModel); + expect(component.viewModel.title).toBe('before'); + }); + + it('rejects stale, forbidden, accessor, and non-finite debugger property edits', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + class EditableComponent extends TestComponent {} + const viewModel = Object.defineProperties( + {}, + { + accessor: { enumerable: true, get: () => 'secret' }, + count: { configurable: true, enumerable: true, value: 1, writable: true }, + }, + ); + renderer.begin(); + renderer.beginComponent(EditableComponent, makeComponentPrototype()); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + const component = renderer.getRootComponent() as EditableComponent; + const node = renderer.getRootVirtualNode()!; + const countDescriptor = Object.getOwnPropertyDescriptor(viewModel, 'count')!; + + for (const [propertyName, expectedDescriptor, newValue] of [ + ['children', countDescriptor, 2], + ['constructor', countDescriptor, 2], + ['prototype', countDescriptor, 2], + ['__proto__', countDescriptor, 2], + ['accessor', Object.getOwnPropertyDescriptor(viewModel, 'accessor')!, 'changed'], + ['count', countDescriptor, Number.NaN], + ['count', countDescriptor, Number.POSITIVE_INFINITY], + ['count', countDescriptor, -0], + ['count', countDescriptor, '2'], + ] as const) { + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue, + node, + propertyName, + }), + ) + .withContext(propertyName) + .toBeFalse(); + } + Object.defineProperty(viewModel, 'count', { ...countDescriptor, value: 3 }); + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: countDescriptor, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 2, + node, + propertyName: 'count', + }), + ).toBeFalse(); + }); + + it('rejects a property edit when Proxy reflection replaces the current ViewModel', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + const componentPrototype = makeComponentPrototype(); + class EditableComponent extends TestComponent {} + const replacementViewModel = { count: 9 }; + let replaceDuringReflection = false; + let replacementTriggered = false; + + const render = (viewModel: PropertyList) => { + renderer.begin(); + renderer.beginComponent(EditableComponent, componentPrototype); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + }; + const viewModel = new Proxy( + Object.defineProperty({}, 'count', { + configurable: true, + enumerable: true, + value: 1, + writable: true, + }), + { + ownKeys(target) { + if (replaceDuringReflection) { + replaceDuringReflection = false; + replacementTriggered = true; + render(replacementViewModel); + } + return Reflect.ownKeys(target); + }, + }, + ); + render(viewModel); + const component = renderer.getRootComponent() as EditableComponent; + const node = renderer.getRootVirtualNode()!; + const countDescriptor = Object.getOwnPropertyDescriptor(viewModel, 'count')!; + replaceDuringReflection = true; + + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: countDescriptor, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 2, + node, + propertyName: 'count', + }), + ).toBeFalse(); + expect(replacementTriggered).toBeTrue(); + expect(component.viewModel).toBe(replacementViewModel); + expect(component.viewModel.count).toBe(9); + }); + + it('fails closed when Proxy reflection mutates an unrelated ViewModel descriptor', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + class EditableComponent extends TestComponent {} + const target = { other: 'before', title: 'before' }; + let armMutation = false; + let reflectingDescriptors = false; + let mutationTriggered = false; + const viewModel = new Proxy(target, { + getOwnPropertyDescriptor(currentTarget, propertyName) { + if (reflectingDescriptors && propertyName === 'title' && !mutationTriggered) { + mutationTriggered = true; + currentTarget.other = 'newer'; + } + return Reflect.getOwnPropertyDescriptor(currentTarget, propertyName); + }, + ownKeys(currentTarget) { + reflectingDescriptors = armMutation; + return Reflect.ownKeys(currentTarget); + }, + }); + renderer.begin(); + renderer.beginComponent(EditableComponent, makeComponentPrototype()); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + const component = renderer.getRootComponent() as EditableComponent; + const node = renderer.getRootVirtualNode()!; + const titleDescriptor = Object.getOwnPropertyDescriptor(target, 'title')!; + armMutation = true; + + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: titleDescriptor, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 'debugger', + node, + propertyName: 'title', + }), + ).toBeFalse(); + expect(mutationTriggered).toBeTrue(); + expect(component.viewModel === viewModel).toBeTrue(); + expect(target.other).toBe('newer'); + expect(target.title).toBe('before'); + }); + + it('fails closed when a Proxy getPrototypeOf trap mutates an unrelated ViewModel descriptor', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + class EditableComponent extends TestComponent {} + const target = { other: 'before', title: 'before' }; + let armMutation = false; + let mutationTriggered = false; + const viewModel = new Proxy(target, { + getPrototypeOf(currentTarget) { + if (armMutation && !mutationTriggered) { + mutationTriggered = true; + currentTarget.other = 'newer'; + } + return Reflect.getPrototypeOf(currentTarget); + }, + }); + renderer.begin(); + renderer.beginComponent(EditableComponent, makeComponentPrototype()); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + const component = renderer.getRootComponent() as EditableComponent; + const node = renderer.getRootVirtualNode()!; + const titleDescriptor = Object.getOwnPropertyDescriptor(target, 'title')!; + armMutation = true; + + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: titleDescriptor, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 'debugger', + node, + propertyName: 'title', + }), + ).toBeFalse(); + expect(mutationTriggered).toBeTrue(); + expect(component.viewModel === viewModel).toBeTrue(); + expect(target.other).toBe('newer'); + expect(target.title).toBe('before'); + }); + + it('rejects inherited properties even when the descriptor map prototype is polluted', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + class EditableComponent extends TestComponent {} + const viewModel = { count: 1 }; + renderer.begin(); + renderer.beginComponent(EditableComponent, makeComponentPrototype()); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + const component = renderer.getRootComponent() as EditableComponent; + const node = renderer.getRootVirtualNode()!; + const pollutedPropertyName = '__valdiDebuggerPollutedDescriptor'; + const expectedDescriptor = { + configurable: true, + enumerable: true, + value: 1, + writable: true, + }; + Object.defineProperty(Object.prototype, pollutedPropertyName, { + configurable: true, + enumerable: false, + value: expectedDescriptor, + writable: true, + }); + try { + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 2, + node, + propertyName: pollutedPropertyName, + }), + ).toBeFalse(); + expect(Object.getOwnPropertyDescriptor(component.viewModel, pollutedPropertyName)).toBeUndefined(); + } finally { + delete (Object.prototype as Record)[pollutedPropertyName]; + } + }); + + it('preserves a newer ViewModel installed reentrantly by a rerender observer', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + const componentPrototype = makeComponentPrototype(); + class EditableComponent extends TestComponent {} + const viewModel = { title: 'before' }; + const replacementViewModel = { title: 'newer' }; + const render = (nextViewModel: PropertyList) => { + renderer.begin(); + renderer.beginComponent(EditableComponent, componentPrototype); + renderer.setViewModelFull(nextViewModel); + renderer.endComponent(); + renderer.end(); + }; + render(viewModel); + const component = renderer.getRootComponent() as EditableComponent; + const node = renderer.getRootVirtualNode()!; + const titleDescriptor = Object.getOwnPropertyDescriptor(viewModel, 'title')!; + let observerCalls = 0; + renderer.addObserver({ + onComponentWillRerender() { + observerCalls++; + render(replacementViewModel); + }, + }); + + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: titleDescriptor, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 'debugger', + node, + propertyName: 'title', + }), + ).toBeFalse(); + expect(observerCalls).toBe(1); + expect(component.viewModel).toBe(replacementViewModel); + expect(component.viewModel.title).toBe('newer'); + }); + + it('clones after rerender observers so an in-place update to another property is preserved', () => { + const output = new RendererTestDelegate(); + const renderer = makeRenderer(output); + class EditableComponent extends TestComponent {} + const viewModel = { other: 'before', title: 'before' }; + renderer.begin(); + renderer.beginComponent(EditableComponent, makeComponentPrototype()); + renderer.setViewModelFull(viewModel); + renderer.endComponent(); + renderer.end(); + const component = renderer.getRootComponent() as EditableComponent; + const node = renderer.getRootVirtualNode()!; + const titleDescriptor = Object.getOwnPropertyDescriptor(viewModel, 'title')!; + renderer.addObserver({ + onComponentWillRerender() { + viewModel.other = 'newer'; + }, + }); + + expect( + renderer.editDebugComponentProperty({ + component, + expectedDescriptor: titleDescriptor, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 'debugger', + node, + propertyName: 'title', + }), + ).toBeTrue(); + expect(component.viewModel).not.toBe(viewModel); + expect(component.viewModel.other).toBe('newer'); + expect(component.viewModel.title).toBe('debugger'); + }); + it('can avoid re-render slotted components', () => { const output = new RendererTestDelegate(); const renderer = makeRenderer(output); 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 d6503ce8..34b91f1b 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRendererDelegate.ts @@ -15,7 +15,10 @@ import { registerElements, setAllElementsAttributeDelegate, } from './HTMLRenderer'; -import { captureComponentHierarchySnapshot } from './debug/ComponentHierarchySnapshot'; +import { + captureComponentHierarchySnapshot, + type ComponentPropertyEditRegistrar, +} from './debug/ComponentHierarchySnapshot'; import type { WebValdiLayout } from './views/WebValdiLayout'; export interface UpdateAttributeDelegate { @@ -38,6 +41,7 @@ export interface WebRendererDebugNodeSnapshot { key: string; name: string; properties?: Record; + propertyEdits?: Record; }; element?: { id: number; @@ -49,6 +53,11 @@ export interface WebRendererDebugNodeSnapshot { }; } +export interface WebRendererDebugPropertyEditMetadata { + componentToken: string; + snapshotRevision: number; +} + export interface WebRendererDebugElementSnapshot extends WebRendererDebugNodeSnapshot { bounds: { x: number; @@ -73,6 +82,7 @@ export interface WebRendererDebugComponentSnapshot extends WebRendererDebugNodeS key: string; name: string; properties?: Record; + propertyEdits?: Record; }; } @@ -289,7 +299,11 @@ export class ValdiWebRendererDelegate implements IRendererDelegate { return node === undefined ? undefined : { htmlElement: node.htmlElement, type: node.type }; } - getDebugSnapshot(renderer: IRenderer, maximumSerializedCharacters: number): WebRendererDebugSnapshot { + getDebugSnapshot( + renderer: IRenderer, + maximumSerializedCharacters: number, + componentPropertyEditRegistrar?: ComponentPropertyEditRegistrar, + ): WebRendererDebugSnapshot { const viewport = { width: typeof window === 'undefined' ? 0 : window.innerWidth, height: typeof window === 'undefined' ? 0 : window.innerHeight, @@ -326,7 +340,7 @@ export class ValdiWebRendererDelegate implements IRendererDelegate { } const topologyRevision = this.debugTopologyRevision; - const componentTree = captureComponentHierarchySnapshot(elementTree, renderer); + const componentTree = captureComponentHierarchySnapshot(elementTree, renderer, componentPropertyEditRegistrar); if (componentTree === undefined || topologyRevision !== this.debugTopologyRevision) { return elementSnapshot; } @@ -345,6 +359,7 @@ function stripComponentProperties(root: WebRendererDebugNodeSnapshot): void { const node = pending.pop()!; if (node.component !== undefined) { delete node.component.properties; + delete node.component.propertyEdits; } pending.push(...node.children); } diff --git a/src/valdi_modules/src/valdi/web_renderer/src/debug/ComponentHierarchySnapshot.ts b/src/valdi_modules/src/valdi/web_renderer/src/debug/ComponentHierarchySnapshot.ts index 04cfe27f..e9ff5fd1 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/debug/ComponentHierarchySnapshot.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/debug/ComponentHierarchySnapshot.ts @@ -6,6 +6,7 @@ import type { WebRendererDebugComponentSnapshot, WebRendererDebugElementSnapshot, WebRendererDebugNodeSnapshot, + WebRendererDebugPropertyEditMetadata, } from '../ValdiWebRendererDelegate'; import { captureDebuggerPropertiesSnapshot, type DebuggerValueSnapshotLimits } from './DebuggerValueSnapshot'; @@ -18,12 +19,36 @@ const MAX_COMPONENT_NAME_CHARACTERS = 256; const MAX_COMPONENT_KEY_CHARACTERS = 256; const MAX_COMPONENT_PROTOTYPE_DEPTH = 16; const MAX_COMPONENT_PROPERTY_BYTES = 65_536; +const MAX_EDITABLE_VIEW_MODEL_OWN_KEYS = 1_000; +const EDITABLE_PROPERTY_DESCRIPTOR_FIELDS: Array = [ + 'configurable', + 'enumerable', + 'get', + 'set', + 'value', + 'writable', +]; const COMPONENT_PROPERTY_LIMITS: DebuggerValueSnapshotLimits = { maximumDepth: 4, maximumEntries: 50, maximumPropertyNameCharacters: 256, maximumStringBytes: 65_536, }; +const FORBIDDEN_EDITABLE_PROPERTY_NAMES = new Set(['__proto__', 'children', 'constructor', 'prototype']); + +export interface ComponentPropertyEditCandidate { + readonly component: IComponent; + readonly componentId: string; + readonly descriptor: PropertyDescriptor; + readonly node: IRenderedVirtualNode; + readonly propertyName: string; + readonly viewModel: object; + readonly viewModelExtensible: boolean; +} + +export type ComponentPropertyEditRegistrar = ( + candidate: ComponentPropertyEditCandidate, +) => WebRendererDebugPropertyEditMetadata | undefined; interface IndexedElementTree { readonly childIdsByParentId: Map; @@ -45,6 +70,13 @@ interface ComponentPropertyBudget { remainingBytes: number; } +interface EditableViewModelShape { + readonly descriptors: ReadonlyMap; + readonly extensible: boolean; + readonly keys: readonly (string | symbol)[]; + readonly prototype: object | null; +} + interface VirtualTraversalFrame { readonly componentPath: string[]; readonly depth: number; @@ -66,6 +98,7 @@ type DebugVirtualNodeSnapshotReader = NonNullable, consumedChildCountByParentId: Map, componentPropertyBudget: ComponentPropertyBudget, + componentPropertyEditRegistrar: ComponentPropertyEditRegistrar | undefined, usedElementIds: Set, ): CapturedHierarchyNode | undefined { const captured = frame.captured; @@ -273,7 +308,16 @@ function captureCompletedFrame( componentIds.add(componentId); const firstElementId = frame.capturedChildren.find(child => child.firstElementId !== undefined)?.firstElementId; const backingElement = firstElementId === undefined ? undefined : indexedElements.elementsById.get(firstElementId); - const properties = captureComponentProperties(captured.componentViewModel, componentPropertyBudget); + const propertyCapture = captureComponentProperties( + captured.componentViewModel, + componentPropertyBudget, + componentPropertyEditRegistrar, + { + component, + componentId, + node: captured.node, + }, + ); const node: WebRendererDebugComponentSnapshot = { ...(backingElement === undefined ? {} : { bounds: backingElement.bounds }), children: frame.capturedChildren.map(child => child.node), @@ -281,7 +325,8 @@ function captureCompletedFrame( ...(firstElementId === undefined ? {} : { elementId: firstElementId }), key: captured.key, name: componentName, - ...(properties === undefined ? {} : { properties }), + ...(propertyCapture === undefined ? {} : { properties: propertyCapture.properties }), + ...(propertyCapture?.propertyEdits === undefined ? {} : { propertyEdits: propertyCapture.propertyEdits }), }, id: componentId, tag: componentName, @@ -399,6 +444,7 @@ function isCapturedVirtualTopologyCurrent( } if (current.componentViewModel !== captured.componentViewModel && captured.componentOutput !== undefined) { delete captured.componentOutput.component.properties; + delete captured.componentOutput.component.propertyEdits; } remainingChildLinks -= children.length; remainingTraversalLinks -= current.traversedLinkCount; @@ -414,10 +460,23 @@ function isCapturedVirtualTopologyCurrent( return true; } +interface ComponentPropertyCaptureIdentity { + readonly component: IComponent; + readonly componentId: string; + readonly node: IRenderedVirtualNode; +} + +interface ComponentPropertyCapture { + readonly properties: Record; + readonly propertyEdits?: Record; +} + function captureComponentProperties( viewModel: unknown, budget: ComponentPropertyBudget, -): Record | undefined { + componentPropertyEditRegistrar: ComponentPropertyEditRegistrar | undefined, + identity: ComponentPropertyCaptureIdentity, +): ComponentPropertyCapture | undefined { if (budget.remainingBytes <= 0) { return undefined; } @@ -434,7 +493,153 @@ function captureComponentProperties( return undefined; } budget.remainingBytes -= captured.serializedBytes; - return captured.value; + const propertyEdits = captureComponentPropertyEdits( + viewModel, + captured.value, + componentPropertyEditRegistrar, + identity, + ); + return { + properties: captured.value, + ...(propertyEdits === undefined ? {} : { propertyEdits }), + }; +} + +function captureComponentPropertyEdits( + viewModel: unknown, + capturedProperties: Record, + registrar: ComponentPropertyEditRegistrar | undefined, + identity: ComponentPropertyCaptureIdentity, +): Record | undefined { + if (registrar === undefined || typeof viewModel !== 'object' || viewModel === null) { + return undefined; + } + try { + const shape = captureStableEditableViewModelShape(viewModel); + if (shape === undefined) return undefined; + const propertyEdits = Object.create(null) as Record; + for (const propertyName of Object.keys(capturedProperties)) { + if (propertyName.trim().length === 0 || FORBIDDEN_EDITABLE_PROPERTY_NAMES.has(propertyName)) continue; + const descriptor = shape.descriptors.get(propertyName); + const capturedDescriptor = Object.getOwnPropertyDescriptor(capturedProperties, propertyName); + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') || + capturedDescriptor === undefined || + !Object.prototype.hasOwnProperty.call(capturedDescriptor, 'value') || + !isEditableScalar(descriptor.value) || + !Object.is(capturedDescriptor.value, descriptor.value) + ) { + continue; + } + const metadata = registrar({ + component: identity.component, + componentId: identity.componentId, + descriptor: { ...descriptor }, + node: identity.node, + propertyName, + viewModel, + viewModelExtensible: shape.extensible, + }); + if (metadata !== undefined) { + Object.defineProperty(propertyEdits, propertyName, { + configurable: true, + enumerable: true, + value: metadata, + writable: true, + }); + } + } + return Object.keys(propertyEdits).length === 0 ? undefined : propertyEdits; + } catch (_error) { + return undefined; + } +} + +function captureEditableViewModelShape(viewModel: object): EditableViewModelShape | undefined { + const keys = Reflect.ownKeys(viewModel); + if (keys.length > MAX_EDITABLE_VIEW_MODEL_OWN_KEYS) return undefined; + const descriptors = new Map(); + for (const key of keys) { + const descriptor = Reflect.getOwnPropertyDescriptor(viewModel, key); + if (descriptor === undefined) return undefined; + if (!isEditableViewModelDataDescriptor(descriptor)) return undefined; + descriptors.set(key, descriptor); + } + const prototype = Reflect.getPrototypeOf(viewModel); + if (prototype !== Object.prototype && prototype !== null) return undefined; + return { + descriptors, + extensible: Reflect.isExtensible(viewModel), + keys, + prototype, + }; +} + +function captureStableEditableViewModelShape(viewModel: object): EditableViewModelShape | undefined { + const captured = captureEditableViewModelShape(viewModel); + const verified = captureEditableViewModelShape(viewModel); + if ( + captured === undefined || + verified === undefined || + captured.prototype !== verified.prototype || + captured.extensible !== verified.extensible || + captured.keys.length !== verified.keys.length + ) { + return undefined; + } + for (let index = 0; index < captured.keys.length; index++) { + const capturedKey = captured.keys[index]; + const verifiedKey = verified.keys[index]; + const capturedDescriptor = captured.descriptors.get(capturedKey); + const verifiedDescriptor = verified.descriptors.get(verifiedKey); + if ( + capturedKey !== verifiedKey || + capturedDescriptor === undefined || + verifiedDescriptor === undefined || + !sameEditablePropertyDescriptor(capturedDescriptor, verifiedDescriptor) + ) { + return undefined; + } + } + return verified; +} + +function sameEditablePropertyDescriptor(left: PropertyDescriptor, right: PropertyDescriptor): boolean { + for (const field of EDITABLE_PROPERTY_DESCRIPTOR_FIELDS) { + const leftField = Object.getOwnPropertyDescriptor(left, field); + const rightField = Object.getOwnPropertyDescriptor(right, field); + if (leftField === undefined || rightField === undefined) { + if (leftField !== rightField) return false; + continue; + } + if ( + !Object.prototype.hasOwnProperty.call(leftField, 'value') || + !Object.prototype.hasOwnProperty.call(rightField, 'value') || + !Object.is(leftField.value, rightField.value) + ) { + return false; + } + } + return true; +} + +function isEditableViewModelDataDescriptor(descriptor: PropertyDescriptor): boolean { + const valueField = Object.getOwnPropertyDescriptor(descriptor, 'value'); + return ( + valueField !== undefined && + Object.prototype.hasOwnProperty.call(valueField, 'value') && + typeof valueField.value !== 'function' + ); +} + +function isEditableScalar(value: unknown): value is boolean | number | string { + return ( + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) + ); } function readElementId(element: IRenderedElement): string | undefined { 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 index d7697ea6..f10515b8 100644 --- a/src/valdi_modules/src/valdi/web_renderer/src/debug/WebDebuggerBridge.ts +++ b/src/valdi_modules/src/valdi/web_renderer/src/debug/WebDebuggerBridge.ts @@ -1,6 +1,7 @@ import type { IRenderer } from 'valdi_core/src/IRenderer'; import type { ValdiWebRendererDelegate, WebRendererDebugSnapshot } from '../ValdiWebRendererDelegate'; import { MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS } from '../ValdiWebRendererDelegate'; +import type { ComponentPropertyEditCandidate, ComponentPropertyEditRegistrar } from './ComponentHierarchySnapshot'; import { hasWebLocationQueryParameter } from '../utils/LocationQuery'; const WEB_DEBUGGER_CHANNEL = 'valdi-web-debugger'; @@ -13,6 +14,7 @@ const SOURCE_METADATA_TRUNCATION_MARKER = '... '; export interface StandaloneWebDebuggerSnapshot { channel: string; + componentPropertyEditingAvailable: boolean; source: { title: string; url: string; @@ -23,10 +25,26 @@ export interface StandaloneWebDebuggerSnapshot { export interface StandaloneWebDebuggerRuntime { clearHighlight?(): boolean; + editComponentProperty?(request: unknown): boolean; getSnapshot(): StandaloneWebDebuggerSnapshot; highlightNode?(nodeId: string): boolean; } +interface ComponentPropertyEditTokenRecord extends ComponentPropertyEditCandidate { + readonly expiresAt: number; + readonly revision: number; +} + +interface ComponentPropertyEditCapture { + accepting: boolean; + available: boolean; + readonly captureGeneration: number; + readonly lifecycleGeneration: number; + nextToken?: string; + readonly registry: Map; + readonly revision: number; +} + interface DebuggableWindow extends Window { __VALDI_WEB_DEBUGGER__?: StandaloneWebDebuggerRuntime; } @@ -37,6 +55,14 @@ const PREVIOUS_STANDALONE_RUNTIMES = new WeakMap< StandaloneWebDebuggerRuntime, StandaloneWebDebuggerRuntime | undefined >(); +const COMPONENT_PROPERTY_TOKEN_BYTES = 16; +const COMPONENT_PROPERTY_TOKEN_LIMIT = 1_000; +const COMPONENT_PROPERTY_TOKEN_LIFETIME_MS = 120_000; +const COMPONENT_PROPERTY_TOKEN_PATTERN = /^[0-9a-f]{32}$/; +const MAX_COMPONENT_ID_CHARACTERS = 4_096; +const MAX_COMPONENT_PROPERTY_NAME_CHARACTERS = 256; +const MAX_COMPONENT_PROPERTY_STRING_BYTES = 65_536; +const COMPONENT_PROPERTY_EDIT_ERROR = 'The component property edit is stale or invalid.'; export class WebDebuggerBridge { private destroyed = false; @@ -44,6 +70,12 @@ export class WebDebuggerBridge { private highlightedNode?: HTMLDivElement; private standaloneRuntime?: StandaloneWebDebuggerRuntime; private previousStandaloneRuntime?: StandaloneWebDebuggerRuntime; + private componentPropertyEditTokens = new Map(); + private componentPropertyEditPreviousTokens = new Map(); + private componentPropertyEditCaptureGeneration = 0; + private componentPropertyEditExpiryTimer?: ReturnType; + private componentPropertyEditLifecycleGeneration = 0; + private componentPropertySnapshotRevision = 0; constructor( _root: HTMLElement | ShadowRoot, @@ -60,8 +92,11 @@ export class WebDebuggerBridge { if (!this.enabled || this.destroyed) { return; } - this.removeHighlightOverlay(); this.destroyed = true; + this.componentPropertyEditCaptureGeneration++; + this.componentPropertyEditLifecycleGeneration++; + this.clearComponentPropertyEditRegistry(); + this.removeHighlightOverlay(); const debuggableWindow = window as DebuggableWindow; if (this.standaloneRuntime) { @@ -83,32 +118,83 @@ export class WebDebuggerBridge { if (this.destroyed) { throw new Error('Web debugger runtime has been destroyed.'); } + const captureGeneration = ++this.componentPropertyEditCaptureGeneration; + const lifecycleGeneration = this.componentPropertyEditLifecycleGeneration; const source = { title: captureBoundedSourceMetadata(document.title), url: captureBoundedSourceMetadata(window.location.href), }; + let componentPropertyEditingAvailable = false; + try { + componentPropertyEditingAvailable = typeof this.renderer.editDebugComponentProperty === 'function'; + } catch (_error) { + componentPropertyEditingAvailable = false; + } const envelopeCharacters = JSON.stringify({ channel: WEB_DEBUGGER_CHANNEL, + componentPropertyEditingAvailable: false, 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 editCapture = this.createComponentPropertyEditCapture( + componentPropertyEditingAvailable, + captureGeneration, + lifecycleGeneration, ); + let snapshot: WebRendererDebugSnapshot; + try { + snapshot = this.delegate.getDebugSnapshot( + this.renderer, + Math.max(0, MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS - envelopeCharacters), + editCapture.available ? this.createComponentPropertyEditRegistrar(editCapture) : undefined, + ); + } catch (error) { + editCapture.accepting = false; + editCapture.registry.clear(); + if (this.isComponentPropertyEditCaptureCurrent(editCapture) && !editCapture.available) { + this.clearComponentPropertyEditRegistry(); + } + throw error; + } + editCapture.accepting = false; + const captureIsCurrent = this.isComponentPropertyEditCaptureCurrent(editCapture); + if (!captureIsCurrent || !editCapture.available) { + stripComponentPropertyEditMetadata(snapshot); + editCapture.registry.clear(); + } + if ( + !captureIsCurrent && + (this.destroyed || lifecycleGeneration !== this.componentPropertyEditLifecycleGeneration) + ) { + throw new Error('Web debugger runtime has been destroyed.'); + } + const componentPropertyEditingAvailableForResponse = captureIsCurrent && editCapture.available; const response: StandaloneWebDebuggerSnapshot = { channel: WEB_DEBUGGER_CHANNEL, + componentPropertyEditingAvailable: componentPropertyEditingAvailableForResponse, source, snapshot, type: 'snapshot', }; if (JSON.stringify(response).length <= MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS) { + if (captureIsCurrent) { + if (componentPropertyEditingAvailableForResponse) { + this.publishComponentPropertyEditRegistry(editCapture); + } else { + this.clearComponentPropertyEditRegistry(); + } + } return response; } + editCapture.registry.clear(); + if (captureIsCurrent && !editCapture.available) { + this.clearComponentPropertyEditRegistry(); + } return { ...response, + componentPropertyEditingAvailable: false, snapshot: { tree: null, viewport: { @@ -119,11 +205,181 @@ export class WebDebuggerBridge { }; } + private createComponentPropertyEditCapture( + componentPropertyEditingAvailable: boolean, + captureGeneration: number, + lifecycleGeneration: number, + ): ComponentPropertyEditCapture { + this.componentPropertySnapshotRevision = + this.componentPropertySnapshotRevision >= Number.MAX_SAFE_INTEGER + ? 1 + : this.componentPropertySnapshotRevision + 1; + const capture: ComponentPropertyEditCapture = { + accepting: true, + available: componentPropertyEditingAvailable, + captureGeneration, + lifecycleGeneration, + registry: new Map(), + revision: this.componentPropertySnapshotRevision, + }; + if (!capture.available) return capture; + capture.nextToken = this.createComponentPropertyToken(capture.registry); + if (capture.nextToken === undefined) capture.available = false; + return capture; + } + + private createComponentPropertyEditRegistrar(capture: ComponentPropertyEditCapture): ComponentPropertyEditRegistrar { + return candidate => { + if (!capture.accepting) return undefined; + if (!this.isComponentPropertyEditCaptureCurrent(capture)) { + capture.available = false; + capture.registry.clear(); + return undefined; + } + if (!capture.available || capture.registry.size >= COMPONENT_PROPERTY_TOKEN_LIMIT) return undefined; + const token = capture.nextToken; + if (token === undefined) { + capture.available = false; + capture.registry.clear(); + return undefined; + } + capture.registry.set(token, { + ...candidate, + descriptor: { ...candidate.descriptor }, + expiresAt: Date.now() + COMPONENT_PROPERTY_TOKEN_LIFETIME_MS, + revision: capture.revision, + }); + capture.nextToken = this.createComponentPropertyToken(capture.registry); + if (capture.nextToken === undefined && capture.registry.size < COMPONENT_PROPERTY_TOKEN_LIMIT) { + capture.available = false; + capture.registry.clear(); + return undefined; + } + return { componentToken: token, snapshotRevision: capture.revision }; + }; + } + + private editComponentProperty(request: unknown): boolean { + if (this.destroyed) throw new Error(COMPONENT_PROPERTY_EDIT_ERROR); + const parsed = parseComponentPropertyEditRequest(request); + if (parsed !== undefined) this.pruneExpiredComponentPropertyEditTokens(Date.now()); + const registry = + parsed === undefined + ? undefined + : this.componentPropertyEditTokens.has(parsed.componentToken) + ? this.componentPropertyEditTokens + : this.componentPropertyEditPreviousTokens.has(parsed.componentToken) + ? this.componentPropertyEditPreviousTokens + : undefined; + const record = parsed === undefined ? undefined : registry?.get(parsed.componentToken); + if ( + parsed === undefined || + record === undefined || + record.revision !== parsed.snapshotRevision || + record.componentId !== parsed.componentId || + record.propertyName !== parsed.propertyName + ) { + throw new Error(COMPONENT_PROPERTY_EDIT_ERROR); + } + + registry!.delete(parsed.componentToken); + if (this.componentPropertyEditTokenCount === 0) this.cancelComponentPropertyEditExpiry(); + try { + const edit = this.renderer.editDebugComponentProperty; + const updated = + typeof edit === 'function' && + edit.call(this.renderer, { + component: record.component, + expectedDescriptor: record.descriptor, + expectedViewModel: record.viewModel, + expectedViewModelExtensible: record.viewModelExtensible, + newValue: parsed.value, + node: record.node, + propertyName: record.propertyName, + }); + if (!updated) throw new Error(COMPONENT_PROPERTY_EDIT_ERROR); + return true; + } catch (_error) { + throw new Error(COMPONENT_PROPERTY_EDIT_ERROR); + } + } + + private isComponentPropertyEditCaptureCurrent(capture: ComponentPropertyEditCapture): boolean { + return ( + !this.destroyed && + capture.captureGeneration === this.componentPropertyEditCaptureGeneration && + capture.lifecycleGeneration === this.componentPropertyEditLifecycleGeneration + ); + } + + private cancelComponentPropertyEditExpiry(): void { + if (this.componentPropertyEditExpiryTimer === undefined) return; + clearTimeout(this.componentPropertyEditExpiryTimer); + this.componentPropertyEditExpiryTimer = undefined; + } + + private clearComponentPropertyEditRegistry(): void { + this.cancelComponentPropertyEditExpiry(); + this.componentPropertyEditTokens.clear(); + this.componentPropertyEditPreviousTokens.clear(); + } + + private get componentPropertyEditTokenCount(): number { + return this.componentPropertyEditTokens.size + this.componentPropertyEditPreviousTokens.size; + } + + private createComponentPropertyToken( + captureRegistry: Map, + ): string | undefined { + return createComponentPropertyToken( + token => + captureRegistry.has(token) || + this.componentPropertyEditTokens.has(token) || + this.componentPropertyEditPreviousTokens.has(token), + ); + } + + private publishComponentPropertyEditRegistry(capture: ComponentPropertyEditCapture): void { + this.cancelComponentPropertyEditExpiry(); + this.componentPropertyEditPreviousTokens.clear(); + this.componentPropertyEditPreviousTokens = this.componentPropertyEditTokens; + this.componentPropertyEditTokens = capture.registry; + this.pruneExpiredComponentPropertyEditTokens(Date.now()); + this.scheduleComponentPropertyEditExpiry(); + } + + private pruneExpiredComponentPropertyEditTokens(now: number): void { + for (const registry of [this.componentPropertyEditTokens, this.componentPropertyEditPreviousTokens]) { + for (const [token, record] of registry) { + if (record.expiresAt <= now) registry.delete(token); + } + } + if (this.componentPropertyEditTokenCount === 0) this.cancelComponentPropertyEditExpiry(); + } + + private scheduleComponentPropertyEditExpiry(): void { + if (this.destroyed || this.componentPropertyEditTokenCount === 0) return; + let nextExpiry = Number.POSITIVE_INFINITY; + for (const registry of [this.componentPropertyEditTokens, this.componentPropertyEditPreviousTokens]) { + for (const record of registry.values()) nextExpiry = Math.min(nextExpiry, record.expiresAt); + } + const delay = Math.max(0, nextExpiry - Date.now()); + let timer: ReturnType; + timer = setTimeout(() => { + if (this.componentPropertyEditExpiryTimer !== timer || this.destroyed) return; + this.componentPropertyEditExpiryTimer = undefined; + this.pruneExpiredComponentPropertyEditTokens(Date.now()); + this.scheduleComponentPropertyEditExpiry(); + }, delay); + this.componentPropertyEditExpiryTimer = timer; + } + private registerStandaloneRuntime(): void { const debuggableWindow = window as DebuggableWindow; this.previousStandaloneRuntime = debuggableWindow.__VALDI_WEB_DEBUGGER__; const runtime: StandaloneWebDebuggerRuntime = { clearHighlight: () => (this.destroyed ? false : this.removeHighlightOverlay()), + editComponentProperty: request => this.editComponentProperty(request), getSnapshot: () => this.getSnapshot(), highlightNode: nodeId => this.highlightNode(nodeId), }; @@ -194,6 +450,121 @@ export class WebDebuggerBridge { } } +interface ParsedComponentPropertyEditRequest { + readonly componentId: string; + readonly componentToken: string; + readonly propertyName: string; + readonly snapshotRevision: number; + readonly value: boolean | number | string; +} + +function parseComponentPropertyEditRequest(request: unknown): ParsedComponentPropertyEditRequest | undefined { + if (typeof request !== 'object' || request === null || Array.isArray(request)) return undefined; + try { + if (Object.getOwnPropertySymbols(request).length !== 0) return undefined; + const descriptors = Object.getOwnPropertyDescriptors(request); + const names = Object.keys(descriptors).sort(); + const expectedNames = ['componentId', 'componentToken', 'propertyName', 'snapshotRevision', 'value']; + if (names.length !== expectedNames.length || names.some((name, index) => name !== expectedNames[index])) { + return undefined; + } + const values = Object.create(null) as Record; + for (const name of names) { + const descriptor = descriptors[name]; + if ( + descriptor === undefined || + descriptor.enumerable !== true || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + return undefined; + } + values[name] = descriptor.value; + } + const componentId = values['componentId']; + const componentToken = values['componentToken']; + const propertyName = values['propertyName']; + const snapshotRevision = values['snapshotRevision']; + const value = values['value']; + if ( + typeof componentId !== 'string' || + componentId.length === 0 || + componentId.length > MAX_COMPONENT_ID_CHARACTERS || + typeof componentToken !== 'string' || + !COMPONENT_PROPERTY_TOKEN_PATTERN.test(componentToken) || + typeof propertyName !== 'string' || + propertyName.trim().length === 0 || + propertyName.length > MAX_COMPONENT_PROPERTY_NAME_CHARACTERS || + !Number.isSafeInteger(snapshotRevision) || + (snapshotRevision as number) <= 0 || + !isEditableComponentPropertyValue(value) + ) { + return undefined; + } + return { + componentId, + componentToken, + propertyName, + snapshotRevision: snapshotRevision as number, + value, + }; + } catch (_error) { + return undefined; + } +} + +function isEditableComponentPropertyValue(value: unknown): value is boolean | number | string { + return ( + (typeof value === 'string' && isUtf8ByteLengthAtMost(value, MAX_COMPONENT_PROPERTY_STRING_BYTES)) || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) + ); +} + +function isUtf8ByteLengthAtMost(value: string, maximumBytes: number): boolean { + let bytes = 0; + for (let index = 0; index < value.length; index++) { + const codePoint = value.codePointAt(index)!; + if (codePoint <= 0x7f) bytes += 1; + else if (codePoint <= 0x7ff) bytes += 2; + else if (codePoint <= 0xffff) bytes += 3; + else { + bytes += 4; + index++; + } + if (bytes > maximumBytes) return false; + } + return true; +} + +function createComponentPropertyToken(hasToken: (token: string) => boolean): string | undefined { + try { + const getRandomValues = globalThis.crypto?.getRandomValues; + if (typeof getRandomValues !== 'function') return undefined; + for (let attempt = 0; attempt < 8; attempt++) { + const bytes = new Uint8Array(COMPONENT_PROPERTY_TOKEN_BYTES); + getRandomValues.call(globalThis.crypto, bytes); + const token = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); + if (COMPONENT_PROPERTY_TOKEN_PATTERN.test(token) && !hasToken(token)) return token; + } + return undefined; + } catch (_error) { + return undefined; + } +} + +function stripComponentPropertyEditMetadata(snapshot: WebRendererDebugSnapshot): void { + if (snapshot.tree === null) return; + const pending = [snapshot.tree]; + const visited = new Set(); + while (pending.length > 0) { + const node = pending.pop()!; + if (visited.has(node)) continue; + visited.add(node); + if (node.component !== undefined) delete node.component.propertyEdits; + pending.push(...node.children); + } +} + function captureBoundedSourceMetadata(value: string): string { const maximumContentCharacters = MAX_SOURCE_METADATA_SERIALIZED_CHARACTERS - 2; const markerCharacters = getJsonStringContentCharacterLength(SOURCE_METADATA_TRUNCATION_MARKER); 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 index 38495db0..eea06aca 100644 --- a/src/valdi_modules/src/valdi/web_renderer/test/LegacyWebDebuggerAdapter.spec.ts +++ b/src/valdi_modules/src/valdi/web_renderer/test/LegacyWebDebuggerAdapter.spec.ts @@ -513,6 +513,220 @@ describe('ValdiWebRendererDelegate debugger adapter', () => { expect(Object.prototype.hasOwnProperty.call(snapshot.tree?.component?.properties ?? {}, 'secret')).toBeFalse(); }); + it('registers only exact captured own scalar data descriptors for component edits', () => { + class EditableComponent {} + + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + const element = makeRenderedElement(1, 'layout', {}); + const componentInstance = new EditableComponent() as unknown as IComponent; + const viewModel = Object.create(null) as Record; + const unusualPropertyName = 'line\r\n\u0000\ud800"[]'; + Object.defineProperties(viewModel, { + ' ': { configurable: true, enumerable: true, value: 'blank name', writable: true }, + children: { enumerable: true, value: 'blocked' }, + complex: { enumerable: true, value: { nested: true } }, + count: { configurable: false, enumerable: true, value: 4, writable: false }, + enabled: { configurable: true, enumerable: true, value: true, writable: true }, + infinite: { enumerable: true, value: Number.POSITIVE_INFINITY }, + title: { configurable: true, enumerable: true, value: 'safe', writable: true }, + [unusualPropertyName]: { configurable: true, enumerable: true, value: 'unusual', writable: true }, + zero: { enumerable: true, value: -0 }, + }); + const component = makeVirtualNode('editable', { component: componentInstance }); + component.componentViewModel = viewModel; + const elementNode = makeVirtualNode('layout', { element }); + setVirtualChildren(component, [elementNode]); + const candidates: Array> = []; + + const snapshot = delegate.getDebugSnapshot( + makeHierarchyRenderer([element], component), + MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS, + candidate => { + candidates.push(candidate as unknown as Record); + return { componentToken: candidates.length.toString(16).padStart(32, '0'), snapshotRevision: 7 }; + }, + ); + + expect(candidates.map(candidate => candidate['propertyName']).sort()).toEqual([ + 'count', + 'enabled', + unusualPropertyName, + 'title', + ]); + expect(candidates[0]?.['component'] === componentInstance).toBeTrue(); + expect(candidates[0]?.['componentId']).toBe('component:[null,"editable"]'); + expect(candidates[0]?.['node'] === (component as unknown as IRenderedVirtualNode)).toBeTrue(); + expect(candidates[0]?.['viewModel'] === viewModel).toBeTrue(); + expect(candidates.every(candidate => candidate['viewModelExtensible'] === true)).toBeTrue(); + expect(snapshot.tree?.component?.propertyEdits).toEqual({ + count: { componentToken: '0'.repeat(31) + '1', snapshotRevision: 7 }, + enabled: { componentToken: '0'.repeat(31) + '2', snapshotRevision: 7 }, + title: { componentToken: '0'.repeat(31) + '3', snapshotRevision: 7 }, + [unusualPropertyName]: { componentToken: '0'.repeat(31) + '4', snapshotRevision: 7 }, + }); + }); + + it('keeps custom-prototype, accessor-, method-, and oversized ViewModels read only', () => { + class ProxyComponent {} + class CustomViewModel { + title = 'custom'; + } + + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + const element = makeRenderedElement(1, 'layout', {}); + const component = makeVirtualNode('restricted', { + component: new ProxyComponent() as unknown as IComponent, + }); + const elementNode = makeVirtualNode('layout', { element }); + setVirtualChildren(component, [elementNode]); + const registrar = jasmine.createSpy('registrar').and.returnValue({ + componentToken: 'a'.repeat(32), + snapshotRevision: 1, + }); + + component.componentViewModel = new CustomViewModel(); + const customPrototypeSnapshot = delegate.getDebugSnapshot( + makeHierarchyRenderer([element], component), + MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS, + registrar, + ); + expect(customPrototypeSnapshot.tree?.component?.properties).toEqual({ title: 'custom' }); + expect(customPrototypeSnapshot.tree?.component?.propertyEdits).toBeUndefined(); + + const accessorGetter = jasmine.createSpy('accessorGetter').and.throwError('must not execute'); + const accessorViewModel = Object.create(null) as Record; + Object.defineProperties(accessorViewModel, { + readSecret: { configurable: true, enumerable: false, get: accessorGetter }, + title: { configurable: true, enumerable: true, value: 'accessor-backed', writable: true }, + }); + component.componentViewModel = accessorViewModel; + const accessorSnapshot = delegate.getDebugSnapshot( + makeHierarchyRenderer([element], component), + MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS, + registrar, + ); + expect(accessorSnapshot.tree?.component?.properties).toEqual({ title: 'accessor-backed' }); + expect(accessorSnapshot.tree?.component?.propertyEdits).toBeUndefined(); + expect(accessorGetter).not.toHaveBeenCalled(); + + const methodViewModel = { title: 'method-backed' }; + Object.defineProperty(methodViewModel, 'readSecret', { + configurable: true, + enumerable: false, + value() { + return 'secret'; + }, + writable: true, + }); + component.componentViewModel = methodViewModel; + const methodSnapshot = delegate.getDebugSnapshot( + makeHierarchyRenderer([element], component), + MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS, + registrar, + ); + expect(methodSnapshot.tree?.component?.properties).toEqual({ title: 'method-backed' }); + expect(methodSnapshot.tree?.component?.propertyEdits).toBeUndefined(); + + const oversizedViewModel = Object.fromEntries( + Array.from({ length: 1_001 }, (_value, index) => [`property${index}`, index]), + ); + component.componentViewModel = oversizedViewModel; + const oversizedSnapshot = delegate.getDebugSnapshot( + makeHierarchyRenderer([element], component), + MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS, + registrar, + ); + expect(oversizedSnapshot.tree?.component?.properties?.['property0']).toBe(0); + expect(oversizedSnapshot.tree?.component?.propertyEdits).toBeUndefined(); + expect(registrar).not.toHaveBeenCalled(); + }); + + it('retains read-only properties when a Proxy blocks edit-descriptor reflection', () => { + class ProxyComponent {} + + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + const element = makeRenderedElement(1, 'layout', {}); + let ownKeyReads = 0; + const viewModel = new Proxy( + { title: 'safe' }, + { + ownKeys: target => { + ownKeyReads++; + if (ownKeyReads > 1) throw new Error('edit reflection unavailable'); + return Reflect.ownKeys(target); + }, + }, + ); + const component = makeVirtualNode('proxy', { component: new ProxyComponent() as unknown as IComponent }); + component.componentViewModel = viewModel; + const elementNode = makeVirtualNode('layout', { element }); + setVirtualChildren(component, [elementNode]); + const registrar = jasmine.createSpy('registrar'); + + const snapshot = delegate.getDebugSnapshot( + makeHierarchyRenderer([element], component), + MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS, + registrar, + ); + + expect(snapshot.tree?.component?.properties).toEqual({ title: 'safe' }); + expect(snapshot.tree?.component?.propertyEdits).toBeUndefined(); + expect(registrar).not.toHaveBeenCalled(); + }); + + it('does not register a property deleted during Proxy reflection through a polluted descriptor-map prototype', () => { + class ProxyComponent {} + + const delegate = new ValdiWebRendererDelegate(dom.createElement('main')); + delegate.onElementCreated(1, 'layout'); + delegate.onElementBecameRoot(1); + const element = makeRenderedElement(1, 'layout', {}); + const target: { pollutedScalar?: string } = { pollutedScalar: 'safe' }; + let ownKeyReads = 0; + const viewModel = new Proxy(target, { + ownKeys: currentTarget => { + ownKeyReads++; + if (ownKeyReads === 2) delete currentTarget.pollutedScalar; + return Reflect.ownKeys(currentTarget); + }, + }); + const component = makeVirtualNode('proxy-polluted', { + component: new ProxyComponent() as unknown as IComponent, + }); + component.componentViewModel = viewModel; + const elementNode = makeVirtualNode('layout', { element }); + setVirtualChildren(component, [elementNode]); + const registrar = jasmine.createSpy('registrar').and.returnValue({ + componentToken: 'a'.repeat(32), + snapshotRevision: 1, + }); + Object.defineProperty(Object.prototype, 'pollutedScalar', { + configurable: true, + enumerable: false, + value: { configurable: true, enumerable: true, value: 'safe', writable: true }, + writable: true, + }); + try { + const snapshot = delegate.getDebugSnapshot( + makeHierarchyRenderer([element], component), + MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS, + registrar, + ); + + expect(snapshot.tree?.component?.properties).toEqual({ pollutedScalar: 'safe' }); + expect(snapshot.tree?.component?.propertyEdits).toBeUndefined(); + expect(registrar).not.toHaveBeenCalled(); + } finally { + delete (Object.prototype as Record)['pollutedScalar']; + } + }); + it('shares the component property budget without discarding over-budget component hierarchy', () => { class ParentBudgetComponent {} class ChildBudgetComponent {} 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 index c8c097d5..fa77fd5f 100644 --- a/src/valdi_modules/src/valdi/web_renderer/test/WebDebuggerBridge.spec.ts +++ b/src/valdi_modules/src/valdi/web_renderer/test/WebDebuggerBridge.spec.ts @@ -1,8 +1,13 @@ import 'jasmine/src/jasmine'; import type { IRenderer } from 'valdi_core/src/IRenderer'; -import type { ValdiWebRendererDelegate } from '../src/ValdiWebRendererDelegate'; +import type { + ValdiWebRendererDelegate, + WebRendererDebugPropertyEditMetadata, + WebRendererDebugSnapshot, +} from '../src/ValdiWebRendererDelegate'; import { MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS } from '../src/ValdiWebRendererDelegate'; -import type { StandaloneWebDebuggerRuntime } from '../src/debug/WebDebuggerBridge'; +import type { ComponentPropertyEditRegistrar } from '../src/debug/ComponentHierarchySnapshot'; +import type { StandaloneWebDebuggerRuntime, StandaloneWebDebuggerSnapshot } from '../src/debug/WebDebuggerBridge'; import { WebDebuggerBridge } from '../src/debug/WebDebuggerBridge'; interface FakeDebuggerWindow { @@ -16,10 +21,26 @@ interface FakeDebuggerWindow { removeEventListener: jasmine.Spy; } +interface WebDebuggerBridgeRegistryState { + componentPropertyEditExpiryTimer?: ReturnType; + componentPropertyEditPreviousTokens: Map; + componentPropertyEditTokens: Map; +} + +function registryState(bridge: WebDebuggerBridge): WebDebuggerBridgeRegistryState { + return bridge as unknown as WebDebuggerBridgeRegistryState; +} + +function registryTokenCount(bridge: WebDebuggerBridge): number { + const state = registryState(bridge); + return state.componentPropertyEditTokens.size + state.componentPropertyEditPreviousTokens.size; +} + describe('WebDebuggerBridge legacy renderer adapter', () => { let previousWindow: unknown; let previousDocument: unknown; let previousElement: unknown; + let previousCrypto: PropertyDescriptor | undefined; let fakeWindow: FakeDebuggerWindow; let delegate: ValdiWebRendererDelegate; let renderer: IRenderer; @@ -54,6 +75,7 @@ describe('WebDebuggerBridge legacy renderer adapter', () => { previousWindow = (globalThis as { window?: unknown }).window; previousDocument = (globalThis as { document?: unknown }).document; previousElement = (globalThis as { Element?: unknown }).Element; + previousCrypto = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); appendedOverlays = []; fakeWindow = { addEventListener: jasmine.createSpy('addEventListener'), @@ -66,6 +88,18 @@ describe('WebDebuggerBridge legacy renderer adapter', () => { }; fakeWindow.parent = fakeWindow; (globalThis as { Element?: unknown }).Element = FakeElement; + let tokenCounter = 0; + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + getRandomValues: (bytes: Uint8Array) => { + tokenCounter++; + bytes.fill(0); + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32(bytes.length - 4, tokenCounter); + return bytes; + }, + }, + }); (globalThis as { window?: unknown }).window = fakeWindow; (globalThis as { document?: unknown }).document = { body: { @@ -93,13 +127,15 @@ describe('WebDebuggerBridge legacy renderer adapter', () => { restoreGlobal('window', previousWindow); restoreGlobal('document', previousDocument); restoreGlobal('Element', previousElement); + restoreGlobalProperty('crypto', previousCrypto); }); - it('exposes the real top-level Owl renderer only after explicit debugger opt-in', () => { + it('exposes the real top-level Owl renderer read only when the renderer mutation API is unavailable', () => { const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); expect(fakeWindow.__VALDI_WEB_DEBUGGER__?.getSnapshot()).toEqual({ channel: 'valdi-web-debugger', + componentPropertyEditingAvailable: false, source: { title: 'Valdi Owl sample', url: fakeWindow.location.href }, snapshot: { tree: null, viewport: { width: 640, height: 480 } }, type: 'snapshot', @@ -134,7 +170,7 @@ describe('WebDebuggerBridge legacy renderer adapter', () => { 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).toHaveBeenCalledWith(renderer, jasmine.any(Number), undefined); expect(getDebugSnapshot.calls.mostRecent().args[1]).toBeLessThan(MAX_WEB_DEBUGGER_SERIALIZED_CHARACTERS); expect(renderer.getRootVirtualNode as unknown as jasmine.Spy).not.toHaveBeenCalled(); @@ -166,6 +202,654 @@ describe('WebDebuggerBridge legacy renderer adapter', () => { bridge.destroy(); }); + it('issues single-use exact-identity edit tokens', () => { + const component = {} as never; + const node = {} as never; + const viewModel = Object.defineProperty({}, 'title', { + configurable: false, + enumerable: true, + value: 'before', + writable: true, + }); + const editDebugComponentProperty = jasmine.createSpy('editDebugComponentProperty').and.returnValue(true); + renderer.editDebugComponentProperty = editDebugComponentProperty; + delegate.getDebugSnapshot = jasmine + .createSpy('getDebugSnapshot') + .and.callFake((_renderer: IRenderer, _maximum: number, registrar: ComponentPropertyEditRegistrar | undefined) => { + const propertyEdit = registrar?.({ + component, + componentId: 'component:[null,"root"]', + descriptor: Object.getOwnPropertyDescriptor(viewModel, 'title')!, + node, + propertyName: 'title', + viewModel, + viewModelExtensible: true, + }); + return { + tree: { + children: [], + component: { + key: 'root', + name: 'Root', + properties: { title: 'before' }, + ...(propertyEdit === undefined ? {} : { propertyEdits: { title: propertyEdit } }), + }, + id: 'component:[null,"root"]', + tag: 'Root', + }, + viewport: { width: 640, height: 480 }, + }; + }); + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + const runtime = fakeWindow.__VALDI_WEB_DEBUGGER__!; + + const first = runtime.getSnapshot(); + const firstEdit = first.snapshot.tree?.component?.propertyEdits?.['title']; + expect(first.componentPropertyEditingAvailable).toBeTrue(); + expect(firstEdit?.componentToken).toMatch(/^[0-9a-f]{32}$/); + expect(firstEdit?.snapshotRevision).toBe(1); + if (!firstEdit) throw new Error('Expected editable property metadata.'); + for (const invalidRequest of [ + { componentId: 'other-component' }, + { propertyName: 'other-property' }, + { propertyName: ' ' }, + { snapshotRevision: firstEdit.snapshotRevision + 1 }, + { value: '😀'.repeat(20_000) }, + ]) { + expect(() => + runtime.editComponentProperty?.({ + componentId: 'component:[null,"root"]', + componentToken: firstEdit.componentToken, + propertyName: 'title', + snapshotRevision: firstEdit.snapshotRevision, + value: 'after', + ...invalidRequest, + }), + ).toThrowError('The component property edit is stale or invalid.'); + } + expect(editDebugComponentProperty).not.toHaveBeenCalled(); + expect( + runtime.editComponentProperty?.({ + componentId: 'component:[null,"root"]', + componentToken: firstEdit.componentToken, + propertyName: 'title', + snapshotRevision: firstEdit.snapshotRevision, + value: 'after', + }), + ).toBeTrue(); + expect(editDebugComponentProperty).toHaveBeenCalledWith( + jasmine.objectContaining({ + component, + expectedViewModel: viewModel, + expectedViewModelExtensible: true, + newValue: 'after', + node, + propertyName: 'title', + }), + ); + expect(() => + runtime.editComponentProperty?.({ + componentId: 'component:[null,"root"]', + componentToken: firstEdit.componentToken, + propertyName: 'title', + snapshotRevision: firstEdit.snapshotRevision, + value: 'again', + }), + ).toThrowError('The component property edit is stale or invalid.'); + + const second = runtime.getSnapshot(); + const secondEdit = second.snapshot.tree?.component?.propertyEdits?.['title']; + if (!secondEdit) throw new Error('Expected replacement editable property metadata.'); + expect(secondEdit?.snapshotRevision).toBe(2); + expect(secondEdit?.componentToken).not.toBe(firstEdit.componentToken); + expect(() => + runtime.editComponentProperty?.({ + componentId: 'component:[null,"root"]', + componentToken: firstEdit.componentToken, + propertyName: 'title', + snapshotRevision: firstEdit.snapshotRevision, + value: 'stale', + }), + ).toThrowError('The component property edit is stale or invalid.'); + bridge.destroy(); + expect(() => + runtime.editComponentProperty?.({ + componentId: 'component:[null,"root"]', + componentToken: secondEdit.componentToken, + propertyName: 'title', + snapshotRevision: secondEdit.snapshotRevision, + value: 'after-destroy', + }), + ).toThrowError('The component property edit is stale or invalid.'); + expect(editDebugComponentProperty.calls.count()).toBe(1); + }); + + it('keeps published revisions intact when a delegate calls a retained registrar too late', () => { + const component = {} as never; + const node = {} as never; + const viewModel = { title: 'before' }; + const candidate = { + component, + componentId: 'component-id', + descriptor: Object.getOwnPropertyDescriptor(viewModel, 'title')!, + node, + propertyName: 'title', + viewModel, + viewModelExtensible: true, + }; + const retainedRegistrars: ComponentPropertyEditRegistrar[] = []; + renderer.editDebugComponentProperty = jasmine.createSpy('editDebugComponentProperty').and.returnValue(true); + delegate.getDebugSnapshot = jasmine + .createSpy('getDebugSnapshot') + .and.callFake((_renderer: IRenderer, _maximum: number, registrar: ComponentPropertyEditRegistrar | undefined) => { + if (!registrar) throw new Error('Expected component property editing to be available.'); + retainedRegistrars.push(registrar); + const metadata = registrar(candidate); + return { + tree: { + children: [], + component: { + key: 'root', + name: 'Root', + properties: { title: 'before' }, + propertyEdits: { title: metadata }, + }, + id: 'component-id', + tag: 'Root', + }, + viewport: { height: 1, width: 1 }, + }; + }); + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + const runtime = fakeWindow.__VALDI_WEB_DEBUGGER__!; + const first = runtime.getSnapshot().snapshot.tree?.component?.propertyEdits?.['title']; + const second = runtime.getSnapshot().snapshot.tree?.component?.propertyEdits?.['title']; + if (!first || !second) throw new Error('Expected current and previous edit metadata.'); + + expect(retainedRegistrars[0]?.(candidate)).toBeUndefined(); + expect(retainedRegistrars[1]?.(candidate)).toBeUndefined(); + expect(registryState(bridge).componentPropertyEditTokens.size).toBe(1); + expect(registryState(bridge).componentPropertyEditPreviousTokens.size).toBe(1); + for (const metadata of [first, second]) { + expect( + runtime.editComponentProperty?.({ + componentId: 'component-id', + componentToken: metadata.componentToken, + propertyName: 'title', + snapshotRevision: metadata.snapshotRevision, + value: 'after', + }), + ).toBeTrue(); + } + expect(registryTokenCount(bridge)).toBe(0); + bridge.destroy(); + }); + + it('retains exactly one prior accepted revision across a discarded in-flight poll', () => { + const component = {} as never; + const node = {} as never; + const viewModel = { title: 'before' }; + const editDebugComponentProperty = jasmine.createSpy('editDebugComponentProperty').and.returnValue(true); + renderer.editDebugComponentProperty = editDebugComponentProperty; + delegate.getDebugSnapshot = jasmine + .createSpy('getDebugSnapshot') + .and.callFake((_renderer: IRenderer, _maximum: number, registrar: ComponentPropertyEditRegistrar | undefined) => { + const metadata = registrar?.({ + component, + componentId: 'component-id', + descriptor: Object.getOwnPropertyDescriptor(viewModel, 'title')!, + node, + propertyName: 'title', + viewModel, + viewModelExtensible: true, + }); + return { + tree: { + children: [], + component: { + key: 'root', + name: 'Root', + properties: { title: 'before' }, + propertyEdits: { title: metadata }, + }, + id: 'component-id', + tag: 'Root', + }, + viewport: { height: 1, width: 1 }, + }; + }); + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + const runtime = fakeWindow.__VALDI_WEB_DEBUGGER__!; + const first = runtime.getSnapshot().snapshot.tree?.component?.propertyEdits?.['title']; + const second = runtime.getSnapshot().snapshot.tree?.component?.propertyEdits?.['title']; + if (!first || !second) throw new Error('Expected two accepted edit revisions.'); + + expect(registryState(bridge).componentPropertyEditTokens.size).toBe(1); + expect(registryState(bridge).componentPropertyEditPreviousTokens.size).toBe(1); + expect( + runtime.editComponentProperty?.({ + componentId: 'component-id', + componentToken: first.componentToken, + propertyName: 'title', + snapshotRevision: first.snapshotRevision, + value: 'visible-snapshot-edit', + }), + ).toBeTrue(); + + const third = runtime.getSnapshot().snapshot.tree?.component?.propertyEdits?.['title']; + const fourth = runtime.getSnapshot().snapshot.tree?.component?.propertyEdits?.['title']; + if (!third || !fourth) throw new Error('Expected bounded replacement edit revisions.'); + expect(registryTokenCount(bridge)).toBe(2); + expect(() => + runtime.editComponentProperty?.({ + componentId: 'component-id', + componentToken: second.componentToken, + propertyName: 'title', + snapshotRevision: second.snapshotRevision, + value: 'too-old', + }), + ).toThrowError('The component property edit is stale or invalid.'); + expect( + runtime.editComponentProperty?.({ + componentId: 'component-id', + componentToken: third.componentToken, + propertyName: 'title', + snapshotRevision: third.snapshotRevision, + value: 'previous', + }), + ).toBeTrue(); + expect( + runtime.editComponentProperty?.({ + componentId: 'component-id', + componentToken: fourth.componentToken, + propertyName: 'title', + snapshotRevision: fourth.snapshotRevision, + value: 'current', + }), + ).toBeTrue(); + expect(registryTokenCount(bridge)).toBe(0); + bridge.destroy(); + }); + + it('keeps a nested newer snapshot registry instead of adopting the superseded outer capture', () => { + const outerComponent = {} as never; + const innerComponent = {} as never; + const outerNode = {} as never; + const innerNode = {} as never; + const outerViewModel = { title: 'outer' }; + const innerViewModel = { title: 'inner' }; + const editDebugComponentProperty = jasmine.createSpy('editDebugComponentProperty').and.returnValue(true); + renderer.editDebugComponentProperty = editDebugComponentProperty; + let nested = false; + let runtime: StandaloneWebDebuggerRuntime; + let innerSnapshot: StandaloneWebDebuggerSnapshot | undefined; + delegate.getDebugSnapshot = jasmine + .createSpy('getDebugSnapshot') + .and.callFake((_renderer: IRenderer, _maximum: number, registrar: ComponentPropertyEditRegistrar | undefined) => { + const component = nested ? innerComponent : outerComponent; + const node = nested ? innerNode : outerNode; + const viewModel = nested ? innerViewModel : outerViewModel; + const title = nested ? 'inner' : 'outer'; + const propertyEdit = registrar?.({ + component, + componentId: `component-${title}`, + descriptor: Object.getOwnPropertyDescriptor(viewModel, 'title')!, + node, + propertyName: 'title', + viewModel, + viewModelExtensible: true, + }); + if (!nested) { + nested = true; + innerSnapshot = runtime.getSnapshot(); + nested = false; + } + return { + tree: { + children: [], + component: { + key: title, + name: 'Root', + properties: { title }, + ...(propertyEdit === undefined ? {} : { propertyEdits: { title: propertyEdit } }), + }, + id: `component-${title}`, + tag: 'Root', + }, + viewport: { width: 1, height: 1 }, + }; + }); + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + runtime = fakeWindow.__VALDI_WEB_DEBUGGER__!; + + const outerSnapshot = runtime.getSnapshot(); + const innerMetadata = innerSnapshot?.snapshot.tree?.component?.propertyEdits?.['title']; + + expect(outerSnapshot.componentPropertyEditingAvailable).toBeFalse(); + expect(outerSnapshot.snapshot.tree?.component?.propertyEdits).toBeUndefined(); + expect(innerSnapshot?.componentPropertyEditingAvailable).toBeTrue(); + expect(registryState(bridge).componentPropertyEditTokens.size).toBe(1); + if (!innerMetadata) throw new Error('Expected nested snapshot edit metadata.'); + expect( + runtime.editComponentProperty?.({ + componentId: 'component-inner', + componentToken: innerMetadata.componentToken, + propertyName: 'title', + snapshotRevision: innerMetadata.snapshotRevision, + value: 'inner-after', + }), + ).toBeTrue(); + expect(editDebugComponentProperty).toHaveBeenCalledWith( + jasmine.objectContaining({ + component: innerComponent, + expectedViewModel: innerViewModel, + node: innerNode, + }), + ); + bridge.destroy(); + }); + + it('invalidates a local capture when the bridge is destroyed reentrantly during snapshotting', () => { + const component = {} as never; + const node = {} as never; + const viewModel = { title: 'before' }; + renderer.editDebugComponentProperty = jasmine.createSpy('editDebugComponentProperty').and.returnValue(true); + let bridge: WebDebuggerBridge; + let capturedMetadata: WebRendererDebugPropertyEditMetadata | undefined; + let retainedSnapshot: WebRendererDebugSnapshot | undefined; + delegate.getDebugSnapshot = jasmine + .createSpy('getDebugSnapshot') + .and.callFake((_renderer: IRenderer, _maximum: number, registrar: ComponentPropertyEditRegistrar | undefined) => { + capturedMetadata = registrar?.({ + component, + componentId: 'component-id', + descriptor: Object.getOwnPropertyDescriptor(viewModel, 'title')!, + node, + propertyName: 'title', + viewModel, + viewModelExtensible: true, + }); + retainedSnapshot = { + tree: { + children: [], + component: { + key: 'root', + name: 'Root', + properties: { title: 'before' }, + ...(capturedMetadata === undefined ? {} : { propertyEdits: { title: capturedMetadata } }), + }, + id: 'component-id', + tag: 'Root', + }, + viewport: { width: 1, height: 1 }, + }; + bridge.destroy(); + return retainedSnapshot; + }); + bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + const runtime = fakeWindow.__VALDI_WEB_DEBUGGER__!; + + expect(() => runtime.getSnapshot()).toThrowError('Web debugger runtime has been destroyed.'); + expect(fakeWindow.__VALDI_WEB_DEBUGGER__).toBeUndefined(); + expect(registryState(bridge).componentPropertyEditTokens.size).toBe(0); + expect(registryState(bridge).componentPropertyEditPreviousTokens.size).toBe(0); + expect(registryState(bridge).componentPropertyEditExpiryTimer).toBeUndefined(); + expect(retainedSnapshot?.tree?.component?.propertyEdits).toBeUndefined(); + if (!capturedMetadata) throw new Error('Expected the disposed capture to issue metadata before destruction.'); + expect(() => + runtime.editComponentProperty?.({ + componentId: 'component-id', + componentToken: capturedMetadata?.componentToken, + propertyName: 'title', + snapshotRevision: capturedMetadata?.snapshotRevision, + value: 'after', + }), + ).toThrowError('The component property edit is stale or invalid.'); + }); + + it('consumes an authorized token before a renderer-side failure and expires current tokens', () => { + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(0)); + try { + const component = {} as never; + const node = {} as never; + const viewModel = { enabled: true }; + const editDebugComponentProperty = jasmine.createSpy('editDebugComponentProperty').and.returnValue(false); + renderer.editDebugComponentProperty = editDebugComponentProperty; + delegate.getDebugSnapshot = jasmine + .createSpy('getDebugSnapshot') + .and.callFake( + (_renderer: IRenderer, _maximum: number, registrar: ComponentPropertyEditRegistrar | undefined) => { + const propertyEdit = registrar?.({ + component, + componentId: 'component-id', + descriptor: Object.getOwnPropertyDescriptor(viewModel, 'enabled')!, + node, + propertyName: 'enabled', + viewModel, + viewModelExtensible: true, + }); + return { + tree: { + children: [], + component: { + key: 'root', + name: 'Root', + properties: { enabled: true }, + propertyEdits: { enabled: propertyEdit }, + }, + id: 'component-id', + tag: 'Root', + }, + viewport: { width: 1, height: 1 }, + }; + }, + ); + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + const runtime = fakeWindow.__VALDI_WEB_DEBUGGER__!; + const metadata = runtime.getSnapshot().snapshot.tree?.component?.propertyEdits?.['enabled']; + if (!metadata) throw new Error('Expected property edit metadata.'); + const request = { + componentId: 'component-id', + componentToken: metadata.componentToken, + propertyName: 'enabled', + snapshotRevision: metadata.snapshotRevision, + value: false, + }; + expect(() => runtime.editComponentProperty?.(request)).toThrowError( + 'The component property edit is stale or invalid.', + ); + editDebugComponentProperty.and.returnValue(true); + expect(() => runtime.editComponentProperty?.(request)).toThrowError( + 'The component property edit is stale or invalid.', + ); + expect(editDebugComponentProperty.calls.count()).toBe(1); + + const throwingMetadata = runtime.getSnapshot().snapshot.tree?.component?.propertyEdits?.['enabled']; + if (!throwingMetadata) throw new Error('Expected replacement property edit metadata.'); + editDebugComponentProperty.and.throwError('sensitive renderer identity'); + const throwingRequest = { + ...request, + componentToken: throwingMetadata.componentToken, + snapshotRevision: throwingMetadata.snapshotRevision, + }; + expect(() => runtime.editComponentProperty?.(throwingRequest)).toThrowError( + 'The component property edit is stale or invalid.', + ); + editDebugComponentProperty.and.returnValue(true); + expect(() => runtime.editComponentProperty?.(throwingRequest)).toThrowError( + 'The component property edit is stale or invalid.', + ); + expect(editDebugComponentProperty.calls.count()).toBe(2); + + const replacedMetadata = runtime.getSnapshot().snapshot.tree?.component?.propertyEdits?.['enabled']; + if (!replacedMetadata) throw new Error('Expected replacement property edit metadata.'); + expect(registryState(bridge).componentPropertyEditTokens.size).toBe(1); + const replacedTimer = registryState(bridge).componentPropertyEditExpiryTimer; + expect(replacedTimer).toBeDefined(); + jasmine.clock().tick(1); + const expiringMetadata = runtime.getSnapshot().snapshot.tree?.component?.propertyEdits?.['enabled']; + if (!expiringMetadata) throw new Error('Expected expiring property edit metadata.'); + expect(expiringMetadata.componentToken).not.toBe(replacedMetadata.componentToken); + expect(registryState(bridge).componentPropertyEditExpiryTimer).not.toBe(replacedTimer); + jasmine.clock().tick(119_999); + expect(registryState(bridge).componentPropertyEditTokens.size).toBe(1); + expect(registryState(bridge).componentPropertyEditPreviousTokens.size).toBe(0); + jasmine.clock().tick(1); + expect(registryState(bridge).componentPropertyEditTokens.size).toBe(0); + expect(registryState(bridge).componentPropertyEditPreviousTokens.size).toBe(0); + expect(registryState(bridge).componentPropertyEditExpiryTimer).toBeUndefined(); + expect(() => + runtime.editComponentProperty?.({ + ...request, + componentToken: expiringMetadata.componentToken, + snapshotRevision: expiringMetadata.snapshotRevision, + }), + ).toThrowError('The component property edit is stale or invalid.'); + expect(runtime.getSnapshot().snapshot.tree?.component?.propertyEdits?.['enabled']).toBeDefined(); + expect(registryState(bridge).componentPropertyEditTokens.size).toBe(1); + expect(registryState(bridge).componentPropertyEditExpiryTimer).toBeDefined(); + bridge.destroy(); + expect(registryState(bridge).componentPropertyEditTokens.size).toBe(0); + expect(registryState(bridge).componentPropertyEditExpiryTimer).toBeUndefined(); + } finally { + jasmine.clock().uninstall(); + } + }); + + it('limits each accepted revision to one thousand tokens and retains at most two thousand total', () => { + const component = {} as never; + const node = {} as never; + const viewModel = Object.create(null) as Record; + const properties = Object.create(null) as Record; + for (let index = 0; index <= 1_000; index++) { + const propertyName = `property${index}`; + Object.defineProperty(viewModel, propertyName, { + configurable: true, + enumerable: true, + value: index, + writable: true, + }); + Object.defineProperty(properties, propertyName, { + configurable: true, + enumerable: true, + value: index, + writable: true, + }); + } + renderer.editDebugComponentProperty = jasmine.createSpy('editDebugComponentProperty').and.returnValue(true); + delegate.getDebugSnapshot = jasmine + .createSpy('getDebugSnapshot') + .and.callFake((_renderer: IRenderer, _maximum: number, registrar: ComponentPropertyEditRegistrar | undefined) => { + const propertyEdits = Object.create(null) as Record; + for (const propertyName of Object.keys(properties)) { + const metadata = registrar?.({ + component, + componentId: 'component-id', + descriptor: Object.getOwnPropertyDescriptor(viewModel, propertyName)!, + node, + propertyName, + viewModel, + viewModelExtensible: true, + }); + if (metadata !== undefined) propertyEdits[propertyName] = metadata; + } + return { + tree: { + children: [], + component: { + key: 'root', + name: 'Root', + properties, + propertyEdits, + }, + id: 'component-id', + tag: 'Root', + }, + viewport: { width: 1, height: 1 }, + }; + }); + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + + const runtime = fakeWindow.__VALDI_WEB_DEBUGGER__!; + const snapshot = runtime.getSnapshot(); + const propertyEdits = snapshot.snapshot.tree?.component?.propertyEdits ?? {}; + + expect(snapshot.componentPropertyEditingAvailable).toBeTrue(); + expect(Object.keys(propertyEdits).length).toBe(1_000); + expect(propertyEdits['property999']?.componentToken).toMatch(/^[0-9a-f]{32}$/); + expect(propertyEdits['property1000']).toBeUndefined(); + runtime.getSnapshot(); + expect(registryState(bridge).componentPropertyEditTokens.size).toBe(1_000); + expect(registryState(bridge).componentPropertyEditPreviousTokens.size).toBe(1_000); + runtime.getSnapshot(); + expect(registryTokenCount(bridge)).toBe(2_000); + bridge.destroy(); + }); + + it('downgrades to read-only and clears both retained revisions when secure randomness fails', () => { + const component = {} as never; + const node = {} as never; + const viewModel = { title: 'safe' }; + renderer.editDebugComponentProperty = jasmine.createSpy('editDebugComponentProperty').and.returnValue(true); + delegate.getDebugSnapshot = jasmine + .createSpy('getDebugSnapshot') + .and.callFake((_renderer: IRenderer, _maximum: number, registrar: ComponentPropertyEditRegistrar | undefined) => { + const metadata = registrar?.({ + component, + componentId: 'component-id', + descriptor: Object.getOwnPropertyDescriptor(viewModel, 'title')!, + node, + propertyName: 'title', + viewModel, + viewModelExtensible: true, + }); + return { + tree: { + children: [], + component: { + key: 'root', + name: 'Root', + properties: { title: 'safe' }, + ...(metadata === undefined ? {} : { propertyEdits: { title: metadata } }), + }, + id: 'component-id', + tag: 'Root', + }, + viewport: { width: 1, height: 1 }, + }; + }); + const bridge = new WebDebuggerBridge({} as HTMLElement, delegate, renderer); + const runtime = fakeWindow.__VALDI_WEB_DEBUGGER__!; + const firstMetadata = runtime.getSnapshot().snapshot.tree?.component?.propertyEdits?.['title']; + if (!firstMetadata) throw new Error('Expected an initial secure token.'); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { + getRandomValues: () => { + throw new Error('unavailable'); + }, + }, + }); + + const snapshot = runtime.getSnapshot(); + + expect(snapshot.componentPropertyEditingAvailable).toBeFalse(); + expect(snapshot.snapshot.tree?.component?.properties).toEqual({ title: 'safe' }); + expect(snapshot.snapshot.tree?.component?.propertyEdits).toBeUndefined(); + expect(registryTokenCount(bridge)).toBe(0); + expect(() => + runtime.editComponentProperty?.({ + componentId: 'component-id', + componentToken: firstMetadata.componentToken, + propertyName: 'title', + snapshotRevision: firstMetadata.snapshotRevision, + value: 'after', + }), + ).toThrowError('The component property edit is stale or invalid.'); + 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'; @@ -205,9 +889,11 @@ describe('WebDebuggerBridge legacy renderer adapter', () => { 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, - ); + 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__!; @@ -297,6 +983,7 @@ describe('WebDebuggerBridge legacy renderer adapter', () => { return { getSnapshot: () => ({ channel: 'valdi-web-debugger', + componentPropertyEditingAvailable: true, source: { title, url: fakeWindow.location.href }, snapshot: { tree: null, viewport: { width: 1, height: 1 } }, type: 'snapshot', @@ -312,3 +999,8 @@ function restoreGlobal(name: string, value: unknown): void { (globalThis as Record)[name] = value; } } + +function restoreGlobalProperty(name: string, descriptor: PropertyDescriptor | undefined): void { + delete (globalThis as Record)[name]; + if (descriptor !== undefined) Object.defineProperty(globalThis, name, descriptor); +}