Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { findHighlightRanges, formatObjectLogMessage } from './format-object-log-message'

describe('formatObjectLogMessage', () => {
it('formats a JSON object with two-space indentation', () => {
expect(formatObjectLogMessage('{"message":"Started","context":{"attempt":2},"ports":[80,443]}').message).toBe(`{
"message": "Started",
"context": {
"attempt": 2
},
"ports": [
80,
443
]
}`)
})

it('preserves numeric literals and duplicate keys', () => {
expect(formatObjectLogMessage('{"id":9007199254740993,"decimal":42.0,"exponent":1e3,"key":1,"key":2}').message)
.toBe(`{
"id": 9007199254740993,
"decimal": 42.0,
"exponent": 1e3,
"key": 1,
"key": 2
}`)
})

it('preserves whitespace and escaped characters inside strings', () => {
expect(formatObjectLogMessage('{"message":"keep spaces, braces { } and \\"quotes\\""}').message).toBe(`{
"message": "keep spaces, braces { } and \\"quotes\\""
}`)
})

it('returns the formatted ranges for object keys', () => {
const formattedLogMessage = formatObjectLogMessage('{"message":"Started","context":{"attempt":2}}')

expect(
formattedLogMessage.keyRanges?.map(({ start, end }) => formattedLogMessage.message.slice(start, end))
).toEqual(['"message"', '"context"', '"attempt"'])
})

it.each([
['invalid JSON', '{"message":"Started"'],
['an array', '["Started"]'],
['a string', '"Started"'],
['a number', '42'],
['a boolean', 'true'],
['null', 'null'],
['plain text', 'Started successfully'],
])('keeps %s unchanged', (_description, message) => {
expect(formatObjectLogMessage(message)).toEqual({ message })
})

it('maps a raw compact match to its formatted range', () => {
const rawMessage = '{"message":"Started","attempt":2}'
const formattedLogMessage = formatObjectLogMessage(rawMessage)
const ranges = findHighlightRanges(rawMessage, formattedLogMessage, '"message":"Started"')

expect(ranges).toEqual([{ start: 4, end: 24 }])
expect(formattedLogMessage.message.slice(ranges[0]?.start, ranges[0]?.end)).toBe('"message": "Started"')
})

it('finds case-insensitive matches in plain-text messages', () => {
const rawMessage = 'Started successfully'

expect(findHighlightRanges(rawMessage, formatObjectLogMessage(rawMessage), 'started')).toEqual([
{ start: 0, end: 7 },
])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
export interface HighlightRange {
start: number
end: number
}

export interface FormattedLogMessage {
message: string
sourceMap?: number[]
keyRanges?: HighlightRange[]
}

function nextNonWhitespaceCharacter(message: string, start: number) {
for (let index = start; index < message.length; index++) {
if (!/\s/.test(message[index] ?? '')) return message[index]
}

return undefined
}

function previousNonWhitespaceCharacter(message: string, start: number) {
for (let index = start; index >= 0; index--) {
if (!/\s/.test(message[index] ?? '')) return message[index]
}

return undefined
}

export function formatObjectLogMessage(message: string): FormattedLogMessage {
try {
const parsedMessage: unknown = JSON.parse(message)

if (typeof parsedMessage !== 'object' || parsedMessage === null || Array.isArray(parsedMessage)) {
return { message }
}
} catch {
return { message }
}

let formattedMessage = ''
let indentation = 0
let isInsideString = false
let isEscaped = false
let currentStringStart = 0
const sourceMap = Array.from<number>({ length: message.length }).fill(-1)
const keyRanges: HighlightRange[] = []

const appendSourceCharacter = (character: string, sourceIndex: number) => {
sourceMap[sourceIndex] = formattedMessage.length
formattedMessage += character
}

const appendNewLine = () => {
formattedMessage += `\n${' '.repeat(indentation)}`
}

for (let index = 0; index < message.length; index++) {
const character = message[index] ?? ''

if (isInsideString) {
appendSourceCharacter(character, index)

if (isEscaped) {
isEscaped = false
} else if (character === '\\') {
isEscaped = true
} else if (character === '"') {
isInsideString = false
if (nextNonWhitespaceCharacter(message, index + 1) === ':') {
keyRanges.push({ start: currentStringStart, end: formattedMessage.length })
}
}

continue
}

if (/\s/.test(character)) continue

if (character === '"') {
isInsideString = true
currentStringStart = formattedMessage.length
appendSourceCharacter(character, index)
continue
}

if (character === '{' || character === '[') {
appendSourceCharacter(character, index)

const closingCharacter = character === '{' ? '}' : ']'
if (nextNonWhitespaceCharacter(message, index + 1) !== closingCharacter) {
indentation++
appendNewLine()
}
continue
}

if (character === '}' || character === ']') {
const openingCharacter = character === '}' ? '{' : '['
if (previousNonWhitespaceCharacter(message, index - 1) !== openingCharacter) {
indentation--
appendNewLine()
}
appendSourceCharacter(character, index)
continue
}

appendSourceCharacter(character, index)

if (character === ',') {
appendNewLine()
} else if (character === ':') {
formattedMessage += ' '
}
}

return { message: formattedMessage, sourceMap, keyRanges }
}

export function findHighlightRanges(
rawMessage: string,
{ message, sourceMap }: FormattedLogMessage,
searchTerm: string | null | undefined
): HighlightRange[] {
if (!searchTerm) return []

const ranges: HighlightRange[] = []
const normalizedSearchTerm = searchTerm.toLowerCase()
const searchableMessage = sourceMap ? rawMessage : message
const normalizedMessage = searchableMessage.toLowerCase()
Comment thread
RemiBonnet marked this conversation as resolved.
let matchIndex = normalizedMessage.indexOf(normalizedSearchTerm)

while (matchIndex >= 0) {
if (!sourceMap) {
ranges.push({ start: matchIndex, end: matchIndex + searchTerm.length })
} else {
const matchEnd = matchIndex + searchTerm.length
const mappedOffsets = sourceMap.slice(matchIndex, matchEnd).filter((offset) => offset >= 0)

if (mappedOffsets.length > 0) {
ranges.push({ start: mappedOffsets[0] ?? 0, end: (mappedOffsets[mappedOffsets.length - 1] ?? 0) + 1 })
}
}

matchIndex = normalizedMessage.indexOf(normalizedSearchTerm, matchIndex + searchTerm.length)
}

return ranges
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,70 @@ describe('RowServiceLogs', () => {
expect(screen.getByText('Test log message')).toBeInTheDocument()
})

it('renders JSON object messages with two-space indentation', () => {
const message = '{"message":"Started","context":{"attempt":2},"ports":[80,443]}'
const formattedMessage = `{
"message": "Started",
"context": {
"attempt": 2
},
"ports": [
80,
443
]
}`

const { container } = renderRowServiceLogs({ ...mockLog, message })

expect(container.querySelector('[data-log-message="true"]')?.textContent).toBe(formattedMessage)
})

it('renders JSON object keys with the accent color', () => {
const { container } = renderRowServiceLogs({
...mockLog,
message: '{"message":"Started","context":{"attempt":2}}',
})

expect(Array.from(container.querySelectorAll('.text-accent1')).map((element) => element.textContent)).toEqual([
'"message"',
'"context"',
'"attempt"',
])
expect(container.querySelector('[data-log-message="true"]')).toHaveTextContent('"message": "Started"')
})

it('highlights search text in a formatted JSON object message', () => {
renderRowServiceLogs({ ...mockLog, message: '{"message":"Started"}' }, false, 'Started')

expect(screen.getByText('Started').closest('mark')).toBeInTheDocument()
expect(screen.getByText('Started').closest('[data-log-message="true"]')).toHaveTextContent(
'{ "message": "Started" }'
)
})

it('highlights a compact raw JSON match after formatting', () => {
const { container } = renderRowServiceLogs(
{ ...mockLog, message: '{"message":"Started","attempt":2}' },
false,
'"message":"Started"'
)

expect(
Array.from(container.querySelectorAll('mark'))
.map((element) => element.textContent)
.join('')
).toBe('"message": "Started"')
})

it.each(['["Started"]', '"Started"', '42', 'true', 'null', '{"message":"Started"', 'Started successfully'])(
'keeps non-object message %s unchanged',
(message) => {
const { container } = renderRowServiceLogs({ ...mockLog, message })

expect(container.querySelector('[data-log-message="true"]')?.textContent).toBe(message)
}
)

it('toggles expanded state on click', async () => {
const { userEvent } = renderRowServiceLogs()

Expand Down
Loading
Loading