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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Unicode case folding can make matchIndex no longer index rawMessage, so searches after characters such as İ highlight the wrong rendered text. Use an index-preserving case-insensitive matcher or retain a mapping from normalized offsets back to original offsets.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At libs/domains/service-logs/feature/src/lib/list-service-logs/row-service-logs/format-object-log-message.ts, line 121:

<comment>Unicode case folding can make `matchIndex` no longer index `rawMessage`, so searches after characters such as `İ` highlight the wrong rendered text. Use an index-preserving case-insensitive matcher or retain a mapping from normalized offsets back to original offsets.</comment>

<file context>
@@ -1,13 +1,140 @@
+  const ranges: HighlightRange[] = []
+  const normalizedSearchTerm = searchTerm.toLowerCase()
+  const searchableMessage = sourceMap ? rawMessage : message
+  const normalizedMessage = searchableMessage.toLowerCase()
+  let matchIndex = normalizedMessage.indexOf(normalizedSearchTerm)
+
</file context>

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