|
| 1 | +import type { Provision } from '@metamask/kernel-utils/session'; |
| 2 | +import { describe, expect, it } from 'vitest'; |
| 3 | + |
| 4 | +import { |
| 5 | + escapeControlCharsInStrings, |
| 6 | + extractStringFields, |
| 7 | + formatExpandedContent, |
| 8 | + formatProvisionCompact, |
| 9 | + formatTime, |
| 10 | + parseDescription, |
| 11 | + splitShellCommand, |
| 12 | + tryParseJsonObject, |
| 13 | +} from './format.ts'; |
| 14 | + |
| 15 | +describe('formatTime', () => { |
| 16 | + it.each([ |
| 17 | + ['2026-01-02T03:04:05.000Z', /^\d{2}:\d{2}:\d{2}$/u], |
| 18 | + ['2026-12-31T23:59:59.000Z', /^\d{2}:\d{2}:\d{2}$/u], |
| 19 | + ])('renders ISO %s as HH:mm:ss', (iso, pattern) => { |
| 20 | + expect(formatTime(iso)).toMatch(pattern); |
| 21 | + }); |
| 22 | + |
| 23 | + it('pads single-digit fields with zero', () => { |
| 24 | + // Construct a Date in the local zone so we can predict the output. |
| 25 | + const date = new Date(2026, 0, 1, 3, 4, 5); |
| 26 | + expect(formatTime(date.toISOString())).toBe('03:04:05'); |
| 27 | + }); |
| 28 | +}); |
| 29 | + |
| 30 | +describe('splitShellCommand', () => { |
| 31 | + it.each<[string, string[]]>([ |
| 32 | + ['ls -la', ['ls -la']], |
| 33 | + ['ls && cd /tmp', ['ls', '&& cd /tmp']], |
| 34 | + ['git log | head', ['git log', '| head']], |
| 35 | + ['echo a ; echo b', ['echo a', '; echo b']], |
| 36 | + [ |
| 37 | + 'git log --oneline | head -10 && echo done', |
| 38 | + ['git log --oneline', '| head -10', '&& echo done'], |
| 39 | + ], |
| 40 | + ['true || false', ['true || false']], |
| 41 | + ])('splits %s into segments', (command, expected) => { |
| 42 | + expect(splitShellCommand(command)).toStrictEqual(expected); |
| 43 | + }); |
| 44 | + |
| 45 | + it('returns empty array for empty input', () => { |
| 46 | + expect(splitShellCommand('')).toStrictEqual([]); |
| 47 | + }); |
| 48 | +}); |
| 49 | + |
| 50 | +describe('tryParseJsonObject', () => { |
| 51 | + it('parses a valid object literal', () => { |
| 52 | + expect(tryParseJsonObject('{"a":1,"b":"x"}')).toStrictEqual({ |
| 53 | + a: 1, |
| 54 | + b: 'x', |
| 55 | + }); |
| 56 | + }); |
| 57 | + |
| 58 | + it.each<[string]>([['[1,2,3]'], ['"hello"'], ['42'], ['null'], ['not json']])( |
| 59 | + 'returns null for non-object input %s', |
| 60 | + (input) => { |
| 61 | + expect(tryParseJsonObject(input)).toBeNull(); |
| 62 | + }, |
| 63 | + ); |
| 64 | +}); |
| 65 | + |
| 66 | +describe('escapeControlCharsInStrings', () => { |
| 67 | + it('escapes newlines inside string values', () => { |
| 68 | + expect(escapeControlCharsInStrings('{"a":"line1\nline2"}')).toBe( |
| 69 | + '{"a":"line1\\nline2"}', |
| 70 | + ); |
| 71 | + }); |
| 72 | + |
| 73 | + it('escapes tabs and CR inside string values', () => { |
| 74 | + expect(escapeControlCharsInStrings('{"a":"x\ty\rz"}')).toBe( |
| 75 | + '{"a":"x\\ty\\rz"}', |
| 76 | + ); |
| 77 | + }); |
| 78 | + |
| 79 | + it('preserves structural whitespace outside strings', () => { |
| 80 | + const input = '{\n "a": "ok"\n}'; |
| 81 | + expect(escapeControlCharsInStrings(input)).toBe(input); |
| 82 | + }); |
| 83 | + |
| 84 | + it('leaves existing backslash escapes alone', () => { |
| 85 | + expect(escapeControlCharsInStrings('{"a":"\\nliteral"}')).toBe( |
| 86 | + '{"a":"\\nliteral"}', |
| 87 | + ); |
| 88 | + }); |
| 89 | +}); |
| 90 | + |
| 91 | +describe('parseDescription', () => { |
| 92 | + it('parses a Bash invocation with a JSON body', () => { |
| 93 | + const result = parseDescription( |
| 94 | + 'Allow Bash({"command":"ls -la","description":"list"})', |
| 95 | + ); |
| 96 | + expect(result).toStrictEqual({ |
| 97 | + label: 'Allow Bash', |
| 98 | + params: { command: 'ls -la', description: 'list' }, |
| 99 | + }); |
| 100 | + }); |
| 101 | + |
| 102 | + it('parses a Read invocation', () => { |
| 103 | + const result = parseDescription('Allow Read({"file_path":"/tmp/foo.txt"})'); |
| 104 | + expect(result).toStrictEqual({ |
| 105 | + label: 'Allow Read', |
| 106 | + params: { file_path: '/tmp/foo.txt' }, |
| 107 | + }); |
| 108 | + }); |
| 109 | + |
| 110 | + it('parses an Edit invocation', () => { |
| 111 | + const result = parseDescription( |
| 112 | + 'Allow Edit({"file_path":"/x","old_string":"a","new_string":"b"})', |
| 113 | + ); |
| 114 | + expect(result).toStrictEqual({ |
| 115 | + label: 'Allow Edit', |
| 116 | + params: { file_path: '/x', old_string: 'a', new_string: 'b' }, |
| 117 | + }); |
| 118 | + }); |
| 119 | + |
| 120 | + it('returns null params when no parens are present', () => { |
| 121 | + expect(parseDescription('Just a label')).toStrictEqual({ |
| 122 | + label: 'Just a label', |
| 123 | + params: null, |
| 124 | + }); |
| 125 | + }); |
| 126 | + |
| 127 | + it('preserves the label even when JSON parse fails', () => { |
| 128 | + expect(parseDescription('Allow Bash({not json})')).toStrictEqual({ |
| 129 | + label: 'Allow Bash', |
| 130 | + params: null, |
| 131 | + }); |
| 132 | + }); |
| 133 | + |
| 134 | + it('retries via control-char escaping when JSON contains literal newlines', () => { |
| 135 | + const result = parseDescription('Allow Bash({"command":"a\nb"})'); |
| 136 | + expect(result).toStrictEqual({ |
| 137 | + label: 'Allow Bash', |
| 138 | + params: { command: 'a\nb' }, |
| 139 | + }); |
| 140 | + }); |
| 141 | + |
| 142 | + it('returns null params when the closing paren is missing', () => { |
| 143 | + expect(parseDescription('Allow Bash({"command":"x"}')).toStrictEqual({ |
| 144 | + label: 'Allow Bash', |
| 145 | + params: null, |
| 146 | + }); |
| 147 | + }); |
| 148 | +}); |
| 149 | + |
| 150 | +describe('extractStringFields', () => { |
| 151 | + it('extracts top-level string fields', () => { |
| 152 | + expect( |
| 153 | + extractStringFields('{"command":"ls -la","description":"list"}'), |
| 154 | + ).toStrictEqual([ |
| 155 | + ['command', 'ls -la'], |
| 156 | + ['description', 'list'], |
| 157 | + ]); |
| 158 | + }); |
| 159 | + |
| 160 | + it('decodes common escape sequences in values', () => { |
| 161 | + expect(extractStringFields('{"x":"a\\nb\\tc\\rd"}')).toStrictEqual([ |
| 162 | + ['x', 'a\nb\tc\rd'], |
| 163 | + ]); |
| 164 | + }); |
| 165 | + |
| 166 | + it('skips non-string fields', () => { |
| 167 | + expect(extractStringFields('{"n":42,"s":"hi"}')).toStrictEqual([ |
| 168 | + ['s', 'hi'], |
| 169 | + ]); |
| 170 | + }); |
| 171 | + |
| 172 | + it('returns null when input is not object-shaped', () => { |
| 173 | + expect(extractStringFields('[1,2,3]')).toBeNull(); |
| 174 | + }); |
| 175 | + |
| 176 | + it('returns null when no string fields are found', () => { |
| 177 | + expect(extractStringFields('{"n":42}')).toBeNull(); |
| 178 | + }); |
| 179 | +}); |
| 180 | + |
| 181 | +describe('formatProvisionCompact', () => { |
| 182 | + it('formats a single-invocation provision', () => { |
| 183 | + const provision: Provision = { |
| 184 | + tool: 'Bash', |
| 185 | + patterns: [ |
| 186 | + { |
| 187 | + name: 'ls', |
| 188 | + argPatterns: [{ kind: 'exact', value: '-la' }], |
| 189 | + }, |
| 190 | + ], |
| 191 | + }; |
| 192 | + expect(formatProvisionCompact(provision)).toBe('ls -la'); |
| 193 | + }); |
| 194 | + |
| 195 | + it('joins a pipeline with ` | `', () => { |
| 196 | + const provision: Provision = { |
| 197 | + tool: 'Bash', |
| 198 | + patterns: [ |
| 199 | + { |
| 200 | + name: 'git', |
| 201 | + argPatterns: [{ kind: 'exact', value: 'log' }, { kind: 'wildcard' }], |
| 202 | + }, |
| 203 | + { |
| 204 | + name: 'head', |
| 205 | + argPatterns: [{ kind: 'wildcard' }], |
| 206 | + }, |
| 207 | + ], |
| 208 | + }; |
| 209 | + expect(formatProvisionCompact(provision)).toBe('git log * | head *'); |
| 210 | + }); |
| 211 | + |
| 212 | + it('renders prefix patterns with a trailing star', () => { |
| 213 | + const provision: Provision = { |
| 214 | + tool: 'Read', |
| 215 | + patterns: [ |
| 216 | + { |
| 217 | + name: 'Read', |
| 218 | + argPatterns: [{ kind: 'prefix', prefix: '/tmp/' }], |
| 219 | + }, |
| 220 | + ], |
| 221 | + }; |
| 222 | + expect(formatProvisionCompact(provision)).toBe('Read /tmp/*'); |
| 223 | + }); |
| 224 | +}); |
| 225 | + |
| 226 | +describe('formatExpandedContent', () => { |
| 227 | + it('splits a Bash command on shell operators', () => { |
| 228 | + const description = 'Allow Bash({"command":"ls && cd /tmp"})'; |
| 229 | + expect(formatExpandedContent(description)).toBe('ls\n&& cd /tmp'); |
| 230 | + }); |
| 231 | + |
| 232 | + it('splits a Bash command on newlines when present', () => { |
| 233 | + const description = 'Allow Bash({"command":"line1\\nline2"})'; |
| 234 | + expect(formatExpandedContent(description)).toBe('line1\nline2'); |
| 235 | + }); |
| 236 | + |
| 237 | + it('renders generic tools as key: value lines', () => { |
| 238 | + const description = 'Allow Read({"file_path":"/x","limit":50})'; |
| 239 | + expect(formatExpandedContent(description)).toBe('file_path: /x\nlimit: 50'); |
| 240 | + }); |
| 241 | + |
| 242 | + it('truncates very long string values with an ellipsis', () => { |
| 243 | + const longValue = 'a'.repeat(250); |
| 244 | + const description = `Allow Read({"file_path":"${longValue}"})`; |
| 245 | + const output = formatExpandedContent(description); |
| 246 | + expect(output.startsWith('file_path: ')).toBe(true); |
| 247 | + expect(output.endsWith('…')).toBe(true); |
| 248 | + expect(output.length).toBeLessThan(longValue.length + 'file_path: '.length); |
| 249 | + }); |
| 250 | + |
| 251 | + it('truncates long Bash segments independently', () => { |
| 252 | + const longSegment = 'a'.repeat(250); |
| 253 | + const description = `Allow Bash({"command":"${longSegment} && ls"})`; |
| 254 | + const output = formatExpandedContent(description); |
| 255 | + const [first, second] = output.split('\n'); |
| 256 | + expect(first?.endsWith('…')).toBe(true); |
| 257 | + expect(second).toBe('&& ls'); |
| 258 | + }); |
| 259 | + |
| 260 | + it('falls back to extractStringFields when JSON parsing fails', () => { |
| 261 | + // Description with an unescaped quote inside the value — outer JSON fails. |
| 262 | + const description = 'Allow Bash({"command":"echo "hi""})'; |
| 263 | + const output = formatExpandedContent(description); |
| 264 | + expect(output).toContain('echo'); |
| 265 | + }); |
| 266 | + |
| 267 | + it('returns the raw description when no parens are present', () => { |
| 268 | + expect(formatExpandedContent('hello world')).toBe('hello world'); |
| 269 | + }); |
| 270 | +}); |
0 commit comments