diff --git a/app/shared/src/chatview/i18n/resources.test.ts b/app/shared/src/chatview/i18n/resources.test.ts new file mode 100644 index 000000000..ddd9c123a --- /dev/null +++ b/app/shared/src/chatview/i18n/resources.test.ts @@ -0,0 +1,219 @@ +// real_tested=true +import { describe, expect, it } from 'vitest'; + +import { CHATVIEW_I18N_NAMESPACE, chatviewResources } from './resources'; +import type { Locale } from './resources'; + +// ── Helpers ─────────────────────────────────────────────────────────── + +const LOCALES: Locale[] = ['zh', 'en']; + +/** + * i18next plural suffixes. English cardinal plurals require `_one`/`_other` + * resource keys; zh (which has no plural rule) uses the bare base key + * instead. `zero|two|few|many` are included for completeness. + */ +const PLURAL_SUFFIX_PATTERN = /_(zero|one|two|few|many|other)$/; + +/** + * Strips a trailing i18next plural suffix so that + * `agentStreaming.toolCalls_one` and `agentStreaming.toolCalls` map to the + * same logical key. The suffix must be preceded by an underscore, so a + * regular key like `subagentStream.cat.other` is left untouched. + */ +function logicalKey(key: string): string { + return key.replace(PLURAL_SUFFIX_PATTERN, ''); +} + +function isPluralKey(key: string): boolean { + return PLURAL_SUFFIX_PATTERN.test(key); +} + +/** + * Recursively flattens a (possibly nested) translation map into dot-path + * leaves. The current resources are flat string maps, but walking + * recursively keeps these checks valid if a locale is ever nested. + */ +function flattenLeaves(node: unknown, prefix: string, out: Map): void { + if (typeof node === 'string') { + out.set(prefix, node); + return; + } + if (typeof node === 'object' && node !== null && !Array.isArray(node)) { + for (const [segment, child] of Object.entries(node)) { + const path = prefix === '' ? segment : `${prefix}.${segment}`; + flattenLeaves(child, path, out); + } + return; + } + // Non-string leaf — flagged by the structure tests. + out.set(prefix, node); +} + +function leavesOf(locale: Locale): Map { + const leaves = new Map(); + flattenLeaves(chatviewResources[locale], '', leaves); + return leaves; +} + +/** + * Extracts interpolation placeholder names from a translation value, + * accepting both i18next's `{{name}}` form and the single-brace `{name}` + * form used elsewhere in the resources. + */ +function extractPlaceholderNames(value: string): string[] { + const names = new Set(); + const placeholderPattern = /\{\{([A-Za-z0-9]+)\}\}|\{([A-Za-z0-9]+)\}/g; + for (const match of value.matchAll(placeholderPattern)) { + const name = match[1] ?? match[2]; + if (name !== undefined) names.add(name); + } + return [...names].sort(); +} + +const ZH_LEAVES = leavesOf('zh'); +const EN_LEAVES = leavesOf('en'); + +/** Base keys of the plural forms declared in en (e.g. `agentStreaming.toolCalls`). */ +const EN_PLURAL_BASES = new Set( + [...EN_LEAVES.keys()].filter(isPluralKey).map(logicalKey), +); + +/** Flat dot-separated identifiers, e.g. `card.think.running`. */ +const FLAT_KEY_PATTERN = /^[A-Za-z0-9]+(\.[A-Za-z0-9]+)*$/; + +// ── Namespace constant ──────────────────────────────────────────────── + +describe('CHATVIEW_I18N_NAMESPACE', () => { + it('is the documented chatview namespace identifier', () => { + expect(CHATVIEW_I18N_NAMESPACE).toBe('chatview'); + expect(CHATVIEW_I18N_NAMESPACE.length).toBeGreaterThan(0); + }); +}); + +// ── Export structure ────────────────────────────────────────────────── + +describe('chatviewResources export structure', () => { + it('exposes exactly the zh and en locale maps', () => { + expect(Object.keys(chatviewResources).sort()).toEqual(['en', 'zh']); + }); + + it('stores every locale value as non-empty string leaves', () => { + for (const locale of LOCALES) { + const leaves = leavesOf(locale); + expect(leaves.size, `${locale} has no keys`).toBeGreaterThan(0); + const nonStringLeaves = [...leaves.entries()] + .filter(([, value]) => typeof value !== 'string') + .map(([key]) => `${locale}.${key}`); + expect(nonStringLeaves).toEqual([]); + } + }); + + it('defines a sizable key set per locale', () => { + // Guards against accidental truncation of the resource maps. + for (const locale of LOCALES) { + expect(leavesOf(locale).size).toBeGreaterThanOrEqual(200); + } + }); +}); + +// ── zh/en key-set parity ────────────────────────────────────────────── + +describe('key-set parity across locales', () => { + it('every en key has a zh counterpart (plural-aware)', () => { + const missingInZh = [...new Set([...EN_LEAVES.keys()].map(logicalKey))] + .filter((key) => !ZH_LEAVES.has(key)) + .sort(); + expect(missingInZh).toEqual([]); + }); + + it('every zh key has an en counterpart (plural-aware)', () => { + const missingInEn = [...ZH_LEAVES.keys()] + .filter((key) => !EN_LEAVES.has(key) && !EN_PLURAL_BASES.has(key)) + .sort(); + expect(missingInEn).toEqual([]); + }); +}); + +// ── i18next plural conventions ──────────────────────────────────────── + +describe('i18next plural key conventions', () => { + it('zh keeps bare base keys and never uses plural suffixes', () => { + const zhPluralKeys = [...ZH_LEAVES.keys()].filter(isPluralKey).sort(); + expect(zhPluralKeys).toEqual([]); + }); + + it('every en plural base declares the complete one/other pair and a zh base key', () => { + for (const base of [...EN_PLURAL_BASES].sort()) { + expect(EN_LEAVES.has(`${base}_one`), `missing en key ${base}_one`).toBe(true); + expect(EN_LEAVES.has(`${base}_other`), `missing en key ${base}_other`).toBe(true); + expect(ZH_LEAVES.has(base), `missing zh base key ${base}`).toBe(true); + } + }); +}); + +// ── Value hygiene ───────────────────────────────────────────────────── + +describe('translation value hygiene', () => { + it('contains no empty-string values', () => { + const emptyKeys: string[] = []; + for (const locale of LOCALES) { + for (const [key, value] of leavesOf(locale)) { + if (typeof value === 'string' && value.length === 0) { + emptyKeys.push(`${locale}.${key}`); + } + } + } + expect(emptyKeys).toEqual([]); + }); + + it('contains no whitespace-only values', () => { + const blankKeys: string[] = []; + for (const locale of LOCALES) { + for (const [key, value] of leavesOf(locale)) { + if (typeof value === 'string' && value.trim().length === 0) { + blankKeys.push(`${locale}.${key}`); + } + } + } + expect(blankKeys).toEqual([]); + }); +}); + +// ── Key format ──────────────────────────────────────────────────────── + +describe('translation key format', () => { + it('uses flat dot-separated identifier keys', () => { + const malformedKeys: string[] = []; + for (const locale of LOCALES) { + for (const key of leavesOf(locale).keys()) { + // Plural suffixes are validated separately; the base must still be + // a flat dot-separated identifier. + if (!FLAT_KEY_PATTERN.test(logicalKey(key))) { + malformedKeys.push(`${locale}.${key}`); + } + } + } + expect(malformedKeys).toEqual([]); + }); +}); + +// ── Interpolation placeholders ──────────────────────────────────────── + +describe('interpolation placeholder consistency', () => { + it('zh and en reference the same placeholders for each shared key', () => { + const mismatches: string[] = []; + for (const key of ZH_LEAVES.keys()) { + const zhValue = ZH_LEAVES.get(key); + const enValue = + EN_LEAVES.get(key) ?? EN_LEAVES.get(`${key}_other`) ?? EN_LEAVES.get(`${key}_one`); + if (typeof zhValue !== 'string' || typeof enValue !== 'string') continue; + const zhPlaceholders = extractPlaceholderNames(zhValue).join(','); + const enPlaceholders = extractPlaceholderNames(enValue).join(','); + if (zhPlaceholders !== enPlaceholders) { + mismatches.push(`${key}: zh=[${zhPlaceholders}] en=[${enPlaceholders}]`); + } + } + expect(mismatches).toEqual([]); + }); +}); diff --git a/app/shared/src/composer/attachments.test.ts b/app/shared/src/composer/attachments.test.ts index fd43cf8f5..55760f7b8 100644 --- a/app/shared/src/composer/attachments.test.ts +++ b/app/shared/src/composer/attachments.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest'; +// real_tested=true — computeFileHash runs the real webcrypto SHA-256 in jsdom; no crypto mocking. +import { describe, expect, it, vi } from 'vitest'; + import { + attachmentRefToComposerAttachment, browserFilesToComposerAttachments, + computeFileHash, desktopPathsToComposerAttachments, formatComposerAttachmentContext, formatComposerAttachmentSize, @@ -8,74 +12,559 @@ import { shouldPreviewComposerFile, shouldPreviewComposerFileName, } from './attachments'; -import type { ComposerAttachment } from './types'; +import type { AttachmentRef, ComposerAttachment } from './types'; -describe('composer attachments', () => { - it('formats sizes and attachment context for Edge prompts', () => { - const attachments: ComposerAttachment[] = [{ - id: 'attachment-1', - name: 'notes.txt', - source: 'browser', - size: 1536, - mime: 'text/plain', - contentPreview: 'alpha\nbeta', - }]; +const MAX_COMPOSER_ATTACHMENT_PREVIEW = 12_000; +const SHA256_EMPTY = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; +const SHA256_HELLO = '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'; +const SHA256_ABC = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'; + +function makeFile(contents = 'hello', name = 'hello.txt', type = 'text/plain'): File { + return new File([contents], name, { type }); +} + +function makeRef(overrides: Partial = {}): AttachmentRef { + return { + id: 'att-1', + name: 'stored.txt', + size: 1234, + mime_type: 'text/plain', + ...overrides, + }; +} + +function makeAttachment(overrides: Partial = {}): ComposerAttachment { + return { + id: 'att-1', + name: 'hello.txt', + ...overrides, + }; +} + +describe('computeFileHash', () => { + it('hashes "hello" to the canonical SHA-256 digest', async () => { + await expect(computeFileHash(makeFile('hello', 'hello.txt'))).resolves.toBe(SHA256_HELLO); + }); + + it('hashes an empty file to the SHA-256 of the empty string', async () => { + await expect(computeFileHash(makeFile('', 'empty.txt'))).resolves.toBe(SHA256_EMPTY); + }); + + it('hashes "abc" to the canonical SHA-256 digest', async () => { + await expect(computeFileHash(makeFile('abc', 'abc.txt'))).resolves.toBe(SHA256_ABC); + }); + + it('returns a deterministic 64-char lowercase hex digest for unicode content', async () => { + const file = makeFile('héllo 世界 🌍', 'unicode.txt'); + const firstDigest = await computeFileHash(file); + const secondDigest = await computeFileHash(file); + + expect(firstDigest).toBe(secondDigest); + expect(firstDigest).toMatch(/^[0-9a-f]{64}$/); + }); + + it('hashes binary content into a distinct digest', async () => { + const binaryFile = new File([new Uint8Array([0, 1, 2, 3, 254, 255])], 'binary.bin'); + const digest = await computeFileHash(binaryFile); + + expect(digest).toMatch(/^[0-9a-f]{64}$/); + expect(digest).not.toBe(SHA256_EMPTY); + expect(digest).not.toBe(SHA256_HELLO); + }); +}); + +describe('attachmentRefToComposerAttachment', () => { + const file = makeFile('hello', 'local-name.txt'); + + it('maps every ref field onto the attachment', () => { + const ref = makeRef({ original_name: 'server-name.txt' }); + const attachment = attachmentRefToComposerAttachment(ref, file); + + expect(attachment.id).toBe('att-1'); + expect(attachment.size).toBe(1234); + expect(attachment.mime).toBe('text/plain'); + expect(attachment.attachmentRef).toBe(ref); + }); + + it('prefers the server original_name over the local file name', () => { + const attachment = attachmentRefToComposerAttachment( + makeRef({ original_name: 'server-name.txt' }), + file, + ); + + expect(attachment.name).toBe('server-name.txt'); + }); + + it('falls back to the local file name when original_name is missing', () => { + const attachment = attachmentRefToComposerAttachment(makeRef(), file); + + expect(attachment.name).toBe('local-name.txt'); + }); + + it('treats an empty original_name as missing', () => { + const attachment = attachmentRefToComposerAttachment(makeRef({ original_name: '' }), file); + + expect(attachment.name).toBe('local-name.txt'); + }); + it('defaults the source to browser', () => { + const attachment = attachmentRefToComposerAttachment(makeRef(), file); + + expect(attachment.source).toBe('browser'); + }); + + it('honours an explicit desktop source', () => { + const attachment = attachmentRefToComposerAttachment(makeRef(), file, 'desktop'); + + expect(attachment.source).toBe('desktop'); + }); +}); + +describe('formatComposerAttachmentSize', () => { + it('returns undefined for undefined input', () => { + expect(formatComposerAttachmentSize(undefined)).toBeUndefined(); + }); + + it('treats null the same as undefined', () => { + expect(formatComposerAttachmentSize(null as unknown as number | undefined)).toBeUndefined(); + }); + + it('formats zero bytes', () => { + expect(formatComposerAttachmentSize(0)).toBe('0 B'); + }); + + it('formats sub-kilobyte sizes as raw bytes', () => { + expect(formatComposerAttachmentSize(1023)).toBe('1023 B'); + }); + + it('formats exactly one kilobyte with one decimal', () => { + expect(formatComposerAttachmentSize(1024)).toBe('1.0 KB'); + }); + + it('formats fractional kilobytes', () => { expect(formatComposerAttachmentSize(1536)).toBe('1.5 KB'); - expect(formatComposerAttachmentContext(attachments)).toContain('notes.txt'); - expect(formatComposerAttachmentContext(attachments)).toContain('Browser file picker'); - expect(formatComposerAttachmentContext(attachments)).toContain('alpha'); - expect(formatComposerPromptWithAttachments('Read this', attachments)).toContain('Read this\n\nAttached files:'); }); - it('supports attachment-only prompts', () => { - const attachments: ComposerAttachment[] = [{ - id: 'attachment-1', - name: 'notes.txt', - source: 'browser', - }]; + it('formats exactly one megabyte', () => { + expect(formatComposerAttachmentSize(1024 * 1024)).toBe('1.0 MB'); + }); - expect(formatComposerPromptWithAttachments('', attachments)).toMatch(/^Attached files:/); + it('rounds megabyte values to one decimal', () => { + expect(formatComposerAttachmentSize(5.75 * 1024 * 1024)).toBe('5.8 MB'); }); - it('converts browser files and previews text-like files', async () => { - const textFile = new File(['attachment-token'], 'notes.md', { type: 'text/markdown' }); - const imageFile = new File(['raw'], 'image.png', { type: 'image/png' }); + it('keeps negative values in the byte branch', () => { + expect(formatComposerAttachmentSize(-512)).toBe('-512 B'); + }); +}); - expect(shouldPreviewComposerFile(textFile)).toBe(true); - expect(shouldPreviewComposerFileName('notes.ts')).toBe(true); - expect(shouldPreviewComposerFile(imageFile)).toBe(false); +describe('formatComposerAttachmentContext', () => { + it('returns an empty string for no attachments', () => { + expect(formatComposerAttachmentContext([])).toBe(''); + }); + + it('renders a minimal browser attachment', () => { + expect(formatComposerAttachmentContext([makeAttachment()])).toBe( + 'Attached files:\n1. hello.txt\n Source: Browser file picker', + ); + }); + + it('treats an undefined source as the browser picker', () => { + expect(formatComposerAttachmentContext([makeAttachment({ source: undefined })])).toBe( + 'Attached files:\n1. hello.txt\n Source: Browser file picker', + ); + }); + + it('labels desktop attachments as the desktop file picker', () => { + expect(formatComposerAttachmentContext([makeAttachment({ source: 'desktop' })])).toBe( + 'Attached files:\n1. hello.txt\n Source: Desktop file picker', + ); + }); - const attachments = await browserFilesToComposerAttachments([textFile]); - expect(attachments[0]).toEqual(expect.objectContaining({ + it('includes the path line when a path is set', () => { + expect(formatComposerAttachmentContext([makeAttachment({ path: '/tmp/notes.md' })])).toBe( + 'Attached files:\n1. hello.txt\n Path: /tmp/notes.md\n Source: Browser file picker', + ); + }); + + it('includes the formatted size line', () => { + expect(formatComposerAttachmentContext([makeAttachment({ size: 512 })])).toContain( + ' Size: 512 B', + ); + }); + + it('omits the size line when size is undefined', () => { + expect(formatComposerAttachmentContext([makeAttachment()])).not.toContain('Size'); + }); + + it('includes the MIME line when mime is set', () => { + expect(formatComposerAttachmentContext([makeAttachment({ mime: 'application/json' })])).toContain( + ' MIME: application/json', + ); + }); + + it('omits the MIME line when mime is undefined', () => { + expect(formatComposerAttachmentContext([makeAttachment()])).not.toContain('MIME'); + }); + + it('renders an untruncated content preview with indented lines', () => { + expect( + formatComposerAttachmentContext([makeAttachment({ contentPreview: 'line1\nline2' })]), + ).toBe( + [ + 'Attached files:', + '1. hello.txt', + ' Source: Browser file picker', + ' Content preview:', + ' line1', + ' line2', + ].join('\n'), + ); + }); + + it('marks truncated content previews', () => { + expect( + formatComposerAttachmentContext([makeAttachment({ contentPreview: 'cut off', truncated: true })]), + ).toContain(' Content preview (truncated):'); + }); + + it('normalizes CRLF preview content onto single lines', () => { + expect( + formatComposerAttachmentContext([makeAttachment({ contentPreview: 'alpha\r\nbeta' })]), + ).toBe( + [ + 'Attached files:', + '1. hello.txt', + ' Source: Browser file picker', + ' Content preview:', + ' alpha', + ' beta', + ].join('\n'), + ); + }); + + it('numbers multiple attachments in order', () => { + const context = formatComposerAttachmentContext([ + makeAttachment({ id: 'a', name: 'a.txt' }), + makeAttachment({ id: 'b', name: 'b.txt', source: 'desktop' }), + ]); + + expect(context).toBe( + [ + 'Attached files:', + '1. a.txt', + ' Source: Browser file picker', + '2. b.txt', + ' Source: Desktop file picker', + ].join('\n'), + ); + }); + + it('renders the full kitchen-sink attachment', () => { + const context = formatComposerAttachmentContext([ + makeAttachment({ + name: 'notes.md', + source: 'desktop', + path: '/tmp/notes.md', + size: 2048, + mime: 'text/markdown', + contentPreview: 'first line\nsecond line', + truncated: true, + }), + ]); + + expect(context).toBe( + [ + 'Attached files:', + '1. notes.md', + ' Path: /tmp/notes.md', + ' Source: Desktop file picker', + ' Size: 2.0 KB', + ' MIME: text/markdown', + ' Content preview (truncated):', + ' first line', + ' second line', + ].join('\n'), + ); + }); +}); + +describe('formatComposerPromptWithAttachments', () => { + it('returns an empty string for empty text and no attachments', () => { + expect(formatComposerPromptWithAttachments('', [])).toBe(''); + }); + + it('trims whitespace-only text down to an empty string', () => { + expect(formatComposerPromptWithAttachments(' \n\t ', [])).toBe(''); + }); + + it('joins trimmed text and attachment context with a blank line', () => { + expect(formatComposerPromptWithAttachments(' hello world ', [makeAttachment()])).toBe( + 'hello world\n\nAttached files:\n1. hello.txt\n Source: Browser file picker', + ); + }); + + it('returns just the attachment context when the text is empty', () => { + expect(formatComposerPromptWithAttachments('', [makeAttachment()])).toBe( + 'Attached files:\n1. hello.txt\n Source: Browser file picker', + ); + }); + + it('returns just the attachment context when the text is whitespace-only', () => { + expect(formatComposerPromptWithAttachments('\n\n ', [makeAttachment()])).toBe( + 'Attached files:\n1. hello.txt\n Source: Browser file picker', + ); + }); +}); + +describe('shouldPreviewComposerFile', () => { + it('previews files with a text MIME type regardless of name', () => { + expect(shouldPreviewComposerFile(makeFile('x', 'weird.unknown', 'text/plain'))).toBe(true); + }); + + it('previews files whose name matches the pattern even with a binary MIME type', () => { + expect(shouldPreviewComposerFile(makeFile('x', 'notes.md', 'application/octet-stream'))).toBe(true); + }); + + it('previews files whose name matches the pattern when the type is empty', () => { + expect(shouldPreviewComposerFile(makeFile('x', 'notes.md', ''))).toBe(true); + }); + + it('does not preview files with neither a text MIME nor a matching name', () => { + expect(shouldPreviewComposerFile(makeFile('x', 'data.unknown', 'application/octet-stream'))).toBe( + false, + ); + }); +}); + +describe('shouldPreviewComposerFileName', () => { + it('accepts any name with a text MIME type', () => { + expect(shouldPreviewComposerFileName('weird.unknown', 'text/plain')).toBe(true); + }); + + it('matches common text extensions without a MIME type', () => { + expect(shouldPreviewComposerFileName('notes.md')).toBe(true); + }); + + it('matches extensions case-insensitively', () => { + expect(shouldPreviewComposerFileName('NOTES.MD')).toBe(true); + }); + + it('matches the jsonl extension', () => { + expect(shouldPreviewComposerFileName('trace.jsonl')).toBe(true); + }); + + it('matches the log extension', () => { + expect(shouldPreviewComposerFileName('server.log')).toBe(true); + }); + + it('matches on the name even when the MIME type is not text', () => { + expect(shouldPreviewComposerFileName('data.json', 'application/octet-stream')).toBe(true); + }); + + it('rejects unknown extensions without a MIME type', () => { + expect(shouldPreviewComposerFileName('blob.unknown')).toBe(false); + }); + + it('rejects multi-part archive extensions', () => { + expect(shouldPreviewComposerFileName('archive.tar.gz')).toBe(false); + }); + + it('rejects extension-less file names', () => { + expect(shouldPreviewComposerFileName('Dockerfile')).toBe(false); + }); + + it('rejects hidden dotfiles', () => { + expect(shouldPreviewComposerFileName('.env')).toBe(false); + }); + + it('rejects unknown extensions with a non-text MIME type', () => { + expect(shouldPreviewComposerFileName('blob.unknown', 'application/octet-stream')).toBe(false); + }); +}); + +describe('browserFilesToComposerAttachments', () => { + it('returns an empty list for no files', async () => { + await expect(browserFilesToComposerAttachments([])).resolves.toEqual([]); + }); + + it('builds a preview attachment with id, metadata and the file reference', async () => { + const file = makeFile('hello', 'notes.md', 'text/markdown'); + const [first] = await browserFilesToComposerAttachments([file]); + + expect(first?.id).toMatch(/^browser-\d+-0-notes\.md$/); + expect(first).toMatchObject({ name: 'notes.md', source: 'browser', - contentPreview: 'attachment-token', - })); + size: 5, + mime: 'text/markdown', + contentPreview: 'hello', + truncated: false, + file, + }); }); - it('converts desktop paths and reads previews only for text-like names', async () => { - const reads: string[] = []; - const attachments = await desktopPathsToComposerAttachments( - ['D:\\Code\\TokenDance\\AgentHub\\notes.md', 'D:\\Code\\TokenDance\\AgentHub\\image.png'], - async (path) => { - reads.push(path); - return 'desktop attachment token'; - }, - ); + it('truncates content beyond the preview limit and flags it', async () => { + const longContent = 'a'.repeat(MAX_COMPOSER_ATTACHMENT_PREVIEW + 1); + const [first] = await browserFilesToComposerAttachments([makeFile(longContent, 'long.txt')]); + + expect(first?.contentPreview).toHaveLength(MAX_COMPOSER_ATTACHMENT_PREVIEW); + expect(first?.truncated).toBe(true); + }); + + it('does not flag content exactly at the preview limit', async () => { + const exactContent = 'b'.repeat(MAX_COMPOSER_ATTACHMENT_PREVIEW); + const [first] = await browserFilesToComposerAttachments([makeFile(exactContent, 'exact.txt')]); + + expect(first?.contentPreview).toBe(exactContent); + expect(first?.truncated).toBe(false); + }); + + it('skips the preview for non-previewable files', async () => { + const file = makeFile('bytes', 'archive.bin', 'application/octet-stream'); + const [first] = await browserFilesToComposerAttachments([file]); + + expect(first?.contentPreview).toBeUndefined(); + expect(first?.mime).toBe('application/octet-stream'); + }); + + it('omits preview fields for empty text content', async () => { + const [first] = await browserFilesToComposerAttachments([ + makeFile('', 'empty.md', 'text/markdown'), + ]); + + expect(first).not.toHaveProperty('contentPreview'); + expect(first).not.toHaveProperty('truncated'); + }); + + it('swallows text() read failures and keeps the attachment', async () => { + const brokenFile = { + name: 'broken.txt', + type: 'text/plain', + size: 3, + text: () => Promise.reject(new Error('read failed')), + } as unknown as File; + + const [first] = await browserFilesToComposerAttachments([brokenFile]); + + expect(first?.name).toBe('broken.txt'); + expect(first?.contentPreview).toBeUndefined(); + }); + + it('skips the preview when file.text is unavailable', async () => { + const legacyFile = { + name: 'legacy.txt', + type: 'text/plain', + size: 3, + } as unknown as File; + + const [first] = await browserFilesToComposerAttachments([legacyFile]); + + expect(first?.contentPreview).toBeUndefined(); + }); + + it('omits the mime field when the file type is empty', async () => { + const [first] = await browserFilesToComposerAttachments([makeFile('a,b', 'data.csv', '')]); + + expect(first).not.toHaveProperty('mime'); + expect(first?.contentPreview).toBe('a,b'); + }); + + it('indexes multiple files in order', async () => { + const [first, second] = await browserFilesToComposerAttachments([ + makeFile('one', 'a.txt'), + makeFile('two', 'b.txt'), + ]); + + expect(first?.id).toMatch(/^browser-\d+-0-a\.txt$/); + expect(second?.id).toMatch(/^browser-\d+-1-b\.txt$/); + expect(first?.contentPreview).toBe('one'); + expect(second?.contentPreview).toBe('two'); + }); +}); + +describe('desktopPathsToComposerAttachments', () => { + it('returns an empty list for no paths', async () => { + await expect(desktopPathsToComposerAttachments([], vi.fn())).resolves.toEqual([]); + }); + + it('reads previewable files and builds a desktop attachment', async () => { + const readText = vi.fn().mockResolvedValue('file contents'); + const [first] = await desktopPathsToComposerAttachments(['/tmp/notes.md'], readText); - expect(reads).toEqual(['D:\\Code\\TokenDance\\AgentHub\\notes.md']); - expect(attachments[0]).toEqual(expect.objectContaining({ + expect(readText).toHaveBeenCalledWith('/tmp/notes.md'); + expect(first?.id).toMatch(/^desktop-\d+-0-notes\.md$/); + expect(first).toMatchObject({ name: 'notes.md', - path: 'D:\\Code\\TokenDance\\AgentHub\\notes.md', source: 'desktop', - contentPreview: 'desktop attachment token', - })); - expect(attachments[1]).toEqual(expect.objectContaining({ - name: 'image.png', - path: 'D:\\Code\\TokenDance\\AgentHub\\image.png', - source: 'desktop', - })); - expect(attachments[1]?.contentPreview).toBeUndefined(); + path: '/tmp/notes.md', + contentPreview: 'file contents', + truncated: false, + }); + expect(first).not.toHaveProperty('mime'); + }); + + it('extracts the basename from windows-style paths', async () => { + const readText = vi.fn().mockResolvedValue('win'); + const [first] = await desktopPathsToComposerAttachments( + ['C:\\Users\\dev\\notes.md'], + readText, + ); + + expect(first?.name).toBe('notes.md'); + expect(first?.path).toBe('C:\\Users\\dev\\notes.md'); + }); + + it('keeps plain names without separators unchanged', async () => { + const readText = vi.fn().mockResolvedValue('plain'); + const [first] = await desktopPathsToComposerAttachments(['notes.md'], readText); + + expect(first?.name).toBe('notes.md'); + }); + + it('falls back to the raw path when it has no basename', async () => { + const readText = vi.fn(); + const [first] = await desktopPathsToComposerAttachments(['/'], readText); + + expect(first?.name).toBe('/'); + expect(readText).not.toHaveBeenCalled(); + }); + + it('truncates content beyond the preview limit and flags it', async () => { + const longContent = 'x'.repeat(MAX_COMPOSER_ATTACHMENT_PREVIEW + 1); + const readText = vi.fn().mockResolvedValue(longContent); + const [first] = await desktopPathsToComposerAttachments(['/tmp/long.txt'], readText); + + expect(first?.contentPreview).toHaveLength(MAX_COMPOSER_ATTACHMENT_PREVIEW); + expect(first?.truncated).toBe(true); + }); + + it('swallows readText failures and keeps the attachment', async () => { + const readText = vi.fn().mockRejectedValue(new Error('EACCES')); + const [first] = await desktopPathsToComposerAttachments(['/tmp/notes.md'], readText); + + expect(first?.name).toBe('notes.md'); + expect(first?.contentPreview).toBeUndefined(); + }); + + it('skips readText for non-previewable names', async () => { + const readText = vi.fn().mockResolvedValue('never read'); + const [first] = await desktopPathsToComposerAttachments(['/tmp/archive.tar.gz'], readText); + + expect(readText).not.toHaveBeenCalled(); + expect(first?.contentPreview).toBeUndefined(); + expect(first?.name).toBe('archive.tar.gz'); + }); + + it('indexes multiple paths in order', async () => { + const readText = vi.fn().mockResolvedValue('content'); + const [first, second] = await desktopPathsToComposerAttachments( + ['/tmp/a.md', '/tmp/b.txt'], + readText, + ); + + expect(first?.id).toMatch(/^desktop-\d+-0-a\.md$/); + expect(second?.id).toMatch(/^desktop-\d+-1-b\.txt$/); + expect(readText).toHaveBeenCalledTimes(2); }); }); diff --git a/app/shared/src/transcript/edgeEventEvidence.test.ts b/app/shared/src/transcript/edgeEventEvidence.test.ts new file mode 100644 index 000000000..ff3a12091 --- /dev/null +++ b/app/shared/src/transcript/edgeEventEvidence.test.ts @@ -0,0 +1,568 @@ +// real_tested=true +import { describe, expect, it } from 'vitest'; +import type { EventEnvelope, EventScope } from '../events'; +import { + AGENT_AUTHOR, + EDGE_AUTHOR, + agentAuthorFromEvent, + approvalEvidence, + approvalHubContext, + blockBase, + eventRunId, + fileEvidence, + normalizeApprovalRisk, + normalizeEvidenceStatus, + normalizeFileAction, + runEvidence, + toolEvidence, +} from './edgeEventEvidence'; +import type { EvidenceRef, EvidenceRefStatus, TranscriptAuthor } from './types'; + +function edgeEvent( + id: string, + seq: number, + type: string, + payload: Record, + sentAt = `2026-06-07T03:00:0${seq}Z`, + scopeOverrides: EventScope = {}, +): EventEnvelope { + return { + version: 'v1', + id, + seq, + type, + scope: { + threadId: 'thread-live', + runId: typeof payload.runId === 'string' ? payload.runId : undefined, + ...scopeOverrides, + }, + sentAt, + payload, + }; +} + +const AGENT_ROLE: TranscriptAuthor = { id: 'agent', name: 'Agent', role: 'agent' }; +const EDGE_ROLE: TranscriptAuthor = { id: 'edge', name: 'Edge', role: 'system' }; + +describe('author constants', () => { + it('exports the canonical agent author', () => { + expect(AGENT_AUTHOR).toEqual(AGENT_ROLE); + }); + + it('exports the canonical edge author', () => { + expect(EDGE_AUTHOR).toEqual(EDGE_ROLE); + }); +}); + +describe('agentAuthorFromEvent', () => { + it('derives the author from payload.agentId and payload.agentName', () => { + expect( + agentAuthorFromEvent( + edgeEvent('evt-1', 1, 'run.agent.text_delta', { agentId: 'agent-7', agentName: 'Researcher' }), + ), + ).toEqual({ id: 'agent-7', name: 'Researcher', role: 'agent' }); + }); + + it('falls back through legacy snake_case id fields in priority order', () => { + expect( + agentAuthorFromEvent(edgeEvent('evt-2', 2, 'run.agent.text_delta', { agent_id: 'a-2' })), + ).toMatchObject({ id: 'a-2' }); + expect( + agentAuthorFromEvent( + edgeEvent('evt-3', 3, 'run.agent.text_delta', { agentInstanceId: 'a-3' }), + ), + ).toMatchObject({ id: 'a-3' }); + expect( + agentAuthorFromEvent( + edgeEvent('evt-4', 4, 'run.agent.text_delta', { agent_instance_id: 'a-4' }), + ), + ).toMatchObject({ id: 'a-4' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-5', 5, 'run.agent.text_delta', { workerId: 'w-5' })), + ).toMatchObject({ id: 'w-5' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-6', 6, 'run.agent.text_delta', { worker_id: 'w-6' })), + ).toMatchObject({ id: 'w-6' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-7', 7, 'run.agent.text_delta', { runnerId: 'r-7' })), + ).toMatchObject({ id: 'r-7' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-8', 8, 'run.agent.text_delta', { runner_id: 'r-8' })), + ).toMatchObject({ id: 'r-8' }); + }); + + it('prefers the first non-empty payload id over later fields', () => { + expect( + agentAuthorFromEvent( + edgeEvent('evt-9', 9, 'run.agent.text_delta', { + agentId: 'primary', + agent_id: 'legacy', + workerId: 'worker', + }), + ), + ).toMatchObject({ id: 'primary' }); + }); + + it('reads the author id from scope fields when payload has none', () => { + expect( + agentAuthorFromEvent( + edgeEvent('evt-10', 1, 'run.agent.text_delta', {}, undefined, { agentId: 'scope-a' }), + ), + ).toMatchObject({ id: 'scope-a' }); + expect( + agentAuthorFromEvent( + edgeEvent('evt-11', 2, 'run.agent.text_delta', {}, undefined, { agent_id: 'scope-b' }), + ), + ).toMatchObject({ id: 'scope-b' }); + expect( + agentAuthorFromEvent( + edgeEvent('evt-12', 3, 'run.agent.text_delta', {}, undefined, { agentInstanceId: 'scope-c' }), + ), + ).toMatchObject({ id: 'scope-c' }); + expect( + agentAuthorFromEvent( + edgeEvent('evt-13', 4, 'run.agent.text_delta', {}, undefined, { agent_instance_id: 'scope-d' }), + ), + ).toMatchObject({ id: 'scope-d' }); + }); + + it('derives the id from the label via safeAuthorId when no explicit id exists', () => { + expect( + agentAuthorFromEvent( + edgeEvent('evt-14', 5, 'run.agent.text_delta', { agentName: 'My Cool Agent!!' }), + ), + ).toEqual({ id: 'my-cool-agent', name: 'My Cool Agent!!', role: 'agent' }); + }); + + it('falls back to the canonical agent id when the label sanitizes to nothing', () => { + expect( + agentAuthorFromEvent(edgeEvent('evt-15', 6, 'run.agent.text_delta', { agentName: '!!!' })), + ).toEqual({ id: 'agent', name: '!!!', role: 'agent' }); + }); + + it('falls back through legacy label fields in priority order', () => { + expect( + agentAuthorFromEvent(edgeEvent('evt-16', 7, 'run.agent.text_delta', { agent_name: 'n-16' })), + ).toMatchObject({ name: 'n-16' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-17', 8, 'run.agent.text_delta', { agentLabel: 'l-17' })), + ).toMatchObject({ name: 'l-17' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-18', 9, 'run.agent.text_delta', { agent_label: 'l-18' })), + ).toMatchObject({ name: 'l-18' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-19', 1, 'run.agent.text_delta', { displayName: 'd-19' })), + ).toMatchObject({ name: 'd-19' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-20', 2, 'run.agent.text_delta', { display_name: 'd-20' })), + ).toMatchObject({ name: 'd-20' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-21', 3, 'run.agent.text_delta', { workerName: 'w-21' })), + ).toMatchObject({ name: 'w-21' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-22', 4, 'run.agent.text_delta', { worker_name: 'w-22' })), + ).toMatchObject({ name: 'w-22' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-23', 5, 'run.agent.text_delta', { worker: 'w-23' })), + ).toMatchObject({ name: 'w-23' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-24', 6, 'run.agent.text_delta', { agent: 'a-24' })), + ).toMatchObject({ name: 'a-24' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-25', 7, 'run.agent.text_delta', { runnerName: 'r-25' })), + ).toMatchObject({ name: 'r-25' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-26', 8, 'run.agent.text_delta', { runner_name: 'r-26' })), + ).toMatchObject({ name: 'r-26' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-27', 9, 'run.agent.text_delta', { adapterLabel: 'ad-27' })), + ).toMatchObject({ name: 'ad-27' }); + expect( + agentAuthorFromEvent(edgeEvent('evt-28', 1, 'run.agent.text_delta', { adapter_label: 'ad-28' })), + ).toMatchObject({ name: 'ad-28' }); + }); + + it('uses the explicit id for the name when no label exists', () => { + expect( + agentAuthorFromEvent(edgeEvent('evt-29', 2, 'run.agent.text_delta', { agentId: 'a-29' })), + ).toEqual({ id: 'a-29', name: 'a-29', role: 'agent' }); + }); + + it('returns the canonical agent author when the event has no author fields', () => { + expect(agentAuthorFromEvent(edgeEvent('evt-30', 3, 'run.agent.text_delta', {}))).toEqual( + AGENT_ROLE, + ); + }); + + it('treats whitespace-only and non-string fields as missing', () => { + expect( + agentAuthorFromEvent( + edgeEvent('evt-31', 4, 'run.agent.text_delta', { agentId: ' ', agentName: '\t ' }), + ), + ).toEqual(AGENT_ROLE); + expect( + agentAuthorFromEvent( + edgeEvent('evt-32', 5, 'run.agent.text_delta', { agentId: 42, agentName: ['x'] }), + ), + ).toEqual(AGENT_ROLE); + }); + + it('trims whitespace from explicit ids and labels', () => { + expect( + agentAuthorFromEvent( + edgeEvent('evt-33', 6, 'run.agent.text_delta', { agentId: ' a-33 ', agentName: ' N33 ' }), + ), + ).toEqual({ id: 'a-33', name: 'N33', role: 'agent' }); + }); +}); + +describe('blockBase', () => { + const author: TranscriptAuthor = { id: 'edge', name: 'Edge', role: 'system' }; + const runRef: EvidenceRef = { id: 'run-r1', kind: 'run', label: 'Run r1', status: 'running' }; + + it('builds the block id from the event id', () => { + const base = blockBase(edgeEvent('evt-b1', 1, 'run.started', {}), author, []); + expect(base.id).toBe('edge-event-evt-b1'); + expect(base.author).toEqual(author); + }); + + it('includes createdAt from sentAt and evidenceRefs when provided', () => { + expect(blockBase(edgeEvent('evt-b2', 2, 'run.started', {}, '2026-06-07T03:00:02Z'), author, [runRef])).toEqual({ + id: 'edge-event-evt-b2', + author, + createdAt: '2026-06-07T03:00:02Z', + evidenceRefs: [runRef], + }); + }); + + it('omits createdAt when sentAt is empty', () => { + const base = blockBase(edgeEvent('evt-b3', 3, 'run.started', {}, ''), author, [runRef]); + expect(base.createdAt).toBeUndefined(); + expect(base.evidenceRefs).toEqual([runRef]); + }); + + it('omits evidenceRefs when the ref list is empty', () => { + const base = blockBase(edgeEvent('evt-b4', 4, 'run.started', {}), author, []); + expect(base.evidenceRefs).toBeUndefined(); + }); +}); + +describe('runEvidence', () => { + it('returns an empty list for undefined and empty run ids', () => { + expect(runEvidence(undefined, 'running')).toEqual([]); + expect(runEvidence('', 'running')).toEqual([]); + }); + + it('builds a run ref with the id prefix and run label', () => { + expect(runEvidence('run-9', 'completed')).toEqual([ + { id: 'run-run-9', kind: 'run', label: 'Run run-9', status: 'completed' }, + ]); + }); + + it('passes through the status verbatim', () => { + expect(runEvidence('run-10', 'failed')).toEqual([ + { id: 'run-run-10', kind: 'run', label: 'Run run-10', status: 'failed' }, + ]); + expect(runEvidence('run-11', 'pending')).toEqual([ + { id: 'run-run-11', kind: 'run', label: 'Run run-11', status: 'pending' }, + ]); + }); +}); + +describe('toolEvidence', () => { + it('returns an empty list for undefined and empty ids', () => { + expect(toolEvidence(undefined, 'Tool', 'running')).toEqual([]); + expect(toolEvidence('', 'Tool', 'running')).toEqual([]); + }); + + it('builds a tool ref with the id prefix and provided label', () => { + expect(toolEvidence('call-1', 'read_file', 'completed')).toEqual([ + { id: 'tool-call-1', kind: 'tool', label: 'read_file', status: 'completed' }, + ]); + }); + + it('preserves a label that differs from the id', () => { + expect(toolEvidence('call-2', 'Run bash command', 'failed')).toEqual([ + { id: 'tool-call-2', kind: 'tool', label: 'Run bash command', status: 'failed' }, + ]); + }); +}); + +describe('approvalEvidence', () => { + it('keeps labels that already contain "approval" and trims them', () => { + expect(approvalEvidence('ap-1', ' tool approval ', 'pending')).toEqual({ + id: 'approval-ap-1', + kind: 'approval', + label: 'tool approval', + status: 'pending', + }); + }); + + it('appends " approval" to labels without the word', () => { + expect(approvalEvidence('ap-2', 'tool call', 'failed')).toEqual({ + id: 'approval-ap-2', + kind: 'approval', + label: 'tool call approval', + status: 'failed', + }); + }); + + it('matches the word approval case-insensitively', () => { + expect(approvalEvidence('ap-3', 'Tool APPROVAL', 'completed')).toEqual({ + id: 'approval-ap-3', + kind: 'approval', + label: 'Tool APPROVAL', + status: 'completed', + }); + }); + + it('preserves status and prefixes the id', () => { + expect(approvalEvidence('ap-4', 'x', 'running')).toEqual({ + id: 'approval-ap-4', + kind: 'approval', + label: 'x approval', + status: 'running', + }); + }); +}); + +describe('approvalHubContext', () => { + it('extracts all fields from payload snake_case keys', () => { + expect( + approvalHubContext( + edgeEvent('evt-h1', 1, 'approval.requested', { + team_id: 'team-1', + team_run_id: 'team-run-1', + agent_task_id: 'task-1', + target_id: 'target-1', + edge_device_id: 'device-1', + correlation_id: 'corr-1', + }), + ), + ).toEqual({ + teamId: 'team-1', + teamRunId: 'team-run-1', + agentTaskId: 'task-1', + targetId: 'target-1', + edgeDeviceId: 'device-1', + correlationId: 'corr-1', + }); + }); + + it('falls back to payload camelCase keys', () => { + expect( + approvalHubContext( + edgeEvent('evt-h2', 2, 'approval.requested', { + teamId: 'team-2', + teamRunId: 'team-run-2', + agentTaskId: 'task-2', + targetId: 'target-2', + edgeDeviceId: 'device-2', + correlationId: 'corr-2', + }), + ), + ).toEqual({ + teamId: 'team-2', + teamRunId: 'team-run-2', + agentTaskId: 'task-2', + targetId: 'target-2', + edgeDeviceId: 'device-2', + correlationId: 'corr-2', + }); + }); + + it('falls back through run_id and runId for teamRunId', () => { + expect( + approvalHubContext(edgeEvent('evt-h3', 3, 'approval.requested', { run_id: 'r-3' })), + ).toEqual({ teamRunId: 'r-3' }); + expect( + approvalHubContext(edgeEvent('evt-h4', 4, 'approval.requested', { runId: 'r-4' })), + ).toEqual({ teamRunId: 'r-4' }); + expect( + approvalHubContext( + edgeEvent('evt-h5', 5, 'approval.requested', { run_id: 'r-5', runId: 'r-6' }), + ), + ).toEqual({ teamRunId: 'r-5' }); + }); + + it('falls back to scope fields for task, target, and device ids', () => { + expect( + approvalHubContext( + edgeEvent('evt-h6', 6, 'approval.requested', {}, undefined, { + taskId: 'scope-task', + targetId: 'scope-target', + deviceId: 'scope-device', + }), + ), + ).toEqual({ + agentTaskId: 'scope-task', + targetId: 'scope-target', + edgeDeviceId: 'scope-device', + }); + }); + + it('omits keys for fields that are absent', () => { + expect(approvalHubContext(edgeEvent('evt-h7', 7, 'approval.requested', { team_id: 't-7' }))).toEqual({ + teamId: 't-7', + }); + expect(approvalHubContext(edgeEvent('evt-h8', 8, 'approval.requested', {}))).toEqual({}); + }); + + it('trims whitespace and ignores non-string values', () => { + expect( + approvalHubContext( + edgeEvent('evt-h9', 9, 'approval.requested', { + team_id: ' t-9 ', + team_run_id: 5, + correlation_id: '', + }), + ), + ).toEqual({ teamId: 't-9' }); + }); +}); + +describe('fileEvidence', () => { + it('builds a file ref carrying the path in the id, label, and path fields', () => { + expect(fileEvidence('src/main.ts')).toEqual({ + id: 'file-src/main.ts', + kind: 'file', + label: 'src/main.ts', + path: 'src/main.ts', + }); + }); + + it('handles nested and whitespace-heavy paths verbatim', () => { + expect(fileEvidence('/tmp/a b/c.txt')).toEqual({ + id: 'file-/tmp/a b/c.txt', + kind: 'file', + label: '/tmp/a b/c.txt', + path: '/tmp/a b/c.txt', + }); + }); +}); + +describe('eventRunId', () => { + it('prefers payload.runId over scope.runId', () => { + expect( + eventRunId(edgeEvent('evt-r1', 1, 'run.started', { runId: 'payload-run' }, undefined, { runId: 'scope-run' })), + ).toBe('payload-run'); + }); + + it('falls back to scope.runId when the payload has none', () => { + expect(eventRunId(edgeEvent('evt-r2', 2, 'run.started', {}, undefined, { runId: 'scope-run' }))).toBe( + 'scope-run', + ); + }); + + it('returns undefined when neither payload nor scope has a run id', () => { + expect(eventRunId(edgeEvent('evt-r3', 3, 'run.started', {}))).toBeUndefined(); + }); + + it('treats whitespace-only run ids as missing', () => { + expect( + eventRunId(edgeEvent('evt-r4', 4, 'run.started', { runId: ' ' }, undefined, { runId: 'scope-run' })), + ).toBe('scope-run'); + expect( + eventRunId(edgeEvent('evt-r5', 5, 'run.started', {}, undefined, { runId: ' ' })), + ).toBeUndefined(); + }); +}); + +describe('normalizeEvidenceStatus', () => { + const pendingStatuses = ['pending', 'queued']; + const runningStatuses = ['running', 'starting', 'streaming', 'draining']; + const failedStatuses = ['failed', 'cancelled', 'error', 'denied', 'rejected']; + const completedStatuses = ['completed', 'finished', 'succeeded', 'success', 'approved', 'ready']; + + it.each(pendingStatuses)('maps %s to pending', (status) => { + expect(normalizeEvidenceStatus(status)).toBe('pending'); + }); + + it.each(runningStatuses)('maps %s to running', (status) => { + expect(normalizeEvidenceStatus(status)).toBe('running'); + }); + + it.each(failedStatuses)('maps %s to failed', (status) => { + expect(normalizeEvidenceStatus(status)).toBe('failed'); + }); + + it.each(completedStatuses)('maps %s to completed', (status) => { + expect(normalizeEvidenceStatus(status)).toBe('completed'); + }); + + it('defaults unknown and undefined statuses to running', () => { + expect(normalizeEvidenceStatus(undefined)).toBe('running'); + expect(normalizeEvidenceStatus('bogus')).toBe('running'); + expect(normalizeEvidenceStatus('')).toBe('running'); + }); + + it('trims surrounding whitespace before matching', () => { + expect(normalizeEvidenceStatus(' pending ')).toBe('pending'); + expect(normalizeEvidenceStatus('\tcompleted\n')).toBe('completed'); + }); +}); + +describe('normalizeApprovalRisk', () => { + it('returns undefined for missing or unknown risks', () => { + expect(normalizeApprovalRisk(undefined)).toBeUndefined(); + expect(normalizeApprovalRisk('')).toBeUndefined(); + expect(normalizeApprovalRisk('catastrophic')).toBeUndefined(); + }); + + it('maps english risk levels', () => { + expect(normalizeApprovalRisk('low')).toBe('low'); + expect(normalizeApprovalRisk('medium')).toBe('medium'); + expect(normalizeApprovalRisk('mid')).toBe('medium'); + expect(normalizeApprovalRisk('high')).toBe('high'); + expect(normalizeApprovalRisk('critical')).toBe('critical'); + }); + + it('maps chinese risk levels', () => { + expect(normalizeApprovalRisk('低风险')).toBe('low'); + expect(normalizeApprovalRisk('中风险')).toBe('medium'); + expect(normalizeApprovalRisk('高风险')).toBe('high'); + expect(normalizeApprovalRisk('关键风险')).toBe('critical'); + }); + + it('trims and lowercases input before matching', () => { + expect(normalizeApprovalRisk(' HIGH ')).toBe('high'); + expect(normalizeApprovalRisk('\tCritical\n')).toBe('critical'); + }); + + it('does not match partially overlapping words', () => { + expect(normalizeApprovalRisk('medium-high')).toBeUndefined(); + expect(normalizeApprovalRisk('lower')).toBeUndefined(); + }); +}); + +describe('normalizeFileAction', () => { + it('maps create variants to created', () => { + expect(normalizeFileAction('created')).toBe('created'); + expect(normalizeFileAction('create')).toBe('created'); + expect(normalizeFileAction('added')).toBe('created'); + expect(normalizeFileAction('add')).toBe('created'); + }); + + it('maps delete variants to deleted', () => { + expect(normalizeFileAction('deleted')).toBe('deleted'); + expect(normalizeFileAction('delete')).toBe('deleted'); + expect(normalizeFileAction('removed')).toBe('deleted'); + expect(normalizeFileAction('remove')).toBe('deleted'); + }); + + it('defaults any other value to modified', () => { + expect(normalizeFileAction('modified')).toBe('modified'); + expect(normalizeFileAction('update')).toBe('modified'); + expect(normalizeFileAction('rewrote')).toBe('modified'); + }); + + it('defaults missing actions to modified', () => { + expect(normalizeFileAction(undefined)).toBe('modified'); + expect(normalizeFileAction('')).toBe('modified'); + }); + + it('trims and lowercases input before matching', () => { + expect(normalizeFileAction(' ADD ')).toBe('created'); + expect(normalizeFileAction('\tDelete\n')).toBe('deleted'); + }); +});