Skip to content

Commit 3e51152

Browse files
committed
refactor(common): consolidate browser-actions parsing (Commit 2.13)
- Create parseActionValue() utility for string→type conversion - Create LAZY_EDIT_PATTERNS constant for pattern matching - Update parseBrowserActionXML and parseBrowserActionAttributes - Simplify hasLazyEdit() and replaceNonStandardPlaceholderComments() - Add comprehensive unit tests
1 parent 299c8f3 commit 3e51152

File tree

4 files changed

+285
-101
lines changed

4 files changed

+285
-101
lines changed
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { describe, expect, it } from 'bun:test'
2+
3+
import { parseActionValue } from '../browser-actions'
4+
5+
describe('parseActionValue', () => {
6+
it('should parse boolean strings', () => {
7+
expect(parseActionValue('true')).toBe(true)
8+
expect(parseActionValue('false')).toBe(false)
9+
})
10+
11+
it('should parse numeric strings', () => {
12+
expect(parseActionValue('42')).toBe(42)
13+
expect(parseActionValue('3.14')).toBe(3.14)
14+
expect(parseActionValue('0')).toBe(0)
15+
expect(parseActionValue('-10')).toBe(-10)
16+
expect(parseActionValue('-3.14')).toBe(-3.14)
17+
})
18+
19+
it('should parse JSON objects', () => {
20+
expect(parseActionValue('{"key":"value"}')).toEqual({ key: 'value' })
21+
expect(parseActionValue('{"timeout":5000}')).toEqual({ timeout: 5000 })
22+
})
23+
24+
it('should parse JSON arrays', () => {
25+
expect(parseActionValue('[1,2,3]')).toEqual([1, 2, 3])
26+
expect(parseActionValue('["a","b"]')).toEqual(['a', 'b'])
27+
})
28+
29+
it('should return invalid JSON as string', () => {
30+
expect(parseActionValue('{invalid}')).toBe('{invalid}')
31+
expect(parseActionValue('[incomplete')).toBe('[incomplete')
32+
})
33+
34+
it('should keep regular strings as strings', () => {
35+
expect(parseActionValue('hello')).toBe('hello')
36+
expect(parseActionValue('https://example.com')).toBe('https://example.com')
37+
expect(parseActionValue('')).toBe('')
38+
})
39+
40+
it('should not parse strings that look like numbers but are not', () => {
41+
// Mixed alphanumeric strings should remain as strings
42+
expect(parseActionValue('123abc')).toBe('123abc')
43+
})
44+
45+
it('should keep empty string as empty string', () => {
46+
expect(parseActionValue('')).toBe('')
47+
})
48+
49+
// Edge case tests for strict numeric parsing
50+
describe('numeric parsing edge cases', () => {
51+
it('should keep whitespace-only strings as strings', () => {
52+
expect(parseActionValue(' ')).toBe(' ')
53+
expect(parseActionValue(' ')).toBe(' ')
54+
expect(parseActionValue('\t')).toBe('\t')
55+
expect(parseActionValue('\n')).toBe('\n')
56+
})
57+
58+
it('should keep hex strings as strings', () => {
59+
expect(parseActionValue('0x10')).toBe('0x10')
60+
expect(parseActionValue('0xFF')).toBe('0xFF')
61+
expect(parseActionValue('0xABC')).toBe('0xABC')
62+
})
63+
64+
it('should keep binary strings as strings', () => {
65+
expect(parseActionValue('0b10')).toBe('0b10')
66+
expect(parseActionValue('0b1111')).toBe('0b1111')
67+
})
68+
69+
it('should keep octal strings as strings', () => {
70+
expect(parseActionValue('0o10')).toBe('0o10')
71+
expect(parseActionValue('0o777')).toBe('0o777')
72+
})
73+
74+
it('should keep Infinity as string', () => {
75+
expect(parseActionValue('Infinity')).toBe('Infinity')
76+
expect(parseActionValue('-Infinity')).toBe('-Infinity')
77+
})
78+
79+
it('should keep NaN as string', () => {
80+
expect(parseActionValue('NaN')).toBe('NaN')
81+
})
82+
83+
it('should keep scientific notation as string', () => {
84+
expect(parseActionValue('1e10')).toBe('1e10')
85+
expect(parseActionValue('1E10')).toBe('1E10')
86+
expect(parseActionValue('1e+10')).toBe('1e+10')
87+
expect(parseActionValue('1e-10')).toBe('1e-10')
88+
expect(parseActionValue('2.5e3')).toBe('2.5e3')
89+
})
90+
91+
it('should keep explicit positive sign as string', () => {
92+
expect(parseActionValue('+5')).toBe('+5')
93+
expect(parseActionValue('+3.14')).toBe('+3.14')
94+
})
95+
96+
it('should parse numbers with leading zeros as numbers', () => {
97+
// Leading zeros are allowed and parsed as decimal numbers
98+
expect(parseActionValue('007')).toBe(7)
99+
expect(parseActionValue('00')).toBe(0)
100+
expect(parseActionValue('0123')).toBe(123)
101+
})
102+
103+
it('should keep strings with embedded whitespace as strings', () => {
104+
expect(parseActionValue(' 42 ')).toBe(' 42 ')
105+
expect(parseActionValue('4 2')).toBe('4 2')
106+
})
107+
})
108+
109+
// Edge case tests for JSON parsing
110+
describe('JSON parsing edge cases', () => {
111+
it('should parse nested JSON objects', () => {
112+
expect(parseActionValue('{"a":{"b":{"c":1}}}')).toEqual({
113+
a: { b: { c: 1 } },
114+
})
115+
})
116+
117+
it('should parse nested JSON arrays', () => {
118+
expect(parseActionValue('[[1,2],[3,4]]')).toEqual([
119+
[1, 2],
120+
[3, 4],
121+
])
122+
})
123+
124+
it('should parse JSON with mixed types', () => {
125+
expect(parseActionValue('[1,"a",true,null]')).toEqual([1, 'a', true, null])
126+
})
127+
128+
it('should parse JSON with special characters', () => {
129+
expect(parseActionValue('{"key":"value with spaces"}')).toEqual({
130+
key: 'value with spaces',
131+
})
132+
expect(parseActionValue('{"key":"line1\\nline2"}')).toEqual({
133+
key: 'line1\nline2',
134+
})
135+
})
136+
137+
it('should parse empty JSON structures', () => {
138+
expect(parseActionValue('{}')).toEqual({})
139+
expect(parseActionValue('[]')).toEqual([])
140+
})
141+
})
142+
})

common/src/browser-actions.ts

Lines changed: 41 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -359,30 +359,10 @@ export function parseBrowserActionXML(xmlString: string): BrowserAction {
359359
// Parse special values (booleans, numbers, objects)
360360
const parsedAttrs = Object.entries(attrs).reduce(
361361
(acc, [key, value]) => {
362-
try {
363-
// Try to parse as JSON for objects
364-
if (value.startsWith('{') || value.startsWith('[')) {
365-
acc[key] = JSON.parse(value)
366-
}
367-
// Parse booleans
368-
else if (value === 'true' || value === 'false') {
369-
acc[key] = value === 'true'
370-
}
371-
// Parse numbers
372-
else if (!isNaN(Number(value))) {
373-
acc[key] = Number(value)
374-
}
375-
// Keep as string
376-
else {
377-
acc[key] = value
378-
}
379-
} catch {
380-
// If parsing fails, keep as string
381-
acc[key] = value
382-
}
362+
acc[key] = parseActionValue(value)
383363
return acc
384364
},
385-
{} as Record<string, any>,
365+
{} as Record<string, unknown>,
386366
)
387367

388368
// Construct and validate the BrowserAction
@@ -393,6 +373,38 @@ export function parseBrowserActionXML(xmlString: string): BrowserAction {
393373
export type BrowserResponse = z.infer<typeof BrowserResponseSchema>
394374
export type BrowserAction = z.infer<typeof BrowserActionSchema>
395375

376+
/**
377+
* Strict regex for decimal numbers: optional minus, one or more digits, optional decimal part.
378+
* This prevents parsing of hex (0x10), binary (0b10), octal (0o10), Infinity, NaN,
379+
* scientific notation (1e10), and whitespace strings.
380+
*/
381+
const STRICT_DECIMAL_REGEX = /^-?\d+(\.\d+)?$/
382+
383+
/**
384+
* Parse a string value into its appropriate JS type (boolean, number, object, or string)
385+
*/
386+
export function parseActionValue(value: string): unknown {
387+
// Try to parse as JSON for objects/arrays
388+
if (value.startsWith('{') || value.startsWith('[')) {
389+
try {
390+
return JSON.parse(value)
391+
} catch {
392+
return value
393+
}
394+
}
395+
// Parse booleans
396+
if (value === 'true') return true
397+
if (value === 'false') return false
398+
// Parse numbers using strict decimal regex to avoid edge cases:
399+
// - Whitespace strings: Number(' ') === 0
400+
// - Hex strings: Number('0x10') === 16
401+
// - Infinity: Number('Infinity') === Infinity
402+
// - Scientific notation: Number('1e10') === 10000000000
403+
if (STRICT_DECIMAL_REGEX.test(value)) return Number(value)
404+
// Keep as string
405+
return value
406+
}
407+
396408
/**
397409
* Parse browser action XML attributes into a typed BrowserAction object
398410
*/
@@ -402,12 +414,12 @@ export function parseBrowserActionAttributes(
402414
const { action, ...rest } = attributes
403415
return {
404416
type: action,
405-
...Object.entries(rest).reduce((acc, [key, value]) => {
406-
// Convert string values to appropriate types
407-
if (value === 'true') return { ...acc, [key]: true }
408-
if (value === 'false') return { ...acc, [key]: false }
409-
if (!isNaN(Number(value))) return { ...acc, [key]: Number(value) }
410-
return { ...acc, [key]: value }
411-
}, {}),
417+
...Object.entries(rest).reduce(
418+
(acc, [key, value]) => {
419+
acc[key] = parseActionValue(value)
420+
return acc
421+
},
422+
{} as Record<string, unknown>,
423+
),
412424
} as BrowserAction
413425
}

common/src/util/__tests__/string.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { describe, expect, it } from 'bun:test'
22

33
import { EXISTING_CODE_MARKER } from '../../old-constants'
4-
import { pluralize, replaceNonStandardPlaceholderComments } from '../string'
4+
import {
5+
hasLazyEdit,
6+
LAZY_EDIT_PATTERNS,
7+
pluralize,
8+
replaceNonStandardPlaceholderComments,
9+
} from '../string'
510

611
describe('pluralize', () => {
712
it('should handle singular and plural cases correctly', () => {
@@ -238,6 +243,62 @@ describe('pluralize', () => {
238243
})
239244
})
240245

246+
describe('LAZY_EDIT_PATTERNS', () => {
247+
it('should detect C-style single-line comments', () => {
248+
expect(LAZY_EDIT_PATTERNS.some((p) => p.test('// ... existing code ...'))).toBe(true)
249+
expect(LAZY_EDIT_PATTERNS.some((p) => p.test('// ... rest of file'))).toBe(true)
250+
})
251+
252+
it('should detect C-style multi-line comments', () => {
253+
expect(LAZY_EDIT_PATTERNS.some((p) => p.test('/* ... unchanged ... */'))).toBe(true)
254+
})
255+
256+
it('should detect Python/Ruby comments', () => {
257+
expect(LAZY_EDIT_PATTERNS.some((p) => p.test('# ... existing code ...'))).toBe(true)
258+
})
259+
260+
it('should detect HTML comments', () => {
261+
expect(LAZY_EDIT_PATTERNS.some((p) => p.test('<!-- ... keep ... -->'))).toBe(true)
262+
})
263+
264+
it('should detect JSX comments', () => {
265+
expect(LAZY_EDIT_PATTERNS.some((p) => p.test('{/* ... some code ... */'))).toBe(true)
266+
})
267+
268+
it('should detect SQL/Haskell comments', () => {
269+
expect(LAZY_EDIT_PATTERNS.some((p) => p.test('-- ... file ...'))).toBe(true)
270+
})
271+
272+
it('should detect MATLAB comments', () => {
273+
expect(LAZY_EDIT_PATTERNS.some((p) => p.test('% ... rest ...'))).toBe(true)
274+
})
275+
276+
it('should not match regular comments', () => {
277+
expect(LAZY_EDIT_PATTERNS.some((p) => p.test('// regular comment'))).toBe(false)
278+
expect(LAZY_EDIT_PATTERNS.some((p) => p.test('/* normal comment */'))).toBe(false)
279+
})
280+
})
281+
282+
describe('hasLazyEdit', () => {
283+
it('should detect common lazy edit patterns', () => {
284+
expect(hasLazyEdit('... existing code ...')).toBe(true)
285+
expect(hasLazyEdit('// rest of the file')).toBe(true)
286+
expect(hasLazyEdit('# rest of the function')).toBe(true)
287+
})
288+
289+
it('should detect regex-based lazy edit patterns', () => {
290+
expect(hasLazyEdit('// ... existing code ...')).toBe(true)
291+
expect(hasLazyEdit('/* ... unchanged ... */')).toBe(true)
292+
expect(hasLazyEdit('# ... keep ...')).toBe(true)
293+
expect(hasLazyEdit('<!-- ... file ... -->')).toBe(true)
294+
})
295+
296+
it('should not detect regular code', () => {
297+
expect(hasLazyEdit('const x = 1')).toBe(false)
298+
expect(hasLazyEdit('// this is a comment')).toBe(false)
299+
})
300+
})
301+
241302
describe('replaceNonStandardPlaceholderComments', () => {
242303
it('should replace C-style comments', () => {
243304
const input = `

0 commit comments

Comments
 (0)