mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
test: remove low-value suites and copied test oracles (#33708)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -1155,7 +1155,6 @@
|
||||
2 test-no-wall-clock:src/shared/fish-query-reply-child-stdin.node-pty.test.ts
|
||||
1 test-no-wall-clock:src/shared/git-binary-compatibility.test.ts
|
||||
1 test-no-wall-clock:src/shared/mobile-push-contract.test.ts
|
||||
2 test-no-wall-clock:src/shared/native-chat-ask-fifo.test.ts
|
||||
1 test-no-wall-clock:src/shared/plugins/plugin-kill-list.test.ts
|
||||
2 test-no-wall-clock:src/shared/pty-reply-echo-shapes.node-pty.test.ts
|
||||
5 test-no-wall-clock:src/shared/sidebar-list/smart-sort.test.ts
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('parseAgentSessionFileCached', () => {
|
||||
expect(incremental?.totalTokens).toBe(420)
|
||||
})
|
||||
|
||||
it('parses an oversized record without quadratic carry copying', async () => {
|
||||
it('parses a multi-chunk record without quadratic carry copying', async () => {
|
||||
const root = await makeTempDir()
|
||||
const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl')
|
||||
// Span many chunks to expose quadratic copying from repeatedly joining the carried record prefix.
|
||||
@@ -163,7 +163,12 @@ describe('parseAgentSessionFileCached', () => {
|
||||
process.platform,
|
||||
stats
|
||||
)
|
||||
expect(parsed).not.toBeNull()
|
||||
expect(parsed?.messageCount).toBe(3)
|
||||
expect(parsed?.previewMessages.map((message) => message.text)).toEqual([
|
||||
'question',
|
||||
expect.stringMatching(/^x+\.\.\.$/),
|
||||
'tail answer'
|
||||
])
|
||||
} finally {
|
||||
Buffer.concat = originalConcat
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createAccumulator, finalizeSession } from './session-scanner-accumulator'
|
||||
import {
|
||||
cloneSessionAccumulator,
|
||||
createAccumulator,
|
||||
finalizeSession
|
||||
} from './session-scanner-accumulator'
|
||||
import { addPreviewMessage } from './session-scanner-messages'
|
||||
import type { SessionAccumulator } from './session-scanner-types'
|
||||
|
||||
@@ -36,6 +40,8 @@ describe('AI Vault preview-window truncation signal', () => {
|
||||
it('carries the flag onto snapshots taken from a cloned accumulator', () => {
|
||||
const accumulator = accumulatorWithUserTurns(PREVIEW_LIMIT + 1)
|
||||
|
||||
expect(finalizeSession({ ...accumulator }, 'darwin')?.previewMessagesTruncated).toBe(true)
|
||||
expect(
|
||||
finalizeSession(cloneSessionAccumulator(accumulator), 'darwin')?.previewMessagesTruncated
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -397,7 +397,7 @@ describe('AiVaultScannerServiceClient', () => {
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('faults the child on malformed output without affecting later processes', async () => {
|
||||
it('rejects the active request and kills the child on malformed output', async () => {
|
||||
const { child, client } = setup()
|
||||
const request = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
readyAiVaultServiceChild(child)
|
||||
@@ -440,6 +440,7 @@ describe('AiVaultScannerServiceClient', () => {
|
||||
})
|
||||
|
||||
it('re-reads the init frame on every spawn so a respawn sees current consent', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
let enabled = false
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
@@ -467,7 +468,10 @@ describe('AiVaultScannerServiceClient', () => {
|
||||
children[0]!.emit('error', new Error('crashed'))
|
||||
await expect(first).rejects.toThrow('crashed')
|
||||
void client.request({ type: 'request', operation: 'titles', requests: [] }).catch(() => {})
|
||||
await vi.waitFor(() => expect(children.length).toBeGreaterThan(1))
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(children).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(children).toHaveLength(2)
|
||||
for (const respawned of children.slice(1)) {
|
||||
expect(respawned.sent[0]).toMatchObject({ sessionSearch: { settings: { enabled: true } } })
|
||||
}
|
||||
|
||||
@@ -79,10 +79,6 @@ describe('buildAiVaultServiceEnv', () => {
|
||||
expect(env.OPENCODE_DB).toBe('opencode-alt.db')
|
||||
})
|
||||
|
||||
it('runs the forked Electron binary as plain Node', () => {
|
||||
expect(buildAiVaultServiceEnv({}, 'linux').ELECTRON_RUN_AS_NODE).toBe('1')
|
||||
})
|
||||
|
||||
it('ignores a POSIX variable that only matches an allowed name by case', () => {
|
||||
expect(buildAiVaultServiceEnv({ codex_home: '/tmp/spoof' }, 'linux')).toEqual({
|
||||
ELECTRON_RUN_AS_NODE: '1'
|
||||
@@ -145,10 +141,6 @@ describe('buildRelayAiVaultServiceEnv', () => {
|
||||
expect(env.CODEX_HOME).toBeUndefined()
|
||||
expect(env.PATH).toBe('/usr/bin')
|
||||
})
|
||||
|
||||
it('stays plain Node rather than an Electron child', () => {
|
||||
expect(buildRelayAiVaultServiceEnv({}, 'linux').ELECTRON_RUN_AS_NODE).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('carries OMP root/profile inputs only to the desktop service, retaining empty canonical profile', () => {
|
||||
|
||||
@@ -96,16 +96,3 @@ it('refreshes a late root without rebuilding the index', async () => {
|
||||
await indexedSessions('conversation', ['early-session', 'late-session'])
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the live indexer when an unchanged root snapshot is refreshed', async () => {
|
||||
await writeMessageGraphTranscript(openclawTranscript(spawnRoot, 'early-session'), [
|
||||
'a conversation in a root the spawn already knew'
|
||||
])
|
||||
subject.apply(init(harness.roots))
|
||||
await indexedSessions('conversation', ['early-session'])
|
||||
|
||||
const close = vi.spyOn(SessionSearchIndexer.prototype, 'close')
|
||||
currentRoots = { ...spawnRoots }
|
||||
await indexedSessions('conversation', ['early-session'])
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -110,12 +110,4 @@ describe('attachAiVaultScopeCwdAliases', () => {
|
||||
|
||||
expect(listed.sessions).toEqual(rows)
|
||||
})
|
||||
|
||||
it('returns the scan result unchanged when no scope path is a link', async () => {
|
||||
const scanned = result([session('real', realRepo)])
|
||||
|
||||
expect(
|
||||
attachAiVaultScopeCwdAliases(scanned, await resolveAiVaultScopeRealPaths([realRepo]))
|
||||
).toBe(scanned)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,9 @@ const OBSERVED: Named[] = [
|
||||
["'none'", 'none'],
|
||||
["'unknown'", 'unknown'],
|
||||
['same object', { ...META }],
|
||||
['different object', { ...OTHER }]
|
||||
['different object', { ...OTHER }],
|
||||
['different path', { ...META, path: '/chats/b/meta.json' }],
|
||||
['different size', { ...META, sizeBytes: 21 }]
|
||||
]
|
||||
|
||||
// entry (row) x observed (column). A `true` cell is a cache hit.
|
||||
@@ -27,28 +29,36 @@ const TRUTH_TABLE: Record<string, Record<string, boolean>> = {
|
||||
"'none'": true,
|
||||
"'unknown'": false,
|
||||
'same object': false,
|
||||
'different object': false
|
||||
'different object': false,
|
||||
'different path': false,
|
||||
'different size': false
|
||||
},
|
||||
"'none'": {
|
||||
undefined: true,
|
||||
"'none'": true,
|
||||
"'unknown'": false,
|
||||
'same object': false,
|
||||
'different object': false
|
||||
'different object': false,
|
||||
'different path': false,
|
||||
'different size': false
|
||||
},
|
||||
"'unknown'": {
|
||||
undefined: false,
|
||||
"'none'": false,
|
||||
"'unknown'": false,
|
||||
'same object': false,
|
||||
'different object': false
|
||||
'different object': false,
|
||||
'different path': false,
|
||||
'different size': false
|
||||
},
|
||||
object: {
|
||||
undefined: false,
|
||||
"'none'": false,
|
||||
"'unknown'": false,
|
||||
'same object': true,
|
||||
'different object': false
|
||||
'different object': false,
|
||||
'different path': false,
|
||||
'different size': false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,18 +67,3 @@ describe.each(ENTRIES)('cached %s', (entryLabel, entry) => {
|
||||
expect(sidecarUnchanged(entry, observed)).toBe(TRUTH_TABLE[entryLabel][observedLabel])
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a vanished sidecar as a change, not as "never had one"', () => {
|
||||
expect(sidecarUnchanged({ ...META }, 'none')).toBe(false)
|
||||
})
|
||||
|
||||
it('never concludes anything from an unreadable sidecar, in either position', () => {
|
||||
expect(sidecarUnchanged('unknown', 'none')).toBe(false)
|
||||
expect(sidecarUnchanged('unknown', { ...META })).toBe(false)
|
||||
expect(sidecarUnchanged({ ...META }, 'unknown')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps an agent with no sidecar at all a cache hit', () => {
|
||||
expect(sidecarUnchanged(undefined, undefined)).toBe(true)
|
||||
expect(sidecarUnchanged(undefined, 'none')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -152,14 +152,3 @@ it('publishes an OpenCode SQLite session through the same channel as every file
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('gives an OpenCode session the same three roles a file provider publishes', async () => {
|
||||
const reads = await readEveryAgentVault()
|
||||
|
||||
const sqliteRead = reads.find(
|
||||
(read) => splitOpenCodeSqliteCandidate(read.path)?.sessionId === OPENCODE_SQLITE_SESSION
|
||||
)
|
||||
expect(new Set(sqliteRead?.messages.map((message) => message.role))).toEqual(
|
||||
new Set(['user', 'assistant', 'tool'])
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import {
|
||||
boundedText,
|
||||
transcriptMessageFromText,
|
||||
transcriptMessagesFromDecodedMessage,
|
||||
transcriptMessagesFromContent
|
||||
} from './session-transcript-message-content'
|
||||
import { decodeSessionMessage } from './session-decoded-message'
|
||||
import { asRecord } from './session-scanner-record-value'
|
||||
|
||||
const AT = '2026-05-01T10:00:00.000Z'
|
||||
const MESSAGE_LIMIT = 256 * 1024
|
||||
@@ -196,132 +194,50 @@ it('bounds joins for a large multi-block turn and keeps its later tool messages'
|
||||
expect(largestJoinedLength).toBeLessThanOrEqual(MESSAGE_LIMIT + 1)
|
||||
})
|
||||
|
||||
function previousToolResultText(content: unknown): string | null {
|
||||
if (typeof content === 'string') {
|
||||
return boundedText(content)
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return null
|
||||
}
|
||||
const parts: string[] = []
|
||||
let length = 0
|
||||
for (const item of content) {
|
||||
const text = typeof item === 'string' ? item : asRecord(item)?.text
|
||||
if (typeof text === 'string' && text) {
|
||||
parts.push(text)
|
||||
length += text.length
|
||||
if (length >= MESSAGE_LIMIT) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return boundedText(parts.join('\n'))
|
||||
}
|
||||
|
||||
it('matches the previous tool-result collector at newline and surrogate boundaries', () => {
|
||||
const cases: unknown[][] = [
|
||||
['x'.repeat(MESSAGE_LIMIT - 2), '\ud800'],
|
||||
['x'.repeat(MESSAGE_LIMIT - 1), '\ud800'],
|
||||
[`${'x'.repeat(MESSAGE_LIMIT - 1)}\ud800`, 'later'],
|
||||
['x'.repeat(MESSAGE_LIMIT - 2), '\ud800\udc00', 'later'],
|
||||
['x'.repeat(MESSAGE_LIMIT), '', 'later'],
|
||||
['', '', '\ud800', '\udfff', '\u0000'],
|
||||
[' '.repeat(MESSAGE_LIMIT), 'visible beyond the cap'],
|
||||
[null, 10, { type: 'image', text: 'still collected' }, { content: 'ignored' }]
|
||||
it('bounds joined text at newline and raw surrogate boundaries', () => {
|
||||
const cases = [
|
||||
{
|
||||
parts: ['x'.repeat(MESSAGE_LIMIT - 2), '\ud800'],
|
||||
text: `${'x'.repeat(MESSAGE_LIMIT - 2)}\n\ud800`,
|
||||
truncated: false
|
||||
},
|
||||
{
|
||||
parts: ['x'.repeat(MESSAGE_LIMIT - 1), '\ud800'],
|
||||
text: `${'x'.repeat(MESSAGE_LIMIT - 1)}\n`,
|
||||
truncated: true
|
||||
},
|
||||
{
|
||||
parts: [`${'x'.repeat(MESSAGE_LIMIT - 1)}\ud800`, 'later'],
|
||||
text: 'x'.repeat(MESSAGE_LIMIT - 1),
|
||||
truncated: true
|
||||
},
|
||||
{
|
||||
parts: ['x'.repeat(MESSAGE_LIMIT - 2), '\ud800\udc00', 'later'],
|
||||
text: `${'x'.repeat(MESSAGE_LIMIT - 2)}\n`,
|
||||
truncated: true
|
||||
},
|
||||
{
|
||||
parts: ['x'.repeat(MESSAGE_LIMIT - 3), '\ud800\udc00', 'later'],
|
||||
text: `${'x'.repeat(MESSAGE_LIMIT - 3)}\n\ud800\udc00`,
|
||||
truncated: true
|
||||
},
|
||||
{
|
||||
parts: ['x'.repeat(MESSAGE_LIMIT), '', 'later'],
|
||||
text: 'x'.repeat(MESSAGE_LIMIT),
|
||||
truncated: true
|
||||
},
|
||||
{
|
||||
parts: ['', '', '\ud800', '\udfff', '\u0000'],
|
||||
text: '\n\n\ud800\n\udfff\n\u0000',
|
||||
truncated: false
|
||||
},
|
||||
{ parts: [' '.repeat(MESSAGE_LIMIT), 'visible beyond the cap'], text: null, truncated: false }
|
||||
]
|
||||
let seed = 0x6a09e667
|
||||
const random = (): number => {
|
||||
seed ^= seed << 13
|
||||
seed ^= seed >>> 17
|
||||
seed ^= seed << 5
|
||||
return seed >>> 0
|
||||
}
|
||||
const lengths = [0, 1, 12, 4096, MESSAGE_LIMIT - 1, MESSAGE_LIMIT, MESSAGE_LIMIT + 1]
|
||||
const atoms = ['a', ' ', '\n', '\ud800', '\udfff', '\ud83d\ude80', '\u0000', '漢']
|
||||
for (let sample = 0; sample < 120; sample++) {
|
||||
cases.push(
|
||||
Array.from({ length: 1 + (random() % 6) }, (_, index) => {
|
||||
const length = lengths[random() % lengths.length]
|
||||
const atom = atoms[random() % atoms.length]
|
||||
const text = atom.repeat(Math.ceil(length / atom.length)).slice(0, length)
|
||||
return index % 2 === 0 ? text : { type: 'text', text }
|
||||
})
|
||||
for (const { parts, text, truncated } of cases) {
|
||||
expect(transcriptMessagesFromContent('user', parts, AT)).toEqual(
|
||||
text === null
|
||||
? []
|
||||
: [{ role: 'user', text, timestamp: AT, ...(truncated ? { textTruncated: true } : {}) }]
|
||||
)
|
||||
}
|
||||
for (const content of cases) {
|
||||
const text = previousToolResultText(content)
|
||||
expect(
|
||||
transcriptMessagesFromContent(
|
||||
'user',
|
||||
[
|
||||
{ type: 'tool_result', content },
|
||||
{ type: 'tool_use', name: 'Read', input: { path: 'after' } },
|
||||
{ type: 'tool_result', content: 'finished' }
|
||||
],
|
||||
AT
|
||||
).map(({ role, text, timestamp }) => ({ role, text, timestamp }))
|
||||
).toEqual([
|
||||
...(text ? [{ role: 'tool', text, timestamp: AT }] : []),
|
||||
{ role: 'tool', text: 'Read: after', timestamp: AT },
|
||||
{ role: 'tool', text: 'finished', timestamp: AT }
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('matches join-then-bound at newline and raw surrogate boundaries', () => {
|
||||
const partsCases = [
|
||||
['x'.repeat(MESSAGE_LIMIT - 2), '\ud800'],
|
||||
['x'.repeat(MESSAGE_LIMIT - 1), '\ud800'],
|
||||
[`${'x'.repeat(MESSAGE_LIMIT - 1)}\ud800`, 'later'],
|
||||
['x'.repeat(MESSAGE_LIMIT - 2), '\ud800\udc00', 'later'],
|
||||
['x'.repeat(MESSAGE_LIMIT - 3), '\ud800\udc00', 'later'],
|
||||
['x'.repeat(MESSAGE_LIMIT), '', 'later'],
|
||||
['', '', '\ud800', '\udfff', '\u0000'],
|
||||
[' '.repeat(MESSAGE_LIMIT), 'visible beyond the cap']
|
||||
]
|
||||
for (const parts of partsCases) {
|
||||
const text = boundedText(parts.join('\n'))
|
||||
expect(
|
||||
transcriptMessagesFromContent('user', parts, AT).map(({ role, text, timestamp }) => ({
|
||||
role,
|
||||
text,
|
||||
timestamp
|
||||
}))
|
||||
).toEqual(text ? [{ role: 'user', text, timestamp: AT }] : [])
|
||||
}
|
||||
})
|
||||
|
||||
it('matches the previous join-then-bound output across generated block mixtures', () => {
|
||||
let seed = 0x6a09e667
|
||||
const random = (): number => {
|
||||
seed ^= seed << 13
|
||||
seed ^= seed >>> 17
|
||||
seed ^= seed << 5
|
||||
return seed >>> 0
|
||||
}
|
||||
const lengths = [0, 1, 12, 4096, MESSAGE_LIMIT - 1, MESSAGE_LIMIT, MESSAGE_LIMIT + 1]
|
||||
const atoms = ['a', ' ', '\n', '\ud800', '\udfff', '\ud83d\ude80', '\u0000', '漢']
|
||||
const roles = ['user', 'assistant', 'tool', 'system', 'unknown'] as const
|
||||
for (let sample = 0; sample < 120; sample++) {
|
||||
const parts = Array.from({ length: 2 + (random() % 7) }, () => {
|
||||
const length = lengths[random() % lengths.length]
|
||||
const atom = atoms[random() % atoms.length]
|
||||
return atom.repeat(Math.ceil(length / atom.length)).slice(0, length)
|
||||
})
|
||||
const role = roles[random() % roles.length]
|
||||
const content = parts.map((text, index) => (index % 2 === 0 ? text : { type: 'Text', text }))
|
||||
const text = boundedText(parts.filter((part, index) => index % 2 === 0 || part).join('\n'))
|
||||
expect(
|
||||
transcriptMessagesFromContent(
|
||||
role,
|
||||
[...content, { type: 'tool_use', name: 'Bash', input: { command: 'pwd' } }],
|
||||
AT
|
||||
).map(({ role, text, timestamp }) => ({ role, text, timestamp }))
|
||||
).toEqual([
|
||||
...(text && (role === 'user' || role === 'assistant' || role === 'tool')
|
||||
? [{ role, text, timestamp: AT }]
|
||||
: []),
|
||||
{ role: 'tool', text: 'Bash: pwd', timestamp: AT }
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
+2
-19
@@ -154,7 +154,7 @@ describe('formatBrowserAnnotationsAsMarkdown', () => {
|
||||
expect(account).toContain('**Feedback:** B feedback')
|
||||
})
|
||||
|
||||
it('escapes and bounds added mixed-page context with the existing inline formatting', () => {
|
||||
it('escapes added mixed-page context with the existing inline formatting', () => {
|
||||
const first = makeAnnotation()
|
||||
const second = makeAnnotation({ browserPageId: 'page`id\n## injected' })
|
||||
second.payload.page.sanitizedUrl = 'invalid`url\n## injected'
|
||||
@@ -164,23 +164,6 @@ describe('formatBrowserAnnotationsAsMarkdown', () => {
|
||||
expect(markdown).not.toContain('\n## injected')
|
||||
})
|
||||
|
||||
it('includes agent-useful selectors, source, react tree, styles, and feedback', () => {
|
||||
const markdown = formatBrowserAnnotationsAsMarkdown([makeAnnotation()])
|
||||
|
||||
expect(markdown).toContain('## Design Feedback: /pricing')
|
||||
expect(markdown).toContain('**Browser tab id:** page-1')
|
||||
expect(markdown).not.toContain('Orca CLI')
|
||||
expect(markdown).not.toContain('--page page-1')
|
||||
expect(markdown).not.toContain('Page Feedback')
|
||||
expect(markdown).toContain('**Selector:** `main.pricing > button.primary`')
|
||||
expect(markdown).toContain('**Source:** src/components/PricingCta.tsx:42:8')
|
||||
expect(markdown).toContain('**React:** <App> <PricingCta>')
|
||||
expect(markdown).toContain('**Intent:** change')
|
||||
expect(markdown).not.toContain('**Priority:**')
|
||||
expect(markdown).toContain('- font-size: 16px')
|
||||
expect(markdown).toContain('**Feedback:** Make this primary action more obvious.')
|
||||
})
|
||||
|
||||
it('returns an empty string when no annotations exist', () => {
|
||||
expect(formatBrowserAnnotationsAsMarkdown([])).toBe('')
|
||||
})
|
||||
@@ -309,7 +292,7 @@ describe('formatBrowserAnnotationsAsMarkdown', () => {
|
||||
)
|
||||
|
||||
expect(selectedLineStart).not.toBe(-1)
|
||||
expect(selectedLine).toBeDefined()
|
||||
expect(selectedLine).toBe(`**Selected text:** "${'x'.repeat(2047)}"`)
|
||||
expect(selectedLine).not.toContain('😀')
|
||||
expect(selectedLine).not.toContain('tail')
|
||||
expect(selectedLine).not.toContain('\ufffd')
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ function renderTray(currentUrl?: string): {
|
||||
}
|
||||
|
||||
describe('BrowserPageAnnotationTray edit mode', () => {
|
||||
it('attributes a retained note to its captured page while preserving edit and delete controls', () => {
|
||||
it('attributes a retained note to its captured page while preserving deletion', () => {
|
||||
const { handleDeleteBrowserAnnotation } = renderTray('https://example.com/next')
|
||||
expect(screen.getByTitle('https://example.com')).toHaveTextContent('https://example.com')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete annotation 1' }))
|
||||
|
||||
+2
-2
@@ -45,7 +45,7 @@ describe('current-document annotation marker projection', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not resurrect old same-URL geometry on dom-ready or return, but permits pending viewport events', () => {
|
||||
it('keeps retired same-URL geometry hidden and permits pending viewport events', () => {
|
||||
const { setAnnotationViewportBridge, notes } = createBridge()
|
||||
const args = {
|
||||
toolTargetId: 'page-1',
|
||||
@@ -100,7 +100,7 @@ describe('current-document annotation marker projection', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('tolerates a destroyed guest without retaining or throwing a rejected bridge call', async () => {
|
||||
it('handles a rejected bridge call without a synchronous exception', async () => {
|
||||
const { setAnnotationViewportBridge, notes } = createBridge()
|
||||
setAnnotationViewportBridge.mockRejectedValueOnce(new Error('Guest destroyed'))
|
||||
expect(() =>
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('markup document undo/redo', () => {
|
||||
expect(canRedo(doc)).toBe(false)
|
||||
})
|
||||
|
||||
it('commits shapes and clears the redo stack', () => {
|
||||
it('commits shapes in order and enables undo', () => {
|
||||
let doc = createMarkupDocument()
|
||||
doc = commitShape(doc, pen('a'))
|
||||
doc = commitShape(doc, pen('b'))
|
||||
@@ -115,7 +115,7 @@ describe('markup geometry', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('scales rect/arrow endpoints and text position + font size', () => {
|
||||
it('scales arrow endpoints and text position + font size', () => {
|
||||
const arrow: ArrowShape = {
|
||||
id: 'a',
|
||||
kind: 'arrow',
|
||||
|
||||
+1
-10
@@ -3,8 +3,7 @@ import {
|
||||
clampMarkupScale,
|
||||
dataUrlByteLength,
|
||||
effectiveMarkupScale,
|
||||
markupCanvasSize,
|
||||
MARKUP_DOWNSCALE_STEPS
|
||||
markupCanvasSize
|
||||
} from './markup-screenshot-compose'
|
||||
|
||||
describe('clampMarkupScale', () => {
|
||||
@@ -62,11 +61,3 @@ describe('dataUrlByteLength', () => {
|
||||
expect(dataUrlByteLength('not-a-data-url')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compose budget plans', () => {
|
||||
it('tries full size first, then shrinks (PNG only, descending)', () => {
|
||||
expect(MARKUP_DOWNSCALE_STEPS[0]).toBe(1)
|
||||
expect([...MARKUP_DOWNSCALE_STEPS]).toEqual([...MARKUP_DOWNSCALE_STEPS].sort((a, b) => b - a))
|
||||
expect(MARKUP_DOWNSCALE_STEPS.every((s) => s > 0 && s <= 1)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,8 +61,11 @@ describe('drawShape dispatch', () => {
|
||||
]
|
||||
}
|
||||
drawShape(ctx, shape)
|
||||
expect(methods('moveTo')).toHaveLength(1)
|
||||
expect(methods('lineTo')).toHaveLength(2)
|
||||
expect(methods('moveTo')).toEqual([{ method: 'moveTo', args: [0, 0] }])
|
||||
expect(methods('lineTo')).toEqual([
|
||||
{ method: 'lineTo', args: [5, 5] },
|
||||
{ method: 'lineTo', args: [9, 1] }
|
||||
])
|
||||
expect(methods('stroke')).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -132,7 +135,7 @@ describe('drawShape dispatch', () => {
|
||||
})
|
||||
|
||||
it('renders single-line text with a halo (stroke behind fill)', () => {
|
||||
const { ctx, methods } = makeRecordingCtx()
|
||||
const { ctx, calls } = makeRecordingCtx()
|
||||
drawShape(ctx, {
|
||||
id: 't',
|
||||
kind: 'text',
|
||||
@@ -141,13 +144,14 @@ describe('drawShape dispatch', () => {
|
||||
text: 'fix this',
|
||||
fontSize: 18
|
||||
})
|
||||
expect(methods('strokeText')).toHaveLength(1)
|
||||
expect(methods('fillText')).toHaveLength(1)
|
||||
expect(methods('fillText')[0].args[0]).toBe('fix this')
|
||||
expect(calls.filter((call) => call.method.endsWith('Text'))).toEqual([
|
||||
{ method: 'strokeText', args: ['fix this', 5, 5] },
|
||||
{ method: 'fillText', args: ['fix this', 5, 5] }
|
||||
])
|
||||
})
|
||||
|
||||
it('save/restore brackets every shape so state never leaks', () => {
|
||||
const { ctx, methods } = makeRecordingCtx()
|
||||
it('brackets rectangle drawing with save and restore calls', () => {
|
||||
const { ctx, calls } = makeRecordingCtx()
|
||||
drawShape(ctx, {
|
||||
id: 'r',
|
||||
kind: 'rect',
|
||||
@@ -156,8 +160,11 @@ describe('drawShape dispatch', () => {
|
||||
from: { x: 0, y: 0 },
|
||||
to: { x: 1, y: 1 }
|
||||
})
|
||||
expect(methods('save')).toHaveLength(1)
|
||||
expect(methods('restore')).toHaveLength(1)
|
||||
expect(calls).toEqual([
|
||||
{ method: 'save', args: [] },
|
||||
{ method: 'strokeRect', args: [0, 0, 1, 1] },
|
||||
{ method: 'restore', args: [] }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -519,7 +519,7 @@ describe('BrowserAddressBar autocomplete preview', () => {
|
||||
expect(mocks.removeBrowserHistoryEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dismisses suggestions when focus moves into an Electron webview guest', async () => {
|
||||
it('dismisses suggestions on a webview focusin event', async () => {
|
||||
const onNavigate = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
const webview = document.createElement('webview')
|
||||
@@ -556,7 +556,7 @@ describe('BrowserAddressBar autocomplete preview', () => {
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets the second press of a double-click select a word instead of the whole URL', async () => {
|
||||
it('clears the whole-URL selection on the second double-click press', async () => {
|
||||
const url = 'https://github.com/stablyai/orca/pull/27021'
|
||||
await act(async () => {
|
||||
root.render(<AddressBarHarness initialValue={url} onNavigate={vi.fn()} onSubmit={vi.fn()} />)
|
||||
|
||||
@@ -47,7 +47,15 @@ describe('BrowserFind session flags', () => {
|
||||
it('starts a new find session when the query changes', () => {
|
||||
const { findInPage } = openFindWithQuery('needle')
|
||||
|
||||
expect(findInPage).toHaveBeenLastCalledWith('needle', { findNext: true })
|
||||
expect(findInPage).toHaveBeenCalledExactlyOnceWith('needle', { findNext: true })
|
||||
findInPage.mockClear()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Find in page...'), {
|
||||
target: { value: 'replacement' }
|
||||
})
|
||||
act(() => vi.advanceTimersByTime(250))
|
||||
|
||||
expect(findInPage).toHaveBeenCalledExactlyOnceWith('replacement', { findNext: true })
|
||||
})
|
||||
|
||||
it('advances forward as a follow-up request, not a new session', () => {
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH,
|
||||
isBrowserAddressBarCollapsed,
|
||||
shouldOverlayBrowserAddressBar
|
||||
} from './browser-address-bar-expansion'
|
||||
|
||||
describe('isBrowserAddressBarCollapsed', () => {
|
||||
it('treats an unmeasured slot as roomy so the bar never overlays before layout', () => {
|
||||
expect(isBrowserAddressBarCollapsed(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('collapses below the minimum inline width', () => {
|
||||
expect(isBrowserAddressBarCollapsed(BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH - 1)).toBe(true)
|
||||
expect(isBrowserAddressBarCollapsed(48)).toBe(true)
|
||||
})
|
||||
|
||||
it('stays inline at or above the minimum width', () => {
|
||||
expect(isBrowserAddressBarCollapsed(BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH)).toBe(false)
|
||||
expect(isBrowserAddressBarCollapsed(640)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldOverlayBrowserAddressBar', () => {
|
||||
it('overlays only while a collapsed bar is focused', () => {
|
||||
expect(shouldOverlayBrowserAddressBar({ inlineWidth: 48, focused: true })).toBe(true)
|
||||
expect(shouldOverlayBrowserAddressBar({ inlineWidth: 48, focused: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('never overlays a bar that already fits', () => {
|
||||
expect(shouldOverlayBrowserAddressBar({ inlineWidth: 640, focused: true })).toBe(false)
|
||||
})
|
||||
})
|
||||
+3
-3
@@ -209,20 +209,20 @@ describe('browser address bar suggestions', () => {
|
||||
url: 'https://acme.dev/a',
|
||||
normalizedUrl: 'https://acme.dev/a',
|
||||
title: 'A',
|
||||
lastVisitedAt: hoursAgo(23),
|
||||
lastVisitedAt: hoursAgo(1),
|
||||
visitCount: 50
|
||||
}),
|
||||
historyEntry({
|
||||
url: 'https://acme.dev/b',
|
||||
normalizedUrl: 'https://acme.dev/b',
|
||||
title: 'B',
|
||||
lastVisitedAt: hoursAgo(1),
|
||||
lastVisitedAt: hoursAgo(23),
|
||||
visitCount: 5000
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
expect(historyUrls(suggestions)).toEqual(['https://acme.dev/b', 'https://acme.dev/a'])
|
||||
expect(historyUrls(suggestions)).toEqual(['https://acme.dev/a', 'https://acme.dev/b'])
|
||||
})
|
||||
|
||||
it('floors the recency bonus at zero for entries older than a day', () => {
|
||||
|
||||
+7
-9
@@ -89,19 +89,17 @@ describe('BrowserChromeFoldedMenuItems', () => {
|
||||
const { container } = render(
|
||||
<BrowserChromeFoldedMenuItems
|
||||
tools={[
|
||||
{ stage: 'draw', label: 'Draw', icon: Share2, onSelect: vi.fn(), active: false },
|
||||
{ stage: 'share', label: 'Share', icon: Share2, onSelect: vi.fn() }
|
||||
{ stage: 'share', label: 'Share', icon: Share2, onSelect: vi.fn() },
|
||||
{ stage: 'draw', label: 'Draw', icon: Share2, onSelect: vi.fn(), active: false }
|
||||
]}
|
||||
deferUntilClose={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
// Rendered order: Draw, divider, Share, closing divider.
|
||||
expect(Array.from(container.querySelectorAll('button, hr')).map((el) => el.tagName)).toEqual([
|
||||
'BUTTON',
|
||||
'HR',
|
||||
'BUTTON',
|
||||
'HR'
|
||||
])
|
||||
expect(
|
||||
Array.from(container.querySelectorAll('button, hr')).map((el) =>
|
||||
el.tagName === 'HR' ? 'divider' : el.textContent
|
||||
)
|
||||
).toEqual(['Draw', 'divider', 'Share', 'divider'])
|
||||
})
|
||||
})
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatBrowserImportSummary } from './browser-detected-browsers-summary'
|
||||
|
||||
const SUPPORTED_LABELS = [
|
||||
'Google Chrome',
|
||||
'Microsoft Edge',
|
||||
'Arc',
|
||||
'Brave',
|
||||
'Comet',
|
||||
'Helium',
|
||||
'Firefox',
|
||||
'Safari'
|
||||
]
|
||||
|
||||
describe('formatBrowserImportSummary', () => {
|
||||
it('uses real detected browser labels after detection completes', () => {
|
||||
expect(
|
||||
formatBrowserImportSummary({
|
||||
detectedBrowsers: [{ label: 'Google Chrome' }, { label: 'Safari' }],
|
||||
detectedBrowsersLoaded: true,
|
||||
supportedImportLabels: SUPPORTED_LABELS
|
||||
})
|
||||
).toBe('Detected: Google Chrome, Safari.')
|
||||
})
|
||||
|
||||
it('summarizes long detected-browser lists with a trailing count', () => {
|
||||
expect(
|
||||
formatBrowserImportSummary({
|
||||
detectedBrowsers: [
|
||||
{ label: 'Google Chrome' },
|
||||
{ label: 'Safari' },
|
||||
{ label: 'Microsoft Edge' },
|
||||
{ label: 'Arc' },
|
||||
{ label: 'Firefox' },
|
||||
{ label: 'Brave' }
|
||||
],
|
||||
detectedBrowsersLoaded: true,
|
||||
supportedImportLabels: SUPPORTED_LABELS
|
||||
})
|
||||
).toBe('Detected: Google Chrome, Safari, Microsoft Edge, Arc, +2 more.')
|
||||
})
|
||||
|
||||
it('falls back to supported import sources when detection finds nothing', () => {
|
||||
expect(
|
||||
formatBrowserImportSummary({
|
||||
detectedBrowsers: [],
|
||||
detectedBrowsersLoaded: true,
|
||||
supportedImportLabels: SUPPORTED_LABELS
|
||||
})
|
||||
).toBe('Import from: Google Chrome, Microsoft Edge, Arc, Brave, +4 more.')
|
||||
})
|
||||
})
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldShowBrowserImportHint } from './browser-import-hint-visibility'
|
||||
|
||||
describe('shouldShowBrowserImportHint', () => {
|
||||
it('shows after persisted UI loads', () => {
|
||||
expect(
|
||||
shouldShowBrowserImportHint({
|
||||
persistedUIReady: true,
|
||||
browserImportHintHidden: false
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('stays hidden until persisted UI loads', () => {
|
||||
expect(
|
||||
shouldShowBrowserImportHint({
|
||||
persistedUIReady: false,
|
||||
browserImportHintHidden: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('honors explicit hint dismissal', () => {
|
||||
expect(
|
||||
shouldShowBrowserImportHint({
|
||||
persistedUIReady: true,
|
||||
browserImportHintHidden: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
+2
-97
@@ -1,26 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useRef } from 'react'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
addressBar: {
|
||||
current: null as { onSubmit: () => void; onNavigate: (url: string) => void } | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./BrowserAddressBar', () => ({
|
||||
default: (props: { value: string; onSubmit: () => void; onNavigate: (url: string) => void }) => {
|
||||
mocks.addressBar.current = props
|
||||
return <input aria-label="Address" value={props.value} readOnly />
|
||||
}
|
||||
}))
|
||||
|
||||
import BrowserAddressBar from './BrowserAddressBar'
|
||||
import {
|
||||
BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE,
|
||||
BROWSER_CHROME_ADDRESS_SLOT_HEIGHT_CLASS
|
||||
} from './browser-chrome-address-slot'
|
||||
import {
|
||||
BrowserNavigationControlRow,
|
||||
type BrowserNavigationControls
|
||||
@@ -36,48 +16,12 @@ function renderRow(overrides: Partial<BrowserNavigationControls> = {}): BrowserN
|
||||
navigate: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
function Host(): React.JSX.Element {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
return (
|
||||
<BrowserNavigationControlRow
|
||||
controls={controls}
|
||||
addressSlot={
|
||||
<BrowserAddressBar
|
||||
pageId="page-1"
|
||||
value="https://example.com/"
|
||||
committedValue="https://example.com/"
|
||||
onChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
onNavigate={controls.navigate}
|
||||
inputRef={inputRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
render(<Host />)
|
||||
render(<BrowserNavigationControlRow controls={controls} addressSlot={null} />)
|
||||
return controls
|
||||
}
|
||||
|
||||
/** The read-only counterpart the document preview passes, standing in for any non-URL identity. */
|
||||
function renderWithIdentityChip(): void {
|
||||
render(
|
||||
<BrowserNavigationControlRow
|
||||
controls={{
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
goBack: vi.fn(),
|
||||
goForward: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
navigate: vi.fn()
|
||||
}}
|
||||
addressSlot={<span>docs/report.html</span>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('BrowserNavigationControlRow', () => {
|
||||
afterEach(() => cleanup())
|
||||
afterEach(cleanup)
|
||||
|
||||
it('drives every history action through the controls seam', () => {
|
||||
const controls = renderRow()
|
||||
@@ -94,43 +38,4 @@ describe('BrowserNavigationControlRow', () => {
|
||||
expect(screen.getByLabelText<HTMLButtonElement>('Back').disabled).toBe(true)
|
||||
expect(screen.getByLabelText<HTMLButtonElement>('Forward').disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('routes an address-bar suggestion pick into the backend navigate', () => {
|
||||
const controls = renderRow()
|
||||
mocks.addressBar.current?.onNavigate('https://picked.example/')
|
||||
expect(controls.navigate).toHaveBeenCalledWith('https://picked.example/')
|
||||
})
|
||||
|
||||
it('anchors the contextual tour on whichever backend renders the row', () => {
|
||||
renderRow()
|
||||
expect(document.querySelector('[data-contextual-tour-target="browser-toolbar"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
// A read-only document identity must retain the same history controls as an address bar.
|
||||
it('renders a non-address identity widget in the same slot', () => {
|
||||
renderWithIdentityChip()
|
||||
expect(screen.getByText('docs/report.html')).not.toBeNull()
|
||||
expect(screen.queryByLabelText('Address')).toBeNull()
|
||||
expect(screen.getByLabelText('Back')).not.toBeNull()
|
||||
expect(screen.getByLabelText('Reload')).not.toBeNull()
|
||||
})
|
||||
|
||||
// The row sets height so switching between input and document text cannot resize the toolbar.
|
||||
it('gives every identity widget the same slot height, and stretches it to fill', () => {
|
||||
renderRow()
|
||||
const addressSlot = document.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`)
|
||||
const addressSlotClass = addressSlot?.className ?? ''
|
||||
const addressWidgetParent = screen.getByLabelText('Address').parentElement
|
||||
cleanup()
|
||||
|
||||
renderWithIdentityChip()
|
||||
const chipSlot = document.querySelector(`[${BROWSER_CHROME_ADDRESS_SLOT_ATTRIBUTE}]`)
|
||||
|
||||
expect(addressSlotClass).toContain(BROWSER_CHROME_ADDRESS_SLOT_HEIGHT_CLASS)
|
||||
expect(chipSlot?.className).toBe(addressSlotClass)
|
||||
expect(addressSlotClass).toContain('items-stretch')
|
||||
// Both widgets are direct children, so the slot's height reaches them instead of a wrapper.
|
||||
expect(addressWidgetParent).toBe(addressSlot)
|
||||
expect(screen.getByText('docs/report.html').parentElement).toBe(chipSlot)
|
||||
})
|
||||
})
|
||||
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { BrowserPageZoomIndicator } from './browser-page-zoom-indicator'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
it('preserves zoom feedback visibility and accessible status as it updates', () => {
|
||||
const view = render(
|
||||
<BrowserPageZoomIndicator
|
||||
state={{ ariaHidden: false, opacityClassName: 'opacity-100' }}
|
||||
percent={125}
|
||||
/>
|
||||
)
|
||||
const indicator = screen.getByRole('status')
|
||||
expect(indicator.textContent).toBe('125%')
|
||||
expect(indicator.getAttribute('aria-live')).toBe('polite')
|
||||
expect(indicator.getAttribute('aria-hidden')).toBe('false')
|
||||
expect(indicator.classList.contains('opacity-100')).toBe(true)
|
||||
expect(indicator.classList.contains('pointer-events-none')).toBe(true)
|
||||
|
||||
view.rerender(
|
||||
<BrowserPageZoomIndicator
|
||||
state={{ ariaHidden: true, opacityClassName: 'opacity-0' }}
|
||||
percent={100}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
expect(screen.getByRole('status', { hidden: true })).toBe(indicator)
|
||||
expect(indicator.textContent).toBe('100%')
|
||||
expect(indicator.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(indicator.classList.contains('opacity-0')).toBe(true)
|
||||
expect(indicator.classList.contains('opacity-100')).toBe(false)
|
||||
})
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getBrowserPagesForWorkspace } from './browser-pane-page-selection'
|
||||
import type { BrowserPage } from '../../../../../shared/browser-workspace-types'
|
||||
|
||||
function makeBrowserPage(id: string): BrowserPage {
|
||||
return {
|
||||
id,
|
||||
workspaceId: 'workspace-a',
|
||||
worktreeId: 'worktree-a',
|
||||
url: `https://example.com/${id}`,
|
||||
title: id,
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('getBrowserPagesForWorkspace', () => {
|
||||
it('returns only the owning workspace page array so unrelated page updates keep the selector stable', () => {
|
||||
const pages = [makeBrowserPage('page-1')]
|
||||
const browserPagesByWorkspace = {
|
||||
workspaceA: pages,
|
||||
workspaceB: [makeBrowserPage('page-2')]
|
||||
}
|
||||
|
||||
expect(getBrowserPagesForWorkspace(browserPagesByWorkspace, 'workspaceA')).toBe(pages)
|
||||
expect(
|
||||
getBrowserPagesForWorkspace(
|
||||
{ ...browserPagesByWorkspace, workspaceB: [makeBrowserPage('page-3')] },
|
||||
'workspaceA'
|
||||
)
|
||||
).toBe(pages)
|
||||
expect(getBrowserPagesForWorkspace(browserPagesByWorkspace, 'missing')).toBe(
|
||||
getBrowserPagesForWorkspace({}, 'missing')
|
||||
)
|
||||
})
|
||||
})
|
||||
+26
-9
@@ -169,28 +169,45 @@ describe('browser workspace pane retention props', () => {
|
||||
it('threads all three retention terms to the page that owns them', () => {
|
||||
renderWorkspacePane()
|
||||
expect(mocks.pageProps.some((props) => props.id === 'page-b')).toBe(false)
|
||||
|
||||
cleanup()
|
||||
const token = acquireBrowserAutomationVisibility('page-b')
|
||||
hydrateBrowserDrivers([
|
||||
{ browserPageId: 'page-b', driver: { kind: 'mobile', clientId: 'phone-1' } }
|
||||
])
|
||||
hydrateBrowserRemoteViewerPages(['page-b'])
|
||||
renderWorkspacePane()
|
||||
|
||||
const token = acquireBrowserAutomationVisibility('page-b')
|
||||
renderWorkspacePane()
|
||||
expect(propsFor('page-b')).toEqual({
|
||||
id: 'page-b',
|
||||
isAutomationVisible: true,
|
||||
isMobileDriven: false,
|
||||
isRemotelyViewed: false
|
||||
})
|
||||
cleanup()
|
||||
releaseBrowserAutomationVisibility(token)
|
||||
|
||||
hydrateBrowserDrivers([
|
||||
{ browserPageId: 'page-b', driver: { kind: 'mobile', clientId: 'phone-1' } }
|
||||
])
|
||||
renderWorkspacePane()
|
||||
expect(propsFor('page-b')).toEqual({
|
||||
id: 'page-b',
|
||||
isAutomationVisible: false,
|
||||
isMobileDriven: true,
|
||||
isRemotelyViewed: false
|
||||
})
|
||||
cleanup()
|
||||
hydrateBrowserDrivers([])
|
||||
|
||||
hydrateBrowserRemoteViewerPages(['page-b'])
|
||||
renderWorkspacePane()
|
||||
expect(propsFor('page-b')).toEqual({
|
||||
id: 'page-b',
|
||||
isAutomationVisible: false,
|
||||
isMobileDriven: false,
|
||||
isRemotelyViewed: true
|
||||
})
|
||||
// Why the sibling: each term must land on the page it belongs to, not on every page of the tab.
|
||||
expect(propsFor('page-a')).toEqual({
|
||||
id: 'page-a',
|
||||
isAutomationVisible: false,
|
||||
isMobileDriven: false,
|
||||
isRemotelyViewed: false
|
||||
})
|
||||
releaseBrowserAutomationVisibility(token)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MarkdownTableOfContentsPanel } from './MarkdownTableOfContentsPanel'
|
||||
import type { MarkdownTocItem } from './markdown-table-of-contents'
|
||||
|
||||
const sampleItems: MarkdownTocItem[] = [
|
||||
{
|
||||
id: 'intro',
|
||||
level: 1,
|
||||
title: 'Intro',
|
||||
children: [
|
||||
{
|
||||
id: 'setup',
|
||||
level: 2,
|
||||
title: 'Setup',
|
||||
children: [
|
||||
{
|
||||
id: 'install',
|
||||
level: 3,
|
||||
title: 'Install',
|
||||
children: [
|
||||
{
|
||||
id: 'configure',
|
||||
level: 4,
|
||||
title: 'Configure',
|
||||
children: [
|
||||
{
|
||||
id: 'options',
|
||||
level: 5,
|
||||
title: 'Options',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
describe('MarkdownTableOfContentsPanel', () => {
|
||||
it('renders level collapse controls and disclosure buttons', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<MarkdownTableOfContentsPanel items={sampleItems} onClose={() => {}} onNavigate={() => {}} />
|
||||
)
|
||||
|
||||
expect(html).toContain('Collapse by level')
|
||||
expect(html).toContain('Collapse to heading level 1')
|
||||
expect(html).toContain('Collapse to heading level 4')
|
||||
expect(html).toContain('Expand all heading levels')
|
||||
expect(html).toContain('H5')
|
||||
expect(html).toContain('Collapse Intro')
|
||||
expect(html).toContain('Install')
|
||||
expect(html).toContain('Configure')
|
||||
expect(html).toContain('Options')
|
||||
expect(html).toContain('Intro')
|
||||
expect(html).toContain('Setup')
|
||||
expect(html).toContain('data-markdown-toc-resize-handle')
|
||||
expect(html).toContain('Resize table of contents')
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { RichMarkdownSlashMenu } from './RichMarkdownSlashMenu'
|
||||
|
||||
describe('RichMarkdownSlashMenu', () => {
|
||||
it('keeps a visible search field when there are no matching blocks', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<RichMarkdownSlashMenu
|
||||
editor={null}
|
||||
slashMenu={{ query: 'zzz', from: 1, to: 5, left: 0, top: 0 }}
|
||||
filteredCommands={[]}
|
||||
selectedIndex={0}
|
||||
onImagePick={() => {}}
|
||||
onEmojiPick={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(html).toContain('aria-label="Search blocks"')
|
||||
expect(html).toContain('value="zzz"')
|
||||
expect(html).toContain('No blocks found')
|
||||
})
|
||||
})
|
||||
@@ -1,150 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CmdJPaletteFeatureTipVisual } from './CmdJPaletteFeatureTipVisual'
|
||||
|
||||
const prefersReducedMotionMock = vi.hoisted(() => vi.fn(() => false))
|
||||
const shortcutMock = vi.hoisted(() => vi.fn(() => ({ keys: ['⌘', 'J'], doubleTap: false })))
|
||||
const formatShortcutMock = vi.hoisted(() => vi.fn(() => [{ keys: ['⌘', 'J'], doubleTap: false }]))
|
||||
|
||||
vi.mock('@/components/feature-wall/feature-wall-modal-helpers', () => ({
|
||||
usePrefersReducedMotion: prefersReducedMotionMock
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useShortcutKeyDetails: shortcutMock,
|
||||
formatShortcutKeyComboDetails: formatShortcutMock
|
||||
}))
|
||||
|
||||
async function renderVisual(): Promise<{ container: HTMLDivElement; root: Root }> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CmdJPaletteFeatureTipVisual />)
|
||||
})
|
||||
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
describe('CmdJPaletteFeatureTipVisual', () => {
|
||||
beforeEach(() => {
|
||||
prefersReducedMotionMock.mockReturnValue(false)
|
||||
shortcutMock.mockReturnValue({ keys: ['⌘', 'J'], doubleTap: false })
|
||||
formatShortcutMock.mockReturnValue([{ keys: ['⌘', 'J'], doubleTap: false }])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders the static end state without motion classes when reduced motion is preferred', () => {
|
||||
prefersReducedMotionMock.mockReturnValue(true)
|
||||
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
expect(html).toContain('auth')
|
||||
expect(html).toContain('auth-redirect')
|
||||
expect(html).not.toContain('payments-api')
|
||||
expect(html).not.toContain('animate-spin')
|
||||
expect(html).not.toContain('animate-cmd-j-tip-caret')
|
||||
expect(html).not.toContain('animate-cmd-j-tip-result-in')
|
||||
})
|
||||
|
||||
it('does not schedule animation timers when reduced motion is preferred', async () => {
|
||||
prefersReducedMotionMock.mockReturnValue(true)
|
||||
const setTimeoutSpy = vi.spyOn(window, 'setTimeout')
|
||||
|
||||
const { root } = await renderVisual()
|
||||
await act(async () => {
|
||||
root.unmount()
|
||||
})
|
||||
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears pending timers on unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
|
||||
|
||||
const { root } = await renderVisual()
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
await act(async () => {
|
||||
root.unmount()
|
||||
})
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('settles after the one-shot demo instead of looping idle timers', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const { container, root } = await renderVisual()
|
||||
await act(async () => {
|
||||
vi.runAllTimers()
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('auth')
|
||||
expect(container.textContent).toContain('auth-redirect')
|
||||
expect(container.textContent).not.toContain('payments-api')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
await act(async () => {
|
||||
root.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not render infinite animation classes in the default preview', () => {
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
expect(html).not.toContain('animate-spin')
|
||||
expect(html).not.toContain('animate-cmd-j-tip-caret')
|
||||
})
|
||||
|
||||
it('falls back to default per-key chips when the live binding is unassigned', () => {
|
||||
shortcutMock.mockReturnValue({ keys: [], doubleTap: false })
|
||||
formatShortcutMock.mockReturnValue([{ keys: ['Ctrl', 'Shift', 'J'], doubleTap: false }])
|
||||
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
expect(formatShortcutMock).toHaveBeenCalledWith('worktree.palette')
|
||||
expect(html).toContain('Ctrl')
|
||||
expect(html).toContain('Shift')
|
||||
expect(html).toContain('J')
|
||||
})
|
||||
|
||||
it('shows the create-worktree row only once the demo query is non-empty', () => {
|
||||
prefersReducedMotionMock.mockReturnValue(true)
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
expect(html).toContain('Create worktree')
|
||||
expect(html).toContain('auth')
|
||||
})
|
||||
|
||||
it('renders the live binding as separate shortcut key chips with plus separators', () => {
|
||||
shortcutMock.mockReturnValue({ keys: ['⌘', 'J'], doubleTap: false })
|
||||
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
expect(html).toContain('⌘')
|
||||
expect(html).toContain('J')
|
||||
expect(html).toContain('+')
|
||||
})
|
||||
|
||||
it('renders double-tap shortcut chips without plus separators', () => {
|
||||
shortcutMock.mockReturnValue({ keys: ['⇧', '⇧'], doubleTap: true })
|
||||
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
expect(html).toContain('⇧')
|
||||
expect(html).not.toContain('+')
|
||||
})
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FEATURE_TIPS, type FeatureTip } from '../../../../shared/feature-tips'
|
||||
import { CmdJPaletteTipDialog } from './CmdJPaletteTipDialog'
|
||||
|
||||
const shortcutLabelMock = vi.hoisted(() => vi.fn(() => '⌘J'))
|
||||
const formatShortcutLabelMock = vi.hoisted(() => vi.fn(() => '⌘J'))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useShortcutLabel: shortcutLabelMock,
|
||||
formatShortcutLabel: formatShortcutLabelMock
|
||||
}))
|
||||
|
||||
vi.mock('./CmdJPaletteFeatureTipVisual', () => ({
|
||||
CmdJPaletteFeatureTipVisual: () => <div data-testid="palette-visual" />
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <footer>{children}</footer>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>
|
||||
}))
|
||||
|
||||
vi.mock('./FeatureTipActions', () => ({
|
||||
FeatureTipActions: () => <div data-testid="feature-tip-actions" />
|
||||
}))
|
||||
|
||||
function getCmdJPaletteTip(): FeatureTip {
|
||||
const tip = FEATURE_TIPS.find((entry) => entry.id === 'cmd-j-palette')
|
||||
if (!tip) {
|
||||
throw new Error('Expected cmd-j-palette feature tip fixture')
|
||||
}
|
||||
return { ...tip }
|
||||
}
|
||||
|
||||
const paletteTip = getCmdJPaletteTip()
|
||||
|
||||
function renderDialog(): string {
|
||||
return renderToStaticMarkup(
|
||||
<CmdJPaletteTipDialog
|
||||
open
|
||||
tip={paletteTip}
|
||||
primaryBusy={false}
|
||||
onOpenChange={vi.fn()}
|
||||
onPrimaryAction={vi.fn()}
|
||||
onSkip={vi.fn()}
|
||||
onRebindClick={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('CmdJPaletteTipDialog', () => {
|
||||
it('shows a tip badge so the dialog reads as education, not a new feature', () => {
|
||||
const html = renderDialog()
|
||||
|
||||
expect(html).toContain('TIP')
|
||||
})
|
||||
|
||||
it('inlines the live shortcut label in the title', () => {
|
||||
shortcutLabelMock.mockReturnValue('⌘J')
|
||||
|
||||
const html = renderDialog()
|
||||
|
||||
expect(html).toContain('Jump to a worktree with')
|
||||
expect(html).toContain('⌘J')
|
||||
})
|
||||
|
||||
it('falls back to the default shortcut label when the binding is unassigned', () => {
|
||||
shortcutLabelMock.mockReturnValue('Unassigned')
|
||||
formatShortcutLabelMock.mockReturnValue('Ctrl+Shift+J')
|
||||
|
||||
const html = renderDialog()
|
||||
|
||||
expect(formatShortcutLabelMock).toHaveBeenCalledWith('worktree.palette')
|
||||
expect(html).toContain('Ctrl+Shift+J')
|
||||
expect(html).not.toContain('Unassigned')
|
||||
})
|
||||
|
||||
it('keeps the rebind guidance inside the dialog description', () => {
|
||||
const html = renderDialog()
|
||||
|
||||
expect(html).toContain('Settings → Shortcuts')
|
||||
expect(html).toContain('spin up a new worktree')
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-search-client'
|
||||
import type { AiVaultSearchStatus } from '../../../../shared/ai-vault-search-types'
|
||||
import { FEATURE_TIPS, type FeatureTip } from '../../../../shared/feature-tips'
|
||||
import { SessionSearchTipDialog } from './SessionSearchTipDialog'
|
||||
import type { SessionSearchTipStage } from './use-session-search-tip-setup'
|
||||
|
||||
vi.mock('./SessionSearchFeatureTipVisual', () => ({
|
||||
SessionSearchFeatureTipVisual: () => <div data-testid="session-search-visual" />
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <footer>{children}</footer>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>
|
||||
}))
|
||||
|
||||
function getTip(): FeatureTip {
|
||||
const tip = FEATURE_TIPS.find((entry) => entry.id === 'agent-session-search')
|
||||
if (!tip) {
|
||||
throw new Error('Expected agent-session-search feature tip fixture')
|
||||
}
|
||||
return tip
|
||||
}
|
||||
|
||||
function render(stage: SessionSearchTipStage, status: AiVaultSearchStatus | null): string {
|
||||
return renderToStaticMarkup(
|
||||
<SessionSearchTipDialog
|
||||
open
|
||||
tip={getTip()}
|
||||
primaryBusy={false}
|
||||
onOpenChange={() => {}}
|
||||
onPrimaryAction={() => {}}
|
||||
onSettingsClick={() => {}}
|
||||
stage={stage}
|
||||
status={status}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SessionSearchTipDialog', () => {
|
||||
it('offers to turn search on and links to its settings', () => {
|
||||
const html = render('offer', null)
|
||||
expect(html).toContain('Search every agent session')
|
||||
expect(html).toContain('Turn on session search')
|
||||
expect(html).toContain('Settings → Agent Session Search')
|
||||
expect(html).toContain('session-search-visual')
|
||||
})
|
||||
|
||||
it('shows live progress and lets indexing continue in the background', () => {
|
||||
const html = render('indexing', {
|
||||
...unavailableSessionSearchStatus(),
|
||||
enabled: true,
|
||||
phase: 'indexing',
|
||||
filesIndexed: 120,
|
||||
filesDue: 380,
|
||||
messagesIndexed: 5000
|
||||
})
|
||||
expect(html).toContain('Indexing your agent sessions')
|
||||
expect(html).toContain('Settings → Agent Session Search')
|
||||
expect(html).toContain('120 of 500 sessions')
|
||||
expect(html).toContain('Continue in background')
|
||||
expect(html).not.toMatch(/<button[^>]*\sdisabled=""/)
|
||||
expect(html).toContain('session-search-visual')
|
||||
})
|
||||
|
||||
it('does not call a not-yet-started indexer unavailable', () => {
|
||||
const html = render('indexing', { ...unavailableSessionSearchStatus(), enabled: true })
|
||||
expect(html).toContain('Finding your sessions')
|
||||
expect(html).not.toContain('not available')
|
||||
})
|
||||
|
||||
it('offers to start searching once the index is ready', () => {
|
||||
const html = render('ready', {
|
||||
...unavailableSessionSearchStatus(),
|
||||
enabled: true,
|
||||
phase: 'current',
|
||||
filesIndexed: 500,
|
||||
lastSweepCompletedAt: 1
|
||||
})
|
||||
expect(html).toContain('Session search is ready')
|
||||
expect(html).toContain('Start searching')
|
||||
expect(html).toContain('Settings → Agent Session Search')
|
||||
expect(html).not.toMatch(/<button[^>]*\sdisabled=""/)
|
||||
})
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { VoiceDictationFeatureTipVisual } from './VoiceDictationFeatureTipVisual'
|
||||
|
||||
const prefersReducedMotionMock = vi.hoisted(() => vi.fn(() => false))
|
||||
const shortcutMock = vi.hoisted(() => vi.fn(() => ({ keys: ['⌘', 'E'], doubleTap: false })))
|
||||
|
||||
vi.mock('@/components/feature-wall/feature-wall-modal-helpers', () => ({
|
||||
usePrefersReducedMotion: prefersReducedMotionMock
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useShortcutKeyDetails: shortcutMock
|
||||
}))
|
||||
|
||||
async function renderVisual(): Promise<{ container: HTMLDivElement; root: Root }> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
await act(async () => root.render(<VoiceDictationFeatureTipVisual />))
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
describe('VoiceDictationFeatureTipVisual', () => {
|
||||
beforeEach(() => {
|
||||
prefersReducedMotionMock.mockReturnValue(false)
|
||||
shortcutMock.mockReturnValue({ keys: ['⌘', 'E'], doubleTap: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('types the dictated prompt once and settles on the complete sentence', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { container, root } = await renderVisual()
|
||||
const prompt = container.querySelector('[data-testid="dictated-prompt"]')
|
||||
|
||||
expect(prompt?.textContent).toBe('')
|
||||
await act(async () => vi.advanceTimersByTime(650))
|
||||
expect(prompt?.textContent).toBe('R')
|
||||
await act(async () => vi.runAllTimers())
|
||||
expect(prompt?.textContent).toBe(
|
||||
'Review this diff for edge cases and add tests for anything you find.'
|
||||
)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
await act(async () => root.unmount())
|
||||
})
|
||||
|
||||
it('gives the prompt the full remaining row width', () => {
|
||||
const html = renderToStaticMarkup(<VoiceDictationFeatureTipVisual />)
|
||||
const promptClass = html.match(/data-testid="dictated-prompt" class="([^"]+)"/)?.[1]
|
||||
|
||||
expect(promptClass).toContain('min-w-0')
|
||||
expect(promptClass).toContain('flex-1')
|
||||
expect(promptClass).not.toContain('max-w-')
|
||||
})
|
||||
|
||||
it('renders the completed prompt without timers for reduced motion', async () => {
|
||||
prefersReducedMotionMock.mockReturnValue(true)
|
||||
const setTimeoutSpy = vi.spyOn(window, 'setTimeout')
|
||||
const { container, root } = await renderVisual()
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
'Review this diff for edge cases and add tests for anything you find.'
|
||||
)
|
||||
expect(container.innerHTML).not.toContain('animate-waveform')
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => root.unmount())
|
||||
})
|
||||
|
||||
it('does not show a shortcut that the user has unassigned', () => {
|
||||
shortcutMock.mockReturnValue({ keys: [], doubleTap: false })
|
||||
|
||||
const html = renderToStaticMarkup(<VoiceDictationFeatureTipVisual />)
|
||||
|
||||
expect(html).not.toContain('Start dictation')
|
||||
expect(html).not.toContain('Ctrl')
|
||||
})
|
||||
})
|
||||
@@ -1,134 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, type ReactNode, type RefObject } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { FEATURE_TIPS, type FeatureTip } from '../../../../shared/feature-tips'
|
||||
import { VoiceDictationTipDialog } from './VoiceDictationTipDialog'
|
||||
|
||||
const shortcutMock = vi.hoisted(() => vi.fn(() => ({ keys: ['⌘', 'E'], doubleTap: false })))
|
||||
const dialogContentPropsMock = vi.hoisted(() => vi.fn())
|
||||
const featureTipActionsPropsMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useShortcutKeyDetails: shortcutMock
|
||||
}))
|
||||
|
||||
vi.mock('./VoiceDictationFeatureTipVisual', () => ({
|
||||
VoiceDictationFeatureTipVisual: () => <div data-testid="voice-visual" />
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode
|
||||
onOpenAutoFocus?: (event: Event) => void
|
||||
}) => {
|
||||
dialogContentPropsMock(props)
|
||||
return <div>{children}</div>
|
||||
},
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <footer>{children}</footer>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>
|
||||
}))
|
||||
|
||||
vi.mock('./FeatureTipActions', () => ({
|
||||
FeatureTipActions: (props: { primaryButtonRef?: RefObject<HTMLButtonElement | null> }) => {
|
||||
featureTipActionsPropsMock(props)
|
||||
return <button ref={props.primaryButtonRef}>Primary action</button>
|
||||
}
|
||||
}))
|
||||
|
||||
function getVoiceTip(): FeatureTip {
|
||||
const tip = FEATURE_TIPS.find((entry) => entry.id === 'voice-dictation')
|
||||
if (!tip) {
|
||||
throw new Error('Expected voice-dictation feature tip fixture')
|
||||
}
|
||||
return { ...tip }
|
||||
}
|
||||
|
||||
function renderDialog(): string {
|
||||
return renderToStaticMarkup(
|
||||
<VoiceDictationTipDialog
|
||||
open
|
||||
tip={getVoiceTip()}
|
||||
primaryBusy={false}
|
||||
onOpenChange={vi.fn()}
|
||||
onPrimaryAction={vi.fn()}
|
||||
onSkip={vi.fn()}
|
||||
onVoiceSettingsClick={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('VoiceDictationTipDialog', () => {
|
||||
beforeEach(() => {
|
||||
shortcutMock.mockReturnValue({ keys: ['⌘', 'E'], doubleTap: false })
|
||||
dialogContentPropsMock.mockClear()
|
||||
featureTipActionsPropsMock.mockClear()
|
||||
})
|
||||
|
||||
it('uses the established feature-tip layout and durable copy', () => {
|
||||
const html = renderDialog()
|
||||
|
||||
expect(html).toContain('TIP')
|
||||
expect(html).toContain('Dictate into any pane')
|
||||
expect(html).not.toContain('Turn speech into text wherever')
|
||||
expect(html).not.toContain('is here')
|
||||
})
|
||||
|
||||
it('shows the live dictation shortcut and voice settings path', () => {
|
||||
const html = renderDialog()
|
||||
|
||||
expect(html).toContain('⌘')
|
||||
expect(html).toContain('E')
|
||||
expect(html).toContain('to start voice dictation. Press')
|
||||
expect(html).toContain('again to stop.')
|
||||
expect(html).toContain('Settings → Voice')
|
||||
})
|
||||
|
||||
it('uses generic instructions instead of a shortcut the user has unassigned', () => {
|
||||
shortcutMock.mockReturnValue({ keys: [], doubleTap: false })
|
||||
|
||||
const html = renderDialog()
|
||||
|
||||
expect(html).toContain(
|
||||
'Assign a dictation shortcut before starting voice dictation in a focused pane.'
|
||||
)
|
||||
expect(html).not.toContain('to start voice dictation. Press')
|
||||
expect(html).not.toContain('Ctrl')
|
||||
})
|
||||
|
||||
it('moves initial focus to the primary action', async () => {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<VoiceDictationTipDialog
|
||||
open
|
||||
tip={getVoiceTip()}
|
||||
primaryBusy={false}
|
||||
onOpenChange={vi.fn()}
|
||||
onPrimaryAction={vi.fn()}
|
||||
onSkip={vi.fn()}
|
||||
onVoiceSettingsClick={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
const event = { preventDefault: vi.fn() } as unknown as Event
|
||||
|
||||
dialogContentPropsMock.mock.lastCall?.[0].onOpenAutoFocus(event)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(document.activeElement?.textContent).toBe('Primary action')
|
||||
|
||||
await act(async () => root.unmount())
|
||||
container.remove()
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { BrowserAnimatedVisual } from './BrowserAnimatedVisual'
|
||||
import { EditorAnimatedVisual } from './EditorAnimatedVisual'
|
||||
import { ReviewPRViewAnimatedVisual } from './ReviewPRViewAnimatedVisual'
|
||||
import { WorkbenchAnimatedVisual } from './WorkbenchAnimatedVisual'
|
||||
|
||||
const originalUserAgent = navigator.userAgent
|
||||
|
||||
function setUserAgent(userAgent: string): void {
|
||||
Object.defineProperty(navigator, 'userAgent', {
|
||||
configurable: true,
|
||||
value: userAgent
|
||||
})
|
||||
}
|
||||
|
||||
describe('feature wall shortcut labels', () => {
|
||||
afterEach(() => {
|
||||
setUserAgent(originalUserAgent)
|
||||
})
|
||||
|
||||
it('renders Windows shortcut copy in workbench and browser visuals', () => {
|
||||
setUserAgent('Windows NT 10.0')
|
||||
|
||||
const html = [
|
||||
renderToStaticMarkup(<BrowserAnimatedVisual reducedMotion />),
|
||||
renderToStaticMarkup(<WorkbenchAnimatedVisual reducedMotion />),
|
||||
renderToStaticMarkup(<EditorAnimatedVisual reducedMotion />),
|
||||
renderToStaticMarkup(<ReviewPRViewAnimatedVisual reducedMotion />)
|
||||
].join('\n')
|
||||
|
||||
expect(html).toContain('Ctrl+Shift+B')
|
||||
expect(html).toContain('Ctrl+Shift+D')
|
||||
expect(html).toContain('Alt+Shift+D')
|
||||
expect(html).toContain('Ctrl+B')
|
||||
expect(html).toContain('Ctrl+I')
|
||||
expect(html).toContain('Checks')
|
||||
expect(html).not.toContain('⌘')
|
||||
})
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_CATALOG } from '@/lib/agent-catalog'
|
||||
import { AgentStep } from './AgentStep'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
describe('AgentStep', () => {
|
||||
it('shows the collapsed fallback agents summary', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<AgentStep
|
||||
selectedAgent={null}
|
||||
onSelect={vi.fn()}
|
||||
detectedSet={new Set([AGENT_CATALOG[0].id])}
|
||||
isDetecting={false}
|
||||
yoloPermissions
|
||||
onYoloPermissionsChange={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
expect(html).toContain(`Show ${AGENT_CATALOG.length - 1} more agents →`)
|
||||
expect(html).toContain('data-agent-grid-scroll')
|
||||
expect(html).toContain('data-slot="switch"')
|
||||
expect(html).toContain('Yolo mode')
|
||||
expect(html).not.toContain('role="radiogroup"')
|
||||
})
|
||||
|
||||
it('labels the fallback agents summary as hide when expanded', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<TooltipProvider>
|
||||
<AgentStep
|
||||
selectedAgent={AGENT_CATALOG[1].id}
|
||||
onSelect={vi.fn()}
|
||||
detectedSet={new Set([AGENT_CATALOG[0].id])}
|
||||
isDetecting={false}
|
||||
yoloPermissions
|
||||
onYoloPermissionsChange={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
expect(html).toContain('Hide agents')
|
||||
expect(html).not.toContain(`Show ${AGENT_CATALOG.length - 1} more agents →`)
|
||||
})
|
||||
})
|
||||
@@ -1,65 +0,0 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { WindowsTerminalStep } from './WindowsTerminalStep'
|
||||
|
||||
function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
|
||||
return {
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
terminalWindowsWslDistro: null,
|
||||
terminalRightClickToPaste: true,
|
||||
...overrides
|
||||
} as GlobalSettings
|
||||
}
|
||||
|
||||
describe('WindowsTerminalStep', () => {
|
||||
it('renders default shell and right-click behavior choices', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<WindowsTerminalStep settings={createSettings()} updateSettings={vi.fn()} />
|
||||
)
|
||||
|
||||
expect(html).toContain('Default Shell')
|
||||
expect(html).toContain('PowerShell')
|
||||
expect(html).toContain('Command Prompt')
|
||||
expect(html).toContain('Right-click behavior')
|
||||
expect(html).toContain('Paste on right-click')
|
||||
expect(html).toContain('Open context menu')
|
||||
expect(html).toContain('role="radiogroup"')
|
||||
expect(html).toContain('aria-checked="true"')
|
||||
expect(html).toContain('aria-pressed="true"')
|
||||
expect(html).toContain('fill="#2E74B5"')
|
||||
expect(html).not.toContain('lucide-terminal')
|
||||
})
|
||||
|
||||
it('keeps the WSL distro control visible when WSL is already selected', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<WindowsTerminalStep
|
||||
settings={createSettings({
|
||||
terminalWindowsShell: 'wsl.exe',
|
||||
terminalWindowsWslDistro: 'Debian'
|
||||
})}
|
||||
updateSettings={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(html).toContain('WSL')
|
||||
expect(html).toContain('WSL Distribution')
|
||||
})
|
||||
|
||||
it('renders Git Bash with the Git Bash mark instead of a text badge', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<WindowsTerminalStep
|
||||
settings={createSettings({
|
||||
terminalWindowsShell: WINDOWS_GIT_BASH_SHELL
|
||||
})}
|
||||
updateSettings={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(html).toContain('Git Bash')
|
||||
expect(html).toContain('gwindows_logo.svg')
|
||||
expect(html).not.toContain('>Git<')
|
||||
expect(html).not.toContain('>Git<')
|
||||
})
|
||||
})
|
||||
@@ -80,7 +80,7 @@ describe('ModifierDoubleTapDetector', () => {
|
||||
const d = new ModifierDoubleTapDetector()
|
||||
d.process(down('Shift'), 0)
|
||||
// A missed keyUp makes a fresh keyDown restart the first tap.
|
||||
d.process(down('Shift'), 50)
|
||||
expect(d.process(down('Shift'), 50)).toBeNull()
|
||||
d.process(up('Shift'), 60)
|
||||
// The next press within the window still completes the gesture.
|
||||
expect(d.process(down('Shift'), 200)).toEqual({ modifier: 'Shift' })
|
||||
|
||||
@@ -37,9 +37,8 @@ const requested = (promptId = PROMPT_ID): string =>
|
||||
const settled = (promptId = PROMPT_ID): string =>
|
||||
logLine({ kind: 'user_input_prompt_settled', prompt_id: promptId, outcome: 'answered' })
|
||||
|
||||
function localShard(sessionId: string): string[] {
|
||||
const hex = sessionId.replace(/-/g, '').slice(0, 12)
|
||||
const date = new Date(Number.parseInt(hex, 16))
|
||||
function sessionShard(): string[] {
|
||||
const date = new Date('2026-09-22T19:41:18.839Z')
|
||||
return [
|
||||
String(date.getFullYear()),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
@@ -53,7 +52,7 @@ describe('muse session log', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
sessionsDir = mkdtempSync(join(tmpdir(), 'muse-session-log-'))
|
||||
const dir = join(sessionsDir, ...localShard(SESSION_ID), SESSION_ID)
|
||||
const dir = join(sessionsDir, ...sessionShard(), SESSION_ID)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
logPath = join(dir, 'session.jsonl')
|
||||
})
|
||||
@@ -69,9 +68,11 @@ describe('muse session log', () => {
|
||||
|
||||
it('returns undefined for a missing log or a non-v7 session id', () => {
|
||||
expect(findMuseSessionLogPath(SESSION_ID, sessionsDir)).toBeUndefined()
|
||||
expect(
|
||||
findMuseSessionLogPath('1471f5e6-bd60-41a2-bfcf-c9086dbd4092', sessionsDir)
|
||||
).toBeUndefined()
|
||||
const nonV7 = '01a0caa3-0e77-4d41-bad7-46283a45633d'
|
||||
const dir = join(sessionsDir, ...sessionShard(), nonV7)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'session.jsonl'), '')
|
||||
expect(findMuseSessionLogPath(nonV7, sessionsDir)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads prompts batched inside a retained_frame', () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { normalizePromptField } from './agent-status-field-normalization'
|
||||
import { selectStructuredAgentTurnActivity } from './native-chat-turn-activity'
|
||||
|
||||
it('does not trim every preceding activity line to select the final activity', () => {
|
||||
@@ -17,59 +16,26 @@ it('does not trim every preceding activity line to select the final activity', (
|
||||
})
|
||||
|
||||
it('preserves the last nonempty LF-delimited line before prompt normalization', () => {
|
||||
const texts = [
|
||||
'',
|
||||
'\n',
|
||||
'\n\n',
|
||||
' \r\n\t',
|
||||
'first\rsecond',
|
||||
'\nfirst\r\nsecond\r\n',
|
||||
'first\n\u00a0\u2003\n',
|
||||
'first\n\u2028second\u2029',
|
||||
'a\n😀',
|
||||
'a\n\ud800',
|
||||
'a\n\udc00',
|
||||
`a\n${'x'.repeat(199)}😀`,
|
||||
`first\n${' '.repeat(3000)}last`,
|
||||
'first\n\0\n',
|
||||
'first\n\ufeff\n'
|
||||
const cases: [string, string | null][] = [
|
||||
['', null],
|
||||
['\n', null],
|
||||
['\n\n', null],
|
||||
[' \r\n\t', null],
|
||||
['first\rsecond', 'first second'],
|
||||
['\nfirst\r\nsecond\r\n', 'second'],
|
||||
['first\n\u00a0\u2003\n', 'first'],
|
||||
['first\n\u2028second\u2029', 'second'],
|
||||
['a\n😀', '😀'],
|
||||
['a\n\ud800', '\ud800'],
|
||||
['a\n\udc00', '\udc00'],
|
||||
[`a\n${'x'.repeat(199)}😀`, 'x'.repeat(199)],
|
||||
[`first\n${' '.repeat(3000)}last`, 'last'],
|
||||
['first\n\0\n', '\0'],
|
||||
['first\n\ufeff\n', 'first']
|
||||
]
|
||||
const alphabet = [
|
||||
'a',
|
||||
' ',
|
||||
'\n',
|
||||
'\r',
|
||||
'\t',
|
||||
'\u00a0',
|
||||
'\u2003',
|
||||
'\u2028',
|
||||
'\ufeff',
|
||||
'😀',
|
||||
'\ud800',
|
||||
'\udc00'
|
||||
]
|
||||
let seed = 57
|
||||
const next = () => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
|
||||
return seed
|
||||
}
|
||||
for (let index = 0; index < 1000; index++) {
|
||||
let text = ''
|
||||
const length = next() % 300
|
||||
for (let offset = 0; offset < length; offset++) {
|
||||
text += alphabet[next() % alphabet.length]
|
||||
}
|
||||
texts.push(text)
|
||||
}
|
||||
for (const text of texts) {
|
||||
const line = text
|
||||
.split('\n')
|
||||
.map((part) => part.trim())
|
||||
.findLast((part) => part.length > 0)
|
||||
const normalized = line ? normalizePromptField(line) : ''
|
||||
const expected = normalized ? { kind: 'description', text: normalized } : null
|
||||
for (const [text, expected] of cases) {
|
||||
expect(selectStructuredAgentTurnActivity([], 'turn', { turnId: 'turn', text })).toEqual(
|
||||
expected
|
||||
expected ? { kind: 'description', text: expected } : null
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,31 +8,6 @@ import {
|
||||
} from './native-chat-agent-profiles'
|
||||
|
||||
describe('native chat agent picker profiles', () => {
|
||||
// Composer trigger stays /; skillPrefix changes only picked-skill insertion.
|
||||
it('keeps Codex skills invocable as dollar tokens', () => {
|
||||
expect(getNativeChatAgentProfile('codex')).toMatchObject({
|
||||
skillPrefix: '$',
|
||||
skillSourceOwner: 'codex'
|
||||
})
|
||||
})
|
||||
|
||||
it('writes Claude-family and Grok skills as slash tokens', () => {
|
||||
expect(getNativeChatAgentProfile('claude')).toMatchObject({
|
||||
skillPrefix: '/',
|
||||
skillSourceOwner: 'claude'
|
||||
})
|
||||
expect(getNativeChatAgentProfile('openclaude')).toMatchObject({ skillSourceOwner: 'claude' })
|
||||
expect(getNativeChatAgentProfile('grok')).toMatchObject({
|
||||
skillPrefix: '/',
|
||||
skillSourceOwner: 'grok'
|
||||
})
|
||||
})
|
||||
|
||||
// OpenCode 2.x reads the same skill folders as 1.x.
|
||||
it("lists OpenCode's skills for OpenCode 2", () => {
|
||||
expect(getNativeChatAgentProfile('opencode2')).toMatchObject({ skillSourceOwner: 'opencode' })
|
||||
})
|
||||
|
||||
it('does not grant custom or unverified agents a skill grammar', () => {
|
||||
expect(getNativeChatAgentProfile('custom-agent')).toBeNull()
|
||||
})
|
||||
@@ -72,26 +47,15 @@ describe('native chat command replies', () => {
|
||||
expect(getNativeChatCommandReply('omp', 'compact')).toBeNull()
|
||||
})
|
||||
|
||||
it('declares only /context, the one reply the desktop chat implements', () => {
|
||||
for (const agent of ['claude', 'openclaude', 'codex', 'omp', 'grok', 'custom-agent']) {
|
||||
const declared = getVerifiedNativeChatCommands(agent).filter(({ reply }) => reply)
|
||||
expect(declared.map(({ name }) => name)).toEqual(
|
||||
agent === 'omp' || agent === 'openclaude' ? ['context'] : []
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves out of the agent-answered catalog only the commands that declare a reply', () => {
|
||||
const names = (agent: string) =>
|
||||
getAgentAnsweredNativeChatCommands(agent).map(({ name }) => name)
|
||||
expect(names('openclaude')).toEqual(names('claude'))
|
||||
expect(names('omp')).toEqual(
|
||||
getVerifiedNativeChatCommands('omp')
|
||||
.map(({ name }) => name)
|
||||
.filter((name) => name !== 'context')
|
||||
)
|
||||
expect(getAgentAnsweredNativeChatCommands('codex')).toEqual(
|
||||
getVerifiedNativeChatCommands('codex')
|
||||
)
|
||||
expect(names('openclaude')).toContain('compact')
|
||||
expect(names('openclaude')).not.toContain('context')
|
||||
expect(names('omp')).toContain('model')
|
||||
expect(names('omp')).toContain('usage')
|
||||
expect(names('omp')).not.toContain('context')
|
||||
expect(names('codex')).toContain('permissions')
|
||||
expect(names('grok')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,158 +1,18 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { extractPendingAsk } from './native-chat-ask'
|
||||
import { isInterruptedStatusMessage, type NativeChatMessage } from './native-chat-types'
|
||||
import type { AskPrompt } from './native-chat-ask-types'
|
||||
|
||||
/** Verbatim pre-cursor shift() FIFO oracle; canonical question input permits inline parsing. */
|
||||
function referenceExtractPendingAsk(messages: readonly NativeChatMessage[]): AskPrompt | null {
|
||||
let pending: AskPrompt | null = null
|
||||
const outstanding: (AskPrompt | null)[] = []
|
||||
for (const message of messages) {
|
||||
if (message.role === 'user' || isInterruptedStatusMessage(message)) {
|
||||
outstanding.length = 0
|
||||
pending = null
|
||||
}
|
||||
for (const block of message.blocks) {
|
||||
if (block.type === 'tool-call') {
|
||||
const input = block.input as { questions?: unknown[] } | undefined
|
||||
const parsed =
|
||||
Array.isArray(input?.questions) && input.questions.length > 0
|
||||
? ({ questions: input.questions } as unknown as AskPrompt)
|
||||
: null
|
||||
if (parsed) {
|
||||
pending = parsed
|
||||
}
|
||||
outstanding.push(parsed)
|
||||
} else if (block.type === 'tool-result' && outstanding.length > 0) {
|
||||
const resolved = outstanding.shift()
|
||||
if (resolved && resolved === pending) {
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return pending
|
||||
}
|
||||
import type { NativeChatMessage } from './native-chat-types'
|
||||
|
||||
type Block = NativeChatMessage['blocks'][number]
|
||||
|
||||
function questionTexts(prompt: AskPrompt | null): string[] | null {
|
||||
return prompt ? (prompt.questions as { question?: string }[]).map((q) => q.question ?? '') : null
|
||||
}
|
||||
|
||||
function ask(text: string): Block {
|
||||
return { type: 'tool-call', name: 'AskUserQuestion', input: { questions: [{ question: text }] } }
|
||||
}
|
||||
function plainCall(): Block {
|
||||
return { type: 'tool-call', name: 'Bash', input: { command: 'ls' } }
|
||||
}
|
||||
function toolResult(): Block {
|
||||
return { type: 'tool-result', output: 'ok' }
|
||||
}
|
||||
function assistant(id: string, blocks: Block[]): NativeChatMessage {
|
||||
return { id, role: 'assistant', timestamp: 1, source: 'transcript', blocks }
|
||||
}
|
||||
function userTurn(id: string): NativeChatMessage {
|
||||
return { id, role: 'user', timestamp: 1, source: 'transcript', blocks: [] }
|
||||
}
|
||||
function interrupted(id: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'system',
|
||||
timestamp: 1,
|
||||
source: 'transcript',
|
||||
blocks: [{ type: 'text', text: 'Interrupted by user' }]
|
||||
} as NativeChatMessage
|
||||
}
|
||||
|
||||
function makeRandom(seed: number): () => number {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0
|
||||
return state / 0x100000000
|
||||
}
|
||||
}
|
||||
|
||||
function randomTranscript(random: () => number, messageCount: number): NativeChatMessage[] {
|
||||
const messages: NativeChatMessage[] = []
|
||||
let askSeq = 0
|
||||
for (let m = 0; m < messageCount; m += 1) {
|
||||
const roll = random()
|
||||
if (roll < 0.12) {
|
||||
messages.push(userTurn(`u${m}`))
|
||||
continue
|
||||
}
|
||||
if (roll < 0.2) {
|
||||
messages.push(interrupted(`i${m}`))
|
||||
continue
|
||||
}
|
||||
const blocks: Block[] = []
|
||||
const blockCount = 1 + Math.floor(random() * 6)
|
||||
for (let b = 0; b < blockCount; b += 1) {
|
||||
const kind = random()
|
||||
if (kind < 0.3) {
|
||||
blocks.push(ask(`q${(askSeq += 1)}`))
|
||||
} else if (kind < 0.6) {
|
||||
blocks.push(plainCall())
|
||||
} else if (kind < 0.9) {
|
||||
blocks.push(toolResult())
|
||||
} else {
|
||||
blocks.push({ type: 'text', text: 'chatter' })
|
||||
}
|
||||
}
|
||||
messages.push(assistant(`a${m}`, blocks))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
it('matches the shift-based FIFO on randomized add/resolve/cancel sequences', () => {
|
||||
let pendingCases = 0
|
||||
let clearedCases = 0
|
||||
for (let seed = 1; seed <= 4000; seed += 1) {
|
||||
const random = makeRandom(seed)
|
||||
const messages = randomTranscript(random, 1 + Math.floor(random() * 12))
|
||||
const actual = extractPendingAsk(messages)
|
||||
// Compare by content: the oracle keeps raw question objects, production re-parses them.
|
||||
expect(questionTexts(actual), `seed ${seed}`).toEqual(
|
||||
questionTexts(referenceExtractPendingAsk(messages))
|
||||
)
|
||||
if (actual) {
|
||||
pendingCases += 1
|
||||
} else {
|
||||
clearedCases += 1
|
||||
}
|
||||
}
|
||||
// Both verdicts must be well represented or the sweep proves nothing.
|
||||
expect(pendingCases).toBeGreaterThan(500)
|
||||
expect(clearedCases).toBeGreaterThan(500)
|
||||
})
|
||||
|
||||
it('returns the same ask when a reconnect replays the transcript incrementally', () => {
|
||||
for (let seed = 1; seed <= 500; seed += 1) {
|
||||
const random = makeRandom(seed)
|
||||
const messages = randomTranscript(random, 1 + Math.floor(random() * 10))
|
||||
// Reconnect extraction must match at every streamed prefix, avoiding dropped or duplicate asks.
|
||||
for (let end = 1; end <= messages.length; end += 1) {
|
||||
const prefix = messages.slice(0, end)
|
||||
expect(questionTexts(extractPendingAsk(prefix)), `seed ${seed} prefix ${end}`).toEqual(
|
||||
questionTexts(referenceExtractPendingAsk(prefix))
|
||||
)
|
||||
}
|
||||
// Extraction is pure: replaying the identical transcript yields the identical verdict.
|
||||
expect(extractPendingAsk(messages)).toEqual(extractPendingAsk(messages.slice()))
|
||||
}
|
||||
})
|
||||
|
||||
it('stays linear over a large call/result batch', () => {
|
||||
const blocks: Block[] = [
|
||||
...Array.from({ length: 100_000 }, (_, i) => ask(`q${i}`)),
|
||||
...Array.from({ length: 100_000 }, () => toolResult())
|
||||
]
|
||||
const started = Date.now()
|
||||
expect(extractPendingAsk([assistant('m', blocks)])).toBeNull()
|
||||
// The removed shift() FIFO was quadratic here (~5e9 element moves) and took minutes.
|
||||
expect(Date.now() - started).toBeLessThan(3000)
|
||||
})
|
||||
|
||||
it('keeps the newest ask pending when an older duplicate-content ask resolves first', () => {
|
||||
const pending = extractPendingAsk([assistant('m', [ask('same'), ask('same'), toolResult()])])
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('extractPendingAsk', () => {
|
||||
expect(pending?.questions[0]?.question).toBe('Deploy?')
|
||||
})
|
||||
|
||||
it("clears the ask when its own result arrives, keeping the newest ask's identity", () => {
|
||||
it('keeps the newest ask when an older call receives its result', () => {
|
||||
const first = { questions: [{ question: 'First?', options: [] }] }
|
||||
const pending = extractPendingAsk([
|
||||
message('m1', [
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildNativeChatImagePasteBytes,
|
||||
buildNativeChatPasteBytes,
|
||||
isMultilineDraft,
|
||||
NATIVE_CHAT_SUBMIT
|
||||
buildNativeChatPasteBytes
|
||||
} from './native-chat-paste-bytes'
|
||||
|
||||
const BEGIN = '\x1b[200~'
|
||||
const END = '\x1b[201~'
|
||||
|
||||
describe('NATIVE_CHAT_SUBMIT', () => {
|
||||
it('is a bare carriage return so the Enter write is unambiguous', () => {
|
||||
expect(NATIVE_CHAT_SUBMIT).toBe('\r')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNativeChatPasteBytes', () => {
|
||||
it('single-line text has no trailing submit (Enter is written separately)', () => {
|
||||
expect(buildNativeChatPasteBytes('hello world')).toBe('hello world')
|
||||
@@ -52,12 +44,3 @@ describe('buildNativeChatImagePasteBytes', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMultilineDraft', () => {
|
||||
it('is false for single-line', () => {
|
||||
expect(isMultilineDraft('one line')).toBe(false)
|
||||
})
|
||||
it('is true when a newline is present', () => {
|
||||
expect(isMultilineDraft('a\nb')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
nativeChatReasoningDisclosureKey,
|
||||
nativeChatReasoningHeadline,
|
||||
nativeChatReasoningHeadlineText,
|
||||
selectNativeChatLiveReasoning
|
||||
@@ -67,10 +66,6 @@ describe('the open block the live line discloses', () => {
|
||||
expect(select([prompt, child])).toBeNull()
|
||||
expect(select([prompt, open, child])).toBe(open)
|
||||
})
|
||||
|
||||
it('keys one block the same for the line and the row, apart from tool runs', () => {
|
||||
expect(nativeChatReasoningDisclosureKey('r-1')).toBe('reasoning:r-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the reasoning headline', () => {
|
||||
|
||||
@@ -122,13 +122,17 @@ describe('recordNativeChatSessionOptionCommand', () => {
|
||||
})
|
||||
|
||||
it('clears tracked truth for a bare picker command and reports the agent picker', () => {
|
||||
const claude = claudeRecord('sonnet')
|
||||
claude.valuesByModel.sonnet = { effort: { value: 'low', source: 'dispatched' } }
|
||||
const claudeResult = recordNativeChatSessionOptionCommand({
|
||||
catalog: CLAUDE_SESSION_OPTION_CATALOG,
|
||||
models: CLAUDE_SESSION_OPTION_CATALOG.models,
|
||||
record: claudeRecord('sonnet'),
|
||||
record: claude,
|
||||
command: '/model'
|
||||
})
|
||||
expect(claudeResult.opensAgentPicker).toBe(true)
|
||||
expect(claudeResult).toEqual({ changed: true, opensAgentPicker: true })
|
||||
expect(claude.model).toBeUndefined()
|
||||
expect(claude.valuesByModel.sonnet).toBeUndefined()
|
||||
const codexRecord = createNativeChatSessionOptionRecord('codex')
|
||||
codexRecord.model = { value: 'gpt-5.5', source: 'dispatched' }
|
||||
const codexResult = recordNativeChatSessionOptionCommand({
|
||||
@@ -356,6 +360,7 @@ describe('recordNativeChatSessionOptionCommand for grok', () => {
|
||||
|
||||
it('still resets tracked state when the typed model is a real switch', () => {
|
||||
const record = grokRecord()
|
||||
record.valuesByModel['grok-build'] = { effort: { value: 'high', source: 'reported' } }
|
||||
recordGrok(record, '/effort low')
|
||||
recordNativeChatSessionOptionCommand({
|
||||
catalog: GROK_SESSION_OPTION_CATALOG,
|
||||
@@ -367,6 +372,7 @@ describe('recordNativeChatSessionOptionCommand for grok', () => {
|
||||
command: '/model grok-build'
|
||||
})
|
||||
expect(record.model).toEqual({ value: 'grok-build', source: 'dispatched' })
|
||||
expect(record.valuesByModel['grok-build']).toBeUndefined()
|
||||
expect(record.valuesByModel['grok-4.7']?.effort).toEqual({
|
||||
value: 'low',
|
||||
source: 'dispatched'
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('buildNativeChatSessionOptionSnapshot', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('routes Codex model changes through its typed TUI picker', () => {
|
||||
it('describes Codex model changes as agent-picker actions', () => {
|
||||
const snapshot = buildNativeChatSessionOptionSnapshot({
|
||||
catalog: CODEX_SESSION_OPTION_CATALOG,
|
||||
models: CODEX_SESSION_OPTION_CATALOG.models,
|
||||
@@ -368,7 +368,7 @@ describe('defaults on load', () => {
|
||||
})
|
||||
const effort = snapshot.find((descriptor) => descriptor.id === 'effort')
|
||||
expect(effort).toMatchObject({ valueSource: 'default' })
|
||||
expect(effort?.kind.type === 'select' ? effort.kind.currentValue : null).toBeDefined()
|
||||
expect(effort?.kind.type === 'select' ? effort.kind.currentValue : null).toBe('high')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -427,7 +427,7 @@ describe('a boolean option always carries a value to render', () => {
|
||||
expect(effort).toMatchObject({ valueSource: 'unknown' })
|
||||
})
|
||||
|
||||
it('does not let the resolved display value reach the composed --model argument', () => {
|
||||
it('includes composed launch defaults only when requested', () => {
|
||||
// Composed model uses caller picks, never display descriptors; resolving a switch cannot alter dispatch.
|
||||
expect(resolveAgentSessionOptionLaunch('cursor', { model: 'claude-opus-4-8' })).toEqual({
|
||||
args: ['--model', 'claude-opus-4-8-thinking-high'],
|
||||
|
||||
@@ -39,12 +39,6 @@ describe('applyNativeChatReportedSessionOptions', () => {
|
||||
expect(applyNativeChatReportedSessionOptions(record, { model: 'sonnet' })).toBe(false)
|
||||
expect(record.valuesByModel.sonnet?.effort).toEqual({ value: 'high', source: 'dispatched' })
|
||||
})
|
||||
|
||||
it('is a no-op when the report matches tracked state', () => {
|
||||
const record = claudeRecord()
|
||||
record.model = { value: 'sonnet', source: 'reported' }
|
||||
expect(applyNativeChatReportedSessionOptions(record, { model: 'sonnet' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchNativeChatCatalogModelId', () => {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getNativeChatAgentProfile,
|
||||
getVerifiedNativeChatCommands
|
||||
} from './native-chat-agent-profiles'
|
||||
import {
|
||||
applySlashSuggestion,
|
||||
classifyNativeChatSend,
|
||||
@@ -13,19 +9,6 @@ import {
|
||||
} from './native-chat-slash-commands'
|
||||
|
||||
describe('OMP terminal command catalog', () => {
|
||||
it('offers the verified model, planning and context commands without generic help', () => {
|
||||
const commands = getAgentSlashCommands('omp')
|
||||
const names = commands.map((command) => command.name)
|
||||
expect(names).toEqual(
|
||||
expect.arrayContaining(['model', 'switch', 'plan', 'compact', 'context', 'usage'])
|
||||
)
|
||||
expect(names).not.toContain('help')
|
||||
expect(getVerifiedNativeChatCommands('omp')).toEqual(commands)
|
||||
expect(getNativeChatAgentProfile('omp')).toBeNull()
|
||||
expect(new Set(names).size).toBe(names.length)
|
||||
expect(getAgentSlashCommands('pi').map((command) => command.name)).toEqual(['clear', 'help'])
|
||||
})
|
||||
|
||||
it('completes arguments separately from picker command dispatch', () => {
|
||||
const matches = filterSlashCommands(getAgentSlashCommands('omp'), 'MOD')
|
||||
expect(matches).toHaveLength(1)
|
||||
|
||||
@@ -10,27 +10,6 @@ import {
|
||||
} from './native-chat-slash-commands'
|
||||
|
||||
describe('getAgentSlashCommands', () => {
|
||||
it('returns Codex-specific commands (e.g. /model, /resume) for codex', () => {
|
||||
const names = getAgentSlashCommands('codex').map((c) => c.name)
|
||||
expect(names).toContain('model')
|
||||
expect(names).toContain('resume')
|
||||
expect(names).toContain('diff')
|
||||
})
|
||||
|
||||
it('returns Claude commands for claude (no Codex-only /model)', () => {
|
||||
const names = getAgentSlashCommands('claude').map((c) => c.name)
|
||||
expect(names).toContain('clear')
|
||||
expect(names).toContain('compact')
|
||||
expect(names).not.toContain('model')
|
||||
// Claude's terminal writes no /context reply the chat could show.
|
||||
expect(names).not.toContain('context')
|
||||
})
|
||||
|
||||
it('offers OpenClaude /context, whose report lands in its transcript', () => {
|
||||
const names = getAgentSlashCommands('openclaude').map((c) => c.name)
|
||||
expect(names).toEqual([...getAgentSlashCommands('claude').map((c) => c.name), 'context'])
|
||||
})
|
||||
|
||||
it('falls back to a small common set for an unknown agent (never empty)', () => {
|
||||
const names = getAgentSlashCommands('some-other-agent').map((c) => c.name)
|
||||
expect(names).toEqual(['clear', 'help'])
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('nativeChatSubagentGroupHeader', () => {
|
||||
expect(header).toMatchObject({ alertState: 'failed', alert: '1 failed' })
|
||||
})
|
||||
|
||||
it('settles to the worst verdict, the run length and the tokens', () => {
|
||||
it('summarizes completed siblings with the run length and tokens', () => {
|
||||
const header = nativeChatSubagentGroupHeader(
|
||||
[
|
||||
child('a', 'completed', { startedAt: 0, settledAt: 60_000, tokens: 9_000 }),
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('normalizeNativeChatTaskList', () => {
|
||||
).toEqual(list(task('One'), task('Two')))
|
||||
})
|
||||
|
||||
it.each([undefined, null, 42, [], '{', '{}', { todos: null }, { todos: [{}] }])(
|
||||
it.each([[undefined], [null], [42], [[]], ['{'], ['{}'], [{ todos: null }], [{ todos: [{}] }]])(
|
||||
'returns null for malformed input %j',
|
||||
(input) => {
|
||||
expect(normalizeNativeChatTaskList('TodoWrite', input)).toBeNull()
|
||||
|
||||
@@ -31,43 +31,6 @@ describe('selectStructuredAgentTurnActivity', () => {
|
||||
expect(activity).toEqual({ kind: 'description', text: 'Preparing the answer' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'a provider retry warning',
|
||||
{
|
||||
kind: 'status',
|
||||
tone: 'warning',
|
||||
text: 'Claude hit a temporary problem and is retrying.'
|
||||
}
|
||||
],
|
||||
[
|
||||
'a compaction marker',
|
||||
{ kind: 'status', text: 'Context compacted', presentation: 'compaction' }
|
||||
],
|
||||
['a cancellation note', { kind: 'status', text: 'Cancellation requested.' }],
|
||||
[
|
||||
'a suppressed-notification summary',
|
||||
{ kind: 'status', text: '3 more provider notifications not shown for this turn' }
|
||||
]
|
||||
] satisfies [string, AgentJournalItemBody][])(
|
||||
'never turns %s from earlier in the turn into the live line',
|
||||
(_name, body) => {
|
||||
const later = item(3, {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
input: { command: 'pnpm test' },
|
||||
state: 'running'
|
||||
})
|
||||
|
||||
expect(
|
||||
selectStructuredAgentTurnActivity([turnStart, item(2, body), later], 'turn-1')
|
||||
).toBeNull()
|
||||
expect(
|
||||
selectStructuredAgentTurnActivity([turnStart, later, item(4, body)], 'turn-1')
|
||||
).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it("never puts the model's reasoning on the indicator line", () => {
|
||||
const reasoning = item(2, {
|
||||
kind: 'message',
|
||||
@@ -86,24 +49,6 @@ describe('selectStructuredAgentTurnActivity', () => {
|
||||
).toEqual({ kind: 'description', text: 'Running a command' })
|
||||
})
|
||||
|
||||
it('skips reasoning behind a typed turn item too', () => {
|
||||
const typedTurnStart = item(1, { kind: 'turn', turnId: 'turn-1', state: 'running' })
|
||||
|
||||
expect(
|
||||
selectStructuredAgentTurnActivity(
|
||||
[
|
||||
typedTurnStart,
|
||||
item(2, {
|
||||
kind: 'message',
|
||||
role: 'reasoning',
|
||||
blocks: [{ type: 'text', text: 'Weighing two approaches' }]
|
||||
})
|
||||
],
|
||||
'turn-1'
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('shows matching ephemeral provider activity, never an older journal status', () => {
|
||||
const activity = selectStructuredAgentTurnActivity(
|
||||
[turnStart, item(2, { kind: 'status', text: 'Older journal status' })],
|
||||
@@ -147,67 +92,6 @@ describe('selectStructuredAgentTurnActivity', () => {
|
||||
|
||||
expect(activity).toBeNull()
|
||||
})
|
||||
|
||||
it('does not read a journal status that repeats a recent tool label', () => {
|
||||
const activity = selectStructuredAgentTurnActivity(
|
||||
[
|
||||
turnStart,
|
||||
item(2, {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
input: { command: 'pnpm lint' },
|
||||
state: 'completed'
|
||||
}),
|
||||
item(3, { kind: 'status', text: 'Running pnpm lint' })
|
||||
],
|
||||
'turn-1'
|
||||
)
|
||||
|
||||
expect(activity).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores active and settled tools so the tail can use a broad fallback', () => {
|
||||
const activity = selectStructuredAgentTurnActivity(
|
||||
[
|
||||
turnStart,
|
||||
item(2, {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
input: { command: 'pnpm test' },
|
||||
state: 'running'
|
||||
}),
|
||||
item(3, {
|
||||
kind: 'tool-call',
|
||||
name: 'shell',
|
||||
input: { command: 'pnpm lint' },
|
||||
state: 'completed'
|
||||
})
|
||||
],
|
||||
'turn-1'
|
||||
)
|
||||
|
||||
expect(activity).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores diagnostic provider frames and returns nothing after the turn settles', () => {
|
||||
const diagnostic = item(2, {
|
||||
kind: 'status',
|
||||
text: 'codex · notification:new/event',
|
||||
providerFrame: {
|
||||
provider: 'codex',
|
||||
kind: 'notification:new/event',
|
||||
payload: {
|
||||
head: '{}',
|
||||
byteLength: 2,
|
||||
digest: 'a'.repeat(64),
|
||||
truncated: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(selectStructuredAgentTurnActivity([turnStart, diagnostic], 'turn-1')).toBeNull()
|
||||
expect(selectStructuredAgentTurnActivity([turnStart, diagnostic], null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectStructuredAgentTurnActivity — which agent it answers for', () => {
|
||||
|
||||
@@ -74,15 +74,6 @@ describe('nativeChatTurnFold', () => {
|
||||
expect([...foldedRows].sort()).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
it("never folds the reader's own message, which anchors the turn", () => {
|
||||
const { foldedRows } = nativeChatTurnFold({
|
||||
rows: TURN,
|
||||
settledTurnKeys: SETTLED,
|
||||
expandedTurnKeys: NONE
|
||||
})
|
||||
expect(foldedRows.has(0)).toBe(false)
|
||||
})
|
||||
|
||||
// A stored-only provider event draws nothing: a disclosure over it would open onto nothing.
|
||||
it('offers no disclosure for a turn whose only other row draws nothing', () => {
|
||||
const { foldedRows, foldableTurnKeys } = nativeChatTurnFold({
|
||||
|
||||
@@ -77,11 +77,6 @@ describe('nativeChatTurnBarRows', () => {
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('puts a turn opened by a rendered message under that message', () => {
|
||||
const messages = [message('A', 'user'), message('t1')]
|
||||
expect(nativeChatTurnBarRows(messages, ['A', 'A']).get('A')).toEqual({ index: 0, above: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeChatTurnDrawOrder', () => {
|
||||
|
||||
@@ -103,15 +103,6 @@ describe('buildNativeChatVisualDocument', () => {
|
||||
expect(new URL('#overview', 'about:srcdoc').href).toBe('about:srcdoc#overview')
|
||||
expect(() => new URL('page-2.html', 'about:srcdoc')).toThrow()
|
||||
})
|
||||
|
||||
it('checks the theme message comes from the host window', () => {
|
||||
expect(document).toContain('event.source !== host')
|
||||
})
|
||||
|
||||
it('answers a ping from the host with the ping id', () => {
|
||||
expect(document).toContain(`"ping":"${NATIVE_CHAT_VISUAL_PING_TYPE}"`)
|
||||
expect(document).toContain('send({ type: C.pong, id: data.id })')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeChatVisualThemeCss', () => {
|
||||
|
||||
@@ -144,10 +144,6 @@ describe('a run of provider retry rows', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it("draws an older host's single revised row as it is", () => {
|
||||
expect(drawn([retry(4)])).toEqual(['Codex is retrying: Reconnecting... 4/5.'])
|
||||
})
|
||||
|
||||
it('hands back the same list when nothing retried', () => {
|
||||
const messages: NativeChatMessage[] = [
|
||||
{ id: 'a', role: 'assistant', blocks: [], timestamp: 1, source: 'transcript' }
|
||||
|
||||
@@ -106,16 +106,6 @@ describe('deriveNativeChatStreamingText', () => {
|
||||
).toBe('Partial answer that is now much longer than before')
|
||||
})
|
||||
|
||||
it('keeps showing while the preview still leads (grows past the last turn)', () => {
|
||||
// The transcript hasn't flushed the new content yet; preview is longer.
|
||||
expect(
|
||||
deriveNativeChatStreamingText({
|
||||
messages: [assistant('Partial')],
|
||||
previewText: 'Partial answer that is now much longer than before',
|
||||
working: true
|
||||
})
|
||||
).toBe('Partial answer that is now much longer than before')
|
||||
})
|
||||
it('hides the sentence it streamed while a tool runs, but not a new reply after the tool', () => {
|
||||
// A Claude transcript writes the sentence and the tool call it led into as separate rows.
|
||||
const sentence = 'Checking the notes file now, then I will compare it with the settings.'
|
||||
|
||||
@@ -1,53 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isShellActivityToolCall,
|
||||
NATIVE_CHAT_TOOL_ICON_NAMES,
|
||||
nativeChatToolCategory,
|
||||
nativeChatToolIconName,
|
||||
nativeChatToolRunCategory,
|
||||
nativeChatToolRunIconName,
|
||||
type NativeChatToolCategory
|
||||
nativeChatToolRunIconName
|
||||
} from './native-chat-tool-icon'
|
||||
|
||||
const ALL_CATEGORIES: NativeChatToolCategory[] = [
|
||||
'read',
|
||||
'search',
|
||||
'listFiles',
|
||||
'unknown',
|
||||
'fileChange',
|
||||
'webSearch',
|
||||
'browser',
|
||||
'mcpToolCall',
|
||||
'subAgentActivity',
|
||||
'todoList',
|
||||
'other'
|
||||
]
|
||||
|
||||
describe('native chat tool icons', () => {
|
||||
it('names a glyph for every category in the vocabulary', () => {
|
||||
expect(NATIVE_CHAT_TOOL_ICON_NAMES).toEqual({
|
||||
read: 'eye',
|
||||
search: 'search',
|
||||
listFiles: 'folder',
|
||||
unknown: 'square-terminal',
|
||||
fileChange: 'pencil',
|
||||
webSearch: 'globe',
|
||||
browser: 'globe',
|
||||
mcpToolCall: 'plug',
|
||||
subAgentActivity: 'bot',
|
||||
todoList: 'list-checks',
|
||||
other: 'wrench'
|
||||
})
|
||||
expect(Object.keys(NATIVE_CHAT_TOOL_ICON_NAMES).sort()).toEqual([...ALL_CATEGORIES].sort())
|
||||
})
|
||||
|
||||
it('gives each category a distinct glyph, except the browser sharing the web globe', () => {
|
||||
const glyphs = ALL_CATEGORIES.filter((category) => category !== 'browser').map(
|
||||
(category) => NATIVE_CHAT_TOOL_ICON_NAMES[category]
|
||||
)
|
||||
expect(new Set(glyphs).size).toBe(glyphs.length)
|
||||
})
|
||||
|
||||
it('maps the row words the Codex lane renders to their category', () => {
|
||||
expect(nativeChatToolCategory('read')).toBe('read')
|
||||
expect(nativeChatToolCategory('search')).toBe('search')
|
||||
@@ -271,9 +231,6 @@ describe('native chat tool icons', () => {
|
||||
})
|
||||
|
||||
describe('qualified tool identity icons', () => {
|
||||
it.each(['mcp__linear__list_issues'])('uses the MCP glyph for %s', (name) =>
|
||||
expect(nativeChatToolIconName(name)).toBe('plug')
|
||||
)
|
||||
it.each([
|
||||
'setup.py',
|
||||
'src/read',
|
||||
|
||||
@@ -1,36 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pairToolBlocks, type NativeChatToolPair } from './native-chat-tool-pairs'
|
||||
import { pairToolBlocks } from './native-chat-tool-pairs'
|
||||
import type { NativeChatBlock } from '../../native-chat-types'
|
||||
|
||||
function original(blocks: readonly NativeChatBlock[], limit: number): NativeChatToolPair[] {
|
||||
const pairs: NativeChatToolPair[] = []
|
||||
const slots: (number | null)[] = []
|
||||
let ordinal = 0
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'tool-call') {
|
||||
if (pairs.length < limit) {
|
||||
slots.push(pairs.length)
|
||||
pairs.push({ call: block })
|
||||
} else {
|
||||
slots.push(null)
|
||||
}
|
||||
} else if (block.type === 'tool-result') {
|
||||
const slot = slots[ordinal]
|
||||
if (slot === undefined) {
|
||||
if (pairs.length < limit) {
|
||||
pairs.push({ result: block })
|
||||
}
|
||||
} else {
|
||||
ordinal++
|
||||
if (slot !== null) {
|
||||
pairs[slot].result = block
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
const call: NativeChatBlock = { type: 'tool-call', name: 'read', input: {} }
|
||||
const result: NativeChatBlock = { type: 'tool-result', output: 'done' }
|
||||
|
||||
@@ -49,23 +20,6 @@ describe('tool pair limits', () => {
|
||||
expect(reads).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves FIFO and stray-result behavior across finite and unlimited limits', () => {
|
||||
for (let seed = 0; seed < 128; seed++) {
|
||||
const blocks = Array.from({ length: 30 }, (_, i) =>
|
||||
(seed * 13 + i * 17) % 7 < 3
|
||||
? call
|
||||
: (seed + i) % 3
|
||||
? result
|
||||
: { type: 'text' as const, text: 'hi' }
|
||||
)
|
||||
for (const limit of [0, 1, 2, 5, 0.5, -1, Infinity, Number.NaN]) {
|
||||
expect(pairToolBlocks(blocks, limit)).toEqual(original(blocks, limit))
|
||||
}
|
||||
}
|
||||
expect(pairToolBlocks([call, call, result, result], 1)).toEqual(
|
||||
original([call, call, result, result], 1)
|
||||
)
|
||||
})
|
||||
it('still answers every retained call when the results trail far behind', () => {
|
||||
// Retain scanning until every retained call is answered, or mobile would spin on completed tools.
|
||||
const blocks = [call, call, ...Array.from({ length: 5000 }, () => result)]
|
||||
@@ -73,7 +27,6 @@ describe('tool pair limits', () => {
|
||||
{ call, result },
|
||||
{ call, result }
|
||||
])
|
||||
expect(pairToolBlocks(blocks, 2)).toEqual(original(blocks, 2))
|
||||
})
|
||||
|
||||
it('gives a result that names its call to that call, past one that finished with no output', () => {
|
||||
@@ -115,7 +68,6 @@ describe('tool pair limits', () => {
|
||||
|
||||
it('keeps a leading stray result and then stops at the limit', () => {
|
||||
const blocks = [result, call, result, call, result]
|
||||
expect(pairToolBlocks(blocks, 1)).toEqual(original(blocks, 1))
|
||||
expect(pairToolBlocks(blocks, 1)).toEqual([{ result }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,39 +14,6 @@ function result(callId?: string): NativeChatToolResultBlock {
|
||||
return { type: 'tool-result', output: 'done', ...(callId !== undefined ? { callId } : {}) }
|
||||
}
|
||||
|
||||
// The original scan is an independent oracle for named and positional matching.
|
||||
function scanPairs(blocks: readonly NativeChatBlock[], limit = Infinity): NativeChatToolPair[] {
|
||||
const pairs: NativeChatToolPair[] = []
|
||||
const pending: number[] = []
|
||||
for (const block of blocks) {
|
||||
if (pairs.length >= limit && pending.length === 0) {
|
||||
break
|
||||
}
|
||||
if (block.type === 'tool-call') {
|
||||
if (pairs.length < limit) {
|
||||
pending.push(pairs.length)
|
||||
pairs.push({ call: block })
|
||||
}
|
||||
} else if (block.type === 'tool-result') {
|
||||
const pendingIndex =
|
||||
block.callId === undefined
|
||||
? pending.length > 0
|
||||
? 0
|
||||
: -1
|
||||
: pending.findIndex((slot) => pairs[slot]?.call?.callId === block.callId)
|
||||
if (pendingIndex === -1) {
|
||||
if (pairs.length < limit) {
|
||||
pairs.push({ result: block })
|
||||
}
|
||||
} else {
|
||||
const [slot] = pending.splice(pendingIndex, 1)
|
||||
pairs[slot]!.result = block
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
function expectSameReferences(
|
||||
actual: readonly NativeChatToolPair[],
|
||||
expected: readonly NativeChatToolPair[]
|
||||
@@ -75,10 +42,7 @@ function countedCalls(count: number): {
|
||||
return { calls, reads: () => reads }
|
||||
}
|
||||
|
||||
function countQueueMovement(
|
||||
blocks: readonly NativeChatBlock[],
|
||||
pair = pairToolBlocks
|
||||
): {
|
||||
function countQueueMovement(blocks: readonly NativeChatBlock[]): {
|
||||
pairs: NativeChatToolPair[]
|
||||
movedSlots: number
|
||||
} {
|
||||
@@ -113,7 +77,7 @@ function countQueueMovement(
|
||||
return originalShift.call(this)
|
||||
}
|
||||
try {
|
||||
return { pairs: pair(blocks), movedSlots }
|
||||
return { pairs: pairToolBlocks(blocks), movedSlots }
|
||||
} finally {
|
||||
Array.prototype.shift = originalShift
|
||||
Array.prototype.splice = originalSplice
|
||||
@@ -131,7 +95,6 @@ describe('tool pairing lookup budget', () => {
|
||||
const results = Array.from({ length: count }, (_, index) => result(idOf(index)))
|
||||
const blocks = [...calls, ...results]
|
||||
const measured = countQueueMovement(blocks)
|
||||
expect(countQueueMovement(blocks, scanPairs).movedSlots).toBe((count * (count - 1)) / 2)
|
||||
expectSameReferences(
|
||||
measured.pairs,
|
||||
calls.map((pending, index) => ({ call: pending, result: results[index] }))
|
||||
@@ -213,22 +176,6 @@ describe('tool pairing identity and ordering', () => {
|
||||
expect(reads).toBeLessThanOrEqual(count * 3)
|
||||
})
|
||||
|
||||
it('keeps duplicate and positional matching correct across drained and refilled queues', () => {
|
||||
const blocks: NativeChatBlock[] = []
|
||||
for (let epoch = 0; epoch < 512; epoch += 1) {
|
||||
blocks.push(
|
||||
call('same'),
|
||||
call('other'),
|
||||
result('other'),
|
||||
call('same'),
|
||||
result(),
|
||||
result('same'),
|
||||
result('missing')
|
||||
)
|
||||
}
|
||||
expectSameReferences(pairToolBlocks(blocks), scanPairs(blocks))
|
||||
})
|
||||
|
||||
it('matches repeated and empty IDs FIFO while positional results consume the oldest call', () => {
|
||||
const calls = [call('same'), call(''), call('same'), call(), call('same')]
|
||||
const results = [result('same'), result(), result('same'), result(), result('same')]
|
||||
@@ -264,37 +211,4 @@ describe('tool pairing identity and ordering', () => {
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
it('matches the original semantics for mixed identities, interleavings, and all limits', () => {
|
||||
const ids: readonly (string | undefined)[] = [
|
||||
undefined,
|
||||
'',
|
||||
'same',
|
||||
'same',
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'東京',
|
||||
'i\u0307',
|
||||
'💡'
|
||||
]
|
||||
const limits = [0, -1, 0.5, 1, 2, 5, 30, Infinity, Number.NaN]
|
||||
for (let seed = 0; seed < 256; seed += 1) {
|
||||
let random = seed + 1
|
||||
const next = (): number => {
|
||||
random = (Math.imul(random, 1664525) + 1013904223) >>> 0
|
||||
return random
|
||||
}
|
||||
const blocks: NativeChatBlock[] = Array.from({ length: 48 }, () => {
|
||||
const kind = next() % 5
|
||||
const id = ids[next() % ids.length]
|
||||
return Object.freeze(
|
||||
kind < 2 ? call(id) : kind < 4 ? result(id) : { type: 'text', text: 'context' }
|
||||
)
|
||||
})
|
||||
Object.freeze(blocks)
|
||||
for (const limit of limits) {
|
||||
expectSameReferences(pairToolBlocks(blocks, limit), scanPairs(blocks, limit))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user