Merge pull request #1014 from ozymandiashh/fix/997-session-legend-titles

fix(dash): lead the session legend with the session title
This commit is contained in:
Resham Joshi 2026-08-18 11:49:20 -07:00 committed by GitHub
commit 564c618829
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 379 additions and 14 deletions

View file

@ -31,6 +31,7 @@
- **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972)
### Fixed
- **The session chart legend now leads with a visible session disambiguator and title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now puts the short session id first, prefers the title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997)
- **Context-bloat detection now counts reasoning tokens as generated output.** `detectContextBloat` divided context by `totalOutputTokens` alone, but reasoning is stored beside output rather than inside it, so for every reasoning-bearing provider the detector saw a fraction of the tokens actually generated and invented findings - a session whose real ratio was 20:1, under the 25:1 threshold, was reported as 133:1 and "high impact". It now uses the same `output + reasoning` sum the reports use, which corrects grok, codex, kiro, hermes, qwen and cursor-agent alike.
- **The unpriced-models warning in the dashboard is now readable at every terminal width.** It lived in a fixed-width panel with an inline model list and a fix command, so it clipped mid-name at 80 columns and clipped *earlier* at 200, where the three-column layout narrows each panel - neither the affected models nor a runnable command survived. The panel line is now a pointer, `! N unpriced: codeburn models --unpriced` (shortened to `! N: codeburn models --unpriced` below 45 columns of panel), and the model list moves to that command's plain output, which is full width, copyable, and lists every model rather than the first two. The command's hint no longer reads as an unconditional instruction to alias: a subscription or flat-rate model is correctly $0, and mapping it onto another model's per-token rate would invent spend that was never billed. Provider-supplied model IDs are now stripped of terminal control characters in every human-readable report rather than only on the unpriced path, and `--unpriced` shows raw IDs instead of friendly names because `model-alias` keys on the raw ID. (#969)
- **`codeburn models --unpriced --top N` returned nothing for a `--top N` smaller than the number of priced models.** `--top` is applied inside `aggregateModels`, before the unpriced filter, on rows sorted cost-first — and unpriced rows are $0 on both, so they sorted last and the slice removed exactly the rows the flag exists to show. A user with unpriced models was told they had none. The slice now runs after the filter — and after ranking, because unpriced rows tie at $0 on both keys, so slicing them in aggregate order kept whichever models happened to appear earliest in the transcript rather than the largest. The order now matches the one the unpriced-models warning shows. (#969)

View file

@ -33,7 +33,12 @@ function makeTooltip(labels: Record<string, string>, fmt: (n: number) => string,
{items.slice(0, 6).map((p: any) => (
<div key={p.dataKey} className="flex items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: p.color }} />
<span className="flex-1 truncate text-tertiary-foreground">{labels[String(p.dataKey)] ?? String(p.dataKey)}</span>
<span
className="flex-1 truncate text-tertiary-foreground"
title={labels[String(p.dataKey)] ?? String(p.dataKey)}
>
{labels[String(p.dataKey)] ?? String(p.dataKey)}
</span>
<span className="tabular-nums text-muted-foreground">{fmt(p.value)}</span>
</div>
))}
@ -172,7 +177,7 @@ function GranularLines({
{series.map(item => (
<span key={item.key} className="flex min-w-0 items-center gap-1.5">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: item.color }} />
<span className="max-w-40 truncate">{item.label}</span>
<span className="max-w-40 truncate" title={item.label}>{item.label}</span>
</span>
))}
</div>

View file

@ -1,3 +1,5 @@
import stripAnsi from 'strip-ansi'
import type { DateRange, ProjectSummary } from './types.js'
const FIFTEEN_MINUTES = 15
@ -5,6 +7,10 @@ const ONE_HOUR = 60
const ONE_DAY = 24 * 60
const MINUTE_MS = 60 * 1000
const MAX_SERIES_PER_METRIC = 6
// Keep metadata bounded for the legend and tooltip: 80 characters preserves a
// useful title without letting the parser's 200-char transcript cap dominate
// either UI surface.
const MAX_SESSION_TITLE_LENGTH = 80
export type GranularSeries = {
id: string
@ -41,6 +47,25 @@ type RawBucket = {
sessions: Map<string, Totals>
}
type SessionTitleCandidate = {
title: string
lastTimestamp: string
}
type SessionLabelInfo = {
provider: string
projectPath: string
projectNames: Set<string>
sessionId: string
titleCandidates: Map<string, SessionTitleCandidate>
}
type SessionLabelEntry = {
key: string
info: SessionLabelInfo
baseLabel: string
}
function nonNegative(value: number): number {
return Number.isFinite(value) && value > 0 ? value : 0
}
@ -99,6 +124,114 @@ function shortSessionId(sessionId: string): string {
return trimmed.length > 12 ? `${trimmed.slice(0, 6)}${trimmed.slice(-4)}` : trimmed || 'unknown'
}
function cleanSessionTitle(title: string | undefined): string | undefined {
if (title === undefined) return undefined
// Match the control-character range used by the model-name sanitizer. ANSI
// sequences are removed first; remaining controls become spaces so transcript
// line breaks cannot join words before internal whitespace is collapsed.
const cleaned = stripAnsi(title)
.replace(/[\x00-\x1F\x7F-\x9F]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!cleaned) return undefined
return Array.from(cleaned).slice(0, MAX_SESSION_TITLE_LENGTH).join('').trimEnd() || undefined
}
// SessionSummary.lastTimestamp is normally an ISO timestamp, but fixtures and
// older cache entries can be incomplete. Valid timestamps win over invalid
// ones; two invalid values are an exact tie and are resolved alphabetically by
// the caller.
function compareTimestamps(a: string, b: string): number {
const aMs = Date.parse(a)
const bMs = Date.parse(b)
const aValid = Number.isFinite(aMs)
const bValid = Number.isFinite(bMs)
if (aValid && bValid) return aMs - bMs
if (aValid) return 1
if (bValid) return -1
return 0
}
function preferredProjectName(projectNames: Set<string>): string {
return [...projectNames].sort()[0] ?? 'Unknown project'
}
function preferredSessionTitle(titleCandidates: Map<string, SessionTitleCandidate>): string | undefined {
const cleaned = [...titleCandidates.values()]
.map(candidate => {
const title = cleanSessionTitle(candidate.title)
return title === undefined ? undefined : { title, lastTimestamp: candidate.lastTimestamp }
})
.filter((candidate): candidate is SessionTitleCandidate => candidate !== undefined)
cleaned.sort((a, b) => {
const timestampOrder = compareTimestamps(b.lastTimestamp, a.lastTimestamp)
if (timestampOrder !== 0) return timestampOrder
return a.title < b.title ? -1 : a.title > b.title ? 1 : 0
})
return cleaned[0]?.title
}
function buildSessionLabels(inputs: Map<string, SessionLabelInfo>): Map<string, string> {
// Stable raw-key order makes the residual used-label guard independent of
// project/session discovery order when a title happens to match another
// label shape.
const entries: SessionLabelEntry[] = [...inputs.entries()].map(([key, info]) => {
const sessionLabel = preferredSessionTitle(info.titleCandidates)
?? shortProjectLabel(info.projectPath, preferredProjectName(info.projectNames))
return {
key,
info,
baseLabel: `${shortSessionId(info.sessionId)} (${info.provider}) · ${sessionLabel}`,
}
}).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0)
const byBaseLabel = new Map<string, SessionLabelEntry[]>()
for (const entry of entries) {
const group = byBaseLabel.get(entry.baseLabel) ?? []
group.push(entry)
byBaseLabel.set(entry.baseLabel, group)
}
const labels = new Map<string, string>()
const usedLabels = new Set<string>()
const setUniqueLabel = (entry: SessionLabelEntry, candidate: string): void => {
let label = candidate
if (usedLabels.has(label)) {
const identity = `${candidate} · ${entry.info.projectPath} · ${entry.info.sessionId}`
label = identity
let suffix = 2
while (usedLabels.has(label)) label = `${identity} · ${suffix++}`
}
labels.set(entry.key, label)
usedLabels.add(label)
}
for (const group of byBaseLabel.values()) {
if (group.length === 1) {
setUniqueLabel(group[0]!, group[0]!.baseLabel)
continue
}
const projectLabels = group.map(entry => shortProjectLabel(entry.info.projectPath, preferredProjectName(entry.info.projectNames)))
if (new Set(projectLabels).size === group.length) {
for (let i = 0; i < group.length; i++) {
const entry = group[i]!
setUniqueLabel(entry, `${entry.baseLabel} · ${projectLabels[i]}`)
}
continue
}
// A short project label can still collide (for example two worktrees with
// the same final path segments). The full path + id is only used for this
// residual collision, and is unique because provider/path/id form the key.
for (const entry of group) {
setUniqueLabel(entry, `${entry.baseLabel} · ${entry.info.projectPath} · ${entry.info.sessionId}`)
}
}
return labels
}
// Legend labels: the sanitized project dir ("-Users-name-Projects-app") is
// unreadable, so prefer the real projectPath's last two segments ("app/web").
// Fall back to the sanitized name when no usable path exists.
@ -184,7 +317,7 @@ export function buildGranularHistory(
const modelTotals = new Map<string, Totals>()
const sessionTotals = new Map<string, Totals>()
const modelLabels = new Map<string, string>()
const sessionLabels = new Map<string, string>()
const sessionLabelInputs = new Map<string, SessionLabelInfo>()
let callCount = 0
for (const project of projects) {
@ -214,7 +347,27 @@ export function buildGranularHistory(
add(modelTotals, modelKey, cost, tokens)
add(sessionTotals, sessionKey, cost, tokens)
modelLabels.set(modelKey, modelKey === '<synthetic>' ? 'Other model' : modelKey)
sessionLabels.set(sessionKey, `${shortProjectLabel(project.projectPath, projectName)} · ${shortSessionId(session.sessionId)} (${call.provider})`)
// Collect raw metadata first. Titles are cleaned once per distinct
// session-key candidate after all calls are aggregated, so a late
// cache title can win without putting sanitisation on the call path.
const labelInfo = sessionLabelInputs.get(sessionKey) ?? {
provider: call.provider,
projectPath: project.projectPath,
projectNames: new Set<string>(),
sessionId: session.sessionId,
titleCandidates: new Map<string, SessionTitleCandidate>(),
}
labelInfo.projectNames.add(projectName)
if (session.title !== undefined) {
const existingTitle = labelInfo.titleCandidates.get(session.title)
if (!existingTitle || compareTimestamps(session.lastTimestamp, existingTitle.lastTimestamp) > 0) {
labelInfo.titleCandidates.set(session.title, {
title: session.title,
lastTimestamp: session.lastTimestamp,
})
}
}
sessionLabelInputs.set(sessionKey, labelInfo)
callCount++
}
}
@ -225,6 +378,7 @@ export function buildGranularHistory(
return { bucketMinutes, modelSeries: [], sessionSeries: [], points: [] }
}
const sessionLabels = buildSessionLabels(sessionLabelInputs)
const modelProjection = projectSeries(rawBuckets, 'models', modelTotals, modelLabels)
const sessionProjection = projectSeries(rawBuckets, 'sessions', sessionTotals, sessionLabels)
return {

View file

@ -90,6 +90,15 @@ function openBrowser(url: string): void {
}
}
export function injectDashboardBootstrap(html: string, payload: unknown): string {
const json = JSON.stringify(payload)
if (json === undefined) throw new TypeError('dashboard bootstrap payload is not serializable')
// Keep the JSON safe at the boundary where it enters an HTML script. This is
// deliberately inside the helper so callers cannot forget the escape.
const safeJson = json.replace(/</g, String.fromCharCode(92) + 'u003c')
return html.replace('<script type="module"', () => `<script>window.__CODEBURN_BOOTSTRAP__=${safeJson}</script>\n <script type="module"`)
}
export async function runWebDashboard(opts: {
period: string
provider: string
@ -180,9 +189,7 @@ export async function runWebDashboard(opts: {
const html = await readFile(filePath, 'utf8')
const payload = await getLocalPayload(opts.period, opts.provider, opts.from, opts.to)
const devices = [{ id: 'local', name: hostname(), local: true, payload }]
// Escape every '<' so a device/model/project name can't close the <script>.
const json = JSON.stringify({ devices }).replace(/</g, String.fromCharCode(92) + 'u003c')
const injected = html.replace('<script type="module"', `<script>window.__CODEBURN_BOOTSTRAP__=${json}</script>\n <script type="module"`)
const injected = injectDashboardBootstrap(html, { devices })
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
res.end(injected)
}

View file

@ -45,7 +45,7 @@ function apiCall(options: {
}
}
function project(sessions: Array<{ id: string; project?: string; calls: ParsedApiCall[] }>): ProjectSummary {
function project(sessions: Array<{ id: string; project?: string; title?: string; lastTimestamp?: string; calls: ParsedApiCall[] }>): ProjectSummary {
return {
project: 'demo',
projectPath: '/repos/demo',
@ -56,8 +56,9 @@ function project(sessions: Array<{ id: string; project?: string; calls: ParsedAp
sessions: sessions.map(session => ({
sessionId: session.id,
project: session.project ?? 'demo',
title: session.title,
firstTimestamp: session.calls[0]?.timestamp ?? '',
lastTimestamp: session.calls.at(-1)?.timestamp ?? '',
lastTimestamp: session.lastTimestamp ?? session.calls.at(-1)?.timestamp ?? '',
totalCostUSD: session.calls.reduce((sum, call) => sum + call.costUSD, 0),
totalSavingsUSD: 0,
totalInputTokens: session.calls.reduce((sum, call) => sum + call.usage.inputTokens, 0),
@ -107,6 +108,131 @@ describe('granular history', () => {
expect(granularBucketMinutes(range(24 * 30))).toBe(1440)
})
it('prefers a sanitised session title and preserves the exact project fallback when it is missing or blank', () => {
const timestamp = '2026-07-15T12:05:00.000Z'
const start = new Date('2026-07-15T00:00:00.000Z')
const end = new Date('2026-07-15T23:59:59.999Z')
const history = buildGranularHistory([project([
{ id: 'session-titled-123456', title: 'Refactor billing module', calls: [apiCall({ timestamp, cost: 1 })] },
{ id: 'session-absent-123457', calls: [apiCall({ timestamp, cost: 1 })] },
{ id: 'session-empty-123458', title: '', calls: [apiCall({ timestamp, cost: 1 })] },
{ id: 'session-blank-123459', title: ' \t\n ', calls: [apiCall({ timestamp, cost: 1 })] },
])], { start, end }, end)
expect(history.sessionSeries.map(series => series.label)).toEqual([
'sessio…3456 (claude) · Refactor billing module',
'sessio…3457 (claude) · repos/demo',
'sessio…3458 (claude) · repos/demo',
'sessio…3459 (claude) · repos/demo',
])
})
it('keeps identical session titles distinguishable with the short session id', () => {
const timestamp = '2026-07-15T12:05:00.000Z'
const start = new Date('2026-07-15T00:00:00.000Z')
const end = new Date('2026-07-15T23:59:59.999Z')
const history = buildGranularHistory([project([
{ id: 'session-111111', title: 'Refactor billing module', calls: [apiCall({ timestamp, cost: 1 })] },
{ id: 'session-222222', title: 'Refactor billing module', calls: [apiCall({ timestamp, cost: 1 })] },
])], { start, end }, end)
expect(history.sessionSeries.map(series => series.id)).toEqual(['session_0', 'session_1'])
expect(history.sessionSeries.map(series => series.label)).toEqual([
'sessio…1111 (claude) · Refactor billing module',
'sessio…2222 (claude) · Refactor billing module',
])
expect(new Set(history.sessionSeries.map(series => series.label)).size).toBe(2)
})
it('keeps the disambiguator inside the visible legend prefix for long shared title prefixes', () => {
const timestamp = '2026-07-15T12:05:00.000Z'
const start = new Date('2026-07-15T00:00:00.000Z')
const end = new Date('2026-07-15T23:59:59.999Z')
const sharedPrefix = 'same long title prefix '.repeat(5)
const history = buildGranularHistory([project([
{ id: 'a1b2c3-session-7f01', title: `${sharedPrefix}alpha`, calls: [apiCall({ timestamp, cost: 1 })] },
{ id: 'd4e5f6-session-8a02', title: `${sharedPrefix}beta`, calls: [apiCall({ timestamp, cost: 1 })] },
])], { start, end }, end)
// 160px at the chart's 10px font fits roughly 31-32 lowercase glyphs;
// compare a conservative prefix that must be visible in that budget.
const visibleCharacterBudget = 24
const visiblePrefixes = history.sessionSeries.map(series => series.label.slice(0, visibleCharacterBudget))
expect(new Set(visiblePrefixes).size).toBe(2)
expect(history.sessionSeries.map(series => series.label)).toEqual(expect.arrayContaining([
expect.stringMatching(/^a1b2c3…7f01 \(claude\) · /),
expect.stringMatching(/^d4e5f6…8a02 \(claude\) · /),
]))
})
it('sanitises control characters and ANSI escapes in session titles', () => {
const timestamp = '2026-07-15T12:05:00.000Z'
const start = new Date('2026-07-15T00:00:00.000Z')
const end = new Date('2026-07-15T23:59:59.999Z')
const history = buildGranularHistory([project([{
id: 'session-sanitised-123456',
title: '\x1b[31mRefactor\x1b[0m\t billing\nmodule\x00',
calls: [apiCall({ timestamp, cost: 1 })],
}])], { start, end }, end)
expect(history.sessionSeries[0]?.label).toBe('sessio…3456 (claude) · Refactor billing module')
expect(history.sessionSeries[0]?.label).not.toContain('\x1b')
expect(history.sessionSeries[0]?.label).not.toContain('\x00')
})
it('caps over-long session titles before putting them in the legend label', () => {
const timestamp = '2026-07-15T12:05:00.000Z'
const start = new Date('2026-07-15T00:00:00.000Z')
const end = new Date('2026-07-15T23:59:59.999Z')
const history = buildGranularHistory([project([{
id: 'session-long-title-123456',
title: 'x'.repeat(200),
calls: [apiCall({ timestamp, cost: 1 })],
}])], { start, end }, end)
expect(history.sessionSeries[0]?.label).toBe('sessio…3456 (claude) · ' + 'x'.repeat(80))
})
it('caps session titles by code point without splitting an emoji', () => {
const timestamp = '2026-07-15T12:05:00.000Z'
const start = new Date('2026-07-15T00:00:00.000Z')
const end = new Date('2026-07-15T23:59:59.999Z')
const title = 'x'.repeat(79) + '😀' + ' after the boundary'
const history = buildGranularHistory([project([{
id: 'session-emoji-title-123456',
title,
calls: [apiCall({ timestamp, cost: 1 })],
}])], { start, end }, end)
const label = history.sessionSeries[0]?.label ?? ''
const titlePart = label.slice(label.indexOf(' · ') + 3)
expect(titlePart).toBe('x'.repeat(79) + '😀')
expect([...titlePart]).toEqual([...('x'.repeat(79) + '😀')])
})
it('prefers a title from any duplicate session summary sharing a key', () => {
const timestamp = '2026-07-15T12:05:00.000Z'
const start = new Date('2026-07-15T00:00:00.000Z')
const end = new Date('2026-07-15T23:59:59.999Z')
const history = buildGranularHistory([project([
{
id: 'session-cache-collision-123456',
title: 'A stale session title',
lastTimestamp: '2026-07-15T12:06:00.000Z',
calls: [apiCall({ timestamp, cost: 1 })],
},
{
id: 'session-cache-collision-123456',
title: 'Z recovered session title',
lastTimestamp: '2026-07-15T12:07:00.000Z',
calls: [apiCall({ timestamp, cost: 2 })],
},
])], { start, end }, end)
expect(history.sessionSeries).toHaveLength(1)
expect(history.sessionSeries[0]?.label).toBe('sessio…3456 (claude) · Z recovered session title')
})
it('fills idle buckets and keeps separate model and session lines from real call timestamps', () => {
const start = new Date('2026-07-15T00:00:00.000Z')
const end = new Date('2026-07-15T23:59:59.999Z')
@ -143,8 +269,8 @@ describe('granular history', () => {
// Labels use the real projectPath's last two segments, not the sanitized
// project name.
const alpha = history.sessionSeries.find(series => series.label === 'repos/demo · sessio…3456 (claude)')!
const beta = history.sessionSeries.find(series => series.label === 'repos/demo · sessio…4321 (codex)')!
const alpha = history.sessionSeries.find(series => series.label === 'sessio…3456 (claude) · repos/demo')!
const beta = history.sessionSeries.find(series => series.label === 'sessio…4321 (codex) · repos/demo')!
expect(sumSeries(history, 'sessions', alpha.id, 'cost')).toBe(1.75)
expect(sumSeries(history, 'sessions', beta.id, 'tokens')).toBe(300)
// Cache reads are intentionally not folded into the browser's Tokens line.
@ -216,11 +342,48 @@ describe('granular history', () => {
expect(history.sessionSeries).toHaveLength(2)
expect(history.sessionSeries.map(series => series.label)).toEqual(expect.arrayContaining([
expect.stringContaining('alpha ·'),
expect.stringContaining('beta ·'),
expect.stringContaining('repos/alpha'),
expect.stringContaining('repos/beta'),
]))
})
it('adds the project only when identical title and session id labels collide', () => {
const timestamp = '2026-07-15T12:05:00.000Z'
const alpha = project([{ id: 'shared-session-123456', title: 'Same task', calls: [apiCall({ timestamp, cost: 1 })] }])
const beta = project([{ id: 'shared-session-123456', title: 'Same task', calls: [apiCall({ timestamp, cost: 1 })] }])
alpha.projectPath = '/repos/alpha'
beta.projectPath = '/repos/beta'
const start = new Date('2026-07-15T00:00:00.000Z')
const end = new Date('2026-07-15T23:59:59.999Z')
const history = buildGranularHistory([alpha, beta], { start, end }, end)
const labels = history.sessionSeries.map(series => series.label)
expect(labels).toEqual([
'shared…3456 (claude) · Same task · repos/alpha',
'shared…3456 (claude) · Same task · repos/beta',
])
expect(new Set(labels).size).toBe(2)
})
it('keeps all labels unique even when a title matches another label shape', () => {
const timestamp = '2026-07-15T12:05:00.000Z'
const alpha = project([{ id: 'sameid', title: 'Task', calls: [apiCall({ timestamp, cost: 1 })] }])
const beta = project([{ id: 'sameid', title: 'Task', calls: [apiCall({ timestamp, cost: 1 })] }])
const shaped = project([{ id: 'sameid', title: 'Task · repos/alpha (claude)', calls: [apiCall({ timestamp, cost: 1 })] }])
alpha.projectPath = '/repos/alpha (claude)'
beta.projectPath = '/repos/beta (claude)'
shaped.projectPath = '/other/shaped'
const start = new Date('2026-07-15T00:00:00.000Z')
const end = new Date('2026-07-15T23:59:59.999Z')
const labelsFor = (projects: ProjectSummary[]) => buildGranularHistory(projects, { start, end }, end).sessionSeries.map(series => series.label)
const labels = labelsFor([alpha, beta, shaped])
expect(new Set(labels).size).toBe(labels.length)
expect(labels.slice().sort()).toEqual(labelsFor([shaped, beta, alpha]).slice().sort())
})
it('aligns quarter-hour buckets to local wall time in a fractional-offset timezone', () => {
const previousTz = process.env['TZ']
process.env['TZ'] = 'Asia/Kathmandu'

View file

@ -6,7 +6,42 @@ import type { Server } from 'http'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { runWebDashboard } from '../src/web-dashboard.js'
import { injectDashboardBootstrap, runWebDashboard } from '../src/web-dashboard.js'
describe('web dashboard bootstrap injection', () => {
it('keeps replacement syntax in a payload value literal', () => {
const payloadValue = "$`|$'|$&|$1"
const payload = { devices: [{ name: payloadValue }] }
const html = '<!doctype html><script type="module" src="/app.js"></script>'
const injected = injectDashboardBootstrap(html, payload)
expect(injected).toContain(`window.__CODEBURN_BOOTSTRAP__=${JSON.stringify(payload)}</script>`)
expect(injected).toContain(`"name":"${payloadValue}"`)
})
it('escapes script-closing payload values and preserves the served bootstrap payload', () => {
const hostileName = '</script><script>globalThis.bootstrapPwned = true</script>'
const payload = {
devices: [{
id: 'local',
name: hostileName,
payload: { current: { topProjects: [{ name: hostileName }] } },
}],
}
const html = '<!doctype html><script type="module" src="/app.js"></script>'
const servedHtml = injectDashboardBootstrap(html, payload)
const marker = 'window.__CODEBURN_BOOTSTRAP__='
const start = servedHtml.indexOf(marker) + marker.length
const end = servedHtml.indexOf('</script>', start)
const serialized = servedHtml.slice(start, end)
expect(serialized).not.toContain('</script')
expect(serialized).toContain('\\u003c/script>')
expect(JSON.parse(serialized)).toEqual(payload)
})
})
// Regression guard for the original bug: a bad `period` query used to hit
// process.exit(1) and kill the long-running dashboard server. The handlers must