diff --git a/src/adapter/objectPreview/index.ts b/src/adapter/objectPreview/index.ts index bdff413ef..89e245e49 100644 --- a/src/adapter/objectPreview/index.ts +++ b/src/adapter/objectPreview/index.ts @@ -600,6 +600,84 @@ export function formatAsTable(param: Cdp.Runtime.ObjectPreview): string { return table.map(row => stringUtils.trimEnd(row, maxTableWidth)).join('\n'); } +function formatPropertyPreviewAsJson(prop: Cdp.Runtime.PropertyPreview): string | undefined { + switch (prop.type) { + case 'string': + return JSON.stringify(prop.value ?? ''); + case 'number': { + const num = Number(prop.value); + return isFinite(num) ? String(num) : 'null'; + } + case 'boolean': + return prop.value === 'true' ? 'true' : 'false'; + case 'undefined': + case 'function': + case 'symbol': + case 'accessor': + return undefined; // omit from JSON (matches JSON.stringify behavior) + case 'bigint': + return undefined; // not JSON serializable + case 'object': + if (prop.subtype === 'null') return 'null'; + if (prop.valuePreview) return formatObjectPreviewAsJson(prop.valuePreview); + return '{}'; + default: + return prop.value !== undefined ? JSON.stringify(prop.value) : 'null'; + } +} + +function formatObjectPreviewAsJson(preview: Cdp.Runtime.ObjectPreview): string { + if (preview.subtype === 'array' || preview.subtype === 'typedarray') { + const items: (string | undefined)[] = []; + for (const prop of preview.properties || []) { + if (!/^\d+$/.test(prop.name)) continue; + items[parseInt(prop.name, 10)] = formatPropertyPreviewAsJson(prop) ?? 'null'; + } + const result: string[] = []; + for (let i = 0; i < items.length; i++) { + result.push(items[i] ?? 'null'); + } + return '[' + result.join(',') + ']'; + } + + const parts: string[] = []; + for (const prop of preview.properties || []) { + const value = formatPropertyPreviewAsJson(prop); + if (value !== undefined) { + parts.push(JSON.stringify(prop.name) + ':' + value); + } + } + return '{' + parts.join(',') + '}'; +} + +function formatAsJson(param: ObjectPreview.AnyObject): string { + const raw = param as Cdp.Runtime.RemoteObject; + + if (raw.type === 'string') { + return JSON.stringify(raw.value ?? raw.description ?? ''); + } + if (raw.type === 'number') { + if (raw.unserializableValue) return 'null'; // NaN, Infinity, -Infinity → null in JSON + return String(raw.value ?? raw.description); + } + if (raw.type === 'boolean') { + return String(raw.value); + } + if (raw.type === 'undefined') { + return 'undefined'; + } + if (raw.type === 'bigint' || raw.type === 'symbol') { + return 'undefined'; // not JSON serializable + } + if (raw.type === 'function') { + return 'undefined'; + } + // raw.type === 'object' + if (raw.subtype === 'null') return 'null'; + if (raw.preview) return formatObjectPreviewAsJson(raw.preview); + return '{}'; +} + export const messageFormatters: messageFormat.Formatters = new Map([ ['', (param, context) => previewRemoteObjectInternal(param, context)], ['s', (param, context) => formatAsString(param as ObjectPreview.StringObj, context.budget)], @@ -619,6 +697,7 @@ export const messageFormatters: messageFormat.Formatters messageFormat.formatCssAsAnsi((param as { value: string }).value)], + ['j', param => formatAsJson(param)], ['o', (param, context) => previewRemoteObjectInternal(param, context)], ['O', (param, context) => previewRemoteObjectInternal(param, context)], ]); diff --git a/src/adapter/objectPreview/objectPreview.test.ts b/src/adapter/objectPreview/objectPreview.test.ts new file mode 100644 index 000000000..739433a27 --- /dev/null +++ b/src/adapter/objectPreview/objectPreview.test.ts @@ -0,0 +1,172 @@ +/*--------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ + +import { expect } from 'chai'; +import { formatMessage } from '../messageFormat'; +import { messageFormatters } from './index'; + +describe('objectPreview', () => { + describe('%j format specifier', () => { + const format = (fmt: string, ...args: object[]) => + formatMessage(fmt, args as never, messageFormatters).result; + + it('formats a simple object as JSON', () => { + expect( + format('%j', { + type: 'object', + subtype: undefined, + className: 'Object', + description: 'Object', + preview: { + type: 'object', + description: 'Object', + overflow: false, + properties: [{ name: 'host', type: 'string', value: 'localhost' }], + }, + }), + ).to.equal('{"host":"localhost"}'); + }); + + it('formats a nested object as JSON', () => { + expect( + format('%j', { + type: 'object', + subtype: undefined, + className: 'Object', + description: 'Object', + preview: { + type: 'object', + description: 'Object', + overflow: false, + properties: [ + { + name: 'a', + type: 'object', + subtype: undefined, + valuePreview: { + type: 'object', + description: 'Object', + overflow: false, + properties: [{ name: 'b', type: 'number', value: '1' }], + }, + }, + ], + }, + }), + ).to.equal('{"a":{"b":1}}'); + }); + + it('formats an array as JSON', () => { + expect( + format('%j', { + type: 'object', + subtype: 'array', + className: 'Array', + description: 'Array(3)', + preview: { + type: 'object', + subtype: 'array', + description: 'Array(3)', + overflow: false, + properties: [ + { name: '0', type: 'number', value: '1' }, + { name: '1', type: 'number', value: '2' }, + { name: '2', type: 'number', value: '3' }, + ], + }, + }), + ).to.equal('[1,2,3]'); + }); + + it('formats null as JSON', () => { + expect(format('%j', { type: 'object', subtype: 'null' })).to.equal('null'); + }); + + it('formats a string as JSON', () => { + expect( + format('%j', { type: 'string', value: 'hello', subtype: undefined }), + ).to.equal('"hello"'); + }); + + it('formats a number as JSON', () => { + expect( + format('%j', { type: 'number', value: 42, description: '42', subtype: undefined }), + ).to.equal('42'); + }); + + it('formats NaN as null in JSON', () => { + expect( + format('%j', { + type: 'number', + unserializableValue: 'NaN', + description: 'NaN', + subtype: undefined, + }), + ).to.equal('null'); + }); + + it('formats a boolean as JSON', () => { + expect( + format('%j', { type: 'boolean', value: true, description: 'true', subtype: undefined }), + ).to.equal('true'); + }); + + it('formats undefined as the string "undefined"', () => { + expect(format('%j', { type: 'undefined', subtype: undefined })).to.equal('undefined'); + }); + + it('formats a function as the string "undefined"', () => { + expect( + format('%j', { + type: 'function', + subtype: undefined, + description: 'function() {}', + }), + ).to.equal('undefined'); + }); + + it('handles mixed specifiers correctly', () => { + expect( + format( + '%s: %s %j', + { type: 'string', value: 'id', subtype: undefined }, + { type: 'string', value: 'Request headers', subtype: undefined }, + { + type: 'object', + subtype: undefined, + className: 'Object', + description: 'Object', + preview: { + type: 'object', + description: 'Object', + overflow: false, + properties: [{ name: 'host', type: 'string', value: 'localhost' }], + }, + }, + ), + ).to.equal('id: Request headers {"host":"localhost"}'); + }); + + it('omits undefined properties from JSON object', () => { + expect( + format('%j', { + type: 'object', + subtype: undefined, + className: 'Object', + description: 'Object', + preview: { + type: 'object', + description: 'Object', + overflow: false, + properties: [ + { name: 'a', type: 'string', value: 'hello' }, + { name: 'fn', type: 'function', value: 'function()' }, + { name: 'undef', type: 'undefined' }, + ], + }, + }), + ).to.equal('{"a":"hello"}'); + }); + }); +}); diff --git a/src/test/console/console-format-json-format.txt b/src/test/console/console-format-json-format.txt new file mode 100644 index 000000000..231b202f4 --- /dev/null +++ b/src/test/console/console-format-json-format.txt @@ -0,0 +1,18 @@ +Evaluating: 'console.log("%j", {host: "localhost"})' +stdout> {"host":"localhost"} +stdout> > {"host":"localhost"} +stdout> > arg1: {host: 'localhost'} + +Evaluating: 'console.log("%s: %s %j", "id", "Request headers", {host: "localhost"})' +stdout> id: Request headers {"host":"localhost"} +stdout> > id: Request headers {"host":"localhost"} +stdout> > arg3: {host: 'localhost'} + +Evaluating: 'console.log("%j", [1, 2, 3])' +stdout> [1,2,3] +stdout> > [1,2,3] +stdout> > arg1: (3) [1, 2, 3] + +Evaluating: 'console.log("%j", null)' +stdout> null + diff --git a/src/test/console/consoleFormatTest.ts b/src/test/console/consoleFormatTest.ts index f7a9fd5e3..24a709e96 100644 --- a/src/test/console/consoleFormatTest.ts +++ b/src/test/console/consoleFormatTest.ts @@ -444,6 +444,17 @@ describe('console format', () => { p.assertLog(); }); + itIntegrates('json format', async ({ r }) => { + const p = await r.launchAndLoad('blank'); + await p.logger.evaluateAndLog([ + `console.log("%j", {host: "localhost"})`, + `console.log("%s: %s %j", "id", "Request headers", {host: "localhost"})`, + `console.log("%j", [1, 2, 3])`, + `console.log("%j", null)`, + ]); + p.assertLog(); + }); + itIntegrates('colors', async ({ r }) => { const p = await r.launchAndLoad(`blank`);