test: restore TUI status-rule clock and title guards dropped by #120071
- appChromeBlockedTimers: 'status-chrome timers track the current overlay model' (#12463). Each overlay state is mounted on the real StatusRule: the in-flow prompts (approval, billing, clarify, confirm, secret, subscription, sudo), agents/journey and the ambient dock keep the 1s clocks running, and the occluding set pauses them. Gating clarify/ambient in $isStatusRuleOccluded turns it red. - appChromeBlockedTimers: the existing 're-syncs the elapsed read-outs' test flaked under load (a fixed 20ms flush); it now polls for the reveal frame. - appChromeStatusRule: session title renders without a raw accent background fill (#82465 contrast bug). - appChromeStatusRule: idle-since read-out is shown when idle, hidden while busy and before the first turn completes.
This commit is contained in:
@@ -304,7 +304,9 @@ describe('status-chrome timers under an occluding overlay', () => {
|
||||
nowSpy.mockReturnValue(T0 + 300_000)
|
||||
rule.clear()
|
||||
resetOverlayState()
|
||||
await flush()
|
||||
// Poll for the reveal frame instead of a fixed tick: under CI load the
|
||||
// store-driven re-render can land well after one 20ms scheduler turn.
|
||||
await vi.waitFor(() => expect(rule.output()).toContain('6m 0s'), { interval: 10, timeout: 5_000 })
|
||||
|
||||
const resumed = rule.output()
|
||||
|
||||
@@ -344,6 +346,65 @@ describe('status-chrome timers under an occluding overlay', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// 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
|
||||
|
||||
@@ -54,6 +54,40 @@ const findClickableWithText = (node: ReactNodeLike, needle: string): React.React
|
||||
return findClickableWithText(node.props.children, needle)
|
||||
}
|
||||
|
||||
// Find the innermost element whose own (direct) text content includes the
|
||||
// needle. Used to assert the colour the session title 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,
|
||||
@@ -113,9 +147,19 @@ describe('StatusRule session title', () => {
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -312,6 +356,75 @@ describe('StatusRule battery indicator', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user