Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
activeSessionCountLabel,
|
||||
canTypeOrchestratorPrompt,
|
||||
clampOrchestratorSelection,
|
||||
closeFallbackAfterClose,
|
||||
currentSessionSelectionIndex,
|
||||
draftModelArgFromPickerValue,
|
||||
draftModelDisplayLabel,
|
||||
draftTitleFromPrompt,
|
||||
fixedSessionColumnStyle,
|
||||
isNewSessionRow,
|
||||
newSessionMarkerColor,
|
||||
newSessionRowIndex,
|
||||
orchestratorContextHint,
|
||||
orchestratorContextHintSegments,
|
||||
orchestratorGlobalHotkeyHint,
|
||||
orchestratorGlobalHotkeyHintSegments,
|
||||
orchestratorHintSegmentColor,
|
||||
orchestratorRowClickAction,
|
||||
orchestratorVisibleRowIndexes,
|
||||
relativeSessionAge,
|
||||
resumableHistory,
|
||||
selectedSessionRowStyle,
|
||||
sessionRowKindAt,
|
||||
sessionsCountLabel
|
||||
} from '../components/activeSessionSwitcher.js'
|
||||
import { listRowStyle } from '../components/overlayPrimitives.js'
|
||||
import type { SessionActiveItem } from '../gatewayTypes.js'
|
||||
import type { SessionListItem } from '../gatewayTypes.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
describe('session orchestrator helpers', () => {
|
||||
it('labels live sessions compactly for tight overlays', () => {
|
||||
expect(activeSessionCountLabel(0)).toBe('0 live sessions')
|
||||
expect(activeSessionCountLabel(1)).toBe('1 live session')
|
||||
expect(activeSessionCountLabel(3)).toBe('3 live sessions')
|
||||
expect(activeSessionCountLabel(1)).not.toContain('in this TUI')
|
||||
})
|
||||
|
||||
it('keeps session orchestrator hotkey hints short and contextual', () => {
|
||||
expect(orchestratorContextHint(false)).toBe('Session row: Enter switch · Ctrl+D close')
|
||||
expect(orchestratorContextHint(true)).toBe('New row: type prompt · Enter start · Tab model')
|
||||
expect(orchestratorGlobalHotkeyHint).toBe('↑↓ move · Ctrl+N new · Ctrl+R refresh · Esc close')
|
||||
expect(orchestratorGlobalHotkeyHint.length).toBeLessThanOrEqual(56)
|
||||
})
|
||||
|
||||
it('assigns themed colors consistently to orchestrator labels and hotkeys', () => {
|
||||
expect(orchestratorContextHintSegments(false)).toEqual([
|
||||
{ role: 'label', text: 'Session row:' },
|
||||
{ role: 'text', text: ' ' },
|
||||
{ role: 'hotkey', text: 'Enter' },
|
||||
{ role: 'text', text: ' switch · ' },
|
||||
{ role: 'hotkey', text: 'Ctrl+D' },
|
||||
{ role: 'text', text: ' close' }
|
||||
])
|
||||
expect(orchestratorContextHintSegments(true)).toEqual([
|
||||
{ role: 'label', text: 'New row:' },
|
||||
{ role: 'text', text: ' type prompt · ' },
|
||||
{ role: 'hotkey', text: 'Enter' },
|
||||
{ role: 'text', text: ' start · ' },
|
||||
{ role: 'hotkey', text: 'Tab' },
|
||||
{ role: 'text', text: ' model' }
|
||||
])
|
||||
expect(orchestratorGlobalHotkeyHintSegments.filter(s => s.role === 'hotkey').map(s => s.text)).toEqual([
|
||||
'↑↓',
|
||||
'Ctrl+N',
|
||||
'Ctrl+R',
|
||||
'Esc'
|
||||
])
|
||||
expect(orchestratorHintSegmentColor(DEFAULT_THEME, 'hotkey')).toBe(DEFAULT_THEME.color.accent)
|
||||
expect(orchestratorHintSegmentColor(DEFAULT_THEME, 'label')).toBe(DEFAULT_THEME.color.label)
|
||||
expect(orchestratorHintSegmentColor(DEFAULT_THEME, 'text')).toBe(DEFAULT_THEME.color.muted)
|
||||
expect(newSessionMarkerColor(DEFAULT_THEME, false)).toBe(DEFAULT_THEME.color.label)
|
||||
expect(newSessionMarkerColor(DEFAULT_THEME, true)).toBe(DEFAULT_THEME.color.text)
|
||||
})
|
||||
|
||||
it('uses the shared list-row primitive for the selected row (same as completions)', () => {
|
||||
const style = selectedSessionRowStyle(DEFAULT_THEME)
|
||||
const shared = listRowStyle(DEFAULT_THEME, true)
|
||||
|
||||
// One source of truth: the session switcher and the completions popover
|
||||
// cannot disagree about what "selected" looks like.
|
||||
expect(style.backgroundColor).toBe(shared.backgroundColor)
|
||||
expect(style.color).toBe(shared.color)
|
||||
// Readability contract survives: never accent-on-accent inverse.
|
||||
expect(style.backgroundColor).not.toBe(DEFAULT_THEME.color.accent)
|
||||
expect(style.color).not.toBe(DEFAULT_THEME.color.accent)
|
||||
// Inactive rows paint nothing — the terminal's canvas is the row bg.
|
||||
expect(listRowStyle(DEFAULT_THEME, false)).toEqual({})
|
||||
})
|
||||
|
||||
it('turns model picker values into session-scoped draft model args', () => {
|
||||
expect(draftModelArgFromPickerValue('kimi-k2.6 --provider ollama-cloud --tui-session')).toBe(
|
||||
'kimi-k2.6 --provider ollama-cloud --session'
|
||||
)
|
||||
expect(draftModelArgFromPickerValue('openai/gpt-5.5 --provider openai-codex --global')).toBe(
|
||||
'openai/gpt-5.5 --provider openai-codex --session'
|
||||
)
|
||||
})
|
||||
|
||||
it('highlights the current live session when the picker opens', () => {
|
||||
const sessions = [
|
||||
{ id: 'first', status: 'idle' },
|
||||
{ id: 'second', status: 'working', current: true },
|
||||
{ id: 'third', status: 'idle' }
|
||||
] satisfies SessionActiveItem[]
|
||||
|
||||
expect(currentSessionSelectionIndex(sessions, 'second')).toBe(1)
|
||||
expect(
|
||||
currentSessionSelectionIndex(
|
||||
[
|
||||
{ id: 'first', status: 'idle' },
|
||||
{ id: 'third', status: 'idle' }
|
||||
],
|
||||
'third'
|
||||
)
|
||||
).toBe(1)
|
||||
expect(currentSessionSelectionIndex(sessions, 'missing')).toBe(1)
|
||||
expect(currentSessionSelectionIndex([], 'missing')).toBe(0)
|
||||
})
|
||||
|
||||
it('adds a selectable New row after the live sessions and gates prompt typing to it', () => {
|
||||
expect(newSessionRowIndex(0)).toBe(0)
|
||||
expect(newSessionRowIndex(3)).toBe(3)
|
||||
expect(clampOrchestratorSelection(-5, 2)).toBe(0)
|
||||
expect(clampOrchestratorSelection(99, 2)).toBe(2)
|
||||
expect(isNewSessionRow(0, 0)).toBe(true)
|
||||
expect(isNewSessionRow(1, 2)).toBe(false)
|
||||
expect(isNewSessionRow(2, 2)).toBe(true)
|
||||
expect(canTypeOrchestratorPrompt(1, 2)).toBe(false)
|
||||
expect(canTypeOrchestratorPrompt(2, 2)).toBe(true)
|
||||
expect(orchestratorVisibleRowIndexes(3, 3, 12)).toEqual([0, 1, 2, 3])
|
||||
expect(orchestratorVisibleRowIndexes(13, 13, 12)).toContain(13)
|
||||
})
|
||||
|
||||
it('selects a safe fallback after closing the current live session', () => {
|
||||
const remaining = [
|
||||
{ id: 'next', status: 'idle' },
|
||||
{ id: 'other', status: 'working' }
|
||||
] satisfies SessionActiveItem[]
|
||||
|
||||
expect(closeFallbackAfterClose('other', 'current', remaining)).toEqual({ action: 'stay' })
|
||||
expect(closeFallbackAfterClose('current', 'current', remaining)).toEqual({ action: 'activate', sessionId: 'next' })
|
||||
expect(closeFallbackAfterClose('current', 'current', [])).toEqual({ action: 'new' })
|
||||
})
|
||||
|
||||
it('shows clean draft model labels without picker flags or provider params', () => {
|
||||
expect(draftModelDisplayLabel('kimi-k2.6 --provider ollama-cloud --tui-session')).toBe('kimi-k2.6')
|
||||
expect(draftModelDisplayLabel('openai/gpt-5.5 --provider openai-codex --global')).toBe('gpt-5.5')
|
||||
expect(draftModelDisplayLabel('')).toBe('current/default')
|
||||
})
|
||||
|
||||
it('maps row clicks to existing-session activation or New-row focus', () => {
|
||||
const sessions = [
|
||||
{ id: 'a', status: 'idle' },
|
||||
{ id: 'b', status: 'idle' }
|
||||
] satisfies SessionActiveItem[]
|
||||
|
||||
expect(orchestratorRowClickAction(1, sessions)).toEqual({ action: 'activate', sessionId: 'b' })
|
||||
expect(orchestratorRowClickAction(2, sessions)).toEqual({ action: 'select-new' })
|
||||
expect(orchestratorRowClickAction(99, sessions)).toEqual({ action: 'select-new' })
|
||||
})
|
||||
|
||||
it('keeps fixed table columns from shrinking into adjacent columns', () => {
|
||||
expect(fixedSessionColumnStyle().flexShrink).toBe(0)
|
||||
})
|
||||
|
||||
it('builds a compact title from the orchestrator prompt', () => {
|
||||
expect(draftTitleFromPrompt(' Build the websocket orchestrator panel and make it robust. ', 24)).toBe(
|
||||
'Build the websocket orc…'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unified Sessions overlay helpers', () => {
|
||||
it('orders rows as [new][live…][history…]', () => {
|
||||
// 2 live sessions, any number of history rows after them.
|
||||
expect(sessionRowKindAt(0, 2)).toBe('new')
|
||||
expect(sessionRowKindAt(1, 2)).toBe('live')
|
||||
expect(sessionRowKindAt(2, 2)).toBe('live')
|
||||
expect(sessionRowKindAt(3, 2)).toBe('history')
|
||||
expect(sessionRowKindAt(9, 2)).toBe('history')
|
||||
// No live sessions: row 0 is new, everything after is history.
|
||||
expect(sessionRowKindAt(0, 0)).toBe('new')
|
||||
expect(sessionRowKindAt(1, 0)).toBe('history')
|
||||
})
|
||||
|
||||
it('drops already-live sessions from the resumable history (dedupe by id)', () => {
|
||||
const history = [
|
||||
{ id: 'a', message_count: 1, preview: '', started_at: 0, title: 'A' },
|
||||
{ id: 'b', message_count: 2, preview: '', started_at: 0, title: 'B' },
|
||||
{ id: 'c', message_count: 3, preview: '', started_at: 0, title: 'C' }
|
||||
] satisfies SessionListItem[]
|
||||
|
||||
const live = [{ id: 'b', status: 'idle' }] satisfies SessionActiveItem[]
|
||||
|
||||
expect(resumableHistory(history, live).map(h => h.id)).toEqual(['a', 'c'])
|
||||
expect(resumableHistory(history, []).map(h => h.id)).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('labels live + resumable counts compactly', () => {
|
||||
expect(sessionsCountLabel(0, 0)).toBe('0 live · 0 resumable')
|
||||
expect(sessionsCountLabel(2, 7)).toBe('2 live · 7 resumable')
|
||||
})
|
||||
|
||||
it('renders relative session age, blank when unknown', () => {
|
||||
const nowSec = Math.floor(Date.now() / 1000)
|
||||
|
||||
expect(relativeSessionAge(nowSec)).toBe('today')
|
||||
expect(relativeSessionAge(nowSec - 36 * 3600)).toBe('yesterday')
|
||||
expect(relativeSessionAge(nowSec - 3 * 86400)).toBe('3d ago')
|
||||
expect(relativeSessionAge(undefined)).toBe('')
|
||||
expect(relativeSessionAge(0)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,459 @@
|
||||
import { PassThrough } from 'stream'
|
||||
|
||||
import { renderSync } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { GatewayProvider } from '../app/gatewayContext.js'
|
||||
import type { AppLayoutProps, OverlayState, UiState } from '../app/interfaces.js'
|
||||
import { patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import { patchUiState, resetUiState } from '../app/uiStore.js'
|
||||
import { StatusRule } from '../components/appChrome.js'
|
||||
import { AppLayout } from '../components/appLayout.js'
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
import { DEFAULT_VOICE_RECORD_KEY } from '../lib/platform.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
type StatusRuleProps = React.ComponentProps<typeof StatusRule>
|
||||
type IntervalSpy = ReturnType<typeof vi.spyOn<typeof globalThis, 'setInterval'>>
|
||||
|
||||
// Fixed wall clock so the rendered elapsed read-outs are exact strings rather
|
||||
// than whatever the machine's clock happens to produce mid-test.
|
||||
const T0 = 1_800_000_000_000
|
||||
|
||||
const mounted: Array<() => void> = []
|
||||
|
||||
/**
|
||||
* Mount a real StatusRule through Ink so the leaf components' effects — and
|
||||
* therefore their `setInterval` calls — actually run. The existing
|
||||
* appChromeStatusRule tests invoke `StatusRule(...)` as a plain function,
|
||||
* which only builds the element tree and never mounts FaceTicker /
|
||||
* SessionDuration / IdleSince, so it cannot observe timer behaviour.
|
||||
*
|
||||
* Teardown is registered up front so a failing assertion still unmounts the
|
||||
* tree — a leaked instance would keep re-arming timers into the next test.
|
||||
*/
|
||||
const mountTree = (tree: React.ReactElement, { interactive = false } = {}) => {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 120, isTTY: false, rows: 20 })
|
||||
// PromptZone's prompts call `useInput`, which needs raw mode; without it Ink
|
||||
// swaps the whole tree for an error panel and stops updating.
|
||||
Object.assign(
|
||||
stdin,
|
||||
interactive ? { isTTY: true, ref: () => {}, setRawMode: () => {}, unref: () => {} } : { isTTY: false }
|
||||
)
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(tree, {
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
mounted.push(() => {
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
})
|
||||
|
||||
return {
|
||||
/** Drop frames rendered so far so `output()` reads only what comes next. */
|
||||
clear: () => {
|
||||
output = ''
|
||||
},
|
||||
output: () => stripAnsi(output)
|
||||
}
|
||||
}
|
||||
|
||||
const mount = (props: StatusRuleProps) => mountTree(<StatusRule {...props} />)
|
||||
|
||||
const idleProps: StatusRuleProps = {
|
||||
bgCount: 0,
|
||||
busy: false,
|
||||
cols: 120,
|
||||
cwdLabel: '~/repo',
|
||||
lastTurnEndedAt: T0 - 5_000,
|
||||
liveSessionCount: 0,
|
||||
model: 'opus-4.8',
|
||||
sessionStartedAt: T0 - 60_000,
|
||||
status: 'ready',
|
||||
statusColor: DEFAULT_THEME.color.ok,
|
||||
t: DEFAULT_THEME,
|
||||
turnStartedAt: null,
|
||||
usage: { context_max: 200_000, context_percent: 25, context_used: 50_000, total: 50_000 },
|
||||
voiceLabel: ''
|
||||
}
|
||||
|
||||
// Busy swaps the idle read-out for the FaceTicker, which owns the glyph +
|
||||
// verb + elapsed-clock trio.
|
||||
const busyProps: StatusRuleProps = {
|
||||
...idleProps,
|
||||
busy: true,
|
||||
indicatorStyle: 'kaomoji',
|
||||
lastTurnEndedAt: null,
|
||||
turnStartedAt: T0 - 30_000
|
||||
}
|
||||
|
||||
/** Delays of every interval armed while the spy was installed. */
|
||||
const armedDelays = (spy: IntervalSpy) => spy.mock.calls.map(call => call[1])
|
||||
|
||||
const oneSecondTimers = (spy: IntervalSpy) => armedDelays(spy).filter(delay => delay === 1000).length
|
||||
|
||||
/** The handlers of every 1s clock armed so far — `() => setNow(Date.now())`. */
|
||||
const oneSecondTicks = (spy: IntervalSpy) =>
|
||||
spy.mock.calls.filter(call => call[1] === 1000).map(call => call[0] as () => void)
|
||||
|
||||
// ── AppLayout harness ────────────────────────────────────────────────
|
||||
//
|
||||
// teknium1's review of this file was right that mounting StatusRule alone
|
||||
// proves the store pauses timers but NOT that the overlay in question covers
|
||||
// the status rule. These props render the real AppLayout so the rule sits in
|
||||
// its true position relative to PromptZone / FloatingOverlays / the widget
|
||||
// slot, and the assertions can read what is actually on screen.
|
||||
|
||||
const gatewayStub = {
|
||||
gw: {
|
||||
request: () => new Promise<never>(() => {}),
|
||||
send: () => {}
|
||||
} as unknown as GatewayClient,
|
||||
rpc: (() => new Promise<never>(() => {})) as never
|
||||
}
|
||||
|
||||
const layoutProps: AppLayoutProps = {
|
||||
actions: {
|
||||
activateLiveSession: () => {},
|
||||
answerApproval: () => {},
|
||||
answerClarify: () => {},
|
||||
answerSecret: () => {},
|
||||
answerSudo: () => {},
|
||||
clearSelection: () => {},
|
||||
closeLiveSession: () => Promise.resolve(null),
|
||||
newLiveSession: () => {},
|
||||
newPromptSession: () => {},
|
||||
onModelSelect: () => {},
|
||||
resumeById: () => {},
|
||||
setStickyPrompt: () => {}
|
||||
},
|
||||
composer: {
|
||||
cols: 120,
|
||||
compIdx: 0,
|
||||
completions: [],
|
||||
empty: true,
|
||||
handleTextPaste: () => null,
|
||||
input: '',
|
||||
inputBuf: [],
|
||||
pagerPageSize: 10,
|
||||
queueEditIdx: null,
|
||||
queuedDisplay: [],
|
||||
submit: () => {},
|
||||
updateInput: () => {},
|
||||
voiceRecordKey: DEFAULT_VOICE_RECORD_KEY
|
||||
},
|
||||
mouseTracking: 'off',
|
||||
progress: { showProgressArea: false },
|
||||
status: {
|
||||
cwdLabel: '~/repo',
|
||||
goodVibesTick: 0,
|
||||
lastTurnEndedAt: T0 - 5_000,
|
||||
sessionStartedAt: T0 - 60_000,
|
||||
sessionTitle: '',
|
||||
showStickyPrompt: false,
|
||||
statusColor: DEFAULT_THEME.color.ok,
|
||||
stickyPrompt: '',
|
||||
turnStartedAt: null,
|
||||
voiceLabel: ''
|
||||
},
|
||||
transcript: {
|
||||
historyItems: [],
|
||||
scrollRef: { current: null },
|
||||
virtualHistory: {
|
||||
bottomSpacer: 0,
|
||||
end: 0,
|
||||
measureRef: () => () => {},
|
||||
offsets: [],
|
||||
start: 0,
|
||||
topSpacer: 0
|
||||
},
|
||||
virtualRows: []
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount the real AppLayout with the given overlay + ui state applied first. */
|
||||
const mountLayout = (overlay: Partial<OverlayState> = {}, ui: Partial<UiState> = {}) => {
|
||||
patchUiState({ sessionTitle: 'test', sid: 'sid-1', status: 'ready', ...ui })
|
||||
patchOverlayState(overlay)
|
||||
|
||||
return mountTree(
|
||||
<GatewayProvider value={gatewayStub}>
|
||||
<AppLayout {...layoutProps} />
|
||||
</GatewayProvider>,
|
||||
{ interactive: true }
|
||||
)
|
||||
}
|
||||
|
||||
// Give React's scheduler a turn so a store-driven re-render (and the effect
|
||||
// re-arm that follows it) lands before we assert.
|
||||
const flush = () => new Promise(resolve => setTimeout(resolve, 20))
|
||||
|
||||
let intervalSpy: IntervalSpy
|
||||
let nowSpy: ReturnType<typeof vi.spyOn<typeof Date, 'now'>>
|
||||
|
||||
beforeEach(() => {
|
||||
resetOverlayState()
|
||||
resetUiState()
|
||||
nowSpy = vi.spyOn(Date, 'now').mockReturnValue(T0)
|
||||
intervalSpy = vi.spyOn(globalThis, 'setInterval')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
mounted.pop()!()
|
||||
}
|
||||
|
||||
intervalSpy.mockRestore()
|
||||
nowSpy.mockRestore()
|
||||
resetOverlayState()
|
||||
resetUiState()
|
||||
})
|
||||
|
||||
describe('status-chrome timers under an occluding overlay', () => {
|
||||
it('arms the one-second SessionDuration + IdleSince clocks when nothing covers the rule', () => {
|
||||
mount(idleProps)
|
||||
|
||||
expect(oneSecondTimers(intervalSpy)).toBe(2)
|
||||
})
|
||||
|
||||
it('arms no timer at all when an occluding overlay is already open', () => {
|
||||
patchOverlayState({ modelPicker: true })
|
||||
|
||||
mount(idleProps)
|
||||
|
||||
expect(oneSecondTimers(intervalSpy)).toBe(0)
|
||||
})
|
||||
|
||||
it('arms the FaceTicker glyph/verb/clock trio mid-turn when nothing covers the rule', () => {
|
||||
mount(busyProps)
|
||||
|
||||
// kaomoji cadence for the glyph + verb rotation, plus the elapsed clock.
|
||||
expect(armedDelays(intervalSpy)).toContain(2500)
|
||||
expect(oneSecondTimers(intervalSpy)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('freezes the FaceTicker verb on compacting and skips verb rotation (#97239)', () => {
|
||||
const { output } = mount({ ...busyProps, compacting: true })
|
||||
|
||||
expect(output()).toContain('compacting')
|
||||
// Glyph still ticks at the kaomoji cadence; the rotating-verb timer does not.
|
||||
expect(armedDelays(intervalSpy).filter(delay => delay === 2500)).toHaveLength(1)
|
||||
expect(oneSecondTimers(intervalSpy)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('arms no FaceTicker timer mid-turn while the modal widget slot is open', () => {
|
||||
patchOverlayState({ widget: { appId: 'demo', state: null } })
|
||||
|
||||
mount(busyProps)
|
||||
|
||||
expect(armedDelays(intervalSpy)).not.toContain(2500)
|
||||
expect(oneSecondTimers(intervalSpy)).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the FaceTicker running mid-turn under a flow-layout sudo prompt', () => {
|
||||
// `sudo` is in `$isBlocked` but renders in PromptZone's normal flow, so it
|
||||
// pushes the rule down rather than covering it — the trio must keep going.
|
||||
patchOverlayState({ sudo: { requestId: 'sudo-1' } })
|
||||
|
||||
mount(busyProps)
|
||||
|
||||
expect(armedDelays(intervalSpy)).toContain(2500)
|
||||
expect(oneSecondTimers(intervalSpy)).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('keeps the clocks running when a floating overlay cannot reach a bottom status rule', () => {
|
||||
// FloatingOverlays is `position="absolute" bottom="100%"` inside
|
||||
// ComposerPane's relative Box, so it grows UPWARD: it covers the `at="top"`
|
||||
// rule and never the `at="bottom"` one.
|
||||
patchUiState({ statusBar: 'bottom' })
|
||||
patchOverlayState({ modelPicker: true })
|
||||
|
||||
mount(idleProps)
|
||||
|
||||
expect(oneSecondTimers(intervalSpy)).toBe(2)
|
||||
})
|
||||
|
||||
it('re-syncs the elapsed read-outs from the wall clock on reveal instead of resuming stale', async () => {
|
||||
// Regression guard for the naive fix: an early `return` that pauses the
|
||||
// interval but never re-seeds `now` leaves SessionDuration and IdleSince
|
||||
// frozen at the instant the overlay opened.
|
||||
patchOverlayState({ sessions: true })
|
||||
|
||||
const rule = mount(idleProps)
|
||||
|
||||
expect(rule.output()).toContain('1m 0s')
|
||||
expect(rule.output()).toContain('✓ 5s')
|
||||
|
||||
// Five minutes of wall clock elapse while the overlay covers the rule.
|
||||
nowSpy.mockReturnValue(T0 + 300_000)
|
||||
rule.clear()
|
||||
resetOverlayState()
|
||||
await flush()
|
||||
|
||||
const resumed = rule.output()
|
||||
|
||||
// Caught up to real elapsed time, not stuck on the pre-overlay values.
|
||||
expect(resumed).toContain('6m 0s')
|
||||
expect(resumed).toContain('✓ 5m 5s')
|
||||
expect(resumed).not.toContain('1m 0s')
|
||||
|
||||
// …and the clocks are running again.
|
||||
expect(oneSecondTimers(intervalSpy)).toBe(2)
|
||||
})
|
||||
|
||||
it('tears the clocks down when an overlay opens over an already-running status rule', async () => {
|
||||
mount(idleProps)
|
||||
|
||||
// Handles of the two live 1-second clocks (SessionDuration + IdleSince).
|
||||
const clocks = intervalSpy.mock.results
|
||||
.filter((_result, i) => intervalSpy.mock.calls[i]?.[1] === 1000)
|
||||
.map(result => result.value as ReturnType<typeof setInterval>)
|
||||
|
||||
expect(clocks).toHaveLength(2)
|
||||
|
||||
const clearSpy = vi.spyOn(globalThis, 'clearInterval')
|
||||
|
||||
patchOverlayState({ pluginsHub: true })
|
||||
await flush()
|
||||
|
||||
// Each running clock is cleared as the overlay goes up …
|
||||
for (const handle of clocks) {
|
||||
expect(clearSpy).toHaveBeenCalledWith(handle)
|
||||
}
|
||||
|
||||
// … and the occluded re-run arms no replacement (still just the original two).
|
||||
expect(oneSecondTimers(intervalSpy)).toBe(2)
|
||||
|
||||
clearSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// teknium1's review of #12463 called out that its test asserted on a `picker`
|
||||
// overlay state that no longer exists. Pin the gate to fields the current
|
||||
// OverlayState actually carries so a rename breaks this file loudly.
|
||||
describe('status-chrome timers track the current overlay model', () => {
|
||||
// Everything that genuinely paints over the rule: the modal widget slot,
|
||||
// plus the FloatingOverlays set (with the rule at its default `top`).
|
||||
const occluding: Array<[string, Partial<OverlayState>]> = [
|
||||
['modelPicker', { modelPicker: true }],
|
||||
['pager', { pager: { lines: ['a'], offset: 0 } }],
|
||||
['petPicker', { petPicker: true }],
|
||||
['pluginsHub', { pluginsHub: true }],
|
||||
['sessions', { sessions: true }],
|
||||
['skillsHub', { skillsHub: true }],
|
||||
['widget', { widget: { appId: 'demo', state: null } }]
|
||||
]
|
||||
|
||||
// In `$isBlocked` but NOT occluding. `agents` / `journey` unmount the whole
|
||||
// ComposerPane subtree, so React's effect cleanup already stops the clocks
|
||||
// and gating on them would be dead code; the rest are PromptZone states that
|
||||
// render in normal flow and push the rule down without covering it.
|
||||
const nonOccluding: Array<[string, Partial<OverlayState>]> = [
|
||||
['agents', { agents: true }],
|
||||
['approval', { approval: { command: 'ls', requestId: 'a-1' } as OverlayState['approval'] }],
|
||||
['billing', { billing: { kind: 'credits' } as OverlayState['billing'] }],
|
||||
['clarify', { clarify: { question: 'which?', requestId: 'c-1' } as OverlayState['clarify'] }],
|
||||
['confirm', { confirm: { onConfirm: () => {}, prompt: 'sure?' } as OverlayState['confirm'] }],
|
||||
['journey', { journey: true }],
|
||||
['secret', { secret: { envVar: 'TOKEN', prompt: 'token?' } as OverlayState['secret'] }],
|
||||
['subscription', { subscription: { kind: 'expired' } as OverlayState['subscription'] }],
|
||||
['sudo', { sudo: { requestId: 'sudo-1' } as OverlayState['sudo'] }]
|
||||
]
|
||||
|
||||
it.each(occluding)('pauses the status clocks while %s covers the rule', (_name, patch) => {
|
||||
patchOverlayState(patch)
|
||||
|
||||
mount(idleProps)
|
||||
|
||||
expect(oneSecondTimers(intervalSpy)).toBe(0)
|
||||
})
|
||||
|
||||
it.each(nonOccluding)('keeps the status clocks running while %s is open', (_name, patch) => {
|
||||
patchOverlayState(patch)
|
||||
|
||||
mount(idleProps)
|
||||
|
||||
expect(oneSecondTimers(intervalSpy)).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps the clocks running for the non-occluding ambient dock', () => {
|
||||
// `ambient` is a glanceable in-flow dock that reserves its own rows and
|
||||
// doesn't cover the status rule, so pausing there would be a regression.
|
||||
patchOverlayState({ ambient: [{ appId: 'clock', state: null }] })
|
||||
|
||||
mount(idleProps)
|
||||
|
||||
expect(oneSecondTimers(intervalSpy)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// The visibility gate teknium1 asked for: mount the REAL AppLayout so the
|
||||
// status rule sits in its true position relative to PromptZone (normal flow,
|
||||
// above ComposerPane) and FloatingOverlays (absolute, growing upward), then
|
||||
// assert on what is actually on screen rather than on the store alone.
|
||||
describe('AppLayout status-rule visibility', () => {
|
||||
it('keeps the status rule on screen AND its clock advancing under a flow-layout approval prompt', async () => {
|
||||
const layout = mountLayout({ approval: { command: 'rm -rf /', requestId: 'a-1' } as OverlayState['approval'] })
|
||||
|
||||
await flush()
|
||||
|
||||
// The rule is genuinely rendered — the approval prompt pushed it, it did
|
||||
// not cover it — so freezing its clock would freeze something visible.
|
||||
expect(layout.output()).toContain('~/repo')
|
||||
expect(layout.output()).toContain('1m 0s')
|
||||
expect(oneSecondTimers(intervalSpy)).toBe(2)
|
||||
|
||||
// …and it really advances: drive the armed 1s handlers forward.
|
||||
nowSpy.mockReturnValue(T0 + 30_000)
|
||||
|
||||
for (const tick of oneSecondTicks(intervalSpy)) {
|
||||
tick()
|
||||
}
|
||||
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
expect(layout.output()).toContain('1m 30s')
|
||||
})
|
||||
|
||||
it('keeps the status rule on screen AND its clock advancing under a flow-layout sudo prompt', async () => {
|
||||
const layout = mountLayout({ sudo: { requestId: 'sudo-1' } as OverlayState['sudo'] })
|
||||
|
||||
await flush()
|
||||
|
||||
expect(layout.output()).toContain('1m 0s')
|
||||
expect(oneSecondTimers(intervalSpy)).toBe(2)
|
||||
})
|
||||
|
||||
it('arms no clock under a floating model picker while the rule is at the top', async () => {
|
||||
mountLayout({ modelPicker: true }, { statusBar: 'top' })
|
||||
|
||||
await flush()
|
||||
|
||||
expect(oneSecondTimers(intervalSpy)).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the clocks armed under a floating model picker while the rule is at the bottom', async () => {
|
||||
mountLayout({ modelPicker: true }, { statusBar: 'bottom' })
|
||||
|
||||
await flush()
|
||||
|
||||
expect(oneSecondTimers(intervalSpy)).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,549 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { StatusRule } from '../components/appChrome.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
type ReactNodeLike = React.ReactNode
|
||||
|
||||
const textContent = (node: ReactNodeLike): string => {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof node === 'string' || typeof node === 'number') {
|
||||
return String(node)
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(textContent).join('')
|
||||
}
|
||||
|
||||
if (React.isValidElement(node)) {
|
||||
return textContent(node.props.children)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const findClickableWithText = (node: ReactNodeLike, needle: string): React.ReactElement | null => {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findClickableWithText(child, needle)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!React.isValidElement(node)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof node.props.onClick === 'function' && textContent(node).includes(needle)) {
|
||||
return node
|
||||
}
|
||||
|
||||
return findClickableWithText(node.props.children, needle)
|
||||
}
|
||||
|
||||
// Find the innermost element whose own (direct) text content includes the
|
||||
// needle. Used to assert the colour the notice text is rendered with.
|
||||
const findElementWithText = (node: ReactNodeLike, needle: string): React.ReactElement | null => {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findElementWithText(child, needle)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!React.isValidElement(node)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Prefer the deepest matching element so we get the leaf <Text> that
|
||||
// actually carries the colour, not an ancestor Box.
|
||||
const deeper = findElementWithText(node.props.children, needle)
|
||||
|
||||
if (deeper) {
|
||||
return deeper
|
||||
}
|
||||
|
||||
return textContent(node).includes(needle) ? node : null
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
bgCount: 0,
|
||||
busy: false,
|
||||
cols: 100,
|
||||
cwdLabel: '~/repo',
|
||||
liveSessionCount: 0,
|
||||
model: 'opus-4.8',
|
||||
sessionStartedAt: null,
|
||||
status: 'ready',
|
||||
statusColor: DEFAULT_THEME.color.ok,
|
||||
t: DEFAULT_THEME,
|
||||
turnStartedAt: null,
|
||||
usage: { context_max: 200_000, context_percent: 25, context_used: 50_000, total: 50_000 },
|
||||
voiceLabel: ''
|
||||
}
|
||||
|
||||
describe('StatusRule session title', () => {
|
||||
it('pins the named session at the far-right edge instead of the cwd label', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
sessionTitle: 'weekly-digest'
|
||||
})
|
||||
|
||||
const rendered = textContent(element)
|
||||
const title = findElementWithText(element, 'weekly-digest')
|
||||
|
||||
expect(rendered).toContain('weekly-digest')
|
||||
expect(rendered).not.toContain('~/repo')
|
||||
// Regression for issue #82465: a raw, full-saturation accent-hue
|
||||
// background (e.g. #FFBF00 on DARK_SEEDS) paired with statusFg (a
|
||||
// near-white tone never designed to sit on it) rendered at roughly a
|
||||
// 1.5-2:1 contrast ratio -- unreadable. No background fill at all;
|
||||
// the accent color goes on the text instead, matching the theme's
|
||||
// own convention that a raw accent hue is never used as a solid
|
||||
// fill elsewhere (fills are always softened, e.g. activeRow).
|
||||
expect(title?.props.backgroundColor).toBeUndefined()
|
||||
expect(title?.props.color).toBe(DEFAULT_THEME.color.accent)
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatusRule background-subagent indicator', () => {
|
||||
it('renders ⛓ N on a wide terminal when subagents are running', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
usage: { ...baseProps.usage, active_subagents: 3 }
|
||||
})
|
||||
|
||||
expect(textContent(element)).toContain('⛓ 3')
|
||||
})
|
||||
|
||||
it('omits the segment when no subagents are running', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
usage: { ...baseProps.usage, active_subagents: 0 }
|
||||
})
|
||||
|
||||
expect(textContent(element)).not.toContain('⛓')
|
||||
})
|
||||
|
||||
it('omits the segment when the field is absent', () => {
|
||||
const element = StatusRule({ ...baseProps })
|
||||
|
||||
expect(textContent(element)).not.toContain('⛓')
|
||||
})
|
||||
|
||||
it('spells out the auto-resume hint when idle with subagents in flight', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
usage: { ...baseProps.usage, active_subagents: 1 }
|
||||
})
|
||||
|
||||
expect(textContent(element)).toContain('resumes when subagent finishes')
|
||||
})
|
||||
|
||||
it('pluralizes the resume hint for multiple in-flight subagents', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
usage: { ...baseProps.usage, active_subagents: 3 }
|
||||
})
|
||||
|
||||
expect(textContent(element)).toContain('resumes when 3 subagents finish')
|
||||
})
|
||||
|
||||
it('hides the resume hint mid-turn (a busy turn owns the indicator)', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
busy: true,
|
||||
turnStartedAt: Date.now(),
|
||||
usage: { ...baseProps.usage, active_subagents: 2 }
|
||||
})
|
||||
|
||||
expect(textContent(element)).not.toContain('resumes when')
|
||||
})
|
||||
|
||||
it('omits the resume hint when no subagents are running', () => {
|
||||
const element = StatusRule({ ...baseProps })
|
||||
|
||||
expect(textContent(element)).not.toContain('resumes when')
|
||||
})
|
||||
|
||||
it('drops the subagent segment before the bg segment on a narrow terminal', () => {
|
||||
// cols=44 is below the subagents breakpoint (92) but the bg breakpoint
|
||||
// (88) too — both gone. Assert the lower-priority subagent indicator is
|
||||
// not shown when space is tight even with a live count.
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
cols: 44,
|
||||
bgCount: 1,
|
||||
usage: { ...baseProps.usage, active_subagents: 2 }
|
||||
})
|
||||
|
||||
expect(textContent(element)).not.toContain('⛓')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatusRule session count click target', () => {
|
||||
it('makes the live session count itself clickable', () => {
|
||||
const openSwitcher = vi.fn()
|
||||
|
||||
const element = StatusRule({
|
||||
bgCount: 0,
|
||||
busy: false,
|
||||
cols: 100,
|
||||
cwdLabel: '~/repo',
|
||||
liveSessionCount: 1,
|
||||
model: 'kimi-k2.6',
|
||||
onSessionCountClick: openSwitcher,
|
||||
sessionStartedAt: null,
|
||||
status: 'ready',
|
||||
statusColor: DEFAULT_THEME.color.ok,
|
||||
t: DEFAULT_THEME,
|
||||
turnStartedAt: null,
|
||||
usage: { total: 0 },
|
||||
voiceLabel: ''
|
||||
})
|
||||
|
||||
const clickableSessionCount = findClickableWithText(element, '1 session')
|
||||
|
||||
expect(clickableSessionCount).not.toBeNull()
|
||||
clickableSessionCount!.props.onClick({ stopImmediatePropagation: vi.fn() })
|
||||
expect(openSwitcher).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps status + model and drops the low-value tail on a narrow terminal', () => {
|
||||
const element = StatusRule({
|
||||
bgCount: 0,
|
||||
busy: false,
|
||||
cols: 44,
|
||||
cwdLabel: '~/src/hermes-agent/apps/desktop (bb/tui-statusbar-responsive)',
|
||||
liveSessionCount: 3,
|
||||
model: 'opus-4.8',
|
||||
onSessionCountClick: vi.fn(),
|
||||
sessionStartedAt: Date.now() - 60_000,
|
||||
status: 'ready',
|
||||
statusColor: DEFAULT_THEME.color.ok,
|
||||
t: DEFAULT_THEME,
|
||||
turnStartedAt: null,
|
||||
usage: {
|
||||
calls: 0,
|
||||
context_max: 200_000,
|
||||
context_percent: 25,
|
||||
context_used: 50_000,
|
||||
input: 0,
|
||||
output: 0,
|
||||
total: 50_000
|
||||
},
|
||||
voiceLabel: 'voice off'
|
||||
})
|
||||
|
||||
const rendered = textContent(element)
|
||||
|
||||
// Must-keep essentials survive intact …
|
||||
expect(rendered).toContain('ready')
|
||||
expect(rendered).toContain('opus 4.8')
|
||||
// … while the low-value tail (session count) is dropped, not truncated.
|
||||
expect(rendered).not.toContain('3 sessions')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatusRule credits notice render priority', () => {
|
||||
it('replaces the idle status with the notice text and keeps model + context', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ credits exhausted' }
|
||||
})
|
||||
|
||||
const rendered = textContent(element)
|
||||
|
||||
// Notice replaces the status verb slot …
|
||||
expect(rendered).toContain('✕ credits exhausted')
|
||||
expect(rendered).not.toContain('ready')
|
||||
// … but model + context stay visible.
|
||||
expect(rendered).toContain('opus 4.8')
|
||||
expect(rendered).toContain('50k')
|
||||
})
|
||||
|
||||
it('busy wins: the FaceTicker shows, the notice is hidden mid-turn', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
busy: true,
|
||||
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' },
|
||||
turnStartedAt: Date.now()
|
||||
})
|
||||
|
||||
const rendered = textContent(element)
|
||||
|
||||
// Notice must NOT render while busy.
|
||||
expect(rendered).not.toContain('⚠ 90% used')
|
||||
// Model still visible.
|
||||
expect(rendered).toContain('opus 4.8')
|
||||
})
|
||||
|
||||
it('colours the notice by level (error → theme error, success → statusGood)', () => {
|
||||
const errEl = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ exhausted' }
|
||||
})
|
||||
|
||||
const errText = findElementWithText(errEl, '✕ exhausted')
|
||||
expect(errText?.props.color).toBe(DEFAULT_THEME.color.error)
|
||||
|
||||
const okEl = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 }
|
||||
})
|
||||
|
||||
const okText = findElementWithText(okEl, '✓ restored')
|
||||
expect(okText?.props.color).toBe(DEFAULT_THEME.color.statusGood)
|
||||
})
|
||||
|
||||
it('does NOT add a glyph — the notice text is rendered verbatim', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' }
|
||||
})
|
||||
|
||||
const noticeText = findElementWithText(element, '90% used')
|
||||
|
||||
// The leaf carries exactly the policy text — no extra prepended glyph.
|
||||
expect(noticeText?.props.children).toBe('⚠ 90% used')
|
||||
})
|
||||
|
||||
it('the notice text is the shrinkable element (flexShrink=1 + truncate-end) so a long notice ellipsizes', () => {
|
||||
const longText = '⚠ ' + 'x'.repeat(200)
|
||||
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
cols: 50,
|
||||
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: longText }
|
||||
})
|
||||
|
||||
// The leaf <Text> truncates rather than wrapping/clipping the pinned tail.
|
||||
const noticeText = findElementWithText(element, 'xxxxx')
|
||||
expect(noticeText?.props.wrap).toBe('truncate-end')
|
||||
|
||||
// Its container box yields first (flexShrink=1) so model stays visible.
|
||||
const findShrinkBoxContaining = (node: ReactNodeLike): React.ReactElement | null => {
|
||||
if (!React.isValidElement(node)) {
|
||||
if (Array.isArray(node)) {
|
||||
for (const c of node) {
|
||||
const f = findShrinkBoxContaining(c)
|
||||
|
||||
if (f) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (node.props.flexShrink === 1 && textContent(node).includes('xxxxx') && node.type !== StatusRule) {
|
||||
// Prefer the closest shrink box that wraps the notice text.
|
||||
const deeper = findShrinkBoxContaining(node.props.children)
|
||||
|
||||
return deeper ?? node
|
||||
}
|
||||
|
||||
return findShrinkBoxContaining(node.props.children)
|
||||
}
|
||||
|
||||
const shrinkBox = findShrinkBoxContaining(element)
|
||||
expect(shrinkBox).not.toBeNull()
|
||||
|
||||
// Model survives on a narrow terminal because the notice yields.
|
||||
expect(textContent(element)).toContain('opus 4.8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatusRule battery indicator', () => {
|
||||
it('renders the battery label with a battery glyph on AC-off', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
battery: { available: true, category: 'good', percent: 82, plugged: false }
|
||||
})
|
||||
|
||||
expect(textContent(element)).toContain('🔋 82%')
|
||||
})
|
||||
|
||||
it('uses a bolt glyph while charging', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
battery: { available: true, category: 'good', percent: 82, plugged: true }
|
||||
})
|
||||
|
||||
expect(textContent(element)).toContain('⚡ 82%')
|
||||
})
|
||||
|
||||
it('colours the read-out by category (critical → theme statusCritical)', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
battery: { available: true, category: 'critical', percent: 7, plugged: false }
|
||||
})
|
||||
|
||||
const leaf = findElementWithText(element, '7%')
|
||||
expect(leaf?.props.color).toBe(DEFAULT_THEME.color.statusCritical)
|
||||
})
|
||||
|
||||
it('omits the segment when battery is null', () => {
|
||||
const element = StatusRule({ ...baseProps, battery: null })
|
||||
|
||||
expect(textContent(element)).not.toContain('%🔋')
|
||||
expect(textContent(element)).not.toContain('🔋')
|
||||
})
|
||||
|
||||
it('omits the segment when no battery is available (desktop/server)', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
battery: { available: false, category: 'dim', percent: null, plugged: null }
|
||||
})
|
||||
|
||||
expect(textContent(element)).not.toContain('🔋')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatusRule idle-since read-out', () => {
|
||||
// The IdleSince component uses hooks, so it can't be invoked outside a
|
||||
// renderer — assert on the element tree instead (same reason the duration
|
||||
// tests don't check SessionDuration's text).
|
||||
const findComponentByName = (node: ReactNodeLike, name: string): React.ReactElement | null => {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findComponentByName(child, name)
|
||||
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (!React.isValidElement(node)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof node.type === 'function' && node.type.name === name) {
|
||||
return node
|
||||
}
|
||||
|
||||
return findComponentByName(node.props.children, name)
|
||||
}
|
||||
|
||||
it('shows time since the last final agent response when idle', () => {
|
||||
const endedAt = Date.now() - 42_000
|
||||
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
lastTurnEndedAt: endedAt,
|
||||
sessionStartedAt: Date.now() - 60_000
|
||||
})
|
||||
|
||||
const idle = findComponentByName(element, 'IdleSince')
|
||||
|
||||
expect(idle).not.toBeNull()
|
||||
expect(idle!.props.endedAt).toBe(endedAt)
|
||||
})
|
||||
|
||||
it('is hidden while a turn is busy', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
busy: true,
|
||||
lastTurnEndedAt: Date.now() - 42_000,
|
||||
turnStartedAt: Date.now()
|
||||
})
|
||||
|
||||
expect(findComponentByName(element, 'IdleSince')).toBeNull()
|
||||
})
|
||||
|
||||
it('is hidden before the first turn completes', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
lastTurnEndedAt: null,
|
||||
sessionStartedAt: Date.now() - 60_000
|
||||
})
|
||||
|
||||
expect(findComponentByName(element, 'IdleSince')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatusRule perf read-outs (cache hit / latency / tps)', () => {
|
||||
const perfUsage = {
|
||||
...baseProps.usage,
|
||||
avg_latency_s: 3.2,
|
||||
avg_tps: 50.4,
|
||||
cache_hit_pct: 87,
|
||||
calls: 4,
|
||||
input: 1000,
|
||||
output: 500
|
||||
}
|
||||
|
||||
it('renders all three segments on a wide terminal', () => {
|
||||
const element = StatusRule({ ...baseProps, cols: 160, usage: perfUsage })
|
||||
const rendered = textContent(element)
|
||||
|
||||
expect(rendered).toContain('◎ 87%')
|
||||
expect(rendered).toContain('◷ 3.2s')
|
||||
expect(rendered).toContain('↑ 50 t/s')
|
||||
})
|
||||
|
||||
it('self-hides when the server omits the keys', () => {
|
||||
const element = StatusRule({ ...baseProps, cols: 160 })
|
||||
const rendered = textContent(element)
|
||||
|
||||
expect(rendered).not.toContain('◎')
|
||||
expect(rendered).not.toContain('◷')
|
||||
expect(rendered).not.toContain('t/s')
|
||||
})
|
||||
|
||||
it('honors the display.status_bar.fields visibility filter', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
cols: 160,
|
||||
statusBarFields: new Set(['model', 'context_pct', 'cache_hit']),
|
||||
usage: perfUsage
|
||||
})
|
||||
|
||||
const rendered = textContent(element)
|
||||
|
||||
expect(rendered).toContain('◎ 87%')
|
||||
expect(rendered).not.toContain('◷')
|
||||
expect(rendered).not.toContain('t/s')
|
||||
})
|
||||
|
||||
it('hides the session title badge when the fields filter omits title', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
cols: 160,
|
||||
sessionTitle: 'weekly-digest',
|
||||
statusBarFields: new Set(['model', 'context_pct'])
|
||||
})
|
||||
|
||||
expect(textContent(element)).not.toContain('weekly-digest')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { StatusRule } from '../components/appChrome.js'
|
||||
import type * as EnvModule from '../config/env.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
// DEV_CREDITS_MODE is a module-load-time constant (config/env.ts reads
|
||||
// process.env.HERMES_DEV_CREDITS exactly once, at import). Mutating process.env
|
||||
// inside a test can't flip it after the module is loaded — so mock the module to
|
||||
// the dev-on value for this file. vitest hoists vi.mock above the imports, so
|
||||
// appChrome picks up the mocked flag. Lives in its own file so the override
|
||||
// stays scoped (the other StatusRule tests run with the real, dev-off value).
|
||||
vi.mock('../config/env.js', async importOriginal => {
|
||||
const actual = await importOriginal<typeof EnvModule>()
|
||||
|
||||
return { ...actual, DEV_CREDITS_MODE: true }
|
||||
})
|
||||
|
||||
type ReactNodeLike = React.ReactNode
|
||||
|
||||
const textContent = (node: ReactNodeLike): string => {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof node === 'string' || typeof node === 'number') {
|
||||
return String(node)
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(textContent).join('')
|
||||
}
|
||||
|
||||
if (React.isValidElement(node)) {
|
||||
return textContent(node.props.children)
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
bgCount: 0,
|
||||
busy: false,
|
||||
cols: 100,
|
||||
cwdLabel: '~/repo',
|
||||
liveSessionCount: 0,
|
||||
model: 'opus-4.8',
|
||||
sessionStartedAt: null,
|
||||
status: 'ready',
|
||||
statusColor: DEFAULT_THEME.color.ok,
|
||||
t: DEFAULT_THEME,
|
||||
turnStartedAt: null,
|
||||
usage: { context_max: 200_000, context_percent: 25, context_used: 50_000, total: 50_000 },
|
||||
voiceLabel: ''
|
||||
}
|
||||
|
||||
describe('StatusRule dev-credits banner (HERMES_DEV_CREDITS on)', () => {
|
||||
it('keeps the dev-credits banner visible alongside a notice', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' },
|
||||
usage: { ...baseProps.usage, dev_credits_spent_micros: 12_345 }
|
||||
})
|
||||
|
||||
const rendered = textContent(element)
|
||||
|
||||
// The notice and the dev banner coexist …
|
||||
expect(rendered).toContain('⚠ 90% used')
|
||||
expect(rendered).toContain('(dev credits)')
|
||||
// … and the Δ spend segment renders (12345 micros → 1.2¢).
|
||||
expect(rendered).toContain('Δ')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { approvalAction, approvalOptions } from '../components/prompts.js'
|
||||
|
||||
describe('approvalAction — pure key dispatch for ApprovalPrompt', () => {
|
||||
it('maps Esc to deny — parity with global Ctrl+C cancellation', () => {
|
||||
expect(approvalAction('', { escape: true }, 0)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
expect(approvalAction('', { escape: true }, 2)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
})
|
||||
|
||||
it('maps number keys 1..4 to once/session/always/deny in registration order', () => {
|
||||
expect(approvalAction('1', {}, 0)).toEqual({ kind: 'choose', choice: 'once' })
|
||||
expect(approvalAction('2', {}, 0)).toEqual({ kind: 'choose', choice: 'session' })
|
||||
expect(approvalAction('3', {}, 0)).toEqual({ kind: 'choose', choice: 'always' })
|
||||
expect(approvalAction('4', {}, 0)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
})
|
||||
|
||||
it('ignores out-of-range numbers', () => {
|
||||
expect(approvalAction('0', {}, 1)).toEqual({ kind: 'noop' })
|
||||
expect(approvalAction('5', {}, 1)).toEqual({ kind: 'noop' })
|
||||
expect(approvalAction('9', {}, 1)).toEqual({ kind: 'noop' })
|
||||
})
|
||||
|
||||
it('confirms the current selection on Enter', () => {
|
||||
expect(approvalAction('', { return: true }, 0)).toEqual({ kind: 'choose', choice: 'once' })
|
||||
expect(approvalAction('', { return: true }, 3)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
})
|
||||
|
||||
it('moves selection up/down within bounds', () => {
|
||||
expect(approvalAction('', { upArrow: true }, 2)).toEqual({ kind: 'move', delta: -1 })
|
||||
expect(approvalAction('', { downArrow: true }, 1)).toEqual({ kind: 'move', delta: 1 })
|
||||
})
|
||||
|
||||
it('clamps selection movement at the edges', () => {
|
||||
expect(approvalAction('', { upArrow: true }, 0)).toEqual({ kind: 'noop' })
|
||||
expect(approvalAction('', { downArrow: true }, 3)).toEqual({ kind: 'noop' })
|
||||
})
|
||||
|
||||
it('Esc beats numeric/return — denying is always the first interpretation', () => {
|
||||
// If a terminal somehow delivers Esc + a digit in the same event, deny
|
||||
// wins. Documents the precedence so a future refactor doesn't flip it.
|
||||
expect(approvalAction('1', { escape: true }, 0)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
expect(approvalAction('', { escape: true, return: true }, 1)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
})
|
||||
|
||||
it('returns noop for unrelated keystrokes (printable letters etc.)', () => {
|
||||
expect(approvalAction('a', {}, 0)).toEqual({ kind: 'noop' })
|
||||
expect(approvalAction(' ', {}, 0)).toEqual({ kind: 'noop' })
|
||||
})
|
||||
|
||||
it('respects a reduced option set when permanent allow is disabled', () => {
|
||||
// tirith content-security warning present → no "always"; the 3-item set is
|
||||
// once/session/deny, so 3 maps to deny and 4 is out of range.
|
||||
const opts = ['once', 'session', 'deny'] as const
|
||||
|
||||
expect(approvalAction('3', {}, 0, opts)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
expect(approvalAction('4', {}, 0, opts)).toEqual({ kind: 'noop' })
|
||||
expect(approvalAction('', { downArrow: true }, 2, opts)).toEqual({ kind: 'noop' })
|
||||
expect(approvalAction('', { return: true }, 2, opts)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
})
|
||||
|
||||
it('offers only once and deny for Smart DENY owner override', () => {
|
||||
const opts = approvalOptions({
|
||||
allowPermanent: true,
|
||||
command: 'rm -rf /',
|
||||
description: 'blocked',
|
||||
smartDenied: true
|
||||
})
|
||||
|
||||
expect(opts).toEqual(['once', 'deny'])
|
||||
expect(approvalAction('2', {}, 0, opts)).toEqual({ kind: 'choose', choice: 'deny' })
|
||||
expect(approvalAction('3', {}, 0, opts)).toEqual({ kind: 'noop' })
|
||||
})
|
||||
|
||||
it('uses explicit gateway choices as the prompt contract', () => {
|
||||
expect(
|
||||
approvalOptions({
|
||||
allowPermanent: true,
|
||||
choices: ['once', 'deny'],
|
||||
command: 'rm -rf /',
|
||||
description: 'blocked'
|
||||
})
|
||||
).toEqual(['once', 'deny'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { asCommandDispatch } from '../lib/rpc.js'
|
||||
|
||||
describe('asCommandDispatch', () => {
|
||||
it('parses exec, alias, skill, and send', () => {
|
||||
expect(asCommandDispatch({ type: 'exec', output: 'hi' })).toEqual({ type: 'exec', output: 'hi' })
|
||||
expect(asCommandDispatch({ type: 'alias', target: 'help' })).toEqual({ type: 'alias', target: 'help' })
|
||||
expect(asCommandDispatch({ type: 'skill', name: 'x', message: 'do' })).toEqual({
|
||||
type: 'skill',
|
||||
name: 'x',
|
||||
message: 'do'
|
||||
})
|
||||
expect(asCommandDispatch({ type: 'send', message: 'hello world' })).toEqual({
|
||||
type: 'send',
|
||||
message: 'hello world'
|
||||
})
|
||||
expect(asCommandDispatch({ type: 'prefill', message: 'edit me' })).toEqual({
|
||||
type: 'prefill',
|
||||
message: 'edit me'
|
||||
})
|
||||
expect(asCommandDispatch({ type: 'prefill', message: 'edit me', notice: '↶ rewound' })).toEqual({
|
||||
type: 'prefill',
|
||||
message: 'edit me',
|
||||
notice: '↶ rewound'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed payloads', () => {
|
||||
expect(asCommandDispatch(null)).toBeNull()
|
||||
expect(asCommandDispatch({ type: 'alias' })).toBeNull()
|
||||
expect(asCommandDispatch({ type: 'skill', name: 1 })).toBeNull()
|
||||
expect(asCommandDispatch({ type: 'send' })).toBeNull()
|
||||
expect(asCommandDispatch({ type: 'send', message: 42 })).toBeNull()
|
||||
expect(asCommandDispatch({ type: 'prefill' })).toBeNull()
|
||||
expect(asCommandDispatch({ type: 'prefill', message: 42 })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComposerToken } from '../app/interfaces.js'
|
||||
import { droppedTokens, expandTokens, imageToken, nextImageIndex } from '../domain/attachments.js'
|
||||
|
||||
const paste = (label: string, text: string): ComposerToken => ({ kind: 'paste', label, text })
|
||||
|
||||
const image = (index: number, path = `/tmp/img${index}.png`): ComposerToken => ({
|
||||
index,
|
||||
kind: 'image',
|
||||
label: imageToken(index),
|
||||
path
|
||||
})
|
||||
|
||||
describe('expandTokens (what the agent actually receives)', () => {
|
||||
it('replaces a collapsed paste label with its full content', () => {
|
||||
const label = '[[ hello.. [3 lines] .. world ]]'
|
||||
const expand = expandTokens([paste(label, 'hello\nfoo\nworld')])
|
||||
|
||||
expect(expand(`here: ${label} done`)).toBe('here: hello\nfoo\nworld done')
|
||||
})
|
||||
|
||||
it('is a no-op for already-expanded / token-free text (recall round-trip)', () => {
|
||||
const expanded = 'hello\nfoo\nworld'
|
||||
expect(expandTokens([])(expanded)).toBe(expanded)
|
||||
})
|
||||
|
||||
it('expands repeated identical labels in submission order', () => {
|
||||
const label = '[[ x [1 lines] ]]'
|
||||
const expand = expandTokens([paste(label, 'first'), paste(label, 'second')])
|
||||
|
||||
expect(expand(`${label} then ${label}`)).toBe('first then second')
|
||||
})
|
||||
|
||||
it('leaves an unmatched label intact', () => {
|
||||
const label = '[[ orphan [2 lines] ]]'
|
||||
expect(expandTokens([])(label)).toBe(label)
|
||||
})
|
||||
|
||||
it('drops an image token from the text — the gateway already holds the file', () => {
|
||||
const expand = expandTokens([image(1)])
|
||||
|
||||
expect(expand(`what is in ${imageToken(1)}`)).toBe('what is in')
|
||||
})
|
||||
|
||||
it('leaves no double space where an image token sat mid-sentence', () => {
|
||||
const expand = expandTokens([image(1)])
|
||||
|
||||
expect(expand(`before ${imageToken(1)} after`)).toBe('before after')
|
||||
})
|
||||
|
||||
it('resolves an image-only message to empty text', () => {
|
||||
expect(expandTokens([image(1)])(imageToken(1))).toBe('')
|
||||
})
|
||||
|
||||
it('resolves pastes and images in one pass', () => {
|
||||
const label = '[[ log.. [9 lines] ]]'
|
||||
const expand = expandTokens([paste(label, 'stack\ntrace'), image(2)])
|
||||
|
||||
expect(expand(`${label} and ${imageToken(2)}`)).toBe('stack\ntrace and')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextImageIndex (user-facing numbering)', () => {
|
||||
it('starts at 1', () => {
|
||||
expect(nextImageIndex([])).toBe(1)
|
||||
})
|
||||
|
||||
it('counts past existing images', () => {
|
||||
expect(nextImageIndex([image(1), image(2)])).toBe(3)
|
||||
})
|
||||
|
||||
it('ignores paste tokens', () => {
|
||||
expect(nextImageIndex([paste('[[ x ]]', 'y')])).toBe(1)
|
||||
})
|
||||
|
||||
it('does not reuse an index after an earlier image is deleted', () => {
|
||||
// [[ Image 1 ]] was erased; the next attach must not become Image 1 again
|
||||
// or expandTokens would resolve two different files to one label.
|
||||
expect(nextImageIndex([image(2)])).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('droppedTokens (deleting the token unattaches the thing)', () => {
|
||||
it('reports an image whose token was erased from the text', () => {
|
||||
expect(droppedTokens([image(1)], 'just text now')).toEqual([image(1)])
|
||||
})
|
||||
|
||||
it('reports nothing while the token is still present', () => {
|
||||
expect(droppedTokens([image(1)], `look at ${imageToken(1)}`)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a surviving token when a sibling is erased', () => {
|
||||
expect(droppedTokens([image(1), image(2)], imageToken(2))).toEqual([image(1)])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
import { PassThrough } from 'stream'
|
||||
|
||||
import { renderSync } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Stub useInput so the overlay doesn't enter raw mode under renderSync.
|
||||
vi.mock('@hermes/ink', async importOriginal => {
|
||||
const mod = await importOriginal()
|
||||
|
||||
return { ...mod, useInput: () => {} }
|
||||
})
|
||||
|
||||
import type { BillingOverlayState } from '../app/interfaces.js'
|
||||
import { BillingOverlay } from '../components/billingOverlay.js'
|
||||
import type { BillingStateResponse } from '../gatewayTypes.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
function render(overlay: BillingOverlayState): string {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(
|
||||
React.createElement(BillingOverlay, {
|
||||
onClose: () => {},
|
||||
onPatch: () => {},
|
||||
overlay,
|
||||
t
|
||||
}),
|
||||
{
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
}
|
||||
)
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
|
||||
return stripAnsi(output)
|
||||
}
|
||||
|
||||
const billState = (overrides: Partial<BillingStateResponse> = {}): BillingStateResponse =>
|
||||
({
|
||||
auto_reload: null,
|
||||
balance_display: '$12.00',
|
||||
balance_usd: '12',
|
||||
can_charge: true,
|
||||
card: { brand: 'visa', last4: '4242', masked: 'visa ····4242' },
|
||||
charge_presets: ['25', '50'],
|
||||
charge_presets_display: ['$25', '$50'],
|
||||
cli_billing_enabled: true,
|
||||
is_admin: true,
|
||||
logged_in: true,
|
||||
max_usd: '1000',
|
||||
min_usd: '10',
|
||||
monthly_cap: null,
|
||||
ok: true,
|
||||
org_name: 'Acme',
|
||||
portal_url: 'https://portal/billing',
|
||||
role: 'OWNER',
|
||||
...overrides
|
||||
}) as BillingStateResponse
|
||||
|
||||
const ctx = {
|
||||
applyAutoReload: vi.fn(() => Promise.resolve(true)),
|
||||
charge: vi.fn(() => Promise.resolve('submitted' as const)),
|
||||
openPortal: vi.fn(),
|
||||
refreshState: vi.fn(() => Promise.resolve(null)),
|
||||
requestRemoteSpending: vi.fn(() => Promise.resolve(true)),
|
||||
sys: vi.fn(),
|
||||
validate: vi.fn((raw: string) => ({ amount: raw }))
|
||||
}
|
||||
|
||||
const overlay = (screen: BillingOverlayState['screen']): BillingOverlayState => ({
|
||||
ctx,
|
||||
pendingCharge: { amount: '100' },
|
||||
screen,
|
||||
state: billState()
|
||||
})
|
||||
|
||||
describe('BillingOverlay — step-up screen (Allow Remote Spending)', () => {
|
||||
it('renders the one-time-setup prompt with the held amount, never leaking the raw scope', () => {
|
||||
const out = render(overlay('stepup'))
|
||||
expect(out).toContain('One-time setup')
|
||||
expect(out).toContain('Allow Remote Spending')
|
||||
expect(out).toContain('$100') // resumes the held purchase
|
||||
expect(out).toContain('Not now')
|
||||
expect(out).not.toContain('billing:manage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BillingOverlay — overview (reordered, dollars)', () => {
|
||||
it('leads with balance in the title, Add funds first, no "credits"', () => {
|
||||
const out = render(overlay('overview'))
|
||||
expect(out).toContain('Top up · balance $12.00') // balance in the title
|
||||
expect(out).toContain('Add funds') // buy action, renamed
|
||||
expect(out).toContain('Auto-reload')
|
||||
expect(out).toContain('Manage on portal')
|
||||
expect(out.toLowerCase()).not.toContain('credits') // dollars only
|
||||
// No standalone "Allow Remote Spending" item — discovered at pay time.
|
||||
expect(out).not.toContain('Allow Remote Spending')
|
||||
})
|
||||
|
||||
it('renders the two-bar dollar usage when a usage model is present', () => {
|
||||
const withUsage: BillingOverlayState = {
|
||||
...overlay('overview'),
|
||||
state: {
|
||||
...billState(),
|
||||
usage: {
|
||||
available: true,
|
||||
status: 'healthy',
|
||||
plan_name: 'Plus',
|
||||
has_topup: true,
|
||||
plan_bar: {
|
||||
kind: 'plan',
|
||||
remaining_display: '$14.00',
|
||||
total_display: '$20.00',
|
||||
spent_display: '$6.00',
|
||||
pct_used: 30,
|
||||
fill_fraction: 0.7
|
||||
},
|
||||
topup_bar: {
|
||||
kind: 'topup',
|
||||
remaining_display: '$12.00',
|
||||
total_display: '$12.00',
|
||||
spent_display: '$0.00',
|
||||
pct_used: null,
|
||||
fill_fraction: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const out = render(withUsage)
|
||||
expect(out).toContain('$14.00 left of $20.00')
|
||||
expect(out).toContain('30% used')
|
||||
expect(out).toContain('never expires')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BillingOverlay — auto-reload card divergence', () => {
|
||||
const autoReload = (card: NonNullable<BillingStateResponse['auto_reload']>['card']) => ({
|
||||
card,
|
||||
enabled: true,
|
||||
reload_to_display: '$100',
|
||||
reload_to_usd: '100',
|
||||
threshold_display: '$20',
|
||||
threshold_usd: '20'
|
||||
})
|
||||
|
||||
it('warns when auto-reload charges a distinct card and offers the portal hand-off', () => {
|
||||
const out = render({
|
||||
...overlay('autoreload'),
|
||||
state: billState({
|
||||
auto_reload: autoReload({ kind: 'distinct', payment_method_id: 'pm_other', brand: 'Visa', last4: '9999' })
|
||||
})
|
||||
})
|
||||
|
||||
expect(out).toContain('Auto-refill is charging Visa ••9999 — not your card on file')
|
||||
expect(out).toContain('authorize Nous Research to charge Visa ••9999')
|
||||
expect(out).toContain('Use your card on file — manage on portal')
|
||||
})
|
||||
|
||||
it('uses generic distinct-card copy when metadata is unresolved', () => {
|
||||
const out = render({
|
||||
...overlay('autoreload'),
|
||||
state: billState({
|
||||
auto_reload: autoReload({ kind: 'distinct', payment_method_id: 'pm_other', brand: null, last4: null })
|
||||
})
|
||||
})
|
||||
|
||||
expect(out).toContain('Auto-refill is charging a different card — not your card on file')
|
||||
})
|
||||
|
||||
it.each(['canonical', 'none'] as const)('does not warn for a %s auto-reload card', kind => {
|
||||
const out = render({
|
||||
...overlay('autoreload'),
|
||||
state: billState({ auto_reload: autoReload({ kind }) })
|
||||
})
|
||||
|
||||
expect(out).not.toContain('not your card on file')
|
||||
expect(out).not.toContain('Use your card on file — manage on portal')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { blockRenders, hasLeadGap, messageGroup, prevRenderedMsg } from '../domain/blockLayout.js'
|
||||
import type { Msg } from '../types.js'
|
||||
|
||||
const m = (over: Partial<Msg>): Msg => ({ role: 'assistant', text: '', ...over })
|
||||
|
||||
describe('messageGroup', () => {
|
||||
it('classifies each block kind into its visual band', () => {
|
||||
expect(messageGroup(m({ role: 'assistant' }))).toBe('model')
|
||||
expect(messageGroup(m({ role: 'assistant', kind: 'diff' }))).toBe('diff')
|
||||
expect(messageGroup(m({ role: 'system', kind: 'trail' }))).toBe('trail')
|
||||
expect(messageGroup(m({ role: 'system' }))).toBe('note')
|
||||
expect(messageGroup(m({ role: 'user' }))).toBe('user')
|
||||
expect(messageGroup(m({ role: 'user', kind: 'slash' }))).toBe('slash')
|
||||
expect(messageGroup(m({ role: 'system', kind: 'intro' }))).toBe('intro')
|
||||
expect(messageGroup(m({ role: 'system', kind: 'panel' }))).toBe('intro')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasLeadGap', () => {
|
||||
const trail = m({ role: 'system', kind: 'trail' })
|
||||
const model = m({ role: 'assistant' })
|
||||
const note = m({ role: 'system' })
|
||||
const user = m({ role: 'user' })
|
||||
const diff = m({ role: 'assistant', kind: 'diff' })
|
||||
const slash = m({ role: 'user', kind: 'slash' })
|
||||
|
||||
it('opens a gap only at a boundary between working-area groups', () => {
|
||||
expect(hasLeadGap(trail, model)).toBe(true)
|
||||
expect(hasLeadGap(model, trail)).toBe(true)
|
||||
expect(hasLeadGap(model, note)).toBe(true)
|
||||
expect(hasLeadGap(note, model)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps same-group neighbours flush (the grouping)', () => {
|
||||
expect(hasLeadGap(trail, trail)).toBe(false)
|
||||
expect(hasLeadGap(model, model)).toBe(false)
|
||||
expect(hasLeadGap(note, note)).toBe(false)
|
||||
})
|
||||
|
||||
it('never gaps the first block (no predecessor)', () => {
|
||||
expect(hasLeadGap(undefined, model)).toBe(false)
|
||||
expect(hasLeadGap(undefined, trail)).toBe(false)
|
||||
})
|
||||
|
||||
it('suppresses the gap after blocks that already paint a trailing line', () => {
|
||||
// user and diff carry their own marginBottom — the following block must
|
||||
// not add a second blank line on top of it.
|
||||
expect(hasLeadGap(user, trail)).toBe(false)
|
||||
expect(hasLeadGap(user, model)).toBe(false)
|
||||
expect(hasLeadGap(diff, model)).toBe(false)
|
||||
})
|
||||
|
||||
it('still gaps after a slash echo (it has no trailing margin)', () => {
|
||||
expect(hasLeadGap(slash, model)).toBe(true)
|
||||
expect(hasLeadGap(slash, trail)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets user / slash / diff own their spacing (never managed here)', () => {
|
||||
expect(hasLeadGap(model, user)).toBe(false)
|
||||
expect(hasLeadGap(model, slash)).toBe(false)
|
||||
expect(hasLeadGap(model, diff)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blockRenders', () => {
|
||||
const trail: Msg = { role: 'system', kind: 'trail', text: '', tools: ['Edit foo.ts'] }
|
||||
const model: Msg = { role: 'assistant', text: 'hi' }
|
||||
const todos: Msg = { role: 'system', kind: 'trail', text: '', todos: [{ content: 'a', id: '1', status: 'pending' }] }
|
||||
|
||||
it('always renders non-trail blocks', () => {
|
||||
expect(blockRenders(model, { detailsMode: 'hidden', commandOverride: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('renders a content-bearing trail unless every section is hidden', () => {
|
||||
expect(blockRenders(trail, { detailsMode: 'collapsed' })).toBe(true)
|
||||
expect(blockRenders(trail, { detailsMode: 'expanded' })).toBe(true)
|
||||
// /details hidden routes through commandOverride, which hides every section.
|
||||
expect(blockRenders(trail, { detailsMode: 'hidden', commandOverride: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('does not render a content-less trail (e.g. finalDetails with only a token tally)', () => {
|
||||
const tally: Msg = { role: 'system', kind: 'trail', text: '', toolTokens: 40 }
|
||||
|
||||
expect(blockRenders(tally, { detailsMode: 'expanded' })).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps todo trails visible even when details are hidden', () => {
|
||||
expect(blockRenders(todos, { detailsMode: 'hidden', commandOverride: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prevRenderedMsg', () => {
|
||||
const hiddenCtx = { commandOverride: true, detailsMode: 'hidden' as const }
|
||||
const shownCtx = { detailsMode: 'collapsed' as const }
|
||||
|
||||
const rows: Msg[] = [
|
||||
{ role: 'user', text: 'q' }, // 0
|
||||
{ role: 'system', kind: 'trail', text: '', tools: ['Edit foo.ts'] }, // 1
|
||||
{ role: 'assistant', text: 'first' }, // 2
|
||||
{ role: 'system', kind: 'trail', text: '', tools: ['Edit bar.ts'] }, // 3
|
||||
{ role: 'assistant', text: 'second' } // 4
|
||||
]
|
||||
|
||||
const at = (i: number) => rows[i]
|
||||
|
||||
it('returns the literal predecessor when everything renders', () => {
|
||||
expect(prevRenderedMsg(at, 2, shownCtx)).toBe(rows[1])
|
||||
expect(prevRenderedMsg(at, 4, shownCtx)).toBe(rows[3])
|
||||
})
|
||||
|
||||
it('skips hidden trails so grouping sees the nearest visible block', () => {
|
||||
// With trails hidden, the prose at index 2 groups against the user (not the
|
||||
// invisible trail) and the prose at index 4 groups against the prose at 2.
|
||||
expect(prevRenderedMsg(at, 2, hiddenCtx)).toBe(rows[0])
|
||||
expect(prevRenderedMsg(at, 4, hiddenCtx)).toBe(rows[2])
|
||||
})
|
||||
|
||||
it('returns undefined at the top of the transcript', () => {
|
||||
expect(prevRenderedMsg(at, 0, shownCtx)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { PassThrough } from 'stream'
|
||||
|
||||
import { renderSync } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { SessionPanel } from '../components/branding.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
import type { McpServerStatus, SessionInfo } from '../types.js'
|
||||
|
||||
// Invariant under test: the TUI banner's MCP headline counts *connected*
|
||||
// servers, never configured-but-disabled ones. This mirrors the classic CLI
|
||||
// banner (`mcp_connected = sum(1 for s in mcp_status if s["connected"])` in
|
||||
// hermes_cli/banner.py) and the "connected" label on the MCP collapse toggle.
|
||||
//
|
||||
// Regression: branding.tsx used the raw `info.mcp_servers.length`, so a
|
||||
// disabled `linear` server alongside a connected `nous-support` server made
|
||||
// the TUI report "2 MCP" while the classic CLI correctly reported "1 MCP".
|
||||
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
const makeStreams = (columns = 100) => {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
|
||||
Object.assign(stdout, { columns, isTTY: false, rows: 40 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
|
||||
let captured = ''
|
||||
stdout.on('data', chunk => {
|
||||
captured += chunk.toString()
|
||||
})
|
||||
|
||||
return { capture: () => captured, stderr, stdin, stdout }
|
||||
}
|
||||
|
||||
const mcp = (over: Partial<McpServerStatus> & Pick<McpServerStatus, 'name'>): McpServerStatus => ({
|
||||
connected: false,
|
||||
tools: 0,
|
||||
transport: 'http',
|
||||
...over
|
||||
})
|
||||
|
||||
const baseInfo = (mcp_servers: McpServerStatus[]): SessionInfo => ({
|
||||
mcp_servers,
|
||||
model: 'test-model',
|
||||
skills: { core: ['a', 'b'] },
|
||||
tools: { file: ['read_file', 'write_file'] }
|
||||
})
|
||||
|
||||
async function renderFooter(info: SessionInfo): Promise<string> {
|
||||
const streams = makeStreams()
|
||||
|
||||
const instance = renderSync(React.createElement(SessionPanel, { info, sid: 'test', t: DEFAULT_THEME }), {
|
||||
patchConsole: false,
|
||||
stderr: streams.stderr as NodeJS.WriteStream,
|
||||
stdin: streams.stdin as NodeJS.ReadStream,
|
||||
stdout: streams.stdout as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
try {
|
||||
await delay(20)
|
||||
|
||||
// Strip ANSI so we can assert on the rendered text content.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return streams.capture().replace(/\u001b\[[0-9;]*m/g, '')
|
||||
} finally {
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
describe('branding MCP headline count', () => {
|
||||
it('counts only connected servers, not configured-but-disabled ones', async () => {
|
||||
const frame = await renderFooter(
|
||||
baseInfo([
|
||||
mcp({ connected: true, name: 'nous-support', status: 'connected', tools: 6 }),
|
||||
mcp({ connected: false, disabled: true, name: 'linear', status: 'disabled' })
|
||||
])
|
||||
)
|
||||
|
||||
// One connected server → "1 MCP", never "2 MCP".
|
||||
expect(frame).toContain('1 MCP')
|
||||
expect(frame).not.toContain('2 MCP')
|
||||
})
|
||||
|
||||
it('drops the MCP segment entirely when no server is connected', async () => {
|
||||
const frame = await renderFooter(
|
||||
baseInfo([mcp({ connected: false, disabled: true, name: 'linear', status: 'disabled' })])
|
||||
)
|
||||
|
||||
// Matches the classic CLI, which only appends "· N MCP" when N > 0.
|
||||
expect(frame).not.toContain('MCP servers')
|
||||
expect(frame).not.toMatch(/\d MCP\b/)
|
||||
})
|
||||
|
||||
it('counts every connected server when several are connected', async () => {
|
||||
const frame = await renderFooter(
|
||||
baseInfo([
|
||||
mcp({ connected: true, name: 'alpha', status: 'connected' }),
|
||||
mcp({ connected: true, name: 'beta', status: 'connected' }),
|
||||
mcp({ connected: false, disabled: true, name: 'gamma', status: 'disabled' })
|
||||
])
|
||||
)
|
||||
|
||||
expect(frame).toContain('2 MCP')
|
||||
expect(frame).not.toContain('3 MCP')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Bundle-shape regression for issue #31227.
|
||||
*
|
||||
* The dashboard TUI ships as a single esbuild-bundled `dist/entry.js`.
|
||||
* When the bundle contains an `async`-init `__esm` wrapper that participates
|
||||
* in a circular module graph, esbuild's lightweight init helper deadlocks
|
||||
* the top-level `await Promise.all([...])` in src/entry.tsx — the user
|
||||
* sees only 141 bytes of ANSI reset sequences and a blank screen forever.
|
||||
*
|
||||
* Root cause: re-exporting `ink-text-input` from `@hermes/ink`'s
|
||||
* entry-exports drags the upstream `ink` package into the bundle. That
|
||||
* `ink` graph and our in-tree `@hermes/ink` graph reference each other
|
||||
* via React/`ink-text-input`, producing the circular async cycle that
|
||||
* `__esm` cannot resolve.
|
||||
*
|
||||
* These tests guard the two structural properties that, together,
|
||||
* keep the bundle deadlock-free:
|
||||
*
|
||||
* 1. No `async` `__esm` modules in the bundle. As long as every init
|
||||
* runs synchronously, `__esm`'s closure-capture quirk is irrelevant.
|
||||
* 2. No `ink-text-input` / `node_modules/ink/build` modules in the
|
||||
* bundle. Their absence is what makes #1 hold; if a future commit
|
||||
* re-introduces the re-export, it would reintroduce the cycle.
|
||||
*
|
||||
* The bundle is a build artifact, so the test builds it on demand and
|
||||
* skips itself when esbuild can't be resolved (e.g. during a partial
|
||||
* install). It does not need a TTY.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const uiTuiRoot = resolve(here, '..', '..')
|
||||
const bundlePath = resolve(uiTuiRoot, 'dist', 'entry.js')
|
||||
|
||||
function bundleIsFresh(): boolean {
|
||||
if (!existsSync(bundlePath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const bundleMtime = statSync(bundlePath).mtimeMs
|
||||
|
||||
const sourceMtime = statSync(resolve(uiTuiRoot, 'packages/hermes-ink/src/entry-exports.ts')).mtimeMs
|
||||
|
||||
return bundleMtime >= sourceMtime
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let bundleSrc = ''
|
||||
|
||||
beforeAll(() => {
|
||||
if (!bundleIsFresh()) {
|
||||
// Refresh the bundle so the regression test runs against current
|
||||
// sources, not whatever was last committed by hand.
|
||||
execFileSync(process.execPath, [resolve(uiTuiRoot, 'scripts/build.mjs')], {
|
||||
cwd: uiTuiRoot,
|
||||
stdio: ['ignore', 'ignore', 'inherit'],
|
||||
timeout: 120_000
|
||||
})
|
||||
}
|
||||
|
||||
bundleSrc = readFileSync(bundlePath, 'utf8')
|
||||
}, 180_000)
|
||||
|
||||
describe('TUI bundle (issue #31227)', () => {
|
||||
it('has no async __esm wrappers (would risk circular-await deadlock)', () => {
|
||||
// esbuild emits `async "<path>"() { ... }` as the first key of a
|
||||
// module's `__esm` definition when the module body contains
|
||||
// top-level await. The lightweight `__esm` helper at the top of
|
||||
// the bundle does NOT await nested inits, so any async __esm
|
||||
// module in a circular graph hangs forever the first time it's
|
||||
// entered.
|
||||
const matches = bundleSrc.match(/async "(packages|src|node_modules)\/[^"]+"\s*\(\)/g) ?? []
|
||||
expect(
|
||||
matches,
|
||||
`Found ${matches.length} async __esm wrappers — these can deadlock #31227. First few:\n${matches.slice(0, 3).join('\n')}`
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('does not bundle the upstream ink package or ink-text-input', () => {
|
||||
// Pulling either of these in re-creates the circular async chain
|
||||
// that #31227 was about. The in-tree fork at @hermes/ink replaces
|
||||
// all of `ink`; nothing in ui-tui imports `TextInput` from
|
||||
// `@hermes/ink` so the re-export is unused dead weight.
|
||||
expect(bundleSrc.includes('node_modules/ink/build/index.js')).toBe(false)
|
||||
expect(bundleSrc.includes('node_modules/ink-text-input/build/index.js')).toBe(false)
|
||||
})
|
||||
|
||||
it('has the @hermes/ink entry-exports module compiled to sync init', () => {
|
||||
// Sanity check that the alias swap to packages/hermes-ink/src/entry-exports.ts
|
||||
// is still active and producing the expected synchronous init shape.
|
||||
expect(bundleSrc).toMatch(/var init_entry_exports = __esm\(\{\s*"packages\/hermes-ink\/src\/entry-exports\.ts"\(\)/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { gauge, hbars, sparkline, sparkRows } from '../lib/charts.js'
|
||||
|
||||
describe('chart primitives', () => {
|
||||
it('sparkline spans the block ramp and respects width', () => {
|
||||
const line = sparkline([0, 1, 2, 3, 4, 5, 6, 7], 8)
|
||||
|
||||
expect(line).toBe('▁▂▃▄▅▆▇█')
|
||||
expect(sparkline([1, 2, 3, 4], 2)).toHaveLength(2) // window = last N
|
||||
})
|
||||
|
||||
it('is dimension-stable: short/empty series pad to exactly width', () => {
|
||||
// Warm-up must never resize the card — latest sample pins right.
|
||||
expect(sparkline([5], 6)).toHaveLength(6)
|
||||
expect(sparkline([5], 6).endsWith('▁')).toBe(true) // flat series → bottom block, right-pinned
|
||||
expect(sparkline([], 6)).toBe(' ')
|
||||
expect(sparkRows([7], 5, 2).every(row => row.length === 5)).toBe(true)
|
||||
expect(sparkRows([], 5, 2)).toEqual([' ', ' '])
|
||||
expect(hbars([1, 4], 8).every(bar => bar.length === 8)).toBe(true)
|
||||
})
|
||||
|
||||
it('sparkRows partitions each column across rows (top line first)', () => {
|
||||
const rows = sparkRows([0, 8, 4], 3, 2)
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows.every(r => r.length === 3)).toBe(true)
|
||||
// Max value fills the top row cell; min value leaves it blank.
|
||||
expect(rows[0]![1]).toBe('█')
|
||||
expect(rows[0]![0]).toBe(' ')
|
||||
})
|
||||
|
||||
it('gauge clamps and fills proportionally', () => {
|
||||
expect(gauge(0.5, 8)).toBe('████░░░░')
|
||||
expect(gauge(-1, 4)).toBe('░░░░')
|
||||
expect(gauge(9, 4)).toBe('████')
|
||||
})
|
||||
|
||||
it('hbars scales to the max with eighth-block tips', () => {
|
||||
const [half, full] = hbars([4, 8], 8)
|
||||
|
||||
expect(full).toBe('████████')
|
||||
expect(half).toBe('████ ')
|
||||
expect(hbars([3, 8], 8)[0]).toBe('███ ') // 3/8 of 8 cells, padded
|
||||
expect(hbars([1, 2], 3)[0]).toMatch(/^█?[▏▎▍▌▋▊▉█] *$/) // fractional tip, padded
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,393 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isUsableClipboardText, readClipboardText, writeClipboardText } from '../lib/clipboard.js'
|
||||
|
||||
describe('readClipboardText', () => {
|
||||
it('reads text from pbpaste on macOS', async () => {
|
||||
const run = vi.fn().mockResolvedValue({ stdout: 'hello world\n' })
|
||||
|
||||
await expect(readClipboardText('darwin', run)).resolves.toBe('hello world\n')
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'pbpaste',
|
||||
[],
|
||||
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('reads text from PowerShell on Windows', async () => {
|
||||
const b64 = Buffer.from('from windows\r\n', 'utf8').toString('base64')
|
||||
const run = vi.fn().mockResolvedValue({ stdout: b64 })
|
||||
|
||||
await expect(readClipboardText('win32', run)).resolves.toBe('from windows\r\n')
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'powershell',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
'[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw)))'
|
||||
],
|
||||
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('tries powershell.exe first on WSL', async () => {
|
||||
const b64 = Buffer.from('from wsl\n', 'utf8').toString('base64')
|
||||
const run = vi.fn().mockResolvedValue({ stdout: b64 })
|
||||
|
||||
await expect(readClipboardText('linux', run, { WSL_INTEROP: '/tmp/socket' } as NodeJS.ProcessEnv)).resolves.toBe(
|
||||
'from wsl\n'
|
||||
)
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
'[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw)))'
|
||||
],
|
||||
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('uses wl-paste on Wayland Linux', async () => {
|
||||
const run = vi.fn().mockResolvedValue({ stdout: 'from wayland\n' })
|
||||
|
||||
await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBe(
|
||||
'from wayland\n'
|
||||
)
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'wl-paste',
|
||||
['--type', 'text'],
|
||||
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to xclip on Linux when wl-paste fails', async () => {
|
||||
const run = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('wl-paste missing'))
|
||||
.mockResolvedValueOnce({ stdout: 'from xclip\n' })
|
||||
|
||||
await expect(readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)).resolves.toBe(
|
||||
'from xclip\n'
|
||||
)
|
||||
expect(run).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'wl-paste',
|
||||
['--type', 'text'],
|
||||
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
|
||||
)
|
||||
expect(run).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xclip',
|
||||
['-selection', 'clipboard', '-out'],
|
||||
expect.objectContaining({ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024, windowsHide: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when every clipboard backend fails', async () => {
|
||||
const run = vi.fn().mockRejectedValue(new Error('clipboard failed'))
|
||||
|
||||
await expect(
|
||||
readClipboardText('linux', run, { WAYLAND_DISPLAY: 'wayland-1' } as NodeJS.ProcessEnv)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('preserves CJK text via base64 decoding from PowerShell on WSL', async () => {
|
||||
const cjkText = '你好世界,测试中文 🎉'
|
||||
const b64 = Buffer.from(cjkText, 'utf8').toString('base64')
|
||||
const run = vi.fn().mockResolvedValue({ stdout: b64 })
|
||||
|
||||
await expect(readClipboardText('linux', run, { WSL_INTEROP: '/tmp/socket' } as NodeJS.ProcessEnv)).resolves.toBe(
|
||||
cjkText
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isUsableClipboardText', () => {
|
||||
it('accepts normal text', () => {
|
||||
expect(isUsableClipboardText('hello world\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty or whitespace-only content', () => {
|
||||
expect(isUsableClipboardText('')).toBe(false)
|
||||
expect(isUsableClipboardText(' \n\t')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects binary-looking clipboard payloads', () => {
|
||||
expect(isUsableClipboardText('PNG\u0000\u0001\u0002\u0003IHDR')).toBe(false)
|
||||
expect(isUsableClipboardText('TIFF\ufffd\ufffd\ufffdmetadata')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeClipboardText', () => {
|
||||
it('does nothing off macOS when no tools are available', async () => {
|
||||
const child = {
|
||||
once: vi.fn((event: string, cb: (code?: number) => void) => {
|
||||
if (event === 'close') {
|
||||
cb(1) // non-zero exit = failure
|
||||
}
|
||||
|
||||
return child
|
||||
}),
|
||||
unref: vi.fn(),
|
||||
stdin: { end: vi.fn() }
|
||||
}
|
||||
|
||||
const start = vi.fn().mockReturnValue(child)
|
||||
|
||||
// Linux with no WAYLAND_DISPLAY / no WSL_INTEROP — falls through xclip then xsel, both fail
|
||||
await expect(writeClipboardText('hello', 'linux', start, {})).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('writes text to pbcopy on macOS', async () => {
|
||||
const stdin = { end: vi.fn() }
|
||||
|
||||
const child = {
|
||||
once: vi.fn((event: string, cb: (code?: number) => void) => {
|
||||
if (event === 'close') {
|
||||
cb(0)
|
||||
}
|
||||
|
||||
return child
|
||||
}),
|
||||
unref: vi.fn(),
|
||||
stdin
|
||||
}
|
||||
|
||||
const start = vi.fn().mockReturnValue(child)
|
||||
|
||||
await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(true)
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
'pbcopy',
|
||||
[],
|
||||
expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
|
||||
)
|
||||
expect(stdin.end).toHaveBeenCalledWith('hello world')
|
||||
})
|
||||
|
||||
it('returns false when pbcopy fails', async () => {
|
||||
const child = {
|
||||
once: vi.fn((event: string, cb: () => void) => {
|
||||
if (event === 'error') {
|
||||
cb()
|
||||
}
|
||||
|
||||
return child
|
||||
}),
|
||||
unref: vi.fn(),
|
||||
stdin: { end: vi.fn() }
|
||||
}
|
||||
|
||||
const start = vi.fn().mockReturnValue(child)
|
||||
|
||||
await expect(writeClipboardText('hello world', 'darwin', start as any)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('uses wl-copy on Wayland Linux', async () => {
|
||||
const stdin = { end: vi.fn() }
|
||||
|
||||
const child = {
|
||||
once: vi.fn((event: string, cb: (code?: number) => void) => {
|
||||
if (event === 'close') {
|
||||
cb(0)
|
||||
}
|
||||
|
||||
return child
|
||||
}),
|
||||
unref: vi.fn(),
|
||||
stdin
|
||||
}
|
||||
|
||||
const start = vi.fn().mockReturnValue(child)
|
||||
|
||||
await expect(
|
||||
writeClipboardText('wayland text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })
|
||||
).resolves.toBe(true)
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
'wl-copy',
|
||||
['--type', 'text/plain'],
|
||||
expect.objectContaining({ stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true })
|
||||
)
|
||||
expect(stdin.end).toHaveBeenCalledWith('wayland text')
|
||||
})
|
||||
|
||||
it('falls back to xclip when wl-copy fails on Wayland', async () => {
|
||||
let callCount = 0
|
||||
const stdin = { end: vi.fn() }
|
||||
|
||||
const child = {
|
||||
once: vi.fn((event: string, cb: (code?: number) => void) => {
|
||||
if (event === 'close') {
|
||||
callCount++
|
||||
// wl-copy fails, xclip succeeds
|
||||
cb(callCount === 1 ? 1 : 0)
|
||||
}
|
||||
|
||||
return child
|
||||
}),
|
||||
unref: vi.fn(),
|
||||
stdin
|
||||
}
|
||||
|
||||
const start = vi.fn().mockReturnValue(child)
|
||||
|
||||
await expect(writeClipboardText('x11 text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })).resolves.toBe(
|
||||
true
|
||||
)
|
||||
expect(start).toHaveBeenNthCalledWith(1, 'wl-copy', ['--type', 'text/plain'], expect.anything())
|
||||
expect(start).toHaveBeenNthCalledWith(2, 'xclip', ['-selection', 'clipboard', '-in'], expect.anything())
|
||||
})
|
||||
|
||||
it('falls back to xsel when both wl-copy and xclip fail', async () => {
|
||||
let callCount = 0
|
||||
const stdin = { end: vi.fn() }
|
||||
|
||||
const child = {
|
||||
once: vi.fn((event: string, cb: (code?: number) => void) => {
|
||||
if (event === 'close') {
|
||||
callCount++
|
||||
cb(callCount < 3 ? 1 : 0) // first two fail, third (xsel) succeeds
|
||||
}
|
||||
|
||||
return child
|
||||
}),
|
||||
unref: vi.fn(),
|
||||
stdin
|
||||
}
|
||||
|
||||
const start = vi.fn().mockReturnValue(child)
|
||||
|
||||
await expect(
|
||||
writeClipboardText('xsel text', 'linux', start as any, { WAYLAND_DISPLAY: 'wayland-1' })
|
||||
).resolves.toBe(true)
|
||||
expect(start).toHaveBeenNthCalledWith(3, 'xsel', ['--clipboard', '--input'], expect.anything())
|
||||
})
|
||||
|
||||
it('uses PowerShell on WSL2 when WSL_DISTRO_NAME is set', async () => {
|
||||
const stdin = { end: vi.fn() }
|
||||
|
||||
const child = {
|
||||
once: vi.fn((event: string, cb: (code?: number) => void) => {
|
||||
if (event === 'close') {
|
||||
cb(0)
|
||||
}
|
||||
|
||||
return child
|
||||
}),
|
||||
unref: vi.fn(),
|
||||
stdin
|
||||
}
|
||||
|
||||
const start = vi.fn().mockReturnValue(child)
|
||||
|
||||
await expect(writeClipboardText('wsl text', 'linux', start as any, { WSL_DISTRO_NAME: 'Ubuntu' })).resolves.toBe(
|
||||
true
|
||||
)
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
expect.arrayContaining(['-NoProfile', '-NonInteractive']),
|
||||
expect.anything()
|
||||
)
|
||||
// PowerShell uses base64-encoded UTF-8 via command argument, not stdin
|
||||
expect(stdin.end).not.toHaveBeenCalled()
|
||||
const calledArgs = start.mock.calls[0][1] as string[]
|
||||
const commandIdx = calledArgs.indexOf('-Command')
|
||||
expect(commandIdx).toBeGreaterThan(-1)
|
||||
const script = calledArgs[commandIdx + 1]
|
||||
expect(script).toContain('FromBase64String')
|
||||
expect(script).toContain(Buffer.from('wsl text', 'utf8').toString('base64'))
|
||||
})
|
||||
|
||||
it('prefers the Windows clipboard path over wl-copy inside WSLg', async () => {
|
||||
const stdin = { end: vi.fn() }
|
||||
|
||||
const child = {
|
||||
once: vi.fn((event: string, cb: (code?: number) => void) => {
|
||||
if (event === 'close') {
|
||||
cb(0)
|
||||
}
|
||||
|
||||
return child
|
||||
}),
|
||||
unref: vi.fn(),
|
||||
stdin
|
||||
}
|
||||
|
||||
const start = vi.fn().mockReturnValue(child)
|
||||
|
||||
await expect(
|
||||
writeClipboardText('wslg text', 'linux', start as any, {
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
WSL_DISTRO_NAME: 'Ubuntu'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
expect(start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'powershell.exe',
|
||||
expect.arrayContaining(['-NoProfile', '-NonInteractive']),
|
||||
expect.anything()
|
||||
)
|
||||
// PowerShell uses base64-encoded UTF-8 via command argument, not stdin
|
||||
expect(stdin.end).not.toHaveBeenCalled()
|
||||
const calledArgs = start.mock.calls[0][1] as string[]
|
||||
const commandIdx = calledArgs.indexOf('-Command')
|
||||
const script = calledArgs[commandIdx + 1]
|
||||
expect(script).toContain('FromBase64String')
|
||||
expect(script).toContain(Buffer.from('wslg text', 'utf8').toString('base64'))
|
||||
})
|
||||
|
||||
it('uses PowerShell on Windows', async () => {
|
||||
const stdin = { end: vi.fn() }
|
||||
|
||||
const child = {
|
||||
once: vi.fn((event: string, cb: (code?: number) => void) => {
|
||||
if (event === 'close') {
|
||||
cb(0)
|
||||
}
|
||||
|
||||
return child
|
||||
}),
|
||||
unref: vi.fn(),
|
||||
stdin
|
||||
}
|
||||
|
||||
const start = vi.fn().mockReturnValue(child)
|
||||
|
||||
await expect(writeClipboardText('windows text', 'win32', start as any)).resolves.toBe(true)
|
||||
expect(start).toHaveBeenCalledWith(
|
||||
'powershell',
|
||||
expect.arrayContaining(['-NoProfile', '-NonInteractive']),
|
||||
expect.anything()
|
||||
)
|
||||
// PowerShell uses base64-encoded UTF-8 via command argument, not stdin
|
||||
expect(stdin.end).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves CJK text via base64 encoding in PowerShell on WSL', async () => {
|
||||
const stdin = { end: vi.fn() }
|
||||
|
||||
const child = {
|
||||
once: vi.fn((event: string, cb: (code?: number) => void) => {
|
||||
if (event === 'close') {
|
||||
cb(0)
|
||||
}
|
||||
|
||||
return child
|
||||
}),
|
||||
unref: vi.fn(),
|
||||
stdin
|
||||
}
|
||||
|
||||
const start = vi.fn().mockReturnValue(child)
|
||||
const cjkText = '你好世界,测试中文 🎉'
|
||||
|
||||
await expect(writeClipboardText(cjkText, 'linux', start as any, { WSL_INTEROP: '/tmp/socket' })).resolves.toBe(true)
|
||||
const calledArgs = start.mock.calls[0][1] as string[]
|
||||
const commandIdx = calledArgs.indexOf('-Command')
|
||||
const script = calledArgs[commandIdx + 1]
|
||||
expect(script).toContain(Buffer.from(cjkText, 'utf8').toString('base64'))
|
||||
expect(script).toContain('UTF8.GetString')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { applyCompletion, completionToApplyOnSubmit } from '../domain/slash.js'
|
||||
|
||||
describe('applyCompletion', () => {
|
||||
it('replaces from compReplace and drops the leading slash from the row', () => {
|
||||
// The gateway's slash completer returns bare command names with
|
||||
// replace_from = 1 (after the leading "/").
|
||||
expect(applyCompletion('/ex', 'exit', 1)).toBe('/exit')
|
||||
})
|
||||
|
||||
it('keeps the leading slash when the row carries one and input does not', () => {
|
||||
expect(applyCompletion('ex', '/exit', 0)).toBe('/exit')
|
||||
})
|
||||
|
||||
it('replaces an argument token after a space (subcommand completion)', () => {
|
||||
expect(applyCompletion('/cron ad', 'add', 6)).toBe('/cron add')
|
||||
})
|
||||
|
||||
it('applies an inline skill pick without disturbing the prose in front of it', () => {
|
||||
// The gateway returns bare names for `complete.slash`; a mid-message
|
||||
// reference replaces from just after its own `/`, so "please run " stays.
|
||||
expect(applyCompletion('please run /cle', 'clean', 12)).toBe('please run /clean')
|
||||
})
|
||||
|
||||
it('drops the row slash based on the character before the replace point, not the input start', () => {
|
||||
// Widget-app rows carry a leading slash. Mid-message the input does NOT
|
||||
// start with `/`, so a start-anchored check would double it up.
|
||||
expect(applyCompletion('please run /cle', '/clean', 12)).toBe('please run /clean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('completionToApplyOnSubmit', () => {
|
||||
it('accepts a completion that finishes a partial command name', () => {
|
||||
// "/ex" -> "/exit": a real token change, so Enter accepts it.
|
||||
expect(completionToApplyOnSubmit('/ex', 'exit', 1)).toBe('/exit')
|
||||
})
|
||||
|
||||
it('does NOT swallow Enter when the completion only adds a trailing space', () => {
|
||||
// This is the bug: once "/exit" is fully typed, the gateway returns the
|
||||
// command with a trailing space ("exit ") so the classic-CLI dropdown
|
||||
// stays open. In the TUI that must NOT eat the Enter — the command is
|
||||
// already complete, so Enter should submit.
|
||||
expect(completionToApplyOnSubmit('/exit', 'exit ', 1)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not swallow Enter when applying the row is a no-op', () => {
|
||||
expect(completionToApplyOnSubmit('/exit', 'exit', 1)).toBeNull()
|
||||
})
|
||||
|
||||
it('still accepts a real argument completion (no trailing-space false positive)', () => {
|
||||
expect(completionToApplyOnSubmit('/cron ad', 'add', 6)).toBe('/cron add')
|
||||
})
|
||||
|
||||
it('submits (no accept) once an argument is fully typed and only a space is added', () => {
|
||||
expect(completionToApplyOnSubmit('/cron add', 'add ', 6)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when there is no row text', () => {
|
||||
expect(completionToApplyOnSubmit('/exit', undefined, 1)).toBeNull()
|
||||
expect(completionToApplyOnSubmit('/exit', '', 1)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { highlightsStable, splitComposerHighlights } from '../domain/composerHighlights.js'
|
||||
|
||||
const painted = (text: string) =>
|
||||
splitComposerHighlights(text)
|
||||
.filter(segment => segment.ref)
|
||||
.map(segment => segment.text)
|
||||
|
||||
describe('splitComposerHighlights', () => {
|
||||
it('marks a command invocation and a skill named mid-prose', () => {
|
||||
expect(painted('/work fix the leak')).toEqual(['/work'])
|
||||
expect(painted('clean this up with /clean')).toEqual(['/clean'])
|
||||
expect(painted('run /clean then /work')).toEqual(['/clean', '/work'])
|
||||
})
|
||||
|
||||
it('marks @ references, including quoted values with spaces', () => {
|
||||
expect(painted('see @file:src/a.ts please')).toEqual(['@file:src/a.ts'])
|
||||
expect(painted('see @file:`my notes.md` please')).toEqual(['@file:`my notes.md`'])
|
||||
expect(painted('diff @diff and @staged')).toEqual(['@diff', '@staged'])
|
||||
})
|
||||
|
||||
it('marks attachment and paste tokens', () => {
|
||||
expect(painted('what is in [[ Image 1 ]] here')).toEqual(['[[ Image 1 ]]'])
|
||||
expect(painted('paste [[ log.. [3 lines] ]] ok')).toEqual(['[[ log.. [3 lines] ]]'])
|
||||
})
|
||||
|
||||
it('marks every kind in one message', () => {
|
||||
expect(painted('/work with @file:a.ts and [[ Image 2 ]]')).toEqual(['/work', '@file:a.ts', '[[ Image 2 ]]'])
|
||||
})
|
||||
|
||||
it('leaves paths, bare slashes, and email addresses alone', () => {
|
||||
for (const text of [
|
||||
'look at /usr/local/bin',
|
||||
'check src/foo/bar',
|
||||
'a 3 /4 b',
|
||||
'either / or',
|
||||
'email me@example.com'
|
||||
]) {
|
||||
expect(splitComposerHighlights(text)).toEqual([{ ref: false, text }])
|
||||
}
|
||||
})
|
||||
|
||||
it('marks a half-typed token so the accent tracks the caret', () => {
|
||||
// The composer paints while you type — waiting for the token to close
|
||||
// would flash the accent on only after the last character. A bare `/`
|
||||
// counts at the caret: that's the command menu opening.
|
||||
expect(painted('/wor')).toEqual(['/wor'])
|
||||
expect(painted('ref @fi')).toEqual(['@fi'])
|
||||
expect(painted('/')).toEqual(['/'])
|
||||
})
|
||||
|
||||
it('round-trips the input exactly', () => {
|
||||
for (const text of ['/work a', 'x @file:b [[ Image 1 ]]', 'plain text', '', 'look at /usr/local/bin']) {
|
||||
expect(
|
||||
splitComposerHighlights(text)
|
||||
.map(segment => segment.text)
|
||||
.join('')
|
||||
).toBe(text)
|
||||
}
|
||||
})
|
||||
|
||||
it('always returns at least one segment', () => {
|
||||
expect(splitComposerHighlights('')).toEqual([{ ref: false, text: '' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('highlightsStable', () => {
|
||||
// Fast-echo writes ONLY the new cells, so it may run only when every
|
||||
// character already on screen keeps the colour it had.
|
||||
it('allows the bypass while a token just grows', () => {
|
||||
expect(highlightsStable('/wor', '/work')).toBe(true)
|
||||
expect(highlightsStable('hello', 'hello ')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks the bypass when a keystroke re-colours existing cells', () => {
|
||||
expect(highlightsStable('[[ a ]', '[[ a ]]')).toBe(false)
|
||||
expect(highlightsStable('/usr', '/usr/')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { FACES } from '../content/faces.js'
|
||||
import { HOTKEYS } from '../content/hotkeys.js'
|
||||
import { PLACEHOLDERS } from '../content/placeholders.js'
|
||||
import { TOOL_VERBS, VERBS } from '../content/verbs.js'
|
||||
import { ROLE } from '../domain/roles.js'
|
||||
import { ZERO } from '../domain/usage.js'
|
||||
import { INTERPOLATION_RE } from '../protocol/interpolation.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
describe('constants', () => {
|
||||
it('ZERO', () => expect(ZERO).toEqual({ calls: 0, input: 0, output: 0, total: 0 }))
|
||||
|
||||
it('string arrays are populated', () => {
|
||||
for (const arr of [FACES, PLACEHOLDERS, VERBS]) {
|
||||
expect(arr.length).toBeGreaterThan(0)
|
||||
arr.forEach(s => expect(typeof s).toBe('string'))
|
||||
}
|
||||
})
|
||||
|
||||
it('HOTKEYS are [key, desc] pairs', () => {
|
||||
HOTKEYS.forEach(([k, d]) => {
|
||||
expect(typeof k).toBe('string')
|
||||
expect(typeof d).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
it('documents Ctrl/Cmd+L as non-destructive redraw', () => {
|
||||
const hotkey = HOTKEYS.find(([k]) => k.endsWith('+L'))
|
||||
expect(hotkey).toBeDefined()
|
||||
expect(hotkey?.[1]).toBe('redraw / repaint')
|
||||
})
|
||||
|
||||
it('TOOL_VERBS maps known tools (verb-only, no emoji)', () => {
|
||||
expect(TOOL_VERBS.terminal).toBe('terminal')
|
||||
expect(TOOL_VERBS.read_file).toBe('reading')
|
||||
})
|
||||
|
||||
it('INTERPOLATION_RE matches {!cmd}', () => {
|
||||
INTERPOLATION_RE.lastIndex = 0
|
||||
expect(INTERPOLATION_RE.test('{!date}')).toBe(true)
|
||||
|
||||
INTERPOLATION_RE.lastIndex = 0
|
||||
expect(INTERPOLATION_RE.test('plain')).toBe(false)
|
||||
})
|
||||
|
||||
it('ROLE produces glyph/body/prefix per role', () => {
|
||||
for (const role of ['assistant', 'system', 'tool', 'user'] as const) {
|
||||
expect(ROLE[role](DEFAULT_THEME)).toHaveProperty('glyph')
|
||||
}
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Pinned regression for the multi-line composer cursor-drift bug.
|
||||
*
|
||||
* Symptom: in `hermes --tui`, typing into the composer until the input
|
||||
* wraps across multiple visual rows would leave several blank cells
|
||||
* between the last typed character and the (hardware) cursor block.
|
||||
* Worse on narrow terminals (the Cursor IDE built-in terminal in
|
||||
* particular).
|
||||
*
|
||||
* Root cause: the composer's `cursorLayout` (used by `useDeclaredCursor`
|
||||
* to place the hardware cursor) ran a hand-rolled word-wrap algorithm,
|
||||
* while Ink's `<Text wrap="wrap">` renders via `wrap-ansi`. The two
|
||||
* disagreed on many real inputs — wrap-ansi would keep "branch
|
||||
* investigate" on one row while cursorLayout claimed it had wrapped,
|
||||
* etc. — so the declared cursor position drifted from where the text
|
||||
* was actually rendered. The fix sources cursorLayout's line breaks
|
||||
* directly from wrap-ansi, guaranteeing agreement.
|
||||
*
|
||||
* This test pins the contract: for every char that would be typed into
|
||||
* the composer, the cursor position reported by cursorLayout MUST equal
|
||||
* the end-of-text position that wrap-ansi would render. Any future
|
||||
* regression that lets the two diverge re-introduces the drift.
|
||||
*/
|
||||
import { wrapAnsi } from '@hermes/ink'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { cursorLayout, inputVisualHeight } from '../lib/inputMetrics.js'
|
||||
|
||||
function wrapAnsiEnd(text: string, cols: number): { line: number; column: number } {
|
||||
const wrapped = wrapAnsi(text, cols, { hard: true, trim: false })
|
||||
const lines = wrapped.split('\n')
|
||||
const last = lines[lines.length - 1] ?? ''
|
||||
|
||||
return { line: lines.length - 1, column: last.length }
|
||||
}
|
||||
|
||||
const USER_REPORT_MESSAGE =
|
||||
// Paraphrase of the user's actual bug report, included verbatim so the
|
||||
// test is grounded in a realistic typing pattern (long single line,
|
||||
// mixed-length words, punctuation, no hard newlines).
|
||||
'im in cursor terminal using hermes --tui and as i type multiline my caret at the end will often ' +
|
||||
'go.. randomly.. like multiple spaces away lol and idk why. theres no rhyme/reason really but ' +
|
||||
'there should literally never be a non-user added space at the end of my composer input right? ' +
|
||||
'i dont think it happens on new sessions but only existing ones. there have been a few prs to ' +
|
||||
'try to fix this and all not working. ok it just happened, to me, nowso attaching screenshot ' +
|
||||
'and you can see its multiline, new session. on a new bb/<xxx> branch investigate'
|
||||
|
||||
describe('cursor-drift regression — composer cursorLayout matches Ink rendering', () => {
|
||||
it('agrees with wrap-ansi at every typing-prefix of the user-reported message', () => {
|
||||
// Walks the message char-by-char (mirroring what the TUI sees when a
|
||||
// user types). At every prefix, cursorLayout must place the cursor
|
||||
// exactly where wrap-ansi would render the end of the text.
|
||||
//
|
||||
// Pre-fix: this failed on most narrow widths because the hand-rolled
|
||||
// wrap algorithm broke at slightly different points than wrap-ansi.
|
||||
for (const cols of [40, 50, 55, 60, 65, 70, 80]) {
|
||||
let acc = ''
|
||||
|
||||
for (const ch of USER_REPORT_MESSAGE) {
|
||||
acc += ch
|
||||
const layout = cursorLayout(acc, acc.length, cols)
|
||||
const expected = wrapAnsiEnd(acc, cols)
|
||||
|
||||
expect(
|
||||
layout,
|
||||
`mismatch at cols=${cols}, len=${acc.length}, last-char=${JSON.stringify(ch)}, ` +
|
||||
`tail=${JSON.stringify(acc.slice(-30))}`
|
||||
).toEqual(expected)
|
||||
}
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
it('keeps cursor on the same row when text exactly fills the terminal width', () => {
|
||||
// wrap-ansi does NOT push exact-fill text onto a phantom next line.
|
||||
// The previous algorithm did — that's what produced the visible
|
||||
// "cursor parked one row below the last char" symptom on narrow
|
||||
// terminals at certain message lengths.
|
||||
for (const cols of [8, 12, 18, 24]) {
|
||||
const text = 'a'.repeat(cols)
|
||||
const layout = cursorLayout(text, text.length, cols)
|
||||
const inkLines = wrapAnsi(text, cols, { hard: true, trim: false }).split('\n')
|
||||
|
||||
expect(layout.line).toBe(0)
|
||||
expect(layout.column).toBe(cols)
|
||||
expect(inkLines).toHaveLength(1)
|
||||
expect(inputVisualHeight(text, cols)).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not stuff a trailing whitespace word onto a phantom line', () => {
|
||||
// "branch investigate" at cols=20 fits on one row in wrap-ansi. The
|
||||
// bug claimed otherwise, parking the cursor at (line=1, col=?) and
|
||||
// leaving the user's "branch investigate" rendered alone on row 0
|
||||
// with the cursor block several cells past it.
|
||||
const text = 'branch investigate'
|
||||
const cols = 20
|
||||
|
||||
expect(cursorLayout(text, text.length, cols)).toEqual({ column: text.length, line: 0 })
|
||||
expect(cursorLayout(text, text.length, cols)).toEqual(wrapAnsiEnd(text, cols))
|
||||
})
|
||||
|
||||
it('agrees with wrap-ansi for word-wrap that pushes a word onto the next line', () => {
|
||||
// "hello world" at cols=8 wraps to ["hello ", "world"] in wrap-ansi.
|
||||
// The cursor at end-of-text must land at line=1, col=5 — where Ink
|
||||
// actually renders the last 'd'. The previous algorithm reported
|
||||
// (line=2, col=0) here (phantom extra wrap), which parked the
|
||||
// cursor on a row Ink never painted.
|
||||
const text = 'hello world'
|
||||
const cols = 8
|
||||
|
||||
expect(cursorLayout(text, text.length, cols)).toEqual({ column: 5, line: 1 })
|
||||
expect(cursorLayout(text, text.length, cols)).toEqual(wrapAnsiEnd(text, cols))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isSectionName, parseDetailsMode, resolveSections, SECTION_NAMES, sectionMode } from '../domain/details.js'
|
||||
|
||||
describe('parseDetailsMode', () => {
|
||||
it('accepts the canonical modes case-insensitively', () => {
|
||||
expect(parseDetailsMode('hidden')).toBe('hidden')
|
||||
expect(parseDetailsMode(' COLLAPSED ')).toBe('collapsed')
|
||||
expect(parseDetailsMode('Expanded')).toBe('expanded')
|
||||
})
|
||||
|
||||
it('rejects junk', () => {
|
||||
expect(parseDetailsMode('truncated')).toBeNull()
|
||||
expect(parseDetailsMode('')).toBeNull()
|
||||
expect(parseDetailsMode(undefined)).toBeNull()
|
||||
expect(parseDetailsMode(42)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSectionName', () => {
|
||||
it('only lets the four canonical sections through', () => {
|
||||
expect(isSectionName('thinking')).toBe(true)
|
||||
expect(isSectionName('tools')).toBe(true)
|
||||
expect(isSectionName('subagents')).toBe(true)
|
||||
expect(isSectionName('activity')).toBe(true)
|
||||
|
||||
expect(isSectionName('Thinking')).toBe(false) // case-sensitive on purpose
|
||||
expect(isSectionName('bogus')).toBe(false)
|
||||
expect(isSectionName('')).toBe(false)
|
||||
expect(isSectionName(7)).toBe(false)
|
||||
})
|
||||
|
||||
it('SECTION_NAMES exposes them all', () => {
|
||||
expect([...SECTION_NAMES].sort()).toEqual(['activity', 'subagents', 'thinking', 'tools'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSections', () => {
|
||||
it('parses a well-formed sections object', () => {
|
||||
expect(
|
||||
resolveSections({
|
||||
thinking: 'expanded',
|
||||
tools: 'expanded',
|
||||
subagents: 'collapsed',
|
||||
activity: 'hidden'
|
||||
})
|
||||
).toEqual({
|
||||
thinking: 'expanded',
|
||||
tools: 'expanded',
|
||||
subagents: 'collapsed',
|
||||
activity: 'hidden'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops unknown section names and unknown modes', () => {
|
||||
expect(
|
||||
resolveSections({
|
||||
thinking: 'expanded',
|
||||
tools: 'maximised',
|
||||
bogus: 'hidden',
|
||||
activity: 'hidden'
|
||||
})
|
||||
).toEqual({ thinking: 'expanded', activity: 'hidden' })
|
||||
})
|
||||
|
||||
it('treats nullish/non-objects as empty overrides', () => {
|
||||
expect(resolveSections(undefined)).toEqual({})
|
||||
expect(resolveSections(null)).toEqual({})
|
||||
expect(resolveSections('hidden')).toEqual({})
|
||||
expect(resolveSections([])).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sectionMode', () => {
|
||||
it('falls back to the global mode for sections without a built-in default', () => {
|
||||
expect(sectionMode('subagents', 'collapsed', {})).toBe('collapsed')
|
||||
expect(sectionMode('subagents', 'expanded', undefined)).toBe('expanded')
|
||||
expect(sectionMode('subagents', 'hidden', {})).toBe('hidden')
|
||||
})
|
||||
|
||||
it('streams thinking + tools expanded by default for persisted config values', () => {
|
||||
expect(sectionMode('thinking', 'collapsed', {})).toBe('expanded')
|
||||
expect(sectionMode('thinking', 'hidden', undefined)).toBe('expanded')
|
||||
expect(sectionMode('tools', 'collapsed', {})).toBe('expanded')
|
||||
expect(sectionMode('tools', 'hidden', undefined)).toBe('expanded')
|
||||
})
|
||||
|
||||
it('hides the activity panel by default for persisted config values', () => {
|
||||
expect(sectionMode('activity', 'collapsed', {})).toBe('hidden')
|
||||
expect(sectionMode('activity', 'expanded', undefined)).toBe('hidden')
|
||||
expect(sectionMode('activity', 'hidden', {})).toBe('hidden')
|
||||
})
|
||||
|
||||
it('applies in-session /details mode globally over built-in defaults', () => {
|
||||
expect(sectionMode('thinking', 'collapsed', {}, true)).toBe('collapsed')
|
||||
expect(sectionMode('tools', 'hidden', {}, true)).toBe('hidden')
|
||||
expect(sectionMode('activity', 'expanded', undefined, true)).toBe('expanded')
|
||||
})
|
||||
|
||||
it('honours per-section overrides over both the section default and global mode', () => {
|
||||
expect(sectionMode('thinking', 'collapsed', { thinking: 'collapsed' })).toBe('collapsed')
|
||||
expect(sectionMode('tools', 'collapsed', { tools: 'hidden' })).toBe('hidden')
|
||||
expect(sectionMode('activity', 'collapsed', { activity: 'expanded' })).toBe('expanded')
|
||||
expect(sectionMode('activity', 'expanded', { activity: 'collapsed' })).toBe('collapsed')
|
||||
})
|
||||
|
||||
it('lets per-section overrides escape the global hidden mode', () => {
|
||||
// Regression for the case where global details_mode: hidden used to
|
||||
// short-circuit the entire accordion and prevent overrides from
|
||||
// surfacing — `sections.tools: expanded` must still resolve to expanded.
|
||||
expect(sectionMode('subagents', 'hidden', { subagents: 'expanded' })).toBe('expanded')
|
||||
expect(sectionMode('thinking', 'hidden', { thinking: 'collapsed' })).toBe('collapsed')
|
||||
expect(sectionMode('activity', 'hidden', { activity: 'expanded' })).toBe('expanded')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ensureEmojiPresentation } from '../lib/emoji.js'
|
||||
|
||||
const VS16 = '\uFE0F'
|
||||
|
||||
describe('ensureEmojiPresentation', () => {
|
||||
it('passes through ASCII unchanged', () => {
|
||||
expect(ensureEmojiPresentation('hello world')).toBe('hello world')
|
||||
expect(ensureEmojiPresentation('')).toBe('')
|
||||
})
|
||||
|
||||
it('passes through emoji that already defaults to emoji presentation', () => {
|
||||
expect(ensureEmojiPresentation('🚀 rocket')).toBe('🚀 rocket')
|
||||
expect(ensureEmojiPresentation('😀')).toBe('😀')
|
||||
})
|
||||
|
||||
it('injects VS16 after text-default emoji codepoints', () => {
|
||||
expect(ensureEmojiPresentation('⚠ careful')).toBe(`⚠${VS16} careful`)
|
||||
expect(ensureEmojiPresentation('ℹ info')).toBe(`ℹ${VS16} info`)
|
||||
expect(ensureEmojiPresentation('love ❤ you')).toBe(`love ❤${VS16} you`)
|
||||
expect(ensureEmojiPresentation('✔ done')).toBe(`✔${VS16} done`)
|
||||
})
|
||||
|
||||
it('is idempotent when VS16 is already present', () => {
|
||||
const already = `⚠${VS16} ℹ${VS16} ❤${VS16}`
|
||||
|
||||
expect(ensureEmojiPresentation(already)).toBe(already)
|
||||
expect(ensureEmojiPresentation(ensureEmojiPresentation('⚠'))).toBe(`⚠${VS16}`)
|
||||
})
|
||||
|
||||
it('leaves keycap sequences alone when the base is not a text-default emoji', () => {
|
||||
expect(ensureEmojiPresentation('1\u20e3')).toBe('1\u20e3')
|
||||
})
|
||||
|
||||
it('injects VS16 before ZWJ so text-default bases participate in emoji sequences', () => {
|
||||
// ❤ + ZWJ + 🔥 → ❤️🔥 (heart on fire). Without VS16 between the heart
|
||||
// and the ZWJ, terminals render the heart in text/monochrome form and
|
||||
// the ZWJ ligature can fail to form.
|
||||
const heartFire = '\u2764\u200d\ud83d\udd25'
|
||||
|
||||
expect(ensureEmojiPresentation(heartFire)).toBe(`\u2764\uFE0F\u200d\ud83d\udd25`)
|
||||
})
|
||||
|
||||
it('leaves explicit text-presentation selector (VS15) alone', () => {
|
||||
// `❤︎` (U+2764 + U+FE0E) asks for text presentation — injecting VS16
|
||||
// would create an invalid double-variation sequence.
|
||||
const explicitText = '\u2764\ufe0e'
|
||||
|
||||
expect(ensureEmojiPresentation(explicitText)).toBe(explicitText)
|
||||
})
|
||||
|
||||
it('returns the original reference when no change is needed', () => {
|
||||
const already = `⚠${VS16} ℹ${VS16} ❤${VS16}`
|
||||
|
||||
// Reference equality — the lazy allocator should short-circuit to the
|
||||
// input when nothing needed injection.
|
||||
expect(ensureEmojiPresentation(already)).toBe(already)
|
||||
})
|
||||
|
||||
it('handles mixed content', () => {
|
||||
expect(ensureEmojiPresentation('⚠ path: /tmp/x ❤ done')).toBe(`⚠${VS16} path: /tmp/x ❤${VS16} done`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
__resetLinkTitleCache,
|
||||
fetchLinkTitle,
|
||||
hostPathLabel,
|
||||
isTitleFetchable,
|
||||
normalizeExternalUrl,
|
||||
urlSlugTitleLabel
|
||||
} from '../lib/externalLink.js'
|
||||
|
||||
afterEach(() => {
|
||||
__resetLinkTitleCache()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('external link helpers', () => {
|
||||
it('formats URL fallbacks as host + path', () => {
|
||||
expect(
|
||||
hostPathLabel(
|
||||
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
|
||||
)
|
||||
).toBe('getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894')
|
||||
})
|
||||
|
||||
it('derives readable title fallbacks from URL slugs', () => {
|
||||
expect(
|
||||
urlSlugTitleLabel(
|
||||
'https://www.getyourguide.com/fajardo-l882/from-fajardo-icacos-island-full-day-catamaran-trip-t19891/'
|
||||
)
|
||||
).toBe('From Fajardo Icacos Island Full Day Catamaran Trip')
|
||||
})
|
||||
|
||||
it('keeps x.com status fallbacks link-like instead of generic Status labels', () => {
|
||||
expect(urlSlugTitleLabel('https://x.com/grok/status/2056065022749479209')).toBe(
|
||||
'x.com/grok/status/2056065022749479209'
|
||||
)
|
||||
})
|
||||
|
||||
it('normalizes scheme-less links', () => {
|
||||
expect(normalizeExternalUrl(' expedia.com/things-to-do/puerto-rico-el-yunque ')).toBe(
|
||||
'https://expedia.com/things-to-do/puerto-rico-el-yunque'
|
||||
)
|
||||
})
|
||||
|
||||
it('filters out local/non-http targets for title fetches', () => {
|
||||
expect(isTitleFetchable('https://www.expedia.com/things-to-do/foo')).toBe(true)
|
||||
expect(isTitleFetchable('http://localhost:5174')).toBe(false)
|
||||
expect(isTitleFetchable('file:///tmp/demo.html')).toBe(false)
|
||||
expect(isTitleFetchable('mailto:hello@example.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks private, link-local, and intranet hosts', () => {
|
||||
expect(isTitleFetchable('http://10.0.0.12/path')).toBe(false)
|
||||
expect(isTitleFetchable('http://172.22.5.4/path')).toBe(false)
|
||||
expect(isTitleFetchable('http://192.168.1.22/path')).toBe(false)
|
||||
expect(isTitleFetchable('http://169.254.169.254/latest/meta-data')).toBe(false)
|
||||
expect(isTitleFetchable('http://[fd00::1]/')).toBe(false)
|
||||
expect(isTitleFetchable('http://[fe80::1]/')).toBe(false)
|
||||
expect(isTitleFetchable('http://printer.local/status')).toBe(false)
|
||||
expect(isTitleFetchable('http://intranet/status')).toBe(false)
|
||||
expect(isTitleFetchable('https://8.8.8.8/status')).toBe(true)
|
||||
})
|
||||
|
||||
it('deduplicates in-flight title fetches and caches results', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('<html><head><title>El Yunque Tour Water Slide, Rope Swing & Pickup</title></head></html>', {
|
||||
headers: { 'content-type': 'text/html; charset=utf-8' },
|
||||
status: 200
|
||||
})
|
||||
)
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const url =
|
||||
'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure.a46272756.activity-details'
|
||||
|
||||
const [first, second] = await Promise.all([fetchLinkTitle(url), fetchLinkTitle(url)])
|
||||
|
||||
expect(first).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
|
||||
expect(second).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const third = await fetchLinkTitle(url)
|
||||
|
||||
expect(third).toBe('El Yunque Tour Water Slide, Rope Swing & Pickup')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shares cache across protocol/www URL variants', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('<html><head><title>Shared Canonical Title</title></head></html>', {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
status: 200
|
||||
})
|
||||
)
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const first = 'https://www.getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/'
|
||||
const second = 'http://getyourguide.com/san-juan-puerto-rico-l355/sunset-tours-tc306/'
|
||||
|
||||
const [a, b] = await Promise.all([fetchLinkTitle(first), fetchLinkTitle(second)])
|
||||
|
||||
expect(a).toBe('Shared Canonical Title')
|
||||
expect(b).toBe('Shared Canonical Title')
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores error-like fetched titles', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('<html><head><title>Just a moment...</title></head></html>', {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
status: 200
|
||||
})
|
||||
)
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
const url =
|
||||
'https://www.getyourguide.com/culebra-island-l145468/from-fajardo-full-day-cordillera-islands-catamaran-tour-t19894/'
|
||||
|
||||
await expect(fetchLinkTitle(url)).resolves.toBe('')
|
||||
})
|
||||
|
||||
it('decodes HTML entities in fetched titles', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response('<html><head><title>AT&T 'Deals'</title></head></html>', {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
status: 200
|
||||
})
|
||||
)
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(fetchLinkTitle('https://example.com/offers')).resolves.toBe("AT&T 'Deals'")
|
||||
})
|
||||
|
||||
it('skips network fetch for non-fetchable targets', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await expect(fetchLinkTitle('http://localhost:3000/path')).resolves.toBe('')
|
||||
await expect(fetchLinkTitle('mailto:hello@example.com')).resolves.toBe('')
|
||||
await expect(fetchLinkTitle('file:///tmp/demo.html')).resolves.toBe('')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const ENV_KEYS = ['COLORTERM', 'FORCE_COLOR', 'HERMES_TUI_TRUECOLOR', 'NO_COLOR', 'TERM', 'TERM_PROGRAM'] as const
|
||||
let importId = 0
|
||||
|
||||
async function withCleanEnv(setup: () => void, body: () => Promise<void>) {
|
||||
const saved: Record<string, string | undefined> = {}
|
||||
|
||||
for (const k of ENV_KEYS) {
|
||||
saved[k] = process.env[k]
|
||||
delete process.env[k]
|
||||
}
|
||||
|
||||
try {
|
||||
setup()
|
||||
await body()
|
||||
} finally {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (saved[k] === undefined) {
|
||||
delete process.env[k]
|
||||
} else {
|
||||
process.env[k] = saved[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('forceTruecolor', () => {
|
||||
it('does not force truecolor by default', async () => {
|
||||
await withCleanEnv(
|
||||
() => {},
|
||||
async () => {
|
||||
await import('../lib/forceTruecolor.js?t=default-' + importId++)
|
||||
expect(process.env.COLORTERM).toBeUndefined()
|
||||
expect(process.env.FORCE_COLOR).toBeUndefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('does not infer truecolor from Apple Terminal on pre-Tahoe macOS', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.TERM_PROGRAM = 'Apple_Terminal'
|
||||
process.env.TERM = 'xterm-256color'
|
||||
},
|
||||
async () => {
|
||||
const mod = await import('../lib/forceTruecolor.js?t=apple-' + importId++)
|
||||
expect(mod.shouldForceTruecolor({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(false)
|
||||
expect(process.env.COLORTERM).toBeUndefined()
|
||||
expect(process.env.FORCE_COLOR).toBeUndefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('downgrades Apple Terminal when truecolor is only advertised by env', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.TERM_PROGRAM = 'Apple_Terminal'
|
||||
process.env.COLORTERM = 'truecolor'
|
||||
process.env.FORCE_COLOR = '3'
|
||||
},
|
||||
async () => {
|
||||
const mod = await import('../lib/forceTruecolor.js?t=downgrade-' + importId++)
|
||||
expect(
|
||||
mod.shouldDowngradeAppleTerminalTruecolor({
|
||||
TERM_PROGRAM: 'Apple_Terminal',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3'
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toBe(true)
|
||||
expect(process.env.COLORTERM).toBeUndefined()
|
||||
expect(process.env.FORCE_COLOR).toBeUndefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps non-Apple terminals untouched when they advertise truecolor', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.TERM_PROGRAM = 'vscode'
|
||||
process.env.COLORTERM = 'truecolor'
|
||||
process.env.FORCE_COLOR = '3'
|
||||
},
|
||||
async () => {
|
||||
const mod = await import('../lib/forceTruecolor.js?t=keep-non-apple-' + importId++)
|
||||
expect(
|
||||
mod.shouldDowngradeAppleTerminalTruecolor({
|
||||
TERM_PROGRAM: 'vscode',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3'
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toBe(false)
|
||||
expect(process.env.COLORTERM).toBe('truecolor')
|
||||
expect(process.env.FORCE_COLOR).toBe('3')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('sets COLORTERM=truecolor and FORCE_COLOR=3 when explicitly enabled', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.HERMES_TUI_TRUECOLOR = '1'
|
||||
},
|
||||
async () => {
|
||||
await import('../lib/forceTruecolor.js?t=enabled-' + importId++)
|
||||
expect(process.env.COLORTERM).toBe('truecolor')
|
||||
expect(process.env.FORCE_COLOR).toBe('3')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('respects HERMES_TUI_TRUECOLOR=0 opt-out', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.HERMES_TUI_TRUECOLOR = '0'
|
||||
process.env.TERM_PROGRAM = 'Apple_Terminal'
|
||||
},
|
||||
async () => {
|
||||
await import('../lib/forceTruecolor.js?t=optout-' + importId++)
|
||||
expect(process.env.COLORTERM).toBeUndefined()
|
||||
expect(process.env.FORCE_COLOR).toBeUndefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('lets explicit opt-in keep Apple truecolor advertisement', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.TERM_PROGRAM = 'Apple_Terminal'
|
||||
process.env.COLORTERM = 'truecolor'
|
||||
process.env.FORCE_COLOR = '3'
|
||||
process.env.HERMES_TUI_TRUECOLOR = '1'
|
||||
},
|
||||
async () => {
|
||||
const mod = await import('../lib/forceTruecolor.js?t=apple-explicit-on-' + importId++)
|
||||
expect(
|
||||
mod.shouldDowngradeAppleTerminalTruecolor({
|
||||
TERM_PROGRAM: 'Apple_Terminal',
|
||||
COLORTERM: 'truecolor',
|
||||
FORCE_COLOR: '3',
|
||||
HERMES_TUI_TRUECOLOR: '1'
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toBe(false)
|
||||
expect(process.env.COLORTERM).toBe('truecolor')
|
||||
expect(process.env.FORCE_COLOR).toBe('3')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('respects NO_COLOR', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.NO_COLOR = '1'
|
||||
process.env.HERMES_TUI_TRUECOLOR = '1'
|
||||
},
|
||||
async () => {
|
||||
await import('../lib/forceTruecolor.js?t=no-color-' + importId++)
|
||||
expect(process.env.COLORTERM).toBeUndefined()
|
||||
expect(process.env.FORCE_COLOR).toBeUndefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('respects existing FORCE_COLOR unless Hermes truecolor is explicit', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.FORCE_COLOR = ''
|
||||
},
|
||||
async () => {
|
||||
const mod = await import('../lib/forceTruecolor.js?t=force-color-' + importId++)
|
||||
expect(mod.shouldForceTruecolor(process.env)).toBe(false)
|
||||
expect(process.env.COLORTERM).toBeUndefined()
|
||||
expect(process.env.FORCE_COLOR).toBe('')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('lets explicit Hermes truecolor override existing FORCE_COLOR', async () => {
|
||||
await withCleanEnv(
|
||||
() => {
|
||||
process.env.FORCE_COLOR = '0'
|
||||
process.env.HERMES_TUI_TRUECOLOR = '1'
|
||||
},
|
||||
async () => {
|
||||
await import('../lib/forceTruecolor.js?t=explicit-force-' + importId++)
|
||||
expect(process.env.COLORTERM).toBe('truecolor')
|
||||
expect(process.env.FORCE_COLOR).toBe('3')
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,626 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
interface ListenerEntry {
|
||||
callback: (event: any) => void
|
||||
once: boolean
|
||||
}
|
||||
|
||||
const { FakeWebSocket } = vi.hoisted(() => {
|
||||
class FakeWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSING = 2
|
||||
static CLOSED = 3
|
||||
static instances: FakeWebSocket[] = []
|
||||
|
||||
readyState = FakeWebSocket.CONNECTING
|
||||
sent: string[] = []
|
||||
readonly url: string
|
||||
private listeners = new Map<string, ListenerEntry[]>()
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
FakeWebSocket.instances.push(this)
|
||||
}
|
||||
|
||||
static reset() {
|
||||
FakeWebSocket.instances = []
|
||||
}
|
||||
|
||||
addEventListener(type: string, callback: (event: any) => void, options?: unknown) {
|
||||
const once =
|
||||
typeof options === 'object' &&
|
||||
options !== null &&
|
||||
'once' in options &&
|
||||
Boolean((options as { once?: unknown }).once)
|
||||
|
||||
const entries = this.listeners.get(type) ?? []
|
||||
|
||||
entries.push({ callback, once })
|
||||
this.listeners.set(type, entries)
|
||||
}
|
||||
|
||||
removeEventListener(type: string, callback: (event: any) => void) {
|
||||
const entries = this.listeners.get(type)
|
||||
|
||||
if (!entries) {
|
||||
return
|
||||
}
|
||||
|
||||
this.listeners.set(
|
||||
type,
|
||||
entries.filter(entry => entry.callback !== callback)
|
||||
)
|
||||
}
|
||||
|
||||
send(payload: string) {
|
||||
if (this.readyState !== FakeWebSocket.OPEN) {
|
||||
throw new Error('socket not open')
|
||||
}
|
||||
|
||||
this.sent.push(payload)
|
||||
}
|
||||
|
||||
close(code = 1000) {
|
||||
if (this.readyState === FakeWebSocket.CLOSED) {
|
||||
return
|
||||
}
|
||||
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
this.emit('close', { code })
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = FakeWebSocket.OPEN
|
||||
this.emit('open', {})
|
||||
}
|
||||
|
||||
message(data: string) {
|
||||
this.emit('message', { data })
|
||||
}
|
||||
|
||||
private emit(type: string, event: any) {
|
||||
const entries = [...(this.listeners.get(type) ?? [])]
|
||||
|
||||
for (const entry of entries) {
|
||||
entry.callback(event)
|
||||
|
||||
if (entry.once) {
|
||||
this.removeEventListener(type, entry.callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { FakeWebSocket }
|
||||
})
|
||||
|
||||
vi.mock('undici', () => ({ WebSocket: FakeWebSocket }))
|
||||
|
||||
import {
|
||||
GatewayClient,
|
||||
RECONNECT_BASE_MS,
|
||||
RECONNECT_MAX_MS,
|
||||
WS_HEARTBEAT_DEAD_MS,
|
||||
WS_HEARTBEAT_INTERVAL_MS
|
||||
} from '../gatewayClient.js'
|
||||
|
||||
describe('GatewayClient websocket attach mode', () => {
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
let originalGatewayUrl: string | undefined
|
||||
let originalSidecarUrl: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
originalGatewayUrl = process.env.HERMES_TUI_GATEWAY_URL
|
||||
originalSidecarUrl = process.env.HERMES_TUI_SIDECAR_URL
|
||||
FakeWebSocket.reset()
|
||||
;(globalThis as { WebSocket?: unknown }).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalGatewayUrl === undefined) {
|
||||
delete process.env.HERMES_TUI_GATEWAY_URL
|
||||
} else {
|
||||
process.env.HERMES_TUI_GATEWAY_URL = originalGatewayUrl
|
||||
}
|
||||
|
||||
if (originalSidecarUrl === undefined) {
|
||||
delete process.env.HERMES_TUI_SIDECAR_URL
|
||||
} else {
|
||||
process.env.HERMES_TUI_SIDECAR_URL = originalSidecarUrl
|
||||
}
|
||||
|
||||
FakeWebSocket.reset()
|
||||
|
||||
if (originalWebSocket) {
|
||||
globalThis.WebSocket = originalWebSocket
|
||||
} else {
|
||||
delete (globalThis as { WebSocket?: unknown }).WebSocket
|
||||
}
|
||||
})
|
||||
|
||||
it('waits for websocket open and resolves RPC requests', async () => {
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
const gw = new GatewayClient()
|
||||
|
||||
gw.start()
|
||||
const gatewaySocket = FakeWebSocket.instances[0]!
|
||||
const req = gw.request<{ ok: boolean }>('session.create', { cols: 80 })
|
||||
|
||||
expect(gatewaySocket.sent).toHaveLength(0)
|
||||
gatewaySocket.open()
|
||||
await vi.waitFor(() => expect(gatewaySocket.sent).toHaveLength(1))
|
||||
|
||||
const frame = JSON.parse(gatewaySocket.sent[0] ?? '{}') as { id: string; method: string }
|
||||
expect(frame.method).toBe('session.create')
|
||||
|
||||
gatewaySocket.message(JSON.stringify({ id: frame.id, jsonrpc: '2.0', result: { ok: true } }))
|
||||
await expect(req).resolves.toEqual({ ok: true })
|
||||
|
||||
gw.kill()
|
||||
})
|
||||
|
||||
it('drains buffered events on a later microtask, not synchronously inside drain()', async () => {
|
||||
// Regression for #36658: in attach mode the already-running gateway
|
||||
// replays `gateway.ready` the instant the socket connects, so it lands in
|
||||
// bufferedEvents BEFORE the consumer's mount-time subscribe effect runs.
|
||||
// If drain() emitted those synchronously, the gateway.ready handler's
|
||||
// setState cascade would run inside React's first commit -> "Too many
|
||||
// re-renders" (#301). drain() must defer the buffered flush so the first
|
||||
// commit settles first.
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
const gw = new GatewayClient()
|
||||
|
||||
gw.start()
|
||||
const gatewaySocket = FakeWebSocket.instances[0]!
|
||||
|
||||
gatewaySocket.open()
|
||||
// Server replays ready BEFORE the consumer subscribes (attach-mode timing):
|
||||
gatewaySocket.message(
|
||||
JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } })
|
||||
)
|
||||
|
||||
const order: string[] = []
|
||||
|
||||
gw.on('event', ev => order.push(`event:${ev.type}`))
|
||||
gw.drain()
|
||||
order.push('after-drain')
|
||||
|
||||
// Buffered event must NOT have fired synchronously inside drain():
|
||||
expect(order).toEqual(['after-drain'])
|
||||
|
||||
// ...and must arrive on the next microtask.
|
||||
await vi.waitFor(() => expect(order).toContain('event:gateway.ready'))
|
||||
expect(order).toEqual(['after-drain', 'event:gateway.ready'])
|
||||
|
||||
gw.kill()
|
||||
})
|
||||
|
||||
it('preserves FIFO order when a live event arrives before the deferred flush', async () => {
|
||||
// #36658 hardening: `subscribed` must NOT flip synchronously in drain().
|
||||
// A live event delivered in the window between drain() returning and the
|
||||
// deferred microtask running must still queue BEHIND the chronologically
|
||||
// earlier buffered events, not jump ahead of them.
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
const gw = new GatewayClient()
|
||||
|
||||
gw.start()
|
||||
const gatewaySocket = FakeWebSocket.instances[0]!
|
||||
|
||||
gatewaySocket.open()
|
||||
// Buffered first (replayed on connect, before subscribe):
|
||||
gatewaySocket.message(
|
||||
JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'gateway.ready', payload: {} } })
|
||||
)
|
||||
|
||||
const order: string[] = []
|
||||
|
||||
gw.on('event', ev => order.push(ev.type))
|
||||
gw.drain()
|
||||
|
||||
// A LIVE event arrives synchronously in the post-drain / pre-microtask gap:
|
||||
gatewaySocket.message(
|
||||
JSON.stringify({ jsonrpc: '2.0', method: 'event', params: { type: 'session.info', payload: {} } })
|
||||
)
|
||||
|
||||
// Nothing emitted yet (subscribed stays false until the microtask):
|
||||
expect(order).toEqual([])
|
||||
|
||||
await vi.waitFor(() => expect(order.length).toBe(2))
|
||||
// FIFO preserved: the earlier-buffered gateway.ready precedes the live one.
|
||||
expect(order).toEqual(['gateway.ready', 'session.info'])
|
||||
|
||||
gw.kill()
|
||||
})
|
||||
|
||||
it('mirrors event frames to sidecar websocket when configured', async () => {
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
process.env.HERMES_TUI_SIDECAR_URL = 'ws://gateway.test/api/pub?token=abc&channel=demo'
|
||||
|
||||
const gw = new GatewayClient()
|
||||
const seen: string[] = []
|
||||
|
||||
gw.on('event', ev => seen.push(ev.type))
|
||||
gw.start()
|
||||
|
||||
const gatewaySocket = FakeWebSocket.instances[0]!
|
||||
gatewaySocket.open()
|
||||
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2))
|
||||
|
||||
const sidecarSocket = FakeWebSocket.instances[1]!
|
||||
|
||||
sidecarSocket.open()
|
||||
gw.drain()
|
||||
// drain() flips `subscribed` on a microtask now (#36658); let it settle so
|
||||
// the subsequent live event takes the synchronous publish path.
|
||||
await Promise.resolve()
|
||||
|
||||
const eventFrame = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'event',
|
||||
params: { type: 'tool.start', payload: { tool_id: 't1' } }
|
||||
})
|
||||
|
||||
gatewaySocket.message(eventFrame)
|
||||
|
||||
expect(seen).toContain('tool.start')
|
||||
expect(sidecarSocket.sent).toContain(eventFrame)
|
||||
|
||||
gw.kill()
|
||||
})
|
||||
|
||||
it('publishes local dashboard-control events to the sidecar websocket', async () => {
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
process.env.HERMES_TUI_SIDECAR_URL = 'ws://gateway.test/api/pub?token=abc&channel=demo'
|
||||
|
||||
const gw = new GatewayClient()
|
||||
const seen: string[] = []
|
||||
|
||||
gw.on('event', ev => seen.push(ev.type))
|
||||
gw.start()
|
||||
|
||||
const gatewaySocket = FakeWebSocket.instances[0]!
|
||||
|
||||
gatewaySocket.open()
|
||||
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2))
|
||||
|
||||
const sidecarSocket = FakeWebSocket.instances[1]!
|
||||
|
||||
sidecarSocket.open()
|
||||
gw.drain()
|
||||
// drain() flips `subscribed` on a microtask now (#36658); let it settle.
|
||||
await Promise.resolve()
|
||||
|
||||
gw.publishLocalEvent({
|
||||
payload: { reason: 'idle_exit_hotkey' },
|
||||
session_id: 'sid-old',
|
||||
type: 'dashboard.new_session_requested'
|
||||
})
|
||||
|
||||
expect(seen).toContain('dashboard.new_session_requested')
|
||||
expect(JSON.parse(sidecarSocket.sent.at(-1) ?? '{}')).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
method: 'event',
|
||||
params: {
|
||||
payload: { reason: 'idle_exit_hotkey' },
|
||||
session_id: 'sid-old',
|
||||
type: 'dashboard.new_session_requested'
|
||||
}
|
||||
})
|
||||
|
||||
gw.kill()
|
||||
})
|
||||
|
||||
it('emits exit when attached websocket closes', async () => {
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
const gw = new GatewayClient()
|
||||
const exits: Array<null | number> = []
|
||||
|
||||
gw.on('exit', code => exits.push(code))
|
||||
gw.start()
|
||||
|
||||
const gatewaySocket = FakeWebSocket.instances[0]!
|
||||
|
||||
gatewaySocket.open()
|
||||
gw.drain()
|
||||
// drain() flips `subscribed` on a microtask now (#36658); let it settle so
|
||||
// the close below takes the synchronous exit path.
|
||||
await Promise.resolve()
|
||||
gatewaySocket.close(1011)
|
||||
|
||||
expect(exits).toEqual([1011])
|
||||
expect(gw.getLogTail(20)).toContain('[lifecycle] websocket close code=1011')
|
||||
expect(gw.getLogTail(20)).toContain('[lifecycle] transport exit code=1011')
|
||||
})
|
||||
|
||||
it('rejects pending RPCs with websocket wording when the attached socket closes', async () => {
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
const gw = new GatewayClient()
|
||||
|
||||
gw.start()
|
||||
const gatewaySocket = FakeWebSocket.instances[0]!
|
||||
|
||||
gatewaySocket.open()
|
||||
gw.drain()
|
||||
|
||||
const req = gw.request('session.create', {})
|
||||
await vi.waitFor(() => expect(gatewaySocket.sent.length).toBeGreaterThan(0))
|
||||
|
||||
gatewaySocket.close(1011)
|
||||
|
||||
await expect(req).rejects.toThrow(/gateway websocket closed \(1011\)/)
|
||||
})
|
||||
|
||||
it('rejects pending RPCs when kill() closes the attached websocket', async () => {
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
const gw = new GatewayClient()
|
||||
|
||||
gw.start()
|
||||
const gatewaySocket = FakeWebSocket.instances[0]!
|
||||
|
||||
gatewaySocket.open()
|
||||
gw.drain()
|
||||
|
||||
const req = gw.request('session.create', {})
|
||||
await vi.waitFor(() => expect(gatewaySocket.sent.length).toBeGreaterThan(0))
|
||||
|
||||
gw.kill('test.shutdown')
|
||||
|
||||
await expect(req).rejects.toThrow(/gateway closed/)
|
||||
expect(gw.getLogTail(20)).toContain('[lifecycle] GatewayClient.kill reason=test.shutdown')
|
||||
})
|
||||
|
||||
it('reattaches when HERMES_TUI_GATEWAY_URL rotates between requests', async () => {
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway-old.test/api/ws?token=abc'
|
||||
const gw = new GatewayClient()
|
||||
|
||||
gw.start()
|
||||
const firstSocket = FakeWebSocket.instances[0]!
|
||||
|
||||
firstSocket.open()
|
||||
gw.drain()
|
||||
|
||||
const stale = gw.request('session.create', {})
|
||||
await vi.waitFor(() => expect(firstSocket.sent.length).toBeGreaterThan(0))
|
||||
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway-new.test/api/ws?token=xyz'
|
||||
const next = gw.request('session.create', {})
|
||||
|
||||
await expect(stale).rejects.toThrow(/gateway attach url changed/)
|
||||
await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2))
|
||||
|
||||
const secondSocket = FakeWebSocket.instances[1]!
|
||||
expect(secondSocket.url).toContain('gateway-new.test')
|
||||
|
||||
secondSocket.open()
|
||||
await vi.waitFor(() => expect(secondSocket.sent.length).toBeGreaterThan(0))
|
||||
|
||||
const frame = JSON.parse(secondSocket.sent[0] ?? '{}') as { id: string }
|
||||
secondSocket.message(JSON.stringify({ id: frame.id, jsonrpc: '2.0', result: { ok: true } }))
|
||||
|
||||
await expect(next).resolves.toEqual({ ok: true })
|
||||
gw.kill()
|
||||
})
|
||||
|
||||
it('uses the undici WebSocket fallback when global WebSocket is unavailable', () => {
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=hunter2&channel=secret'
|
||||
delete (globalThis as { WebSocket?: unknown }).WebSocket
|
||||
|
||||
const gw = new GatewayClient()
|
||||
|
||||
gw.start()
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
expect(FakeWebSocket.instances[0]?.url).toBe('ws://gateway.test/api/ws?token=hunter2&channel=secret')
|
||||
|
||||
gw.kill()
|
||||
})
|
||||
|
||||
it('redacts attach URL secrets when the WebSocket constructor throws', () => {
|
||||
const secretUrl = 'ws://gateway.test/api/ws?token=hunter2&channel=secret'
|
||||
|
||||
process.env.HERMES_TUI_GATEWAY_URL = secretUrl
|
||||
;(globalThis as { WebSocket?: unknown }).WebSocket = class ThrowingWebSocket extends FakeWebSocket {
|
||||
constructor(url: string) {
|
||||
throw new TypeError(`Invalid URL: ${url}`)
|
||||
}
|
||||
} as unknown as typeof WebSocket
|
||||
|
||||
const gw = new GatewayClient()
|
||||
|
||||
gw.start()
|
||||
gw.drain()
|
||||
|
||||
const tail = gw.getLogTail(20)
|
||||
expect(tail).not.toContain('hunter2')
|
||||
expect(tail).not.toContain('channel=secret')
|
||||
expect(tail).not.toContain(secretUrl)
|
||||
expect(tail).toContain('ws://gateway.test/api/ws?***')
|
||||
|
||||
gw.kill()
|
||||
})
|
||||
|
||||
it('redacts sidecar URL secrets when the WebSocket constructor throws', async () => {
|
||||
const sidecarUrl = 'ws://gateway.test/api/pub?token=hunter2&channel=secret'
|
||||
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
process.env.HERMES_TUI_SIDECAR_URL = sidecarUrl
|
||||
;(globalThis as { WebSocket?: unknown }).WebSocket = class ThrowingSidecarWebSocket extends FakeWebSocket {
|
||||
constructor(url: string) {
|
||||
if (url.includes('/api/pub')) {
|
||||
throw new TypeError(`Invalid URL: ${url}`)
|
||||
}
|
||||
|
||||
super(url)
|
||||
}
|
||||
} as unknown as typeof WebSocket
|
||||
|
||||
const gw = new GatewayClient()
|
||||
|
||||
gw.start()
|
||||
const gatewaySocket = FakeWebSocket.instances[0]!
|
||||
gatewaySocket.open()
|
||||
await vi.waitFor(() => expect(gw.getLogTail(20)).toContain('[sidecar] failed to connect'))
|
||||
|
||||
const tail = gw.getLogTail(20)
|
||||
expect(tail).not.toContain('hunter2')
|
||||
expect(tail).not.toContain('channel=secret')
|
||||
expect(tail).not.toContain(sidecarUrl)
|
||||
expect(tail).toContain('ws://gateway.test/api/pub?***')
|
||||
|
||||
gw.kill()
|
||||
})
|
||||
|
||||
it('redacts user-info credentials even on URLs the WHATWG parser rejects', () => {
|
||||
// Port 99999 is outside the WHATWG URL parser's valid 0–65535
|
||||
// range and survives `.trim()`, so the fixture deterministically
|
||||
// exercises `redactUrl()`'s fallback branch across Node versions.
|
||||
// (An earlier `%zz` user-info fixture did NOT actually throw in
|
||||
// recent Node — WHATWG accepts malformed percent escapes there —
|
||||
// which silently routed the test through the structured-URL path.)
|
||||
const fixture = 'ws://alice:hunter2@gateway.test:99999/api/ws?token=secret'
|
||||
expect(() => new URL(fixture)).toThrow()
|
||||
|
||||
process.env.HERMES_TUI_GATEWAY_URL = fixture
|
||||
;(globalThis as { WebSocket?: unknown }).WebSocket = class ThrowingWebSocket extends FakeWebSocket {
|
||||
constructor(url: string) {
|
||||
throw new TypeError(`Invalid URL: ${url}`)
|
||||
}
|
||||
} as unknown as typeof WebSocket
|
||||
|
||||
const gw = new GatewayClient()
|
||||
|
||||
gw.start()
|
||||
gw.drain()
|
||||
|
||||
const tail = gw.getLogTail(20)
|
||||
expect(tail).not.toContain('alice')
|
||||
expect(tail).not.toContain('hunter2')
|
||||
expect(tail).not.toContain('token=secret')
|
||||
|
||||
gw.kill()
|
||||
})
|
||||
|
||||
it('keeps a healthy idle websocket open when heartbeat acknowledgements arrive (issue #32997)', async () => {
|
||||
vi.useFakeTimers()
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
const gw = new GatewayClient()
|
||||
|
||||
try {
|
||||
gw.start()
|
||||
const socket = FakeWebSocket.instances[0]!
|
||||
|
||||
socket.open()
|
||||
socket.message(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'event',
|
||||
params: { type: 'gateway.ready', payload: { heartbeat: true } }
|
||||
})
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(WS_HEARTBEAT_INTERVAL_MS)
|
||||
|
||||
const heartbeat = JSON.parse(socket.sent.at(-1) ?? '{}') as { id: string; method: string }
|
||||
|
||||
expect(heartbeat.method).toBe('gateway.ping')
|
||||
socket.message(JSON.stringify({ id: heartbeat.id, jsonrpc: '2.0', result: { ok: true } }))
|
||||
|
||||
await vi.advanceTimersByTimeAsync(WS_HEARTBEAT_DEAD_MS + WS_HEARTBEAT_INTERVAL_MS)
|
||||
expect(socket.readyState).toBe(FakeWebSocket.OPEN)
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
} finally {
|
||||
gw.kill()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('auto-reconnects after a missing heartbeat acknowledgement (issue #32997)', async () => {
|
||||
vi.useFakeTimers()
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
const gw = new GatewayClient()
|
||||
|
||||
try {
|
||||
gw.start()
|
||||
const first = FakeWebSocket.instances[0]!
|
||||
|
||||
first.open()
|
||||
first.message(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'event',
|
||||
params: { type: 'gateway.ready', payload: { heartbeat: true } }
|
||||
})
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(WS_HEARTBEAT_INTERVAL_MS)
|
||||
expect(JSON.parse(first.sent.at(-1) ?? '{}')).toMatchObject({ method: 'gateway.ping' })
|
||||
await vi.advanceTimersByTimeAsync(WS_HEARTBEAT_DEAD_MS + WS_HEARTBEAT_INTERVAL_MS)
|
||||
await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS)
|
||||
expect(FakeWebSocket.instances.length).toBeGreaterThanOrEqual(2)
|
||||
} finally {
|
||||
gw.kill()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not heartbeat an older backend that omits the capability', async () => {
|
||||
vi.useFakeTimers()
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
const gw = new GatewayClient()
|
||||
|
||||
try {
|
||||
gw.start()
|
||||
const socket = FakeWebSocket.instances[0]!
|
||||
|
||||
socket.open()
|
||||
socket.message(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'event',
|
||||
params: { type: 'gateway.ready', payload: {} }
|
||||
})
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(WS_HEARTBEAT_DEAD_MS + WS_HEARTBEAT_INTERVAL_MS)
|
||||
expect(socket.readyState).toBe(FakeWebSocket.OPEN)
|
||||
expect(socket.sent).toEqual([])
|
||||
expect(FakeWebSocket.instances).toHaveLength(1)
|
||||
} finally {
|
||||
gw.kill()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not double-reconnect when the exit subscriber restarts immediately', async () => {
|
||||
vi.useFakeTimers()
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
const gw = new GatewayClient()
|
||||
|
||||
try {
|
||||
gw.on('exit', () => gw.start())
|
||||
gw.start()
|
||||
const first = FakeWebSocket.instances[0]!
|
||||
|
||||
first.open()
|
||||
gw.drain()
|
||||
await Promise.resolve()
|
||||
first.close(1011)
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(2)
|
||||
await vi.advanceTimersByTimeAsync(RECONNECT_BASE_MS)
|
||||
expect(FakeWebSocket.instances).toHaveLength(2)
|
||||
} finally {
|
||||
gw.kill()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not auto-reconnect after an intentional kill() (issue #32997)', async () => {
|
||||
vi.useFakeTimers()
|
||||
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
|
||||
const gw = new GatewayClient()
|
||||
gw.start()
|
||||
FakeWebSocket.instances[0]!.open()
|
||||
gw.kill() // sets disposed
|
||||
await vi.advanceTimersByTimeAsync(WS_HEARTBEAT_DEAD_MS + RECONNECT_MAX_MS + 1000)
|
||||
expect(FakeWebSocket.instances.length).toBe(1) // no reconnect attempted
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { GATEWAY_RECOVERY_LIMIT, GATEWAY_RECOVERY_WINDOW_MS, planGatewayRecovery } from '../app/gatewayRecovery.js'
|
||||
|
||||
describe('planGatewayRecovery', () => {
|
||||
it('recovers the live session and records the attempt', () => {
|
||||
const plan = planGatewayRecovery('sess-1', null, [], 1000)
|
||||
|
||||
expect(plan).toEqual({ attempts: [1000], recover: true, sid: 'sess-1' })
|
||||
})
|
||||
|
||||
it('does not recover when there is no session to resume', () => {
|
||||
expect(planGatewayRecovery(null, null, [], 1000)).toEqual({ attempts: [], recover: false, sid: null })
|
||||
})
|
||||
|
||||
it('keeps retrying the recovery target through a startup crash-loop, bounded by the budget', () => {
|
||||
// First exit: live sid present.
|
||||
let attempts: number[] = []
|
||||
let plan = planGatewayRecovery('sess-1', null, attempts, 0)
|
||||
|
||||
expect(plan.recover).toBe(true)
|
||||
expect(plan.sid).toBe('sess-1')
|
||||
attempts = plan.attempts
|
||||
|
||||
// Respawn crash-loops before gateway.ready: live sid is now null, but the
|
||||
// recovery target carries it forward so we keep trying up to the budget.
|
||||
for (let i = 1; i < GATEWAY_RECOVERY_LIMIT; i++) {
|
||||
plan = planGatewayRecovery(null, 'sess-1', attempts, i)
|
||||
expect(plan.recover).toBe(true)
|
||||
expect(plan.sid).toBe('sess-1')
|
||||
attempts = plan.attempts
|
||||
}
|
||||
|
||||
// Budget exhausted: fall back to the inert state instead of spawn-storming.
|
||||
plan = planGatewayRecovery(null, 'sess-1', attempts, GATEWAY_RECOVERY_LIMIT)
|
||||
expect(plan.recover).toBe(false)
|
||||
expect(plan.sid).toBe('sess-1')
|
||||
})
|
||||
|
||||
it('prunes attempts older than the window so recovery re-arms', () => {
|
||||
const old = Array.from({ length: GATEWAY_RECOVERY_LIMIT }, (_, i) => i)
|
||||
const plan = planGatewayRecovery('sess-1', null, old, GATEWAY_RECOVERY_WINDOW_MS + 100)
|
||||
|
||||
expect(plan.attempts).toEqual([GATEWAY_RECOVERY_WINDOW_MS + 100])
|
||||
expect(plan.recover).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { shouldExitForSignal } from '../lib/gracefulExit.js'
|
||||
|
||||
describe('shouldExitForSignal', () => {
|
||||
it('ignores only the signals explicitly disabled for embedded dashboard chat', () => {
|
||||
expect(shouldExitForSignal('SIGINT', ['SIGINT'])).toBe(false)
|
||||
expect(shouldExitForSignal('SIGTERM', ['SIGINT'])).toBe(true)
|
||||
expect(shouldExitForSignal('SIGHUP', ['SIGINT'])).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,261 @@
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
import { renderSync } from '@hermes/ink'
|
||||
import React, { useState } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { TextInput } from '../components/textInput.js'
|
||||
|
||||
// End-to-end regression coverage for Vietnamese Telex IME recomposition
|
||||
// (OpenKey / Unikey / EVKey). These IMEs commit a finished syllable by
|
||||
// emitting a burst of backspaces (and, for OpenKey, a U+202F NARROW NO-BREAK
|
||||
// SPACE marker) followed by the recomposed characters. The byte streams below
|
||||
// are real captures taken from OpenKey and EVKey on macOS while typing the
|
||||
// phrase "vương sỹ hạnh" (Telex: "vuonwg syx hanhj").
|
||||
//
|
||||
// The bug these guard against: characters were dropped and a stray space was
|
||||
// left mid-syllable (e.g. "hạnh" rendered as "hạ "). Root causes fixed:
|
||||
// 1. parse-keypress split fused control-byte+text chunks so the recomposed
|
||||
// text survives instead of being discarded with the control byte.
|
||||
// 2. textInput commits multi-character (IME/paste) inserts synchronously
|
||||
// instead of through the 16ms key-burst path that raced re-renders.
|
||||
|
||||
class FakeTty extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
columns = 80
|
||||
rows = 24
|
||||
isTTY = true
|
||||
isRaw = false
|
||||
private pendingReads: string[] = []
|
||||
ref(): void {}
|
||||
unref(): void {}
|
||||
read(): string | null {
|
||||
return this.pendingReads.shift() ?? null
|
||||
}
|
||||
send(chunk: string): void {
|
||||
this.pendingReads.push(chunk)
|
||||
this.emit('readable')
|
||||
}
|
||||
setEncoding(): this {
|
||||
return this
|
||||
}
|
||||
setRawMode(mode: boolean): this {
|
||||
this.isRaw = mode
|
||||
|
||||
return this
|
||||
}
|
||||
write(chunk: string | Uint8Array, cb?: (err?: Error | null) => void): boolean {
|
||||
this.chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'))
|
||||
cb?.()
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const tick = () => new Promise<void>(resolve => setImmediate(resolve))
|
||||
|
||||
function Harness({ initial = '', onValue }: { initial?: string; onValue: (value: string) => void }) {
|
||||
const [value, setValue] = useState(initial)
|
||||
|
||||
return React.createElement(TextInput, {
|
||||
onChange: (next: string) => {
|
||||
setValue(next)
|
||||
onValue(next)
|
||||
},
|
||||
value
|
||||
})
|
||||
}
|
||||
|
||||
// Core driver: feeds reads, optionally advancing fake timers between reads to
|
||||
// simulate the small macrotask gaps real IME reads arrive with. Returns the
|
||||
// final value seen by the parent immediately after the last read (no trailing
|
||||
// wait) so a passing assertion proves the commit was synchronous, not deferred.
|
||||
async function drive(
|
||||
reads: string[],
|
||||
{ initial = '', gapMs = 0 }: { initial?: string; gapMs?: number } = {}
|
||||
): Promise<string> {
|
||||
const stdout = new FakeTty()
|
||||
const stdin = new FakeTty()
|
||||
const stderr = new FakeTty()
|
||||
const values: string[] = []
|
||||
|
||||
const instance = renderSync(React.createElement(Harness, { initial, onValue: v => values.push(v) }), {
|
||||
patchConsole: false,
|
||||
stderr: stderr as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
try {
|
||||
await tick()
|
||||
|
||||
for (const r of reads) {
|
||||
stdin.send(r)
|
||||
await tick()
|
||||
|
||||
if (gapMs) {
|
||||
// Advance the fake clock to flush any pending FRAME_BATCH_MS timers
|
||||
// between reads (mirrors the real macrotask gap), then let microtasks run.
|
||||
vi.advanceTimersByTime(gapMs)
|
||||
await tick()
|
||||
}
|
||||
}
|
||||
|
||||
// Assert IMMEDIATELY after the final read — no trailing 60ms wait and
|
||||
// WITHOUT advancing the fake clock past the deferred key-burst window.
|
||||
// If the value is already correct here, the multi-char insert committed
|
||||
// synchronously; the old deferred path (scheduleKeyBurstCommit, 16ms)
|
||||
// has NOT flushed yet, so a stale/dropped tail would still be visible.
|
||||
|
||||
return values.at(-1) ?? ''
|
||||
} finally {
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
const NNBSP = '\u202f'
|
||||
|
||||
describe('Vietnamese Telex IME recomposition', () => {
|
||||
beforeEach(() => {
|
||||
// Only fake setTimeout/setInterval/Date — NOT setImmediate (used by tick()).
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'Date'] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('applies a parser-split backspace plus composed character through useInput', async () => {
|
||||
// OpenKey fuses the erase + recomposed glyph into a single stdin read.
|
||||
expect(await drive(['\x7fô'], { initial: 'o' })).toBe('ô')
|
||||
})
|
||||
|
||||
it('commits a multi-character recompose synchronously (no dropped tail)', async () => {
|
||||
// "hanhj" -> a U+202F marker, four backspaces, then the recomposed "ạnh".
|
||||
// Only a single microtask after the last read — the sync commit must have
|
||||
// already delivered the final value (the deferred path dropped "nh" here).
|
||||
const reads = ['h', 'a', 'n', 'h', NNBSP, '\x7f\x7f', '\x7f\x7f', '\u1EA1nh']
|
||||
|
||||
// No gapMs, no advanceTimersMs — we assert BEFORE the 16ms FRAME_BATCH_MS could fire.
|
||||
expect(await drive(reads)).toBe('h\u1EA1nh')
|
||||
})
|
||||
|
||||
it('reproduces the full phrase "vương sỹ hạnh" from a real OpenKey capture', async () => {
|
||||
// Captured byte stream for Telex "vuonwg syx hanhj": each syllable injects a
|
||||
// U+202F marker, erases, and re-emits. Verified across read timings.
|
||||
const reads = [
|
||||
'v',
|
||||
'u',
|
||||
'o',
|
||||
NNBSP,
|
||||
'\x7f\x7f',
|
||||
'\x7f\u01B0\u01A1',
|
||||
'n',
|
||||
'g',
|
||||
' ',
|
||||
's',
|
||||
'y',
|
||||
NNBSP,
|
||||
'\x7f',
|
||||
'\x7f\u1EF9',
|
||||
' ',
|
||||
'h',
|
||||
'a',
|
||||
'n',
|
||||
'h',
|
||||
NNBSP,
|
||||
'\x7f\x7f\x7f\x7f\u1EA1nh'
|
||||
]
|
||||
|
||||
for (const gapMs of [0, 17, 25]) {
|
||||
expect(await drive(reads, { gapMs })).toBe('vương sỹ hạnh')
|
||||
}
|
||||
})
|
||||
|
||||
it('handles the EVKey capture (clean backspaces, no marker) for "hạnh"', async () => {
|
||||
// EVKey emits three clean backspaces and no U+202F; must also yield "hạnh".
|
||||
const reads = ['h', 'a', 'n', 'h', '\x7f', '\x7f', '\x7f', '\u1EA1nh']
|
||||
|
||||
expect(await drive(reads)).toBe('h\u1EA1nh')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Fast-echo suppression reset (60ms window)', () => {
|
||||
beforeEach(() => {
|
||||
// Only fake setTimeout/setInterval/Date — NOT setImmediate (used by tick()).
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'setInterval', 'Date'] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('suppresses fast-echo backspace for one keystroke after an Ink repaint (IME recompose)', async () => {
|
||||
// Simulate: user types "ha" -> Ink commits normally -> then IME recompose arrives
|
||||
// as NNBSP + backspaces + recomposed text. The first backspace after the Ink
|
||||
// repaint must NOT fast-echo (would strand the NNBSP marker as a stray space).
|
||||
|
||||
// Type "ha" normally (each char goes through fast-echo append path)
|
||||
let reads = ['h', 'a']
|
||||
const stdout1 = new FakeTty()
|
||||
const stdin1 = new FakeTty()
|
||||
const stderr1 = new FakeTty()
|
||||
const values1: string[] = []
|
||||
|
||||
const instance1 = renderSync(React.createElement(Harness, { initial: '', onValue: v => values1.push(v) }), {
|
||||
patchConsole: false,
|
||||
stderr: stderr1 as unknown as NodeJS.WriteStream,
|
||||
stdin: stdin1 as unknown as NodeJS.ReadStream,
|
||||
stdout: stdout1 as unknown as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
try {
|
||||
await tick()
|
||||
|
||||
for (const r of reads) {
|
||||
stdin1.send(r)
|
||||
await tick()
|
||||
}
|
||||
|
||||
// After "ha", fast-echo is enabled (inkRepaintedRef.current = false)
|
||||
expect(values1.at(-1)).toBe('ha')
|
||||
|
||||
// Now simulate an IME recompose burst that forces an Ink repaint:
|
||||
// NNBSP marker forces a full Ink render (syncParent=true in commit).
|
||||
// The next backspace should be SUPPRESSED (fast-echo backspace disabled).
|
||||
stdin1.send(NNBSP + '\x7f\x7f\u1EA1nh') // fused chunk: marker + 2x backspace + "ạnh"
|
||||
await tick()
|
||||
|
||||
// The recomposed value must be committed synchronously (no dropped tail).
|
||||
// The first backspace after the Ink repaint must NOT have written "\b \b" to stdout.
|
||||
// We can't directly inspect stdout here, but we verify the FINAL value is correct.
|
||||
expect(values1.at(-1)).toBe('h\u1EA1nh')
|
||||
|
||||
// Advance fake timers past the 60ms suppression window so the
|
||||
// inkRepaintResetTimer fires and re-enables fast-echo backspace.
|
||||
vi.advanceTimersByTime(60)
|
||||
await tick()
|
||||
|
||||
// Now fast-echo backspace is RE-ENABLED. One backspace deletes exactly
|
||||
// one grapheme ("h") off the end of "hạnh" -> "hạn".
|
||||
stdin1.send('\x7f')
|
||||
await tick()
|
||||
|
||||
expect(values1.at(-1)).toBe('h\u1EA1n')
|
||||
} finally {
|
||||
instance1.unmount()
|
||||
instance1.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('does NOT suppress fast-echo backspace when no Ink repaint occurred (normal typing)', async () => {
|
||||
// Normal ASCII typing never triggers the Ink-repaint suppression.
|
||||
const reads = ['h', 'e', 'l', 'l', 'o']
|
||||
expect(await drive(reads)).toBe('hello')
|
||||
|
||||
// Two backspaces off "hello" -> "hel" via the fast-echo path.
|
||||
const reads2 = [...reads, '\x7f', '\x7f']
|
||||
expect(await drive(reads2)).toBe('hel')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { inlineSlashTrigger } from '../domain/slash.js'
|
||||
import { completionRequestForInput } from '../hooks/useCompletion.js'
|
||||
|
||||
describe('inlineSlashTrigger', () => {
|
||||
it('detects a slash typed mid-message', () => {
|
||||
// The reported bug: only a position-0 slash offered anything, so
|
||||
// "please run /cle" completed nothing.
|
||||
expect(inlineSlashTrigger('please run /cle')).toEqual({ query: 'cle', start: 11 })
|
||||
})
|
||||
|
||||
it('detects a bare slash after whitespace, before any name is typed', () => {
|
||||
expect(inlineSlashTrigger('please run /')).toEqual({ query: '', start: 11 })
|
||||
})
|
||||
|
||||
it('fires after a newline, not just a space', () => {
|
||||
expect(inlineSlashTrigger('text\n/skill')).toEqual({ query: 'skill', start: 5 })
|
||||
})
|
||||
|
||||
it('does not fire at position 0 — that is a command invocation', () => {
|
||||
expect(inlineSlashTrigger('/clean')).toBeNull()
|
||||
expect(inlineSlashTrigger('/')).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves file paths alone', () => {
|
||||
expect(inlineSlashTrigger('look at /usr/local/bin')).toBeNull()
|
||||
expect(inlineSlashTrigger('check src/foo/bar')).toBeNull()
|
||||
expect(inlineSlashTrigger('and/or')).toBeNull()
|
||||
})
|
||||
|
||||
it('stops at the command token — an inline reference takes no args', () => {
|
||||
// Only a position-0 slash is a real invocation, so `/personality alic`
|
||||
// mid-message is prose with a reference in it, already ended.
|
||||
expect(inlineSlashTrigger('hello there /personality alic')).toBeNull()
|
||||
})
|
||||
|
||||
it('reports a start index that replaces only the typed token', () => {
|
||||
const text = 'please run /cle'
|
||||
const trigger = inlineSlashTrigger(text)!
|
||||
|
||||
expect(text.slice(0, trigger.start)).toBe('please run ')
|
||||
expect(text.slice(trigger.start)).toBe('/cle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('completionRequestForInput — inline skill references', () => {
|
||||
it('asks for skills only when the slash is mid-message', () => {
|
||||
const request = completionRequestForInput('please run /cle')
|
||||
|
||||
expect(request).toMatchObject({
|
||||
method: 'complete.slash',
|
||||
params: { text: '/cle' },
|
||||
replaceFrom: 12,
|
||||
skillsOnly: true
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the full command set at position 0', () => {
|
||||
expect(completionRequestForInput('/cle')).toEqual({
|
||||
method: 'complete.slash',
|
||||
params: { text: '/cle' },
|
||||
replaceFrom: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('completes a second slash in a line that starts with a command', () => {
|
||||
// Only the first slash is an invocation. Routing the whole line to the
|
||||
// completer offered nothing, so `/work /cle` went dead while
|
||||
// `do /work then /cle` completed fine.
|
||||
expect(completionRequestForInput('/work /cle')).toMatchObject({
|
||||
method: 'complete.slash',
|
||||
params: { text: '/cle' },
|
||||
replaceFrom: 7,
|
||||
skillsOnly: true
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a command own arguments to the command', () => {
|
||||
for (const input of ['/personality alic', '/cron ad', '/details ']) {
|
||||
expect(completionRequestForInput(input)).toEqual({
|
||||
method: 'complete.slash',
|
||||
params: { text: input },
|
||||
replaceFrom: 1
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('routes a real mid-message path to path completion, not skills', () => {
|
||||
expect(completionRequestForInput('open src/foo/ba')).toMatchObject({ method: 'complete.path' })
|
||||
expect(completionRequestForInput('open /usr/lo')).toMatchObject({ method: 'complete.path' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { handleInputSelectionClipboard } from '../app/useInputHandlers.js'
|
||||
|
||||
const selection = (start = 1, end = 4) => ({
|
||||
clear: vi.fn(),
|
||||
collapseToEnd: vi.fn(),
|
||||
copy: vi.fn(),
|
||||
cut: vi.fn(),
|
||||
end,
|
||||
start,
|
||||
value: 'hello'
|
||||
})
|
||||
|
||||
describe('handleInputSelectionClipboard', () => {
|
||||
it('copies an active composer selection', () => {
|
||||
const active = selection()
|
||||
|
||||
expect(handleInputSelectionClipboard(active, 'copy')).toBe(true)
|
||||
expect(active.copy).toHaveBeenCalledOnce()
|
||||
expect(active.cut).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cuts an active composer selection', () => {
|
||||
const active = selection()
|
||||
|
||||
expect(handleInputSelectionClipboard(active, 'cut')).toBe(true)
|
||||
expect(active.cut).toHaveBeenCalledOnce()
|
||||
expect(active.copy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves shortcuts available when there is no active selection', () => {
|
||||
expect(handleInputSelectionClipboard(null, 'copy')).toBe(false)
|
||||
expect(handleInputSelectionClipboard(selection(2, 2), 'cut')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import { findSlashCommand } from '../app/slash/registry.js'
|
||||
|
||||
describe('/journey slash command', () => {
|
||||
beforeEach(() => {
|
||||
resetOverlayState()
|
||||
})
|
||||
|
||||
it('resolves by name and aliases', () => {
|
||||
expect(findSlashCommand('journey')?.name).toBe('journey')
|
||||
|
||||
for (const alias of ['learning', 'memory-graph']) {
|
||||
expect(findSlashCommand(alias)?.name).toBe('journey')
|
||||
}
|
||||
})
|
||||
|
||||
it('opens the journey overlay when run', () => {
|
||||
expect(getOverlayState().journey).toBe(false)
|
||||
findSlashCommand('journey')!.run('', {} as never, 'journey')
|
||||
expect(getOverlayState().journey).toBe(true)
|
||||
})
|
||||
|
||||
it('is preserved by the flow-overlay soft reset (deliberate, user-opened)', async () => {
|
||||
findSlashCommand('journey')!.run('', {} as never, 'journey')
|
||||
// Mirror turnController.idle(): flow overlays clear, user-opened panels stay.
|
||||
const { resetFlowOverlays } = await import('../app/overlayStore.js')
|
||||
resetFlowOverlays()
|
||||
expect(getOverlayState().journey).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { renderToScreen } from '../../packages/hermes-ink/src/ink/render-to-screen.js'
|
||||
import { cellAtIndex } from '../../packages/hermes-ink/src/ink/screen.js'
|
||||
import { ShimmerRows, shimmerSegments, subscribeShimmerClock } from '../components/loaders.js'
|
||||
|
||||
describe('ShimmerRows leniency (agent-authored calls)', () => {
|
||||
it('accepts a bare row COUNT and derives widths — the generated-code shape', async () => {
|
||||
const { createElement } = await import('react')
|
||||
|
||||
const { screen, height } = renderToScreen(
|
||||
createElement(ShimmerRows, {
|
||||
rows: 3,
|
||||
width: 20,
|
||||
t: { color: { completionBg: '#1a1a2e', label: '#DAA520', muted: '#B8860B' } }
|
||||
}),
|
||||
30
|
||||
)
|
||||
|
||||
expect(height).toBe(3)
|
||||
// Row 0 renders block cells, not a crash.
|
||||
expect(cellAtIndex(screen, 0).char).toBe('▁')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shimmerSegments', () => {
|
||||
it('always partitions the full width', () => {
|
||||
for (let phase = -40; phase < 80; phase++) {
|
||||
const [pre, band, post] = shimmerSegments(20, phase)
|
||||
|
||||
expect(pre + band + post).toBe(20)
|
||||
expect(Math.min(pre, band, post)).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('sweeps: enters from the left edge, exits off the right, then wraps', () => {
|
||||
const bandAt = (phase: number) => shimmerSegments(10, phase, 4)
|
||||
|
||||
expect(bandAt(0)).toEqual([10, 0, 0]) // band fully off-left
|
||||
expect(bandAt(1)).toEqual([0, 1, 9]) // entering
|
||||
expect(bandAt(7)).toEqual([3, 4, 3]) // mid-sweep
|
||||
expect(bandAt(13)).toEqual([9, 1, 0]) // exiting
|
||||
expect(bandAt(14)).toEqual([10, 0, 0]) // gone → next cycle re-enters
|
||||
expect(bandAt(15)).toEqual([0, 1, 9])
|
||||
})
|
||||
|
||||
it('negative phases (row stagger) wrap instead of vanishing', () => {
|
||||
const [pre, band, post] = shimmerSegments(10, -3, 4)
|
||||
|
||||
expect(pre + band + post).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
// Review on #20379 (finding 5): independent 90 ms intervals per shimmer
|
||||
// composition meant an idle TUI with two lazy sections repainted ~22x/sec
|
||||
// forever. All compositions now share ONE clock, and the interval exists
|
||||
// only while subscribers do.
|
||||
describe('subscribeShimmerClock', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('drives any number of subscribers from a single interval', () => {
|
||||
const a: number[] = []
|
||||
const b: number[] = []
|
||||
|
||||
const timersBefore = vi.getTimerCount()
|
||||
const unsubA = subscribeShimmerClock(p => a.push(p))
|
||||
const unsubB = subscribeShimmerClock(p => b.push(p))
|
||||
|
||||
// Two subscribers, ONE new timer.
|
||||
expect(vi.getTimerCount()).toBe(timersBefore + 1)
|
||||
|
||||
vi.advanceTimersByTime(300)
|
||||
|
||||
// Same shared phases, in lockstep.
|
||||
expect(a.length).toBeGreaterThan(0)
|
||||
expect(a).toEqual(b)
|
||||
|
||||
unsubA()
|
||||
unsubB()
|
||||
})
|
||||
|
||||
it('stops the interval with the last unsubscribe', () => {
|
||||
const unsubA = subscribeShimmerClock(() => {})
|
||||
const unsubB = subscribeShimmerClock(() => {})
|
||||
const timersRunning = vi.getTimerCount()
|
||||
|
||||
unsubA()
|
||||
// Still one subscriber — clock keeps running.
|
||||
expect(vi.getTimerCount()).toBe(timersRunning)
|
||||
|
||||
unsubB()
|
||||
expect(vi.getTimerCount()).toBe(timersRunning - 1)
|
||||
})
|
||||
|
||||
it('a late subscriber restarts the clock cleanly', () => {
|
||||
const unsubA = subscribeShimmerClock(() => {})
|
||||
|
||||
unsubA()
|
||||
|
||||
const seen: number[] = []
|
||||
const unsubB = subscribeShimmerClock(p => seen.push(p))
|
||||
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(seen.length).toBeGreaterThan(0)
|
||||
unsubB()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,458 @@
|
||||
import { PassThrough } from 'stream'
|
||||
|
||||
import { Box, renderSync } from '@hermes/ink'
|
||||
import chalk from 'chalk'
|
||||
import React from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AUDIO_DIRECTIVE_RE, INLINE_RE, Md, MEDIA_LINE_RE, stripInlineMarkup } from '../components/markdown.js'
|
||||
import { __resetLinkTitleCache, fetchLinkTitle } from '../lib/externalLink.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { DEFAULT_THEME, LIGHT_THEME } from '../theme.js'
|
||||
|
||||
afterEach(() => {
|
||||
__resetLinkTitleCache()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Stub the network and warm the shared title cache, so a subsequent render
|
||||
// has the resolved title available synchronously.
|
||||
const stubFetchedTitle = (url: string, title: string) => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(`<html><head><title>${title}</title></head></html>`, {
|
||||
headers: { 'content-type': 'text/html' },
|
||||
status: 200
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return fetchLinkTitle(url)
|
||||
}
|
||||
|
||||
const matches = (text: string) => [...text.matchAll(INLINE_RE)].map(m => m[0])
|
||||
const BEL = String.fromCharCode(7)
|
||||
const ESC = String.fromCharCode(27)
|
||||
const CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g')
|
||||
const OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
|
||||
|
||||
const renderPlain = (node: React.ReactNode) => {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(node, {
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
|
||||
return output
|
||||
.replace(OSC_RE, '')
|
||||
.split('\n')
|
||||
.map(line => stripAnsi(line).replace(CSI_RE, '').trimEnd())
|
||||
}
|
||||
|
||||
describe('INLINE_RE emphasis', () => {
|
||||
it('matches word-boundary italic/bold', () => {
|
||||
expect(matches('say _hi_ there')).toEqual(['_hi_'])
|
||||
expect(matches('very __bold move__ today')).toEqual(['__bold move__'])
|
||||
expect(matches('(_paren_) and [_bracket_]')).toEqual(['_paren_', '_bracket_'])
|
||||
})
|
||||
|
||||
it('keeps intraword underscores literal', () => {
|
||||
const path = '/home/me/.hermes/cache/screenshots/browser_screenshot_ecc1c3feab.png'
|
||||
|
||||
expect(matches(path)).toEqual([])
|
||||
expect(matches('snake_case_var and MY_CONST')).toEqual([])
|
||||
expect(matches('foo__bar__baz')).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps Python dunder identifiers literal', () => {
|
||||
expect(matches('if __name__ == "__main__":')).toEqual([])
|
||||
expect(matches('def __init__(self):')).toEqual([])
|
||||
expect(matches('print(__file__)')).toEqual([])
|
||||
})
|
||||
|
||||
it('still matches asterisk emphasis intraword', () => {
|
||||
expect(matches('a*b*c')).toEqual(['*b*'])
|
||||
expect(matches('a**bold**c')).toEqual(['**bold**'])
|
||||
})
|
||||
|
||||
it('matches short alphanumeric subscript (H~2~O, CO~2~, X~n~)', () => {
|
||||
expect(matches('H~2~O')).toEqual(['~2~'])
|
||||
expect(matches('CO~2~ levels')).toEqual(['~2~'])
|
||||
expect(matches('the X~n~ term')).toEqual(['~n~'])
|
||||
})
|
||||
|
||||
it('ignores kaomoji-style ~! and ~? punctuation', () => {
|
||||
// Kimi / Qwen / GLM emit these as decorators and the whole span between
|
||||
// two tildes used to get collapsed into one dim blob.
|
||||
expect(matches('Aww ~! Building step by step, I love it ~!')).toEqual([])
|
||||
expect(matches('cool ~? yeah ~?')).toEqual([])
|
||||
expect(matches('mixed ~! and ~? flow')).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores tilde spans that contain spaces or punctuation', () => {
|
||||
// Real subscript doesn't contain spaces; a tilde followed by words-then-
|
||||
// tilde is almost always conversational. Matching it swallows text.
|
||||
expect(matches('hello ~good idea~ there')).toEqual([])
|
||||
expect(matches('x ~oh no!~ y')).toEqual([])
|
||||
})
|
||||
|
||||
it('does not let strikethrough eat subscript', () => {
|
||||
expect(matches('~~strike~~ and H~2~O')).toEqual(['~~strike~~', '~2~'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripInlineMarkup', () => {
|
||||
it('strips word-boundary emphasis only', () => {
|
||||
expect(stripInlineMarkup('say _hi_ there')).toBe('say hi there')
|
||||
expect(stripInlineMarkup('browser_screenshot_ecc.png')).toBe('browser_screenshot_ecc.png')
|
||||
expect(stripInlineMarkup('__bold move__ and foo__bar__')).toBe('bold move and foo__bar__')
|
||||
})
|
||||
|
||||
it('preserves Python dunder identifiers', () => {
|
||||
expect(stripInlineMarkup('if __name__ == "__main__":')).toBe('if __name__ == "__main__":')
|
||||
expect(stripInlineMarkup('class X: def __init__(self): pass')).toBe('class X: def __init__(self): pass')
|
||||
})
|
||||
|
||||
it('leaves ~!/~? kaomoji alone and still handles real subscript', () => {
|
||||
expect(stripInlineMarkup('Yay ~! nice work ~!')).toBe('Yay ~! nice work ~!')
|
||||
expect(stripInlineMarkup('H~2~O and CO~2~')).toBe('H_2O and CO_2')
|
||||
})
|
||||
|
||||
it('strips inline math delimiters but keeps the formula text', () => {
|
||||
expect(stripInlineMarkup('$\\mathbb{Z}$ is a ring')).toBe('\\mathbb{Z} is a ring')
|
||||
expect(stripInlineMarkup('see \\(a + b\\) ok')).toBe('see a + b ok')
|
||||
})
|
||||
})
|
||||
|
||||
describe('INLINE_RE inline math', () => {
|
||||
it('matches single-dollar math and beats emphasis at the same start', () => {
|
||||
// Without math handling, `*b*` would have matched as italics and
|
||||
// corrupted the formula. With math added to INLINE_RE, the leftmost
|
||||
// match at column 0 (`$P=a*b*c$`) wins.
|
||||
expect(matches('$P=a*b*c$')).toEqual(['$P=a*b*c$'])
|
||||
expect(matches('see $\\mathbb{Z}$ here')).toEqual(['$\\mathbb{Z}$'])
|
||||
})
|
||||
|
||||
it('does not match currency-style prose', () => {
|
||||
expect(matches('it costs $5 and $10')).toEqual([])
|
||||
expect(matches('paid $5')).toEqual([])
|
||||
})
|
||||
|
||||
it('does not let inline math swallow a $$ display fence', () => {
|
||||
// `$$x$$` is a display block, not two abutting inline-math spans.
|
||||
expect(matches('$$x$$')).toEqual([])
|
||||
})
|
||||
|
||||
it('matches \\(...\\) inline math', () => {
|
||||
expect(matches('foo \\(x + y\\) bar')).toEqual(['\\(x + y\\)'])
|
||||
})
|
||||
|
||||
it('does not corrupt subscripts/superscripts inside math', () => {
|
||||
// `_n` and `^r` are markdown emphasis/superscript markers in prose, but
|
||||
// inside a `$...$` span the entire formula is captured as a single
|
||||
// inline-math token so the inner regexes never see those characters.
|
||||
expect(matches('$P=a_n x^n + a_0$')).toEqual(['$P=a_n x^n + a_0$'])
|
||||
expect(matches('$\\beta_1,\\dots,\\beta_r$')).toEqual(['$\\beta_1,\\dots,\\beta_r$'])
|
||||
})
|
||||
|
||||
it('places math content in the correct capture group (regression: m[16] is bare URL)', () => {
|
||||
// When `m[16]` was the bare URL group AND the inline-math `$...$`
|
||||
// group simultaneously (because the bare URL pattern lacked its own
|
||||
// capturing parens), MdInline rendered `$\\mathbb{R}$` as an
|
||||
// underlined autolink instead of italic amber math. Lock down the
|
||||
// numbering: math goes in m[17] / m[18], URLs go in m[16].
|
||||
const url = [...'see https://example.com here'.matchAll(INLINE_RE)][0]!
|
||||
const dollarMath = [...'$\\mathbb{R}$'.matchAll(INLINE_RE)][0]!
|
||||
const parenMath = [...'\\(\\pi\\)'.matchAll(INLINE_RE)][0]!
|
||||
|
||||
expect(url[16]).toBe('https://example.com')
|
||||
expect(url[17]).toBeUndefined()
|
||||
expect(url[18]).toBeUndefined()
|
||||
|
||||
expect(dollarMath[16]).toBeUndefined()
|
||||
expect(dollarMath[17]).toBe('\\mathbb{R}')
|
||||
expect(dollarMath[18]).toBeUndefined()
|
||||
|
||||
expect(parenMath[16]).toBeUndefined()
|
||||
expect(parenMath[17]).toBeUndefined()
|
||||
expect(parenMath[18]).toBe('\\pi')
|
||||
})
|
||||
})
|
||||
|
||||
describe('protocol sentinels', () => {
|
||||
it('captures MEDIA: paths with surrounding quotes or backticks', () => {
|
||||
expect('MEDIA:/tmp/a.png'.match(MEDIA_LINE_RE)?.[1]).toBe('/tmp/a.png')
|
||||
expect(' MEDIA: /home/me/.hermes/cache/screenshots/browser_screenshot_ecc.png '.match(MEDIA_LINE_RE)?.[1]).toBe(
|
||||
'/home/me/.hermes/cache/screenshots/browser_screenshot_ecc.png'
|
||||
)
|
||||
expect('`MEDIA:/tmp/a.png`'.match(MEDIA_LINE_RE)?.[1]).toBe('/tmp/a.png')
|
||||
expect('"MEDIA:C:\\files\\a.png"'.match(MEDIA_LINE_RE)?.[1]).toBe('C:\\files\\a.png')
|
||||
})
|
||||
|
||||
it('ignores MEDIA: tokens embedded in prose', () => {
|
||||
expect('here is MEDIA:/tmp/a.png for you'.match(MEDIA_LINE_RE)).toBeNull()
|
||||
expect('the media: section is empty'.match(MEDIA_LINE_RE)).toBeNull()
|
||||
})
|
||||
|
||||
it('matches the [[audio_as_voice]] directive', () => {
|
||||
expect(AUDIO_DIRECTIVE_RE.test('[[audio_as_voice]]')).toBe(true)
|
||||
expect(AUDIO_DIRECTIVE_RE.test(' [[audio_as_voice]] ')).toBe(true)
|
||||
expect(AUDIO_DIRECTIVE_RE.test('audio_as_voice')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Md wrapping', () => {
|
||||
it('trims spaces from word-wrap continuation lines', () => {
|
||||
const lines = renderPlain(
|
||||
React.createElement(Box, { width: 5 }, React.createElement(Md, { t: DEFAULT_THEME, text: 'Let me' }))
|
||||
)
|
||||
|
||||
expect(lines).toContain('Let')
|
||||
expect(lines).toContain('me')
|
||||
expect(lines).not.toContain(' me')
|
||||
})
|
||||
|
||||
it('keeps nested list and quote indentation out of trim-sensitive text', () => {
|
||||
const lines = renderPlain(
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: 'column', width: 24 },
|
||||
React.createElement(Md, { t: DEFAULT_THEME, text: ' - nested bullet' }),
|
||||
React.createElement(Md, { t: DEFAULT_THEME, text: '>> nested quote' })
|
||||
)
|
||||
)
|
||||
|
||||
expect(lines).toContain(' • nested bullet')
|
||||
expect(lines).toContain(' │ nested quote')
|
||||
})
|
||||
|
||||
it('preserves original inline-code edge spaces', () => {
|
||||
const lines = renderPlain(
|
||||
React.createElement(Box, { width: 24 }, React.createElement(Md, { t: DEFAULT_THEME, text: '` hi ` ok' }))
|
||||
)
|
||||
|
||||
expect(lines.some(line => line.startsWith(' hi ok'))).toBe(true)
|
||||
})
|
||||
|
||||
it('renders Python dunder identifiers literally outside code fences', () => {
|
||||
const lines = renderPlain(
|
||||
React.createElement(
|
||||
Box,
|
||||
{ width: 80 },
|
||||
React.createElement(Md, {
|
||||
t: DEFAULT_THEME,
|
||||
text: 'if __name__ == "__main__":\n obj.__init__()'
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const rendered = lines.join('\n')
|
||||
|
||||
expect(rendered).toContain('if __name__ == "__main__":')
|
||||
expect(rendered).toContain('obj.__init__()')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Md link labels', () => {
|
||||
it('renders bare URLs with readable slug labels', () => {
|
||||
const lines = renderPlain(
|
||||
React.createElement(
|
||||
Box,
|
||||
{ width: 120 },
|
||||
React.createElement(Md, {
|
||||
t: DEFAULT_THEME,
|
||||
text: 'see https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure for details'
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const rendered = lines.join('\n')
|
||||
|
||||
expect(rendered).toContain('Puerto Rico El Yunque Rainforest Adventure')
|
||||
expect(rendered).not.toContain('https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure')
|
||||
})
|
||||
|
||||
it('keeps the authored markdown label even when a page title resolves', async () => {
|
||||
const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure'
|
||||
|
||||
// Warm the shared cache so `useLinkTitle` would have a title to render
|
||||
// synchronously — the label must still win.
|
||||
await stubFetchedTitle(url, 'El Yunque Rainforest Adventure | Expedia')
|
||||
|
||||
const lines = renderPlain(
|
||||
React.createElement(
|
||||
Box,
|
||||
{ width: 80 },
|
||||
React.createElement(Md, { t: DEFAULT_THEME, text: `[Trip details](${url})` })
|
||||
)
|
||||
)
|
||||
|
||||
const rendered = lines.join('\n')
|
||||
|
||||
expect(rendered).toContain('Trip details')
|
||||
expect(rendered).not.toContain('El Yunque Rainforest Adventure | Expedia')
|
||||
})
|
||||
|
||||
it('still resolves titles for links whose label is just the URL', async () => {
|
||||
const url = 'https://www.expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure'
|
||||
|
||||
await stubFetchedTitle(url, 'Rainforest Adventure Tour')
|
||||
|
||||
const lines = renderPlain(
|
||||
React.createElement(Box, { width: 120 }, React.createElement(Md, { t: DEFAULT_THEME, text: `[${url}](${url})` }))
|
||||
)
|
||||
|
||||
expect(lines.join('\n')).toContain('Rainforest Adventure Tour')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderTable CJK width alignment', () => {
|
||||
it('column starts share the same display offset across CJK rows', async () => {
|
||||
const { stringWidth } = await import('@hermes/ink')
|
||||
|
||||
const md = [
|
||||
'| 配置 | Config | 状态 |',
|
||||
'|------|--------|------|',
|
||||
'| Vicuna (report) | dense | × |',
|
||||
'| ChatGLM | chat | ✓ |',
|
||||
'| 通义千问 | qwen | × |'
|
||||
].join('\n')
|
||||
|
||||
// Pre-fix bug: ` `.repeat(w - stripInlineMarkup(...).length) used
|
||||
// UTF-16 code units, so a CJK header cell padded to 2 cells while
|
||||
// the body cell padded to 4, drifting subsequent columns by 2
|
||||
// cells per CJK char.
|
||||
//
|
||||
// Post-fix contract: the prefix preceding the start of column N
|
||||
// has the same display width across the header and every body row
|
||||
// (deduped to skip the divider, which renders independently).
|
||||
const lines = renderPlain(
|
||||
React.createElement(Box, null, React.createElement(Md, { compact: true, t: DEFAULT_THEME, text: md }))
|
||||
).filter(line => line.trim().length > 0)
|
||||
|
||||
// Heuristic: a "data row" line either contains 'Config' (header)
|
||||
// or one of the body labels; a divider is all box-drawing. Use
|
||||
// the substring 'Config' / 'dense' / 'chat' / 'qwen' as the
|
||||
// unique anchor for column 2's start position on each row.
|
||||
const colStarts = (line: string, anchor: string): number => {
|
||||
const idx = line.indexOf(anchor)
|
||||
|
||||
return idx < 0 ? -1 : stringWidth(line.slice(0, idx))
|
||||
}
|
||||
|
||||
const headerCol2 = lines.map(l => colStarts(l, 'Config')).find(v => v >= 0)
|
||||
const denseCol2 = lines.map(l => colStarts(l, 'dense')).find(v => v >= 0)
|
||||
const chatCol2 = lines.map(l => colStarts(l, 'chat')).find(v => v >= 0)
|
||||
const qwenCol2 = lines.map(l => colStarts(l, 'qwen')).find(v => v >= 0)
|
||||
|
||||
expect(headerCol2).toBeDefined()
|
||||
expect(denseCol2).toBe(headerCol2)
|
||||
expect(chatCol2).toBe(headerCol2)
|
||||
// The CJK row is the one that drifted before the fix. It must
|
||||
// align with the rest now.
|
||||
expect(qwenCol2).toBe(headerCol2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('body prose stays in the theme palette', () => {
|
||||
// Prose used to render in the terminal's DEFAULT foreground while inline
|
||||
// tokens beside it carried a theme color, so one line mixed two inks.
|
||||
// Because an inline token can match mid-word, so could a single word.
|
||||
// LIGHT_THEME is the vehicle here because every tone in it is hex, so
|
||||
// emitted SGR maps back to palette entries without format juggling.
|
||||
const foregroundRuns = (text: string): string[] => {
|
||||
// chalk is a singleton and defaults to level 0 under vitest (no TTY),
|
||||
// which would emit no SGR at all and make every assertion here vacuous.
|
||||
const savedLevel = chalk.level
|
||||
chalk.level = 3
|
||||
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 80, isTTY: true, rows: 24 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(
|
||||
React.createElement(Box, { width: 70 }, React.createElement(Md, { cols: 68, t: LIGHT_THEME, text })),
|
||||
{
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
}
|
||||
)
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
chalk.level = savedLevel
|
||||
|
||||
return [...output.matchAll(new RegExp(`${ESC}\\[38;2;(\\d+);(\\d+);(\\d+)m`, 'g'))].map(
|
||||
m =>
|
||||
'#' +
|
||||
m
|
||||
.slice(1, 4)
|
||||
.map(v => Number(v).toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
)
|
||||
}
|
||||
|
||||
const PALETTE = new Set(
|
||||
Object.values(LIGHT_THEME.color)
|
||||
.filter((v): v is string => typeof v === 'string' && v.startsWith('#'))
|
||||
.map(v => v.toLowerCase())
|
||||
)
|
||||
|
||||
const INK = LIGHT_THEME.color.text.toLowerCase()
|
||||
|
||||
it('opens a paragraph with the theme ink, not the terminal default', () => {
|
||||
expect(foregroundRuns('plain prose line')[0]).toBe(INK)
|
||||
})
|
||||
|
||||
it('keeps every foreground on a mixed-token line inside the palette', () => {
|
||||
// `render_terminal_output` trips the underscore-italic token mid-word —
|
||||
// the exact shape that split one word across two inks.
|
||||
const fg = foregroundRuns('set the `flag` and re-render_terminal_output for the run')
|
||||
|
||||
expect(fg.length).toBeGreaterThan(0)
|
||||
|
||||
for (const c of fg) {
|
||||
expect(PALETTE.has(c)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns to the theme ink after an inline token, not to the terminal default', () => {
|
||||
const fg = foregroundRuns('before `code` after')
|
||||
|
||||
expect(fg[0]).toBe(INK)
|
||||
expect(fg.at(-1)).toBe(INK)
|
||||
})
|
||||
|
||||
it('themes list-item prose too', () => {
|
||||
for (const text of ['- a bullet item', '1. a numbered item']) {
|
||||
expect(foregroundRuns(text)).toContain(INK)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { BOX_CLOSE, BOX_OPEN, BOX_RE, texToUnicode } from '../lib/mathUnicode.js'
|
||||
|
||||
const stripBox = (s: string) => s.replace(BOX_RE, '$1')
|
||||
|
||||
describe('texToUnicode — symbols', () => {
|
||||
it('substitutes lowercase Greek', () => {
|
||||
expect(texToUnicode('\\alpha + \\beta + \\pi')).toBe('α + β + π')
|
||||
expect(texToUnicode('\\omega')).toBe('ω')
|
||||
})
|
||||
|
||||
it('substitutes uppercase Greek', () => {
|
||||
expect(texToUnicode('\\Sigma \\Omega \\Pi')).toBe('Σ Ω Π')
|
||||
})
|
||||
|
||||
it('substitutes set theory and logic operators', () => {
|
||||
expect(texToUnicode('A \\cup B \\cap C')).toBe('A ∪ B ∩ C')
|
||||
expect(texToUnicode('\\forall x \\in \\emptyset')).toBe('∀ x ∈ ∅')
|
||||
expect(texToUnicode('p \\implies q \\iff r')).toBe('p ⟹ q ⟺ r')
|
||||
})
|
||||
|
||||
it('substitutes relations and arrows', () => {
|
||||
expect(texToUnicode('a \\le b \\ge c \\ne d')).toBe('a ≤ b ≥ c ≠ d')
|
||||
expect(texToUnicode('f: A \\to B')).toBe('f: A → B')
|
||||
})
|
||||
|
||||
it('uses longest-match-first so \\leq beats \\le', () => {
|
||||
expect(texToUnicode('\\leq')).toBe('≤')
|
||||
})
|
||||
|
||||
it('preserves unknown commands that share a prefix with known ones', () => {
|
||||
// `\leqq` is a real LaTeX command (≦) we don't have in our table.
|
||||
// The word-boundary lookahead prevents `\le` from matching, so the
|
||||
// whole thing is preserved verbatim — much better than `≤qq`.
|
||||
expect(texToUnicode('\\leqq')).toBe('\\leqq')
|
||||
})
|
||||
|
||||
it('refuses to substitute a partial command (word boundary)', () => {
|
||||
expect(texToUnicode('\\alphabet')).toBe('\\alphabet')
|
||||
expect(texToUnicode('\\pin')).toBe('\\pin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('texToUnicode — blackboard / calligraphic / fraktur', () => {
|
||||
it('renders \\mathbb capitals', () => {
|
||||
expect(texToUnicode('\\mathbb{R}')).toBe('ℝ')
|
||||
expect(texToUnicode('\\mathbb{N} \\subset \\mathbb{Z} \\subset \\mathbb{Q} \\subset \\mathbb{R}')).toBe(
|
||||
'ℕ ⊂ ℤ ⊂ ℚ ⊂ ℝ'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders \\mathcal and \\mathfrak', () => {
|
||||
expect(texToUnicode('\\mathcal{F} \\subset \\mathfrak{A}')).toBe('ℱ ⊂ 𝔄')
|
||||
})
|
||||
|
||||
it('preserves \\mathbb{...} when argument is multi-letter or non-letter', () => {
|
||||
expect(texToUnicode('\\mathbb{NN}')).toBe('\\mathbb{NN}')
|
||||
expect(texToUnicode('\\mathbb{1}')).toBe('\\mathbb{1}')
|
||||
})
|
||||
|
||||
it('strips \\mathbf / \\mathit / \\mathrm / \\text wrappers (no Unicode bold/italic in monospace)', () => {
|
||||
expect(texToUnicode('\\mathbf{x}')).toBe('x')
|
||||
expect(texToUnicode('\\text{if } x > 0')).toBe('if x > 0')
|
||||
expect(texToUnicode('\\operatorname{rank}(A)')).toBe('rank(A)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('texToUnicode — sub / superscripts', () => {
|
||||
it('converts simple superscripts', () => {
|
||||
expect(texToUnicode('x^2 + y^2')).toBe('x² + y²')
|
||||
expect(texToUnicode('e^{n}')).toBe('eⁿ')
|
||||
})
|
||||
|
||||
it('converts simple subscripts', () => {
|
||||
expect(texToUnicode('a_1 + a_2 + a_n')).toBe('a₁ + a₂ + aₙ')
|
||||
expect(texToUnicode('x_{0}')).toBe('x₀')
|
||||
})
|
||||
|
||||
it('converts mixed-content scripts when every glyph has a Unicode form', () => {
|
||||
// `+`, digits, and lowercase letters all have superscript glyphs,
|
||||
// so `n+1` → `ⁿ⁺¹`. Comma has no subscript form, so `i,j` falls
|
||||
// back to `_(i,j)` (parens) rather than partially substituting —
|
||||
// parens read as ordinary grouping while braces look like leftover
|
||||
// unrendered LaTeX.
|
||||
expect(texToUnicode('x^{n+1}')).toBe('xⁿ⁺¹')
|
||||
expect(texToUnicode('a_{i,j}')).toBe('a_(i,j)')
|
||||
})
|
||||
|
||||
it('uses parens (not braces) when the body has Greek with no superscript form', () => {
|
||||
// π has no Unicode superscript, so `e^{i\pi}` after symbol pass is
|
||||
// `e^{iπ}` and the script fallback emits `e^(iπ)` — much more
|
||||
// readable than the LaTeX-looking `e^{iπ}`.
|
||||
expect(texToUnicode('e^{i\\pi}')).toBe('e^(iπ)')
|
||||
})
|
||||
|
||||
it('strips braces on script fallback when body collapses to a single char', () => {
|
||||
// `^{\infty}` → symbol pass produces `^{∞}` → convertScript can't
|
||||
// find ∞ in SUPERSCRIPT, but the body is one char so we drop the
|
||||
// braces and emit `^∞` (much more readable than `^{∞}`).
|
||||
expect(texToUnicode('e^{\\infty}')).toBe('e^∞')
|
||||
})
|
||||
|
||||
it('handles a real-world sum', () => {
|
||||
expect(texToUnicode('\\sum_{n=0}^{\\infty} \\frac{1}{n!}')).toBe('∑ₙ₌₀^∞ 1/n!')
|
||||
})
|
||||
})
|
||||
|
||||
describe('texToUnicode — fractions', () => {
|
||||
it('collapses \\frac to a/b', () => {
|
||||
expect(texToUnicode('\\frac{1}{2}')).toBe('1/2')
|
||||
expect(texToUnicode('\\frac{a}{b}')).toBe('a/b')
|
||||
})
|
||||
|
||||
it('parenthesises multi-token numerator / denominator', () => {
|
||||
expect(texToUnicode('\\frac{n+1}{2}')).toBe('(n+1)/2')
|
||||
expect(texToUnicode('\\frac{a + b}{c - d}')).toBe('(a + b)/(c - d)')
|
||||
})
|
||||
|
||||
it('handles nested fractions', () => {
|
||||
expect(texToUnicode('\\frac{1}{\\frac{1}{x}}')).toBe('1/(1/x)')
|
||||
})
|
||||
|
||||
it("handles braces inside numerator / denominator (regression: regex \\frac couldn't)", () => {
|
||||
// The regex-only `\frac` matcher used `[^{}]*` for each arg, which
|
||||
// failed the moment a numerator contained its own braces (here the
|
||||
// `{p-1}` from a superscript). The balanced-brace parser handles it.
|
||||
expect(texToUnicode('\\frac{|t|^{p-1}|P(t)|^p}{(p-1)!}')).toBe('(|t|ᵖ⁻¹|P(t)|ᵖ)/((p-1)!)')
|
||||
})
|
||||
|
||||
it('preserves \\frac when arguments are malformed', () => {
|
||||
expect(texToUnicode('\\frac{a}')).toBe('\\frac{a}')
|
||||
expect(texToUnicode('\\fraction{a}{b}')).toBe('\\fraction{a}{b}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('texToUnicode — typography no-ops', () => {
|
||||
it('strips \\displaystyle / \\textstyle / \\scriptstyle / \\scriptscriptstyle', () => {
|
||||
expect(texToUnicode('\\displaystyle\\sum_{i=1}^n x_i')).toBe('∑ᵢ₌₁ⁿ xᵢ')
|
||||
expect(texToUnicode('f(x) = \\displaystyle \\frac{1}{2}')).toBe('f(x) = 1/2')
|
||||
expect(texToUnicode('\\textstyle x + y')).toBe('x + y')
|
||||
})
|
||||
|
||||
it('strips \\limits / \\nolimits which only affect bound positioning', () => {
|
||||
expect(texToUnicode('\\sum\\limits_{k=1}^n a_k')).toBe('∑ₖ₌₁ⁿ aₖ')
|
||||
expect(texToUnicode('\\int\\nolimits_0^1 f(x) dx')).toBe('∫₀¹ f(x) dx')
|
||||
})
|
||||
|
||||
it('does not eat letter-continuation commands like \\limit_inf', () => {
|
||||
// The `(?![A-Za-z])` lookahead protects hypothetical commands that
|
||||
// start with `\limit` / `\display` / etc. The bare names are stripped
|
||||
// but anything longer is preserved verbatim.
|
||||
expect(texToUnicode('\\limitinf x')).toBe('\\limitinf x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('texToUnicode — sizing wrappers', () => {
|
||||
it('strips \\big / \\Big / \\bigg / \\Bigg before delimiters', () => {
|
||||
expect(texToUnicode('\\bigl[ x \\bigr]')).toBe('[ x ]')
|
||||
expect(texToUnicode('\\Big( y \\Big)')).toBe('( y )')
|
||||
expect(texToUnicode('\\bigg| z \\bigg|')).toBe('| z |')
|
||||
expect(texToUnicode('\\Biggl\\{ a \\Biggr\\}')).toBe('{ a }')
|
||||
})
|
||||
|
||||
it('does not eat \\bigtriangleup or other letter-continuations', () => {
|
||||
expect(texToUnicode('A \\bigtriangleup B')).toBe('A \\bigtriangleup B')
|
||||
})
|
||||
})
|
||||
|
||||
describe('texToUnicode — modular arithmetic and tags', () => {
|
||||
it('renders \\pmod{p} as " (mod p)"', () => {
|
||||
expect(texToUnicode('a \\equiv b \\pmod{p}')).toBe('a ≡ b (mod p)')
|
||||
})
|
||||
|
||||
it('renders \\bmod / \\mod inline', () => {
|
||||
expect(texToUnicode('a \\bmod n')).toBe('a mod n')
|
||||
})
|
||||
|
||||
it('collapses \\tag{n} to " (n)"', () => {
|
||||
expect(texToUnicode('x = y \\tag{24}')).toBe('x = y (24)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('texToUnicode — newly added symbols', () => {
|
||||
it('renders \\nmid, \\blacksquare, \\qed', () => {
|
||||
expect(texToUnicode('p \\nmid q')).toBe('p ∤ q')
|
||||
expect(texToUnicode('Therefore \\blacksquare')).toBe('Therefore ■')
|
||||
expect(texToUnicode('done \\qed')).toBe('done ∎')
|
||||
})
|
||||
})
|
||||
|
||||
describe('texToUnicode — \\boxed / \\fbox', () => {
|
||||
// `\boxed` produces non-printable U+0001 / U+0002 sentinels around its
|
||||
// content so the markdown renderer can apply highlight styling. These
|
||||
// tests assert both the sentinel form and the human-readable
|
||||
// strip-fallback (BOX_RE).
|
||||
it('wraps simple boxed content in BOX_OPEN/BOX_CLOSE sentinels', () => {
|
||||
expect(texToUnicode('\\boxed{x = 0}')).toBe(`${BOX_OPEN}x = 0${BOX_CLOSE}`)
|
||||
expect(stripBox(texToUnicode('\\boxed{x = 0}'))).toBe('x = 0')
|
||||
expect(stripBox(texToUnicode('\\fbox{answer}'))).toBe('answer')
|
||||
})
|
||||
|
||||
it("handles boxed expressions with nested braces (regression: regex couldn't)", () => {
|
||||
// A `[^{}]*` regex would stop at the first `{` inside the body. The
|
||||
// balanced-brace parser walks past it.
|
||||
expect(stripBox(texToUnicode('\\boxed{x^{n+1}}'))).toBe('xⁿ⁺¹')
|
||||
expect(stripBox(texToUnicode('\\boxed{\\frac{a}{b}}'))).toBe('a/b')
|
||||
})
|
||||
|
||||
it('handles real-world boxed final answer', () => {
|
||||
expect(stripBox(texToUnicode('\\boxed{J = -\\sum_{k=0}^n a_k F(k)}'))).toBe('J = -∑ₖ₌₀ⁿ aₖ F(k)')
|
||||
})
|
||||
|
||||
it('preserves \\boxed without a brace argument', () => {
|
||||
expect(texToUnicode('\\boxed something')).toBe('\\boxed something')
|
||||
})
|
||||
})
|
||||
|
||||
describe('texToUnicode — combining marks', () => {
|
||||
it('applies \\overline / \\bar / \\hat / \\vec / \\tilde', () => {
|
||||
expect(texToUnicode('\\overline{x}')).toBe('x\u0305')
|
||||
expect(texToUnicode('\\hat{y}')).toBe('y\u0302')
|
||||
expect(texToUnicode('\\vec{v}')).toBe('v\u20D7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('texToUnicode — left/right delimiters', () => {
|
||||
it('strips \\left and \\right keeping the delimiter character', () => {
|
||||
expect(texToUnicode('\\left( x + y \\right)')).toBe('( x + y )')
|
||||
expect(texToUnicode('\\left| x \\right|')).toBe('| x |')
|
||||
})
|
||||
|
||||
it('handles escaped delimiters \\left\\{ ... \\right\\}', () => {
|
||||
expect(texToUnicode('\\left\\{p/q \\mid q \\neq 0\\right\\}')).toBe('{p/q ∣ q ≠ 0}')
|
||||
})
|
||||
|
||||
it('handles named delimiters via \\left\\langle / \\right\\rangle', () => {
|
||||
expect(texToUnicode('\\left\\langle u, v \\right\\rangle')).toBe('⟨ u, v ⟩')
|
||||
})
|
||||
|
||||
it('drops \\left. and \\right. (which are explicit "no delimiter")', () => {
|
||||
expect(texToUnicode('\\left. f \\right|')).toBe(' f |')
|
||||
})
|
||||
|
||||
it('preserves \\leftarrow / \\rightarrow (word boundary blocks the strip)', () => {
|
||||
expect(texToUnicode('A \\leftarrow B \\rightarrow C')).toBe('A ← B → C')
|
||||
})
|
||||
})
|
||||
|
||||
describe('texToUnicode — labelled arrows', () => {
|
||||
it('renders \\xrightarrow{label} as ─label→', () => {
|
||||
expect(texToUnicode('a \\xrightarrow{x=1} b')).toBe('a ─x=1→ b')
|
||||
})
|
||||
|
||||
it('renders \\xleftarrow{label} as ←label─', () => {
|
||||
expect(texToUnicode('a \\xleftarrow{n} b')).toBe('a ←n─ b')
|
||||
})
|
||||
|
||||
it('still applies symbol substitution inside the label', () => {
|
||||
expect(texToUnicode('a \\xrightarrow{n \\to \\infty} L')).toBe('a ─n → ∞→ L')
|
||||
})
|
||||
})
|
||||
|
||||
describe('texToUnicode — punctuation commands without lookahead', () => {
|
||||
it('substitutes \\{ even when immediately followed by a letter', () => {
|
||||
// Regression: with a global `(?![A-Za-z])` lookahead, `\{p` refused
|
||||
// to substitute (because `p` is a letter) and rendered as `\{p`.
|
||||
expect(texToUnicode('\\{p, q\\}')).toBe('{p, q}')
|
||||
})
|
||||
|
||||
it('substitutes thin-space \\, before a letter', () => {
|
||||
expect(texToUnicode('a\\,b')).toBe('a b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('texToUnicode — round-trip realism', () => {
|
||||
it('renders a typical model-emitted formula', () => {
|
||||
expect(texToUnicode('\\alpha \\in \\mathbb{R}, \\alpha \\notin \\mathbb{Q}')).toBe('α ∈ ℝ, α ∉ ℚ')
|
||||
})
|
||||
|
||||
it('preserves unknown commands verbatim', () => {
|
||||
expect(texToUnicode('\\bigtriangleup \\circledast')).toBe('\\bigtriangleup \\circledast')
|
||||
})
|
||||
|
||||
it('handles commands without delimiters between', () => {
|
||||
// Word-boundary lookahead means `\alpha\beta` doesn't accidentally
|
||||
// match `\alphabeta` as one ungrouped token.
|
||||
expect(texToUnicode('\\alpha\\beta')).toBe('αβ')
|
||||
})
|
||||
|
||||
it('leaves plain text alone', () => {
|
||||
expect(texToUnicode('hello world')).toBe('hello world')
|
||||
expect(texToUnicode('')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// memory.js performs real heap dumps / fs work — stub it so the monitor's
|
||||
// dump path is a no-op in tests.
|
||||
vi.mock('../lib/memory.js', () => ({
|
||||
performHeapDump: vi.fn(async () => null)
|
||||
}))
|
||||
|
||||
// @hermes/ink is dynamically imported only on the dump path; stub the eviction.
|
||||
vi.mock('@hermes/ink', () => ({ evictInkCaches: vi.fn() }))
|
||||
|
||||
import { startMemoryMonitor } from '../lib/memoryMonitor.js'
|
||||
|
||||
const GB = 1024 ** 3
|
||||
const MB = 1024 ** 2
|
||||
|
||||
describe('startMemoryMonitor thresholds (#34095)', () => {
|
||||
let stop: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
stop?.()
|
||||
stop = undefined
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const withHeap = (heapUsed: number, rss = heapUsed) =>
|
||||
vi.spyOn(process, 'memoryUsage').mockReturnValue({
|
||||
arrayBuffers: 0,
|
||||
external: 0,
|
||||
heapTotal: heapUsed,
|
||||
heapUsed,
|
||||
rss
|
||||
} as NodeJS.MemoryUsage)
|
||||
|
||||
it('does NOT fire onCritical at 2.5GB when the heap ceiling is 8GB', async () => {
|
||||
// The old hardcoded 2.5GB constant killed the process at ~31% of the real
|
||||
// ceiling. With relative thresholds (~88%), 2.5GB is well within normal.
|
||||
const onCritical = vi.fn()
|
||||
withHeap(2.5 * GB)
|
||||
stop = startMemoryMonitor({ criticalBytes: 7 * GB, highBytes: 5 * GB, intervalMs: 1, onCritical })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
|
||||
expect(onCritical).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fires onCritical only near the configured ceiling', async () => {
|
||||
const onCritical = vi.fn()
|
||||
// Explicit small ceiling-derived thresholds via override to keep the test
|
||||
// independent of the host V8 heap_size_limit.
|
||||
withHeap(7.5 * GB)
|
||||
stop = startMemoryMonitor({ criticalBytes: 7 * GB, highBytes: 5 * GB, intervalMs: 1, onCritical })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5)
|
||||
|
||||
expect(onCritical).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fires onWarn once on fast sub-threshold heap growth, then re-arms', async () => {
|
||||
const onWarn = vi.fn()
|
||||
// Start low, then jump >150MB across a tick while above the 600MB floor and
|
||||
// below `high` — the silent-death regime.
|
||||
const spy = withHeap(100 * MB)
|
||||
stop = startMemoryMonitor({ highBytes: 2 * GB, intervalMs: 1, onWarn, warnBytes: 600 * MB })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2) // seed lastHeap at 100MB, below floor
|
||||
expect(onWarn).not.toHaveBeenCalled()
|
||||
|
||||
spy.mockReturnValue({
|
||||
arrayBuffers: 0,
|
||||
external: 0,
|
||||
heapTotal: 800 * MB,
|
||||
heapUsed: 800 * MB,
|
||||
rss: 800 * MB
|
||||
} as NodeJS.MemoryUsage)
|
||||
await vi.advanceTimersByTimeAsync(2) // jumped 700MB → above floor + steep
|
||||
expect(onWarn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Stays elevated but not re-firing.
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
expect(onWarn).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Falls back below the floor → re-armed, then climbs again → fires again.
|
||||
spy.mockReturnValue({
|
||||
arrayBuffers: 0,
|
||||
external: 0,
|
||||
heapTotal: 100 * MB,
|
||||
heapUsed: 100 * MB,
|
||||
rss: 100 * MB
|
||||
} as NodeJS.MemoryUsage)
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
spy.mockReturnValue({
|
||||
arrayBuffers: 0,
|
||||
external: 0,
|
||||
heapTotal: 800 * MB,
|
||||
heapUsed: 800 * MB,
|
||||
rss: 800 * MB
|
||||
} as NodeJS.MemoryUsage)
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
expect(onWarn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not warn on slow growth below the steep-growth step', async () => {
|
||||
const onWarn = vi.fn()
|
||||
const spy = withHeap(650 * MB)
|
||||
stop = startMemoryMonitor({ highBytes: 2 * GB, intervalMs: 1, onWarn, warnBytes: 600 * MB })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
// +50MB per tick — above the floor but gentle, not a render-tree blowup.
|
||||
spy.mockReturnValue({
|
||||
arrayBuffers: 0,
|
||||
external: 0,
|
||||
heapTotal: 700 * MB,
|
||||
heapUsed: 700 * MB,
|
||||
rss: 700 * MB
|
||||
} as NodeJS.MemoryUsage)
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
|
||||
expect(onWarn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { mergeUsageStable, usageChanged } from '../app/createGatewayEventHandler.js'
|
||||
import type { Usage } from '../types.js'
|
||||
|
||||
const baseUsage: Usage = {
|
||||
calls: 3,
|
||||
input: 1200,
|
||||
output: 400,
|
||||
total: 1600,
|
||||
context_max: 200000,
|
||||
context_percent: 12,
|
||||
context_used: 24000
|
||||
}
|
||||
|
||||
describe('mergeUsageStable (#41480 status-bar flicker)', () => {
|
||||
it('returns the PRIOR reference when a patch changes nothing', () => {
|
||||
// The load-bearing behavior: an unchanged-value patch must NOT mint a new
|
||||
// object, or every $uiState subscriber re-renders per streaming delta.
|
||||
const patch = { calls: 3, total: 1600 }
|
||||
expect(mergeUsageStable(baseUsage, patch)).toBe(baseUsage)
|
||||
})
|
||||
|
||||
it('returns the prior reference for an undefined patch', () => {
|
||||
expect(mergeUsageStable(baseUsage, undefined)).toBe(baseUsage)
|
||||
})
|
||||
|
||||
it('returns a new merged object when a value actually changes', () => {
|
||||
const merged = mergeUsageStable(baseUsage, { total: 1700 })
|
||||
expect(merged).not.toBe(baseUsage)
|
||||
expect(merged.total).toBe(1700)
|
||||
expect(merged.calls).toBe(3)
|
||||
})
|
||||
|
||||
it('detects an active_subagents-only update (field the original PR missed)', () => {
|
||||
// usageChanged iterates the key union generically, so optional fields the
|
||||
// status rule consumes (active_subagents drives the ⛓ segment and the
|
||||
// resume hint) can never be silently dropped from the comparison.
|
||||
const withSubagents = mergeUsageStable(baseUsage, { active_subagents: 2 })
|
||||
expect(withSubagents).not.toBe(baseUsage)
|
||||
expect(withSubagents.active_subagents).toBe(2)
|
||||
|
||||
// And clearing it back down is also a change.
|
||||
const cleared = mergeUsageStable(withSubagents, { active_subagents: 0 })
|
||||
expect(cleared).not.toBe(withSubagents)
|
||||
expect(cleared.active_subagents).toBe(0)
|
||||
})
|
||||
|
||||
it('treats a key present on only one side as a change', () => {
|
||||
expect(usageChanged(baseUsage, { ...baseUsage, cost_usd: 0.01 })).toBe(true)
|
||||
expect(usageChanged({ ...baseUsage, cost_usd: 0.01 }, baseUsage)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports no change for deep-equal usages', () => {
|
||||
expect(usageChanged(baseUsage, { ...baseUsage })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { shouldShowResponseSeparator, shouldShowThinkingTrail } from '../components/messageLine.js'
|
||||
|
||||
describe('shouldShowResponseSeparator', () => {
|
||||
it('separates assistant response text from visible details', () => {
|
||||
expect(shouldShowResponseSeparator({ role: 'assistant', text: 'final', thinking: 'plan' }, true)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not add a response separator without details or body text', () => {
|
||||
expect(shouldShowResponseSeparator({ role: 'assistant', text: 'final' }, false)).toBe(false)
|
||||
expect(shouldShowResponseSeparator({ role: 'assistant', text: ' ', thinking: 'plan' }, true)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not add response separators to non-assistant transcript rows', () => {
|
||||
expect(shouldShowResponseSeparator({ role: 'user', text: 'prompt' }, true)).toBe(false)
|
||||
expect(shouldShowResponseSeparator({ role: 'system', text: 'note' }, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldShowThinkingTrail', () => {
|
||||
it('hides an ordinary reasoning trail when every section is hidden', () => {
|
||||
const msg = { role: 'system', text: '', thinking: 'plan' } as const
|
||||
expect(shouldShowThinkingTrail(msg, 'hidden', 'hidden', 'hidden')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows an ordinary reasoning trail when any section is visible', () => {
|
||||
const msg = { role: 'system', text: '', thinking: 'plan' } as const
|
||||
expect(shouldShowThinkingTrail(msg, 'collapsed', 'hidden', 'hidden')).toBe(true)
|
||||
expect(shouldShowThinkingTrail(msg, 'hidden', 'collapsed', 'hidden')).toBe(true)
|
||||
expect(shouldShowThinkingTrail(msg, 'hidden', 'hidden', 'expanded')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a MoA reference block visible even when every section is hidden (#64657)', () => {
|
||||
const msg = {
|
||||
role: 'system',
|
||||
text: '',
|
||||
thinking: '◇ Reference 1/2 — model-a\nadvice-a',
|
||||
isMoaReference: true
|
||||
} as const
|
||||
|
||||
expect(shouldShowThinkingTrail(msg, 'hidden', 'hidden', 'hidden')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,273 @@
|
||||
import { PassThrough } from 'stream'
|
||||
|
||||
import { renderSync } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { fmtMsgTimestamp, MessageLine } from '../components/messageLine.js'
|
||||
import { MAX_HISTORY } from '../config/limits.js'
|
||||
import { toTranscriptMessages } from '../domain/messages.js'
|
||||
import { appendTranscriptMessage, capTranscriptHistory, upsert } from '../lib/messages.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
describe('toTranscriptMessages', () => {
|
||||
it('preserves assistant tool-call rows so resume does not drop prior turns', () => {
|
||||
const rows = [
|
||||
{ role: 'user', text: 'first prompt' },
|
||||
{ role: 'tool', context: 'repo', name: 'search_files', text: 'ignored raw result' },
|
||||
{ role: 'assistant', text: 'first answer' },
|
||||
{ role: 'user', text: 'second prompt' }
|
||||
]
|
||||
|
||||
expect(toTranscriptMessages(rows).map(msg => [msg.role, msg.text])).toEqual([
|
||||
['user', 'first prompt'],
|
||||
['assistant', 'first answer'],
|
||||
['user', 'second prompt']
|
||||
])
|
||||
expect(toTranscriptMessages(rows)[1]?.tools?.[0]).toContain('Search Files')
|
||||
})
|
||||
|
||||
it('skips hidden display_kind rows entirely', () => {
|
||||
const rows = [
|
||||
{ role: 'user', text: 'visible prompt' },
|
||||
{ role: 'user', text: '[CONTEXT COMPACTION — REFERENCE ONLY]', display_kind: 'hidden' },
|
||||
{ role: 'assistant', text: 'visible reply' }
|
||||
]
|
||||
|
||||
const result = toTranscriptMessages(rows)
|
||||
expect(result.map(msg => msg.text)).toEqual(['visible prompt', 'visible reply'])
|
||||
expect(result.every(m => !m.text?.includes('COMPACTION'))).toBe(true)
|
||||
})
|
||||
|
||||
it('projects model_switch as an event with replaced text', () => {
|
||||
const rows = [
|
||||
{ role: 'user', text: 'hello' },
|
||||
{ role: 'user', text: '[System: model changed to gpt-5]', display_kind: 'model_switch' },
|
||||
{ role: 'assistant', text: 'hi' }
|
||||
]
|
||||
|
||||
const result = toTranscriptMessages(rows)
|
||||
expect(result.map(msg => [msg.kind, msg.role, msg.text])).toEqual([
|
||||
[undefined, 'user', 'hello'],
|
||||
['event', 'system', 'model changed'],
|
||||
[undefined, 'assistant', 'hi']
|
||||
])
|
||||
})
|
||||
|
||||
it('projects async_delegation_complete with task_count metadata', () => {
|
||||
const rows = [
|
||||
{ role: 'user', text: 'do work' },
|
||||
{ role: 'assistant', text: 'done' },
|
||||
{
|
||||
role: 'user',
|
||||
text: '[IMPORTANT: delegation done]',
|
||||
display_kind: 'async_delegation_complete',
|
||||
display_metadata: { task_count: 3 }
|
||||
},
|
||||
{ role: 'assistant', text: 'merged' }
|
||||
]
|
||||
|
||||
const result = toTranscriptMessages(rows)
|
||||
expect(result.map(msg => [msg.kind, msg.text])).toEqual([
|
||||
[undefined, 'do work'],
|
||||
[undefined, 'done'],
|
||||
['event', '3 background agents finished'],
|
||||
[undefined, 'merged']
|
||||
])
|
||||
})
|
||||
|
||||
it('projects async_delegation_complete without metadata as generic text', () => {
|
||||
const rows = [{ role: 'user', text: 'event', display_kind: 'async_delegation_complete' }]
|
||||
|
||||
const result = toTranscriptMessages(rows)
|
||||
expect(result[0]?.kind).toBe('event')
|
||||
expect(result[0]?.text).toBe('background agent work finished')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageLine', () => {
|
||||
it('preserves a separator after compound user prompt glyphs in transcript rows', () => {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const t = {
|
||||
...DEFAULT_THEME,
|
||||
brand: { ...DEFAULT_THEME.brand, prompt: 'Ψ >' }
|
||||
}
|
||||
|
||||
const instance = renderSync(
|
||||
React.createElement(MessageLine, {
|
||||
cols: 80,
|
||||
msg: { role: 'user', text: 'Okay' },
|
||||
t
|
||||
}),
|
||||
{
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
}
|
||||
)
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
|
||||
const renderedLine = stripAnsi(output)
|
||||
.split('\n')
|
||||
.find(line => line.includes('Okay'))
|
||||
|
||||
expect(renderedLine).toContain('Ψ > Okay')
|
||||
})
|
||||
|
||||
it('keeps historical thinking blocks collapsed by default', () => {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(
|
||||
React.createElement(MessageLine, {
|
||||
cols: 80,
|
||||
msg: { kind: 'trail', role: 'system', text: '', thinking: 'step one\nstep two' },
|
||||
t: DEFAULT_THEME
|
||||
}),
|
||||
{
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
}
|
||||
)
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
|
||||
const rendered = stripAnsi(output)
|
||||
|
||||
expect(rendered).toContain('Thinking')
|
||||
expect(rendered).not.toContain('step one')
|
||||
expect(rendered).not.toContain('step two')
|
||||
})
|
||||
|
||||
it('keeps live thinking blocks expanded while streaming', () => {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(
|
||||
React.createElement(MessageLine, {
|
||||
cols: 80,
|
||||
liveDetails: true,
|
||||
msg: { kind: 'trail', role: 'system', text: '', thinking: 'step one\nstep two' },
|
||||
t: DEFAULT_THEME
|
||||
}),
|
||||
{
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
}
|
||||
)
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
|
||||
const rendered = stripAnsi(output)
|
||||
|
||||
expect(rendered).toContain('Thinking')
|
||||
expect(rendered).toContain('step one')
|
||||
expect(rendered).toContain('step two')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsert', () => {
|
||||
it('appends when last role differs', () => {
|
||||
expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('replaces when last role matches', () => {
|
||||
expect(upsert([{ role: 'assistant', text: 'partial' }], 'assistant', 'full')[0]!.text).toBe('full')
|
||||
})
|
||||
|
||||
it('appends to empty', () => {
|
||||
expect(upsert([], 'user', 'first')).toEqual([{ role: 'user', text: 'first' }])
|
||||
})
|
||||
|
||||
it('does not mutate', () => {
|
||||
const prev = [{ role: 'user' as const, text: 'hi' }]
|
||||
upsert(prev, 'assistant', 'yo')
|
||||
expect(prev).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('capTranscriptHistory', () => {
|
||||
it('keeps the intro and the newest bounded display rows', () => {
|
||||
const intro = { kind: 'intro' as const, role: 'system' as const, text: '' }
|
||||
const rows = Array.from({ length: 1_005 }, (_, index) => ({ role: 'user' as const, text: `m${index}` }))
|
||||
const capped = capTranscriptHistory([intro, ...rows])
|
||||
|
||||
expect(capped).toHaveLength(MAX_HISTORY)
|
||||
expect(capped[0]).toBe(intro)
|
||||
expect(capped[1]?.text).toBe(`m${rows.length - (MAX_HISTORY - 1)}`)
|
||||
expect(capped.at(-1)?.text).toBe('m1004')
|
||||
})
|
||||
})
|
||||
|
||||
describe('display.timestamps (#41531)', () => {
|
||||
it('formats a Unix-seconds timestamp as [HH:MM] and rejects garbage', () => {
|
||||
const noon = new Date()
|
||||
noon.setHours(13, 5, 0, 0)
|
||||
|
||||
expect(fmtMsgTimestamp(noon.getTime() / 1000)).toBe('[13:05]')
|
||||
expect(fmtMsgTimestamp(undefined)).toBeNull()
|
||||
expect(fmtMsgTimestamp(0)).toBeNull()
|
||||
expect(fmtMsgTimestamp(Number.NaN)).toBeNull()
|
||||
})
|
||||
|
||||
it('threads persisted transcript timestamps onto rehydrated rows', () => {
|
||||
const rows = [
|
||||
{ role: 'user', text: 'when was this', timestamp: 1_750_000_000 },
|
||||
{ role: 'assistant', text: 'right then', timestamp: 1_750_000_060 }
|
||||
]
|
||||
|
||||
const result = toTranscriptMessages(rows)
|
||||
expect(result[0]?.createdAt).toBe(1_750_000_000)
|
||||
expect(result[1]?.createdAt).toBe(1_750_000_060)
|
||||
})
|
||||
|
||||
it('stamps live rows at append and preserves supplied times', () => {
|
||||
const before = Date.now() / 1000
|
||||
const [live] = appendTranscriptMessage([], { role: 'user', text: 'now' })
|
||||
|
||||
expect(live?.createdAt).toBeGreaterThanOrEqual(before - 1)
|
||||
expect(live?.createdAt).toBeLessThanOrEqual(Date.now() / 1000 + 1)
|
||||
|
||||
const [kept] = appendTranscriptMessage([], { createdAt: 123, role: 'user', text: 'then' })
|
||||
expect(kept?.createdAt).toBe(123)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js'
|
||||
import { resetOverlayState } from '../app/overlayStore.js'
|
||||
import { turnController } from '../app/turnController.js'
|
||||
import { getTurnState, resetTurnState } from '../app/turnStore.js'
|
||||
import { patchUiState, resetUiState } from '../app/uiStore.js'
|
||||
import type { Msg } from '../types.js'
|
||||
|
||||
const ref = <T>(current: T) => ({ current })
|
||||
|
||||
const buildCtx = (appended: Msg[]) =>
|
||||
({
|
||||
composer: {
|
||||
dequeue: () => undefined,
|
||||
queueEditRef: ref<null | number>(null),
|
||||
sendQueued: () => undefined,
|
||||
setInput: () => undefined
|
||||
},
|
||||
gateway: {
|
||||
gw: { request: () => undefined },
|
||||
rpc: async () => null
|
||||
},
|
||||
session: {
|
||||
STARTUP_RESUME_ID: '',
|
||||
colsRef: ref(80),
|
||||
newSession: () => undefined,
|
||||
resetSession: () => undefined,
|
||||
resumeById: () => undefined,
|
||||
setCatalog: () => undefined
|
||||
},
|
||||
submission: {
|
||||
submitRef: { current: () => undefined }
|
||||
},
|
||||
system: {
|
||||
bellOnComplete: false,
|
||||
sys: () => undefined
|
||||
},
|
||||
transcript: {
|
||||
appendMessage: (msg: Msg) => appended.push(msg),
|
||||
panel: () => undefined,
|
||||
setHistoryItems: () => undefined
|
||||
},
|
||||
voice: {
|
||||
setProcessing: () => undefined,
|
||||
setRecording: () => undefined,
|
||||
setVoiceEnabled: () => undefined
|
||||
}
|
||||
}) as any
|
||||
|
||||
const activityTexts = () => getTurnState().activity.map(item => item.text)
|
||||
|
||||
describe('moa.progress / moa.phase activity surface', () => {
|
||||
beforeEach(() => {
|
||||
resetOverlayState()
|
||||
resetUiState()
|
||||
resetTurnState()
|
||||
turnController.fullReset()
|
||||
patchUiState({ showReasoning: true })
|
||||
})
|
||||
|
||||
it('shows "MoA: refs k/n" as each reference completes, replacing in place', () => {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
onEvent({ payload: { label: 'model-a', refs_done: 1, refs_total: 3 }, type: 'moa.progress' } as any)
|
||||
|
||||
expect(activityTexts()).toContain('MoA: refs 1/3')
|
||||
|
||||
onEvent({ payload: { label: 'model-b', refs_done: 2, refs_total: 3 }, type: 'moa.progress' } as any)
|
||||
|
||||
const texts = activityTexts()
|
||||
expect(texts).toContain('MoA: refs 2/3')
|
||||
// Replaced in place — the stale 1/3 line must not linger alongside 2/3.
|
||||
expect(texts).not.toContain('MoA: refs 1/3')
|
||||
})
|
||||
|
||||
it('swaps the progress line for aggregator copy on moa.phase', () => {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
onEvent({ payload: { label: 'model-a', refs_done: 3, refs_total: 3 }, type: 'moa.progress' } as any)
|
||||
onEvent({ payload: { phase: 'aggregator', refs_done: 3, refs_total: 3 }, type: 'moa.phase' } as any)
|
||||
|
||||
const texts = activityTexts()
|
||||
expect(texts).toContain('MoA: aggregating…')
|
||||
expect(texts).not.toContain('MoA: refs 3/3')
|
||||
})
|
||||
|
||||
it('ignores malformed payloads (missing counters / unknown phase)', () => {
|
||||
const onEvent = createGatewayEventHandler(buildCtx([]))
|
||||
|
||||
onEvent({ payload: {}, type: 'message.start' } as any)
|
||||
const before = activityTexts().length
|
||||
onEvent({ payload: { label: 'model-a' }, type: 'moa.progress' } as any)
|
||||
onEvent({ payload: { phase: 'reference' }, type: 'moa.phase' } as any)
|
||||
|
||||
expect(activityTexts().length).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { providerIndexAfterClearingFilter } from '../components/modelPicker.js'
|
||||
import type { ModelOptionProvider } from '../gatewayTypes.js'
|
||||
|
||||
const provider = (slug: string, name = slug): ModelOptionProvider => ({ name, slug })
|
||||
|
||||
describe('ModelPicker provider filtering', () => {
|
||||
it('keeps the selected provider when clearing the provider filter', () => {
|
||||
const nous = provider('nous', 'Nous Portal')
|
||||
const ollama = provider('ollama-cloud', 'Ollama Cloud')
|
||||
|
||||
const rows = [
|
||||
{ name: nous.name, provider: nous },
|
||||
{ name: ollama.name, provider: ollama }
|
||||
]
|
||||
|
||||
// With a provider-stage filter like "ollama", the selected row is index 0
|
||||
// in the filtered list, but index 1 in the full list after setFilter('').
|
||||
expect(providerIndexAfterClearingFilter(rows, ollama)).toBe(1)
|
||||
})
|
||||
|
||||
it('returns -1 when provider is undefined', () => {
|
||||
const rows = [{ name: 'A', provider: provider('a') }]
|
||||
|
||||
expect(providerIndexAfterClearingFilter(rows, undefined)).toBe(-1)
|
||||
})
|
||||
|
||||
it('returns -1 when provider slug is not in rows', () => {
|
||||
const rows = [
|
||||
{ name: 'A', provider: provider('a') },
|
||||
{ name: 'B', provider: provider('b') }
|
||||
]
|
||||
|
||||
expect(providerIndexAfterClearingFilter(rows, provider('missing'))).toBe(-1)
|
||||
})
|
||||
|
||||
it('returns -1 for empty rows', () => {
|
||||
expect(providerIndexAfterClearingFilter([], provider('a'))).toBe(-1)
|
||||
})
|
||||
|
||||
it('finds the first match when multiple rows share a slug', () => {
|
||||
const p = provider('dup')
|
||||
|
||||
const rows = [
|
||||
{ name: 'First', provider: p },
|
||||
{ name: 'Second', provider: p }
|
||||
]
|
||||
|
||||
expect(providerIndexAfterClearingFilter(rows, p)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { startPromptLiveSession } from '../app/useMainApp.js'
|
||||
|
||||
describe('startPromptLiveSession', () => {
|
||||
it('starts a kept-live session with generated id/title, applies selected model, then dispatches the prompt', async () => {
|
||||
const calls: Array<[string, unknown]> = []
|
||||
|
||||
const sid = await startPromptLiveSession({
|
||||
dispatchSubmission: prompt => calls.push(['dispatch', prompt]),
|
||||
maybeWarn: value => calls.push(['warn', value]),
|
||||
modelArg: 'kimi-k2.6 --provider ollama-cloud',
|
||||
newLiveSession: async (message, title) => {
|
||||
calls.push(['new', { message, title }])
|
||||
|
||||
return 'abc123'
|
||||
},
|
||||
onModelSwitched: (value, result) => calls.push(['model-switched', { result, value }]),
|
||||
prompt: ' Build the thing ',
|
||||
rpc: async (method, params) => {
|
||||
calls.push(['rpc', { method, params }])
|
||||
|
||||
return { value: 'kimi-k2.6', warning: '' }
|
||||
},
|
||||
sys: text => calls.push(['sys', text])
|
||||
})
|
||||
|
||||
expect(sid).toBe('abc123')
|
||||
expect(calls).toEqual([
|
||||
['new', { message: 'new live session started', title: undefined }],
|
||||
[
|
||||
'rpc',
|
||||
{
|
||||
method: 'config.set',
|
||||
params: { key: 'model', session_id: 'abc123', value: 'kimi-k2.6 --provider ollama-cloud --session' }
|
||||
}
|
||||
],
|
||||
['sys', 'model → kimi-k2.6'],
|
||||
['warn', { value: 'kimi-k2.6', warning: '' }],
|
||||
['model-switched', { result: { value: 'kimi-k2.6', warning: '' }, value: 'kimi-k2.6' }],
|
||||
['dispatch', 'Build the thing']
|
||||
])
|
||||
})
|
||||
|
||||
it('does not start a session for an empty prompt', async () => {
|
||||
const calls: string[] = []
|
||||
|
||||
const sid = await startPromptLiveSession({
|
||||
dispatchSubmission: () => calls.push('dispatch'),
|
||||
maybeWarn: () => calls.push('warn'),
|
||||
newLiveSession: async () => {
|
||||
calls.push('new')
|
||||
|
||||
return 'abc123'
|
||||
},
|
||||
prompt: ' ',
|
||||
rpc: async () => ({ value: 'unused' }),
|
||||
sys: () => calls.push('sys')
|
||||
})
|
||||
|
||||
expect(sid).toBeNull()
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
buildOsc52ClipboardQuery,
|
||||
OSC52_CLIPBOARD_QUERY,
|
||||
parseOsc52ClipboardData,
|
||||
readOsc52Clipboard,
|
||||
writeOsc52Clipboard
|
||||
} from '../lib/osc52.js'
|
||||
|
||||
const envBackup = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...envBackup }
|
||||
})
|
||||
|
||||
describe('buildOsc52ClipboardQuery', () => {
|
||||
it('returns the raw OSC52 query outside multiplexers', () => {
|
||||
delete process.env.TMUX
|
||||
delete process.env.STY
|
||||
|
||||
expect(buildOsc52ClipboardQuery()).toBe(OSC52_CLIPBOARD_QUERY)
|
||||
})
|
||||
|
||||
it('wraps the query for tmux passthrough', () => {
|
||||
process.env.TMUX = '/tmp/tmux-123/default,1,0'
|
||||
|
||||
expect(buildOsc52ClipboardQuery()).toContain('\x1bPtmux;')
|
||||
expect(buildOsc52ClipboardQuery()).toContain(']52;c;?')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseOsc52ClipboardData', () => {
|
||||
it('decodes clipboard payloads', () => {
|
||||
const encoded = Buffer.from('hello from osc52', 'utf8').toString('base64')
|
||||
|
||||
expect(parseOsc52ClipboardData(`c;${encoded}`)).toBe('hello from osc52')
|
||||
})
|
||||
|
||||
it('returns null for empty or query payloads', () => {
|
||||
expect(parseOsc52ClipboardData('c;?')).toBeNull()
|
||||
expect(parseOsc52ClipboardData('c;')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readOsc52Clipboard', () => {
|
||||
it('returns decoded text from a terminal OSC52 response', async () => {
|
||||
const send = vi.fn().mockResolvedValue({
|
||||
code: 52,
|
||||
data: `c;${Buffer.from('queried text', 'utf8').toString('base64')}`,
|
||||
type: 'osc'
|
||||
})
|
||||
|
||||
const flush = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await expect(readOsc52Clipboard({ flush, send })).resolves.toBe('queried text')
|
||||
expect(send).toHaveBeenCalled()
|
||||
expect(flush).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns null when the querier is missing or unsupported', async () => {
|
||||
await expect(readOsc52Clipboard(null)).resolves.toBeNull()
|
||||
|
||||
const send = vi.fn().mockResolvedValue(undefined)
|
||||
const flush = vi.fn().mockResolvedValue(undefined)
|
||||
await expect(readOsc52Clipboard({ flush, send })).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeOsc52Clipboard', () => {
|
||||
it('wraps writes for tmux passthrough', () => {
|
||||
process.env.TMUX = '/tmp/tmux-123/default,1,0'
|
||||
delete process.env.STY
|
||||
|
||||
const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true as any)
|
||||
writeOsc52Clipboard('hello')
|
||||
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
const seq = String(write.mock.calls[0]?.[0] ?? '')
|
||||
expect(seq).toContain('\x1bPtmux;')
|
||||
expect(seq).toContain(']52;c;')
|
||||
|
||||
write.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps raw OSC52 outside multiplexers', () => {
|
||||
delete process.env.TMUX
|
||||
delete process.env.STY
|
||||
|
||||
const write = vi.spyOn(process.stdout, 'write').mockImplementation(() => true as any)
|
||||
writeOsc52Clipboard('hello')
|
||||
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
const seq = String(write.mock.calls[0]?.[0] ?? '')
|
||||
expect(seq.startsWith('\x1b]52;c;')).toBe(true)
|
||||
|
||||
write.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { clampOverlayWidth } from '../components/overlayPrimitives.js'
|
||||
|
||||
describe('clampOverlayWidth', () => {
|
||||
it('prefers preferred, capped by maxWidth', () => {
|
||||
expect(clampOverlayWidth(60)).toBe(60)
|
||||
expect(clampOverlayWidth(60, 40)).toBe(40)
|
||||
expect(clampOverlayWidth(30, 80)).toBe(30)
|
||||
})
|
||||
|
||||
it('honors caps BELOW the usability floor instead of overflowing the cell', () => {
|
||||
// Copilot review on #20379: a 20-col grid cell must get 20, not 24.
|
||||
expect(clampOverlayWidth(60, 20)).toBe(20)
|
||||
expect(clampOverlayWidth(60, 1)).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the floor when the cap allows it', () => {
|
||||
expect(clampOverlayWidth(10, 80)).toBe(24)
|
||||
expect(clampOverlayWidth(10)).toBe(24)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// parentLog gates itself off under VITEST so unit tests can't pollute a real
|
||||
// ~/.hermes. To exercise the real persistence path we clear that gate, point
|
||||
// HERMES_HOME at a temp dir, and re-import the module fresh (path + enabled
|
||||
// flag are captured at module load).
|
||||
const loadFresh = async (home: string) => {
|
||||
vi.resetModules()
|
||||
vi.stubEnv('VITEST', '')
|
||||
vi.stubEnv('HERMES_HOME', home)
|
||||
|
||||
return import('../lib/parentLog.js')
|
||||
}
|
||||
|
||||
describe('recordParentLifecycle', () => {
|
||||
let home: string
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'hermes-parentlog-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
rmSync(home, { force: true, recursive: true })
|
||||
})
|
||||
|
||||
it('appends a timestamped breadcrumb to logs/tui_gateway_crash.log', async () => {
|
||||
const { recordParentLifecycle } = await loadFresh(home)
|
||||
|
||||
recordParentLifecycle('graceful-exit received signal=SIGHUP → killing gateway')
|
||||
|
||||
const contents = readFileSync(join(home, 'logs', 'tui_gateway_crash.log'), 'utf8')
|
||||
|
||||
expect(contents).toContain('[tui-parent]')
|
||||
expect(contents).toContain('graceful-exit received signal=SIGHUP → killing gateway')
|
||||
expect(contents).toMatch(/\d{4}-\d{2}-\d{2}T/)
|
||||
})
|
||||
|
||||
it('collapses embedded newlines so a value stays one breadcrumb', async () => {
|
||||
const { recordParentLifecycle } = await loadFresh(home)
|
||||
|
||||
recordParentLifecycle('uncaughtException: boom\n at foo()\r\n at bar()')
|
||||
|
||||
const lines = readFileSync(join(home, 'logs', 'tui_gateway_crash.log'), 'utf8')
|
||||
.trimEnd()
|
||||
.split('\n')
|
||||
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0]).toContain('boom ↵ at foo() ↵ at bar()')
|
||||
})
|
||||
|
||||
it('caps an oversized breadcrumb so it cannot bloat the shared crash log', async () => {
|
||||
const { recordParentLifecycle } = await loadFresh(home)
|
||||
|
||||
recordParentLifecycle('x'.repeat(10_000))
|
||||
|
||||
const line = readFileSync(join(home, 'logs', 'tui_gateway_crash.log'), 'utf8')
|
||||
|
||||
expect(line).toContain('[truncated 10000 chars]')
|
||||
expect(line.length).toBeLessThan(4_500)
|
||||
})
|
||||
|
||||
it('is a no-op under VITEST so tests stay hermetic', async () => {
|
||||
vi.resetModules()
|
||||
vi.stubEnv('VITEST', 'true')
|
||||
vi.stubEnv('HERMES_HOME', home)
|
||||
|
||||
const { recordParentLifecycle } = await import('../lib/parentLog.js')
|
||||
|
||||
expect(() => recordParentLifecycle('should not be written')).not.toThrow()
|
||||
expect(() => readFileSync(join(home, 'logs', 'tui_gateway_crash.log'), 'utf8')).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { composeTabTitle, fmtCwdBranch, fmtProjectCwdBranch, shortCwd, shortProject } from '../domain/paths.js'
|
||||
|
||||
describe('shortCwd', () => {
|
||||
const origHome = process.env.HOME
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.HOME = '/Users/bb'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = origHome
|
||||
})
|
||||
|
||||
it('collapses HOME to ~', () => {
|
||||
expect(shortCwd('/Users/bb/proj/repo')).toBe('~/proj/repo')
|
||||
})
|
||||
|
||||
it('leaves non-HOME paths alone', () => {
|
||||
expect(shortCwd('/tmp/work')).toBe('/tmp/work')
|
||||
})
|
||||
|
||||
it('truncates long paths from the left with ellipsis', () => {
|
||||
const out = shortCwd('/var/long/deeply/nested/workspace/here', 10)
|
||||
expect(out.startsWith('…')).toBe(true)
|
||||
expect(out.length).toBe(10)
|
||||
expect('/var/long/deeply/nested/workspace/here'.endsWith(out.slice(1))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps paths shorter than max intact', () => {
|
||||
expect(shortCwd('/a/b', 10)).toBe('/a/b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fmtCwdBranch', () => {
|
||||
const origHome = process.env.HOME
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.HOME = '/Users/bb'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = origHome
|
||||
})
|
||||
|
||||
it('returns bare cwd when branch is null', () => {
|
||||
expect(fmtCwdBranch('/Users/bb/proj', null)).toBe('~/proj')
|
||||
})
|
||||
|
||||
it('returns bare cwd when branch is empty', () => {
|
||||
expect(fmtCwdBranch('/Users/bb/proj', '')).toBe('~/proj')
|
||||
})
|
||||
|
||||
it('appends branch in parens', () => {
|
||||
expect(fmtCwdBranch('/Users/bb/proj', 'main')).toBe('~/proj (main)')
|
||||
})
|
||||
|
||||
it('truncates the path to keep the branch tag readable', () => {
|
||||
const out = fmtCwdBranch('/Users/bb/very/deeply/nested/project/folder', 'feature-branch', 30)
|
||||
expect(out).toMatch(/ \(feature-branch\)$/)
|
||||
expect(out.length).toBeLessThanOrEqual(30)
|
||||
})
|
||||
|
||||
it('truncates very long branch names from the right', () => {
|
||||
const out = fmtCwdBranch('/Users/bb/p', 'a-very-long-feature-branch-name')
|
||||
expect(out).toMatch(/^~\/p \(…/)
|
||||
expect(out).toContain(')')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shortProject', () => {
|
||||
it('trims whitespace', () => {
|
||||
expect(shortProject(' website ')).toBe('website')
|
||||
})
|
||||
|
||||
it('truncates long project names from the right', () => {
|
||||
expect(shortProject('a-very-long-project-name', 10)).toBe('a-very-lo…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fmtProjectCwdBranch', () => {
|
||||
const origHome = process.env.HOME
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.HOME = '/Users/bb'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = origHome
|
||||
})
|
||||
|
||||
it('prefixes the cwd/branch label with the project name', () => {
|
||||
expect(fmtProjectCwdBranch('/Users/bb/proj', 'main', 'website', 28)).toBe('website · ~/proj (main)')
|
||||
})
|
||||
|
||||
it('falls back to the cwd/branch label when no project is known', () => {
|
||||
expect(fmtProjectCwdBranch('/Users/bb/proj', 'main', null, 28)).toBe('~/proj (main)')
|
||||
})
|
||||
|
||||
it('keeps the project visible when space is tight', () => {
|
||||
expect(fmtProjectCwdBranch('/Users/bb/proj', 'main', 'hermes-agent', 12)).toBe('hermes-agent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('composeTabTitle', () => {
|
||||
it('joins marker, name, model, and cwd in order', () => {
|
||||
expect(composeTabTitle('✓', 'auth refactor', 'opus-4', '~/proj')).toBe('✓ auth refactor · opus-4 · ~/proj')
|
||||
})
|
||||
|
||||
it('glues the marker to the first segment with a space, not a separator', () => {
|
||||
expect(composeTabTitle('⏳', 'my session', 'opus-4', '~/proj').startsWith('⏳ my session')).toBe(true)
|
||||
})
|
||||
|
||||
it('omits the session name when empty (matches the pre-name format)', () => {
|
||||
expect(composeTabTitle('✓', '', 'opus-4', '~/proj')).toBe('✓ opus-4 · ~/proj')
|
||||
})
|
||||
|
||||
it('treats a whitespace-only name as absent', () => {
|
||||
expect(composeTabTitle('✓', ' ', 'opus-4', '~/proj')).toBe('✓ opus-4 · ~/proj')
|
||||
})
|
||||
|
||||
it('omits the cwd when empty', () => {
|
||||
expect(composeTabTitle('✓', 'my session', 'opus-4', '')).toBe('✓ my session · opus-4')
|
||||
})
|
||||
|
||||
it('falls back to just the marker when only the marker is present', () => {
|
||||
expect(composeTabTitle('✓', '', '', '')).toBe('✓')
|
||||
})
|
||||
|
||||
it('truncates an over-long session name with an ellipsis', () => {
|
||||
const long = 'a'.repeat(40)
|
||||
const out = composeTabTitle('✓', long, 'opus-4', '', 28)
|
||||
const namePart = out.slice('✓ '.length).split(' · ')[0]
|
||||
expect(namePart.endsWith('…')).toBe(true)
|
||||
expect(namePart.length).toBe(28)
|
||||
})
|
||||
|
||||
it('keeps a name at the boundary length intact', () => {
|
||||
const name = 'b'.repeat(28)
|
||||
const out = composeTabTitle('✓', name, 'opus-4', '', 28)
|
||||
expect(out).toBe(`✓ ${name} · opus-4`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { PassThrough } from 'stream'
|
||||
|
||||
import { Box, renderSync } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { usePet } from '../app/usePet.js'
|
||||
import { PetPane } from '../components/appLayout.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
|
||||
vi.mock('../app/usePet.js', () => ({
|
||||
usePet: vi.fn()
|
||||
}))
|
||||
|
||||
const opaqueCell = [255, 0, 0, 255, 0, 0, 255, 255]
|
||||
|
||||
const PET_GLYPHS = new Set(['▀', '▄', '█'])
|
||||
|
||||
const firstGlyphCol = (line: string) => [...line].findIndex(ch => PET_GLYPHS.has(ch))
|
||||
|
||||
const renderFrame = (element: React.ReactElement, columns = 40) => {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns, isTTY: false, rows: 12 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(element, {
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
|
||||
return stripAnsi(output)
|
||||
.split('\n')
|
||||
.map(line => line.replace(/\s+$/, ''))
|
||||
}
|
||||
|
||||
describe('PetPane', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(usePet).mockReset()
|
||||
})
|
||||
|
||||
it('overlays the bottom-right corner with a flat, right-aligned sprite', () => {
|
||||
const columns = 40
|
||||
vi.mocked(usePet).mockReturnValue({
|
||||
enabled: true,
|
||||
grid: [
|
||||
[opaqueCell, opaqueCell],
|
||||
[opaqueCell, opaqueCell]
|
||||
],
|
||||
kitty: null
|
||||
})
|
||||
|
||||
const lines = renderFrame(
|
||||
<Box flexDirection="column" height={8} position="relative" width={columns}>
|
||||
<PetPane />
|
||||
</Box>,
|
||||
columns
|
||||
)
|
||||
|
||||
const cols = lines.map(firstGlyphCol).filter(col => col >= 0)
|
||||
expect(cols.length).toBeGreaterThanOrEqual(2)
|
||||
// Flat (no per-row drift) and right-aligned (a corner block, not full width).
|
||||
expect(new Set(cols).size).toBe(1)
|
||||
expect(cols[0]).toBeGreaterThan(columns / 2)
|
||||
})
|
||||
|
||||
it('renders nothing when disabled', () => {
|
||||
vi.mocked(usePet).mockReturnValue({ enabled: false, grid: null, kitty: null })
|
||||
|
||||
const lines = renderFrame(
|
||||
<Box flexDirection="column" height={8} position="relative" width={40}>
|
||||
<PetPane />
|
||||
</Box>
|
||||
)
|
||||
|
||||
expect(lines.every(line => firstGlyphCol(line) < 0)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createPetSingleFlight, requestPetUpdate } from '../lib/petPolling.js'
|
||||
|
||||
const gateway = (request: ReturnType<typeof vi.fn>) => ({ request }) as never
|
||||
|
||||
describe('requestPetUpdate', () => {
|
||||
it('does not enqueue pet.cells while pets are disabled', async () => {
|
||||
const request = vi.fn().mockResolvedValue({ enabled: false })
|
||||
const needsCells = vi.fn(() => true)
|
||||
|
||||
const update = await requestPetUpdate(gateway(request), 'idle', false, needsCells)
|
||||
|
||||
expect(update).toEqual({ cells: null, meta: { enabled: false } })
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(request).toHaveBeenCalledWith('pet.info.meta')
|
||||
expect(needsCells).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses metadata only when the enabled state is already cached', async () => {
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
enabled: true,
|
||||
scale: 0.33,
|
||||
slug: 'boba',
|
||||
spritesheetRevision: '1:2'
|
||||
})
|
||||
|
||||
const update = await requestPetUpdate(gateway(request), 'idle', false, () => false)
|
||||
|
||||
expect(update?.cells).toBeNull()
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fetches cells only for an enabled uncached state', async () => {
|
||||
const cells = { enabled: true, frames: [], slug: 'boba' }
|
||||
const request = vi.fn().mockResolvedValueOnce({ enabled: true, slug: 'boba' }).mockResolvedValueOnce(cells)
|
||||
|
||||
const update = await requestPetUpdate(gateway(request), 'review', false, () => true)
|
||||
|
||||
expect(update?.cells).toEqual(cells)
|
||||
expect(request).toHaveBeenNthCalledWith(1, 'pet.info.meta')
|
||||
expect(request).toHaveBeenNthCalledWith(2, 'pet.cells', {
|
||||
graphics: false,
|
||||
state: 'review'
|
||||
})
|
||||
})
|
||||
|
||||
it('silently drops cosmetic gateway failures', async () => {
|
||||
const request = vi.fn().mockRejectedValue(new Error('timeout: pet.info.meta'))
|
||||
|
||||
await expect(requestPetUpdate(gateway(request), 'idle', false, () => true)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPetSingleFlight', () => {
|
||||
it('suppresses overlapping polls and permits the next completed poll', async () => {
|
||||
let release = () => undefined
|
||||
|
||||
const blocked = new Promise<void>(resolve => {
|
||||
release = resolve
|
||||
})
|
||||
|
||||
const operation = vi.fn(() => blocked)
|
||||
const run = createPetSingleFlight()
|
||||
|
||||
const first = run(operation)
|
||||
await expect(run(operation)).resolves.toBe(false)
|
||||
expect(operation).toHaveBeenCalledTimes(1)
|
||||
|
||||
release()
|
||||
await expect(first).resolves.toBe(true)
|
||||
await expect(run(async () => undefined)).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,560 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
async function importPlatform(platform: NodeJS.Platform) {
|
||||
vi.resetModules()
|
||||
Object.defineProperty(process, 'platform', { value: platform })
|
||||
|
||||
return import('../lib/platform.js')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('platform action modifier', () => {
|
||||
it('treats kitty Cmd sequences as the macOS action modifier', async () => {
|
||||
const { isActionMod } = await importPlatform('darwin')
|
||||
|
||||
expect(isActionMod({ ctrl: false, meta: false, super: true })).toBe(true)
|
||||
expect(isActionMod({ ctrl: false, meta: true, super: false })).toBe(true)
|
||||
expect(isActionMod({ ctrl: true, meta: false, super: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('still uses Ctrl as the action modifier on non-macOS', async () => {
|
||||
const { isActionMod } = await importPlatform('linux')
|
||||
|
||||
expect(isActionMod({ ctrl: true, meta: false, super: false })).toBe(true)
|
||||
expect(isActionMod({ ctrl: false, meta: false, super: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCopyShortcut', () => {
|
||||
it('keeps Ctrl+C as the local non-macOS copy chord', async () => {
|
||||
const { isCopyShortcut } = await importPlatform('linux')
|
||||
|
||||
expect(isCopyShortcut({ ctrl: true, meta: false, super: false }, 'c', {})).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts client Cmd+C over SSH even when running on Linux', async () => {
|
||||
const { isCopyShortcut } = await importPlatform('linux')
|
||||
const env = { SSH_CONNECTION: '1 2 3 4' } as NodeJS.ProcessEnv
|
||||
|
||||
expect(isCopyShortcut({ ctrl: false, meta: false, super: true }, 'c', env)).toBe(true)
|
||||
expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', env)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not treat local Linux Alt+C as copy', async () => {
|
||||
const { isCopyShortcut } = await importPlatform('linux')
|
||||
|
||||
expect(isCopyShortcut({ ctrl: false, meta: true, super: false }, 'c', {})).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts the VS Code/Cursor forwarded Cmd+C copy sequence on macOS', async () => {
|
||||
const { isCopyShortcut } = await importPlatform('darwin')
|
||||
|
||||
expect(isCopyShortcut({ ctrl: true, meta: false, super: true }, 'c', {})).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isVoiceToggleKey', () => {
|
||||
it('matches raw Ctrl+B on macOS (doc-default across platforms)', async () => {
|
||||
const { isVoiceToggleKey } = await importPlatform('darwin')
|
||||
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b')).toBe(true)
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'B')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches kitty-style Cmd+B on macOS via key.super', async () => {
|
||||
const { isVoiceToggleKey } = await importPlatform('darwin')
|
||||
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b')).toBe(true)
|
||||
// ``key.meta`` is NOT accepted as Cmd — hermes-ink uses meta for
|
||||
// Alt too, so accepting it leaked Alt+B into the default binding
|
||||
// (Copilot round-6 review on #19835). Legacy-terminal mac users
|
||||
// get strict Ctrl+B.
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches Ctrl+B on non-macOS platforms', async () => {
|
||||
const { isVoiceToggleKey } = await importPlatform('linux')
|
||||
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not match unmodified b or other Ctrl combos', async () => {
|
||||
const { isVoiceToggleKey } = await importPlatform('darwin')
|
||||
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, 'b')).toBe(false)
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'a')).toBe(false)
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'c')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseVoiceRecordKey (#18994)', () => {
|
||||
it('falls back to Ctrl+B for empty input', async () => {
|
||||
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
expect(parseVoiceRecordKey('')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
})
|
||||
|
||||
it('parses ctrl+<letter> bindings', async () => {
|
||||
const { parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
expect(parseVoiceRecordKey('ctrl+o')).toEqual({ ch: 'o', mod: 'ctrl', raw: 'ctrl+o' })
|
||||
expect(parseVoiceRecordKey('Ctrl+R')).toEqual({ ch: 'r', mod: 'ctrl', raw: 'ctrl+r' })
|
||||
})
|
||||
|
||||
it('parses alt/super aliases', async () => {
|
||||
const { parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
expect(parseVoiceRecordKey('alt+b').mod).toBe('alt')
|
||||
expect(parseVoiceRecordKey('option+b').mod).toBe('alt')
|
||||
expect(parseVoiceRecordKey('super+b').mod).toBe('super')
|
||||
expect(parseVoiceRecordKey('win+b').mod).toBe('super')
|
||||
})
|
||||
|
||||
it('treats ambiguous mac modifiers (meta / cmd / command) as unrecognised', async () => {
|
||||
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
// ``meta`` / ``cmd`` / ``command`` are ambiguous on the wire:
|
||||
// hermes-ink sets ``key.meta`` for plain Alt on every platform AND
|
||||
// for Cmd on legacy macOS terminals. Accepting any of them would
|
||||
// produce a display/binding mismatch (Copilot round-6 review on
|
||||
// #19835). Users on modern kitty-style terminals spell the
|
||||
// platform action modifier ``super`` / ``win``.
|
||||
expect(parseVoiceRecordKey('meta+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('cmd+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('command+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
})
|
||||
|
||||
it('parses named keys (space, enter, tab, escape, backspace, delete)', async () => {
|
||||
const { parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
// Every named token from the CLI's prompt_toolkit ``c-<name>`` set is
|
||||
// accepted with both the canonical name and its common alias.
|
||||
expect(parseVoiceRecordKey('ctrl+space')).toEqual({
|
||||
ch: 'space',
|
||||
mod: 'ctrl',
|
||||
named: 'space',
|
||||
raw: 'ctrl+space'
|
||||
})
|
||||
expect(parseVoiceRecordKey('alt+enter').named).toBe('enter')
|
||||
expect(parseVoiceRecordKey('alt+return').named).toBe('enter') // ``return`` ↔ ``enter``
|
||||
expect(parseVoiceRecordKey('ctrl+tab').named).toBe('tab')
|
||||
expect(parseVoiceRecordKey('ctrl+escape').named).toBe('escape')
|
||||
expect(parseVoiceRecordKey('ctrl+esc').named).toBe('escape') // ``esc`` alias
|
||||
expect(parseVoiceRecordKey('ctrl+backspace').named).toBe('backspace')
|
||||
expect(parseVoiceRecordKey('ctrl+delete').named).toBe('delete')
|
||||
expect(parseVoiceRecordKey('ctrl+del').named).toBe('delete') // ``del`` alias
|
||||
})
|
||||
|
||||
it('falls back to Ctrl+B for unrecognised multi-character tokens', async () => {
|
||||
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
// Typos / unsupported names (``ctrl+spcae``, ``ctrl+f5``, …) fall back
|
||||
// to the documented Ctrl+B default rather than silently disabling the
|
||||
// binding.
|
||||
expect(parseVoiceRecordKey('ctrl+spcae')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('ctrl+f5')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
})
|
||||
|
||||
// Round-3 Copilot review regressions on #19835.
|
||||
it('does not throw on non-string YAML scalars — falls back instead', async () => {
|
||||
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
// ``config.get full`` surfaces raw YAML values; ``voice.record_key: 1``
|
||||
// or ``voice.record_key: true`` would otherwise crash ``.trim()``.
|
||||
expect(parseVoiceRecordKey(1 as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey(true as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey(null as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey(undefined as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey({} as unknown as string)).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
})
|
||||
|
||||
it('rejects multi-modifier chords rather than silently dropping extras', async () => {
|
||||
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
// Previously ``ctrl+alt+r`` parsed as ``ctrl+r`` and ``cmd+ctrl+b`` as
|
||||
// ``super+b`` — a typo silently bound a different shortcut. Now a
|
||||
// multi-modifier spelling falls back to the documented default.
|
||||
expect(parseVoiceRecordKey('ctrl+alt+r')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('cmd+ctrl+b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('alt+ctrl+space')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
})
|
||||
|
||||
// Round-4 Copilot review regressions on #19835.
|
||||
it('rejects bare-char configs without an explicit modifier', async () => {
|
||||
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
// The classic CLI's prompt_toolkit binds raw-char configs to the key
|
||||
// itself (``c-o`` requires an explicit modifier); rewriting ``o``
|
||||
// → ``ctrl+o`` would silently diverge the two runtimes. Refuse.
|
||||
expect(parseVoiceRecordKey('o')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('b')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('space')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('escape')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
})
|
||||
|
||||
it('rejects ctrl+c / ctrl+d / ctrl+l — reserved by the TUI input handler', async () => {
|
||||
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
// ``useInputHandlers()`` intercepts these before the voice check,
|
||||
// so a binding like ``ctrl+c`` would be advertised but never fire.
|
||||
// Fall back to the documented default instead of lying to the user.
|
||||
expect(parseVoiceRecordKey('ctrl+c')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('ctrl+d')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('ctrl+l')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
// Alt-modifier versions of those letters are NOT intercepted, so
|
||||
// they remain usable.
|
||||
expect(parseVoiceRecordKey('alt+c').mod).toBe('alt')
|
||||
// ``ctrl+x`` is intentionally allowed — only intercepted during
|
||||
// queue-edit (``queueEditIdx !== null``), so the voice binding
|
||||
// works for most of the session (Copilot round-8 review).
|
||||
expect(parseVoiceRecordKey('ctrl+x').mod).toBe('ctrl')
|
||||
expect(parseVoiceRecordKey('ctrl+x').ch).toBe('x')
|
||||
})
|
||||
|
||||
it('rejects super+{c,d,l,v} on macOS — action-mod chords are claimed before voice', async () => {
|
||||
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('darwin')
|
||||
|
||||
// On macOS super+c/d/l/v are copy / exit / clear / paste. Reject at
|
||||
// parse time so /voice status doesn't advertise dead bindings.
|
||||
expect(parseVoiceRecordKey('super+c')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('super+d')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('super+l')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('super+v')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
// Other super letters still work (no global chord claims them).
|
||||
expect(parseVoiceRecordKey('super+b').mod).toBe('super')
|
||||
expect(parseVoiceRecordKey('super+o').mod).toBe('super')
|
||||
})
|
||||
|
||||
it('allows super+{c,d,l,v} on Linux/Windows — those globals key off Ctrl, not Super', async () => {
|
||||
const { parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
// Kitty/CSI-u users on non-mac report Cmd/Super as ``key.super``,
|
||||
// but the TUI's global shortcuts (copy/exit/clear/paste) key off
|
||||
// Ctrl there, so ``super+<letter>`` doesn't collide. Reject would
|
||||
// silently coerce valid configs to Ctrl+B (Copilot round-8 review).
|
||||
expect(parseVoiceRecordKey('super+c').mod).toBe('super')
|
||||
expect(parseVoiceRecordKey('super+d').mod).toBe('super')
|
||||
expect(parseVoiceRecordKey('super+l').mod).toBe('super')
|
||||
expect(parseVoiceRecordKey('super+v').mod).toBe('super')
|
||||
})
|
||||
|
||||
it('rejects alt+{c,d,l} on macOS — meta-as-alt collides with isAction', async () => {
|
||||
const { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } = await importPlatform('darwin')
|
||||
|
||||
// hermes-ink reports Alt as ``key.meta`` on many terminals, and
|
||||
// ``isActionMod`` on darwin accepts ``key.meta`` as the action
|
||||
// modifier. So ``alt+c`` / ``alt+d`` / ``alt+l`` get claimed by
|
||||
// isCopyShortcut / isAction('d') / isAction('l') before voice
|
||||
// runs (Copilot round-12 on #19835).
|
||||
expect(parseVoiceRecordKey('alt+c')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('alt+d')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
expect(parseVoiceRecordKey('alt+l')).toEqual(DEFAULT_VOICE_RECORD_KEY)
|
||||
// Other alt letters stay usable on darwin.
|
||||
expect(parseVoiceRecordKey('alt+r').mod).toBe('alt')
|
||||
expect(parseVoiceRecordKey('alt+space').mod).toBe('alt')
|
||||
})
|
||||
|
||||
it('allows alt+{c,d,l} on Linux/Windows — non-mac isAction keys off Ctrl', async () => {
|
||||
const { parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
// On Linux/Windows ``isActionMod`` ignores key.meta, so alt+<letter>
|
||||
// doesn't collide with copy/exit/clear. Those configs stay usable.
|
||||
expect(parseVoiceRecordKey('alt+c').mod).toBe('alt')
|
||||
expect(parseVoiceRecordKey('alt+d').mod).toBe('alt')
|
||||
expect(parseVoiceRecordKey('alt+l').mod).toBe('alt')
|
||||
})
|
||||
|
||||
// Round-5 Copilot review regressions on #19835.
|
||||
it('super+<key> does NOT fire on key.meta-only events (Alt+X false-fire guard)', async () => {
|
||||
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
|
||||
|
||||
// hermes-ink sets ``key.meta`` for Alt/Option AND for bare Esc on
|
||||
// some macOS terminals. The super branch used to accept
|
||||
// ``isMac && key.meta`` as a Cmd fallback, which made super+<key>
|
||||
// bindings silently fire on Alt+<key> / bare Esc.
|
||||
const superB = parseVoiceRecordKey('super+b')
|
||||
const superSpace = parseVoiceRecordKey('super+space')
|
||||
const superEscape = parseVoiceRecordKey('super+escape')
|
||||
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', superB)).toBe(false)
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, ' ', superSpace)).toBe(false)
|
||||
expect(isVoiceToggleKey({ ctrl: false, escape: true, meta: true, super: false }, '', superEscape)).toBe(false)
|
||||
})
|
||||
|
||||
// Round-6 Copilot review regressions on #19835.
|
||||
it('default ctrl+b does NOT fire on Alt+B via isActionMod meta leak', async () => {
|
||||
const { DEFAULT_VOICE_RECORD_KEY, isVoiceToggleKey } = await importPlatform('darwin')
|
||||
|
||||
// ``isActionMod(key)`` on darwin was accepting ``key.meta`` as the
|
||||
// action modifier, so Alt+B (key.meta=true) fired the default
|
||||
// ctrl+b binding. Now the Cmd-fallback path requires literal
|
||||
// ``key.super`` on macOS and rejects ``key.meta``.
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(false)
|
||||
// Literal Ctrl+B and Cmd+B (kitty-style) still work on darwin.
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
|
||||
})
|
||||
|
||||
it('ctrl+<key> rejects chords with extra alt / meta / super bits', async () => {
|
||||
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
const ctrlO = parseVoiceRecordKey('ctrl+o')
|
||||
|
||||
// ``ctrl+o`` must fire ONLY on literal Ctrl+O, not on
|
||||
// Ctrl+Alt+O / Ctrl+Cmd+O / Ctrl+Meta+O — otherwise the runtime
|
||||
// matches a different chord than the parser would let you
|
||||
// configure.
|
||||
expect(isVoiceToggleKey({ alt: true, ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(false)
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: true, super: false }, 'o', ctrlO)).toBe(false)
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: true }, 'o', ctrlO)).toBe(false)
|
||||
// Sanity: plain Ctrl+O still fires.
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(true)
|
||||
})
|
||||
|
||||
it('super+<key> rejects chords with extra ctrl / alt / meta bits', async () => {
|
||||
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
const superB = parseVoiceRecordKey('super+b')
|
||||
|
||||
expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: true }, 'b', superB)).toBe(false)
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: true }, 'b', superB)).toBe(false)
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: true }, 'b', superB)).toBe(false)
|
||||
// Sanity: plain Super+B still fires.
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', superB)).toBe(true)
|
||||
})
|
||||
|
||||
it('alt+escape does not fire on bare Esc meta-shape', async () => {
|
||||
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
|
||||
const altEscape = parseVoiceRecordKey('alt+escape')
|
||||
|
||||
// Some terminals surface bare Esc as meta=true + escape=true.
|
||||
expect(isVoiceToggleKey({ ctrl: false, escape: true, meta: true, super: false }, '', altEscape)).toBe(false)
|
||||
// Explicit alt bit (kitty-style) still fires the configured chord.
|
||||
expect(isVoiceToggleKey({ alt: true, ctrl: false, escape: true, meta: false, super: false }, '', altEscape)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects matches when Shift is held (different chord than configured)', async () => {
|
||||
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
// Parser rejects multi-modifier configs like ``ctrl+shift+tab``,
|
||||
// so the runtime matcher must also reject Shift-held events —
|
||||
// otherwise ``ctrl+tab`` would fire on Ctrl+Shift+Tab.
|
||||
const ctrlTab = parseVoiceRecordKey('ctrl+tab')
|
||||
const altEnter = parseVoiceRecordKey('alt+enter')
|
||||
const ctrlO = parseVoiceRecordKey('ctrl+o')
|
||||
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: true, super: false, tab: true }, '', ctrlTab)).toBe(false)
|
||||
expect(
|
||||
isVoiceToggleKey({ alt: true, ctrl: false, meta: false, return: true, shift: true, super: false }, '', altEnter)
|
||||
).toBe(false)
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: true, super: false }, 'o', ctrlO)).toBe(false)
|
||||
|
||||
// Sanity: same events without Shift still fire.
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: false, super: false, tab: true }, '', ctrlTab)).toBe(true)
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, shift: false, super: false }, 'o', ctrlO)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatVoiceRecordKey (#18994)', () => {
|
||||
it('renders as the user expects in /voice status', async () => {
|
||||
const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+b'))).toBe('Ctrl+B')
|
||||
expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+o'))).toBe('Ctrl+O')
|
||||
expect(formatVoiceRecordKey(parseVoiceRecordKey('alt+r'))).toBe('Alt+R')
|
||||
// ``super``/``win`` render as ``Super`` on non-mac so the hint
|
||||
// doesn't tell Linux/Windows users to press a Cmd key they don't
|
||||
// have.
|
||||
expect(formatVoiceRecordKey(parseVoiceRecordKey('super+b'))).toBe('Super+B')
|
||||
})
|
||||
|
||||
it('renders named keys in title case (Ctrl+Space, Ctrl+Enter)', async () => {
|
||||
const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+space'))).toBe('Ctrl+Space')
|
||||
expect(formatVoiceRecordKey(parseVoiceRecordKey('alt+enter'))).toBe('Alt+Enter')
|
||||
expect(formatVoiceRecordKey(parseVoiceRecordKey('ctrl+esc'))).toBe('Ctrl+Escape')
|
||||
expect(formatVoiceRecordKey(parseVoiceRecordKey('super+space'))).toBe('Super+Space')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isVoiceToggleKey honours configured record key (#18994)', () => {
|
||||
it('binds the configured letter, not hardcoded b', async () => {
|
||||
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
const ctrlO = parseVoiceRecordKey('ctrl+o')
|
||||
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(true)
|
||||
// The old hardcoded 'b' must NOT match when the user configured 'o'.
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', ctrlO)).toBe(false)
|
||||
})
|
||||
|
||||
it('alt+<letter> binding matches alt OR meta (terminal-protocol parity)', async () => {
|
||||
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
const altR = parseVoiceRecordKey('alt+r')
|
||||
|
||||
expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: false }, 'r', altR)).toBe(true)
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'r', altR)).toBe(true)
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, 'r', altR)).toBe(false)
|
||||
})
|
||||
|
||||
it('binds named keys via ink event flags (space → ch === " ", enter → key.return, …)', async () => {
|
||||
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
const ctrlSpace = parseVoiceRecordKey('ctrl+space')
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, ' ', ctrlSpace)).toBe(true)
|
||||
// Single-char ``b`` must NOT match a ``space``-configured binding.
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', ctrlSpace)).toBe(false)
|
||||
// Space without the configured modifier must not fire either.
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: false }, ' ', ctrlSpace)).toBe(false)
|
||||
|
||||
const ctrlEnter = parseVoiceRecordKey('ctrl+enter')
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, return: true, super: false }, '', ctrlEnter)).toBe(true)
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, return: false, super: false }, '', ctrlEnter)).toBe(false)
|
||||
|
||||
const altTab = parseVoiceRecordKey('alt+tab')
|
||||
expect(isVoiceToggleKey({ alt: true, ctrl: false, meta: false, super: false, tab: true }, '', altTab)).toBe(true)
|
||||
expect(isVoiceToggleKey({ alt: false, ctrl: false, meta: false, super: false, tab: true }, '', altTab)).toBe(false)
|
||||
|
||||
const ctrlEscape = parseVoiceRecordKey('ctrl+escape')
|
||||
expect(isVoiceToggleKey({ ctrl: true, escape: true, meta: false, super: false }, '', ctrlEscape)).toBe(true)
|
||||
expect(isVoiceToggleKey({ ctrl: true, escape: false, meta: false, super: false }, '', ctrlEscape)).toBe(false)
|
||||
|
||||
const ctrlBackspace = parseVoiceRecordKey('ctrl+backspace')
|
||||
expect(isVoiceToggleKey({ backspace: true, ctrl: true, meta: false, super: false }, '', ctrlBackspace)).toBe(true)
|
||||
|
||||
const ctrlDelete = parseVoiceRecordKey('ctrl+delete')
|
||||
expect(isVoiceToggleKey({ ctrl: true, delete: true, meta: false, super: false }, '', ctrlDelete)).toBe(true)
|
||||
})
|
||||
|
||||
it('omitted configured key falls back to ctrl+b (back-compat)', async () => {
|
||||
const { isVoiceToggleKey } = await importPlatform('linux')
|
||||
|
||||
// No third arg → DEFAULT_VOICE_RECORD_KEY → Ctrl+B behaviour.
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b')).toBe(true)
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o')).toBe(false)
|
||||
})
|
||||
|
||||
// Regressions from Copilot review on #19835: the previous implementation
|
||||
// accepted ``isActionMod(key)`` in the ``ctrl`` branch for every
|
||||
// configured key, so bare Esc (which hermes-ink reports with
|
||||
// ``key.meta`` on some macOS terminals) fired ``ctrl+escape``, and
|
||||
// Alt+Space / Alt+Tab fired ``ctrl+space`` / ``ctrl+tab``. The fallback
|
||||
// is now gated to the documented default (``ctrl+b``) only.
|
||||
it('ctrl+escape does NOT fire on bare Esc via key.meta on macOS', async () => {
|
||||
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
|
||||
const ctrlEscape = parseVoiceRecordKey('ctrl+escape')
|
||||
|
||||
// Bare Esc on a legacy macOS terminal: ``key.meta: true``, ``key.escape: true``, no ctrl.
|
||||
expect(isVoiceToggleKey({ ctrl: false, escape: true, meta: true, super: false }, '', ctrlEscape)).toBe(false)
|
||||
// Real Ctrl+Esc still fires.
|
||||
expect(isVoiceToggleKey({ ctrl: true, escape: true, meta: false, super: false }, '', ctrlEscape)).toBe(true)
|
||||
})
|
||||
|
||||
it('ctrl+space does NOT fire on Alt+Space on macOS', async () => {
|
||||
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
|
||||
const ctrlSpace = parseVoiceRecordKey('ctrl+space')
|
||||
|
||||
// Alt+Space surfaces as ``key.meta: true`` with space char.
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, ' ', ctrlSpace)).toBe(false)
|
||||
// Real Ctrl+Space still fires.
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, ' ', ctrlSpace)).toBe(true)
|
||||
})
|
||||
|
||||
it('default ctrl+b accepts raw Ctrl+B and kitty-style Cmd+B on macOS', async () => {
|
||||
const { DEFAULT_VOICE_RECORD_KEY, isVoiceToggleKey } = await importPlatform('darwin')
|
||||
|
||||
// Raw Ctrl+B: always works.
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
|
||||
// Cmd+B via kitty-style ``key.super``: still works.
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(true)
|
||||
// Cmd+B via legacy ``key.meta`` NO LONGER works — ``key.meta`` is
|
||||
// hermes-ink's Alt signal, so accepting it leaked Alt+B into the
|
||||
// default binding (Copilot round-6 review on #19835).
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', DEFAULT_VOICE_RECORD_KEY)).toBe(false)
|
||||
})
|
||||
|
||||
it('custom ctrl+<letter> does NOT accept Cmd fallback on macOS', async () => {
|
||||
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
|
||||
const ctrlO = parseVoiceRecordKey('ctrl+o')
|
||||
|
||||
// Only ``ctrl+b`` gets the action-modifier fallback; ``ctrl+o`` must
|
||||
// be a literal Ctrl bit — otherwise Cmd+O would steal the shortcut.
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'o', ctrlO)).toBe(false)
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'o', ctrlO)).toBe(false)
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', ctrlO)).toBe(true)
|
||||
})
|
||||
|
||||
it('super+b renders "Cmd+B" on darwin and requires the literal key.super bit', async () => {
|
||||
const { formatVoiceRecordKey, isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
|
||||
const superB = parseVoiceRecordKey('super+b')
|
||||
|
||||
expect(formatVoiceRecordKey(superB)).toBe('Cmd+B')
|
||||
// Kitty-style: key.super fires the binding.
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', superB)).toBe(true)
|
||||
// ``key.meta`` is NOT accepted — hermes-ink uses meta for Alt too,
|
||||
// so accepting it here would make super+b silently fire on Alt+B
|
||||
// (Copilot round-5 review on #19835).
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', superB)).toBe(false)
|
||||
// Ctrl held at the same time → reject (different chord).
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: true }, 'b', superB)).toBe(false)
|
||||
})
|
||||
|
||||
// Round-2 Copilot review regressions on #19835.
|
||||
it('super+b renders "Super+B" on Linux (not "Cmd+B")', async () => {
|
||||
const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('linux')
|
||||
|
||||
expect(formatVoiceRecordKey(parseVoiceRecordKey('super+b'))).toBe('Super+B')
|
||||
expect(formatVoiceRecordKey(parseVoiceRecordKey('win+b'))).toBe('Super+B')
|
||||
})
|
||||
|
||||
it('super+b still renders "Cmd+B" on macOS', async () => {
|
||||
const { formatVoiceRecordKey, parseVoiceRecordKey } = await importPlatform('darwin')
|
||||
|
||||
expect(formatVoiceRecordKey(parseVoiceRecordKey('super+b'))).toBe('Cmd+B')
|
||||
expect(formatVoiceRecordKey(parseVoiceRecordKey('win+b'))).toBe('Cmd+B')
|
||||
})
|
||||
|
||||
it('ctrl+b aliases (control+b, "ctrl + b") still accept Cmd+B fallback on macOS', async () => {
|
||||
const { isVoiceToggleKey, parseVoiceRecordKey } = await importPlatform('darwin')
|
||||
const controlB = parseVoiceRecordKey('control+b')
|
||||
const spacedB = parseVoiceRecordKey('ctrl + b')
|
||||
|
||||
// Both parse to the documented default semantically; both must keep
|
||||
// the macOS Cmd+B muscle-memory fallback via kitty-style key.super.
|
||||
// ``key.meta`` is NOT accepted — that's hermes-ink's Alt signal
|
||||
// (round-6 review), so legacy-terminal users get strict Ctrl+B.
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', controlB)).toBe(false)
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: true, super: false }, 'b', spacedB)).toBe(false)
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', controlB)).toBe(true)
|
||||
expect(isVoiceToggleKey({ ctrl: false, meta: false, super: true }, 'b', spacedB)).toBe(true)
|
||||
// Literal Ctrl+B still fires.
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'b', controlB)).toBe(true)
|
||||
// And still reject a ctrl bit on a different letter.
|
||||
expect(isVoiceToggleKey({ ctrl: true, meta: false, super: false }, 'o', controlB)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMacActionFallback', () => {
|
||||
it('routes raw Ctrl+K and Ctrl+W to readline kill-to-end / delete-word on macOS', async () => {
|
||||
const { isMacActionFallback } = await importPlatform('darwin')
|
||||
|
||||
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'k', 'k')).toBe(true)
|
||||
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'w', 'w')).toBe(true)
|
||||
// Must not fire when Cmd (meta/super) is held — those are distinct chords.
|
||||
expect(isMacActionFallback({ ctrl: true, meta: true, super: false }, 'k', 'k')).toBe(false)
|
||||
expect(isMacActionFallback({ ctrl: true, meta: false, super: true }, 'w', 'w')).toBe(false)
|
||||
})
|
||||
|
||||
it('is a no-op on non-macOS (Linux routes Ctrl+K/W through isActionMod directly)', async () => {
|
||||
const { isMacActionFallback } = await importPlatform('linux')
|
||||
|
||||
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'k', 'k')).toBe(false)
|
||||
expect(isMacActionFallback({ ctrl: true, meta: false, super: false }, 'w', 'w')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionWheel.js'
|
||||
|
||||
describe('precisionWheel', () => {
|
||||
it('passes the first modifier-held wheel event', () => {
|
||||
const s = initPrecisionWheel()
|
||||
|
||||
expect(computePrecisionWheelStep(s, 1, true, 1000)).toEqual({ active: true, entered: true, rows: 1 })
|
||||
})
|
||||
|
||||
it('coalesces same-frame events without throttling line-by-line scroll', () => {
|
||||
const s = initPrecisionWheel()
|
||||
|
||||
computePrecisionWheelStep(s, 1, true, 1000)
|
||||
|
||||
expect(computePrecisionWheelStep(s, 1, true, 1008).rows).toBe(0)
|
||||
expect(computePrecisionWheelStep(s, 1, true, 1016).rows).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps queued momentum in precision mode briefly after modifier release', () => {
|
||||
const s = initPrecisionWheel()
|
||||
|
||||
computePrecisionWheelStep(s, 1, true, 1000)
|
||||
|
||||
expect(computePrecisionWheelStep(s, 1, false, 1050)).toMatchObject({ active: true, rows: 1 })
|
||||
})
|
||||
|
||||
it('leaves precision mode once modifier-free momentum goes idle', () => {
|
||||
const s = initPrecisionWheel()
|
||||
|
||||
computePrecisionWheelStep(s, 1, true, 1000)
|
||||
|
||||
expect(computePrecisionWheelStep(s, 1, false, 1100)).toEqual({ active: false, entered: false, rows: 0 })
|
||||
})
|
||||
|
||||
it('does not coalesce immediate reversals', () => {
|
||||
const s = initPrecisionWheel()
|
||||
|
||||
computePrecisionWheelStep(s, 1, true, 1000)
|
||||
|
||||
expect(computePrecisionWheelStep(s, -1, true, 1008).rows).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { composerPromptText } from '../lib/prompt.js'
|
||||
|
||||
describe('composerPromptText', () => {
|
||||
it('returns shell prompt for ! commands', () => {
|
||||
expect(composerPromptText('❯', 'coder', true)).toBe('$')
|
||||
})
|
||||
|
||||
it('prefixes named profiles onto the normal prompt', () => {
|
||||
expect(composerPromptText('❯', 'coder')).toBe('coder ❯')
|
||||
})
|
||||
|
||||
it('does not prefix default or custom profiles', () => {
|
||||
expect(composerPromptText('❯', 'default')).toBe('❯')
|
||||
expect(composerPromptText('❯', 'custom')).toBe('❯')
|
||||
expect(composerPromptText('❯')).toBe('❯')
|
||||
})
|
||||
|
||||
it('uses a Termux-safe ASCII prompt marker in normal mode', () => {
|
||||
expect(composerPromptText('❯', 'coder', false, true, 50)).toBe('>')
|
||||
})
|
||||
|
||||
it('keeps profile prefix suppressed on narrow Termux widths', () => {
|
||||
expect(composerPromptText('❯', 'upstr', false, true, 72)).toBe('>')
|
||||
})
|
||||
|
||||
it('allows profile prefix on very wide Termux panes', () => {
|
||||
expect(composerPromptText('❯', 'upstr', false, true, 120)).toBe('upstr >')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { providerDisplayNames } from '../domain/providers.js'
|
||||
|
||||
describe('providerDisplayNames', () => {
|
||||
it('returns bare names when all are unique', () => {
|
||||
expect(
|
||||
providerDisplayNames([
|
||||
{ name: 'Anthropic', slug: 'anthropic' },
|
||||
{ name: 'OpenAI', slug: 'openai' }
|
||||
])
|
||||
).toEqual(['Anthropic', 'OpenAI'])
|
||||
})
|
||||
|
||||
it('appends slug to every collision so the disambiguation is symmetric', () => {
|
||||
expect(
|
||||
providerDisplayNames([
|
||||
{ name: 'Kimi For Coding', slug: 'kimi-coding' },
|
||||
{ name: 'Kimi For Coding', slug: 'kimi-coding-cn' }
|
||||
])
|
||||
).toEqual(['Kimi For Coding (kimi-coding)', 'Kimi For Coding (kimi-coding-cn)'])
|
||||
})
|
||||
|
||||
it('only disambiguates the colliding group', () => {
|
||||
expect(
|
||||
providerDisplayNames([
|
||||
{ name: 'Anthropic', slug: 'anthropic' },
|
||||
{ name: 'Foo', slug: 'foo-a' },
|
||||
{ name: 'Foo', slug: 'foo-b' }
|
||||
])
|
||||
).toEqual(['Anthropic', 'Foo (foo-a)', 'Foo (foo-b)'])
|
||||
})
|
||||
|
||||
it('falls back to plain name if slug is empty', () => {
|
||||
expect(
|
||||
providerDisplayNames([
|
||||
{ name: 'Foo', slug: '' },
|
||||
{ name: 'Foo', slug: '' }
|
||||
])
|
||||
).toEqual(['Foo', 'Foo'])
|
||||
})
|
||||
|
||||
it('skips disambiguation when slug equals name', () => {
|
||||
expect(
|
||||
providerDisplayNames([
|
||||
{ name: 'foo', slug: 'foo' },
|
||||
{ name: 'foo', slug: 'foo' }
|
||||
])
|
||||
).toEqual(['foo', 'foo'])
|
||||
})
|
||||
|
||||
it('handles empty input', () => {
|
||||
expect(providerDisplayNames([])).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves order', () => {
|
||||
const input = [
|
||||
{ name: 'Z', slug: 'z' },
|
||||
{ name: 'A', slug: 'a1' },
|
||||
{ name: 'A', slug: 'a2' }
|
||||
]
|
||||
|
||||
expect(providerDisplayNames(input)).toEqual(['Z', 'A (a1)', 'A (a2)'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ComposerToken } from '../app/interfaces.js'
|
||||
import { expandPasteTokens, prepareSlashSubmission, queueItemFromSlash } from '../app/useSubmission.js'
|
||||
import { imageToken } from '../domain/attachments.js'
|
||||
|
||||
describe('/queue collapsed paste submission', () => {
|
||||
it('keeps the collapsed argument for display and the full multiline payload for execution', () => {
|
||||
const display = '[[ first.. [3 lines] .. last ]]'
|
||||
|
||||
expect(queueItemFromSlash(`/queue ${display}`, '/queue first\nmiddle\nlast')).toEqual({
|
||||
display,
|
||||
text: 'first\nmiddle\nlast'
|
||||
})
|
||||
})
|
||||
|
||||
it('supports the /q alias and rejects an empty queue command', () => {
|
||||
expect(queueItemFromSlash('/q [[ payload ]]', '/q complete payload')).toEqual({
|
||||
display: '[[ payload ]]',
|
||||
text: 'complete payload'
|
||||
})
|
||||
expect(queueItemFromSlash('/queue', '/queue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('expands paste tokens without consuming image tokens', () => {
|
||||
const paste: ComposerToken = { kind: 'paste', label: '[[ paste [2 lines] ]]', text: 'one\ntwo' }
|
||||
const image: ComposerToken = { kind: 'image', index: 1, label: imageToken(1), path: '/tmp/image.png' }
|
||||
|
||||
expect(expandPasteTokens([paste, image])(`${paste.label} and ${image.label}`)).toBe(`one\ntwo and ${image.label}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareSlashSubmission', () => {
|
||||
const label = '[[ Done — verified.. [412 lines] .. already on it. ]]'
|
||||
const text = 'Done — verified through the real resolver\nline two\nline three'
|
||||
const tokens: ComposerToken[] = [{ kind: 'paste', label, text }]
|
||||
|
||||
// The reported bug: `/pr-triage <paste>` dispatched the LABEL, so the skill
|
||||
// received "[412 lines]" as its argument and the agent reported the paste as
|
||||
// truncated. The command has to carry the full text; only the transcript
|
||||
// stays collapsed.
|
||||
it('dispatches the full paste while the transcript keeps the collapsed label', () => {
|
||||
expect(prepareSlashSubmission(`/pr-triage ${label}`, tokens)).toEqual({
|
||||
command: `/pr-triage ${text}`,
|
||||
display: `/pr-triage ${label}`
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves image tokens as labels — the gateway already holds the file', () => {
|
||||
const image: ComposerToken = { kind: 'image', index: 1, label: imageToken(1), path: '/tmp/shot.png' }
|
||||
|
||||
expect(prepareSlashSubmission(`/pr-triage ${image.label}`, [image]).command).toBe(`/pr-triage ${image.label}`)
|
||||
})
|
||||
|
||||
it('is a no-op on a token-free command', () => {
|
||||
expect(prepareSlashSubmission('/model opus', [])).toEqual({ command: '/model opus', display: '/model opus' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { hasReasoningTag, splitReasoning } from '../lib/reasoning.js'
|
||||
import { cleanThinkingText } from '../lib/text.js'
|
||||
|
||||
describe('splitReasoning', () => {
|
||||
it('extracts <think>…</think> and strips it from text', () => {
|
||||
const { reasoning, text } = splitReasoning('<think>plotting</think>\n\nhere is the answer')
|
||||
|
||||
expect(reasoning).toBe('plotting')
|
||||
expect(text).toBe('here is the answer')
|
||||
})
|
||||
|
||||
it('handles multiple tag shapes', () => {
|
||||
const input = '<reasoning>a</reasoning> <THINKING>b</THINKING> <thought>c</thought> body'
|
||||
const { reasoning, text } = splitReasoning(input)
|
||||
|
||||
expect(reasoning).toContain('a')
|
||||
expect(reasoning).toContain('b')
|
||||
expect(reasoning).toContain('c')
|
||||
expect(text).toBe('body')
|
||||
})
|
||||
|
||||
it('treats unclosed leading <think>… as reasoning (real reasoning-model stream)', () => {
|
||||
const { reasoning, text } = splitReasoning('<think>still deciding')
|
||||
|
||||
expect(reasoning).toBe('still deciding')
|
||||
expect(text).toBe('')
|
||||
})
|
||||
|
||||
it('does not strip trailing prose after a stray mid-text <think> mention', () => {
|
||||
// Regression for "TUI eats last paragraph of output": when the model
|
||||
// emits a literal `<think>` somewhere in prose (quoted explanation, code
|
||||
// example, partial stream-mid-tag), the trailing greedy unclosed-tag
|
||||
// regex used to consume every paragraph after it. Real unclosed
|
||||
// reasoning blocks always lead the message — anchor to ^ so prose
|
||||
// mentions are preserved.
|
||||
const { reasoning, text } = splitReasoning(
|
||||
'final answer paragraph one.\n\n<think>internal note never closed\n\nfinal answer paragraph two.'
|
||||
)
|
||||
|
||||
expect(reasoning).toBe('')
|
||||
expect(text).toBe('final answer paragraph one.\n\n<think>internal note never closed\n\nfinal answer paragraph two.')
|
||||
})
|
||||
|
||||
it('returns empty reasoning and untouched text when no tags present', () => {
|
||||
const { reasoning, text } = splitReasoning('plain body with no tags')
|
||||
|
||||
expect(reasoning).toBe('')
|
||||
expect(text).toBe('plain body with no tags')
|
||||
})
|
||||
|
||||
it('preserves text when reasoning block is empty', () => {
|
||||
const { reasoning, text } = splitReasoning('<think></think>only body')
|
||||
|
||||
expect(reasoning).toBe('')
|
||||
expect(text).toBe('only body')
|
||||
})
|
||||
|
||||
it('detects presence of any supported tag', () => {
|
||||
expect(hasReasoningTag('pre <think>x</think> post')).toBe(true)
|
||||
expect(hasReasoningTag('pre <reasoning>x</reasoning>')).toBe(true)
|
||||
expect(hasReasoningTag('<REASONING_SCRATCHPAD>x</REASONING_SCRATCHPAD>')).toBe(true)
|
||||
expect(hasReasoningTag('no tags at all')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cleanThinkingText', () => {
|
||||
it('removes face/status ticker fragments while preserving real reasoning', () => {
|
||||
expect(
|
||||
cleanThinkingText(
|
||||
'(¬_¬) synthesizing...**Resolving comments on GitHub**\n( ͡° ͜ʖ ͡°) musing...\nActual step\n٩(๑❛ᴗ❛๑)۶ contemplating...next step'
|
||||
)
|
||||
).toBe('**Resolving comments on GitHub**\nActual step\nnext step')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
|
||||
describe('asRpcResult', () => {
|
||||
it('keeps plain object payloads', () => {
|
||||
expect(asRpcResult({ ok: true, value: 'x' })).toEqual({ ok: true, value: 'x' })
|
||||
})
|
||||
|
||||
it('rejects missing or non-object payloads', () => {
|
||||
expect(asRpcResult(undefined)).toBeNull()
|
||||
expect(asRpcResult(null)).toBeNull()
|
||||
expect(asRpcResult('oops')).toBeNull()
|
||||
expect(asRpcResult(['bad'])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('rpcErrorMessage', () => {
|
||||
it('prefers Error messages', () => {
|
||||
expect(rpcErrorMessage(new Error('boom'))).toBe('boom')
|
||||
})
|
||||
|
||||
it('falls back for unknown errors', () => {
|
||||
expect(rpcErrorMessage('broken')).toBe('broken')
|
||||
expect(rpcErrorMessage({ code: 500 })).toBe('request failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { scrollWithSelectionBy } from '../app/scroll.js'
|
||||
|
||||
function makeScroll(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
const getScrollHeight = (overrides.getScrollHeight as (() => number) | undefined) ?? vi.fn(() => 100)
|
||||
|
||||
return {
|
||||
getFreshScrollHeight: vi.fn(() => getScrollHeight()),
|
||||
getPendingDelta: vi.fn(() => 0),
|
||||
getScrollHeight,
|
||||
getScrollTop: vi.fn(() => 10),
|
||||
getViewportHeight: vi.fn(() => 20),
|
||||
getViewportTop: vi.fn(() => 0),
|
||||
scrollBy: vi.fn(),
|
||||
scrollTo: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('scrollWithSelectionBy', () => {
|
||||
it('commits the clamped target directly instead of queueing a scroll delta', () => {
|
||||
const s = makeScroll({
|
||||
getScrollHeight: vi.fn(() => 30),
|
||||
getScrollTop: vi.fn(() => 9),
|
||||
getViewportHeight: vi.fn(() => 20)
|
||||
})
|
||||
|
||||
const selection = {
|
||||
captureScrolledRows: vi.fn(),
|
||||
getState: vi.fn(() => null),
|
||||
shiftAnchor: vi.fn(),
|
||||
shiftSelection: vi.fn()
|
||||
}
|
||||
|
||||
scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection })
|
||||
|
||||
expect(s.scrollTo).toHaveBeenCalledWith(10)
|
||||
expect(s.scrollBy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses fresh scroll height when cached height would swallow a down-scroll at a fake bottom', () => {
|
||||
const s = makeScroll({
|
||||
getFreshScrollHeight: vi.fn(() => 34),
|
||||
getScrollHeight: vi.fn(() => 30),
|
||||
getScrollTop: vi.fn(() => 10),
|
||||
getViewportHeight: vi.fn(() => 20)
|
||||
})
|
||||
|
||||
const selection = {
|
||||
captureScrolledRows: vi.fn(),
|
||||
getState: vi.fn(() => null),
|
||||
shiftAnchor: vi.fn(),
|
||||
shiftSelection: vi.fn()
|
||||
}
|
||||
|
||||
scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection })
|
||||
|
||||
expect(s.scrollTo).toHaveBeenCalledWith(14)
|
||||
expect(s.scrollBy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses fresh height when pending down-scroll reaches the cached fake bottom', () => {
|
||||
const s = makeScroll({
|
||||
getFreshScrollHeight: vi.fn(() => 38),
|
||||
getPendingDelta: vi.fn(() => 2),
|
||||
getScrollHeight: vi.fn(() => 32),
|
||||
getScrollTop: vi.fn(() => 10),
|
||||
getViewportHeight: vi.fn(() => 20)
|
||||
})
|
||||
|
||||
const selection = {
|
||||
captureScrolledRows: vi.fn(),
|
||||
getState: vi.fn(() => null),
|
||||
shiftAnchor: vi.fn(),
|
||||
shiftSelection: vi.fn()
|
||||
}
|
||||
|
||||
scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection })
|
||||
|
||||
expect(s.scrollTo).toHaveBeenCalledWith(18)
|
||||
expect(s.scrollBy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing at the edge instead of queueing dead pending deltas', () => {
|
||||
const s = makeScroll({
|
||||
getScrollHeight: vi.fn(() => 30),
|
||||
getScrollTop: vi.fn(() => 10),
|
||||
getViewportHeight: vi.fn(() => 20)
|
||||
})
|
||||
|
||||
const selection = {
|
||||
captureScrolledRows: vi.fn(),
|
||||
getState: vi.fn(() => null),
|
||||
shiftAnchor: vi.fn(),
|
||||
shiftSelection: vi.fn()
|
||||
}
|
||||
|
||||
scrollWithSelectionBy(10, { scrollRef: { current: s as never }, selection })
|
||||
|
||||
expect(s.scrollTo).not.toHaveBeenCalled()
|
||||
expect(s.scrollBy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves selection capture and shifting on the direct path', () => {
|
||||
const s = makeScroll({
|
||||
getScrollTop: vi.fn(() => 10),
|
||||
getViewportHeight: vi.fn(() => 20),
|
||||
getViewportTop: vi.fn(() => 5)
|
||||
})
|
||||
|
||||
const selection = {
|
||||
captureScrolledRows: vi.fn(),
|
||||
getState: vi.fn(() => ({ anchor: { row: 10 }, focus: { row: 12 } })),
|
||||
shiftAnchor: vi.fn(),
|
||||
shiftSelection: vi.fn()
|
||||
}
|
||||
|
||||
scrollWithSelectionBy(3, { scrollRef: { current: s as never }, selection })
|
||||
|
||||
expect(selection.captureScrolledRows).toHaveBeenCalledWith(5, 7, 'above')
|
||||
expect(selection.shiftSelection).toHaveBeenCalledWith(-3, 5, 24)
|
||||
expect(s.scrollTo).toHaveBeenCalledWith(13)
|
||||
expect(s.scrollBy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,626 @@
|
||||
import { PassThrough } from 'stream'
|
||||
|
||||
import { Box, renderSync, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink'
|
||||
import React, { useLayoutEffect, useRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import SourceBox from '../../packages/hermes-ink/src/ink/components/Box.js'
|
||||
import SourceScrollBox from '../../packages/hermes-ink/src/ink/components/ScrollBox.js'
|
||||
import SourceText from '../../packages/hermes-ink/src/ink/components/Text.js'
|
||||
import type { DOMElement } from '../../packages/hermes-ink/src/ink/dom.js'
|
||||
import Output from '../../packages/hermes-ink/src/ink/output.js'
|
||||
import { scrollFastPathStats as sourceScrollFastPathStats } from '../../packages/hermes-ink/src/ink/render-node-to-output.js'
|
||||
import { renderSync as renderSourceSync } from '../../packages/hermes-ink/src/ink/root.js'
|
||||
import { useVirtualHistory } from '../hooks/useVirtualHistory.js'
|
||||
|
||||
interface Item {
|
||||
height: number
|
||||
heightAfterResize?: number
|
||||
key: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
interface Exposed {
|
||||
scroll: ScrollBoxHandle | null
|
||||
virtualHistory: ReturnType<typeof useVirtualHistory>
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
const makeStreams = () => {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
|
||||
Object.assign(stdout, { columns: 80, isTTY: false, rows: 20 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', () => {})
|
||||
|
||||
return { stderr, stdin, stdout }
|
||||
}
|
||||
|
||||
const itemHeightForColumns = (item: Item | undefined, columns: number) =>
|
||||
columns >= 80 ? (item?.heightAfterResize ?? item?.height ?? 1) : (item?.height ?? 1)
|
||||
|
||||
function Harness({
|
||||
columns = 80,
|
||||
expose,
|
||||
height = 10,
|
||||
generation = 0,
|
||||
initialHeights,
|
||||
items,
|
||||
maxMounted = 16
|
||||
}: {
|
||||
columns?: number
|
||||
expose: React.MutableRefObject<Exposed | null>
|
||||
height?: number
|
||||
generation?: number
|
||||
initialHeights?: ReadonlyMap<string, number>
|
||||
items: readonly Item[]
|
||||
maxMounted?: number
|
||||
}) {
|
||||
const scrollRef = useRef<ScrollBoxHandle | null>(null)
|
||||
|
||||
const virtualHistory = useVirtualHistory(scrollRef, items, columns, {
|
||||
coldStartCount: 16,
|
||||
estimateHeight: index => itemHeightForColumns(items[index], columns),
|
||||
generation,
|
||||
initialHeights,
|
||||
maxMounted,
|
||||
overscan: 2
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
expose.current = { scroll: scrollRef.current, virtualHistory }
|
||||
})
|
||||
|
||||
return React.createElement(
|
||||
ScrollBox,
|
||||
{ flexDirection: 'column', height, ref: scrollRef, stickyScroll: true },
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: 'column', width: '100%' },
|
||||
virtualHistory.topSpacer > 0 ? React.createElement(Box, { height: virtualHistory.topSpacer }) : null,
|
||||
...items.slice(virtualHistory.start, virtualHistory.end).map(item =>
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
height: itemHeightForColumns(item, columns),
|
||||
key: item.key,
|
||||
ref: virtualHistory.measureRef(item.key)
|
||||
},
|
||||
React.createElement(Text, null, item.text ?? item.key)
|
||||
)
|
||||
),
|
||||
virtualHistory.bottomSpacer > 0 ? React.createElement(Box, { height: virtualHistory.bottomSpacer }) : null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function CorruptGeometryHarness({ expose, tick }: { expose: React.MutableRefObject<DOMElement[]>; tick: number }) {
|
||||
const nodes = useRef<DOMElement[]>([])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
expose.current = nodes.current
|
||||
})
|
||||
|
||||
return React.createElement(
|
||||
ScrollBox,
|
||||
{ flexDirection: 'column', height: 8 },
|
||||
...['nan-top', 'positive-infinity', 'negative-infinity', 'billion-rows', 'clipped-huge-fill'].map((label, index) =>
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
backgroundColor: 'blue',
|
||||
borderStyle: label === 'clipped-huge-fill' ? 'single' : undefined,
|
||||
height: 1,
|
||||
key: label,
|
||||
opaque: true,
|
||||
ref: node => {
|
||||
if (node) {
|
||||
nodes.current[index] = node
|
||||
}
|
||||
},
|
||||
width: '100%'
|
||||
},
|
||||
React.createElement(Text, null, `${label}-${tick}`)
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
height: 1,
|
||||
ref: node => {
|
||||
if (node) {
|
||||
nodes.current[5] = node
|
||||
}
|
||||
}
|
||||
},
|
||||
React.createElement(Text, null, React.createElement(Text, null, `nested-corrupt-${tick}`))
|
||||
),
|
||||
React.createElement(Text, null, `adjacent-valid-${tick}`),
|
||||
React.createElement(Box, { height: 20 }, React.createElement(Text, null, `tail-${tick}`))
|
||||
)
|
||||
}
|
||||
|
||||
interface FastPathRepairExpose {
|
||||
adjacent: DOMElement | null
|
||||
dirtyChild: DOMElement | null
|
||||
overlay: DOMElement | null
|
||||
scroll: ScrollBoxHandle | null
|
||||
scrollBox: DOMElement | null
|
||||
}
|
||||
|
||||
function FastPathRepairHarness({
|
||||
expose,
|
||||
tick,
|
||||
dirtyTick = tick,
|
||||
includeOverlay = true
|
||||
}: {
|
||||
dirtyTick?: number
|
||||
expose: React.MutableRefObject<FastPathRepairExpose | null>
|
||||
includeOverlay?: boolean
|
||||
tick: number
|
||||
}) {
|
||||
return React.createElement(
|
||||
SourceBox,
|
||||
{ flexDirection: 'column', height: 12, width: 40 },
|
||||
React.createElement(
|
||||
SourceScrollBox,
|
||||
{
|
||||
flexDirection: 'column',
|
||||
height: 8,
|
||||
ref: scroll => {
|
||||
if (expose.current) {
|
||||
expose.current.scroll = scroll
|
||||
}
|
||||
},
|
||||
width: 40
|
||||
},
|
||||
React.createElement(SourceBox, { height: 2 }, React.createElement(SourceText, null, 'head-row')),
|
||||
React.createElement(
|
||||
SourceBox,
|
||||
{
|
||||
height: 2,
|
||||
ref: dirtyChild => {
|
||||
if (expose.current) {
|
||||
expose.current.dirtyChild = dirtyChild
|
||||
expose.current.scrollBox = dirtyChild?.parentNode?.parentNode ?? null
|
||||
}
|
||||
}
|
||||
},
|
||||
React.createElement(SourceText, null, `dirty-row-${dirtyTick}`)
|
||||
),
|
||||
React.createElement(SourceBox, { height: 20 }, React.createElement(SourceText, null, 'tail-row'))
|
||||
),
|
||||
includeOverlay
|
||||
? React.createElement(
|
||||
SourceBox,
|
||||
{
|
||||
height: 2,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
ref: overlay => {
|
||||
if (expose.current) {
|
||||
expose.current.overlay = overlay
|
||||
}
|
||||
},
|
||||
top: 2,
|
||||
width: 40
|
||||
},
|
||||
React.createElement(SourceText, null, 'overlay-row')
|
||||
)
|
||||
: null,
|
||||
React.createElement(
|
||||
SourceBox,
|
||||
{
|
||||
height: 1,
|
||||
ref: adjacent => {
|
||||
if (expose.current) {
|
||||
expose.current.adjacent = adjacent
|
||||
}
|
||||
}
|
||||
},
|
||||
React.createElement(SourceText, null, `adjacent-fast-path-${tick}`)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function guardFastPathRepairAllocations(maxWidth: number, maxHeight: number) {
|
||||
const originalArrayFill = Array.prototype.fill
|
||||
const originalBlit = Output.prototype.blit
|
||||
const originalClear = Output.prototype.clear
|
||||
const originalRepeat = String.prototype.repeat
|
||||
const originalWrite = Output.prototype.write
|
||||
|
||||
const observed = {
|
||||
largestArrayRows: 0,
|
||||
largestBlitHeight: 0,
|
||||
largestBlitWidth: 0,
|
||||
largestClearHeight: 0,
|
||||
largestClearWidth: 0,
|
||||
largestRepeat: 0,
|
||||
largestWrite: 0,
|
||||
repairWhitespaceWrites: 0
|
||||
}
|
||||
|
||||
vi.spyOn(String.prototype, 'repeat').mockImplementation(function (count: number) {
|
||||
observed.largestRepeat = Math.max(observed.largestRepeat, count)
|
||||
|
||||
if (!Number.isSafeInteger(count) || count < 0 || count > maxWidth) {
|
||||
throw new Error(`unbounded fast-path repeat: ${count}`)
|
||||
}
|
||||
|
||||
return originalRepeat.call(this, count)
|
||||
})
|
||||
vi.spyOn(Array.prototype, 'fill').mockImplementation(function (
|
||||
this: unknown[],
|
||||
value: unknown,
|
||||
start?: number,
|
||||
end?: number
|
||||
) {
|
||||
observed.largestArrayRows = Math.max(observed.largestArrayRows, this.length)
|
||||
|
||||
if (this.length > maxHeight) {
|
||||
throw new Error(`unbounded fast-path row array: ${this.length}`)
|
||||
}
|
||||
|
||||
return Reflect.apply(originalArrayFill, this, [value, start, end])
|
||||
} as typeof Array.prototype.fill)
|
||||
vi.spyOn(Output.prototype, 'blit').mockImplementation(function (...args: Parameters<Output['blit']>) {
|
||||
observed.largestBlitWidth = Math.max(observed.largestBlitWidth, args[3])
|
||||
observed.largestBlitHeight = Math.max(observed.largestBlitHeight, args[4])
|
||||
|
||||
return originalBlit.apply(this, args)
|
||||
})
|
||||
vi.spyOn(Output.prototype, 'clear').mockImplementation(function (...args: Parameters<Output['clear']>) {
|
||||
observed.largestClearWidth = Math.max(observed.largestClearWidth, args[0].width)
|
||||
observed.largestClearHeight = Math.max(observed.largestClearHeight, args[0].height)
|
||||
|
||||
return originalClear.apply(this, args)
|
||||
})
|
||||
vi.spyOn(Output.prototype, 'write').mockImplementation(function (...args: Parameters<Output['write']>) {
|
||||
observed.largestWrite = Math.max(observed.largestWrite, args[2].length)
|
||||
|
||||
if (args[2].length > 0 && /^[ \n]+$/.test(args[2])) {
|
||||
observed.repairWhitespaceWrites++
|
||||
}
|
||||
|
||||
if (args[2].length > maxWidth * maxHeight + maxHeight) {
|
||||
throw new Error(`unbounded fast-path Output.write input: ${args[2].length}`)
|
||||
}
|
||||
|
||||
return originalWrite.apply(this, args)
|
||||
})
|
||||
|
||||
return observed
|
||||
}
|
||||
|
||||
describe('ScrollBox renderer bounds', () => {
|
||||
it('rejects invalid imperative geometry without poisoning scroll state', async () => {
|
||||
const items = Array.from({ length: 20 }, (_, index) => ({ height: 2, key: `item-${index}` }))
|
||||
const expose = { current: null as Exposed | null }
|
||||
const streams = makeStreams()
|
||||
|
||||
const instance = renderSync(React.createElement(Harness, { expose, items }), {
|
||||
patchConsole: false,
|
||||
stderr: streams.stderr as NodeJS.WriteStream,
|
||||
stdin: streams.stdin as NodeJS.ReadStream,
|
||||
stdout: streams.stdout as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
try {
|
||||
await delay(20)
|
||||
const scroll = expose.current!.scroll!
|
||||
|
||||
scroll.scrollTo(4)
|
||||
scroll.scrollTo(Number.NaN)
|
||||
scroll.scrollBy(Number.POSITIVE_INFINITY)
|
||||
scroll.adjustScrollTop(Number.NEGATIVE_INFINITY)
|
||||
scroll.setClampBounds(Number.NaN, Number.POSITIVE_INFINITY)
|
||||
await delay(20)
|
||||
|
||||
expect(scroll.getScrollTop()).toBe(4)
|
||||
expect(scroll.getPendingDelta()).toBe(0)
|
||||
expect(Number.isFinite(scroll.getScrollHeight())).toBe(true)
|
||||
} finally {
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed on corrupt ScrollBox child geometry and keeps adjacent rows renderable', async () => {
|
||||
const expose = { current: [] as DOMElement[] }
|
||||
const streams = makeStreams()
|
||||
const originalRepeat = String.prototype.repeat
|
||||
const originalWrite = Output.prototype.write
|
||||
let largestWriteInput = 0
|
||||
let largestWrite = 0
|
||||
let output = ''
|
||||
|
||||
vi.spyOn(String.prototype, 'repeat').mockImplementation(function (count: number) {
|
||||
if (!Number.isSafeInteger(count) || count < 0 || count > 10_000) {
|
||||
throw new Error(`unbounded string repeat: ${count}`)
|
||||
}
|
||||
|
||||
return originalRepeat.call(this, count)
|
||||
})
|
||||
vi.spyOn(Output.prototype, 'write').mockImplementation(function (...args: Parameters<Output['write']>) {
|
||||
largestWriteInput = Math.max(largestWriteInput, args[2].length)
|
||||
|
||||
if (args[2].length > 10_000) {
|
||||
throw new Error(`unbounded Output.write input: ${args[2].length}`)
|
||||
}
|
||||
|
||||
return originalWrite.apply(this, args)
|
||||
})
|
||||
|
||||
streams.stdout.removeAllListeners('data')
|
||||
streams.stdout.on('data', chunk => {
|
||||
largestWrite = Math.max(largestWrite, chunk.length)
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(React.createElement(CorruptGeometryHarness, { expose, tick: 0 }), {
|
||||
patchConsole: false,
|
||||
stderr: streams.stderr as NodeJS.WriteStream,
|
||||
stdin: streams.stdin as NodeJS.ReadStream,
|
||||
stdout: streams.stdout as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
try {
|
||||
await delay(20)
|
||||
|
||||
const [nanTop, positiveInfinity, negativeInfinity, billionRows, clippedHugeFill, nestedTextWrapper] =
|
||||
expose.current
|
||||
|
||||
expect(nanTop?.yogaNode).toBeDefined()
|
||||
expect(positiveInfinity?.yogaNode).toBeDefined()
|
||||
expect(negativeInfinity?.yogaNode).toBeDefined()
|
||||
expect(billionRows?.yogaNode).toBeDefined()
|
||||
expect(clippedHugeFill?.yogaNode).toBeDefined()
|
||||
expect(nestedTextWrapper?.yogaNode).toBeDefined()
|
||||
|
||||
const nestedText = nestedTextWrapper!.childNodes.find(child => child.nodeName === 'ink-text') as
|
||||
DOMElement | undefined
|
||||
|
||||
const nestedTextChild = nestedText?.childNodes[0]
|
||||
|
||||
expect(nestedTextChild).toBeDefined()
|
||||
|
||||
vi.spyOn(nanTop!.yogaNode!, 'getComputedTop').mockReturnValue(Number.NaN)
|
||||
vi.spyOn(positiveInfinity!.yogaNode!, 'getComputedHeight').mockReturnValue(Number.POSITIVE_INFINITY)
|
||||
vi.spyOn(negativeInfinity!.yogaNode!, 'getComputedHeight').mockReturnValue(Number.NEGATIVE_INFINITY)
|
||||
vi.spyOn(billionRows!.yogaNode!, 'getComputedHeight').mockReturnValue(1_000_000_000)
|
||||
vi.spyOn(clippedHugeFill!.yogaNode!, 'getComputedHeight').mockReturnValue(100_000_000)
|
||||
vi.spyOn(clippedHugeFill!.yogaNode!, 'getComputedWidth').mockReturnValue(100_000_000)
|
||||
|
||||
let nestedOffsetX = 0
|
||||
let nestedOffsetY = 0
|
||||
|
||||
nestedTextChild!.yogaNode = {
|
||||
getComputedLeft: () => nestedOffsetX,
|
||||
getComputedTop: () => nestedOffsetY
|
||||
} as DOMElement['yogaNode']
|
||||
|
||||
output = ''
|
||||
largestWrite = 0
|
||||
largestWriteInput = 0
|
||||
|
||||
const corruptNestedOffsets = [
|
||||
[Number.NaN, 0],
|
||||
[Number.POSITIVE_INFINITY, 0],
|
||||
[Number.NEGATIVE_INFINITY, 0],
|
||||
[-1, 0],
|
||||
[0.5, 0],
|
||||
[100_000_000, 0],
|
||||
[0, Number.NaN],
|
||||
[0, Number.POSITIVE_INFINITY],
|
||||
[0, Number.NEGATIVE_INFINITY],
|
||||
[0, -1],
|
||||
[0, 0.5],
|
||||
[0, 100_000_000]
|
||||
] as const
|
||||
|
||||
for (const [index, [offsetX, offsetY]] of corruptNestedOffsets.entries()) {
|
||||
nestedOffsetX = offsetX
|
||||
nestedOffsetY = offsetY
|
||||
|
||||
expect(() => {
|
||||
instance.rerender(React.createElement(CorruptGeometryHarness, { expose, tick: index + 1 }))
|
||||
}).not.toThrow()
|
||||
await delay(5)
|
||||
}
|
||||
|
||||
await delay(40)
|
||||
|
||||
expect(output).toContain(`adjacent-valid-${corruptNestedOffsets.length}`)
|
||||
expect(largestWriteInput).toBeLessThan(10_000)
|
||||
expect(largestWrite).toBeLessThan(10_000)
|
||||
expect(output.length).toBeLessThan(50_000)
|
||||
} finally {
|
||||
vi.restoreAllMocks()
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('clips corrupt dirty-child DECSTBM repairs before allocating or recursing', async () => {
|
||||
const expose = {
|
||||
current: {
|
||||
adjacent: null,
|
||||
dirtyChild: null,
|
||||
overlay: null,
|
||||
scroll: null,
|
||||
scrollBox: null
|
||||
} as FastPathRepairExpose
|
||||
}
|
||||
|
||||
const streams = makeStreams()
|
||||
let output = ''
|
||||
|
||||
const instance = renderSourceSync(
|
||||
React.createElement(FastPathRepairHarness, { expose, includeOverlay: false, tick: 0 }),
|
||||
{
|
||||
patchConsole: false,
|
||||
stderr: streams.stderr as NodeJS.WriteStream,
|
||||
stdin: streams.stdin as NodeJS.ReadStream,
|
||||
stdout: streams.stdout as NodeJS.WriteStream
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
await delay(20)
|
||||
const dirtyChild = expose.current!.dirtyChild!
|
||||
|
||||
expect(dirtyChild, streams.stderr.read()?.toString()).not.toBeNull()
|
||||
expect(dirtyChild.yogaNode).toBeDefined()
|
||||
vi.spyOn(dirtyChild.yogaNode!, 'getComputedTop').mockReturnValue(-99_999_995)
|
||||
vi.spyOn(dirtyChild.yogaNode!, 'getComputedWidth').mockReturnValue(100_000_000)
|
||||
vi.spyOn(dirtyChild.yogaNode!, 'getComputedHeight').mockReturnValue(100_000_000)
|
||||
|
||||
instance.rerender(React.createElement(FastPathRepairHarness, { expose, includeOverlay: false, tick: 1 }))
|
||||
await delay(20)
|
||||
|
||||
streams.stdout.removeAllListeners('data')
|
||||
streams.stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const observed = guardFastPathRepairAllocations(80, 20)
|
||||
const fastPathsBefore = sourceScrollFastPathStats.taken
|
||||
const capturedBefore = sourceScrollFastPathStats.captured
|
||||
|
||||
expect(() => expose.current!.scroll!.scrollTo(1)).not.toThrow()
|
||||
expect(() =>
|
||||
instance.rerender(React.createElement(FastPathRepairHarness, { expose, includeOverlay: false, tick: 2 }))
|
||||
).not.toThrow()
|
||||
await delay(40)
|
||||
|
||||
expect(sourceScrollFastPathStats.captured, JSON.stringify(sourceScrollFastPathStats)).toBeGreaterThan(
|
||||
capturedBefore
|
||||
)
|
||||
expect(sourceScrollFastPathStats.taken, JSON.stringify(sourceScrollFastPathStats)).toBeGreaterThan(
|
||||
fastPathsBefore
|
||||
)
|
||||
expect(observed.repairWhitespaceWrites).toBeGreaterThan(0)
|
||||
expect(observed.largestRepeat).toBeGreaterThan(0)
|
||||
expect(observed.largestArrayRows).toBeGreaterThan(0)
|
||||
|
||||
output = ''
|
||||
expect(() =>
|
||||
instance.rerender(
|
||||
React.createElement(FastPathRepairHarness, {
|
||||
dirtyTick: 2,
|
||||
expose,
|
||||
includeOverlay: false,
|
||||
tick: 3
|
||||
})
|
||||
)
|
||||
).not.toThrow()
|
||||
await delay(40)
|
||||
|
||||
expect(observed.largestRepeat).toBeLessThanOrEqual(80)
|
||||
expect(observed.largestArrayRows).toBeLessThanOrEqual(20)
|
||||
expect(observed.largestBlitWidth).toBeLessThanOrEqual(80)
|
||||
expect(observed.largestBlitHeight).toBeLessThanOrEqual(20)
|
||||
expect(observed.largestClearWidth).toBeLessThanOrEqual(80)
|
||||
expect(observed.largestClearHeight).toBeLessThanOrEqual(20)
|
||||
expect(observed.largestWrite).toBeLessThanOrEqual(1_620)
|
||||
expect(expose.current!.scroll!.getScrollTop()).toBe(1)
|
||||
expect(expose.current!.scroll!.getViewportHeight()).toBeGreaterThan(0)
|
||||
expect(output).toContain('adjacent-fast-path-3')
|
||||
expect(streams.stderr.read()?.toString() ?? '').toBe('')
|
||||
} finally {
|
||||
vi.restoreAllMocks()
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('clips corrupt absolute-overlay DECSTBM repairs before allocating or recursing', async () => {
|
||||
const expose = {
|
||||
current: {
|
||||
adjacent: null,
|
||||
dirtyChild: null,
|
||||
overlay: null,
|
||||
scroll: null,
|
||||
scrollBox: null
|
||||
} as FastPathRepairExpose
|
||||
}
|
||||
|
||||
const streams = makeStreams()
|
||||
let output = ''
|
||||
|
||||
const instance = renderSourceSync(React.createElement(FastPathRepairHarness, { expose, tick: 0 }), {
|
||||
patchConsole: false,
|
||||
stderr: streams.stderr as NodeJS.WriteStream,
|
||||
stdin: streams.stdin as NodeJS.ReadStream,
|
||||
stdout: streams.stdout as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
try {
|
||||
await delay(20)
|
||||
const overlay = expose.current!.overlay!
|
||||
const scrollBox = expose.current!.scrollBox!
|
||||
|
||||
expect(overlay, streams.stderr.read()?.toString()).not.toBeNull()
|
||||
expect(overlay.yogaNode).toBeDefined()
|
||||
expect(scrollBox.yogaNode).toBeDefined()
|
||||
vi.spyOn(scrollBox.yogaNode!, 'getComputedWidth').mockReturnValue(100_000_000)
|
||||
vi.spyOn(overlay.yogaNode!, 'getComputedWidth').mockReturnValue(100_000_000)
|
||||
vi.spyOn(overlay.yogaNode!, 'getComputedHeight').mockReturnValue(100_000_000)
|
||||
|
||||
instance.rerender(React.createElement(FastPathRepairHarness, { expose, tick: 1 }))
|
||||
await delay(20)
|
||||
instance.rerender(React.createElement(FastPathRepairHarness, { expose, tick: 1 }))
|
||||
await delay(20)
|
||||
|
||||
streams.stdout.removeAllListeners('data')
|
||||
streams.stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const observed = guardFastPathRepairAllocations(80, 20)
|
||||
const fastPathsBefore = sourceScrollFastPathStats.taken
|
||||
const capturedBefore = sourceScrollFastPathStats.captured
|
||||
|
||||
expect(() => expose.current!.scroll!.scrollTo(1)).not.toThrow()
|
||||
expect(expose.current!.scroll!.getScrollTop()).toBe(1)
|
||||
await delay(40)
|
||||
|
||||
expect(sourceScrollFastPathStats.captured, JSON.stringify(sourceScrollFastPathStats)).toBeGreaterThan(
|
||||
capturedBefore
|
||||
)
|
||||
expect(sourceScrollFastPathStats.taken, JSON.stringify(sourceScrollFastPathStats)).toBeGreaterThan(
|
||||
fastPathsBefore
|
||||
)
|
||||
expect(observed.repairWhitespaceWrites).toBeGreaterThan(0)
|
||||
expect(observed.largestRepeat).toBeGreaterThan(0)
|
||||
expect(observed.largestArrayRows).toBeGreaterThan(0)
|
||||
|
||||
output = ''
|
||||
expect(() =>
|
||||
instance.rerender(React.createElement(FastPathRepairHarness, { dirtyTick: 1, expose, tick: 2 }))
|
||||
).not.toThrow()
|
||||
await delay(40)
|
||||
|
||||
expect(observed.largestRepeat).toBeLessThanOrEqual(80)
|
||||
expect(observed.largestArrayRows).toBeLessThanOrEqual(20)
|
||||
expect(observed.largestBlitWidth).toBeLessThanOrEqual(80)
|
||||
expect(observed.largestBlitHeight).toBeLessThanOrEqual(20)
|
||||
expect(observed.largestClearWidth).toBeLessThanOrEqual(80)
|
||||
expect(observed.largestClearHeight).toBeLessThanOrEqual(20)
|
||||
expect(observed.largestWrite).toBeLessThanOrEqual(1_620)
|
||||
expect(output.length).toBeLessThan(2_000)
|
||||
expect(expose.current!.scroll!.getViewportHeight()).toBeGreaterThan(0)
|
||||
expect(output).toContain('adjacent-fast-path-2')
|
||||
expect(streams.stderr.read()?.toString() ?? '').toBe('')
|
||||
} finally {
|
||||
vi.restoreAllMocks()
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { findSlashCommand, SLASH_COMMANDS } from '../app/slash/registry.js'
|
||||
import { parseSlashCommand } from '../domain/slash.js'
|
||||
|
||||
type CommandRoute = 'fallback' | 'local' | 'native'
|
||||
|
||||
interface CommandRegistryLoad {
|
||||
error?: string
|
||||
names: string[]
|
||||
}
|
||||
|
||||
const NATIVE_MUTATING_COMMANDS = new Set(['browser', 'busy', 'fast', 'reload-mcp', 'rollback', 'stop'])
|
||||
|
||||
const MUTATING_COMMANDS = [
|
||||
'bg',
|
||||
'btw',
|
||||
'branch',
|
||||
'browser',
|
||||
'busy',
|
||||
'clear',
|
||||
'compress',
|
||||
'fast',
|
||||
'model',
|
||||
'new',
|
||||
'personality',
|
||||
'queue',
|
||||
'reasoning',
|
||||
'reload-mcp',
|
||||
'retry',
|
||||
'rollback',
|
||||
'steer',
|
||||
'stop',
|
||||
'title',
|
||||
'tools',
|
||||
'undo',
|
||||
'verbose',
|
||||
'voice',
|
||||
'yolo'
|
||||
] as const
|
||||
|
||||
const loadCommandRegistryNames = (): CommandRegistryLoad => {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
try {
|
||||
const names = JSON.parse(
|
||||
execFileSync(
|
||||
process.env.PYTHON ?? 'python3',
|
||||
[
|
||||
'-c',
|
||||
'import json; from hermes_cli.commands import COMMAND_REGISTRY; print(json.dumps([c.name for c in COMMAND_REGISTRY]))'
|
||||
],
|
||||
{ cwd: resolve(here, '../../..'), encoding: 'utf8' }
|
||||
)
|
||||
) as string[]
|
||||
|
||||
return { names: [...new Set(names)] }
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
names: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commandRegistry = loadCommandRegistryNames()
|
||||
const registryIt = commandRegistry.error ? it.skip : it
|
||||
const skipReason = commandRegistry.error ? commandRegistry.error.split('\n')[0] : ''
|
||||
|
||||
const LOCAL_COMMAND_NAMES = new Set(
|
||||
SLASH_COMMANDS.flatMap(command => [command.name, ...(command.aliases ?? [])].map(name => name.toLowerCase()))
|
||||
)
|
||||
|
||||
const classifyRoute = (name: string): CommandRoute => {
|
||||
const normalized = name.toLowerCase()
|
||||
|
||||
if (NATIVE_MUTATING_COMMANDS.has(normalized)) {
|
||||
return 'native'
|
||||
}
|
||||
|
||||
if (LOCAL_COMMAND_NAMES.has(normalized)) {
|
||||
return 'local'
|
||||
}
|
||||
|
||||
return 'fallback'
|
||||
}
|
||||
|
||||
describe('slash parity matrix', () => {
|
||||
if (commandRegistry.error) {
|
||||
it.skip(`Python command registry unavailable: ${skipReason}`, () => {})
|
||||
}
|
||||
|
||||
registryIt('classifies each command registry command as local/native/fallback', () => {
|
||||
const routes = Object.fromEntries(commandRegistry.names.map(name => [name, classifyRoute(name)]))
|
||||
|
||||
expect(routes['model']).toBe('local')
|
||||
expect(routes['browser']).toBe('native')
|
||||
expect(routes['reload-mcp']).toBe('native')
|
||||
expect(routes['rollback']).toBe('native')
|
||||
expect(routes['stop']).toBe('native')
|
||||
})
|
||||
|
||||
registryIt('keeps every mutating command off slash-worker fallback', () => {
|
||||
const routes = Object.fromEntries(commandRegistry.names.map(name => [name, classifyRoute(name)]))
|
||||
|
||||
for (const name of MUTATING_COMMANDS) {
|
||||
expect(routes[name], `missing command in registry: ${name}`).toBeDefined()
|
||||
expect(routes[name], `mutating command must not fallback: ${name}`).not.toBe('fallback')
|
||||
}
|
||||
})
|
||||
|
||||
it('/q alias resolves to queue, not quit (#31983)', () => {
|
||||
// Regression for #31983: the TUI `quit` command used to carry alias `q`,
|
||||
// which collided with the Python-side `/queue` alias. TUI-local commands
|
||||
// dispatch before the backend, so `/q` resolved to /quit (session.die)
|
||||
// instead of queueing a prompt.
|
||||
const cmd = findSlashCommand('q')
|
||||
expect(cmd, '/q must resolve to a command').toBeDefined()
|
||||
expect(cmd!.name).toBe('queue')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseSlashCommand argument fidelity', () => {
|
||||
it('keeps a multi-line argument byte-for-byte', () => {
|
||||
const arg = 'first line\nsecond line\n\n indented tail'
|
||||
|
||||
expect(parseSlashCommand(`/pr-triage ${arg}`)).toEqual({
|
||||
arg,
|
||||
cmd: `/pr-triage ${arg}`,
|
||||
name: 'pr-triage'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves runs of spaces inside the argument', () => {
|
||||
expect(parseSlashCommand('/goal ship it').arg).toBe('ship it')
|
||||
})
|
||||
|
||||
it('still splits the command name off a single separator', () => {
|
||||
expect(parseSlashCommand('/cron add daily')).toEqual({
|
||||
arg: 'add daily',
|
||||
cmd: '/cron add daily',
|
||||
name: 'cron'
|
||||
})
|
||||
expect(parseSlashCommand('/exit')).toEqual({ arg: '', cmd: '/exit', name: 'exit' })
|
||||
expect(parseSlashCommand('/exit ')).toEqual({ arg: '', cmd: '/exit ', name: 'exit' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { clearSpawnHistory, getSpawnHistory, pushDiskSnapshot } from '../app/spawnHistoryStore.js'
|
||||
|
||||
describe('spawnHistoryStore status normalization', () => {
|
||||
beforeEach(() => {
|
||||
clearSpawnHistory()
|
||||
})
|
||||
|
||||
it('keeps timeout/error statuses from disk snapshots', () => {
|
||||
pushDiskSnapshot(
|
||||
{
|
||||
finished_at: 1_700_000_001,
|
||||
label: 'status test',
|
||||
session_id: 'sess-1',
|
||||
started_at: 1_700_000_000,
|
||||
subagents: [
|
||||
{ goal: 'timeout child', id: 'sa-timeout', index: 0, status: 'timeout' },
|
||||
{ goal: 'error child', id: 'sa-error', index: 1, status: 'error' }
|
||||
]
|
||||
},
|
||||
'/tmp/snap-timeout-error.json'
|
||||
)
|
||||
|
||||
const statuses = getSpawnHistory()[0]?.subagents.map(s => s.status)
|
||||
|
||||
expect(statuses).toEqual(['timeout', 'error'])
|
||||
})
|
||||
|
||||
it('falls back unknown disk statuses to completed', () => {
|
||||
pushDiskSnapshot(
|
||||
{
|
||||
finished_at: 1_700_000_011,
|
||||
label: 'unknown status test',
|
||||
session_id: 'sess-2',
|
||||
started_at: 1_700_000_010,
|
||||
subagents: [{ goal: 'mystery child', id: 'sa-unknown', index: 0, status: 'mystery_status' }]
|
||||
},
|
||||
'/tmp/snap-unknown.json'
|
||||
)
|
||||
|
||||
const status = getSpawnHistory()[0]?.subagents[0]?.status
|
||||
|
||||
expect(status).toBe('completed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { patchTurnState, resetTurnState } from '../app/turnStore.js'
|
||||
import { $uiState, resetUiState } from '../app/uiStore.js'
|
||||
|
||||
const shallowEqual = <T extends Record<string, unknown>>(a: T, b: T) =>
|
||||
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(key => Object.is(a[key], b[key]))
|
||||
|
||||
const subscribeSelected = <T extends Record<string, unknown>>(selector: () => T) => {
|
||||
let current = selector()
|
||||
let calls = 0
|
||||
|
||||
const unsubscribe = $uiState.listen(() => {
|
||||
const next = selector()
|
||||
|
||||
if (shallowEqual(next, current)) {
|
||||
return
|
||||
}
|
||||
|
||||
current = next
|
||||
calls++
|
||||
})
|
||||
|
||||
return { calls: () => calls, unsubscribe }
|
||||
}
|
||||
|
||||
describe('TUI state isolation', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
resetTurnState()
|
||||
})
|
||||
|
||||
it('does not notify ui/composer subscribers for high-frequency turn updates', () => {
|
||||
const composerRelevant = subscribeSelected(() => ({ busy: $uiState.get().busy, sid: $uiState.get().sid }))
|
||||
|
||||
try {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
patchTurnState({ streaming: `token ${i}` })
|
||||
}
|
||||
} finally {
|
||||
composerRelevant.unsubscribe()
|
||||
}
|
||||
|
||||
expect(composerRelevant.calls()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { padVerb, VERB_PAD_LEN } from '../components/appChrome.js'
|
||||
import { VERBS } from '../content/verbs.js'
|
||||
|
||||
describe('FaceTicker verb padding', () => {
|
||||
it('pads every verb to the same width', () => {
|
||||
for (const verb of VERBS) {
|
||||
expect(padVerb(verb)).toHaveLength(VERB_PAD_LEN)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps trailing ellipsis attached', () => {
|
||||
for (const verb of VERBS) {
|
||||
expect(padVerb(verb).startsWith(`${verb}…`)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { StatusBarSegments } from '../components/appChrome.js'
|
||||
import { busyIndicatorWidth, statusBarSegments, statusRuleWidths } from '../components/appChrome.js'
|
||||
|
||||
describe('statusRuleWidths', () => {
|
||||
it('keeps the status rule within the terminal width', () => {
|
||||
for (const cols of [8, 12, 20, 40, 100]) {
|
||||
const widths = statusRuleWidths(cols, '~/src/hermes-agent/main (some-long-branch-name)')
|
||||
|
||||
expect(widths.leftWidth + widths.separatorWidth + widths.rightWidth).toBeLessThanOrEqual(cols)
|
||||
expect(widths.leftWidth).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('truncates the cwd segment before it can wrap in skinny terminals', () => {
|
||||
const widths = statusRuleWidths(24, '~/src/hermes-agent/main (bb/some-extremely-long-branch)')
|
||||
|
||||
expect(widths.rightWidth).toBeLessThan('~/src/hermes-agent/main (bb/some-extremely-long-branch)'.length)
|
||||
expect(widths.leftWidth).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
|
||||
it('omits the cwd segment when there is no room for it', () => {
|
||||
expect(statusRuleWidths(2, 'abcdef')).toEqual({ leftWidth: 2, rightWidth: 0, separatorWidth: 0 })
|
||||
})
|
||||
|
||||
it('budgets the cwd segment by display width, not utf-16 length', () => {
|
||||
const widths = statusRuleWidths(30, '目录/分支')
|
||||
|
||||
expect(widths.leftWidth + widths.separatorWidth + widths.rightWidth).toBeLessThanOrEqual(30)
|
||||
expect(widths.rightWidth).toBeGreaterThan('目录/分支'.length)
|
||||
})
|
||||
|
||||
it('reserves the high-priority left content so the cwd/branch yields first', () => {
|
||||
const cwd = '~/src/hermes-agent/apps/desktop (bb/tui-statusbar-responsive)'
|
||||
|
||||
const greedy = statusRuleWidths(70, cwd) // legacy behaviour: cwd hogs the row
|
||||
const reserved = statusRuleWidths(70, cwd, 40) // reserve indicator+model+ctx
|
||||
|
||||
expect(reserved.leftWidth).toBeGreaterThanOrEqual(40)
|
||||
expect(reserved.leftWidth).toBeGreaterThan(greedy.leftWidth)
|
||||
expect(reserved.rightWidth).toBeLessThan(greedy.rightWidth)
|
||||
expect(reserved.leftWidth + reserved.separatorWidth + reserved.rightWidth).toBeLessThanOrEqual(70)
|
||||
})
|
||||
|
||||
it('drops the cwd entirely when the essential left content needs the whole row', () => {
|
||||
expect(statusRuleWidths(40, '~/some/cwd (branch)', 60)).toEqual({
|
||||
leftWidth: 40,
|
||||
rightWidth: 0,
|
||||
separatorWidth: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the default (no reservation) behaviour identical for legacy callers', () => {
|
||||
const cwd = '~/src/hermes-agent/main (some-long-branch-name)'
|
||||
|
||||
expect(statusRuleWidths(80, cwd, 0)).toEqual(statusRuleWidths(80, cwd))
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusBarSegments', () => {
|
||||
it('shows every segment on a wide terminal', () => {
|
||||
const s = statusBarSegments(120)
|
||||
|
||||
expect(s).toEqual({
|
||||
compactCtx: false,
|
||||
bar: true,
|
||||
duration: true,
|
||||
compressions: true,
|
||||
voice: true,
|
||||
bg: true,
|
||||
subagents: true,
|
||||
cacheHit: true,
|
||||
latency: true,
|
||||
tps: true
|
||||
} satisfies StatusBarSegments)
|
||||
})
|
||||
|
||||
it('sheds cache/latency/tps read-outs first as the terminal narrows', () => {
|
||||
// 96/104/110-col breakpoints: these are the lowest-priority perf
|
||||
// read-outs, so they disappear before any pre-existing segment.
|
||||
expect(statusBarSegments(108)).toMatchObject({ cacheHit: true, latency: true, tps: false })
|
||||
expect(statusBarSegments(100)).toMatchObject({ cacheHit: true, latency: false, tps: false })
|
||||
expect(statusBarSegments(94)).toMatchObject({ cacheHit: false, latency: false, tps: false, subagents: true })
|
||||
})
|
||||
|
||||
it('collapses the context bar to a token count on narrow terminals', () => {
|
||||
const s = statusBarSegments(60)
|
||||
|
||||
expect(s.compactCtx).toBe(true)
|
||||
expect(s.bar).toBe(false)
|
||||
expect(s.duration).toBe(false)
|
||||
})
|
||||
|
||||
it('sheds tail segments in priority order as the terminal narrows', () => {
|
||||
// the context bar is the last of the tail to go.
|
||||
const order: (keyof ReturnType<typeof statusBarSegments>)[] = [
|
||||
'bar',
|
||||
'duration',
|
||||
'compressions',
|
||||
'voice',
|
||||
'bg',
|
||||
'subagents'
|
||||
]
|
||||
|
||||
let prevCount = Infinity
|
||||
|
||||
for (const cols of [120, 95, 87, 83, 79, 75, 71]) {
|
||||
const s = statusBarSegments(cols)
|
||||
const visible = order.filter(k => s[k]).length
|
||||
|
||||
expect(visible).toBeLessThanOrEqual(prevCount)
|
||||
prevCount = visible
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('busyIndicatorWidth', () => {
|
||||
it('reserves a bare spinner for the verb-less unicode style', () => {
|
||||
// unicode is a 1-col braille spinner with no verb; far slimmer than the
|
||||
// kaomoji face which carries a wide glyph + rotating verb.
|
||||
expect(busyIndicatorWidth('unicode', false)).toBeLessThan(busyIndicatorWidth('kaomoji', false))
|
||||
expect(busyIndicatorWidth('unicode', false)).toBe(1)
|
||||
})
|
||||
|
||||
it('reserves room for the elapsed-time tail only when a turn is timed', () => {
|
||||
for (const style of ['kaomoji', 'emoji', 'ascii', 'unicode'] as const) {
|
||||
expect(busyIndicatorWidth(style, true)).toBeGreaterThan(busyIndicatorWidth(style, false))
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
import { PassThrough } from 'stream'
|
||||
|
||||
import { Box, renderSync } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { Md } from '../components/markdown.js'
|
||||
import { advanceScan, createScanState, findStableBoundary } from '../components/streamingMarkdown.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
const BEL = String.fromCharCode(7)
|
||||
const ESC = String.fromCharCode(27)
|
||||
const CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g')
|
||||
const OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:${BEL}|${ESC}\\\\)`, 'g')
|
||||
|
||||
const renderPlain = (node: React.ReactNode) => {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(node, {
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
|
||||
return output
|
||||
.replace(OSC_RE, '')
|
||||
.split('\n')
|
||||
.map(line => stripAnsi(line).replace(CSI_RE, '').trimEnd())
|
||||
}
|
||||
|
||||
describe('findStableBoundary', () => {
|
||||
it('returns -1 when no blank line exists yet', () => {
|
||||
expect(findStableBoundary('partial line with no newline yet')).toBe(-1)
|
||||
})
|
||||
|
||||
it('returns -1 when only single newlines exist', () => {
|
||||
expect(findStableBoundary('line one\nline two\nline three')).toBe(-1)
|
||||
})
|
||||
|
||||
it('splits after the last blank line separator', () => {
|
||||
// 'first\n\nsecond\n\nthird' → last blank = before 'third'
|
||||
const text = 'first paragraph\n\nsecond paragraph\n\nthird'
|
||||
const idx = findStableBoundary(text)
|
||||
|
||||
expect(text.slice(0, idx)).toBe('first paragraph\n\nsecond paragraph\n\n')
|
||||
expect(text.slice(idx)).toBe('third')
|
||||
})
|
||||
|
||||
it('refuses to split inside an open fenced block', () => {
|
||||
// Fence opens, contains a blank line inside the code, no close yet.
|
||||
const text = '```ts\nfn();\n\nmore code here'
|
||||
|
||||
expect(findStableBoundary(text)).toBe(-1)
|
||||
})
|
||||
|
||||
it('splits before an open fenced block but not inside', () => {
|
||||
const text = 'intro paragraph\n\n```ts\nfn();\n\nmore code'
|
||||
const idx = findStableBoundary(text)
|
||||
|
||||
expect(text.slice(0, idx)).toBe('intro paragraph\n\n')
|
||||
expect(text.slice(idx).startsWith('```ts')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows splitting after a fenced block closes', () => {
|
||||
const text = '```ts\nfn();\n```\n\nnarration continues'
|
||||
const idx = findStableBoundary(text)
|
||||
|
||||
expect(text.slice(0, idx)).toBe('```ts\nfn();\n```\n\n')
|
||||
expect(text.slice(idx)).toBe('narration continues')
|
||||
})
|
||||
|
||||
it('walks backwards through nested fence boundaries safely', () => {
|
||||
// Two closed fences + narration + one new open fence. The only legal
|
||||
// split is before the open fence, not between the closed ones.
|
||||
const text = '```js\na\n```\n\nmid text\n\n```python\nstill open'
|
||||
const idx = findStableBoundary(text)
|
||||
|
||||
expect(text.slice(0, idx)).toBe('```js\na\n```\n\nmid text\n\n')
|
||||
})
|
||||
|
||||
it('handles empty input', () => {
|
||||
expect(findStableBoundary('')).toBe(-1)
|
||||
})
|
||||
|
||||
it('refuses to split inside an open $$ math block', () => {
|
||||
// Display math has been opened but not closed; the only blank line
|
||||
// sits inside the open block, so there's no safe boundary yet.
|
||||
const text = '$$\nx + y\n\nmore math'
|
||||
|
||||
expect(findStableBoundary(text)).toBe(-1)
|
||||
})
|
||||
|
||||
it('allows splitting after a $$ math block closes', () => {
|
||||
const text = '$$\nx + y = z\n$$\n\nnarration continues'
|
||||
const idx = findStableBoundary(text)
|
||||
|
||||
expect(text.slice(0, idx)).toBe('$$\nx + y = z\n$$\n\n')
|
||||
expect(text.slice(idx)).toBe('narration continues')
|
||||
})
|
||||
|
||||
it('splits before an open $$ block but not inside', () => {
|
||||
// Mirror of the existing fenced-code test: prose, then an unclosed
|
||||
// math block. The only safe boundary is the blank line BEFORE `$$`.
|
||||
const text = 'intro paragraph\n\n$$\nx + y\n\nmore'
|
||||
const idx = findStableBoundary(text)
|
||||
|
||||
expect(text.slice(0, idx)).toBe('intro paragraph\n\n')
|
||||
expect(text.slice(idx).startsWith('$$')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats single-line $$x$$ as zero net toggle', () => {
|
||||
// `$$x = y$$` opens AND closes on one line, so the stable boundary
|
||||
// after it is allowed.
|
||||
const text = 'intro\n\n$$x = y$$\n\nnarration'
|
||||
const idx = findStableBoundary(text)
|
||||
|
||||
expect(text.slice(0, idx)).toBe('intro\n\n$$x = y$$\n\n')
|
||||
expect(text.slice(idx)).toBe('narration')
|
||||
})
|
||||
|
||||
it('refuses to split inside an open \\[ math block', () => {
|
||||
const text = '\\[\nx + y\n\nmore'
|
||||
|
||||
expect(findStableBoundary(text)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
// A corpus exercising every construct the boundary scanner must respect:
|
||||
// paragraphs, fenced code (with blank lines and $$ bait inside), display
|
||||
// math ($$ and \[), setext headings, tables, lists, quotes, headings.
|
||||
const CORPUS = [
|
||||
'Intro paragraph explaining the plan in some detail.\n',
|
||||
'\nSection Title\n=============\n',
|
||||
'\nA paragraph before code.\n',
|
||||
'\n```ts\nconst a = 1\n\nconst b = 2\n// $$ not math $$\n```\n',
|
||||
'\nBetween-blocks narration.\n',
|
||||
'\n$$\nE = mc^2\n\n\\sum_i x_i\n$$\n',
|
||||
'\n- item one\n- item two\n\n1. first\n2. second\n',
|
||||
'\n| a | b |\n|---|---|\n| 1 | 2 |\n',
|
||||
'\n> quoted wisdom\n> second line\n',
|
||||
'\n\\[\nx^2 + y^2 = z^2\n\\]\n',
|
||||
'\n## Closing heading\n',
|
||||
'\nFinal paragraph without a trailing newline'
|
||||
].join('')
|
||||
|
||||
const mulberry32 = (seed: number) => () => {
|
||||
seed |= 0
|
||||
seed = (seed + 0x6d2b79f5) | 0
|
||||
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
|
||||
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
|
||||
describe('advanceScan (incremental scanner)', () => {
|
||||
it('reconstructs the input exactly: blocks + tail === text', () => {
|
||||
const state = createScanState()
|
||||
|
||||
advanceScan(CORPUS, state)
|
||||
|
||||
expect(state.blocks.join('')).toHaveLength(state.settledLen)
|
||||
expect(state.blocks.join('') + CORPUS.slice(state.settledLen)).toBe(CORPUS)
|
||||
expect(state.blocks.length).toBeGreaterThan(5)
|
||||
})
|
||||
|
||||
it('produces identical blocks fed incrementally at arbitrary cut points', () => {
|
||||
const oneShot = createScanState()
|
||||
|
||||
advanceScan(CORPUS, oneShot)
|
||||
|
||||
for (let seed = 1; seed <= 8; seed++) {
|
||||
const rand = mulberry32(seed)
|
||||
const state = createScanState()
|
||||
|
||||
let pos = 0
|
||||
|
||||
while (pos < CORPUS.length) {
|
||||
pos = Math.min(CORPUS.length, pos + 1 + Math.floor(rand() * 7))
|
||||
|
||||
const prevBlocks = state.blocks.length
|
||||
|
||||
advanceScan(CORPUS.slice(0, pos), state)
|
||||
|
||||
// Append-only: previously committed blocks never change.
|
||||
expect(state.blocks.length).toBeGreaterThanOrEqual(prevBlocks)
|
||||
}
|
||||
|
||||
expect(state.blocks).toEqual(oneShot.blocks)
|
||||
expect(state.settledLen).toBe(oneShot.settledLen)
|
||||
}
|
||||
})
|
||||
|
||||
it('is idempotent when called again with the same text', () => {
|
||||
const state = createScanState()
|
||||
|
||||
advanceScan(CORPUS, state)
|
||||
|
||||
const blocks = [...state.blocks]
|
||||
|
||||
advanceScan(CORPUS, state)
|
||||
|
||||
expect(state.blocks).toEqual(blocks)
|
||||
})
|
||||
|
||||
it('holds a partial trailing line in the tail even if it looks fence-like', () => {
|
||||
// "``" could grow into "```ts" — the scanner must not judge the line
|
||||
// until its newline arrives.
|
||||
const state = createScanState()
|
||||
|
||||
advanceScan('para\n\n``', state)
|
||||
|
||||
expect(state.blocks).toEqual(['para\n\n'])
|
||||
expect(state.codeOpen).toBe(false)
|
||||
|
||||
advanceScan('para\n\n```ts\ncode\n\nstill code', state)
|
||||
|
||||
// The blank line inside the now-open fence must not commit a block.
|
||||
expect(state.blocks).toEqual(['para\n\n'])
|
||||
expect(state.codeOpen).toBe(true)
|
||||
|
||||
advanceScan('para\n\n```ts\ncode\n\nstill code\n```\n\nafter\n\n', state)
|
||||
|
||||
expect(state.blocks).toEqual(['para\n\n', '```ts\ncode\n\nstill code\n```\n\n', 'after\n\n'])
|
||||
expect(state.codeOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('does not commit whitespace-only blocks on 3+ newline runs', () => {
|
||||
const state = createScanState()
|
||||
|
||||
advanceScan('alpha\n\n\n\nbeta\n\n', state)
|
||||
|
||||
expect(state.blocks).toEqual(['alpha\n\n', '\n\nbeta\n\n'])
|
||||
expect(state.blocks.join('')).toBe('alpha\n\n\n\nbeta\n\n')
|
||||
})
|
||||
|
||||
it('keeps a setext heading contiguous with its paragraph', () => {
|
||||
// A setext underline attaches to the line above; the only committed
|
||||
// boundary is the blank line, so the pair can never be torn apart or
|
||||
// retroactively merged (the desktop splitter needed a fix for this,
|
||||
// #67176 — blank-line boundaries are immune by construction).
|
||||
const state = createScanState()
|
||||
|
||||
advanceScan('Title\n', state)
|
||||
advanceScan('Title\n====\n', state)
|
||||
advanceScan('Title\n====\n\nbody\n\n', state)
|
||||
|
||||
expect(state.blocks).toEqual(['Title\n====\n\n', 'body\n\n'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('StreamingMd rendering equivalence', () => {
|
||||
it('settled blocks + tail render identically to one combined Md', () => {
|
||||
const state = createScanState()
|
||||
|
||||
advanceScan(CORPUS, state)
|
||||
|
||||
const tail = CORPUS.slice(state.settledLen)
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
const split = renderPlain(
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: 'column' },
|
||||
...state.blocks.map((block, i) => React.createElement(Md, { key: i, t, text: block })),
|
||||
tail ? React.createElement(Md, { key: 'tail', t, text: tail }) : null
|
||||
)
|
||||
)
|
||||
|
||||
const combined = renderPlain(React.createElement(Md, { t, text: CORPUS }))
|
||||
|
||||
expect(split).toEqual(combined)
|
||||
})
|
||||
|
||||
it('renders split/combined identically at every streamed step', () => {
|
||||
const rand = mulberry32(42)
|
||||
const state = createScanState()
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
let pos = 0
|
||||
|
||||
while (pos < CORPUS.length) {
|
||||
pos = Math.min(CORPUS.length, pos + 24 + Math.floor(rand() * 200))
|
||||
|
||||
const text = CORPUS.slice(0, pos)
|
||||
|
||||
advanceScan(text, state)
|
||||
|
||||
const tail = text.slice(state.settledLen)
|
||||
|
||||
const split = renderPlain(
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: 'column' },
|
||||
...state.blocks.map((block, i) => React.createElement(Md, { key: i, t, text: block })),
|
||||
tail ? React.createElement(Md, { key: 'tail', t, text: tail }) : null
|
||||
)
|
||||
)
|
||||
|
||||
const combined = renderPlain(React.createElement(Md, { t, text }))
|
||||
|
||||
expect(split).toEqual(combined)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,407 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildSubagentTree,
|
||||
descendantIds,
|
||||
flattenTree,
|
||||
fmtCost,
|
||||
fmtDuration,
|
||||
fmtTokens,
|
||||
formatSummary,
|
||||
hotnessBucket,
|
||||
peakHotness,
|
||||
sparkline,
|
||||
topLevelSubagents,
|
||||
treeTotals,
|
||||
widthByDepth
|
||||
} from '../lib/subagentTree.js'
|
||||
import type { SubagentProgress } from '../types.js'
|
||||
|
||||
const makeItem = (overrides: Partial<SubagentProgress> & Pick<SubagentProgress, 'id' | 'index'>): SubagentProgress => ({
|
||||
depth: 0,
|
||||
goal: overrides.id,
|
||||
notes: [],
|
||||
parentId: null,
|
||||
status: 'running',
|
||||
taskCount: 1,
|
||||
thinking: [],
|
||||
toolCount: 0,
|
||||
tools: [],
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('aggregate: tokens, cost, files, hotness', () => {
|
||||
it('sums tokens and cost across subtree', () => {
|
||||
const items = [
|
||||
makeItem({ costUsd: 0.01, id: 'p', index: 0, inputTokens: 1000, outputTokens: 500 }),
|
||||
makeItem({
|
||||
costUsd: 0.005,
|
||||
depth: 1,
|
||||
id: 'c1',
|
||||
index: 0,
|
||||
inputTokens: 500,
|
||||
outputTokens: 100,
|
||||
parentId: 'p'
|
||||
}),
|
||||
makeItem({
|
||||
costUsd: 0.008,
|
||||
depth: 1,
|
||||
id: 'c2',
|
||||
index: 1,
|
||||
inputTokens: 300,
|
||||
outputTokens: 200,
|
||||
parentId: 'p'
|
||||
})
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate).toMatchObject({
|
||||
costUsd: 0.023,
|
||||
inputTokens: 1800,
|
||||
outputTokens: 800
|
||||
})
|
||||
})
|
||||
|
||||
it('counts files read + written across subtree', () => {
|
||||
const items = [
|
||||
makeItem({ filesRead: ['a.ts', 'b.ts'], id: 'p', index: 0 }),
|
||||
makeItem({ depth: 1, filesWritten: ['c.ts'], id: 'c', index: 0, parentId: 'p' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate.filesTouched).toBe(3)
|
||||
})
|
||||
|
||||
it('hotness = totalTools / totalDuration', () => {
|
||||
const items = [
|
||||
makeItem({
|
||||
durationSeconds: 10,
|
||||
id: 'p',
|
||||
index: 0,
|
||||
status: 'completed',
|
||||
toolCount: 20
|
||||
})
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate.hotness).toBeCloseTo(2)
|
||||
})
|
||||
|
||||
it('hotness is zero when duration is zero', () => {
|
||||
const items = [makeItem({ id: 'p', index: 0, toolCount: 10 })]
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate.hotness).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hotnessBucket + peakHotness', () => {
|
||||
it('peakHotness walks subtree', () => {
|
||||
const items = [
|
||||
makeItem({ durationSeconds: 100, id: 'p', index: 0, status: 'completed', toolCount: 1 }),
|
||||
makeItem({
|
||||
depth: 1,
|
||||
durationSeconds: 1,
|
||||
id: 'c',
|
||||
index: 0,
|
||||
parentId: 'p',
|
||||
status: 'completed',
|
||||
toolCount: 5
|
||||
})
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(peakHotness(tree)).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
it('hotnessBucket clamps and normalizes', () => {
|
||||
expect(hotnessBucket(0, 10, 4)).toBe(0)
|
||||
expect(hotnessBucket(10, 10, 4)).toBe(3)
|
||||
expect(hotnessBucket(5, 10, 4)).toBe(2)
|
||||
expect(hotnessBucket(100, 10, 4)).toBe(3) // clamped
|
||||
expect(hotnessBucket(5, 0, 4)).toBe(0) // guard against divide-by-zero
|
||||
})
|
||||
})
|
||||
|
||||
describe('fmtCost + fmtTokens', () => {
|
||||
it('fmtCost handles ranges', () => {
|
||||
expect(fmtCost(0)).toBe('')
|
||||
expect(fmtCost(0.001)).toBe('<$0.01')
|
||||
expect(fmtCost(0.42)).toBe('$0.42')
|
||||
expect(fmtCost(1.23)).toBe('$1.23')
|
||||
expect(fmtCost(12.5)).toBe('$12.5')
|
||||
})
|
||||
|
||||
it('fmtTokens handles ranges', () => {
|
||||
expect(fmtTokens(0)).toBe('0')
|
||||
expect(fmtTokens(542)).toBe('542')
|
||||
expect(fmtTokens(1234)).toBe('1.2k')
|
||||
expect(fmtTokens(45678)).toBe('46k')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSummary with tokens', () => {
|
||||
it('includes tokens but not cost', () => {
|
||||
expect(
|
||||
formatSummary({
|
||||
activeCount: 0,
|
||||
costUsd: 0.42,
|
||||
descendantCount: 3,
|
||||
filesTouched: 0,
|
||||
hotness: 0,
|
||||
inputTokens: 8000,
|
||||
maxDepthFromHere: 2,
|
||||
outputTokens: 2000,
|
||||
totalDuration: 30,
|
||||
totalTools: 14
|
||||
})
|
||||
).toBe('d2 · 3 agents · 14 tools · 30s · 10k tok')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSubagentTree', () => {
|
||||
it('returns empty list for empty input', () => {
|
||||
expect(buildSubagentTree([])).toEqual([])
|
||||
})
|
||||
|
||||
it('treats flat list as top-level when no parentId is given', () => {
|
||||
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 }), makeItem({ id: 'c', index: 2 })]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree).toHaveLength(3)
|
||||
expect(tree.map(n => n.item.id)).toEqual(['a', 'b', 'c'])
|
||||
expect(tree.every(n => n.children.length === 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('nests children under their parent by subagent_id', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'parent', index: 0 }),
|
||||
makeItem({ depth: 1, id: 'child-1', index: 0, parentId: 'parent' }),
|
||||
makeItem({ depth: 1, id: 'child-2', index: 1, parentId: 'parent' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree).toHaveLength(1)
|
||||
expect(tree[0]!.children).toHaveLength(2)
|
||||
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['child-1', 'child-2'])
|
||||
})
|
||||
|
||||
it('builds multi-level nesting', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p', index: 0 }),
|
||||
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' }),
|
||||
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.children[0]!.children[0]!.item.id).toBe('gc')
|
||||
expect(tree[0]!.aggregate.maxDepthFromHere).toBe(2)
|
||||
expect(tree[0]!.aggregate.descendantCount).toBe(2)
|
||||
})
|
||||
|
||||
it('promotes orphaned children (missing parent) to top level', () => {
|
||||
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree).toHaveLength(2)
|
||||
expect(tree.map(n => n.item.id)).toEqual(['a', 'orphan'])
|
||||
})
|
||||
|
||||
it('stable sort: children ordered by (depth, index) not insert order', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p', index: 0 }),
|
||||
makeItem({ depth: 1, id: 'c3', index: 2, parentId: 'p' }),
|
||||
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
|
||||
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.children.map(n => n.item.id)).toEqual(['c1', 'c2', 'c3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('aggregate', () => {
|
||||
it('sums tool counts and durations across subtree', () => {
|
||||
const items = [
|
||||
makeItem({ durationSeconds: 10, id: 'p', index: 0, status: 'completed', toolCount: 5 }),
|
||||
makeItem({ depth: 1, durationSeconds: 4, id: 'c1', index: 0, parentId: 'p', status: 'completed', toolCount: 3 }),
|
||||
makeItem({ depth: 1, durationSeconds: 2, id: 'c2', index: 1, parentId: 'p', status: 'completed', toolCount: 1 })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate).toMatchObject({
|
||||
activeCount: 0,
|
||||
descendantCount: 2,
|
||||
totalDuration: 16,
|
||||
totalTools: 9
|
||||
})
|
||||
})
|
||||
|
||||
it('counts queued + running as active', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p', index: 0, status: 'running' }),
|
||||
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p', status: 'queued' }),
|
||||
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p', status: 'completed' })
|
||||
]
|
||||
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(tree[0]!.aggregate.activeCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('widthByDepth', () => {
|
||||
it('returns empty array for empty tree', () => {
|
||||
expect(widthByDepth([])).toEqual([])
|
||||
})
|
||||
|
||||
it('tallies nodes at each depth', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p1', index: 0 }),
|
||||
makeItem({ id: 'p2', index: 1 }),
|
||||
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p1' }),
|
||||
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p1' }),
|
||||
makeItem({ depth: 1, id: 'c3', index: 0, parentId: 'p2' }),
|
||||
makeItem({ depth: 2, id: 'gc1', index: 0, parentId: 'c1' })
|
||||
]
|
||||
|
||||
expect(widthByDepth(buildSubagentTree(items))).toEqual([2, 3, 1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('treeTotals', () => {
|
||||
it('folds a full tree into a single rollup', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p1', index: 0, toolCount: 5 }),
|
||||
makeItem({ id: 'p2', index: 1, toolCount: 2 }),
|
||||
makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p1', toolCount: 3 })
|
||||
]
|
||||
|
||||
const totals = treeTotals(buildSubagentTree(items))
|
||||
expect(totals.descendantCount).toBe(3)
|
||||
expect(totals.totalTools).toBe(10)
|
||||
expect(totals.maxDepthFromHere).toBe(2)
|
||||
})
|
||||
|
||||
it('returns zeros for empty tree', () => {
|
||||
expect(treeTotals([])).toEqual({
|
||||
activeCount: 0,
|
||||
costUsd: 0,
|
||||
descendantCount: 0,
|
||||
filesTouched: 0,
|
||||
hotness: 0,
|
||||
inputTokens: 0,
|
||||
maxDepthFromHere: 0,
|
||||
outputTokens: 0,
|
||||
totalDuration: 0,
|
||||
totalTools: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('flattenTree + descendantIds', () => {
|
||||
const items = [
|
||||
makeItem({ id: 'p', index: 0 }),
|
||||
makeItem({ depth: 1, id: 'c1', index: 0, parentId: 'p' }),
|
||||
makeItem({ depth: 2, id: 'gc', index: 0, parentId: 'c1' }),
|
||||
makeItem({ depth: 1, id: 'c2', index: 1, parentId: 'p' })
|
||||
]
|
||||
|
||||
it('flattens in visit order (depth-first, pre-order)', () => {
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(flattenTree(tree).map(n => n.item.id)).toEqual(['p', 'c1', 'gc', 'c2'])
|
||||
})
|
||||
|
||||
it('collects descendant ids excluding the node itself', () => {
|
||||
const tree = buildSubagentTree(items)
|
||||
expect(descendantIds(tree[0]!)).toEqual(['c1', 'gc', 'c2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sparkline', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(sparkline([])).toBe('')
|
||||
})
|
||||
|
||||
it('renders zeroes as spaces (not bottom glyph)', () => {
|
||||
expect(sparkline([0, 0])).toBe(' ')
|
||||
})
|
||||
|
||||
it('scales to the max value', () => {
|
||||
const out = sparkline([1, 8])
|
||||
expect(out).toHaveLength(2)
|
||||
expect(out[1]).toBe('█')
|
||||
})
|
||||
|
||||
it('sparse widths render as expected', () => {
|
||||
const out = sparkline([2, 3, 7, 4])
|
||||
expect(out).toHaveLength(4)
|
||||
expect([...out].every(ch => /[\s▁-█]/.test(ch))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSummary', () => {
|
||||
const emptyTotals = {
|
||||
activeCount: 0,
|
||||
costUsd: 0,
|
||||
descendantCount: 0,
|
||||
filesTouched: 0,
|
||||
hotness: 0,
|
||||
inputTokens: 0,
|
||||
maxDepthFromHere: 0,
|
||||
outputTokens: 0,
|
||||
totalDuration: 0,
|
||||
totalTools: 0
|
||||
}
|
||||
|
||||
it('collapses zero-valued components', () => {
|
||||
expect(formatSummary({ ...emptyTotals, descendantCount: 1 })).toBe('d0 · 1 agent')
|
||||
})
|
||||
|
||||
it('emits rich summary with all pieces', () => {
|
||||
expect(
|
||||
formatSummary({
|
||||
...emptyTotals,
|
||||
activeCount: 2,
|
||||
descendantCount: 7,
|
||||
maxDepthFromHere: 3,
|
||||
totalDuration: 134,
|
||||
totalTools: 124
|
||||
})
|
||||
).toBe('d3 · 7 agents · 124 tools · 2m 14s · ⚡2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fmtDuration', () => {
|
||||
it('formats under a minute as plain seconds', () => {
|
||||
expect(fmtDuration(0)).toBe('0s')
|
||||
expect(fmtDuration(42)).toBe('42s')
|
||||
expect(fmtDuration(59.4)).toBe('59s')
|
||||
})
|
||||
|
||||
it('formats whole minutes without trailing seconds', () => {
|
||||
expect(fmtDuration(60)).toBe('1m')
|
||||
expect(fmtDuration(180)).toBe('3m')
|
||||
})
|
||||
|
||||
it('mixes minutes and seconds', () => {
|
||||
expect(fmtDuration(134)).toBe('2m 14s')
|
||||
expect(fmtDuration(605)).toBe('10m 5s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('topLevelSubagents', () => {
|
||||
it('returns items with no parent', () => {
|
||||
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ id: 'b', index: 1 })]
|
||||
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('excludes children whose parent is present', () => {
|
||||
const items = [makeItem({ id: 'p', index: 0 }), makeItem({ depth: 1, id: 'c', index: 0, parentId: 'p' })]
|
||||
|
||||
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['p'])
|
||||
})
|
||||
|
||||
it('promotes orphans whose parent is missing', () => {
|
||||
const items = [makeItem({ id: 'a', index: 0 }), makeItem({ depth: 1, id: 'orphan', index: 1, parentId: 'ghost' })]
|
||||
expect(topLevelSubagents(items).map(s => s.id)).toEqual(['a', 'orphan'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,170 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isSessionBusyError, markSubmitting, submitPrompt, type SubmitPromptDeps } from '../app/submissionCore.js'
|
||||
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
|
||||
import type { GatewayClient } from '../gatewayClient.js'
|
||||
|
||||
// A gateway double whose `input.detect_drop` resolution we control, so we can
|
||||
// observe UI state DURING the async gap — the exact window the queue-mode race
|
||||
// lived in.
|
||||
function makeDeferredGateway() {
|
||||
let resolveDrop: (v: unknown) => void = () => {}
|
||||
|
||||
const dropPromise = new Promise(res => {
|
||||
resolveDrop = res
|
||||
})
|
||||
|
||||
const calls: string[] = []
|
||||
|
||||
const gw = {
|
||||
request: vi.fn((method: string) => {
|
||||
calls.push(method)
|
||||
|
||||
if (method === 'input.detect_drop') {
|
||||
return dropPromise
|
||||
}
|
||||
|
||||
// prompt.submit et al: resolve immediately with a success shape.
|
||||
return Promise.resolve({ status: 'streaming' })
|
||||
})
|
||||
} as unknown as GatewayClient
|
||||
|
||||
return { calls, gw, resolveDrop: (v: unknown = { matched: false }) => resolveDrop(v) }
|
||||
}
|
||||
|
||||
function makeDeps(gw: GatewayClient, over: Partial<SubmitPromptDeps> = {}): SubmitPromptDeps {
|
||||
return {
|
||||
appendMessage: vi.fn(),
|
||||
enqueue: vi.fn(),
|
||||
expand: (t: string) => t,
|
||||
gw,
|
||||
setLastUserMsg: vi.fn(),
|
||||
sys: vi.fn(),
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
describe('submissionCore.submitPrompt — synchronous busy (queue-race fix)', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
patchUiState({ sid: 'sess-1' })
|
||||
})
|
||||
|
||||
it('flips busy=true SYNCHRONOUSLY, before input.detect_drop resolves', () => {
|
||||
const { gw, resolveDrop } = makeDeferredGateway()
|
||||
|
||||
expect(getUiState().busy).toBe(false)
|
||||
|
||||
submitPrompt('hello', makeDeps(gw))
|
||||
|
||||
// The critical invariant: busy is already true even though the
|
||||
// detect_drop RPC has NOT resolved yet. This is what makes a second,
|
||||
// rapid submit take the local-enqueue branch instead of racing a second
|
||||
// prompt.submit onto the backend.
|
||||
expect(getUiState().busy).toBe(true)
|
||||
expect(getUiState().status).toBe('running…')
|
||||
|
||||
resolveDrop()
|
||||
})
|
||||
|
||||
it('regression: two back-to-back sends — the SECOND sees busy=true in the gap', async () => {
|
||||
const { gw, resolveDrop } = makeDeferredGateway()
|
||||
|
||||
// Emulate dispatchSubmission's routing decision: it sends only when
|
||||
// busy===false, otherwise it would enqueue. We assert the state the
|
||||
// router reads, which is the real regression.
|
||||
submitPrompt('first message', makeDeps(gw))
|
||||
|
||||
// Before the fix, busy was still false here (set only inside detect_drop's
|
||||
// .then), so a second Enter would wrongly route into send() again.
|
||||
const busyWhenSecondArrives = getUiState().busy
|
||||
expect(busyWhenSecondArrives).toBe(true)
|
||||
|
||||
resolveDrop()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('does not submit when there is no session, and does not mark busy', () => {
|
||||
resetUiState() // sid: null
|
||||
const { gw, calls } = makeDeferredGateway()
|
||||
const sys = vi.fn()
|
||||
|
||||
submitPrompt('hello', makeDeps(gw, { sys }))
|
||||
|
||||
expect(getUiState().busy).toBe(false)
|
||||
expect(sys).toHaveBeenCalledWith('session not ready yet')
|
||||
expect(calls).not.toContain('input.detect_drop')
|
||||
})
|
||||
|
||||
it('after detect_drop resolves (no file), it issues prompt.submit', async () => {
|
||||
const { calls, gw, resolveDrop } = makeDeferredGateway()
|
||||
|
||||
submitPrompt('hi there', makeDeps(gw))
|
||||
expect(calls).toEqual(['input.detect_drop'])
|
||||
|
||||
resolveDrop({ matched: false })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(calls).toContain('prompt.submit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submissionCore.submitPrompt — literal submissions (startup -q queries)', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
patchUiState({ sid: 'sess-1' })
|
||||
})
|
||||
|
||||
it('skipDetectDrop submits directly without the detect_drop round-trip', async () => {
|
||||
const { calls, gw } = makeDeferredGateway()
|
||||
|
||||
submitPrompt('!echo not-a-shell-escape', makeDeps(gw), true, undefined, { skipDetectDrop: true })
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(calls).not.toContain('input.detect_drop')
|
||||
expect(calls).toContain('prompt.submit')
|
||||
})
|
||||
|
||||
it('literal text reaches prompt.submit verbatim', async () => {
|
||||
const submitted: string[] = []
|
||||
|
||||
const gw = {
|
||||
request: vi.fn((method: string, params?: { text?: string }) => {
|
||||
if (method === 'prompt.submit' && params?.text) {
|
||||
submitted.push(params.text)
|
||||
}
|
||||
|
||||
return Promise.resolve({ status: 'streaming' })
|
||||
})
|
||||
} as unknown as GatewayClient
|
||||
|
||||
submitPrompt('/model $(rm -rf ~)', makeDeps(gw), true, undefined, { skipDetectDrop: true })
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(submitted).toEqual(['/model $(rm -rf ~)'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('submissionCore.markSubmitting', () => {
|
||||
beforeEach(() => resetUiState())
|
||||
|
||||
it('sets busy + running status', () => {
|
||||
markSubmitting()
|
||||
expect(getUiState().busy).toBe(true)
|
||||
expect(getUiState().status).toBe('running…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('submissionCore.isSessionBusyError', () => {
|
||||
it('matches the legacy busy rejections but not arbitrary errors', () => {
|
||||
expect(isSessionBusyError(new Error('session busy'))).toBe(true)
|
||||
expect(isSessionBusyError(new Error('waiting for model response'))).toBe(true)
|
||||
expect(isSessionBusyError(new Error('some other failure'))).toBe(false)
|
||||
expect(isSessionBusyError('not an error')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import { subscriptionCommands } from '../app/slash/commands/subscription.js'
|
||||
import { findSlashCommand } from '../app/slash/registry.js'
|
||||
import type { SubscriptionStateResponse } from '../gatewayTypes.js'
|
||||
|
||||
vi.mock('../lib/openExternalUrl.js', () => ({
|
||||
openExternalUrl: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
const subscriptionCommand = subscriptionCommands.find(cmd => cmd.name === 'subscription')!
|
||||
|
||||
const loggedInState = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
|
||||
ok: true,
|
||||
logged_in: true,
|
||||
is_admin: true,
|
||||
can_change_plan: true,
|
||||
org_name: 'Acme',
|
||||
role: 'OWNER',
|
||||
current: null,
|
||||
portal_url: 'https://portal.nousresearch.com/billing',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const guarded =
|
||||
<T>(fn: (r: T) => void) =>
|
||||
(r: null | T) => {
|
||||
if (r) {
|
||||
fn(r)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
|
||||
const buildCtx = (results: Record<string, unknown>) => {
|
||||
const sys = vi.fn()
|
||||
const calls: Array<{ method: string; params: unknown }> = []
|
||||
|
||||
const rpc = vi.fn((method: string, params: unknown) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
return Promise.resolve(results[method])
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
gateway: { rpc },
|
||||
guarded,
|
||||
guardedErr: vi.fn(),
|
||||
sid: 'sid-1',
|
||||
stale: () => false,
|
||||
transcript: { page: vi.fn(), panel: vi.fn(), sys }
|
||||
}
|
||||
|
||||
const run = async (arg: string) => {
|
||||
subscriptionCommand.run(arg, ctx as any, 'subscription')
|
||||
await rpc.mock.results[0]?.value
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
return { calls, ctx, rpc, run, sys }
|
||||
}
|
||||
|
||||
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
|
||||
|
||||
describe('/subscription slash command', () => {
|
||||
beforeEach(() => {
|
||||
resetOverlayState()
|
||||
})
|
||||
|
||||
it('fetches subscription.state and opens the overlay', async () => {
|
||||
const { run } = buildCtx({
|
||||
'subscription.state': loggedInState()
|
||||
})
|
||||
|
||||
await run('')
|
||||
|
||||
const overlay = getOverlayState().subscription
|
||||
|
||||
expect(overlay).not.toBeNull()
|
||||
expect(overlay?.screen).toBe('overview')
|
||||
})
|
||||
|
||||
it('shows portal-login sys line when not logged in', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'subscription.state': loggedInState({ logged_in: false })
|
||||
})
|
||||
|
||||
await run('')
|
||||
|
||||
expect(printed(sys)).toContain('Not logged into Nous Portal')
|
||||
expect(getOverlayState().subscription).toBeNull()
|
||||
})
|
||||
|
||||
it('/upgrade alias resolves to the same command', () => {
|
||||
expect(findSlashCommand('upgrade')).toBe(subscriptionCommand)
|
||||
})
|
||||
|
||||
it('/subscription resolves to the same command', () => {
|
||||
expect(findSlashCommand('subscription')).toBe(subscriptionCommand)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,636 @@
|
||||
import { PassThrough } from 'stream'
|
||||
|
||||
import { renderSync } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const inputHarness = vi.hoisted(() => ({
|
||||
handler: undefined as undefined | ((input: string, key: Record<string, boolean>) => void)
|
||||
}))
|
||||
|
||||
// Stub useInput so the overlay doesn't try to enter raw mode under renderSync
|
||||
// (PassThrough stdin doesn't support it). Box/Text pass through to real Ink.
|
||||
vi.mock('@hermes/ink', async importOriginal => {
|
||||
const mod = await importOriginal()
|
||||
|
||||
return {
|
||||
...mod,
|
||||
useInput: (handler: (input: string, key: Record<string, boolean>) => void) => {
|
||||
inputHarness.handler = handler
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import type { SubscriptionOverlayState } from '../app/interfaces.js'
|
||||
import { SubscriptionOverlay } from '../components/subscriptionOverlay.js'
|
||||
import type { SubscriptionStateResponse } from '../gatewayTypes.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
/** Mount a SubscriptionOverlay via renderSync + PassThrough. */
|
||||
function mount(
|
||||
overlay: SubscriptionOverlayState,
|
||||
onPatch: (next: Partial<SubscriptionOverlayState>) => void = () => {}
|
||||
) {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 100, isTTY: false, rows: 40 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
inputHarness.handler = undefined
|
||||
const element = React.createElement(SubscriptionOverlay, { onClose: () => {}, onPatch, overlay, t })
|
||||
|
||||
const instance = renderSync(element, {
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
return {
|
||||
cleanup: () => {
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
},
|
||||
output: () => stripAnsi(output),
|
||||
rerender: () => instance.rerender(element)
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a SubscriptionOverlay to a string via renderSync + PassThrough. */
|
||||
function render(overlay: SubscriptionOverlayState): string {
|
||||
const mounted = mount(overlay)
|
||||
const output = mounted.output()
|
||||
mounted.cleanup()
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
const TIERS = [
|
||||
{
|
||||
tier_id: 'free',
|
||||
name: 'Free',
|
||||
tier_order: 0,
|
||||
dollars_per_month_display: '$0',
|
||||
monthly_credits: '0',
|
||||
is_current: false,
|
||||
is_enabled: true
|
||||
},
|
||||
{
|
||||
tier_id: 'plus',
|
||||
name: 'Plus',
|
||||
tier_order: 1,
|
||||
dollars_per_month_display: '$20',
|
||||
monthly_credits: '1000',
|
||||
is_current: true,
|
||||
is_enabled: true
|
||||
},
|
||||
{
|
||||
tier_id: 'ultra',
|
||||
name: 'Ultra',
|
||||
tier_order: 2,
|
||||
dollars_per_month_display: '$40',
|
||||
monthly_credits: '3000',
|
||||
is_current: false,
|
||||
is_enabled: true
|
||||
}
|
||||
]
|
||||
|
||||
const state = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse => ({
|
||||
ok: true,
|
||||
logged_in: true,
|
||||
is_admin: true,
|
||||
can_change_plan: true,
|
||||
org_name: 'Acme',
|
||||
org_id: 'org_acme',
|
||||
role: 'OWNER',
|
||||
current: null,
|
||||
tiers: [],
|
||||
portal_url: 'https://portal.nousresearch.com/billing',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
fetchCard: vi.fn(() => Promise.resolve(null)),
|
||||
openManageLink: vi.fn(() => Promise.resolve(true)),
|
||||
openPortal: vi.fn(),
|
||||
preview: vi.fn(() => Promise.resolve(null)),
|
||||
refreshState: vi.fn(() => Promise.resolve(null)),
|
||||
requestRemoteSpending: vi.fn(() => Promise.resolve({ granted: true })),
|
||||
resume: vi.fn(() => Promise.resolve(null)),
|
||||
scheduleCancellation: vi.fn(() => Promise.resolve(null)),
|
||||
scheduleChange: vi.fn(() => Promise.resolve(null)),
|
||||
sys: vi.fn(),
|
||||
upgrade: vi.fn(() => Promise.resolve(null))
|
||||
}
|
||||
|
||||
const overlay = (s: SubscriptionStateResponse): SubscriptionOverlayState => ({ ctx, screen: 'overview', state: s })
|
||||
|
||||
// Overview: the entry screen across every account state (plan + usage + the
|
||||
// actions that enter the in-terminal change flow).
|
||||
describe('SubscriptionOverlay — overview', () => {
|
||||
it('free: upsell + "Start a subscription", no tier list, no "credits"', () => {
|
||||
const out = render(overlay(state({ current: null, usage: { available: true, status: 'free', plan_name: null } })))
|
||||
|
||||
expect(out).toContain('Plan: Free · free models only')
|
||||
expect(out).toContain('Paid models need a subscription')
|
||||
expect(out).toContain('Start a subscription')
|
||||
expect(out).not.toContain('$20/mo')
|
||||
expect(out.toLowerCase()).not.toContain('credits')
|
||||
})
|
||||
|
||||
it('free with catalog: plans render inline; the generic portal row disappears', () => {
|
||||
const out = render(overlay(freeWithCatalog()))
|
||||
|
||||
expect(out).toContain('Plus · $20/mo · $1,000 credits/mo')
|
||||
expect(out).toContain('Ultra · $40/mo · $3,000 credits/mo')
|
||||
expect(out).not.toContain('upgrade') // a start, not a move
|
||||
expect(out).not.toContain('$0/mo') // free tier is not an option
|
||||
expect(out).not.toContain('Choose a plan')
|
||||
expect(out).not.toContain('Start a subscription')
|
||||
})
|
||||
|
||||
it('free with catalog: picking a plan opens the portal once, even on double-Enter', async () => {
|
||||
const openManageLink = vi.fn(() => Promise.resolve(true))
|
||||
const preview = vi.fn(() => Promise.resolve(null))
|
||||
const sys = vi.fn()
|
||||
|
||||
const mounted = mount({
|
||||
ctx: { ...ctx, openManageLink, preview, sys } as SubscriptionOverlayState['ctx'],
|
||||
screen: 'overview',
|
||||
state: freeWithCatalog()
|
||||
})
|
||||
|
||||
inputHarness.handler?.('', { return: true }) // first row = Plus
|
||||
inputHarness.handler?.('', { return: true })
|
||||
await vi.waitFor(() => expect(openManageLink).toHaveBeenCalled())
|
||||
mounted.cleanup()
|
||||
|
||||
expect(openManageLink).toHaveBeenCalledTimes(1)
|
||||
expect(openManageLink).toHaveBeenCalledWith('plus')
|
||||
expect(preview).not.toHaveBeenCalled()
|
||||
// openManageLink narrates the handoff itself.
|
||||
expect(sys).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('subscriber: status line + plan bar + top-up bar, no "credits"', () => {
|
||||
const out = render(
|
||||
overlay(
|
||||
state({
|
||||
current: {
|
||||
tier_id: 'pro',
|
||||
tier_name: 'Pro',
|
||||
monthly_credits: '1000',
|
||||
credits_remaining: '700',
|
||||
cycle_ends_at: '2026-07-01',
|
||||
pending_downgrade_tier_name: null,
|
||||
pending_downgrade_at: null
|
||||
},
|
||||
usage: {
|
||||
available: true,
|
||||
status: 'healthy',
|
||||
plan_name: 'Pro',
|
||||
renews_display: 'Jul 1, 2026',
|
||||
total_spendable_display: '$26.00',
|
||||
has_topup: true,
|
||||
plan_bar: {
|
||||
kind: 'plan',
|
||||
remaining_display: '$14.00',
|
||||
total_display: '$20.00',
|
||||
spent_display: '$6.00',
|
||||
pct_used: 30,
|
||||
fill_fraction: 0.7
|
||||
},
|
||||
topup_bar: {
|
||||
kind: 'topup',
|
||||
remaining_display: '$12.00',
|
||||
total_display: '$12.00',
|
||||
spent_display: '$0.00',
|
||||
pct_used: null,
|
||||
fill_fraction: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(out).toContain('Plan: Pro')
|
||||
expect(out).toContain('$14.00 left of $20.00')
|
||||
expect(out).toContain('30% used')
|
||||
expect(out).toContain('top-up')
|
||||
expect(out).toContain('never expires')
|
||||
expect(out.toLowerCase()).not.toContain('credits')
|
||||
})
|
||||
|
||||
it('low balance: shows alert nudge', () => {
|
||||
const out = render(
|
||||
overlay(
|
||||
state({
|
||||
current: {
|
||||
tier_id: 'pro',
|
||||
tier_name: 'Pro',
|
||||
monthly_credits: '1000',
|
||||
credits_remaining: '170',
|
||||
cycle_ends_at: '2026-07-01',
|
||||
pending_downgrade_tier_name: null,
|
||||
pending_downgrade_at: null
|
||||
},
|
||||
usage: {
|
||||
available: true,
|
||||
status: 'low',
|
||||
plan_name: 'Pro',
|
||||
total_spendable_display: '$3.40',
|
||||
plan_bar: {
|
||||
kind: 'plan',
|
||||
remaining_display: '$3.40',
|
||||
total_display: '$20.00',
|
||||
spent_display: '$16.60',
|
||||
pct_used: 83,
|
||||
fill_fraction: 0.17
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(out).toContain('Plan: Pro · $3.40 left')
|
||||
expect(out).toContain('Low balance')
|
||||
})
|
||||
|
||||
it('not-admin: shows read-only note', () => {
|
||||
const out = render(
|
||||
overlay(
|
||||
state({
|
||||
is_admin: false,
|
||||
can_change_plan: false,
|
||||
role: 'MEMBER',
|
||||
current: {
|
||||
tier_id: 'pro',
|
||||
tier_name: 'Pro',
|
||||
monthly_credits: '1000',
|
||||
credits_remaining: '500',
|
||||
cycle_ends_at: '2026-07-01',
|
||||
pending_downgrade_tier_name: null,
|
||||
pending_downgrade_at: null
|
||||
},
|
||||
usage: { available: true, status: 'healthy', plan_name: 'Pro' }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(out).toContain('view only')
|
||||
expect(out).toContain('Manage on portal')
|
||||
})
|
||||
|
||||
it('downgrade-pending: leads with a Pro ──▶ Free banner + status echo', () => {
|
||||
const out = render(
|
||||
overlay(
|
||||
state({
|
||||
current: {
|
||||
tier_id: 'pro',
|
||||
tier_name: 'Pro',
|
||||
monthly_credits: '1000',
|
||||
credits_remaining: '500',
|
||||
cycle_ends_at: '2026-07-01',
|
||||
pending_downgrade_tier_name: 'Free',
|
||||
pending_downgrade_at: '2026-07-15',
|
||||
pending_downgrade_display: 'Jul 15, 2026'
|
||||
},
|
||||
usage: { available: true, status: 'healthy', plan_name: 'Pro' }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(out).toContain('Scheduled change')
|
||||
expect(out).toContain('──▶')
|
||||
expect(out).toContain('Free')
|
||||
expect(out).toContain('Jul 15, 2026')
|
||||
// the status line itself echoes the transition
|
||||
expect(out).toContain('Plan: Pro → Free')
|
||||
})
|
||||
|
||||
it('team context: redirects to /topup, no tier picker', () => {
|
||||
const out = render(overlay(state({ context: 'team', current: null })))
|
||||
|
||||
expect(out).toContain('shared balance')
|
||||
expect(out).toContain('/topup')
|
||||
})
|
||||
})
|
||||
|
||||
// In-terminal change flow (V3): picker → confirm → result. useInput is mocked
|
||||
// (no key simulation), so these assert each screen's rendered content.
|
||||
|
||||
const subscriber = (overrides: Partial<SubscriptionStateResponse> = {}): SubscriptionStateResponse =>
|
||||
state({
|
||||
current: {
|
||||
tier_id: 'plus',
|
||||
tier_name: 'Plus',
|
||||
monthly_credits: '1000',
|
||||
credits_remaining: '500',
|
||||
cycle_ends_at: '2026-07-01',
|
||||
pending_downgrade_tier_name: null,
|
||||
pending_downgrade_at: null
|
||||
},
|
||||
tiers: TIERS,
|
||||
usage: { available: true, status: 'healthy', plan_name: 'Plus' },
|
||||
...overrides
|
||||
})
|
||||
|
||||
const at = (
|
||||
screen: SubscriptionOverlayState['screen'],
|
||||
s: SubscriptionStateResponse,
|
||||
extra: Partial<SubscriptionOverlayState> = {}
|
||||
): SubscriptionOverlayState => ({ ctx, screen, state: s, ...extra })
|
||||
|
||||
// Free account (no current sub) where NAS still returns the tier catalog.
|
||||
const freeWithCatalog = (): SubscriptionStateResponse =>
|
||||
state({
|
||||
current: null,
|
||||
tiers: TIERS.map(tier => ({ ...tier, is_current: false })),
|
||||
usage: { available: true, plan_name: null, status: 'free' }
|
||||
})
|
||||
|
||||
describe('SubscriptionOverlay — overview actions', () => {
|
||||
it('admin subscriber: offers Change plan + Cancel subscription', () => {
|
||||
const out = render(overlay(subscriber()))
|
||||
|
||||
expect(out).toContain('Change plan')
|
||||
expect(out).toContain('Cancel subscription')
|
||||
})
|
||||
|
||||
it('pending change: offers undo instead of cancel', () => {
|
||||
const out = render(
|
||||
overlay(
|
||||
subscriber({
|
||||
current: {
|
||||
tier_id: 'plus',
|
||||
tier_name: 'Plus',
|
||||
monthly_credits: '1000',
|
||||
credits_remaining: '500',
|
||||
cycle_ends_at: '2026-07-01',
|
||||
cancel_at_period_end: true,
|
||||
cancellation_effective_at: '2026-07-01',
|
||||
pending_downgrade_tier_name: null,
|
||||
pending_downgrade_at: null
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// undo is promoted to the first action; the banner shows the pending cancel
|
||||
expect(out).toContain('Keep Plus (undo this change)')
|
||||
expect(out).toContain('cancels')
|
||||
expect(out).not.toContain('Cancel subscription')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubscriptionOverlay — step-up', () => {
|
||||
it('prompts to allow Remote Spending (never leaks the raw scope)', () => {
|
||||
const out = render(at('stepup', subscriber(), { stepUpRetry: { kind: 'preview', tierId: 'ultra' } }))
|
||||
|
||||
expect(out).toContain('Remote Spending')
|
||||
expect(out).toContain('Allow Remote Spending')
|
||||
expect(out).not.toContain('billing:manage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubscriptionOverlay — picker', () => {
|
||||
it('lists other paid tiers with direction hints; hides current + free', () => {
|
||||
const out = render(at('picker', subscriber()))
|
||||
|
||||
expect(out).toContain('Ultra')
|
||||
expect(out).toContain('$40/mo')
|
||||
expect(out).toContain('upgrade') // ultra (order 2) > plus (order 1)
|
||||
expect(out).not.toContain('Plus · $20/mo') // current tier is not selectable
|
||||
expect(out).not.toContain('$0/mo') // free tier excluded — use Cancel instead
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubscriptionOverlay — confirm', () => {
|
||||
it('charge_now: shows the prorated charge + upgrade copy', () => {
|
||||
const out = render(
|
||||
at('confirm', subscriber(), {
|
||||
pending: {
|
||||
kind: 'upgrade',
|
||||
targetTierId: 'ultra',
|
||||
preview: {
|
||||
ok: true,
|
||||
effect: 'charge_now',
|
||||
target_tier_name: 'Ultra',
|
||||
amount_due_now_cents: 1234,
|
||||
monthly_credits_delta: '2000'
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(out).toContain('Pay $12.34 & upgrade now')
|
||||
expect(out).toContain('Upgrade to Ultra')
|
||||
})
|
||||
|
||||
it('scheduled: shows effective date + no charge now', () => {
|
||||
const out = render(
|
||||
at('confirm', subscriber(), {
|
||||
pending: {
|
||||
kind: 'tier_change',
|
||||
targetTierId: 'plus',
|
||||
preview: {
|
||||
ok: true,
|
||||
effect: 'scheduled',
|
||||
target_tier_name: 'Plus',
|
||||
effective_at: '2026-08-01T00:00:00Z',
|
||||
amount_due_now_cents: null
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(out).toContain('Schedule change to Plus')
|
||||
expect(out).toContain('2026-08-01')
|
||||
expect(out).toContain('No charge now')
|
||||
})
|
||||
|
||||
it('cancellation: shows cancel-at-period-end copy', () => {
|
||||
const out = render(
|
||||
at('confirm', subscriber(), { pending: { kind: 'cancellation', targetTierId: null, preview: null } })
|
||||
)
|
||||
|
||||
expect(out).toContain('Confirm cancellation')
|
||||
expect(out).toContain('will not renew')
|
||||
})
|
||||
|
||||
it('blocked: shows the reason + Manage on portal', () => {
|
||||
const out = render(
|
||||
at('confirm', subscriber(), {
|
||||
pending: {
|
||||
kind: 'tier_change',
|
||||
targetTierId: 'ultra',
|
||||
preview: { ok: true, effect: 'blocked', reason: 'Retract the cancellation before upgrading.' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(out).toContain('Retract the cancellation')
|
||||
expect(out).toContain('Manage on portal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubscriptionOverlay — result', () => {
|
||||
it('ok: shows Done + the re-run hint', () => {
|
||||
const out = render(at('result', subscriber(), { result: { ok: true, message: 'Upgraded to Ultra.' } }))
|
||||
|
||||
expect(out).toContain('Done')
|
||||
expect(out).toContain('Upgraded to Ultra.')
|
||||
expect(out).toContain('Re-run /subscription')
|
||||
})
|
||||
|
||||
it('error with recovery: shows the message + Open the portal', () => {
|
||||
const out = render(
|
||||
at('result', subscriber(), {
|
||||
result: {
|
||||
ok: false,
|
||||
message: 'This upgrade needs extra verification (3DS).',
|
||||
recoveryUrl: 'https://portal.example/x'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(out).toContain('Could not complete')
|
||||
expect(out).toContain('3DS')
|
||||
expect(out).toContain('Open the portal to finish')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubscriptionOverlay — upgrade response mapping', () => {
|
||||
const applyUpgrade = async (response: unknown) => {
|
||||
const onPatch = vi.fn()
|
||||
const upgrade = vi.fn(() => Promise.resolve(response))
|
||||
|
||||
const mounted = mount(
|
||||
at('confirm', subscriber(), {
|
||||
ctx: { ...ctx, upgrade } as SubscriptionOverlayState['ctx'],
|
||||
pending: {
|
||||
idempotencyKey: 'upgrade-key',
|
||||
kind: 'upgrade',
|
||||
preview: { ok: true, effect: 'charge_now', target_tier_name: 'Ultra', amount_due_now_cents: 1234 },
|
||||
targetTierId: 'ultra'
|
||||
}
|
||||
}),
|
||||
onPatch
|
||||
)
|
||||
|
||||
inputHarness.handler?.('', { return: true })
|
||||
await vi.waitFor(() => expect(onPatch).toHaveBeenCalled())
|
||||
mounted.cleanup()
|
||||
|
||||
return onPatch.mock.calls.at(-1)?.[0] as Partial<SubscriptionOverlayState>
|
||||
}
|
||||
|
||||
it.each([
|
||||
['authentication_required', 'upgraded'],
|
||||
['subscription_payment_intent_requires_action', 'payment_failed']
|
||||
])('reason %s routes to card verification regardless of status %s', async (reason, status) => {
|
||||
const patch = await applyUpgrade({
|
||||
ok: status === 'upgraded',
|
||||
reason,
|
||||
recovery_url: 'https://portal.example/verify',
|
||||
status,
|
||||
target_tier_name: 'Ultra'
|
||||
})
|
||||
|
||||
expect(patch.screen).toBe('result')
|
||||
expect(patch.result?.ok).toBe(false)
|
||||
expect(patch.result?.message).toContain('verify your card in the portal')
|
||||
expect(patch.result?.recoveryUrl).toBe('https://portal.example/verify')
|
||||
})
|
||||
|
||||
it('card_declined reason routes to a different-card recovery', async () => {
|
||||
const patch = await applyUpgrade({
|
||||
ok: false,
|
||||
reason: 'card_declined',
|
||||
recovery_url: 'https://portal.example/card',
|
||||
status: 'requires_action'
|
||||
})
|
||||
|
||||
expect(patch.result?.message).toContain('try a different card on the portal')
|
||||
expect(patch.result?.recoveryUrl).toBe('https://portal.example/card')
|
||||
})
|
||||
|
||||
it('already_on_tier remains an immediate success', async () => {
|
||||
const patch = await applyUpgrade({ ok: true, status: 'already_on_tier', target_tier_name: 'Ultra' })
|
||||
|
||||
expect(patch.result).toMatchObject({ message: 'You are already on Ultra.', ok: true })
|
||||
expect(patch.result).not.toHaveProperty('pendingTierId')
|
||||
})
|
||||
|
||||
it('upgraded marks the result as applying to the target tier', async () => {
|
||||
const patch = await applyUpgrade({ ok: true, status: 'upgraded', target_tier_name: 'Ultra' })
|
||||
|
||||
expect(patch.result).toMatchObject({ ok: true, pendingTierId: 'ultra' })
|
||||
})
|
||||
|
||||
it('shows Applying, then Done once refreshed state reaches the upgraded tier', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const refreshState = vi.fn(() =>
|
||||
Promise.resolve(
|
||||
subscriber({
|
||||
current: {
|
||||
tier_id: 'ultra',
|
||||
tier_name: 'Ultra',
|
||||
monthly_credits: '3000',
|
||||
credits_remaining: '3000',
|
||||
cycle_ends_at: '2026-08-01',
|
||||
pending_downgrade_tier_name: null,
|
||||
pending_downgrade_at: null
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const result = { message: 'Upgraded to Ultra.', ok: true, pendingTierId: 'ultra' }
|
||||
const mounted = mount(at('result', subscriber(), { ctx: { ...ctx, refreshState }, result }))
|
||||
|
||||
expect(mounted.output()).toContain('Applying…')
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
mounted.rerender()
|
||||
expect(refreshState).toHaveBeenCalledTimes(1)
|
||||
expect(mounted.output()).toContain('Done')
|
||||
expect(mounted.output()).toContain('Upgraded to Ultra.')
|
||||
mounted.cleanup()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a successful upgrade soft-pending after the bounded confirmation window', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const refreshState = vi.fn(() => Promise.resolve(subscriber()))
|
||||
const result = { message: 'Upgraded to Ultra.', ok: true, pendingTierId: 'ultra' }
|
||||
const mounted = mount(at('result', subscriber(), { ctx: { ...ctx, refreshState }, result }))
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
mounted.rerender()
|
||||
expect(refreshState).toHaveBeenCalledTimes(15)
|
||||
expect(mounted.output()).toContain('Still applying')
|
||||
expect(mounted.output()).toContain('refresh in a moment')
|
||||
expect(mounted.output()).not.toContain('Could not complete')
|
||||
mounted.cleanup()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { highlightLine, isHighlightable } from '../lib/syntax.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
describe('syntax highlighter', () => {
|
||||
it('recognizes supported langs and aliases', () => {
|
||||
expect(isHighlightable('ts')).toBe(true)
|
||||
expect(isHighlightable('js')).toBe(true)
|
||||
expect(isHighlightable('python')).toBe(true)
|
||||
expect(isHighlightable('rs')).toBe(true)
|
||||
expect(isHighlightable('bash')).toBe(true)
|
||||
expect(isHighlightable('whatever')).toBe(false)
|
||||
expect(isHighlightable('')).toBe(false)
|
||||
})
|
||||
|
||||
it('paints a whole-line comment dim', () => {
|
||||
const tokens = highlightLine('// hello', 'ts', t)
|
||||
|
||||
expect(tokens).toEqual([[t.color.muted, '// hello']])
|
||||
})
|
||||
|
||||
it('paints keywords, strings, and numbers in a ts line', () => {
|
||||
const tokens = highlightLine(`const x = 'hi' + 42`, 'ts', t)
|
||||
const colors = tokens.map(tok => tok[0])
|
||||
|
||||
expect(colors).toContain(t.color.border) // const
|
||||
expect(colors).toContain(t.color.accent) // 'hi'
|
||||
expect(colors).toContain(t.color.text) // 42
|
||||
})
|
||||
|
||||
it('falls through unchanged for unknown langs', () => {
|
||||
const tokens = highlightLine(`const x = 1`, 'zzz', t)
|
||||
|
||||
expect(tokens).toEqual([['', 'const x = 1']])
|
||||
})
|
||||
|
||||
it('treats `#` as a python comment, not a selector', () => {
|
||||
const tokens = highlightLine('# comment', 'py', t)
|
||||
|
||||
expect(tokens).toEqual([[t.color.muted, '# comment']])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
isPaintableHex,
|
||||
resetTerminalModes,
|
||||
setTerminalBackground,
|
||||
setTerminalForeground,
|
||||
TERMINAL_MODE_RESET
|
||||
} from '../lib/terminalModes.js'
|
||||
|
||||
describe('terminal mode reset', () => {
|
||||
it('includes common sticky input modes', () => {
|
||||
expect(TERMINAL_MODE_RESET).toContain("\x1b[0'z")
|
||||
expect(TERMINAL_MODE_RESET).toContain("\x1b[0'{")
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?2029l')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1016l')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1015l')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1006l')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1005l')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1003l')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1002l')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1001l')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1000l')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?9l')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1004l')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?2004l')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[?1049l')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[<u')
|
||||
expect(TERMINAL_MODE_RESET).toContain('\x1b[>4m')
|
||||
})
|
||||
|
||||
it('writes reset sequence to TTY streams without fds', () => {
|
||||
const write = vi.fn()
|
||||
|
||||
expect(resetTerminalModes({ isTTY: true, write } as unknown as NodeJS.WriteStream)).toBe(true)
|
||||
expect(write).toHaveBeenCalledWith(TERMINAL_MODE_RESET)
|
||||
})
|
||||
|
||||
it('skips non-TTY streams', () => {
|
||||
const write = vi.fn()
|
||||
|
||||
expect(resetTerminalModes({ isTTY: false, write } as unknown as NodeJS.WriteStream)).toBe(false)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// entry.tsx installs `process.on('exit', () => resetTerminalModes())` as the
|
||||
// final backstop (#28419): /quit, Ctrl+C, Ctrl+D and any process.exit() path
|
||||
// must disarm DEC mouse tracking so the parent shell / next TUI doesn't read
|
||||
// leaked mouse reports as keystrokes. 'exit' handlers run synchronously only,
|
||||
// so the reset must complete via a single synchronous write — verify that an
|
||||
// exit-style invocation disables every SGR mouse mode that produced the
|
||||
// reported `…;…M` garbage.
|
||||
it('disarms mouse tracking from a synchronous exit-style handler', () => {
|
||||
const write = vi.fn()
|
||||
const stream = { isTTY: true, write } as unknown as NodeJS.WriteStream
|
||||
|
||||
// Mirror entry.tsx's process.on('exit') callback.
|
||||
const onExit = () => resetTerminalModes(stream)
|
||||
onExit()
|
||||
|
||||
expect(write).toHaveBeenCalledTimes(1)
|
||||
const written = write.mock.calls[0]?.[0] as string
|
||||
|
||||
for (const mode of ['\x1b[?1006l', '\x1b[?1003l', '\x1b[?1002l', '\x1b[?1000l']) {
|
||||
expect(written).toContain(mode)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Foreground (OSC 10) and background (OSC 11) are the same slot contract —
|
||||
// assert it once over both. Painting BOTH is what keeps every default-fg
|
||||
// token legible when a skin flips the terminal's polarity.
|
||||
describe.each([
|
||||
{ name: 'foreground', osc: 10, set: setTerminalForeground },
|
||||
{ name: 'background', osc: 11, set: setTerminalBackground }
|
||||
])('terminal default $name (OSC $osc)', ({ osc, set }) => {
|
||||
const paint = `\x1b]${osc};`
|
||||
const restore = `\x1b]1${osc}\x07`
|
||||
const tty = (write: ReturnType<typeof vi.fn>) => ({ isTTY: true, write }) as unknown as NodeJS.WriteStream
|
||||
|
||||
const written = (fn: (s: NodeJS.WriteStream) => void): string => {
|
||||
const write = vi.fn()
|
||||
fn(tty(write))
|
||||
|
||||
return (write.mock.calls[0]?.[0] as string) ?? ''
|
||||
}
|
||||
|
||||
// Leave the module's "painted" flag clean so the exact-match reset test above
|
||||
// (and other files) never see a stray restore.
|
||||
afterEach(() => set('', tty(vi.fn())))
|
||||
|
||||
it('paints the terminal default from a valid hex', () => {
|
||||
expect(written(s => set('#08201F', s))).toBe(`${paint}#08201F\x07`)
|
||||
})
|
||||
|
||||
it('ignores an invalid hex and non-TTY streams', () => {
|
||||
expect(written(s => set('teal', s))).toBe('')
|
||||
|
||||
const write = vi.fn()
|
||||
set('#08201f', { isTTY: false, write } as unknown as NodeJS.WriteStream)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('appends the restore to the exit reset once painted, not before', () => {
|
||||
expect(written(resetTerminalModes)).not.toContain(restore)
|
||||
|
||||
set('#101010', tty(vi.fn()))
|
||||
expect(written(resetTerminalModes)).toContain(restore)
|
||||
})
|
||||
|
||||
it('clears back to the terminal default when the next skin drops the color', () => {
|
||||
set('#123456', tty(vi.fn()))
|
||||
expect(written(s => set('', s))).toBe(restore)
|
||||
// Cleared: a later reset no longer restores.
|
||||
expect(written(resetTerminalModes)).not.toContain(restore)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPaintableHex', () => {
|
||||
it('matches exactly what the slot setters paint', () => {
|
||||
expect(isPaintableHex('#08201F')).toBe(true)
|
||||
expect(isPaintableHex('#08201f')).toBe(true)
|
||||
|
||||
for (const junk of ['', 'teal', '#fff', '#12345', '#1234567']) {
|
||||
expect(isPaintableHex(junk)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { terminalParityHints } from '../lib/terminalParity.js'
|
||||
|
||||
describe('terminalParityHints', () => {
|
||||
it('warns for Apple Terminal and SSH/tmux sessions', async () => {
|
||||
const hints = await terminalParityHints({
|
||||
TERM_PROGRAM: 'Apple_Terminal',
|
||||
TERM_SESSION_ID: 'w0t0p0:123',
|
||||
SSH_CONNECTION: '1',
|
||||
TMUX: '/tmp/tmux-1/default,1,0'
|
||||
} as NodeJS.ProcessEnv)
|
||||
|
||||
expect(hints.map(h => h.key)).toEqual(expect.arrayContaining(['apple-terminal', 'remote', 'tmux']))
|
||||
})
|
||||
|
||||
it('suggests IDE setup only for VS Code-family terminals that still need bindings', async () => {
|
||||
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
|
||||
const hints = await terminalParityHints({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, {
|
||||
fileOps: { readFile },
|
||||
homeDir: '/tmp/fake-home'
|
||||
})
|
||||
|
||||
expect(hints.some(h => h.key === 'ide-setup')).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses IDE setup hint when keybindings are already configured', async () => {
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus && terminalTextSelected',
|
||||
args: { text: '\u001b[99;13u' }
|
||||
},
|
||||
{
|
||||
key: 'shift+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[13;2u' }
|
||||
},
|
||||
{
|
||||
key: 'ctrl+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[13;5u' }
|
||||
},
|
||||
{
|
||||
key: 'cmd+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[13;9u' }
|
||||
},
|
||||
{
|
||||
key: 'cmd+z',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[122;9u' }
|
||||
},
|
||||
{
|
||||
key: 'shift+cmd+z',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[122;10u' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const hints = await terminalParityHints({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, {
|
||||
fileOps: { readFile },
|
||||
homeDir: '/tmp/fake-home'
|
||||
})
|
||||
|
||||
expect(hints.some(h => h.key === 'ide-setup')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows IDE setup hint when keybindings use legacy sequences', async () => {
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus && terminalTextSelected',
|
||||
args: { text: '\u001b[99;13u' }
|
||||
},
|
||||
{
|
||||
key: 'shift+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\\\r\n' }
|
||||
},
|
||||
{
|
||||
key: 'ctrl+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\\\r\n' }
|
||||
},
|
||||
{
|
||||
key: 'cmd+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\\\r\n' }
|
||||
},
|
||||
{
|
||||
key: 'cmd+z',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[122;9u' }
|
||||
},
|
||||
{
|
||||
key: 'shift+cmd+z',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[122;10u' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const hints = await terminalParityHints({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv, {
|
||||
fileOps: { readFile },
|
||||
homeDir: '/tmp/fake-home'
|
||||
})
|
||||
|
||||
// Legacy bindings don't match current CSI u targets, so setup is still needed
|
||||
expect(hints.some(h => h.key === 'ide-setup')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,507 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
configureDetectedTerminalKeybindings,
|
||||
configureTerminalKeybindings,
|
||||
detectVSCodeLikeTerminal,
|
||||
getVSCodeStyleConfigDir,
|
||||
shouldPromptForTerminalSetup,
|
||||
stripJsonComments
|
||||
} from '../lib/terminalSetup.js'
|
||||
|
||||
// Tests run from developer shells as well as CI. An inherited SSH_* variable
|
||||
// must not silently force every configure call down the remote-session reject
|
||||
// path; remote behavior is tested explicitly with per-call env objects below.
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('SSH_CONNECTION', '')
|
||||
vi.stubEnv('SSH_TTY', '')
|
||||
vi.stubEnv('SSH_CLIENT', '')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('terminalSetup helpers', () => {
|
||||
it('detects VS Code family terminals from environment', () => {
|
||||
expect(detectVSCodeLikeTerminal({ CURSOR_TRACE_ID: 'x' } as NodeJS.ProcessEnv)).toBe('cursor')
|
||||
expect(detectVSCodeLikeTerminal({ VSCODE_GIT_ASKPASS_MAIN: '/tmp/windsurf' } as NodeJS.ProcessEnv)).toBe('windsurf')
|
||||
expect(detectVSCodeLikeTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe('vscode')
|
||||
expect(detectVSCodeLikeTerminal({} as NodeJS.ProcessEnv)).toBeNull()
|
||||
})
|
||||
|
||||
it('computes VS Code style config dirs cross-platform', () => {
|
||||
expect(getVSCodeStyleConfigDir('Code', 'darwin', {} as NodeJS.ProcessEnv, '/home/me')).toBe(
|
||||
'/home/me/Library/Application Support/Code/User'
|
||||
)
|
||||
expect(getVSCodeStyleConfigDir('Code', 'linux', {} as NodeJS.ProcessEnv, '/home/me')).toBe(
|
||||
'/home/me/.config/Code/User'
|
||||
)
|
||||
expect(
|
||||
getVSCodeStyleConfigDir(
|
||||
'Code',
|
||||
'win32',
|
||||
{ APPDATA: 'C:/Users/me/AppData/Roaming' } as NodeJS.ProcessEnv,
|
||||
'/home/me'
|
||||
)
|
||||
).toBe('C:/Users/me/AppData/Roaming/Code/User')
|
||||
})
|
||||
|
||||
it('strips line comments from keybindings JSON', () => {
|
||||
expect(stripJsonComments('// comment\n[{"key":"shift+enter"}]')).toBe('\n[{"key":"shift+enter"}]')
|
||||
})
|
||||
|
||||
it('strips inline comments and block comments', () => {
|
||||
expect(stripJsonComments('[{"key":"a"} // inline\n]')).toBe('[{"key":"a"} \n]')
|
||||
expect(stripJsonComments('[/* block */{"key":"a"}]')).toBe('[{"key":"a"}]')
|
||||
})
|
||||
|
||||
it('removes trailing commas before ] or }', () => {
|
||||
expect(JSON.parse(stripJsonComments('[{"key":"a"},]'))).toEqual([{ key: 'a' }])
|
||||
expect(JSON.parse(stripJsonComments('[{"key":"a",}]'))).toEqual([{ key: 'a' }])
|
||||
})
|
||||
|
||||
it('preserves comment-like sequences inside strings', () => {
|
||||
const input = '[{"key":"a","args":{"text":"// not a comment"}}]'
|
||||
expect(JSON.parse(stripJsonComments(input))).toEqual([{ key: 'a', args: { text: '// not a comment' } }])
|
||||
})
|
||||
|
||||
it('handles unterminated block comments gracefully', () => {
|
||||
const input = '[{"key":"a"} /* never closed'
|
||||
const stripped = stripJsonComments(input)
|
||||
// The unterminated comment is consumed to end-of-file; the remainder is parseable
|
||||
expect(stripped).toBe('[{"key":"a"} ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('configureTerminalKeybindings', () => {
|
||||
it('writes missing bindings into a VS Code style keybindings file', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.requiresRestart).toBe(true)
|
||||
expect(writeFile).toHaveBeenCalledTimes(1)
|
||||
expect(copyFile).not.toHaveBeenCalled() // no existing file to back up
|
||||
const written = writeFile.mock.calls[0]?.[1] as string
|
||||
expect(written).toContain('cmd+c')
|
||||
expect(written).toContain('terminalTextSelected')
|
||||
expect(written).toContain('\\u001b[99;13u')
|
||||
expect(written).toContain('shift+enter')
|
||||
expect(written).toContain('\\u001b[13;2u')
|
||||
expect(written).toContain('cmd+enter')
|
||||
expect(written).toContain('\\u001b[13;9u')
|
||||
expect(written).toContain('ctrl+enter')
|
||||
expect(written).toContain('\\u001b[13;5u')
|
||||
expect(written).toContain('cmd+z')
|
||||
})
|
||||
|
||||
it('only adds the Cmd+C forwarding binding on macOS', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/home/me',
|
||||
platform: 'linux'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const written = writeFile.mock.calls[0]?.[1] as string
|
||||
expect(written).not.toContain('cmd+c')
|
||||
expect(written).not.toContain('terminalTextSelected')
|
||||
expect(written).not.toContain('\\u001b[99;13u')
|
||||
expect(written).toContain('shift+enter')
|
||||
})
|
||||
|
||||
it('reports conflicts without overwriting existing bindings', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+z',
|
||||
command: 'something.else',
|
||||
when: 'terminalFocus',
|
||||
args: { text: 'noop' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('cursor', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('cmd+z')
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
expect(copyFile).not.toHaveBeenCalled() // no backup when not writing
|
||||
})
|
||||
|
||||
it('flags a global (when-less) binding on the same key as a conflict', async () => {
|
||||
// A user's keybindings.json `cmd+c` with no `when` clause is global —
|
||||
// it overlaps any context, including our terminal scope. We must NOT
|
||||
// silently add a terminal-scoped cmd+c that would shadow it.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'myExtension.smartCopy'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('cmd+c')
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flags an overlapping terminal-context binding as a conflict', async () => {
|
||||
// Existing `cmd+c` scoped to plain `terminalFocus` overlaps with our
|
||||
// `terminalFocus && terminalTextSelected` — both fire when the
|
||||
// terminal is focused with text selected, so the existing binding
|
||||
// would shadow ours. Treat as a conflict even though the strings
|
||||
// aren't identical.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.copySelection',
|
||||
when: 'terminalFocus'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('cmd+c')
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not flag a negated terminalTextSelected binding as a conflict', async () => {
|
||||
// A binding scoped to "terminal focused but no selected text" is
|
||||
// logically disjoint from our copy-forwarding binding, which requires
|
||||
// terminalTextSelected.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus && !terminalTextSelected',
|
||||
args: { text: '\u0003' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(writeFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not flag a disjoint-when binding on the same key as a conflict', async () => {
|
||||
// VS Code allows multiple bindings for the same key when their `when`
|
||||
// clauses don't overlap. A user's pre-existing cmd+c binding scoped to
|
||||
// editor focus should NOT block our terminal-scoped cmd+c binding.
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'editor.action.clipboardCopyAction',
|
||||
when: 'editorFocus'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(writeFile).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('backs up existing keybindings.json only when writing changes', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
const readFile = vi.fn().mockResolvedValue(JSON.stringify([]))
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(writeFile).toHaveBeenCalledTimes(1)
|
||||
expect(copyFile).toHaveBeenCalledTimes(1) // backup created before writing
|
||||
})
|
||||
|
||||
it('reports error when keybindings.json is not readable (EACCES)', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }))
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('Failed to read')
|
||||
expect(writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('auto-detects the current IDE terminal', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
const readFile = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureDetectedTerminalKeybindings({
|
||||
env: { CURSOR_TRACE_ID: 'trace' } as NodeJS.ProcessEnv,
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(writeFile).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to configure IDE bindings from an SSH session', async () => {
|
||||
const result = await configureDetectedTerminalKeybindings({
|
||||
env: { SSH_CONNECTION: '1 2 3 4', TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.message).toContain('local machine')
|
||||
})
|
||||
|
||||
it('prompts for setup when bindings are missing and suppresses prompt when complete', async () => {
|
||||
const readMissing = vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
await expect(
|
||||
shouldPromptForTerminalSetup({
|
||||
env: { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,
|
||||
fileOps: { readFile: readMissing }
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
const readComplete = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus && terminalTextSelected',
|
||||
args: { text: '\u001b[99;13u' }
|
||||
},
|
||||
{
|
||||
key: 'shift+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[13;2u' }
|
||||
},
|
||||
{
|
||||
key: 'ctrl+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[13;5u' }
|
||||
},
|
||||
{
|
||||
key: 'cmd+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[13;9u' }
|
||||
},
|
||||
{
|
||||
key: 'cmd+z',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[122;9u' }
|
||||
},
|
||||
{
|
||||
key: 'shift+cmd+z',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[122;10u' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await expect(
|
||||
shouldPromptForTerminalSetup({
|
||||
env: { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,
|
||||
fileOps: { readFile: readComplete }
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('suppresses terminal setup prompts inside SSH sessions', async () => {
|
||||
await expect(
|
||||
shouldPromptForTerminalSetup({
|
||||
env: { SSH_CONNECTION: '1 2 3 4', TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('prompts for setup when legacy \\\r\n bindings are present', async () => {
|
||||
// Old keybindings using the legacy \\\r\n sequence should be detected as
|
||||
// incomplete — they need migration to the CSI u encoding.
|
||||
const readLegacy = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'cmd+c',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus && terminalTextSelected',
|
||||
args: { text: '\u001b[99;13u' }
|
||||
},
|
||||
{
|
||||
key: 'shift+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\\\r\n' }
|
||||
},
|
||||
{
|
||||
key: 'ctrl+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\\\r\n' }
|
||||
},
|
||||
{
|
||||
key: 'cmd+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\\\r\n' }
|
||||
},
|
||||
{
|
||||
key: 'cmd+z',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[122;9u' }
|
||||
},
|
||||
{
|
||||
key: 'shift+cmd+z',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\u001b[122;10u' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await expect(
|
||||
shouldPromptForTerminalSetup({
|
||||
env: { TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv,
|
||||
fileOps: { readFile: readLegacy }
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('migrates legacy \\\r\n bindings to CSI u sequences', async () => {
|
||||
const mkdir = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const readFile = vi.fn().mockResolvedValue(
|
||||
JSON.stringify([
|
||||
{
|
||||
key: 'shift+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\\\r\n' }
|
||||
},
|
||||
{
|
||||
key: 'ctrl+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\\\r\n' }
|
||||
},
|
||||
{
|
||||
key: 'cmd+enter',
|
||||
command: 'workbench.action.terminal.sendSequence',
|
||||
when: 'terminalFocus',
|
||||
args: { text: '\\\r\n' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const writeFile = vi.fn().mockResolvedValue(undefined)
|
||||
const copyFile = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
const result = await configureTerminalKeybindings('vscode', {
|
||||
fileOps: { copyFile, mkdir, readFile, writeFile },
|
||||
homeDir: '/Users/me',
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.requiresRestart).toBe(true)
|
||||
expect(result.message).toContain('migrated 3 legacy bindings to CSI u encoding')
|
||||
const written = writeFile.mock.calls[0]?.[1] as string
|
||||
const parsed = JSON.parse(written)
|
||||
|
||||
// All three Enter bindings should now use CSI u sequences
|
||||
const enterBindings = parsed.filter((b: { key: string }) =>
|
||||
['shift+enter', 'ctrl+enter', 'cmd+enter'].includes(b.key)
|
||||
)
|
||||
|
||||
expect(enterBindings).toHaveLength(3)
|
||||
expect(enterBindings.find((b: { key: string }) => b.key === 'shift+enter')?.args?.text).toBe('\u001b[13;2u')
|
||||
expect(enterBindings.find((b: { key: string }) => b.key === 'ctrl+enter')?.args?.text).toBe('\u001b[13;5u')
|
||||
expect(enterBindings.find((b: { key: string }) => b.key === 'cmd+enter')?.args?.text).toBe('\u001b[13;9u')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isTermuxEnv, isTermuxTuiMode } from '../lib/termux.js'
|
||||
|
||||
describe('isTermuxEnv', () => {
|
||||
it('detects TERMUX_VERSION marker', () => {
|
||||
expect(isTermuxEnv({ TERMUX_VERSION: '0.118.0' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects Termux PREFIX path marker', () => {
|
||||
expect(isTermuxEnv({ PREFIX: '/data/data/com.termux/files/usr' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for generic Linux envs', () => {
|
||||
expect(isTermuxEnv({ PREFIX: '/usr' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTermuxTuiMode', () => {
|
||||
it('defaults to true inside Termux', () => {
|
||||
expect(isTermuxTuiMode({ TERMUX_VERSION: '0.118.0' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
})
|
||||
|
||||
it('allows explicit opt-out override', () => {
|
||||
expect(isTermuxTuiMode({ TERMUX_VERSION: '0.118.0', HERMES_TUI_TERMUX_MODE: '0' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
|
||||
it('stays false outside Termux even if override is set', () => {
|
||||
expect(isTermuxTuiMode({ HERMES_TUI_TERMUX_MODE: '1', PREFIX: '/usr' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { stableComposerColumns, transcriptBodyWidth } from '../lib/inputMetrics.js'
|
||||
import { composerPromptText } from '../lib/prompt.js'
|
||||
|
||||
describe('Termux composer prompt + width guards', () => {
|
||||
it('uses a single-cell ASCII prompt marker in Termux mode', () => {
|
||||
expect(composerPromptText('❯', 'coder', false, true, 50)).toBe('>')
|
||||
})
|
||||
|
||||
it('suppresses profile prefixes on narrow Termux panes', () => {
|
||||
expect(composerPromptText('❯', 'upstr', false, true, 72)).toBe('>')
|
||||
})
|
||||
|
||||
it('keeps profile context on very wide Termux panes', () => {
|
||||
expect(composerPromptText('❯', 'upstr', false, true, 120)).toBe('upstr >')
|
||||
})
|
||||
|
||||
it('reserves fewer columns for gutter on narrow Termux widths', () => {
|
||||
// 32 columns after prompt: desktop reserves 2 for transcript scrollbar,
|
||||
// Termux keeps those 2 columns for the active composer.
|
||||
expect(stableComposerColumns(40, 8, false)).toBe(28)
|
||||
expect(stableComposerColumns(40, 8, true)).toBe(30)
|
||||
|
||||
// With ample room, Termux still reserves the gutter for alignment.
|
||||
expect(stableComposerColumns(60, 8, true)).toBe(48)
|
||||
})
|
||||
|
||||
it('never over-allocates transcript body width on narrow panes', () => {
|
||||
// Old behavior hard-minned to 20 columns and overflowed narrow layouts.
|
||||
expect(transcriptBodyWidth(24, 'assistant', '>', true)).toBe(19)
|
||||
expect(transcriptBodyWidth(24, 'user', 'upstr >', true)).toBe(14)
|
||||
expect(transcriptBodyWidth(10, 'user', '>', true)).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('keeps legacy desktop floor outside Termux mode', () => {
|
||||
expect(transcriptBodyWidth(24, 'assistant', '>')).toBe(20)
|
||||
expect(transcriptBodyWidth(24, 'user', 'upstr >')).toBe(20)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
boundedLiveRenderText,
|
||||
buildToolTrailLine,
|
||||
buildVerboseToolTrailLine,
|
||||
edgePreview,
|
||||
estimateRows,
|
||||
estimateTokensRough,
|
||||
fmtK,
|
||||
hasAnsi,
|
||||
isToolTrailResultLine,
|
||||
lastCotTrailIndex,
|
||||
parseToolTrailResultLine,
|
||||
pasteTokenLabel,
|
||||
sameToolTrailGroup,
|
||||
sanitizeAnsiForRender,
|
||||
splitToolDuration,
|
||||
stripAnsi,
|
||||
thinkingPreview
|
||||
} from '../lib/text.js'
|
||||
|
||||
describe('isToolTrailResultLine', () => {
|
||||
it('detects completion markers', () => {
|
||||
expect(isToolTrailResultLine('foo ✓')).toBe(true)
|
||||
expect(isToolTrailResultLine('foo ✗')).toBe(true)
|
||||
expect(isToolTrailResultLine('drafting x…')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildToolTrailLine', () => {
|
||||
it('puts completion duration inline before the result marker', () => {
|
||||
const line = buildToolTrailLine('read_file', 'x', false, '', 0.94)
|
||||
|
||||
expect(line).toBe('Read File("x") (0.9s) ✓')
|
||||
expect(parseToolTrailResultLine(line)).toEqual({ call: 'Read File("x") (0.9s)', detail: '', mark: '✓' })
|
||||
expect(splitToolDuration('Read File("x") (0.9s)')).toEqual({ label: 'Read File("x")', duration: ' (0.9s)' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildVerboseToolTrailLine', () => {
|
||||
it('preserves multiline args and result details', () => {
|
||||
const line = buildVerboseToolTrailLine(
|
||||
'terminal',
|
||||
'npm test',
|
||||
false,
|
||||
1.25,
|
||||
'{\n "cmd": "npm test"\n}',
|
||||
'first line\nsecond :: line'
|
||||
)
|
||||
|
||||
expect(line).toContain('Args:\n{')
|
||||
expect(line).toContain('Result:\nfirst line\nsecond :: line')
|
||||
expect(parseToolTrailResultLine(line)).toEqual({
|
||||
call: 'Terminal("npm test") (1.3s)',
|
||||
detail: 'Args:\n{\n "cmd": "npm test"\n}\nResult:\nfirst line\nsecond :: line',
|
||||
mark: '✓'
|
||||
})
|
||||
})
|
||||
|
||||
it('labels verbose failures as errors', () => {
|
||||
const line = buildVerboseToolTrailLine('terminal', 'npm test', true, 0.5, undefined, 'command failed')
|
||||
|
||||
expect(line).toContain('Error:\ncommand failed')
|
||||
expect(line).not.toContain('Result:\ncommand failed')
|
||||
expect(parseToolTrailResultLine(line)).toEqual({
|
||||
call: 'Terminal("npm test") (0.5s)',
|
||||
detail: 'Error:\ncommand failed',
|
||||
mark: '✗'
|
||||
})
|
||||
})
|
||||
|
||||
it('caps a large result to a small persisted preview (#34095)', () => {
|
||||
// A 40KB browser-snapshot-sized result must NOT be embedded whole — the
|
||||
// persisted, expanded-by-default trail block is what blew up the Ink
|
||||
// render tree and silently OOM-killed the TUI. The block stays small.
|
||||
const huge = 'A'.repeat(40_000)
|
||||
const line = buildVerboseToolTrailLine('browser_snapshot', 'https://x.example', false, 2, undefined, huge)
|
||||
|
||||
expect(line).toContain('Result:\n')
|
||||
// Far below the old 16KB live-render budget; the whole line (call + label +
|
||||
// omitted marker + preview) must stay on the order of ~1KB, not ~40KB.
|
||||
expect(line.length).toBeLessThan(2_000)
|
||||
expect(line).toContain('omitted')
|
||||
expect(line.endsWith(' ✓')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not truncate a result that already fits the preview budget', () => {
|
||||
const small = 'ok: 3 files changed'
|
||||
const line = buildVerboseToolTrailLine('patch', 'index.html', false, 0.1, undefined, small)
|
||||
|
||||
expect(line).toContain(`Result:\n${small}`)
|
||||
expect(line).not.toContain('omitted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('lastCotTrailIndex', () => {
|
||||
it('finds last non-result line', () => {
|
||||
expect(lastCotTrailIndex(['a ✓', 'thinking…'])).toBe(1)
|
||||
expect(lastCotTrailIndex(['only result ✓'])).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sameToolTrailGroup', () => {
|
||||
it('matches bare check lines', () => {
|
||||
expect(sameToolTrailGroup('searching', 'searching ✓')).toBe(true)
|
||||
expect(sameToolTrailGroup('searching', 'searching ✗')).toBe(true)
|
||||
})
|
||||
|
||||
it('matches contextual lines', () => {
|
||||
expect(sameToolTrailGroup('searching', 'searching: * ✓')).toBe(true)
|
||||
expect(sameToolTrailGroup('searching', 'searching: foo ✓')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects other tools', () => {
|
||||
expect(sameToolTrailGroup('searching', 'reading ✓')).toBe(false)
|
||||
expect(sameToolTrailGroup('searching', 'searching extra ✓')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fmtK', () => {
|
||||
it('keeps small numbers plain', () => {
|
||||
expect(fmtK(999)).toBe('999')
|
||||
})
|
||||
|
||||
it('formats thousands as lowercase k', () => {
|
||||
expect(fmtK(1000)).toBe('1k')
|
||||
expect(fmtK(1500)).toBe('1.5k')
|
||||
})
|
||||
|
||||
it('formats millions and billions with lowercase suffixes', () => {
|
||||
expect(fmtK(1_000_000)).toBe('1m')
|
||||
expect(fmtK(1_000_000_000)).toBe('1b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateTokensRough', () => {
|
||||
it('uses 4 chars per token rounding up', () => {
|
||||
expect(estimateTokensRough('')).toBe(0)
|
||||
expect(estimateTokensRough('a')).toBe(1)
|
||||
expect(estimateTokensRough('abcd')).toBe(1)
|
||||
expect(estimateTokensRough('abcde')).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ANSI sanitizers', () => {
|
||||
const ESC = String.fromCharCode(27)
|
||||
const BEL = String.fromCharCode(7)
|
||||
|
||||
it('strips CSI/OSC/control bytes from plain previews', () => {
|
||||
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}C${ESC}[?25lD`
|
||||
|
||||
expect(stripAnsi(sample)).toBe('ABCD')
|
||||
})
|
||||
|
||||
it('strips incomplete CSI prefixes and carriage returns', () => {
|
||||
const sample = `A${ESC}[31mB${ESC}[12;${ESC}[CD\rE`
|
||||
|
||||
expect(stripAnsi(sample)).toBe('ABDE')
|
||||
})
|
||||
|
||||
it('keeps SGR color spans but removes cursor controls for Ansi rendering', () => {
|
||||
const sample = `A${ESC}[31mB${ESC}[39m${ESC}[2J${ESC}]0;title${BEL}${ESC}[?25lC`
|
||||
|
||||
expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mC`)
|
||||
})
|
||||
|
||||
it('keeps valid SGR while removing dangling CSI and carriage returns', () => {
|
||||
const sample = `A${ESC}[31mB${ESC}[12;${ESC}[39mC\rD`
|
||||
|
||||
expect(sanitizeAnsiForRender(sample)).toBe(`A${ESC}[31mB${ESC}[39mCD`)
|
||||
})
|
||||
|
||||
it('strips multi-byte non-CSI ESC sequences without leaving trailing bytes', () => {
|
||||
const sample = `A${ESC}(0B${ESC}%GC${ESC})0D`
|
||||
|
||||
expect(stripAnsi(sample)).toBe('ABCD')
|
||||
expect(sanitizeAnsiForRender(sample)).toBe('ABCD')
|
||||
})
|
||||
|
||||
it('detects non-CSI escape prefixes too', () => {
|
||||
expect(hasAnsi(`ok${ESC}Ppayload${ESC}\\`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('thinkingPreview', () => {
|
||||
it('adds paragraph breaks before markdown thinking headings', () => {
|
||||
const raw =
|
||||
'**Considering user instructions**\nI need to answer.**Planning tool execution**\nI can run tools.**Determining weather search parameters**\nUse SF.'
|
||||
|
||||
expect(thinkingPreview(raw, 'full')).toBe(
|
||||
'**Considering user instructions**\nI need to answer.\n\n**Planning tool execution**\nI can run tools.\n\n**Determining weather search parameters**\nUse SF.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundedLiveRenderText', () => {
|
||||
it('preserves short live text verbatim', () => {
|
||||
expect(boundedLiveRenderText('one\ntwo', { maxChars: 100, maxLines: 10 })).toBe('one\ntwo')
|
||||
})
|
||||
|
||||
it('keeps the live tail by character budget', () => {
|
||||
const out = boundedLiveRenderText('abcdefghij', { maxChars: 4, maxLines: 10 })
|
||||
|
||||
expect(out).toContain('ghij')
|
||||
expect(out).toContain('omitted')
|
||||
expect(out).not.toContain('abcdef')
|
||||
})
|
||||
|
||||
it('keeps the live tail by line budget', () => {
|
||||
const out = boundedLiveRenderText(['a', 'b', 'c', 'd'].join('\n'), { maxChars: 100, maxLines: 2 })
|
||||
|
||||
expect(out).toContain('c\nd')
|
||||
expect(out).toContain('omitted 2 lines')
|
||||
expect(out).not.toContain('a\nb')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edgePreview', () => {
|
||||
it('keeps both ends for long text', () => {
|
||||
expect(edgePreview('Vampire Bondage ropes slipped from her neck, still stained with blood', 8, 18)).toBe(
|
||||
'Vampire.. stained with blood'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('thinkingPreview over-bound tail', () => {
|
||||
it('retains the live tail when reasoning exceeds the clean bound', () => {
|
||||
const TAIL = '<<<LIVE_TAIL_MARKER>>>'
|
||||
// Slightly above the 24k clean-tail bound, so the implementation must trim.
|
||||
const reasoning = 'A'.repeat(25_000) + '\n' + TAIL
|
||||
const result = thinkingPreview(reasoning, 'full')
|
||||
expect(result).toContain(TAIL)
|
||||
// The bounded window is shorter than the 25k prefix, but the tail remains.
|
||||
expect(result.length).toBeLessThanOrEqual(25_000)
|
||||
})
|
||||
})
|
||||
describe('pasteTokenLabel', () => {
|
||||
it('builds readable long-paste labels with counts', () => {
|
||||
const label = pasteTokenLabel('Vampire Bondage ropes slipped from her neck, still stained with blood', 250)
|
||||
expect(label.startsWith('[[ ')).toBe(true)
|
||||
expect(label).toContain('[250 lines]')
|
||||
expect(label.endsWith(' ]]')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('estimateRows', () => {
|
||||
it('handles tilde code fences', () => {
|
||||
const md = ['~~~markdown', '# heading', '~~~'].join('\n')
|
||||
|
||||
expect(estimateRows(md, 40)).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('handles checklist bullets as list rows', () => {
|
||||
const md = ['- [x] done', '- [ ] todo'].join('\n')
|
||||
|
||||
expect(estimateRows(md, 40)).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps intraword underscores when sizing snake_case identifiers', () => {
|
||||
const w = 80
|
||||
const snake = 'look at test_case_with_underscores now'
|
||||
const plain = 'look at test case with underscores now'
|
||||
|
||||
expect(estimateRows(snake, w)).toBe(estimateRows(plain, w))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { applyPrintableInsert, shouldRouteMultiCharInputAsPaste } from '../components/textInput.js'
|
||||
|
||||
describe('applyPrintableInsert', () => {
|
||||
it('applies non-bracketed multi-character bursts immediately', () => {
|
||||
const burst = applyPrintableInsert('abc', 3, 'xxxxx')
|
||||
|
||||
const repeated = [...'xxxxx'].reduce((state, ch) => applyPrintableInsert(state.value, state.cursor, ch)!, {
|
||||
cursor: 3,
|
||||
value: 'abc'
|
||||
})
|
||||
|
||||
expect(burst).toEqual({ cursor: 8, value: 'abcxxxxx' })
|
||||
expect(burst).toEqual(repeated)
|
||||
})
|
||||
|
||||
it('replaces the selected range for burst input', () => {
|
||||
expect(applyPrintableInsert('abZZef', 4, 'cd', { end: 4, start: 2 })).toEqual({
|
||||
cursor: 4,
|
||||
value: 'abcdef'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects control or escape-bearing input', () => {
|
||||
expect(applyPrintableInsert('abc', 3, '\x1b[200~pasted')).toBeNull()
|
||||
expect(applyPrintableInsert('abc', 3, '\t')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRouteMultiCharInputAsPaste', () => {
|
||||
it('keeps newline-bearing chunks on the paste path', () => {
|
||||
expect(shouldRouteMultiCharInputAsPaste('hello\nworld')).toBe(true)
|
||||
expect(shouldRouteMultiCharInputAsPaste('hello\r\nworld'.replace(/\r\n/g, '\n'))).toBe(true)
|
||||
})
|
||||
|
||||
it('treats repeated printable key bursts as immediate input', () => {
|
||||
expect(shouldRouteMultiCharInputAsPaste('xxxxx')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { fastAppendEffect, fastBackspaceEffect, resolveCursorLayout } from '../components/textInput.js'
|
||||
import { cursorLayout } from '../lib/inputMetrics.js'
|
||||
|
||||
// Closes Copilot follow-up on PR #26717: the original cursor-drift
|
||||
// fix bumped Ink's displayCursor / cursorDeclaration on fast-echo, but
|
||||
// if TextInput itself re-renders before the deferred 16ms `setCur`
|
||||
// flushes (parent state change, status-bar tick, spinner) the layout
|
||||
// effect inside `useDeclaredCursor` re-publishes a declaration
|
||||
// computed from the STALE React `cur` state and clobbers the Ink-level
|
||||
// bump. The fix is structural: read `curRef.current` (always
|
||||
// up-to-date) when computing the layout, not the `cur` state.
|
||||
//
|
||||
// These tests exercise the real, exported `resolveCursorLayout`,
|
||||
// `fastBackspaceEffect`, and `fastAppendEffect` helpers that
|
||||
// `textInput.tsx` calls at its render site and fast-echo call sites —
|
||||
// no source-text regex, no readFileSync.
|
||||
describe('resolveCursorLayout', () => {
|
||||
it('uses curRefCurrent (the fresh ref value), not the stale cur state', () => {
|
||||
// Simulate the exact bug scenario: `cur` (React state) is stale —
|
||||
// it still reflects the value before a fast-echo append — while
|
||||
// `curRef.current` has already advanced past it.
|
||||
const display = 'hello world'
|
||||
const staleCur = 5
|
||||
const freshCurRefCurrent = 11
|
||||
const columns = 80
|
||||
|
||||
const result = resolveCursorLayout(display, staleCur, freshCurRefCurrent, columns)
|
||||
const expected = cursorLayout(display, freshCurRefCurrent, columns)
|
||||
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it('does not match the layout computed from the stale cur value', () => {
|
||||
const display = 'hello world'
|
||||
const staleCur = 5
|
||||
const freshCurRefCurrent = 11
|
||||
const columns = 80
|
||||
|
||||
const result = resolveCursorLayout(display, staleCur, freshCurRefCurrent, columns)
|
||||
const staleLayout = cursorLayout(display, staleCur, columns)
|
||||
|
||||
expect(result).not.toEqual(staleLayout)
|
||||
})
|
||||
|
||||
it('matches cursorLayout(display, curRefCurrent, columns) even when cur and curRefCurrent agree', () => {
|
||||
const display = 'hello'
|
||||
const cur = 5
|
||||
const columns = 80
|
||||
|
||||
expect(resolveCursorLayout(display, cur, cur, columns)).toEqual(cursorLayout(display, cur, columns))
|
||||
})
|
||||
})
|
||||
|
||||
describe('fastBackspaceEffect', () => {
|
||||
it('removes the last character, moves the cursor back one, and pairs the write with the advance delta', () => {
|
||||
const effect = fastBackspaceEffect('hello', 5)
|
||||
|
||||
expect(effect.newValue).toBe('hell')
|
||||
expect(effect.newCursor).toBe(4)
|
||||
expect(effect.removed).toBe('o')
|
||||
// Both the stdout write and the noteCursorAdvance delta live on the
|
||||
// same returned object — a caller cannot apply `write` without also
|
||||
// having `advanceDelta` in hand, so the pairing can't silently drift.
|
||||
expect(effect.write).toBe('\b \b')
|
||||
expect(effect.advanceDelta).toBe(-1)
|
||||
})
|
||||
|
||||
it('handles deleting from the middle of the fast-echo-eligible tail', () => {
|
||||
const effect = fastBackspaceEffect('abc', 3)
|
||||
|
||||
expect(effect.newValue).toBe('ab')
|
||||
expect(effect.newCursor).toBe(2)
|
||||
expect(effect.removed).toBe('c')
|
||||
expect(effect.write).toBe('\b \b')
|
||||
expect(effect.advanceDelta).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fastAppendEffect', () => {
|
||||
it('appends the text, advances the cursor by the inserted length, and pairs the write with the advance delta', () => {
|
||||
const effect = fastAppendEffect('hello', 5, ' world')
|
||||
|
||||
expect(effect.newValue).toBe('hello world')
|
||||
expect(effect.newCursor).toBe(11)
|
||||
// The stdout write is exactly the inserted text, and the
|
||||
// noteCursorAdvance delta is bundled into the same object.
|
||||
expect(effect.write).toBe(' world')
|
||||
expect(effect.advanceDelta).toBe(' world'.length)
|
||||
})
|
||||
|
||||
it('advance delta always matches the inserted text length, not a hardcoded value', () => {
|
||||
const effect = fastAppendEffect('x', 1, 'abc')
|
||||
|
||||
expect(effect.newValue).toBe('xabc')
|
||||
expect(effect.advanceDelta).toBe(3)
|
||||
expect(effect.write).toBe('abc')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { cutSelection } from '../components/textInput.js'
|
||||
|
||||
describe('cutSelection (transactional cut)', () => {
|
||||
it('removes the selection only after the clipboard write succeeds', async () => {
|
||||
const write = vi.fn().mockResolvedValue(true)
|
||||
const removeSelection = vi.fn()
|
||||
|
||||
const ok = await cutSelection('hello', write, removeSelection)
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(write).toHaveBeenCalledWith('hello')
|
||||
expect(removeSelection).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the text intact when the clipboard write fails (headless/SSH)', async () => {
|
||||
const write = vi.fn().mockResolvedValue(false)
|
||||
const removeSelection = vi.fn()
|
||||
|
||||
const ok = await cutSelection('hello', write, removeSelection)
|
||||
|
||||
expect(ok).toBe(false)
|
||||
expect(write).toHaveBeenCalledWith('hello')
|
||||
// Text must NOT be removed. A failed write would otherwise destroy it with no
|
||||
// clipboard copy to paste back.
|
||||
expect(removeSelection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('awaits the write before removing (no fire-and-forget removal)', async () => {
|
||||
let resolveWrite: (value: boolean) => void = () => {}
|
||||
|
||||
const write = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>(resolve => {
|
||||
resolveWrite = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const removeSelection = vi.fn()
|
||||
|
||||
const pending = cutSelection('hello', write, removeSelection)
|
||||
|
||||
// While the write is still pending the selection must remain untouched.
|
||||
await Promise.resolve()
|
||||
expect(removeSelection).not.toHaveBeenCalled()
|
||||
|
||||
resolveWrite(true)
|
||||
await pending
|
||||
|
||||
expect(removeSelection).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import { colorize } from '@hermes/ink'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
canFastAppendShape,
|
||||
canFastBackspaceShape,
|
||||
colorizeEcho,
|
||||
colorizeHint,
|
||||
hintCursorCell,
|
||||
supportsFastEchoTerminal
|
||||
} from '../components/textInput.js'
|
||||
|
||||
// The fast-echo path bypasses Ink and writes characters directly to stdout
|
||||
// for the common case of typing plain English at the end of the line. These
|
||||
// tests pin the shape preconditions that make that bypass safe.
|
||||
//
|
||||
// Regression intent: any non-ASCII text — Vietnamese precomposed letters
|
||||
// (one grapheme, `text.length === 1`, `stringWidth === 1`, but produced
|
||||
// via IME composition across multiple keystrokes), combining marks
|
||||
// (zero width), CJK (double width), emoji (variable width), or anything
|
||||
// that could be produced by an in-flight IME composition — must NOT
|
||||
// take the bypass. Closes:
|
||||
// - "TUI is experiencing font errors when using Unicode to type Vietnamese"
|
||||
// - #5221 TUI input box renders incorrectly for CJK / East-Asian wide
|
||||
// - #7443 CLI TUI renders and deletes Chinese characters incorrectly
|
||||
// - #17602 / #17603 Chinese text scattering / ghosting
|
||||
|
||||
describe('canFastAppendShape', () => {
|
||||
const COLS = 40
|
||||
|
||||
it('accepts plain ASCII appended at end of single-line input', () => {
|
||||
expect(canFastAppendShape('hello', 5, 'x', COLS, 5)).toBe(true)
|
||||
expect(canFastAppendShape('hello', 5, ' world', COLS, 5)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects when cursor is not at end of line', () => {
|
||||
expect(canFastAppendShape('hello', 3, 'x', COLS, 5)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when current is empty (placeholder render path needed)', () => {
|
||||
expect(canFastAppendShape('', 0, 'x', COLS, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when current contains a newline (multi-line layout)', () => {
|
||||
expect(canFastAppendShape('hi\nthere', 8, 'x', COLS, 5)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when appending would hit the wrap column', () => {
|
||||
// Reaching cols on append must trigger a wrap, which the bypass
|
||||
// cannot draw. Stay strictly below cols.
|
||||
expect(canFastAppendShape('hello', 5, 'x', 6, 5)).toBe(false)
|
||||
})
|
||||
|
||||
// -- Regression coverage: Vietnamese / combining marks / IME --
|
||||
|
||||
it('rejects Vietnamese precomposed letter ề (U+1EC1) — IME composition path', () => {
|
||||
// 'ề' is one grapheme, length 1, width 1, but Vietnamese Telex/IME
|
||||
// produces it via a multi-key composition. Fast-echo would commit the
|
||||
// intermediate state to stdout and desync once the final commit
|
||||
// arrives.
|
||||
expect(canFastAppendShape('hello', 5, 'ề', COLS, 5)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects Vietnamese tone marks ă, ơ, ư (Latin-Extended-A/B)', () => {
|
||||
for (const ch of ['ă', 'ắ', 'ơ', 'ờ', 'ư', 'ự']) {
|
||||
expect(canFastAppendShape('hello', 5, ch, COLS, 5)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects NFD combining marks (U+0300 grave, U+0301 acute, U+0302 circumflex)', () => {
|
||||
// Decomposed Vietnamese: 'e' + combining circumflex + combining grave
|
||||
// = 'ề'. Each combining mark is zero-width but length 1; without the
|
||||
// ASCII guard the second/third keypress would be fast-echoed and
|
||||
// desync the cell column.
|
||||
expect(canFastAppendShape('hello', 5, '\u0300', COLS, 5)).toBe(false)
|
||||
expect(canFastAppendShape('hello', 5, '\u0301', COLS, 5)).toBe(false)
|
||||
expect(canFastAppendShape('hello', 5, '\u0302', COLS, 5)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects CJK (East-Asian wide) characters', () => {
|
||||
expect(canFastAppendShape('hello', 5, '你', COLS, 5)).toBe(false)
|
||||
expect(canFastAppendShape('hello', 5, '日本', COLS, 5)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects emoji', () => {
|
||||
expect(canFastAppendShape('hello', 5, '🙂', COLS, 5)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects ANSI-bearing or control text', () => {
|
||||
expect(canFastAppendShape('hello', 5, '\x1b[31m', COLS, 5)).toBe(false)
|
||||
expect(canFastAppendShape('hello', 5, '\t', COLS, 5)).toBe(false)
|
||||
expect(canFastAppendShape('hello', 5, '\x7f', COLS, 5)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects NBSP and Latin-1 letters that would change the line shape', () => {
|
||||
expect(canFastAppendShape('hello', 5, '\u00a0', COLS, 5)).toBe(false)
|
||||
expect(canFastAppendShape('hello', 5, 'é', COLS, 5)).toBe(false)
|
||||
expect(canFastAppendShape('hello', 5, 'ñ', COLS, 5)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canFastBackspaceShape', () => {
|
||||
it('accepts deleting the last ASCII char', () => {
|
||||
expect(canFastBackspaceShape('hello', 5)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects when cursor is not at end', () => {
|
||||
expect(canFastBackspaceShape('hello', 3)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when there is nothing to delete', () => {
|
||||
expect(canFastBackspaceShape('', 0)).toBe(false)
|
||||
expect(canFastBackspaceShape('hello', 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects when value contains a newline', () => {
|
||||
expect(canFastBackspaceShape('hi\nthere', 8)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects deleting Vietnamese precomposed letter ề', () => {
|
||||
// The "\b \b" shortcut clears one terminal cell; that's fine for a
|
||||
// 1-cell ASCII char but if the previous grapheme is a Vietnamese
|
||||
// letter that the IME may still be holding open, we want Ink to
|
||||
// re-render so composition state stays consistent.
|
||||
expect(canFastBackspaceShape('helloề', 'helloề'.length)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects deleting a CJK character (2 cells)', () => {
|
||||
expect(canFastBackspaceShape('hi你', 'hi你'.length)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects deleting a NFD-composed grapheme with combining marks', () => {
|
||||
// 'e' + U+0302 (circumflex) + U+0300 (grave) — final grapheme is one
|
||||
// cluster but the previous-grapheme slice is multi-codepoint. Width
|
||||
// is 1 but the bypass would be unsafe because the rendered cell
|
||||
// already contained the combined glyph.
|
||||
const s = 'hello' + 'e\u0302\u0300'
|
||||
expect(canFastBackspaceShape(s, s.length)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects deleting an emoji', () => {
|
||||
expect(canFastBackspaceShape('hi🙂', 'hi🙂'.length)).toBe(false)
|
||||
})
|
||||
|
||||
// Closes Copilot PR #26717 round 3: the "\b \b" sequence cannot move
|
||||
// the terminal cursor onto the previous visual row across a
|
||||
// soft-wrap boundary. When the caret sits at visual column 0 of a
|
||||
// wrapped row (column == 0 in the computed cursor layout), backspace
|
||||
// would leave the physical cursor in place while the logical caret
|
||||
// moves up to the end of the previous visual line — desyncing both
|
||||
// Ink's displayCursor model and the user-visible position. The fast
|
||||
// path must fall through in that case so the normal Ink render path
|
||||
// can lay out the correct cursor position.
|
||||
it('rejects fast-backspace at a soft-wrap boundary when columns is known', () => {
|
||||
// value width 6 in a column of 6 → cursorLayout produces (line 1, col 0)
|
||||
// i.e. the caret has overflowed onto the next visual line.
|
||||
const value = 'hello '
|
||||
expect(canFastBackspaceShape(value, value.length, 6)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects fast-backspace at an exact multiple of columns (wide wrap)', () => {
|
||||
// 12 chars at width 6 → two full visual rows, caret at (line 2, col 0).
|
||||
const value = 'abcdefghijkl'
|
||||
expect(canFastBackspaceShape(value, value.length, 6)).toBe(false)
|
||||
})
|
||||
|
||||
it('still accepts fast-backspace inside a wrapped line', () => {
|
||||
// Caret mid-visual-line — "\b \b" can move the cursor one cell left
|
||||
// without crossing a wrap boundary.
|
||||
expect(canFastBackspaceShape('hello world', 'hello world'.length, 20)).toBe(true)
|
||||
expect(canFastBackspaceShape('abcdefghi', 9, 6)).toBe(true) // visual line 1, col 3 → ok
|
||||
})
|
||||
|
||||
it('skips the wrap-boundary check when columns is omitted (legacy contract)', () => {
|
||||
// Callers that don't pass `columns` fall back to the pre-wrap-aware
|
||||
// behavior — the function does NOT magically reject anything that
|
||||
// could be a wrap boundary without the width. Production callers
|
||||
// must always pass `columns`; this case is for unit tests of the
|
||||
// pre-wrap shape contract.
|
||||
expect(canFastBackspaceShape('hello ', 'hello '.length)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('colorizeEcho', () => {
|
||||
// The fast-echo bypass writes raw cells past Ink, so a themed input must
|
||||
// carry the theme fg explicitly — a default-fg glyph goes invisible when a
|
||||
// skin repaints the background to the opposite polarity (dark skin on a
|
||||
// light terminal ⇒ black-on-black).
|
||||
|
||||
it('matches Ink exactly, never a hand-rolled truecolor escape', () => {
|
||||
// The bypass and the Ink render paint the same cells, so they must agree
|
||||
// byte-for-byte at whatever depth the terminal supports. Hand-rolling
|
||||
// `38;2;r;g;b` shipped an escape a 256-color terminal (Apple Terminal)
|
||||
// cannot parse: the accent fell back to the default fg and read GRAY.
|
||||
// Asserted as an equality rather than a literal because chalk resolves
|
||||
// its depth at import time — under vitest that's level 0 (no color).
|
||||
for (const tone of ['#ff2d95', '#e77fa3', 'ansi256(211)']) {
|
||||
expect(colorizeEcho('x', tone)).toBe(colorize('x', tone, 'foreground'))
|
||||
}
|
||||
})
|
||||
|
||||
it('passes through untouched without a color (unthemed keeps terminal default)', () => {
|
||||
expect(colorizeEcho('x')).toBe('x')
|
||||
expect(colorizeEcho('x', undefined)).toBe('x')
|
||||
})
|
||||
|
||||
it('passes through on a non-color value (never emit a garbage SGR)', () => {
|
||||
expect(colorizeEcho('x', 'red')).toBe('x')
|
||||
expect(colorizeEcho('x', '#fff')).toBe('x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('colorizeHint / hintCursorCell', () => {
|
||||
// The placeholder bypass writes raw bytes past Ink too. Hand-rolling
|
||||
// `38;2;r;g;b` here was WORSE than the gray-accent bug colorizeEcho had:
|
||||
// legacy Terminal.app walks compound params one by one, so the literal `2`
|
||||
// in `38;2;…` landed as SGR 2 (dim ON) with no closing `22m` — every
|
||||
// frame that painted the placeholder left the terminal's dim flag stuck,
|
||||
// and later unstyled cells rendered randomly dimmed. Both helpers must
|
||||
// route through Ink's own colorize so depth downgrades with the terminal.
|
||||
|
||||
it('hint matches Ink exactly, never a hand-rolled truecolor escape', () => {
|
||||
for (const tone of ['#8a8094', '#e77fa3']) {
|
||||
expect(colorizeHint('Try it', tone)).toBe(colorize('Try it', tone, 'foreground'))
|
||||
}
|
||||
})
|
||||
|
||||
it('hint falls back to the neutral gray on junk, still through colorize', () => {
|
||||
expect(colorizeHint('x')).toBe(colorize('x', '#808080', 'foreground'))
|
||||
expect(colorizeHint('x', 'nope')).toBe(colorize('x', '#808080', 'foreground'))
|
||||
})
|
||||
|
||||
it('cursor chip composes bg+fg through colorize only', () => {
|
||||
expect(hintCursorCell('T', '#8a8094')).toBe(
|
||||
colorize(colorize('T', '#ffffff', 'foreground'), '#8a8094', 'background')
|
||||
)
|
||||
})
|
||||
|
||||
it('never emits a raw 38;2/48;2 the depth layer did not choose', () => {
|
||||
// chalk is level 0 under vitest, so ANY escape byte here means the
|
||||
// helper bypassed colorize and hand-rolled the sequence.
|
||||
expect(colorizeHint('x', '#8a8094')).not.toContain('\u001b')
|
||||
expect(hintCursorCell('x', '#8a8094')).not.toContain('\u001b')
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsFastEchoTerminal', () => {
|
||||
it('disables fast-echo in Apple Terminal', () => {
|
||||
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'Apple_Terminal' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
|
||||
it('disables fast-echo inside tmux', () => {
|
||||
expect(supportsFastEchoTerminal({ TMUX: '/tmp/tmux-1000/default,1234,0' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
expect(supportsFastEchoTerminal({ TMUX: '/private/tmp/tmux-501/default' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
|
||||
it('tmux wins over Termux fast-echo opt-in', () => {
|
||||
expect(
|
||||
supportsFastEchoTerminal({
|
||||
TMUX: '/tmp/tmux-1000/default,1234,0',
|
||||
HERMES_TUI_TERMUX_FAST_ECHO: '1',
|
||||
TERMUX_VERSION: '0.118.0'
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps fast-echo enabled when TMUX is empty or unset', () => {
|
||||
expect(supportsFastEchoTerminal({ TMUX: '' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
})
|
||||
|
||||
it('disables fast-echo when only a tmux-flavored TERM is present (SSH from tmux, no TMUX forwarded)', () => {
|
||||
// OpenSSH forwards TERM but not TMUX, so a TUI on a remote host launched
|
||||
// from inside local tmux sees TERM=tmux-256color with no TMUX var. The
|
||||
// cursor-drift bug still applies, so fast-echo must stay off.
|
||||
expect(supportsFastEchoTerminal({ TERM: 'tmux' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
expect(supportsFastEchoTerminal({ TERM: 'tmux-256color' } as NodeJS.ProcessEnv)).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT disable fast-echo for screen-flavored TERM (GNU screen out of scope, no reported drift)', () => {
|
||||
// GNU screen sets TERM=screen/screen-256color and has no reported drift.
|
||||
// We must not widen the tmux guard to screen* and regress its perf.
|
||||
expect(supportsFastEchoTerminal({ TERM: 'screen' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
expect(supportsFastEchoTerminal({ TERM: 'screen-256color' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
// And an unrelated 256color TERM must stay enabled.
|
||||
expect(supportsFastEchoTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
})
|
||||
|
||||
it('disables fast-echo by default in Termux mode', () => {
|
||||
expect(
|
||||
supportsFastEchoTerminal({
|
||||
TERMUX_VERSION: '0.118.0',
|
||||
PREFIX: '/data/data/com.termux/files/usr'
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('allows explicit Termux fast-echo opt-in via env override', () => {
|
||||
expect(
|
||||
supportsFastEchoTerminal({
|
||||
HERMES_TUI_TERMUX_FAST_ECHO: '1',
|
||||
TERMUX_VERSION: '0.118.0'
|
||||
} as NodeJS.ProcessEnv)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps fast-echo enabled in VS Code and unknown non-Termux terminals', () => {
|
||||
expect(supportsFastEchoTerminal({ TERM_PROGRAM: 'vscode' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
expect(supportsFastEchoTerminal({ TERM: 'xterm-256color' } as NodeJS.ProcessEnv)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { killToLineEnd, killToLineStart } from '../components/textInput.js'
|
||||
|
||||
// Ctrl+U / Ctrl+K are readline motions scoped to the *current logical line*,
|
||||
// not the whole buffer. Claude Code documents Ctrl+U as "repeat to clear
|
||||
// across lines in multiline input", which only works if a press at a line
|
||||
// boundary consumes the newline and makes progress.
|
||||
|
||||
describe('killToLineStart', () => {
|
||||
it('clears the whole value in single-line input', () => {
|
||||
expect(killToLineStart('hello world', 11)).toEqual({ cursor: 0, value: '' })
|
||||
})
|
||||
|
||||
it('keeps text after the cursor', () => {
|
||||
expect(killToLineStart('hello world', 6)).toEqual({ cursor: 0, value: 'world' })
|
||||
})
|
||||
|
||||
it('only kills the current line, leaving earlier lines intact', () => {
|
||||
expect(killToLineStart('one\ntwo', 7)).toEqual({ cursor: 4, value: 'one\n' })
|
||||
})
|
||||
|
||||
it('consumes the newline when already at a line start, so repeats progress', () => {
|
||||
// Second press from the position the first press left us at.
|
||||
expect(killToLineStart('one\n', 4)).toEqual({ cursor: 3, value: 'one' })
|
||||
})
|
||||
|
||||
it('repeated presses walk up a multiline draft to empty', () => {
|
||||
let state = { cursor: 11, value: 'one\ntwo\nsix' }
|
||||
const seen: string[] = []
|
||||
|
||||
for (let i = 0; i < 6 && state.value !== ''; i++) {
|
||||
state = killToLineStart(state.value, state.cursor)
|
||||
seen.push(state.value)
|
||||
}
|
||||
|
||||
expect(seen).toEqual(['one\ntwo\n', 'one\ntwo', 'one\n', 'one', ''])
|
||||
expect(state).toEqual({ cursor: 0, value: '' })
|
||||
})
|
||||
|
||||
it('is a no-op at the very start of the buffer', () => {
|
||||
expect(killToLineStart('abc', 0)).toEqual({ cursor: 0, value: 'abc' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('killToLineEnd', () => {
|
||||
it('kills to end of a single-line value', () => {
|
||||
expect(killToLineEnd('hello world', 6)).toEqual({ cursor: 6, value: 'hello ' })
|
||||
})
|
||||
|
||||
it('stops at the newline, leaving later lines intact', () => {
|
||||
expect(killToLineEnd('one\ntwo', 0)).toEqual({ cursor: 0, value: '\ntwo' })
|
||||
})
|
||||
|
||||
it('consumes the newline when already at a line end, joining the next line', () => {
|
||||
expect(killToLineEnd('one\ntwo', 3)).toEqual({ cursor: 3, value: 'onetwo' })
|
||||
})
|
||||
|
||||
it('is a no-op at the very end of the buffer', () => {
|
||||
expect(killToLineEnd('abc', 3)).toEqual({ cursor: 3, value: 'abc' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isLineKillModifier } from '../components/textInput.js'
|
||||
|
||||
// Cmd+Backspace should kill to the line boundary, but the modifier it is
|
||||
// distinguished by matters a great deal. `isActionMod` is the wrong test:
|
||||
// - on macOS it accepts `key.meta`, and hermes-ink reports Option as
|
||||
// `meta` — so Option+Backspace (delete-word, the macOS standard) would
|
||||
// silently become "delete the whole line".
|
||||
// - on Linux/Windows it is `key.ctrl`, and Ctrl+Backspace is delete-word
|
||||
// there in readline, VS Code, browsers, and Windows Terminal.
|
||||
// Only the kitty CSI-u / modifyOtherKeys `super` bit means Cmd.
|
||||
|
||||
const key = (over: Partial<{ ctrl: boolean; meta: boolean; super: boolean }> = {}) => ({
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
super: false,
|
||||
...over
|
||||
})
|
||||
|
||||
describe('isLineKillModifier', () => {
|
||||
it('accepts the super bit (Cmd via kitty CSI-u / modifyOtherKeys)', () => {
|
||||
expect(isLineKillModifier(key({ super: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts super even when the terminal also sets a benign ctrl bit', () => {
|
||||
// VS Code/Cursor forward Cmd chords as CSI-u with super + ctrl set.
|
||||
expect(isLineKillModifier(key({ ctrl: true, super: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects meta so Option+Backspace stays delete-word on macOS', () => {
|
||||
expect(isLineKillModifier(key({ meta: true }))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects ctrl so Ctrl+Backspace stays delete-word on Linux/Windows', () => {
|
||||
expect(isLineKillModifier(key({ ctrl: true }))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an unmodified keypress', () => {
|
||||
expect(isLineKillModifier(key())).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a missing super field as absent rather than truthy', () => {
|
||||
expect(isLineKillModifier({ ctrl: false, meta: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { lineNav } from '../components/textInput.js'
|
||||
|
||||
describe('lineNav', () => {
|
||||
it('returns null for single-line input (up)', () => {
|
||||
expect(lineNav('hello world', 6, -1)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for single-line input (down)', () => {
|
||||
expect(lineNav('hello world', 6, 1)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when cursor already on first line of a multiline block', () => {
|
||||
expect(lineNav('one\ntwo\nthree', 2, -1)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when cursor on last line of a multiline block', () => {
|
||||
expect(lineNav('one\ntwo\nthree', 10, 1)).toBeNull()
|
||||
})
|
||||
|
||||
it('moves cursor up one line preserving column', () => {
|
||||
// "hello\nworld" — cursor at col 3 of line 1 ('l' in world) → col 3 of line 0 ('l' in hello)
|
||||
expect(lineNav('hello\nworld', 9, -1)).toBe(3)
|
||||
})
|
||||
|
||||
it('moves cursor down one line preserving column', () => {
|
||||
// cursor at col 2 of line 0 → col 2 of line 1
|
||||
expect(lineNav('hello\nworld', 2, 1)).toBe(8)
|
||||
})
|
||||
|
||||
it('clamps to end of shorter destination line on up', () => {
|
||||
// col 10 on long line → clamp to end of short line "abc"
|
||||
const s = 'abc\nlong long text'
|
||||
const from = 14
|
||||
|
||||
expect(lineNav(s, from, -1)).toBe(3)
|
||||
})
|
||||
|
||||
it('clamps to end of shorter destination line on down', () => {
|
||||
// col 10 on line 0 → clamp to end of "abc" on line 1
|
||||
const s = 'long long text\nabc'
|
||||
|
||||
expect(lineNav(s, 10, 1)).toBe(18)
|
||||
})
|
||||
|
||||
it('handles empty lines correctly', () => {
|
||||
// "a\n\nb" — cursor at line 2 (b) → up to empty line 1
|
||||
expect(lineNav('a\n\nb', 3, -1)).toBe(2)
|
||||
})
|
||||
|
||||
it('handles leading newline without crashing', () => {
|
||||
expect(lineNav('\nfoo', 2, -1)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { shouldPassThroughToGlobalHandler, shouldPreserveCtrlJNewline } from '../components/textInput.js'
|
||||
import { DEFAULT_VOICE_RECORD_KEY, parseVoiceRecordKey } from '../lib/platform.js'
|
||||
|
||||
const key = (overrides: Record<string, unknown> = {}) => ({ ctrl: false, meta: false, ...overrides }) as any
|
||||
|
||||
describe('shouldPreserveCtrlJNewline', () => {
|
||||
it('preserves Ctrl+J as newline in Ghostty even when tmux masks TERM/TERM_PROGRAM', () => {
|
||||
expect(
|
||||
shouldPreserveCtrlJNewline({
|
||||
GHOSTTY_RESOURCES_DIR: '/usr/share/ghostty',
|
||||
TERM: 'tmux-256color',
|
||||
TERM_PROGRAM: 'tmux'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps bare local POSIX LF-compatible prompts submitting on Ctrl+J', () => {
|
||||
expect(shouldPreserveCtrlJNewline({ TERM: 'xterm-256color' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldPassThroughToGlobalHandler', () => {
|
||||
it('passes through the configured voice shortcut while composer is focused', () => {
|
||||
expect(shouldPassThroughToGlobalHandler('o', key({ ctrl: true }), parseVoiceRecordKey('ctrl+o'))).toBe(true)
|
||||
expect(shouldPassThroughToGlobalHandler('r', key({ meta: true }), parseVoiceRecordKey('alt+r'))).toBe(true)
|
||||
expect(shouldPassThroughToGlobalHandler(' ', key({ ctrl: true }), parseVoiceRecordKey('ctrl+space'))).toBe(true)
|
||||
expect(
|
||||
shouldPassThroughToGlobalHandler('', key({ ctrl: true, return: true }), parseVoiceRecordKey('ctrl+enter'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the legacy default pass-through when no custom key is provided', () => {
|
||||
expect(shouldPassThroughToGlobalHandler('b', key({ ctrl: true }), DEFAULT_VOICE_RECORD_KEY)).toBe(true)
|
||||
expect(shouldPassThroughToGlobalHandler('b', key({ ctrl: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not swallow ordinary typing keys', () => {
|
||||
expect(shouldPassThroughToGlobalHandler('h', key(), parseVoiceRecordKey('ctrl+o'))).toBe(false)
|
||||
expect(shouldPassThroughToGlobalHandler('o', key(), parseVoiceRecordKey('ctrl+o'))).toBe(false)
|
||||
})
|
||||
|
||||
it('always passes through non-voice global control keys', () => {
|
||||
expect(shouldPassThroughToGlobalHandler('c', key({ ctrl: true }))).toBe(true)
|
||||
expect(shouldPassThroughToGlobalHandler('x', key({ ctrl: true }))).toBe(true)
|
||||
expect(shouldPassThroughToGlobalHandler('o', key({ ctrl: true }))).toBe(true)
|
||||
expect(shouldPassThroughToGlobalHandler('', key({ escape: true }))).toBe(true)
|
||||
expect(shouldPassThroughToGlobalHandler('', key({ tab: true }))).toBe(true)
|
||||
expect(shouldPassThroughToGlobalHandler('', key({ pageUp: true }))).toBe(true)
|
||||
expect(shouldPassThroughToGlobalHandler('', key({ pageDown: true }))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
async function importTextInput(platform: NodeJS.Platform) {
|
||||
vi.resetModules()
|
||||
Object.defineProperty(process, 'platform', { value: platform })
|
||||
|
||||
return import('../components/textInput.js')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform })
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
const key = (overrides: Record<string, unknown> = {}) =>
|
||||
({ ctrl: false, meta: false, return: true, shift: false, super: false, ...overrides }) as any
|
||||
|
||||
describe('shouldInsertNewlineOnReturn', () => {
|
||||
it('keeps plain Enter (CR) as submit on macOS', async () => {
|
||||
const { shouldInsertNewlineOnReturn } = await importTextInput('darwin')
|
||||
|
||||
expect(shouldInsertNewlineOnReturn(key(), '\r')).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts bare LF as a macOS multiline fallback', async () => {
|
||||
const { shouldInsertNewlineOnReturn } = await importTextInput('darwin')
|
||||
|
||||
expect(shouldInsertNewlineOnReturn(key(), '\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('inserts a newline for explicit modified Enter chords on macOS', async () => {
|
||||
const { shouldInsertNewlineOnReturn } = await importTextInput('darwin')
|
||||
|
||||
expect(shouldInsertNewlineOnReturn(key({ ctrl: true }), '\r')).toBe(true)
|
||||
expect(shouldInsertNewlineOnReturn(key({ shift: true }), '\r')).toBe(true)
|
||||
expect(shouldInsertNewlineOnReturn(key({ meta: true }), '\r')).toBe(true)
|
||||
expect(shouldInsertNewlineOnReturn(key({ super: true }), '\r')).toBe(true)
|
||||
})
|
||||
|
||||
it('inserts a newline for Shift/Ctrl Enter on non-macOS', async () => {
|
||||
const { shouldInsertNewlineOnReturn } = await importTextInput('linux')
|
||||
|
||||
expect(shouldInsertNewlineOnReturn(key({ shift: true }), '\r')).toBe(true)
|
||||
expect(shouldInsertNewlineOnReturn(key({ ctrl: true }), '\r')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps plain Enter as submit on a plain non-macOS terminal', async () => {
|
||||
const prev = { ...process.env }
|
||||
|
||||
for (const k of [
|
||||
'SSH_CONNECTION',
|
||||
'SSH_CLIENT',
|
||||
'SSH_TTY',
|
||||
'WT_SESSION',
|
||||
'GHOSTTY_RESOURCES_DIR',
|
||||
'GHOSTTY_BIN_DIR',
|
||||
'WSL_DISTRO_NAME'
|
||||
]) {
|
||||
delete process.env[k]
|
||||
}
|
||||
|
||||
process.env.TERM = 'xterm-256color'
|
||||
process.env.TERM_PROGRAM = ''
|
||||
|
||||
try {
|
||||
const { shouldInsertNewlineOnReturn } = await importTextInput('linux')
|
||||
|
||||
expect(shouldInsertNewlineOnReturn(key(), '\n')).toBe(false)
|
||||
expect(shouldInsertNewlineOnReturn(key(), '\r')).toBe(false)
|
||||
} finally {
|
||||
process.env = prev
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a bare LF as a multiline fallback over SSH on non-macOS', async () => {
|
||||
const prev = { ...process.env }
|
||||
process.env.SSH_CONNECTION = '10.0.0.1 22 10.0.0.2 22'
|
||||
|
||||
try {
|
||||
const { shouldInsertNewlineOnReturn } = await importTextInput('linux')
|
||||
|
||||
expect(shouldInsertNewlineOnReturn(key(), '\n')).toBe(true)
|
||||
} finally {
|
||||
process.env = prev
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { valueForReturnSubmit } from '../components/textInput.js'
|
||||
|
||||
describe('valueForReturnSubmit', () => {
|
||||
it('includes printable input that arrives in the same keypress as return', () => {
|
||||
expect(valueForReturnSubmit('为什么打字上屏,', 8, '会丢失内容')).toEqual({
|
||||
cursor: 13,
|
||||
value: '为什么打字上屏,会丢失内容'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps IME commit text when it arrives in the same burst as return', () => {
|
||||
expect(valueForReturnSubmit('为什么打字上屏,', 8, '会丢失内容\r')).toEqual({
|
||||
cursor: 13,
|
||||
value: '为什么打字上屏,会丢失内容'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the draft unchanged when return carries no printable input', () => {
|
||||
expect(valueForReturnSubmit('hello', 5, '')).toEqual({ cursor: 5, value: 'hello' })
|
||||
expect(valueForReturnSubmit('hello', 5, '\r')).toEqual({ cursor: 5, value: 'hello' })
|
||||
expect(valueForReturnSubmit('hello', 5, '\n')).toEqual({ cursor: 5, value: 'hello' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { decideRightClickAction } from '../components/textInput.js'
|
||||
|
||||
describe('decideRightClickAction', () => {
|
||||
it('returns paste when there is no selection', () => {
|
||||
expect(decideRightClickAction('hello world', null)).toEqual({ action: 'paste' })
|
||||
})
|
||||
|
||||
it('returns paste for a collapsed (empty) range', () => {
|
||||
expect(decideRightClickAction('hello world', { end: 5, start: 5 })).toEqual({
|
||||
action: 'paste'
|
||||
})
|
||||
})
|
||||
|
||||
it('copies the slice when range covers non-empty text', () => {
|
||||
expect(decideRightClickAction('hello world', { end: 5, start: 0 })).toEqual({
|
||||
action: 'copy',
|
||||
text: 'hello'
|
||||
})
|
||||
})
|
||||
|
||||
it('copies a middle slice', () => {
|
||||
expect(decideRightClickAction('hello world', { end: 11, start: 6 })).toEqual({
|
||||
action: 'copy',
|
||||
text: 'world'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to paste when slice is empty (out-of-range indices)', () => {
|
||||
expect(decideRightClickAction('', { end: 5, start: 0 })).toEqual({ action: 'paste' })
|
||||
})
|
||||
|
||||
it('handles unicode (emoji, CJK) in the slice', () => {
|
||||
const value = 'hi 你好 🎉'
|
||||
expect(decideRightClickAction(value, { end: 5, start: 3 })).toEqual({
|
||||
action: 'copy',
|
||||
text: '你好'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves leading/trailing whitespace in the copied slice', () => {
|
||||
expect(decideRightClickAction(' spaced ', { end: 10, start: 0 })).toEqual({
|
||||
action: 'copy',
|
||||
text: ' spaced '
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
import { renderSync } from '@hermes/ink'
|
||||
import React, { useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { TextInput } from '../components/textInput.js'
|
||||
|
||||
class FakeInput extends EventEmitter {
|
||||
chunks: string[] = []
|
||||
isRaw = false
|
||||
isTTY = true
|
||||
readableLength = 0
|
||||
|
||||
read() {
|
||||
const next = this.chunks.shift() ?? null
|
||||
this.readableLength = this.chunks.length
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
ref = vi.fn()
|
||||
|
||||
send(...chunks: string[]) {
|
||||
this.chunks.push(...chunks)
|
||||
this.readableLength = this.chunks.length
|
||||
this.emit('readable')
|
||||
}
|
||||
|
||||
setEncoding = vi.fn()
|
||||
|
||||
setRawMode = vi.fn((enabled: boolean) => {
|
||||
this.isRaw = enabled
|
||||
})
|
||||
|
||||
unref = vi.fn()
|
||||
}
|
||||
|
||||
const settle = (ms = 0) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
function makeStreams() {
|
||||
const stdin = new FakeInput()
|
||||
const stdout = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
|
||||
Object.assign(stdout, { columns: 80, isTTY: false, rows: 24 })
|
||||
Object.assign(stderr, { columns: 80, isTTY: false, rows: 24 })
|
||||
|
||||
return { stderr, stdin, stdout }
|
||||
}
|
||||
|
||||
describe('TextInput submit clearing', () => {
|
||||
it('accepts the parent clear after a Korean IME commit immediately followed by Enter', async () => {
|
||||
const streams = makeStreams()
|
||||
const changes: string[] = []
|
||||
const submits: string[] = []
|
||||
|
||||
function Harness() {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
columns={80}
|
||||
onChange={next => {
|
||||
changes.push(next)
|
||||
setValue(next)
|
||||
}}
|
||||
onSubmit={text => {
|
||||
submits.push(text)
|
||||
setValue('')
|
||||
}}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const instance = renderSync(React.createElement(Harness), {
|
||||
patchConsole: false,
|
||||
stderr: streams.stderr as NodeJS.WriteStream,
|
||||
stdin: streams.stdin as unknown as NodeJS.ReadStream,
|
||||
stdout: streams.stdout as NodeJS.WriteStream
|
||||
})
|
||||
|
||||
await settle()
|
||||
|
||||
const prefix = '한글을 사용하면 마지막 문자가 남아있는 버그가 있어 리포트해'
|
||||
const finalSyllable = '줘'
|
||||
const full = prefix + finalSyllable
|
||||
|
||||
streams.stdin.send(prefix)
|
||||
await settle(25)
|
||||
|
||||
streams.stdin.send(finalSyllable, '\r')
|
||||
await settle(25)
|
||||
|
||||
streams.stdin.send('x')
|
||||
await settle(25)
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
|
||||
expect(submits).toEqual([full])
|
||||
expect(changes.at(-1)).toBe('x')
|
||||
expect(changes).not.toContain(`${full}x`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { InputEvent } from '../../packages/hermes-ink/src/ink/events/input-event.js'
|
||||
import { INITIAL_STATE, parseMultipleKeypresses } from '../../packages/hermes-ink/src/ink/parse-keypress.js'
|
||||
import { deleteWordForward } from '../components/textInput.js'
|
||||
|
||||
function parseOne(sequence: string) {
|
||||
const [keys] = parseMultipleKeypresses(INITIAL_STATE, sequence)
|
||||
expect(keys).toHaveLength(1)
|
||||
|
||||
return keys[0]!
|
||||
}
|
||||
|
||||
// The web dashboard maps Ctrl+Delete to ESC d (see
|
||||
// web/src/lib/pty-keyboard-shortcuts.ts). hermes-ink decodes that bare
|
||||
// meta-letter form via META_KEY_CODE_RE. If this contract ever changes the
|
||||
// `wordMod && inp === 'd'` binding in textInput.tsx stops firing and
|
||||
// Ctrl+Delete regresses to typing a literal "d".
|
||||
describe('Ctrl+Delete → ESC d decode contract', () => {
|
||||
it('decodes ESC d as meta+"d" so the composer binding is reached', () => {
|
||||
const event = new InputEvent(parseOne('\x1bd'))
|
||||
|
||||
expect(event.key.meta).toBe(true)
|
||||
expect(event.key.ctrl).toBe(false)
|
||||
expect(event.input).toBe('d')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteWordForward', () => {
|
||||
it('deletes the word to the right of the cursor', () => {
|
||||
// cursor before "hello" → removes "hello" and the trailing space.
|
||||
expect(deleteWordForward('foo hello world', 4)).toEqual({ cursor: 4, value: 'foo world' })
|
||||
})
|
||||
|
||||
it('deletes from mid-word to the next word boundary', () => {
|
||||
// cursor inside "hello" (after "he") → removes "llo" + trailing space.
|
||||
expect(deleteWordForward('foo hello world', 6)).toEqual({ cursor: 6, value: 'foo heworld' })
|
||||
})
|
||||
|
||||
it('keeps the cursor fixed while removing text', () => {
|
||||
const result = deleteWordForward('alpha beta', 0)
|
||||
|
||||
expect(result.cursor).toBe(0)
|
||||
expect(result.value).toBe('beta')
|
||||
})
|
||||
|
||||
it('is a no-op when the cursor is already at the end', () => {
|
||||
expect(deleteWordForward('foo bar', 7)).toEqual({ cursor: 7, value: 'foo bar' })
|
||||
})
|
||||
|
||||
it('handles an empty string', () => {
|
||||
expect(deleteWordForward('', 0)).toEqual({ cursor: 0, value: '' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import { wrapAnsi } from '@hermes/ink'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { offsetFromPosition } from '../components/textInput.js'
|
||||
import { composerPromptWidth, cursorLayout, inputVisualHeight, stableComposerColumns } from '../lib/inputMetrics.js'
|
||||
|
||||
// Helper: compute the "end of text" position that wrap-ansi would render
|
||||
// the input to. This is what Ink's <Text wrap="wrap"> uses, so cursorLayout
|
||||
// MUST agree. Disagreement is the cursor-drift bug.
|
||||
function wrapAnsiEndPosition(text: string, cols: number): { line: number; column: number } {
|
||||
const wrapped = wrapAnsi(text, cols, { hard: true, trim: false })
|
||||
const lines = wrapped.split('\n')
|
||||
const last = lines[lines.length - 1] ?? ''
|
||||
|
||||
return { line: lines.length - 1, column: last.length }
|
||||
}
|
||||
|
||||
describe('cursorLayout — word-wrap parity with wrap-ansi', () => {
|
||||
it('places cursor mid-line at its column', () => {
|
||||
expect(cursorLayout('hello world', 6, 40)).toEqual({ column: 6, line: 0 })
|
||||
})
|
||||
|
||||
it('places cursor at end of a non-full line', () => {
|
||||
expect(cursorLayout('hi', 2, 10)).toEqual({ column: 2, line: 0 })
|
||||
})
|
||||
|
||||
it('does not push exact-fill text onto a phantom next line', () => {
|
||||
// Regression: the previous hand-rolled wrap algorithm forced the cursor
|
||||
// onto (line+1, 0) when the text exactly filled the row. wrap-ansi keeps
|
||||
// it on the same row (no soft-wrap), so the cursor must too — otherwise
|
||||
// useDeclaredCursor parks the hardware cursor below the last char and
|
||||
// the user sees several blank cells between text and cursor block
|
||||
// (#cursor-drift-multiline).
|
||||
expect(cursorLayout('abcdefgh', 8, 8)).toEqual({ column: 8, line: 0 })
|
||||
expect(cursorLayout('abcdefgh', 8, 8)).toEqual(wrapAnsiEndPosition('abcdefgh', 8))
|
||||
})
|
||||
|
||||
it('keeps short words on the current line when they fit (no phantom wrap)', () => {
|
||||
// wrap-ansi: "hello wo" at cols=8 stays as one line "hello wo".
|
||||
// The old cursorLayout incorrectly pushed to (1,0) because column=8 hit
|
||||
// the column>=width check, but that disagreed with what Ink actually
|
||||
// rendered.
|
||||
expect(cursorLayout('hello wo', 8, 8)).toEqual({ column: 8, line: 0 })
|
||||
expect(cursorLayout('hello wo', 8, 8)).toEqual(wrapAnsiEndPosition('hello wo', 8))
|
||||
})
|
||||
|
||||
it('moves words across wrap boundaries instead of splitting them', () => {
|
||||
// "hello wor" at cols=8: wrap-ansi breaks at the space, "hello \nwor".
|
||||
expect(cursorLayout('hello wor', 9, 8)).toEqual({ column: 3, line: 1 })
|
||||
expect(cursorLayout('hello worl', 10, 8)).toEqual({ column: 4, line: 1 })
|
||||
expect(cursorLayout('hello world', 11, 8)).toEqual({ column: 5, line: 1 })
|
||||
|
||||
// Each must match what wrap-ansi would actually render.
|
||||
expect(cursorLayout('hello wor', 9, 8)).toEqual(wrapAnsiEndPosition('hello wor', 8))
|
||||
expect(cursorLayout('hello worl', 10, 8)).toEqual(wrapAnsiEndPosition('hello worl', 8))
|
||||
expect(cursorLayout('hello world', 11, 8)).toEqual(wrapAnsiEndPosition('hello world', 8))
|
||||
})
|
||||
|
||||
it('wraps the next word instead of splitting it at the right edge', () => {
|
||||
const text = 'hello world baby chickens are so cool its really rainy outside but wish'
|
||||
|
||||
expect(cursorLayout(text, text.length, 70)).toEqual({ column: 4, line: 1 })
|
||||
expect(inputVisualHeight(text, 70)).toBe(2)
|
||||
})
|
||||
|
||||
it('honours explicit newlines', () => {
|
||||
expect(cursorLayout('one\ntwo', 5, 40)).toEqual({ column: 1, line: 1 })
|
||||
expect(cursorLayout('one\ntwo', 4, 40)).toEqual({ column: 0, line: 1 })
|
||||
})
|
||||
|
||||
it('does not wrap when cursor is before the right edge', () => {
|
||||
expect(cursorLayout('abcdefg', 7, 8)).toEqual({ column: 7, line: 0 })
|
||||
})
|
||||
|
||||
it('matches wrap-ansi end-position for typing-style incremental input', () => {
|
||||
// Pins the actual fix: type a long message char-by-char at a narrow
|
||||
// width and assert the cursor follows wrap-ansi every step of the way.
|
||||
// Before the fix, ~5 boundary positions per pass disagreed and Ink
|
||||
// parked the cursor several cells past the last rendered character.
|
||||
const MSG = 'on a new bb branch investigate and fix the cursor drift bug here'
|
||||
|
||||
for (const cols of [10, 14, 20, 30, 50, 80]) {
|
||||
let acc = ''
|
||||
|
||||
for (const ch of MSG) {
|
||||
acc += ch
|
||||
expect(cursorLayout(acc, acc.length, cols)).toEqual(wrapAnsiEndPosition(acc, cols))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('input metrics helpers', () => {
|
||||
it('computes visual height matching wrap-ansi line count', () => {
|
||||
// Exact-fill text stays on one line in wrap-ansi (no phantom wrap), so
|
||||
// visual height is 1. The previous implementation reported 2 here.
|
||||
expect(inputVisualHeight('abcdefgh', 8)).toBe(1)
|
||||
expect(inputVisualHeight('one\ntwo', 40)).toBe(2)
|
||||
// Multi-line wrap case sanity
|
||||
expect(inputVisualHeight('hello world', 8)).toBe(2)
|
||||
})
|
||||
|
||||
it('counts the prompt gap as its own cell', () => {
|
||||
expect(composerPromptWidth('>')).toBe(2)
|
||||
expect(composerPromptWidth('❯')).toBe(2)
|
||||
expect(composerPromptWidth('Ψ >')).toBe(4)
|
||||
})
|
||||
|
||||
it('reserves gutters on wide panes without starving narrow composer width', () => {
|
||||
expect(stableComposerColumns(100, 3)).toBe(93)
|
||||
expect(stableComposerColumns(100, 5)).toBe(91)
|
||||
expect(stableComposerColumns(10, 3)).toBe(5)
|
||||
expect(stableComposerColumns(6, 3)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('offsetFromPosition — word-wrap inverse of cursorLayout', () => {
|
||||
it('returns 0 for empty input', () => {
|
||||
expect(offsetFromPosition('', 0, 0, 10)).toBe(0)
|
||||
})
|
||||
|
||||
it('maps clicks within a single line', () => {
|
||||
expect(offsetFromPosition('hello', 0, 3, 40)).toBe(3)
|
||||
})
|
||||
|
||||
it('maps clicks past end to value length', () => {
|
||||
expect(offsetFromPosition('hi', 0, 10, 40)).toBe(2)
|
||||
})
|
||||
|
||||
it('maps clicks on a wrapped second row at cols boundary', () => {
|
||||
// Long words still hard-wrap when there is no word boundary.
|
||||
expect(offsetFromPosition('abcdefghij', 1, 0, 8)).toBe(8)
|
||||
})
|
||||
|
||||
it('maps clicks on a word-wrapped second row', () => {
|
||||
// "hello world" at cols=8 wraps to "hello \nworld".
|
||||
expect(offsetFromPosition('hello world', 1, 0, 8)).toBe(6)
|
||||
expect(offsetFromPosition('hello world', 1, 3, 8)).toBe(9)
|
||||
})
|
||||
|
||||
it('maps clicks on the moved final word', () => {
|
||||
const text = 'hello world baby chickens are so cool its really rainy outside but wish'
|
||||
|
||||
expect(offsetFromPosition(text, 1, 0, 70)).toBe(text.indexOf('wish'))
|
||||
expect(offsetFromPosition(text, 1, 3, 70)).toBe(text.indexOf('wish') + 3)
|
||||
})
|
||||
|
||||
it('maps clicks past a \\n into the target line', () => {
|
||||
expect(offsetFromPosition('one\ntwo', 1, 2, 40)).toBe(6)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,683 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// `theme.js` reads `process.env` at module-load to compute DEFAULT_THEME,
|
||||
// and `fromSkin` closes over DEFAULT_THEME. A developer shell with
|
||||
// HERMES_TUI_THEME=light (or HERMES_TUI_BACKGROUND set to something
|
||||
// bright) would flip the base and turn these assertions into a local-
|
||||
// only failure. We sterilize the relevant env vars + dynamically
|
||||
// import the module fresh so EVERY symbol that closes over the env
|
||||
// (DEFAULT_THEME, DARK_THEME, LIGHT_THEME, fromSkin) is loaded against
|
||||
// a known-empty environment.
|
||||
//
|
||||
// `detectLightMode` takes env as an explicit arg, so it's safe to import
|
||||
// statically — but we stay consistent and dynamic-import it too.
|
||||
const RELEVANT_ENV = [
|
||||
'HERMES_TUI_LIGHT',
|
||||
'HERMES_TUI_THEME',
|
||||
'HERMES_TUI_BACKGROUND',
|
||||
'COLORFGBG',
|
||||
'COLORTERM',
|
||||
'TERM_PROGRAM'
|
||||
] as const
|
||||
|
||||
async function importThemeWithEnv(env: Partial<Record<(typeof RELEVANT_ENV)[number], string>> = {}) {
|
||||
for (const key of RELEVANT_ENV) {
|
||||
vi.stubEnv(key, env[key] ?? '')
|
||||
}
|
||||
|
||||
vi.resetModules()
|
||||
|
||||
return import('../theme.js')
|
||||
}
|
||||
|
||||
async function importThemeWithCleanEnv() {
|
||||
return importThemeWithEnv()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('DEFAULT_THEME', () => {
|
||||
it('has brand defaults', async () => {
|
||||
const { DEFAULT_THEME } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(DEFAULT_THEME.brand.name).toBe('Hermes Agent')
|
||||
expect(DEFAULT_THEME.brand.prompt).toBe('❯')
|
||||
expect(DEFAULT_THEME.brand.tool).toBe('┊')
|
||||
})
|
||||
|
||||
it('has color palette', async () => {
|
||||
const { DEFAULT_THEME } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(DEFAULT_THEME.color.primary).toBe('#FFD700')
|
||||
expect(DEFAULT_THEME.color.error).toBe('#ef5350')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LIGHT_THEME', () => {
|
||||
it('avoids bright-yellow accents unreadable on white backgrounds (#11300)', async () => {
|
||||
const { LIGHT_THEME } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(LIGHT_THEME.color.primary).not.toBe('#FFD700')
|
||||
expect(LIGHT_THEME.color.accent).not.toBe('#FFBF00')
|
||||
expect(LIGHT_THEME.color.muted).not.toBe('#B8860B')
|
||||
expect(LIGHT_THEME.color.statusWarn).not.toBe('#FFD700')
|
||||
})
|
||||
|
||||
it('keeps the same shape as DARK_THEME', async () => {
|
||||
const { DARK_THEME, LIGHT_THEME } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(Object.keys(LIGHT_THEME.color).sort()).toEqual(Object.keys(DARK_THEME.color).sort())
|
||||
expect(LIGHT_THEME.brand).toEqual(DARK_THEME.brand)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEFAULT_THEME aliasing', () => {
|
||||
it('defaults to DARK_THEME when nothing signals light', async () => {
|
||||
const { DEFAULT_THEME, DARK_THEME: DARK } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(DEFAULT_THEME).toBe(DARK)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectLightMode', () => {
|
||||
it('returns false on empty env', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({})).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults Apple Terminal to light when no stronger signal is present', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(true)
|
||||
})
|
||||
|
||||
it('honors HERMES_TUI_LIGHT on/off', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({ HERMES_TUI_LIGHT: '1' })).toBe(true)
|
||||
expect(detectLightMode({ HERMES_TUI_LIGHT: 'true' })).toBe(true)
|
||||
expect(detectLightMode({ HERMES_TUI_LIGHT: 'on' })).toBe(true)
|
||||
expect(detectLightMode({ HERMES_TUI_LIGHT: '0' })).toBe(false)
|
||||
expect(detectLightMode({ HERMES_TUI_LIGHT: 'off' })).toBe(false)
|
||||
})
|
||||
|
||||
it('sniffs COLORFGBG bg slots 7 and 15 as light (#11300)', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({ COLORFGBG: '0;15' })).toBe(true)
|
||||
expect(detectLightMode({ COLORFGBG: '0;default;15' })).toBe(true)
|
||||
expect(detectLightMode({ COLORFGBG: '0;7' })).toBe(true)
|
||||
expect(detectLightMode({ COLORFGBG: '15;0' })).toBe(false)
|
||||
expect(detectLightMode({ COLORFGBG: '7;default;0' })).toBe(false)
|
||||
})
|
||||
|
||||
it('falls through on malformed COLORFGBG with empty/non-numeric trailing field', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
// `Number('')` is 0, so `'15;'` would have been read as bg=0
|
||||
// (authoritative dark) and incorrectly blocked TERM_PROGRAM.
|
||||
// The strict /^\d+$/ guard makes these fall through instead.
|
||||
const allowList = new Set(['Apple_Terminal'])
|
||||
|
||||
expect(detectLightMode({ COLORFGBG: '15;', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true)
|
||||
expect(detectLightMode({ COLORFGBG: 'default;default', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true)
|
||||
// Without an allow-list match, fall-through still defaults to dark.
|
||||
expect(detectLightMode({ COLORFGBG: '15;' })).toBe(false)
|
||||
})
|
||||
|
||||
it('lets HERMES_TUI_LIGHT=0 override a light COLORFGBG', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({ COLORFGBG: '0;15', HERMES_TUI_LIGHT: '0' })).toBe(false)
|
||||
})
|
||||
|
||||
it('honors HERMES_TUI_THEME=light/dark as a symmetric explicit override', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({ HERMES_TUI_THEME: 'light' })).toBe(true)
|
||||
expect(detectLightMode({ HERMES_TUI_THEME: 'dark' })).toBe(false)
|
||||
expect(detectLightMode({ COLORFGBG: '0;15', HERMES_TUI_THEME: 'dark' })).toBe(false)
|
||||
expect(detectLightMode({ COLORFGBG: '15;0', HERMES_TUI_THEME: 'light' })).toBe(true)
|
||||
})
|
||||
|
||||
it('uses HERMES_TUI_BACKGROUND luminance when COLORFGBG is missing', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#ffffff' })).toBe(true)
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#000000' })).toBe(false)
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#1e1e1e' })).toBe(false)
|
||||
// Three-char hex normalises like CSS.
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fff' })).toBe(true)
|
||||
// Garbage falls through to the default-dark path.
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: 'not-a-colour' })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects partially-invalid hex instead of silently truncating', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
// `parseInt('fffgff'.slice(2,4), 16)` would return 15 — the strict
|
||||
// regex must reject these inputs so they fall through to default-
|
||||
// dark instead of producing a false-positive light reading.
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffgff' })).toBe(false)
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: 'ffggff' })).toBe(false)
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#xyz' })).toBe(false)
|
||||
// Wrong length also rejected (no implicit padding/truncation).
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffff' })).toBe(false)
|
||||
expect(detectLightMode({ HERMES_TUI_BACKGROUND: '#fffffff' })).toBe(false)
|
||||
})
|
||||
|
||||
it('treats COLORFGBG as authoritative when present so it dominates the TERM_PROGRAM allow-list', async () => {
|
||||
const { detectLightMode } = await importThemeWithCleanEnv()
|
||||
// Injecting the allow-list keeps this precedence rule explicit even if
|
||||
// production defaults change.
|
||||
const allowList = new Set(['Apple_Terminal'])
|
||||
|
||||
// Sanity: the allow-list alone WOULD turn this terminal light.
|
||||
expect(detectLightMode({ TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(true)
|
||||
|
||||
// Dark COLORFGBG must beat the allow-list.
|
||||
expect(detectLightMode({ COLORFGBG: '15;0', TERM_PROGRAM: 'Apple_Terminal' }, allowList)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fromSkin', () => {
|
||||
// `fromSkin` closes over DEFAULT_THEME (which is env-derived), so we
|
||||
// must dynamic-import it after sterilizing env — otherwise an ambient
|
||||
// HERMES_TUI_THEME=light would flip the base palette and make these
|
||||
// assertions order-dependent on the developer's shell.
|
||||
|
||||
it('overrides banner colors', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.primary).toBe('#FF0000')
|
||||
})
|
||||
|
||||
it('preserves unset colors', async () => {
|
||||
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(fromSkin({ banner_title: '#FF0000' }, {}).color.accent).toBe(DEFAULT_THEME.color.accent)
|
||||
})
|
||||
|
||||
it('derives completion current background from resolved completion background (polarity-compatible)', async () => {
|
||||
// Light terminal + light-authored menu fill: the skin's fill is honored
|
||||
// and the current-row derivation mixes off it.
|
||||
const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
|
||||
|
||||
const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {})
|
||||
|
||||
expect(theme.color.completionBg).toBe('#ffffff')
|
||||
// Active row = authored surface mixed toward the accent (ladder knob).
|
||||
expect(theme.color.completionCurrentBg).toBe('#c7c7c7')
|
||||
})
|
||||
|
||||
it('rejects wrong-polarity fills even when skin-authored (terminal owns the canvas)', async () => {
|
||||
// Dark terminal + white menu fill: unlike the desktop app, the TUI cannot
|
||||
// paint its own canvas, so cross-polarity fills fall back to the derived
|
||||
// ladder values, which are mixed from the real background and therefore
|
||||
// polarity-correct by construction.
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
const theme = fromSkin({ banner_accent: '#000000', completion_menu_bg: '#ffffff' }, {})
|
||||
|
||||
expect(luminance(theme.color.completionBg)).toBeLessThanOrEqual(0.35)
|
||||
expect(luminance(theme.color.completionCurrentBg)).toBeLessThanOrEqual(0.35)
|
||||
})
|
||||
|
||||
it('uses active completion color as the selection highlight fallback', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
const theme = fromSkin({ completion_menu_current_bg: '#123456' }, {})
|
||||
|
||||
expect(theme.color.selectionBg).toBe('#123456')
|
||||
})
|
||||
|
||||
it('maps completion meta background colors from skins', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
const theme = fromSkin(
|
||||
{
|
||||
completion_menu_meta_bg: '#111111',
|
||||
completion_menu_meta_current_bg: '#222222'
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
expect(theme.color.completionMetaBg).toBe('#111111')
|
||||
expect(theme.color.completionMetaCurrentBg).toBe('#222222')
|
||||
})
|
||||
|
||||
it('lets selection_bg override completion highlight colors', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
const theme = fromSkin({ completion_menu_current_bg: '#123456', selection_bg: '#654321' }, {})
|
||||
|
||||
expect(theme.color.selectionBg).toBe('#654321')
|
||||
})
|
||||
|
||||
it('overrides branding', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
const { brand } = fromSkin({}, { agent_name: 'TestBot', prompt_symbol: '$' })
|
||||
|
||||
expect(brand.name).toBe('TestBot')
|
||||
expect(brand.prompt).toBe('$')
|
||||
})
|
||||
|
||||
it('normalizes skin prompt symbols to trimmed single-line text', async () => {
|
||||
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(fromSkin({}, { prompt_symbol: ' ⚔ ❯ \n' }).brand.prompt).toBe('⚔ ❯')
|
||||
expect(fromSkin({}, { prompt_symbol: ' Ψ > \n' }).brand.prompt).toBe('Ψ >')
|
||||
expect(fromSkin({}, { prompt_symbol: '\n\t' }).brand.prompt).toBe(DEFAULT_THEME.brand.prompt)
|
||||
})
|
||||
|
||||
it('defaults for empty skin', async () => {
|
||||
const { DEFAULT_THEME, fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(fromSkin({}, {}).color).toEqual(DEFAULT_THEME.color)
|
||||
expect(fromSkin({}, {}).brand.icon).toBe(DEFAULT_THEME.brand.icon)
|
||||
})
|
||||
|
||||
it('normalizes non-banner foregrounds on light Apple Terminal', async () => {
|
||||
const { fromSkin } = await importThemeWithEnv({ TERM_PROGRAM: 'Apple_Terminal' })
|
||||
|
||||
const theme = fromSkin(
|
||||
{
|
||||
banner_accent: '#FFBF00',
|
||||
banner_border: '#CD7F32',
|
||||
banner_dim: '#B8860B',
|
||||
banner_text: '#FFF8DC',
|
||||
banner_title: '#FFD700',
|
||||
prompt: '#FFF8DC'
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
expect(theme.color.primary).toBe('#FFD700')
|
||||
expect(theme.color.accent).toBe('#FFBF00')
|
||||
expect(theme.color.border).toBe('#CD7F32')
|
||||
expect(theme.color.muted).toBe('ansi256(245)')
|
||||
expect(theme.color.text).toBe('ansi256(136)')
|
||||
expect(theme.color.prompt).toBe('ansi256(136)')
|
||||
})
|
||||
|
||||
// ── A skin that authors a background OWNS its polarity ──────────────
|
||||
// The TUI paints the terminal with the skin's background (OSC-11), so
|
||||
// every adaptation pass must run against the skin's canvas, not the host
|
||||
// profile the skin just covered. The real-world failure: a pure-black
|
||||
// skin on light-mode Apple Terminal got its text ansi256-bucketed for a
|
||||
// light background that no longer exists — invisible on the painted black.
|
||||
|
||||
it('a dark-background skin on light Apple Terminal keeps its truecolor text (no light-mode bucketing)', async () => {
|
||||
const { fromSkin } = await importThemeWithEnv({ TERM_PROGRAM: 'Apple_Terminal' })
|
||||
|
||||
const theme = fromSkin({ background: '#000000', ui_accent: '#ff9e18', ui_text: '#ffa726' }, {})
|
||||
|
||||
expect(theme.color.text).toBe('#ffa726')
|
||||
expect(theme.color.prompt).not.toMatch(/^ansi256/)
|
||||
})
|
||||
|
||||
it('a skin background outranks the cached host background for adaptation and tone derivation', async () => {
|
||||
const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
|
||||
|
||||
const theme = fromSkin({ background: '#000000', ui_text: '#ffa726' }, {})
|
||||
|
||||
// Text is not contrast-lifted toward a white host it painted over…
|
||||
expect(theme.color.text).toBe('#ffa726')
|
||||
// …and derived fills mix against the skin's black, not the host's white.
|
||||
expect(luminance(theme.color.completionBg)).toBeLessThanOrEqual(0.35)
|
||||
expect(luminance(theme.color.statusBg)).toBeLessThanOrEqual(0.35)
|
||||
})
|
||||
|
||||
it('skinIsLight: the authored background decides; host detection only when absent', async () => {
|
||||
const { skinIsLight } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
|
||||
|
||||
expect(skinIsLight({ background: '#000000' })).toBe(false)
|
||||
expect(skinIsLight({ background: '#f5f5f5' })).toBe(true)
|
||||
expect(skinIsLight({})).toBe(true) // no canvas of its own → host polarity
|
||||
})
|
||||
|
||||
it('keeps truecolor light Apple Terminal in truecolor (adapting, not ansi256-bucketing)', async () => {
|
||||
const { contrastRatio, fromSkin } = await importThemeWithEnv({
|
||||
COLORTERM: 'truecolor',
|
||||
TERM_PROGRAM: 'Apple_Terminal'
|
||||
})
|
||||
|
||||
const theme = fromSkin({ banner_text: '#FFF8DC' }, {})
|
||||
|
||||
// No ansi256 bucketing on truecolor terminals — a truly invisible cream
|
||||
// (1.08:1 on white) still gets the display shim's gentle light-mode rescue
|
||||
// (floor 1.18: enough to make near-white text appear, not enough to crush
|
||||
// the vivid golds into mud).
|
||||
expect(theme.color.text).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
expect(contrastRatio(theme.color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.18)
|
||||
})
|
||||
|
||||
it('normalizes Apple Terminal names before matching', async () => {
|
||||
const { fromSkin } = await importThemeWithEnv({ TERM_PROGRAM: ' Apple_Terminal ' })
|
||||
const theme = fromSkin({ banner_text: '#FFF8DC' }, {})
|
||||
|
||||
expect(theme.color.text).toBe('ansi256(136)')
|
||||
})
|
||||
|
||||
it('passes banner logo/hero', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerLogo).toBe('LOGO')
|
||||
expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerHero).toBe('HERO')
|
||||
})
|
||||
|
||||
it('maps ui_ color keys + cascades to status', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
const { color } = fromSkin({ ui_ok: '#008000' }, {})
|
||||
|
||||
// The exact value may be contrast-lifted against the background; the
|
||||
// contract is the cascade (ok drives statusGood) and the hue surviving.
|
||||
expect(color.statusGood).toBe(color.ok)
|
||||
expect(color.ok).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
expect(luminance(color.ok)).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// Rec. 709-ish relative luminance, local to the test so assertions are
|
||||
// independent of the implementation under test.
|
||||
const luminance = (hex: string): number => {
|
||||
const n = parseInt(hex.replace('#', ''), 16)
|
||||
|
||||
const channel = (v: number) => {
|
||||
const c = v / 255
|
||||
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
|
||||
return 0.2126 * channel((n >> 16) & 0xff) + 0.7152 * channel((n >> 8) & 0xff) + 0.0722 * channel(n & 0xff)
|
||||
}
|
||||
|
||||
// The bundled slate skin's actual color block — dark-authored (pale pastels,
|
||||
// no completion/selection backgrounds defined).
|
||||
const SLATE_COLORS = {
|
||||
banner_accent: '#8EA8FF',
|
||||
banner_border: '#4169e1',
|
||||
banner_dim: '#4b5563',
|
||||
banner_text: '#c9d1d9',
|
||||
banner_title: '#7eb8f6',
|
||||
prompt: '#c9d1d9',
|
||||
session_border: '#4b5563',
|
||||
session_label: '#7eb8f6',
|
||||
ui_accent: '#7eb8f6',
|
||||
ui_error: '#F7A072',
|
||||
ui_label: '#8EA8FF',
|
||||
ui_ok: '#63D0A6',
|
||||
ui_warn: '#e6a855'
|
||||
}
|
||||
|
||||
// Max per-channel deviation between two hexes.
|
||||
const channelDelta = (a: string, b: string) => {
|
||||
const pa = parseInt(a.replace('#', ''), 16)
|
||||
const pb = parseInt(b.replace('#', ''), 16)
|
||||
|
||||
return Math.max(
|
||||
Math.abs(((pa >> 16) & 0xff) - ((pb >> 16) & 0xff)),
|
||||
Math.abs(((pa >> 8) & 0xff) - ((pb >> 8) & 0xff)),
|
||||
Math.abs((pa & 0xff) - (pb & 0xff))
|
||||
)
|
||||
}
|
||||
|
||||
describe('derived tone ladder', () => {
|
||||
it('reproduces the original hand-tuned tones from seeds (reverse-engineered knobs)', async () => {
|
||||
// The ladder's knobs were grid-search fitted so the MATH lands on the
|
||||
// pre-refactor hand-tuned literals. Contract: every derived tone stays
|
||||
// within a-few-RGB-units of the original (imperceptible), so knob edits
|
||||
// that drift the classic look fail here instead of shipping as vibes.
|
||||
const dark = await importThemeWithCleanEnv()
|
||||
const light = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
|
||||
|
||||
const cases: Array<[string, string, string]> = [
|
||||
[dark.DARK_THEME.color.muted, '#CC9B1F', 'dark muted'],
|
||||
[dark.DARK_THEME.color.label, '#DAA520', 'dark label'],
|
||||
[dark.DARK_THEME.color.statusFg, '#C0C0C0', 'dark statusFg'],
|
||||
[dark.DARK_THEME.color.completionBg, '#1a1a2e', 'dark surface'],
|
||||
[dark.DARK_THEME.color.completionCurrentBg, '#333355', 'dark chip'],
|
||||
[dark.DARK_THEME.color.selectionBg, '#3a3a55', 'dark selection'],
|
||||
// Light canon = liftForContrast(dark literal, white, 4.5): the exact
|
||||
// colors xterm's minimumContrastRatio rendered on light hosts.
|
||||
[light.LIGHT_THEME.color.muted, '#946C08', 'light muted'],
|
||||
[light.LIGHT_THEME.color.statusFg, '#6F6F6F', 'light statusFg'],
|
||||
[light.LIGHT_THEME.color.completionBg, '#F5F5F5', 'light surface'],
|
||||
[light.LIGHT_THEME.color.completionCurrentBg, '#e0d1bf', 'light chip'],
|
||||
[light.LIGHT_THEME.color.selectionBg, '#D4E4F7', 'light selection']
|
||||
]
|
||||
|
||||
for (const [got, original, label] of cases) {
|
||||
expect(channelDelta(got, original), `${label}: ${got} vs original ${original}`).toBeLessThanOrEqual(8)
|
||||
}
|
||||
})
|
||||
|
||||
it('derives dim/secondary tones from the skin identity, not another palette', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
// A seeds-only skin (no dim/label/menu keys authored at all).
|
||||
const { color } = fromSkin({ banner_accent: '#DD4A3A', banner_text: '#F1E6CF', banner_title: '#C7A96B' }, {})
|
||||
|
||||
// Muted recedes from THIS skin's text toward the background with an
|
||||
// accent tint — a red-family derivative, never another skin's gold.
|
||||
expect(color.muted).not.toBe(color.text)
|
||||
expect(luminance(color.muted)).toBeLessThan(luminance(color.text))
|
||||
|
||||
const rgb = (hex: string) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16))
|
||||
const [mr, , mb] = rgb(color.muted)
|
||||
|
||||
expect(mr).toBeGreaterThan(mb!)
|
||||
|
||||
// The active-row chip is the surface tinted with the skin accent —
|
||||
// redder than the plain surface.
|
||||
const [sr] = rgb(color.completionBg)
|
||||
|
||||
expect(rgb(color.completionCurrentBg)[0]).toBeGreaterThan(sr!)
|
||||
})
|
||||
|
||||
it('authored tones still override the ladder', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
const { color } = fromSkin({ banner_dim: '#AA8844', banner_text: '#F1E6CF' }, {})
|
||||
|
||||
expect(color.muted).toBe('#AA8844')
|
||||
expect(color.sessionLabel).toBe('#AA8844')
|
||||
})
|
||||
})
|
||||
|
||||
describe('background-aware adaptation (OSC-11 light terminals)', () => {
|
||||
it('renders a dark-authored skin on light like minimumContrastRatio hosts do (the standardized look)', async () => {
|
||||
const { contrastRatio, fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
|
||||
const { color } = fromSkin(SLATE_COLORS, {})
|
||||
|
||||
// The authored palette IS the design: slate's airy pastels (~1.5:1) pass
|
||||
// through BYTE-IDENTICAL — that receded look is the standardized
|
||||
// rendering, not a washout to fix.
|
||||
expect(color.text.toLowerCase()).toBe('#c9d1d9')
|
||||
expect(color.accent.toLowerCase()).toBe('#7eb8f6')
|
||||
expect(color.muted.toLowerCase()).toBe('#4b5563')
|
||||
|
||||
// Light mode renders the authored palette essentially RAW: a transparent
|
||||
// terminal (the common Cursor case) applies no contrast lift of its own,
|
||||
// and the beloved classic look is the vivid palette, not a WCAG-darkened
|
||||
// one. Foregrounds only clear the near-invisible floor (1.18).
|
||||
for (const key of ['text', 'prompt', 'accent', 'label', 'primary', 'muted', 'border'] as const) {
|
||||
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.18)
|
||||
}
|
||||
|
||||
// Semantic alert colors carry meaning — firmer floor, still gentle on light.
|
||||
for (const key of ['ok', 'error', 'warn', 'statusGood', 'statusCritical'] as const) {
|
||||
expect(contrastRatio(color[key], '#ffffff'), `${key} ${color[key]}`).toBeGreaterThanOrEqual(1.6)
|
||||
}
|
||||
|
||||
// Background roles the skin never defined must be light-polarity fills,
|
||||
// not the dark base's navy.
|
||||
for (const key of ['completionBg', 'completionCurrentBg', 'statusBg', 'selectionBg'] as const) {
|
||||
expect(luminance(color[key]), `${key} ${color[key]}`).toBeGreaterThanOrEqual(0.4)
|
||||
}
|
||||
})
|
||||
|
||||
it('rescues near-invisible colors with a hue-preserving multiplicative lift', async () => {
|
||||
const { contrastRatio, fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
|
||||
// The default dark cream (#FFF8DC, 1.08:1 on white) is genuinely invisible.
|
||||
const { color } = fromSkin({ banner_text: '#FFF8DC' }, {})
|
||||
|
||||
expect(color.text.toLowerCase()).not.toBe('#fff8dc')
|
||||
expect(contrastRatio(color.text, '#ffffff')!).toBeGreaterThanOrEqual(1.18)
|
||||
|
||||
// Multiplicative lift preserves channel ordering (warm stays warm).
|
||||
const [r, g, b] = [1, 3, 5].map(i => parseInt(color.text.slice(i, i + 2), 16))
|
||||
|
||||
expect(r).toBeGreaterThanOrEqual(g!)
|
||||
expect(g).toBeGreaterThanOrEqual(b!)
|
||||
})
|
||||
|
||||
it('leaves the same skin untouched on a dark background', async () => {
|
||||
const { fromSkin } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#1e1e2e' })
|
||||
const { color } = fromSkin(SLATE_COLORS, {})
|
||||
|
||||
expect(color.text).toBe('#c9d1d9')
|
||||
expect(color.accent).toBe('#7eb8f6')
|
||||
expect(luminance(color.completionBg)).toBeLessThanOrEqual(0.35)
|
||||
})
|
||||
|
||||
it('empty skin on a light background resolves to the light base palette', async () => {
|
||||
const { fromSkin, LIGHT_THEME } = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
|
||||
|
||||
expect(fromSkin({}, {}).color).toEqual(LIGHT_THEME.color)
|
||||
})
|
||||
|
||||
it('base palettes are fixed points of the adaptation', async () => {
|
||||
const dark = await importThemeWithCleanEnv()
|
||||
|
||||
expect(dark.fromSkin({}, {}).color).toEqual(dark.DARK_THEME.color)
|
||||
|
||||
const light = await importThemeWithEnv({ HERMES_TUI_BACKGROUND: '#ffffff' })
|
||||
|
||||
expect(light.fromSkin({}, {}).color).toEqual(light.LIGHT_THEME.color)
|
||||
})
|
||||
|
||||
it('defaultThemeForCurrentBackground follows a late HERMES_TUI_BACKGROUND write', async () => {
|
||||
const { DARK_THEME, DEFAULT_THEME, defaultThemeForCurrentBackground, LIGHT_THEME } = await importThemeWithCleanEnv()
|
||||
|
||||
// Module loaded dark (clean env)…
|
||||
expect(DEFAULT_THEME.color.completionBg).toBe(DARK_THEME.color.completionBg)
|
||||
expect(luminance(DEFAULT_THEME.color.completionBg)).toBeLessThanOrEqual(0.35)
|
||||
|
||||
// …then the OSC-11 answer lands and is cached into the env slot.
|
||||
expect(defaultThemeForCurrentBackground({ HERMES_TUI_BACKGROUND: '#ffffff' }).color).toEqual(LIGHT_THEME.color)
|
||||
})
|
||||
|
||||
it('gives tool + thinking their own keys, defaulting to accent + muted', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
// Independent override: recoloring tool/thinking doesn't leak into accent.
|
||||
// (Values flow through #20379's contrast adaptation, so assert the
|
||||
// independence contract, not raw pre-adaptation hexes.)
|
||||
const themed = fromSkin({ ui_accent: '#3aa0ff', ui_tool: '#ff0000', ui_thinking: '#00ff00' }, {})
|
||||
const baseline = fromSkin({ ui_accent: '#3aa0ff' }, {})
|
||||
expect(themed.color.tool).toBe('#ff0000')
|
||||
expect(themed.color.thinking).toBe('#00ff00')
|
||||
expect(themed.color.tool).not.toBe(themed.color.accent)
|
||||
expect(themed.color.accent).toBe(baseline.color.accent) // override didn't touch accent
|
||||
|
||||
// Default: tool follows accent, thinking follows muted — same source →
|
||||
// identical after adaptation.
|
||||
const fallback = fromSkin({ ui_accent: '#3aa0ff', banner_dim: '#8a8a8a' }, {})
|
||||
expect(fallback.color.tool).toBe(fallback.color.accent)
|
||||
expect(fallback.color.thinking).toBe(fallback.color.muted)
|
||||
})
|
||||
|
||||
it('gives code syntax its own keys, defaulting to accent/text/border/muted', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
const themed = fromSkin(
|
||||
{ syntax_string: '#aa0000', syntax_number: '#00aa00', syntax_keyword: '#0000aa', syntax_comment: '#888888' },
|
||||
{}
|
||||
)
|
||||
|
||||
expect(themed.color.syntaxString).toBe('#aa0000')
|
||||
expect(themed.color.syntaxNumber).toBe('#00aa00')
|
||||
expect(themed.color.syntaxKeyword).toBe('#0000aa')
|
||||
expect(themed.color.syntaxComment).toBe('#888888')
|
||||
|
||||
const fallback = fromSkin({ ui_accent: '#abcdef' }, {})
|
||||
expect(fallback.color.syntaxString).toBe('#abcdef') // string follows accent
|
||||
})
|
||||
|
||||
it('lets skins override diff colors', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
const { color } = fromSkin(
|
||||
{ diff_added: '#0a0', diff_removed: '#a00', diff_added_word: '#0f0', diff_removed_word: '#f00' },
|
||||
{}
|
||||
)
|
||||
|
||||
expect(color.diffAdded).toBe('#0a0')
|
||||
expect(color.diffRemoved).toBe('#a00')
|
||||
expect(color.diffAddedWord).toBe('#0f0')
|
||||
expect(color.diffRemovedWord).toBe('#f00')
|
||||
})
|
||||
|
||||
it('maps the status bar from skin status_bar_* keys', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
|
||||
const { color } = fromSkin(
|
||||
{
|
||||
status_bar_bg: '#101020',
|
||||
status_bar_text: '#e0e0e0',
|
||||
status_bar_bad: '#ff8800',
|
||||
status_bar_critical: '#ff0000'
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
expect(color.statusBg).toBe('#101020')
|
||||
expect(color.statusFg).toBe('#e0e0e0')
|
||||
expect(color.statusBad).toBe('#ff8800')
|
||||
expect(color.statusCritical).toBe('#ff0000')
|
||||
})
|
||||
|
||||
it('falls the status bar back to background + semantic colors', async () => {
|
||||
const { fromSkin } = await importThemeWithCleanEnv()
|
||||
const { color } = fromSkin({ background: '#0a0a0a', banner_text: '#fafafa', ui_error: '#dd2222' }, {})
|
||||
|
||||
// background paints the surface → status/completion bg; banner_text → status
|
||||
// fg; ui_error → critical. Semantic hues flow through contrast adaptation,
|
||||
// so `statusCritical` is asserted to track `ui_error` identically rather
|
||||
// than pinning an adapted hex.
|
||||
expect(color.statusBg).toBe('#0a0a0a')
|
||||
expect(color.completionBg).toBe('#0a0a0a')
|
||||
expect(color.statusFg).toBe('#fafafa')
|
||||
expect(color.statusCritical).toBe(fromSkin({ ui_error: '#dd2222' }, {}).color.error)
|
||||
})
|
||||
})
|
||||
|
||||
describe('themeToneHex', () => {
|
||||
it('resolves a tone to the literal color it paints as', async () => {
|
||||
const { themeToneHex } = await importThemeWithCleanEnv()
|
||||
|
||||
// 232+ is the grayscale ramp (8 + (n-232)*10); 16-231 is the 6x6x6 cube.
|
||||
expect(themeToneHex('ansi256(238)')).toBe('#444444')
|
||||
expect(themeToneHex('ansi256(161)')).toBe('#d7005f')
|
||||
// An authored hex is already literal.
|
||||
expect(themeToneHex('#e77fa3')).toBe('#e77fa3')
|
||||
// No paintable color ⇒ '', which releases the terminal default.
|
||||
expect(themeToneHex('')).toBe('')
|
||||
expect(themeToneHex('ansi256(999)')).toBe('')
|
||||
expect(themeToneHex('inherit')).toBe('')
|
||||
})
|
||||
|
||||
it('makes every tone paintable on a quantizing terminal', async () => {
|
||||
// The contract OSC-10 depends on: whatever the palette normalizer does to
|
||||
// a tone, themeToneHex still yields a literal `#rrggbb`. Asserted over the
|
||||
// whole palette so a new tone can't silently regress the default paint.
|
||||
const { fromSkin, themeToneHex } = await importThemeWithEnv({ TERM_PROGRAM: 'Apple_Terminal' })
|
||||
const { color } = fromSkin({ background: '#f6f9fd', ui_text: '#4a4550' }, {})
|
||||
|
||||
for (const tone of Object.values(color)) {
|
||||
expect(themeToneHex(tone)).toMatch(/^#[0-9a-f]{6}$/i)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { type BootTheme, invalidateBootBackground, seedBootEnvironment } from '../lib/themeBoot.js'
|
||||
import { defaultTheme } from '../theme.js'
|
||||
|
||||
// Review on #20379 (finding 2): the boot cache seeds the previous session's
|
||||
// background into HERMES_TUI_BACKGROUND, which detectLightMode treats as a
|
||||
// CURRENT signal. Without provenance, a stale light cache pins a now-dark
|
||||
// terminal to light indefinitely (the current probe's pure-black answer is
|
||||
// distrusted, the pure-white foreground is distrusted, and the macOS
|
||||
// fallback refuses to run while the slot is occupied). These tests cover
|
||||
// the seed/invalidate contract the gateway handler drives.
|
||||
|
||||
const cache = (over: Partial<BootTheme> = {}): BootTheme => ({ theme: defaultTheme, ...over })
|
||||
|
||||
describe('seedBootEnvironment', () => {
|
||||
it('seeds the cached background when no explicit signal outranks it', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
const seeded = seedBootEnvironment(cache({ background: '#ffffff' }), env)
|
||||
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBe('#ffffff')
|
||||
expect(seeded).toEqual({ seededBackground: '#ffffff', seededPin: false })
|
||||
})
|
||||
|
||||
it('never seeds over explicit user signals', () => {
|
||||
for (const preset of [{ HERMES_TUI_THEME: 'dark' }, { HERMES_TUI_LIGHT: '1' }] as NodeJS.ProcessEnv[]) {
|
||||
const env = { ...preset }
|
||||
const seeded = seedBootEnvironment(cache({ background: '#ffffff', mode: 'light' }), env)
|
||||
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBeUndefined()
|
||||
expect(seeded).toEqual({ seededBackground: null, seededPin: false })
|
||||
}
|
||||
|
||||
// A user-exported background keeps its value; only the pin may seed.
|
||||
const env: NodeJS.ProcessEnv = { HERMES_TUI_BACKGROUND: '#123456' }
|
||||
|
||||
expect(seedBootEnvironment(cache({ background: '#ffffff' }), env).seededBackground).toBeNull()
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBe('#123456')
|
||||
})
|
||||
|
||||
it('never seeds the untrusted pure-black fingerprint', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
const seeded = seedBootEnvironment(cache({ background: '#000000' }), env)
|
||||
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBeUndefined()
|
||||
expect(seeded.seededBackground).toBeNull()
|
||||
})
|
||||
|
||||
it('replays a cached config pin coherently with the physical background', () => {
|
||||
// "/theme light" pinned while the physical terminal is dark: the cache
|
||||
// stores BOTH — replaying only the background would resolve the first
|
||||
// skin dark and recreate the light → dark → light flash.
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
const seeded = seedBootEnvironment(cache({ background: '#1e1e1e', mode: 'light' }), env)
|
||||
|
||||
expect(env.HERMES_TUI_THEME).toBe('light')
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBe('#1e1e1e')
|
||||
expect(seeded).toEqual({ seededBackground: '#1e1e1e', seededPin: true })
|
||||
})
|
||||
|
||||
it('replays a pinned dark on a light physical background too', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
const seeded = seedBootEnvironment(cache({ background: '#ffffff', mode: 'dark' }), env)
|
||||
|
||||
expect(env.HERMES_TUI_THEME).toBe('dark')
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBe('#ffffff')
|
||||
expect(seeded.seededPin).toBe(true)
|
||||
})
|
||||
|
||||
it('is a no-op without a cache', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
expect(seedBootEnvironment(null, env)).toEqual({ seededBackground: null, seededPin: false })
|
||||
expect(env).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('invalidateBootBackground', () => {
|
||||
it('clears the slot while it still holds the seeded value (stale light cache, current dark terminal)', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
seedBootEnvironment(cache({ background: '#ffffff' }), env)
|
||||
|
||||
// Current terminal answers OSC-11 with distrusted #000000 → the handler
|
||||
// invalidates: the slot must clear so foreground / COLORFGBG / macOS
|
||||
// appearance / the default get their turn.
|
||||
expect(invalidateBootBackground(env)).toBe(true)
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBeUndefined()
|
||||
|
||||
// Idempotent: a second distrusted answer has nothing left to clear.
|
||||
expect(invalidateBootBackground(env)).toBe(false)
|
||||
})
|
||||
|
||||
it('clears a stale dark cache on a current ambiguous terminal the same way', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
seedBootEnvironment(cache({ background: '#1e1e1e' }), env)
|
||||
|
||||
expect(invalidateBootBackground(env)).toBe(true)
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves a trusted OSC answer that overwrote the seed alone', () => {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
|
||||
seedBootEnvironment(cache({ background: '#ffffff' }), env)
|
||||
|
||||
// A real OSC-11 measurement replaced the hint — it is authoritative.
|
||||
env.HERMES_TUI_BACKGROUND = '#282828'
|
||||
|
||||
expect(invalidateBootBackground(env)).toBe(false)
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBe('#282828')
|
||||
})
|
||||
|
||||
it('is a no-op when nothing was seeded', () => {
|
||||
const env: NodeJS.ProcessEnv = { HERMES_TUI_BACKGROUND: '#ffffff' }
|
||||
|
||||
seedBootEnvironment(null, env)
|
||||
|
||||
expect(invalidateBootBackground(env)).toBe(false)
|
||||
expect(env.HERMES_TUI_BACKGROUND).toBe('#ffffff')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { PassThrough } from 'stream'
|
||||
|
||||
import { renderSync } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ToolTrail } from '../components/thinking.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
const flushEffects = async () => {
|
||||
// Passive effects + the re-render they trigger need a few macrotask
|
||||
// turns (React's scheduler uses MessageChannel) before the next frame
|
||||
// paints — setTimeout(0)-class waits, not setImmediate (which can land
|
||||
// in the wrong phase and observe the pre-effect frame).
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
}
|
||||
}
|
||||
|
||||
const mountTrail = (reasoningActive: boolean, sections?: Record<string, string>) => {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 60, isTTY: false, rows: 20 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(
|
||||
<ToolTrail
|
||||
reasoning="Live reasoning text."
|
||||
reasoningActive={reasoningActive}
|
||||
sections={sections ?? { thinking: 'collapsed' }}
|
||||
t={DEFAULT_THEME}
|
||||
/>,
|
||||
{
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
}
|
||||
)
|
||||
|
||||
// The PassThrough accumulates every repaint, and a collapsed panel stops
|
||||
// repainting entirely once settled — so assert on the FINAL chevron state
|
||||
// in the accumulated output rather than the tail after a clear().
|
||||
const finalChevronOpen = () => stripAnsi(output).lastIndexOf('▾ ') > stripAnsi(output).lastIndexOf('▸ ')
|
||||
|
||||
return { finalChevronOpen, instance }
|
||||
}
|
||||
|
||||
describe('ToolTrail — collapsed mode auto-expands while reasoning is live', () => {
|
||||
it('opens (▾) when reasoningActive is true under sections.thinking: collapsed', async () => {
|
||||
const { finalChevronOpen, instance } = mountTrail(true)
|
||||
|
||||
await flushEffects()
|
||||
|
||||
expect(finalChevronOpen()).toBe(true)
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
})
|
||||
|
||||
it('collapses (▸) when reasoningActive is false under sections.thinking: collapsed', async () => {
|
||||
const { finalChevronOpen, instance } = mountTrail(false)
|
||||
|
||||
await flushEffects()
|
||||
|
||||
expect(finalChevronOpen()).toBe(false)
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
})
|
||||
|
||||
it('closes the panel when the reasoning phase ends mid-turn (rerender)', async () => {
|
||||
const { finalChevronOpen, instance } = mountTrail(true)
|
||||
|
||||
await flushEffects()
|
||||
|
||||
expect(finalChevronOpen()).toBe(true)
|
||||
|
||||
// Reasoning phase finished (final answer / tool call started) — the
|
||||
// turn's reasoningActive drops and the panel must collapse.
|
||||
instance.rerender(
|
||||
<ToolTrail
|
||||
reasoning="Live reasoning text."
|
||||
reasoningActive={false}
|
||||
sections={{ thinking: 'collapsed' }}
|
||||
t={DEFAULT_THEME}
|
||||
/>
|
||||
)
|
||||
|
||||
await flushEffects()
|
||||
|
||||
expect(finalChevronOpen()).toBe(false)
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
})
|
||||
|
||||
it('leaves expanded-mode panels fully manual (no forced collapse)', async () => {
|
||||
const { finalChevronOpen, instance } = mountTrail(false, { thinking: 'expanded' })
|
||||
|
||||
await flushEffects()
|
||||
|
||||
// `expanded` is a manual preference: reasoningActive=false must NOT
|
||||
// force it closed (the auto behavior only applies to `collapsed`).
|
||||
expect(finalChevronOpen()).toBe(true)
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { PassThrough } from 'stream'
|
||||
|
||||
import { renderSync } from '@hermes/ink'
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ToolTrail } from '../components/thinking.js'
|
||||
import { stripAnsi } from '../lib/text.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
describe('ToolTrail — MoA reference panel visibility (#64701)', () => {
|
||||
it('stays expanded after mount effects settle when reasoningAlwaysVisible is set, even under sections.thinking: hidden', async () => {
|
||||
const stdout = new PassThrough()
|
||||
const stdin = new PassThrough()
|
||||
const stderr = new PassThrough()
|
||||
let output = ''
|
||||
|
||||
Object.assign(stdout, { columns: 60, isTTY: false, rows: 20 })
|
||||
Object.assign(stdin, { isTTY: false })
|
||||
Object.assign(stderr, { isTTY: false })
|
||||
stdout.on('data', chunk => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
const instance = renderSync(
|
||||
<ToolTrail
|
||||
reasoning="Reference model output that must stay visible on first paint."
|
||||
reasoningAlwaysVisible
|
||||
sections={{ thinking: 'hidden' }}
|
||||
t={DEFAULT_THEME}
|
||||
/>,
|
||||
{
|
||||
patchConsole: false,
|
||||
stderr: stderr as NodeJS.WriteStream,
|
||||
stdin: stdin as NodeJS.ReadStream,
|
||||
stdout: stdout as NodeJS.WriteStream
|
||||
}
|
||||
)
|
||||
|
||||
// Let queued passive effects (and any re-render they trigger) flush
|
||||
// before reading the frame — the #64701 bug is specifically that the
|
||||
// re-sync effect fires AFTER the first paint and clobbers the
|
||||
// reasoningAlwaysVisible mount value, so asserting on the pre-effect
|
||||
// frame alone would miss the regression entirely.
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
const frame = stripAnsi(output)
|
||||
|
||||
instance.unmount()
|
||||
instance.cleanup()
|
||||
|
||||
// Open chevron (▾) means the panel is still expanded once effects have
|
||||
// settled, as the reasoningAlwaysVisible-seeded useState value intends.
|
||||
// A collapsed (▸) render here means the re-sync effect fired on mount
|
||||
// and clobbered it — the exact #64701 regression.
|
||||
expect(frame).toContain('▾ ')
|
||||
expect(frame).toContain('Thinking')
|
||||
expect(frame).not.toContain('▸ ')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,467 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import { topupCommands } from '../app/slash/commands/topup.js'
|
||||
import type { BillingStateResponse } from '../gatewayTypes.js'
|
||||
|
||||
vi.mock('../lib/openExternalUrl.js', () => ({
|
||||
openExternalUrl: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
const topupCommand = topupCommands.find(cmd => cmd.name === 'topup')!
|
||||
|
||||
const ownerState = (overrides: Partial<BillingStateResponse> = {}): BillingStateResponse => ({
|
||||
auto_reload: {
|
||||
card: { kind: 'canonical' },
|
||||
enabled: false,
|
||||
reload_to_display: '—',
|
||||
reload_to_usd: null,
|
||||
threshold_display: '—',
|
||||
threshold_usd: null
|
||||
},
|
||||
balance_display: '$142.50',
|
||||
balance_usd: '142.5',
|
||||
can_charge: true,
|
||||
card: { brand: 'visa', last4: '4242', masked: 'visa ····4242' },
|
||||
charge_presets: ['25', '50', '100'],
|
||||
charge_presets_display: ['$25', '$50', '$100'],
|
||||
cli_billing_enabled: true,
|
||||
is_admin: true,
|
||||
logged_in: true,
|
||||
max_usd: '10000',
|
||||
min_usd: '10',
|
||||
monthly_cap: {
|
||||
is_default_ceiling: true,
|
||||
limit_display: '$1000',
|
||||
limit_usd: '1000',
|
||||
spent_display: '$180',
|
||||
spent_this_month_usd: '180'
|
||||
},
|
||||
ok: true,
|
||||
org_name: 'Acme',
|
||||
portal_url: 'https://portal/billing?topup=open',
|
||||
role: 'OWNER',
|
||||
...overrides
|
||||
})
|
||||
|
||||
const guarded =
|
||||
<T>(fn: (r: T) => void) =>
|
||||
(r: null | T) => {
|
||||
if (r) {
|
||||
fn(r)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
|
||||
const buildCtx = (results: Record<string, unknown>) => {
|
||||
const sys = vi.fn()
|
||||
const calls: Array<{ method: string; params: unknown }> = []
|
||||
|
||||
const rpc = vi.fn((method: string, params: unknown) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
return Promise.resolve(results[method])
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
gateway: { rpc },
|
||||
guarded,
|
||||
guardedErr: vi.fn(),
|
||||
sid: 'sid-1',
|
||||
stale: () => false,
|
||||
transcript: { page: vi.fn(), panel: vi.fn(), sys }
|
||||
}
|
||||
|
||||
const run = async (arg: string) => {
|
||||
topupCommand.run(arg, ctx as any, 'topup')
|
||||
await rpc.mock.results[0]?.value
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
return { calls, ctx, rpc, run, sys }
|
||||
}
|
||||
|
||||
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
|
||||
|
||||
describe('/billing slash command (overlay-driven)', () => {
|
||||
beforeEach(() => {
|
||||
resetOverlayState()
|
||||
})
|
||||
|
||||
it('not logged in → prompts to log in, no overlay', async () => {
|
||||
const { run, sys } = buildCtx({ 'billing.state': { ...ownerState(), logged_in: false, ok: true } })
|
||||
await run('')
|
||||
expect(printed(sys)).toContain('Not logged into Nous Portal')
|
||||
expect(getOverlayState().billing).toBeNull()
|
||||
})
|
||||
|
||||
it('bare /billing opens the overlay on the overview screen with state', async () => {
|
||||
const { run, rpc } = buildCtx({ 'billing.state': ownerState() })
|
||||
await run('')
|
||||
expect(rpc).toHaveBeenCalledWith('billing.state', {})
|
||||
const billing = getOverlayState().billing
|
||||
expect(billing).toBeTruthy()
|
||||
expect(billing?.screen).toBe('overview')
|
||||
expect(billing?.state.balance_display).toBe('$142.50')
|
||||
expect(billing?.state.charge_presets_display).toEqual(['$25', '$50', '$100'])
|
||||
})
|
||||
|
||||
it('any sub-command arg is ignored — still opens the overview overlay', async () => {
|
||||
const { run } = buildCtx({ 'billing.state': ownerState() })
|
||||
await run('buy 100')
|
||||
const billing = getOverlayState().billing
|
||||
expect(billing?.screen).toBe('overview')
|
||||
// No confirm overlay armed directly by the command anymore.
|
||||
expect(getOverlayState().confirm).toBeNull()
|
||||
})
|
||||
|
||||
it('member overview carries the non-admin state for component-side gating', async () => {
|
||||
const { run } = buildCtx({
|
||||
'billing.state': ownerState({
|
||||
is_admin: false,
|
||||
can_charge: false,
|
||||
role: 'MEMBER',
|
||||
card: null,
|
||||
monthly_cap: null,
|
||||
auto_reload: null
|
||||
})
|
||||
})
|
||||
|
||||
await run('')
|
||||
const billing = getOverlayState().billing
|
||||
expect(billing?.state.is_admin).toBe(false)
|
||||
expect(billing?.screen).toBe('overview')
|
||||
})
|
||||
|
||||
// ── Overlay ctx behaviors (RPC + error mapping live in billing.ts) ──
|
||||
|
||||
it('ctx.validate rejects out-of-bounds and sub-cent amounts, accepts valid', async () => {
|
||||
const { run } = buildCtx({ 'billing.state': ownerState() })
|
||||
await run('')
|
||||
const ctx = getOverlayState().billing!.ctx
|
||||
expect(ctx.validate('5').error).toContain('Minimum is $10')
|
||||
expect(ctx.validate('10.005').error).toContain('2 decimal places')
|
||||
expect(ctx.validate('100').amount).toBe('100')
|
||||
expect(ctx.validate('$50').amount).toBe('50')
|
||||
})
|
||||
|
||||
it('ctx.charge → poll → settled', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
|
||||
'billing.charge_status': { ok: true, status: 'settled', amount_usd: '100' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
const ctx = getOverlayState().billing!.ctx
|
||||
ctx.charge('100')
|
||||
await vi.runAllTimersAsync()
|
||||
const out = printed(sys)
|
||||
expect(out).toContain('Charge submitted')
|
||||
expect(out).toContain('✅ $100 added.')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('ctx.charge → poll → failed adds the portal funnel line', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
|
||||
'billing.charge_status': { ok: true, status: 'failed', reason: 'card_declined' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
getOverlayState().billing!.ctx.charge('100')
|
||||
await vi.runAllTimersAsync()
|
||||
const out = printed(sys)
|
||||
expect(out).toContain('Your card was declined')
|
||||
// Parity with the CLI: a failed poll funnels to the portal (from state.portal_url).
|
||||
expect(out).toContain('Portal: https://portal/billing?topup=open')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('ctx.charge monthly_cap_exceeded surfaces remaining headroom', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': {
|
||||
ok: false,
|
||||
error: 'monthly_cap_exceeded',
|
||||
message: 'Monthly spend cap reached.',
|
||||
payload: { remainingUsd: '42.50' },
|
||||
portal_url: '/billing?topup=open',
|
||||
idempotency_key: 'k'
|
||||
}
|
||||
})
|
||||
|
||||
await run('')
|
||||
getOverlayState().billing!.ctx.charge('100')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const out = printed(sys)
|
||||
expect(out).toContain('Monthly spend cap reached — $42.50 headroom left.')
|
||||
expect(out).toContain('Portal: /billing?topup=open')
|
||||
})
|
||||
|
||||
it('ctx.charge no_payment_method → portal funnel copy', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': {
|
||||
ok: false,
|
||||
error: 'no_payment_method',
|
||||
portal_url: '/billing?topup=open',
|
||||
idempotency_key: 'k'
|
||||
}
|
||||
})
|
||||
|
||||
await run('')
|
||||
getOverlayState().billing!.ctx.charge('100')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const out = printed(sys)
|
||||
expect(out).toContain('No saved card for terminal charges')
|
||||
expect(out).toContain('Portal: /billing?topup=open')
|
||||
})
|
||||
|
||||
it('ctx.charge consent_required → one-time portal confirmation copy + portal funnel', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': {
|
||||
ok: false,
|
||||
error: 'consent_required',
|
||||
portal_url: '/billing/consent',
|
||||
idempotency_key: 'k'
|
||||
}
|
||||
})
|
||||
|
||||
await run('')
|
||||
await getOverlayState().billing!.ctx.charge('100')
|
||||
const out = printed(sys)
|
||||
expect(out).toContain('one-time card confirmation')
|
||||
expect(out).toContain('Portal: /billing/consent')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['org_access_denied', "This token isn't bound to an org you can manage"],
|
||||
['upgrade_cap_exceeded', 'Daily plan-change limit reached'],
|
||||
['auto_top_up_disabled_failures', 'Auto-reload was turned off after repeated charge failures']
|
||||
])('ctx.charge %s → typed recovery copy', async (error, copy) => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': { ok: false, error, idempotency_key: 'k' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
await getOverlayState().billing!.ctx.charge('100')
|
||||
expect(printed(sys)).toContain(copy)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[undefined, 'Stripe is having trouble right now — try again shortly.'],
|
||||
[120, 'Stripe is having trouble right now — try again shortly (try again in ~2 min).']
|
||||
])('ctx.charge stripe_unavailable (retry_after=%s) → transient Stripe copy', async (retryAfter, copy) => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': {
|
||||
ok: false,
|
||||
error: 'stripe_unavailable',
|
||||
idempotency_key: 'k',
|
||||
...(retryAfter == null ? {} : { retry_after: retryAfter })
|
||||
}
|
||||
})
|
||||
|
||||
await run('')
|
||||
await getOverlayState().billing!.ctx.charge('100')
|
||||
const out = printed(sys)
|
||||
expect(out).toContain(copy)
|
||||
expect(out).not.toContain('Too many charges')
|
||||
})
|
||||
|
||||
it('ctx.charge insufficient_scope → resolves needs_remote_spending (overlay routes to stepup)', async () => {
|
||||
const { run } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': { ok: false, error: 'insufficient_scope', idempotency_key: 'k' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
const outcome = await getOverlayState().billing!.ctx.charge('100')
|
||||
// No separate confirm overlay is armed anymore — the overlay's stepup
|
||||
// screen owns the UX; the ctx just reports the outcome.
|
||||
expect(outcome).toBe('needs_remote_spending')
|
||||
expect(getOverlayState().confirm).toBeNull()
|
||||
})
|
||||
|
||||
it.each([[true], [false]])('ctx.requestRemoteSpending → billing.step_up resolves %s', async granted => {
|
||||
const { run, calls } = buildCtx({ 'billing.state': ownerState(), 'billing.step_up': { ok: true, granted } })
|
||||
|
||||
await run('')
|
||||
expect(await getOverlayState().billing!.ctx.requestRemoteSpending()).toBe(granted)
|
||||
expect(calls.find(c => c.method === 'billing.step_up')).toBeTruthy()
|
||||
})
|
||||
|
||||
// ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ──
|
||||
|
||||
it.each([
|
||||
['admin', 'An admin stopped remote spending for this terminal'],
|
||||
['self', 'You stopped remote spending for this terminal']
|
||||
])(
|
||||
'ctx.charge remote_spending_revoked (%s) → clears the overlay (no zombie button) + actor copy',
|
||||
async (actor, copy) => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': {
|
||||
ok: false,
|
||||
error: 'remote_spending_revoked',
|
||||
actor,
|
||||
recovery: 'reconnect',
|
||||
idempotency_key: 'k'
|
||||
}
|
||||
})
|
||||
|
||||
await run('')
|
||||
getOverlayState().billing!.ctx.charge('100')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(printed(sys)).toContain(copy)
|
||||
expect(getOverlayState().billing).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('ctx.charge session_revoked → clears overlay + re-login (not reconnect) copy', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': { ok: false, error: 'session_revoked', recovery: 'login', idempotency_key: 'k' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
getOverlayState().billing!.ctx.charge('100')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(printed(sys)).toContain('Your session was logged out')
|
||||
expect(getOverlayState().billing).toBeNull()
|
||||
})
|
||||
|
||||
it('ctx.charge → poll transport loss reports an unconfirmed outcome', async () => {
|
||||
const { ctx, rpc, run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
rpc.mockImplementation((method: string) => {
|
||||
if (method === 'billing.charge_status') {
|
||||
return Promise.reject(new Error('socket closed'))
|
||||
}
|
||||
|
||||
return Promise.resolve(method === 'billing.charge' ? { ok: true, charge_id: 'ch_1', idempotency_key: 'k' } : null)
|
||||
})
|
||||
await getOverlayState().billing!.ctx.charge('100')
|
||||
await vi.waitFor(() => expect(printed(sys)).toContain('outcome is unconfirmed'))
|
||||
expect(ctx.guardedErr).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ctx.charge cli_billing_disabled / remote_spending_disabled → account-toggle copy', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': {
|
||||
ok: false,
|
||||
error: 'cli_billing_disabled',
|
||||
code: 'remote_spending_disabled',
|
||||
recovery: 'enable_account_toggle',
|
||||
portal_url: '/billing',
|
||||
idempotency_key: 'k'
|
||||
}
|
||||
})
|
||||
|
||||
await run('')
|
||||
getOverlayState().billing!.ctx.charge('100')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
const out = printed(sys)
|
||||
expect(out).toContain('Remote spending is off for this account')
|
||||
// Account-wide switch is NOT a per-terminal revoke — overlay stays open.
|
||||
expect(getOverlayState().billing).toBeTruthy()
|
||||
})
|
||||
|
||||
it('ctx.applyAutoReload(true, …) → billing.auto_reload RPC, resolves true', async () => {
|
||||
const { run, calls } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.auto_reload': { ok: true }
|
||||
})
|
||||
|
||||
await run('')
|
||||
const ok = await getOverlayState().billing!.ctx.applyAutoReload(true, 20, 100)
|
||||
expect(ok).toBe(true)
|
||||
const ar = calls.find(c => c.method === 'billing.auto_reload')
|
||||
expect(ar?.params).toEqual({ enabled: true, threshold: 20, top_up_amount: 100 })
|
||||
})
|
||||
|
||||
it('ctx.applyAutoReload(false) → disables (enabled:false, no amounts)', async () => {
|
||||
const { run, calls } = buildCtx({
|
||||
'billing.state': ownerState({
|
||||
auto_reload: {
|
||||
card: { kind: 'canonical' },
|
||||
enabled: true,
|
||||
reload_to_display: '$100',
|
||||
reload_to_usd: '100',
|
||||
threshold_display: '$20',
|
||||
threshold_usd: '20'
|
||||
}
|
||||
}),
|
||||
'billing.auto_reload': { ok: true }
|
||||
})
|
||||
|
||||
await run('')
|
||||
const ok = await getOverlayState().billing!.ctx.applyAutoReload(false)
|
||||
expect(ok).toBe(true)
|
||||
const ar = calls.find(c => c.method === 'billing.auto_reload')
|
||||
expect(ar?.params).toEqual({ enabled: false })
|
||||
})
|
||||
|
||||
it('ctx.applyAutoReload error → resolves false + maps the error', async () => {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.auto_reload': { ok: false, error: 'monthly_cap_exceeded', message: 'Monthly spend cap reached.' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
const ok = await getOverlayState().billing!.ctx.applyAutoReload(true, 20, 100)
|
||||
expect(ok).toBe(false)
|
||||
expect(printed(sys)).toContain('Monthly spend cap reached.')
|
||||
})
|
||||
|
||||
it('ctx.charge → poll → processing_error has intentional failure copy', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const { run, sys } = buildCtx({
|
||||
'billing.state': ownerState(),
|
||||
'billing.charge': { ok: true, charge_id: 'ch_1', idempotency_key: 'k' },
|
||||
'billing.charge_status': { ok: true, status: 'failed', reason: 'processing_error' }
|
||||
})
|
||||
|
||||
await run('')
|
||||
getOverlayState().billing!.ctx.charge('100')
|
||||
await vi.runAllTimersAsync()
|
||||
expect(printed(sys)).toContain("The charge didn't go through (processing_error).")
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('ctx.openPortal opens the URL + echoes a transcript line', async () => {
|
||||
const { run, sys } = buildCtx({ 'billing.state': ownerState() })
|
||||
await run('')
|
||||
getOverlayState().billing!.ctx.openPortal('https://portal/x')
|
||||
expect(printed(sys)).toContain('Opening portal: https://portal/x')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { turnController } from '../app/turnController.js'
|
||||
import { resetTurnState } from '../app/turnStore.js'
|
||||
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
|
||||
|
||||
// turnController.startMessage() treats "flash and yield" notices (the usage-band
|
||||
// credits.usage and the one-time credits.grant_spent transition) as "show until next
|
||||
// prompt": they flash once, then yield when the next turn starts. Depletion (and
|
||||
// other notices) are sticky until the policy clears them.
|
||||
describe('turnController.startMessage — flash-and-yield notices clear on next prompt', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
resetTurnState()
|
||||
turnController.fullReset()
|
||||
})
|
||||
|
||||
it('clears a standing credits.usage notice when a new turn starts', () => {
|
||||
patchUiState({
|
||||
notice: { key: 'credits.usage', kind: 'sticky', level: 'warn', text: '⚠ Credits 90% used · $20.00 cap' }
|
||||
})
|
||||
turnController.startMessage()
|
||||
expect(getUiState().notice).toBeNull()
|
||||
})
|
||||
|
||||
it('clears a standing credits.grant_spent notice when a new turn starts', () => {
|
||||
// One-time "you've crossed onto top-up" heads-up — shouldn't camp the bar
|
||||
// (e.g. "Grant spent · $990 top-up left" with plenty of top-up remaining).
|
||||
patchUiState({
|
||||
notice: { key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $990.00 top-up left' }
|
||||
})
|
||||
turnController.startMessage()
|
||||
expect(getUiState().notice).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves a sticky credits.depleted notice across a new turn', () => {
|
||||
patchUiState({
|
||||
notice: {
|
||||
key: 'credits.depleted',
|
||||
kind: 'sticky',
|
||||
level: 'error',
|
||||
text: '✕ Credit access paused · run /topup to top up'
|
||||
}
|
||||
})
|
||||
turnController.startMessage()
|
||||
expect(getUiState().notice?.key).toBe('credits.depleted')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { turnController } from '../app/turnController.js'
|
||||
import { getTurnState, resetTurnState } from '../app/turnStore.js'
|
||||
|
||||
// turnController.recordTodos() parses the raw `todo` tool payload into
|
||||
// TodoItem[]. Nested subtasks (apps/desktop's `parent` field) must survive
|
||||
// this parse — the TUI todo panel renders hierarchy from it via todoTree().
|
||||
describe('turnController.recordTodos — preserves the parent field', () => {
|
||||
beforeEach(() => {
|
||||
resetTurnState()
|
||||
turnController.fullReset()
|
||||
})
|
||||
|
||||
it('keeps parent on a valid nested subtask', () => {
|
||||
turnController.recordTodos([
|
||||
{ content: 'Ship feature', id: 'wp1', status: 'in_progress' },
|
||||
{ content: 'Write tests', id: 't1', parent: 'wp1', status: 'pending' }
|
||||
])
|
||||
|
||||
expect(getTurnState().todos).toEqual([
|
||||
{ content: 'Ship feature', id: 'wp1', status: 'in_progress' },
|
||||
{ content: 'Write tests', id: 't1', parent: 'wp1', status: 'pending' }
|
||||
])
|
||||
})
|
||||
|
||||
it('drops a self-referential parent instead of keeping a self-loop', () => {
|
||||
turnController.recordTodos([{ content: 'x', id: 'a', parent: 'a', status: 'pending' }])
|
||||
|
||||
expect(getTurnState().todos).toEqual([{ content: 'x', id: 'a', status: 'pending' }])
|
||||
})
|
||||
|
||||
it('omits parent entirely when absent, matching pre-nesting payloads', () => {
|
||||
turnController.recordTodos([{ content: 'x', id: 'a', status: 'pending' }])
|
||||
|
||||
expect(getTurnState().todos).toEqual([{ content: 'x', id: 'a', status: 'pending' }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
archiveDoneTodos,
|
||||
archiveTodosAtTurnEnd,
|
||||
getTurnState,
|
||||
patchTurnState,
|
||||
resetTurnState,
|
||||
toggleTodoCollapsed
|
||||
} from '../app/turnStore.js'
|
||||
|
||||
describe('turnStore live progress helpers', () => {
|
||||
beforeEach(() => resetTurnState())
|
||||
|
||||
it('archives completed todos into a transcript trail and clears the live anchor', () => {
|
||||
patchTurnState({
|
||||
todos: [
|
||||
{ content: 'prep', id: 'prep', status: 'completed' },
|
||||
{ content: 'serve', id: 'serve', status: 'completed' }
|
||||
]
|
||||
})
|
||||
|
||||
expect(archiveTodosAtTurnEnd()).toEqual([
|
||||
{
|
||||
kind: 'trail',
|
||||
role: 'system',
|
||||
text: '',
|
||||
todoCollapsedByDefault: true,
|
||||
todos: [
|
||||
{ content: 'prep', id: 'prep', status: 'completed' },
|
||||
{ content: 'serve', id: 'serve', status: 'completed' }
|
||||
]
|
||||
}
|
||||
])
|
||||
expect(getTurnState().todos).toEqual([])
|
||||
})
|
||||
|
||||
it('archives incomplete todos with an incomplete flag so the hint renders', () => {
|
||||
patchTurnState({
|
||||
todos: [
|
||||
{ content: 'cook', id: 'cook', status: 'completed' },
|
||||
{ content: 'serve', id: 'serve', status: 'in_progress' },
|
||||
{ content: 'eat', id: 'eat', status: 'pending' }
|
||||
]
|
||||
})
|
||||
|
||||
const archived = archiveTodosAtTurnEnd()
|
||||
expect(archived).toHaveLength(1)
|
||||
expect(archived[0]!.todoIncomplete).toBe(true)
|
||||
expect(archived[0]!.todos?.map(t => t.id)).toEqual(['cook', 'serve', 'eat'])
|
||||
expect(getTurnState().todos).toEqual([])
|
||||
})
|
||||
|
||||
it('returns nothing when there are no todos at turn end', () => {
|
||||
expect(archiveTodosAtTurnEnd()).toEqual([])
|
||||
expect(archiveDoneTodos()).toEqual([])
|
||||
})
|
||||
|
||||
it('tracks collapsed state independently of todo content', () => {
|
||||
toggleTodoCollapsed()
|
||||
expect(getTurnState().todoCollapsed).toBe(true)
|
||||
|
||||
toggleTodoCollapsed()
|
||||
expect(getTurnState().todoCollapsed).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { sessionCommands } from '../app/slash/commands/session.js'
|
||||
import type { SessionUsageResponse } from '../gatewayTypes.js'
|
||||
|
||||
const usageCommand = sessionCommands.find(cmd => cmd.name === 'usage')!
|
||||
|
||||
const USAGE_CTA = 'Run /subscription to change plan · /topup to add to your balance'
|
||||
|
||||
const guarded =
|
||||
<T>(fn: (r: T) => void) =>
|
||||
(r: null | T) => {
|
||||
if (r) {
|
||||
fn(r)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a ctx whose rpc routes by method name to a supplied map of results. */
|
||||
const buildCtx = (results: Record<string, unknown>) => {
|
||||
const sys = vi.fn()
|
||||
const panel = vi.fn()
|
||||
|
||||
const rpc = vi.fn((method: string, _params: unknown) => Promise.resolve(results[method]))
|
||||
|
||||
const ctx = {
|
||||
gateway: { rpc },
|
||||
guarded,
|
||||
guardedErr: vi.fn(),
|
||||
sid: 'sid-1',
|
||||
stale: () => false,
|
||||
transcript: { page: vi.fn(), panel, sys }
|
||||
}
|
||||
|
||||
const run = async (arg: string) => {
|
||||
usageCommand.run(arg, ctx as any, 'usage')
|
||||
await rpc.mock.results[0]?.value
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
return { ctx, panel, run, sys }
|
||||
}
|
||||
|
||||
const baseUsage = (overrides: Partial<SessionUsageResponse> = {}): SessionUsageResponse =>
|
||||
({ calls: 0, input: 0, output: 0, total: 0, ...overrides }) as SessionUsageResponse
|
||||
|
||||
const printed = (sys: ReturnType<typeof vi.fn>) => sys.mock.calls.map(c => c[0]).join('\n')
|
||||
|
||||
const balancePanel = (panel: ReturnType<typeof vi.fn>) => {
|
||||
const sections = panel.mock.calls.find(c => c[0] === 'Balance')?.[1] as { text?: string }[] | undefined
|
||||
|
||||
return (sections ?? []).map(s => s.text ?? '').join('\n')
|
||||
}
|
||||
|
||||
describe('/usage slash command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('always shows the CTA; "no API calls yet" only when there is no balance', async () => {
|
||||
const empty = buildCtx({ 'session.usage': baseUsage({ calls: 0, credits_lines: [] }) })
|
||||
await empty.run('')
|
||||
expect(printed(empty.sys)).toContain('no API calls yet')
|
||||
expect(printed(empty.sys)).toContain(USAGE_CTA)
|
||||
|
||||
const withBalance = buildCtx({ 'session.usage': baseUsage({ calls: 0, credits_lines: ['$50.00 remaining'] }) })
|
||||
await withBalance.run('')
|
||||
expect(printed(withBalance.sys)).not.toContain('no API calls yet')
|
||||
expect(printed(withBalance.sys)).toContain(USAGE_CTA)
|
||||
})
|
||||
|
||||
it('renders the dollar two-bar model (no "credits" wording) when available', async () => {
|
||||
const { panel, run } = buildCtx({
|
||||
'session.usage': baseUsage({
|
||||
usage: {
|
||||
available: true,
|
||||
status: 'healthy',
|
||||
plan_name: 'Plus',
|
||||
renews_display: 'Jul 1, 2026',
|
||||
total_spendable_display: '$26.00',
|
||||
has_topup: true,
|
||||
plan_bar: {
|
||||
kind: 'plan',
|
||||
remaining_display: '$14.00',
|
||||
total_display: '$20.00',
|
||||
spent_display: '$6.00',
|
||||
pct_used: 30,
|
||||
fill_fraction: 0.7
|
||||
},
|
||||
topup_bar: {
|
||||
kind: 'topup',
|
||||
remaining_display: '$12.00',
|
||||
total_display: '$12.00',
|
||||
spent_display: '$0.00',
|
||||
pct_used: null,
|
||||
fill_fraction: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await run('')
|
||||
|
||||
const body = balancePanel(panel)
|
||||
expect(body).toContain('Plus')
|
||||
expect(body).toContain('$14.00 left of $20.00')
|
||||
expect(body).toContain('30% used')
|
||||
expect(body).toContain('top-up')
|
||||
expect(body).toContain('$12.00')
|
||||
expect(body.toLowerCase()).not.toContain('credits')
|
||||
})
|
||||
|
||||
it('shows the free-models upsell for a free account', async () => {
|
||||
const { panel, run } = buildCtx({
|
||||
'session.usage': baseUsage({ usage: { available: true, status: 'free', plan_name: null } })
|
||||
})
|
||||
|
||||
await run('')
|
||||
|
||||
const body = balancePanel(panel)
|
||||
expect(body).toContain('free models only')
|
||||
expect(body).toContain('/subscription')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { toBatteryInfo } from '../app/useBatteryPoll.js'
|
||||
|
||||
describe('toBatteryInfo', () => {
|
||||
it('returns null for a null payload', () => {
|
||||
expect(toBatteryInfo(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('maps a full reading through faithfully', () => {
|
||||
expect(toBatteryInfo({ available: true, category: 'warn', percent: 44, plugged: false })).toEqual({
|
||||
available: true,
|
||||
category: 'warn',
|
||||
percent: 44,
|
||||
plugged: false
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps and rounds the percent into 0-100', () => {
|
||||
expect(toBatteryInfo({ available: true, category: 'good', percent: 142.7, plugged: true })?.percent).toBe(100)
|
||||
expect(toBatteryInfo({ available: true, category: 'critical', percent: -5, plugged: false })?.percent).toBe(0)
|
||||
expect(toBatteryInfo({ available: true, category: 'warn', percent: 43.4, plugged: false })?.percent).toBe(43)
|
||||
})
|
||||
|
||||
it('coerces a missing/invalid percent to null', () => {
|
||||
expect(toBatteryInfo({ available: true, category: 'dim' })?.percent).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the dim category for an unknown value', () => {
|
||||
expect(toBatteryInfo({ available: true, category: 'purple', percent: 50, plugged: false })?.category).toBe('dim')
|
||||
})
|
||||
|
||||
it('treats a non-boolean plugged as unknown (null)', () => {
|
||||
expect(toBatteryInfo({ available: false, category: 'dim', percent: null })?.plugged).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { completionRequestForInput } from '../hooks/useCompletion.js'
|
||||
|
||||
describe('completionRequestForInput', () => {
|
||||
it('routes real slash commands to slash completion', () => {
|
||||
expect(completionRequestForInput('/help')).toMatchObject({
|
||||
method: 'complete.slash',
|
||||
params: { text: '/help' },
|
||||
replaceFrom: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('does not route absolute paths through slash completion', () => {
|
||||
expect(
|
||||
completionRequestForInput('/home/d/Desktop/agenda/CrimsonRed/.hermes/plans/2026-05-04-HANDOFF-NEXT.md')
|
||||
).toMatchObject({
|
||||
method: 'complete.path',
|
||||
params: { word: '/home/d/Desktop/agenda/CrimsonRed/.hermes/plans/2026-05-04-HANDOFF-NEXT.md' },
|
||||
replaceFrom: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps path completion for trailing absolute path tokens', () => {
|
||||
expect(completionRequestForInput('read /home/d/Desktop/file.md')).toMatchObject({
|
||||
method: 'complete.path',
|
||||
params: { word: '/home/d/Desktop/file.md' },
|
||||
replaceFrom: 5
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves plain text alone', () => {
|
||||
expect(completionRequestForInput('hello there')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { looksLikeDroppedPath } from '../app/useComposerState.js'
|
||||
|
||||
describe('looksLikeDroppedPath', () => {
|
||||
it('recognizes macOS screenshot temp paths and file URIs', () => {
|
||||
expect(looksLikeDroppedPath('/var/folders/x/T/TemporaryItems/Screenshot\\ 2026-04-21\\ at\\ 1.04.43 PM.png')).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
looksLikeDroppedPath('file:///var/folders/x/T/TemporaryItems/Screenshot%202026-04-21%20at%201.04.43%20PM.png')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects normal multiline or plain text paste', () => {
|
||||
expect(looksLikeDroppedPath('hello world')).toBe(false)
|
||||
expect(looksLikeDroppedPath('line one\nline two')).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes common image file extensions', () => {
|
||||
expect(looksLikeDroppedPath('/Users/me/Desktop/photo.jpg')).toBe(true)
|
||||
expect(looksLikeDroppedPath('/Users/me/Desktop/diagram.png')).toBe(true)
|
||||
expect(looksLikeDroppedPath('/tmp/capture.webp')).toBe(true)
|
||||
expect(looksLikeDroppedPath('/tmp/image.gif')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes file:// URIs with various extensions', () => {
|
||||
expect(looksLikeDroppedPath('file:///home/user/doc.pdf')).toBe(true)
|
||||
expect(looksLikeDroppedPath('file:///tmp/screenshot.png')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes paths with spaces (not backslash-escaped)', () => {
|
||||
expect(looksLikeDroppedPath('/var/folders/x/T/TemporaryItems/Screenshot 2026-04-21 at 1.04.43 PM.png')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty/whitespace-only input', () => {
|
||||
expect(looksLikeDroppedPath('')).toBe(false)
|
||||
expect(looksLikeDroppedPath(' ')).toBe(false)
|
||||
expect(looksLikeDroppedPath('\n')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects URLs that are not file:// URIs', () => {
|
||||
expect(looksLikeDroppedPath('https://example.com/image.png')).toBe(false)
|
||||
expect(looksLikeDroppedPath('http://localhost/file.pdf')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects short slash-like strings without path structure', () => {
|
||||
// No second '/' or '.' → not a plausible file path
|
||||
expect(looksLikeDroppedPath('/help')).toBe(false)
|
||||
expect(looksLikeDroppedPath('/model sonnet')).toBe(false)
|
||||
expect(looksLikeDroppedPath('/api')).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts absolute paths with directory separators or extensions', () => {
|
||||
expect(looksLikeDroppedPath('/usr/bin/test')).toBe(true)
|
||||
expect(looksLikeDroppedPath('/tmp/file.txt')).toBe(true)
|
||||
expect(looksLikeDroppedPath('/etc/hosts')).toBe(true) // has second /
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,593 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $uiState, resetUiState } from '../app/uiStore.js'
|
||||
import {
|
||||
applyDisplay,
|
||||
hydrateFullConfig,
|
||||
type McpRevState,
|
||||
normalizeBusyInputMode,
|
||||
normalizeIndicatorStyle,
|
||||
normalizeMouseTracking,
|
||||
normalizeStatusBar,
|
||||
syncMcpReload
|
||||
} from '../app/useConfigSync.js'
|
||||
|
||||
describe('applyDisplay', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
})
|
||||
|
||||
it('fans every display flag out to $uiState and the bell callback', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay(
|
||||
{
|
||||
config: {
|
||||
display: {
|
||||
bell_on_complete: true,
|
||||
details_mode: 'expanded',
|
||||
inline_diffs: false,
|
||||
show_reasoning: true,
|
||||
streaming: false,
|
||||
tui_compact: true,
|
||||
tui_statusbar: false
|
||||
}
|
||||
}
|
||||
},
|
||||
setBell
|
||||
)
|
||||
|
||||
const s = $uiState.get()
|
||||
expect(setBell).toHaveBeenCalledWith(true)
|
||||
expect(s.compact).toBe(true)
|
||||
expect(s.detailsMode).toBe('expanded')
|
||||
expect(s.inlineDiffs).toBe(false)
|
||||
expect(s.showReasoning).toBe(true)
|
||||
expect(s.statusBar).toBe('off')
|
||||
expect(s.streaming).toBe(false)
|
||||
})
|
||||
|
||||
it('hydrates the destructive slash confirmation policy from approvals', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay(
|
||||
{
|
||||
config: {
|
||||
approvals: { destructive_slash_confirm: false },
|
||||
display: {}
|
||||
}
|
||||
},
|
||||
setBell
|
||||
)
|
||||
|
||||
expect($uiState.get().destructiveSlashConfirm).toBe(false)
|
||||
|
||||
applyDisplay(
|
||||
{
|
||||
config: {
|
||||
approvals: { destructive_slash_confirm: true },
|
||||
display: {}
|
||||
}
|
||||
},
|
||||
setBell
|
||||
)
|
||||
|
||||
expect($uiState.get().destructiveSlashConfirm).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults destructive slash confirmation on and preserves it across config RPC failure', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: {} } }, setBell)
|
||||
expect($uiState.get().destructiveSlashConfirm).toBe(true)
|
||||
|
||||
applyDisplay(
|
||||
{
|
||||
config: {
|
||||
approvals: { destructive_slash_confirm: false },
|
||||
display: {}
|
||||
}
|
||||
},
|
||||
setBell
|
||||
)
|
||||
applyDisplay(null, setBell)
|
||||
|
||||
expect($uiState.get().destructiveSlashConfirm).toBe(false)
|
||||
})
|
||||
|
||||
it('coerces legacy true + "on" alias to top', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: { tui_statusbar: true as unknown as 'on' } } }, setBell)
|
||||
expect($uiState.get().statusBar).toBe('top')
|
||||
|
||||
applyDisplay({ config: { display: { tui_statusbar: 'on' } } }, setBell)
|
||||
expect($uiState.get().statusBar).toBe('top')
|
||||
})
|
||||
|
||||
it('applies v1 parity defaults when display fields are missing', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: {} } }, setBell)
|
||||
|
||||
const s = $uiState.get()
|
||||
expect(setBell).toHaveBeenCalledWith(false)
|
||||
expect(s.inlineDiffs).toBe(true)
|
||||
expect(s.showReasoning).toBe(false)
|
||||
expect(s.statusBar).toBe('top')
|
||||
expect(s.streaming).toBe(true)
|
||||
expect(s.sections).toEqual({})
|
||||
})
|
||||
|
||||
it('uses documented mouse_tracking with legacy tui_mouse fallback', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: { mouse_tracking: false } } }, setBell)
|
||||
expect($uiState.get().mouseTracking).toBe('off')
|
||||
|
||||
applyDisplay({ config: { display: { mouse_tracking: true, tui_mouse: false } } }, setBell)
|
||||
expect($uiState.get().mouseTracking).toBe('all')
|
||||
|
||||
applyDisplay({ config: { display: { tui_mouse: false } } }, setBell)
|
||||
expect($uiState.get().mouseTracking).toBe('off')
|
||||
})
|
||||
|
||||
it('threads mouse_tracking presets through to $uiState', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: { mouse_tracking: 'wheel' } } }, setBell)
|
||||
expect($uiState.get().mouseTracking).toBe('wheel')
|
||||
|
||||
applyDisplay({ config: { display: { mouse_tracking: 'buttons' } } }, setBell)
|
||||
expect($uiState.get().mouseTracking).toBe('buttons')
|
||||
|
||||
applyDisplay({ config: { display: { mouse_tracking: 'all' } } }, setBell)
|
||||
expect($uiState.get().mouseTracking).toBe('all')
|
||||
})
|
||||
|
||||
it('parses display.sections into per-section overrides', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay(
|
||||
{
|
||||
config: {
|
||||
display: {
|
||||
details_mode: 'collapsed',
|
||||
sections: {
|
||||
activity: 'hidden',
|
||||
tools: 'expanded',
|
||||
thinking: 'expanded',
|
||||
bogus: 'expanded'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
setBell
|
||||
)
|
||||
|
||||
const s = $uiState.get()
|
||||
expect(s.detailsMode).toBe('collapsed')
|
||||
expect(s.sections).toEqual({
|
||||
activity: 'hidden',
|
||||
tools: 'expanded',
|
||||
thinking: 'expanded'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops invalid section modes', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay(
|
||||
{
|
||||
config: {
|
||||
display: {
|
||||
sections: { tools: 'maximised' as unknown as string, activity: 'hidden' }
|
||||
}
|
||||
}
|
||||
},
|
||||
setBell
|
||||
)
|
||||
|
||||
expect($uiState.get().sections).toEqual({ activity: 'hidden' })
|
||||
})
|
||||
|
||||
it('treats a null config like an empty display block', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay(null, setBell)
|
||||
|
||||
const s = $uiState.get()
|
||||
expect(setBell).toHaveBeenCalledWith(false)
|
||||
expect(s.inlineDiffs).toBe(true)
|
||||
expect(s.streaming).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts the new string statusBar modes', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: { tui_statusbar: 'bottom' } } }, setBell)
|
||||
expect($uiState.get().statusBar).toBe('bottom')
|
||||
|
||||
applyDisplay({ config: { display: { tui_statusbar: 'top' } } }, setBell)
|
||||
expect($uiState.get().statusBar).toBe('top')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeStatusBar', () => {
|
||||
it('maps legacy bool + on alias to top/off', () => {
|
||||
expect(normalizeStatusBar(true)).toBe('top')
|
||||
expect(normalizeStatusBar(false)).toBe('off')
|
||||
expect(normalizeStatusBar('on')).toBe('top')
|
||||
})
|
||||
|
||||
it('passes through the canonical enum', () => {
|
||||
expect(normalizeStatusBar('off')).toBe('off')
|
||||
expect(normalizeStatusBar('top')).toBe('top')
|
||||
expect(normalizeStatusBar('bottom')).toBe('bottom')
|
||||
})
|
||||
|
||||
it('defaults missing/unknown values to top', () => {
|
||||
expect(normalizeStatusBar(undefined)).toBe('top')
|
||||
expect(normalizeStatusBar(null)).toBe('top')
|
||||
expect(normalizeStatusBar('sideways')).toBe('top')
|
||||
expect(normalizeStatusBar(42)).toBe('top')
|
||||
})
|
||||
|
||||
it('trims whitespace and folds case', () => {
|
||||
expect(normalizeStatusBar(' Bottom ')).toBe('bottom')
|
||||
expect(normalizeStatusBar('TOP')).toBe('top')
|
||||
expect(normalizeStatusBar(' on ')).toBe('top')
|
||||
expect(normalizeStatusBar('OFF')).toBe('off')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeMouseTracking', () => {
|
||||
it('defaults to all and prefers canonical mouse_tracking over legacy tui_mouse', () => {
|
||||
expect(normalizeMouseTracking({})).toBe('all')
|
||||
expect(normalizeMouseTracking({ mouse_tracking: false })).toBe('off')
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 0 })).toBe('off')
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'off' })).toBe('off')
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'false' })).toBe('off')
|
||||
expect(normalizeMouseTracking({ mouse_tracking: null, tui_mouse: false })).toBe('all')
|
||||
expect(normalizeMouseTracking({ mouse_tracking: true, tui_mouse: false })).toBe('all')
|
||||
expect(normalizeMouseTracking({ tui_mouse: false })).toBe('off')
|
||||
})
|
||||
|
||||
it('accepts preset strings (wheel/buttons/all) and their aliases', () => {
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'wheel' })).toBe('wheel')
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'scroll' })).toBe('wheel')
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'buttons' })).toBe('buttons')
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'click' })).toBe('buttons')
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'all' })).toBe('all')
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'full' })).toBe('all')
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'on' })).toBe('all')
|
||||
expect(normalizeMouseTracking({ mouse_tracking: ' WHEEL ' })).toBe('wheel')
|
||||
})
|
||||
|
||||
it('falls back to all for unknown strings', () => {
|
||||
expect(normalizeMouseTracking({ mouse_tracking: 'rainbows' })).toBe('all')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeBusyInputMode', () => {
|
||||
it('passes through the canonical CLI parity values', () => {
|
||||
expect(normalizeBusyInputMode('queue')).toBe('queue')
|
||||
expect(normalizeBusyInputMode('steer')).toBe('steer')
|
||||
expect(normalizeBusyInputMode('interrupt')).toBe('interrupt')
|
||||
})
|
||||
|
||||
it('trims and lowercases input', () => {
|
||||
expect(normalizeBusyInputMode(' Queue ')).toBe('queue')
|
||||
expect(normalizeBusyInputMode('STEER')).toBe('steer')
|
||||
})
|
||||
|
||||
it('defaults to queue for missing/unknown values (TUI-only override)', () => {
|
||||
// CLI / messaging adapters keep `interrupt` as the framework default
|
||||
// (see hermes_cli/config.py + tui_gateway/server.py::_load_busy_input_mode);
|
||||
// the TUI ships `queue` because typing a follow-up while the agent
|
||||
// streams is the common authoring pattern and an unintended interrupt
|
||||
// loses work.
|
||||
expect(normalizeBusyInputMode(undefined)).toBe('queue')
|
||||
expect(normalizeBusyInputMode(null)).toBe('queue')
|
||||
expect(normalizeBusyInputMode('')).toBe('queue')
|
||||
expect(normalizeBusyInputMode('drop')).toBe('queue')
|
||||
expect(normalizeBusyInputMode(42)).toBe('queue')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeIndicatorStyle', () => {
|
||||
it('passes through the canonical enum', () => {
|
||||
expect(normalizeIndicatorStyle('kaomoji')).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle('emoji')).toBe('emoji')
|
||||
expect(normalizeIndicatorStyle('unicode')).toBe('unicode')
|
||||
expect(normalizeIndicatorStyle('ascii')).toBe('ascii')
|
||||
})
|
||||
|
||||
it('trims and lowercases input', () => {
|
||||
expect(normalizeIndicatorStyle(' Emoji ')).toBe('emoji')
|
||||
expect(normalizeIndicatorStyle('UNICODE')).toBe('unicode')
|
||||
})
|
||||
|
||||
it('defaults to kaomoji for missing/unknown values', () => {
|
||||
expect(normalizeIndicatorStyle(undefined)).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle(null)).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle('')).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle('sparkle')).toBe('kaomoji')
|
||||
expect(normalizeIndicatorStyle(42)).toBe('kaomoji')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyDisplay → busy_input_mode', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
})
|
||||
|
||||
it('threads display.busy_input_mode into $uiState', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: { busy_input_mode: 'queue' } } }, setBell)
|
||||
expect($uiState.get().busyInputMode).toBe('queue')
|
||||
|
||||
applyDisplay({ config: { display: { busy_input_mode: 'steer' } } }, setBell)
|
||||
expect($uiState.get().busyInputMode).toBe('steer')
|
||||
})
|
||||
|
||||
it('falls back to queue when value is missing or invalid (TUI-only default)', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: {} } }, setBell)
|
||||
expect($uiState.get().busyInputMode).toBe('queue')
|
||||
|
||||
applyDisplay({ config: { display: { busy_input_mode: 'drop' } } }, setBell)
|
||||
expect($uiState.get().busyInputMode).toBe('queue')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyDisplay → tui_status_indicator', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
})
|
||||
|
||||
it('threads display.tui_status_indicator into $uiState', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: { tui_status_indicator: 'emoji' } } }, setBell)
|
||||
expect($uiState.get().indicatorStyle).toBe('emoji')
|
||||
|
||||
applyDisplay({ config: { display: { tui_status_indicator: 'unicode' } } }, setBell)
|
||||
expect($uiState.get().indicatorStyle).toBe('unicode')
|
||||
})
|
||||
|
||||
it('falls back to kaomoji default when missing or invalid', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: {} } }, setBell)
|
||||
expect($uiState.get().indicatorStyle).toBe('kaomoji')
|
||||
|
||||
applyDisplay({ config: { display: { tui_status_indicator: 'rainbow' } } }, setBell)
|
||||
expect($uiState.get().indicatorStyle).toBe('kaomoji')
|
||||
})
|
||||
})
|
||||
|
||||
// Regressions from Copilot review on #19835: the config-hydration path
|
||||
// for voice.record_key was untested, so a future regression in the
|
||||
// hydration or mtime-reapply wiring would slip past the suite.
|
||||
describe('applyDisplay → voice.record_key (#18994)', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
})
|
||||
|
||||
it('parses voice.record_key and pushes it through the setter', () => {
|
||||
const setBell = vi.fn()
|
||||
const setVoiceRecordKey = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: {}, voice: { record_key: 'ctrl+space' } } }, setBell, setVoiceRecordKey)
|
||||
|
||||
expect(setVoiceRecordKey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ch: 'space', mod: 'ctrl', named: 'space', raw: 'ctrl+space' })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the documented default when voice.record_key is missing', () => {
|
||||
const setBell = vi.fn()
|
||||
const setVoiceRecordKey = vi.fn()
|
||||
|
||||
applyDisplay({ config: { display: {} } }, setBell, setVoiceRecordKey)
|
||||
|
||||
expect(setVoiceRecordKey).toHaveBeenCalledWith(expect.objectContaining({ ch: 'b', mod: 'ctrl', raw: 'ctrl+b' }))
|
||||
})
|
||||
|
||||
it('is a no-op when the voice setter is not passed (back-compat)', () => {
|
||||
const setBell = vi.fn()
|
||||
|
||||
// applyDisplay is used in the setVoiceEnabled-less init path too;
|
||||
// omitting the third arg must not throw.
|
||||
expect(() => applyDisplay({ config: { display: {}, voice: { record_key: 'alt+r' } } }, setBell)).not.toThrow()
|
||||
})
|
||||
|
||||
it('does not reset voiceRecordKey when cfg is null (transient RPC failure)', () => {
|
||||
const setBell = vi.fn()
|
||||
const setVoiceRecordKey = vi.fn()
|
||||
|
||||
// quietRpc() collapses request failures to null. Resetting the
|
||||
// cached shortcut on every null would clobber a custom binding
|
||||
// after one transient error until the next successful poll
|
||||
// (Copilot round-8 review on #19835).
|
||||
applyDisplay(null, setBell, setVoiceRecordKey)
|
||||
|
||||
expect(setVoiceRecordKey).not.toHaveBeenCalled()
|
||||
// bell is still applied (defaults to false on null), so the setter
|
||||
// runs — we specifically only skip voiceRecordKey.
|
||||
expect(setBell).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Review on #20379 (finding 1): an MCP config revision must never be acked
|
||||
// before the server confirms it was LOADED. The old poll advanced its
|
||||
// accepted revision first and fired reload.mcp second — a reload that failed
|
||||
// (quietRpc → null) left the revision recorded as applied, and no subsequent
|
||||
// poll retried it until an unrelated MCP edit.
|
||||
describe('syncMcpReload (revision-aware ack)', () => {
|
||||
const gwOk = (payload: unknown) =>
|
||||
({ request: vi.fn(() => Promise.resolve(payload)), on: vi.fn(), off: vi.fn() }) as any
|
||||
|
||||
const freshState = (accepted = 'rev-a'): McpRevState => ({ accepted, inFlight: false })
|
||||
|
||||
it('advances accepted only after the server confirms the reload', async () => {
|
||||
const gw = gwOk({ status: 'reloaded', loaded_rev: 'rev-b' })
|
||||
const state = freshState()
|
||||
const onReloaded = vi.fn()
|
||||
|
||||
await syncMcpReload(gw, 's1', 'rev-b', state, onReloaded)
|
||||
|
||||
expect(gw.request).toHaveBeenCalledWith('reload.mcp', { confirm: true, rev: 'rev-b', session_id: 's1' })
|
||||
expect(state.accepted).toBe('rev-b')
|
||||
expect(onReloaded).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does NOT advance accepted when the reload RPC fails — next poll retries', async () => {
|
||||
const gw = { request: vi.fn(() => Promise.reject(new Error('flapping server'))), on: vi.fn(), off: vi.fn() } as any
|
||||
const state = freshState()
|
||||
const onReloaded = vi.fn()
|
||||
|
||||
await syncMcpReload(gw, 's1', 'rev-b', state, onReloaded)
|
||||
|
||||
// The exact failure sequence from the review: reload fails, revision
|
||||
// must remain un-acked so the next tick retries it.
|
||||
expect(state.accepted).toBe('rev-a')
|
||||
expect(state.inFlight).toBe(false)
|
||||
expect(onReloaded).not.toHaveBeenCalled()
|
||||
|
||||
// Next poll tick: the server recovered — the SAME revision goes through.
|
||||
gw.request = vi.fn(() => Promise.resolve({ status: 'reloaded', loaded_rev: 'rev-b' }))
|
||||
await syncMcpReload(gw, 's1', 'rev-b', state, onReloaded)
|
||||
expect(state.accepted).toBe('rev-b')
|
||||
expect(onReloaded).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not advance on confirm_required (reload did not happen)', async () => {
|
||||
const gw = gwOk({ message: 'confirm first', status: 'confirm_required' })
|
||||
const state = freshState()
|
||||
|
||||
await syncMcpReload(gw, 's1', 'rev-b', state)
|
||||
|
||||
expect(state.accepted).toBe('rev-a')
|
||||
})
|
||||
|
||||
it('records the server-reported loaded_rev, not the requested rev', async () => {
|
||||
// A config edit raced the reload: the server re-hashed after discovery
|
||||
// and loaded rev-c. Recording rev-c (not rev-b) makes the next poll a
|
||||
// no-op instead of an immediate redundant reload.
|
||||
const gw = gwOk({ status: 'reloaded', loaded_rev: 'rev-c' })
|
||||
const state = freshState()
|
||||
|
||||
await syncMcpReload(gw, 's1', 'rev-b', state)
|
||||
|
||||
expect(state.accepted).toBe('rev-c')
|
||||
})
|
||||
|
||||
it('is a no-op when the revision is already accepted or empty', async () => {
|
||||
const gw = gwOk({ status: 'reloaded' })
|
||||
const state = freshState()
|
||||
|
||||
await syncMcpReload(gw, 's1', 'rev-a', state)
|
||||
await syncMcpReload(gw, 's1', '', state)
|
||||
|
||||
expect(gw.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not stack requests while one is in flight', async () => {
|
||||
let resolveFirst!: (v: unknown) => void
|
||||
|
||||
const gw = {
|
||||
request: vi.fn(() => new Promise(res => (resolveFirst = res))),
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
} as any
|
||||
|
||||
const state = freshState()
|
||||
|
||||
const first = syncMcpReload(gw, 's1', 'rev-b', state)
|
||||
|
||||
// Second tick while the first RPC is outstanding: swallowed.
|
||||
await syncMcpReload(gw, 's1', 'rev-b', state)
|
||||
expect(gw.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveFirst({ status: 'reloaded', loaded_rev: 'rev-b' })
|
||||
await first
|
||||
expect(state.accepted).toBe('rev-b')
|
||||
})
|
||||
})
|
||||
|
||||
// Round-12 Copilot review regression on #19835: the live mtime-reload
|
||||
// path was previously untested, so a regression in the polling/RPC
|
||||
// wiring to applyDisplay would only be visible at runtime. The fetch
|
||||
// + apply body is now shared as ``hydrateFullConfig()``, exercised
|
||||
// directly from both the initial hydration and the poll-tick body.
|
||||
describe('hydrateFullConfig', () => {
|
||||
beforeEach(() => {
|
||||
resetUiState()
|
||||
})
|
||||
|
||||
const makeFakeGw = (payload: unknown) =>
|
||||
({
|
||||
request: vi.fn(() => Promise.resolve(payload)),
|
||||
on: vi.fn(),
|
||||
off: vi.fn()
|
||||
}) as any
|
||||
|
||||
it('re-applies voice.record_key from a fresh config.get full response', async () => {
|
||||
const gw = makeFakeGw({ config: { display: {}, voice: { record_key: 'ctrl+o' } } })
|
||||
const setBell = vi.fn()
|
||||
const setVoiceRecordKey = vi.fn()
|
||||
|
||||
await hydrateFullConfig(gw, setBell, setVoiceRecordKey)
|
||||
|
||||
expect(gw.request).toHaveBeenCalledWith('config.get', { key: 'full' })
|
||||
expect(setVoiceRecordKey).toHaveBeenCalledWith(expect.objectContaining({ ch: 'o', mod: 'ctrl', raw: 'ctrl+o' }))
|
||||
expect(setBell).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('reapplies the latest value on each invocation (mtime-reload semantics)', async () => {
|
||||
const gw = makeFakeGw({ config: { display: {}, voice: { record_key: 'ctrl+b' } } })
|
||||
const setBell = vi.fn()
|
||||
const setVoiceRecordKey = vi.fn()
|
||||
|
||||
await hydrateFullConfig(gw, setBell, setVoiceRecordKey)
|
||||
expect(setVoiceRecordKey).toHaveBeenLastCalledWith(expect.objectContaining({ ch: 'b' }))
|
||||
|
||||
// Simulate a config edit: gw now returns a new shortcut.
|
||||
gw.request = vi.fn(() => Promise.resolve({ config: { display: {}, voice: { record_key: 'alt+space' } } }))
|
||||
|
||||
await hydrateFullConfig(gw, setBell, setVoiceRecordKey)
|
||||
expect(setVoiceRecordKey).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ ch: 'space', mod: 'alt', named: 'space' })
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves cached voiceRecordKey untouched when the RPC fails', async () => {
|
||||
const gw = { request: vi.fn(() => Promise.reject(new Error('boom'))), on: vi.fn(), off: vi.fn() } as any
|
||||
const setBell = vi.fn()
|
||||
const setVoiceRecordKey = vi.fn()
|
||||
|
||||
const result = await hydrateFullConfig(gw, setBell, setVoiceRecordKey)
|
||||
|
||||
// quietRpc() swallows the error and returns null; applyDisplay
|
||||
// sees cfg=null and skips the voice setter (Copilot round-8).
|
||||
expect(result).toBeNull()
|
||||
expect(setVoiceRecordKey).not.toHaveBeenCalled()
|
||||
// bell setter still fires — applyDisplay's null-cfg path applies
|
||||
// the documented bell default (false).
|
||||
expect(setBell).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('threads through without a voice setter (back-compat call sites)', async () => {
|
||||
const gw = makeFakeGw({ config: { display: { bell_on_complete: true } } })
|
||||
const setBell = vi.fn()
|
||||
|
||||
// No third arg — applyDisplay must not throw and must still apply
|
||||
// display flags (round-2 / round-8 invariant).
|
||||
await expect(hydrateFullConfig(gw, setBell)).resolves.toBeTruthy()
|
||||
expect(setBell).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js'
|
||||
import {
|
||||
applyVoiceRecordResponse,
|
||||
dismissSensitivePrompt,
|
||||
handleIdleHotkeyExit,
|
||||
resolveCtrlCComposerAction,
|
||||
shouldAllowIdleHotkeyExit,
|
||||
shouldDetachEditedHistoryInput,
|
||||
shouldFallThroughForScroll
|
||||
} from '../app/useInputHandlers.js'
|
||||
|
||||
const baseKey = {
|
||||
downArrow: false,
|
||||
pageDown: false,
|
||||
pageUp: false,
|
||||
shift: false,
|
||||
upArrow: false,
|
||||
wheelDown: false,
|
||||
wheelUp: false
|
||||
}
|
||||
|
||||
describe('shouldFallThroughForScroll — keep transcript scrolling alive during prompt overlays', () => {
|
||||
it('falls through for wheel scrolls', () => {
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, wheelUp: true })).toBe(true)
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, wheelDown: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('falls through for PageUp / PageDown', () => {
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, pageUp: true })).toBe(true)
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, pageDown: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('falls through for Shift+ArrowUp / Shift+ArrowDown', () => {
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, shift: true, upArrow: true })).toBe(true)
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, shift: true, downArrow: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT fall through for plain arrows — those drive in-prompt selection', () => {
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, upArrow: true })).toBe(false)
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, downArrow: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT fall through for plain Shift — without an arrow it is a no-op', () => {
|
||||
expect(shouldFallThroughForScroll({ ...baseKey, shift: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('does NOT fall through for unrelated state (no scroll keys held)', () => {
|
||||
expect(shouldFallThroughForScroll(baseKey)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldAllowIdleHotkeyExit', () => {
|
||||
it('keeps idle exit hotkeys enabled in normal terminals', () => {
|
||||
expect(shouldAllowIdleHotkeyExit(false)).toBe(true)
|
||||
})
|
||||
|
||||
it('disables idle exit hotkeys in dashboard chat', () => {
|
||||
expect(shouldAllowIdleHotkeyExit(true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldDetachEditedHistoryInput', () => {
|
||||
const history = ['older message', 'line one\nline two']
|
||||
|
||||
it('detaches a recalled entry as soon as the user edits it', () => {
|
||||
expect(shouldDetachEditedHistoryInput(1, history, 'line one edited\nline two')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps unchanged recalled entries in history navigation', () => {
|
||||
expect(shouldDetachEditedHistoryInput(1, history, 'line one\nline two')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not detach an ordinary current draft', () => {
|
||||
expect(shouldDetachEditedHistoryInput(null, history, 'new draft')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCtrlCComposerAction — draft wins over interrupt', () => {
|
||||
it('clears a non-empty composer even while the agent is streaming', () => {
|
||||
expect(resolveCtrlCComposerAction({ busy: true, hasDraft: true, hasSession: true })).toBe('clear')
|
||||
})
|
||||
|
||||
it('interrupts a running turn when the composer is empty', () => {
|
||||
expect(resolveCtrlCComposerAction({ busy: true, hasDraft: false, hasSession: true })).toBe('interrupt')
|
||||
})
|
||||
|
||||
it('clears an idle composer instead of exiting', () => {
|
||||
expect(resolveCtrlCComposerAction({ busy: false, hasDraft: true, hasSession: true })).toBe('clear')
|
||||
})
|
||||
|
||||
it('exits when idle with an empty composer', () => {
|
||||
expect(resolveCtrlCComposerAction({ busy: false, hasDraft: false, hasSession: true })).toBe('exit')
|
||||
})
|
||||
|
||||
it('does not interrupt a busy session that has no sid yet', () => {
|
||||
expect(resolveCtrlCComposerAction({ busy: true, hasDraft: false, hasSession: false })).toBe('exit')
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleIdleHotkeyExit', () => {
|
||||
it('exits in normal terminals', () => {
|
||||
const actions = { die: vi.fn(), sys: vi.fn() }
|
||||
|
||||
handleIdleHotkeyExit(actions, false)
|
||||
|
||||
expect(actions.die).toHaveBeenCalledTimes(1)
|
||||
expect(actions.sys).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('asks the dashboard for a fresh chat instead of leaving a ghost session', () => {
|
||||
const actions = { die: vi.fn(), sys: vi.fn() }
|
||||
const requestDashboardNewSession = vi.fn()
|
||||
|
||||
handleIdleHotkeyExit(actions, true, requestDashboardNewSession)
|
||||
|
||||
expect(actions.die).not.toHaveBeenCalled()
|
||||
expect(requestDashboardNewSession).toHaveBeenCalledTimes(1)
|
||||
expect(actions.sys).toHaveBeenCalledWith('starting a fresh dashboard chat...')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyVoiceRecordResponse', () => {
|
||||
it('reverts optimistic REC state when the gateway reports voice busy', () => {
|
||||
const setProcessing = vi.fn()
|
||||
const setRecording = vi.fn()
|
||||
const sys = vi.fn()
|
||||
|
||||
applyVoiceRecordResponse({ status: 'busy' }, true, { setProcessing, setRecording }, sys)
|
||||
|
||||
expect(setRecording).toHaveBeenCalledWith(false)
|
||||
expect(setProcessing).toHaveBeenCalledWith(true)
|
||||
expect(sys).toHaveBeenCalledWith('voice: still transcribing; try again shortly')
|
||||
})
|
||||
|
||||
it('keeps optimistic REC state for successful recording starts', () => {
|
||||
const setProcessing = vi.fn()
|
||||
const setRecording = vi.fn()
|
||||
|
||||
applyVoiceRecordResponse({ status: 'recording' }, true, { setProcessing, setRecording }, vi.fn())
|
||||
|
||||
expect(setRecording).not.toHaveBeenCalled()
|
||||
expect(setProcessing).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reverts optimistic REC state when the gateway returns null', () => {
|
||||
const setProcessing = vi.fn()
|
||||
const setRecording = vi.fn()
|
||||
|
||||
applyVoiceRecordResponse(null, true, { setProcessing, setRecording }, vi.fn())
|
||||
|
||||
expect(setRecording).toHaveBeenCalledWith(false)
|
||||
expect(setProcessing).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dismissSensitivePrompt', () => {
|
||||
it('clears a sudo overlay before a stale cancel RPC resolves', async () => {
|
||||
resetOverlayState()
|
||||
patchOverlayState({ sudo: { requestId: 'sudo-1' } })
|
||||
const rpc = vi.fn().mockResolvedValue(null)
|
||||
const sys = vi.fn()
|
||||
|
||||
const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys)
|
||||
|
||||
expect(getOverlayState().sudo).toBeNull()
|
||||
expect(sys).toHaveBeenCalledWith('sudo cancelled')
|
||||
expect(rpc).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' })
|
||||
await pending
|
||||
})
|
||||
|
||||
it('clears a secret overlay before a stale cancel RPC resolves', async () => {
|
||||
resetOverlayState()
|
||||
patchOverlayState({ secret: { envVar: 'API_KEY', prompt: 'Enter API key', requestId: 'secret-1' } })
|
||||
const rpc = vi.fn().mockResolvedValue(null)
|
||||
const sys = vi.fn()
|
||||
|
||||
const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys)
|
||||
|
||||
expect(getOverlayState().secret).toBeNull()
|
||||
expect(sys).toHaveBeenCalledWith('secret entry cancelled')
|
||||
expect(rpc).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' })
|
||||
await pending
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user