codeburn/tests/content-utils.test.ts
Resham Joshi e8009c4559
Some checks are pending
CI / semgrep (push) Waiting to run
fix(parser): tolerate string message content; isolate per-file parse failures (#441) (#450)
Some agents (Pi, and others for injected turns) write a message's `content`
as a string instead of an array of blocks. Parsers did `(content ?? []).filter`,
which throws on a string — and because the daily-cache backfill swallowed parse
errors, one bad session silently wiped the entire trend/history.

- Add normalizeContentBlocks(): string -> one text block, array -> as-is (by
  reference; drops null/undefined elements so the same crash can't happen one
  level down), else -> []. Applied across pi/codex/droid/cursor-agent and the
  Claude path in parser.ts.
- Isolate per-file parse failures in parseProviderSources: skip the offending
  file (warn once per provider) instead of re-throwing and aborting the whole
  backfill. The stale cache entry is already cleared, so the file is excluded.
- Surface backfill failures in hydrateCache via stderr instead of silently
  returning an empty cache.

Likely fixes #425 (previous-day always 0) for the throwing-file cause.
Tests: content-utils unit tests + a Pi string-content regression test.
2026-06-06 04:01:12 +02:00

43 lines
1.9 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { normalizeContentBlocks } from '../src/content-utils.js'
describe('normalizeContentBlocks', () => {
it('passes an array of blocks through unchanged', () => {
const blocks = [{ type: 'text', text: 'hi' }, { type: 'tool_use', text: '' }]
expect(normalizeContentBlocks(blocks)).toBe(blocks)
})
it('wraps a string as a single text block (the issue #441 case)', () => {
expect(normalizeContentBlocks('hello world')).toEqual([{ type: 'text', text: 'hello world' }])
})
it('returns an empty array for null / undefined', () => {
expect(normalizeContentBlocks(null)).toEqual([])
expect(normalizeContentBlocks(undefined)).toEqual([])
})
it('returns an empty array for other non-array values', () => {
// Defensive against corrupt records: a number/object content must not throw downstream.
expect(normalizeContentBlocks(42 as unknown as string)).toEqual([])
expect(normalizeContentBlocks({ type: 'text' } as unknown as string)).toEqual([])
})
it('drops null/undefined elements inside an array (avoids the same crash one level down)', () => {
const dirty = [{ type: 'text', text: 'ok' }, null, undefined, { type: 'tool_use' }] as unknown as Array<{ type?: string }>
const out = normalizeContentBlocks(dirty)
expect(out).toEqual([{ type: 'text', text: 'ok' }, { type: 'tool_use' }])
expect(() => out.filter(b => b.type === 'text')).not.toThrow()
})
it('returns the same reference for a clean array (no copy)', () => {
const clean = [{ type: 'text', text: 'a' }, { type: 'tool_use' }]
expect(normalizeContentBlocks(clean)).toBe(clean)
})
it('the result is always safe to .filter/.some over', () => {
const inputs = ['a string', null, undefined, [{ type: 'text' }], [{ type: 'text' }, null]] as const
for (const input of inputs) {
expect(() => normalizeContentBlocks(input as never).filter(b => b.type === 'text')).not.toThrow()
}
})
})