Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions npm_modules/cli/debugger/devtools-panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,15 @@ button {
color: var(--error);
}

.console-entry.warn {
background: color-mix(in srgb, var(--warning) 8%, var(--background));
color: var(--warning);
}

.console-entry.debug {
color: var(--muted);
}

.console-entry.result .console-chevron,
.console-entry.input .console-chevron {
color: var(--accent);
Expand Down
138 changes: 118 additions & 20 deletions npm_modules/cli/debugger/devtools-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ const state = {
hoveredNodeId: null,
highlightTimer: null,
consoleEntries: [],
consoleEntryKeys: new Set(),
consoleHistory: [],
consoleHistoryIndex: 0,
consoleStream: null,
consoleStreamTargetKey: null,
error: null,
};

Expand Down Expand Up @@ -124,13 +127,7 @@ function walk(node, callback) {
}

function walkVisible(node, callback) {
valdiDebuggerTreeModel.walkVisible(
node,
callback,
current => state.expandedNodeIds.has(nodeId(current)),
[],
0,
);
valdiDebuggerTreeModel.walkVisible(node, callback, current => state.expandedNodeIds.has(nodeId(current)), [], 0);
}

function nodeCount() {
Expand Down Expand Up @@ -210,17 +207,26 @@ async function connectToInspectedApplication() {
}

try {
const payload = await requestJson(
'/api/devtools/target',
{ inspectedUrl, targetNonce: inspectedTargetNonce },
{},
);
const payload = await requestJson('/api/devtools/target', { inspectedUrl, targetNonce: inspectedTargetNonce }, {});
const previousTargetKey = state.target
? `${state.target.id}:${state.target.sessionId}:${inspectedTargetNonce}`
: null;
const nextTargetKey = `${payload.target.id}:${payload.target.sessionId}:${inspectedTargetNonce}`;
if (previousTargetKey !== null && previousTargetKey !== nextTargetKey) {
stopConsoleStream();
state.consoleEntries = [];
state.consoleEntryKeys.clear();
elements.consoleMessages.innerHTML = '';
}
state.target = payload.target;
elements.targetName.textContent = state.target.name || 'Valdi application';
elements.targetName.title = state.target.applicationUrl || inspectedUrl;
elements.targetMetadata.textContent = `Chromium · :${state.target.debuggingPort}`;
setConnected(true);
addConsoleEntry('info', `Connected to ${state.target.applicationUrl}`);
if (previousTargetKey !== nextTargetKey) {
addConsoleEntry('info', `Connected to ${state.target.applicationUrl}`);
}
startConsoleStream();
await refreshSnapshot();
startRefreshTimer();
} catch (error) {
Expand Down Expand Up @@ -591,20 +597,106 @@ function setActiveDetail(detail) {
renderInspector();
}

function addConsoleEntry(kind, value) {
function stopConsoleStream() {
if (!state.consoleStream) return;
state.consoleStream.close();
state.consoleStream = null;
state.consoleStreamTargetKey = null;
}

function startConsoleStream() {
if (!state.target || !state.autoRefresh || !inspectedUrl || !inspectedTargetNonce) {
stopConsoleStream();
return;
}
const targetKey = `${state.target.id}:${state.target.sessionId}:${inspectedTargetNonce}`;
if (state.consoleStream && state.consoleStreamTargetKey === targetKey) return;
stopConsoleStream();

const url = new URL('/api/devtools/console/stream', window.location.origin);
url.searchParams.set('inspectedUrl', inspectedUrl);
url.searchParams.set('sessionId', state.target.sessionId);
url.searchParams.set('targetNonce', inspectedTargetNonce);
const stream = new EventSource(url.toString());
state.consoleStream = stream;
state.consoleStreamTargetKey = targetKey;

stream.addEventListener('console', event => {
if (state.consoleStream !== stream || !state.target) return;
let entry;
try {
entry = JSON.parse(event.data);
} catch (error) {
console.warn('[Valdi DevTools] Ignoring a malformed Chromium console event.', error);
return;
}
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return;
if (
entry.sessionId !== state.target.sessionId ||
entry.targetId !== state.target.id ||
typeof entry.message !== 'string'
) {
return;
}
addConsoleEntry(entry.level, entry.message, entry.timestamp, entry.source);
});

stream.addEventListener('stream-error', event => {
if (state.consoleStream !== stream || !state.target) return;
try {
const payload = JSON.parse(event.data);
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return;
if (
payload.sessionId === state.target.sessionId &&
payload.targetId === state.target.id &&
typeof payload.error === 'string'
) {
addConsoleEntry('error', payload.error);
}
} catch (error) {
console.warn('[Valdi DevTools] Ignoring a malformed Chromium console stream error.', error);
}
});

stream.addEventListener('stream-warning', event => {
if (state.consoleStream !== stream || !state.target) return;
try {
const payload = JSON.parse(event.data);
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return;
if (
payload.sessionId === state.target.sessionId &&
payload.targetId === state.target.id &&
typeof payload.message === 'string'
) {
addConsoleEntry('warn', payload.message);
}
} catch (error) {
console.warn('[Valdi DevTools] Ignoring a malformed Chromium console stream warning.', error);
}
});
}

function addConsoleEntry(kind, value, timestamp, source) {
const normalizedKind = ['debug', 'error', 'info', 'input', 'log', 'result', 'warn'].includes(kind) ? kind : 'log';
const text = String(value);
const boundedValue =
text.length > MAX_CONSOLE_ENTRY_CHARACTERS
? `${text.slice(0, MAX_CONSOLE_ENTRY_CHARACTERS - 1)}…`
: text;
state.consoleEntries.push({ kind, value: boundedValue });
text.length > MAX_CONSOLE_ENTRY_CHARACTERS ? `${text.slice(0, MAX_CONSOLE_ENTRY_CHARACTERS - 1)}…` : text;
const key = timestamp === undefined ? null : `${timestamp}:${normalizedKind}:${String(source ?? '')}:${boundedValue}`;
if (key !== null) {
if (state.consoleEntryKeys.has(key)) return;
state.consoleEntryKeys.add(key);
}
state.consoleEntries.push({ key, kind: normalizedKind, value: boundedValue });
if (state.consoleEntries.length > MAX_CONSOLE_ENTRIES) {
state.consoleEntries.splice(0, state.consoleEntries.length - MAX_CONSOLE_ENTRIES);
const discarded = state.consoleEntries.splice(0, state.consoleEntries.length - MAX_CONSOLE_ENTRIES);
for (const entry of discarded) {
if (entry.key !== null) state.consoleEntryKeys.delete(entry.key);
}
}
elements.consoleMessages.innerHTML = state.consoleEntries
.map(
entry =>
`<div class="console-entry ${escapeHtml(entry.kind)}"><span class="console-chevron">${entry.kind === 'input' ? '›' : entry.kind === 'error' ? '×' : '‹'}</span><pre>${escapeHtml(entry.value)}</pre></div>`,
`<div class="console-entry ${escapeHtml(entry.kind)}"><span class="console-chevron">${entry.kind === 'input' ? '›' : entry.kind === 'error' ? '×' : entry.kind === 'warn' ? '!' : '‹'}</span><pre>${escapeHtml(entry.value)}</pre></div>`,
)
.join('');
elements.consoleMessages.scrollTop = elements.consoleMessages.scrollHeight;
Expand Down Expand Up @@ -658,6 +750,11 @@ function wireEvents() {
elements.refreshButton.addEventListener('click', () => void refreshSnapshot());
elements.autoRefreshToggle.addEventListener('change', () => {
state.autoRefresh = elements.autoRefreshToggle.checked;
if (state.autoRefresh) {
startConsoleStream();
} else {
stopConsoleStream();
}
});
elements.treeFilter.addEventListener('input', () => {
state.search = elements.treeFilter.value;
Expand Down Expand Up @@ -732,6 +829,7 @@ function wireEvents() {
applyTheme(event.data.theme);
}
});
window.addEventListener('pagehide', stopConsoleStream);
document.addEventListener('visibilitychange', () => {
if (!document.hidden && state.activeSection === 'elements') void refreshSnapshot();
});
Expand Down
181 changes: 181 additions & 0 deletions npm_modules/cli/src/debugger/chromiumConsole.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import 'jasmine';
import {
ChromiumConsoleLevel,
ChromiumConsoleSource,
MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH,
formatChromiumConsoleEvent,
} from './chromiumConsole';

describe('Chromium DevTools console event formatting', () => {
it('preserves console levels, primitive arguments, substitutions, and timestamps', () => {
const entry = formatChromiumConsoleEvent({
method: 'Runtime.consoleAPICalled',
params: {
args: [
{ type: 'string', value: '%cCount: %d (%s)' },
{ type: 'string', value: 'color: red' },
{ type: 'number', value: 7.9 },
{ type: 'string', value: 'ready' },
],
timestamp: 1234,
type: 'warning',
},
});

expect(entry).toEqual({
level: ChromiumConsoleLevel.Warning,
message: 'Count: 7 (ready)',
source: ChromiumConsoleSource.Console,
timestamp: 1234,
});
});

it('renders bounded object and array previews while redacting sensitive property values', () => {
const entry = formatChromiumConsoleEvent({
method: 'Runtime.consoleAPICalled',
params: {
args: [
{
preview: {
properties: [
{ name: 'screen', type: 'string', value: 'showcase' },
{ name: 'authorization', type: 'string', value: 'Bearer private-token' },
{ name: 'accessToken', type: 'string', value: 'private-token' },
],
},
type: 'object',
},
{
preview: {
properties: [
{ name: '0', type: 'string', value: 'first' },
{ name: '1', type: 'string', value: 'second' },
],
subtype: 'array',
},
subtype: 'array',
type: 'object',
},
],
type: 'info',
},
});

expect(entry?.message).toBe(
'{screen: showcase, authorization: [REDACTED], accessToken: [REDACTED]} [first, second]',
);
expect(entry?.message).not.toContain('private-token');
});

it('redacts headers, credentials, query parameters, and common API keys', () => {
const entry = formatChromiumConsoleEvent({
method: 'Log.entryAdded',
params: {
entry: {
level: 'warning',
text:
'authorization: Bearer synthetic-token\n' +
'Cookie: session=private-cookie\n' +
'password: correct horse battery staple\n' +
'{"access_token":"private-access-token","password":"private-password"}\n' +
'https://example.test/callback?code=private-code&safe=ok\n' +
'sk-proj-abcdefghijklmnopqrst',
timestamp: 42,
},
},
});

expect(entry?.message).toContain('authorization: [REDACTED]');
expect(entry?.message).toContain('Cookie: [REDACTED]');
expect(entry?.message).toContain('password: [REDACTED]\n');
expect(entry?.message).toContain('"access_token":[REDACTED]');
expect(entry?.message).toContain('?code=[REDACTED]&safe=ok');
expect(entry?.message).not.toContain('private-');
expect(entry?.message).not.toContain('correct horse battery staple');
expect(entry?.message).not.toContain('abcdefghijklmnopqrst');
});

it('surfaces runtime exceptions and bounds oversized messages', () => {
const exception = formatChromiumConsoleEvent({
method: 'Runtime.exceptionThrown',
params: {
exceptionDetails: {
exception: { description: 'Error: Synthetic failure\n at render (showcase.js:4:2)' },
text: 'Uncaught',
},
timestamp: 84,
},
});
const oversized = formatChromiumConsoleEvent({
method: 'Runtime.consoleAPICalled',
params: {
args: [
{
type: 'string',
value: `${'x'.repeat(32_760)} sk-proj-abcdefghijklmnopqrst ${'y'.repeat(100_000)}`,
},
],
type: 'log',
},
});

expect(exception).toEqual({
level: ChromiumConsoleLevel.Error,
message: 'Error: Synthetic failure\n at render (showcase.js:4:2)',
source: ChromiumConsoleSource.Exception,
timestamp: 84,
});
expect(oversized?.message.length).toBe(MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH + 1);
expect(oversized?.message.endsWith('…')).toBeTrue();
expect(oversized?.message).not.toContain('abcdefghijklmnopqrst');
});

it('does not invoke accessors or recurse through deep and proxy-like remote values', () => {
let getterCalls = 0;
const remoteObject: Record<string, unknown> = { type: 'object', value: {} };
let deep = remoteObject['value'] as Record<string, unknown>;
for (let index = 0; index < 20_000; index += 1) {
const next: Record<string, unknown> = {};
deep['next'] = next;
deep = next;
}
Object.defineProperty(remoteObject, 'description', {
enumerable: true,
get: () => {
getterCalls += 1;
throw new Error('must not run');
},
});
const revoked = Proxy.revocable({ type: 'object', description: 'private-value' }, {});
revoked.revoke();

const startedAt = performance.now();
const entry = formatChromiumConsoleEvent({
method: 'Runtime.consoleAPICalled',
params: { args: [remoteObject, revoked.proxy], type: 'debug' },
});

expect(performance.now() - startedAt).toBeLessThan(1000);
expect(getterCalls).toBe(0);
expect(entry?.message).toBe('object [Unavailable]');
expect(entry?.message).not.toContain('private-value');
});

it('bounds sparse argument arrays and ignores malformed or unrelated events', () => {
const sparse: unknown[] = [];
sparse.length = 10_000_000;
sparse[0] = { type: 'string', value: 'first' };

const entry = formatChromiumConsoleEvent({
method: 'Runtime.consoleAPICalled',
params: { args: sparse, type: 'log' },
});

expect(entry?.message.length).toBeLessThan(2000);
expect(entry?.message.startsWith('first')).toBeTrue();
expect(formatChromiumConsoleEvent({ method: 'Network.requestWillBeSent', params: {} })).toBeNull();
expect(formatChromiumConsoleEvent({ method: 'Runtime.consoleAPICalled', params: {} })).toBeNull();
expect(formatChromiumConsoleEvent({ method: 'Log.entryAdded', params: { entry: 'invalid' } })).toBeNull();
expect(formatChromiumConsoleEvent({ method: 'Runtime.exceptionThrown', params: {} })).toBeNull();
});
});
Loading
Loading