fix(vue): escape test selectors outside browsers

- Fall back to standards-compatible CSS identifier escaping when CSS.escape is unavailable

Co-authored-by: Joseph Cumines <joeycumines@gmail.com>
This commit is contained in:
Danila Poyarkov 2026-08-13 18:33:03 +03:00
parent 6d81d53d1d
commit 6a888ea692
2 changed files with 111 additions and 1 deletions

View file

@ -1,5 +1,11 @@
export type TestId = string
type CssEscapeRuntime = {
CSS?: {
escape?: (value: string) => string
}
}
export function testId(id?: TestId | null): { 'data-test-id'?: TestId } {
return id ? { 'data-test-id': id } : {}
}
@ -29,5 +35,66 @@ export function acpPermissionOptionTestId(kind: string): TestId {
}
function cssEscape(value: string): string {
return CSS.escape(value)
const runtime = globalThis as CssEscapeRuntime
const nativeEscape = runtime.CSS?.escape
if (typeof nativeEscape === 'function') {
return nativeEscape(value)
}
return cssEscapeFallback(value)
}
function cssEscapeFallback(value: string): string {
let escaped = ''
for (let index = 0; index < value.length; ) {
const codePoint = value.codePointAt(index)
if (codePoint === undefined) {
break
}
const character = String.fromCodePoint(codePoint)
const nextIndex = index + character.length
const isFirst = index === 0
const isSecondAfterHyphen = index === 1 && value.charCodeAt(0) === 0x002d
if (codePoint === 0x0000) {
escaped += '\uFFFD'
} else if (
isControlCodePoint(codePoint) ||
(isFirst && isDigitCodePoint(codePoint)) ||
(isSecondAfterHyphen && isDigitCodePoint(codePoint))
) {
escaped += `\\${codePoint.toString(16)} `
} else if (isFirst && codePoint === 0x002d && nextIndex >= value.length) {
escaped += '\\-'
} else if (isSafeIdentifierCodePoint(codePoint)) {
escaped += character
} else {
escaped += `\\${character}`
}
index = nextIndex
}
return escaped
}
function isControlCodePoint(codePoint: number): boolean {
return (codePoint >= 0x0001 && codePoint <= 0x001f) || codePoint === 0x007f
}
function isDigitCodePoint(codePoint: number): boolean {
return codePoint >= 0x0030 && codePoint <= 0x0039
}
function isSafeIdentifierCodePoint(codePoint: number): boolean {
return (
codePoint >= 0x0080 ||
codePoint === 0x002d ||
codePoint === 0x005f ||
isDigitCodePoint(codePoint) ||
(codePoint >= 0x0041 && codePoint <= 0x005a) ||
(codePoint >= 0x0061 && codePoint <= 0x007a)
)
}

View file

@ -0,0 +1,43 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { testIdSelector } from '@open-pencil/vue'
const originalCssDescriptor = Reflect.getOwnPropertyDescriptor(globalThis, 'CSS')
function restoreCssGlobal() {
if (originalCssDescriptor) {
Object.defineProperty(globalThis, 'CSS', originalCssDescriptor)
} else {
Reflect.deleteProperty(globalThis, 'CSS')
}
}
afterEach(() => {
restoreCssGlobal()
})
describe('testIdSelector', () => {
test('escapes selectors without requiring a browser CSS global', () => {
Reflect.deleteProperty(globalThis, 'CSS')
expect(testIdSelector('plain-id')).toBe('[data-test-id="plain-id"]')
expect(testIdSelector('a"b c')).toBe('[data-test-id="a\\"b\\ c"]')
expect(testIdSelector('1starts-with-digit')).toBe('[data-test-id="\\31 starts-with-digit"]')
expect(testIdSelector('-1after-hyphen')).toBe('[data-test-id="-\\31 after-hyphen"]')
expect(testIdSelector('-')).toBe('[data-test-id="\\-"]')
expect(testIdSelector('null\u0000char')).toBe('[data-test-id="null<6C>char"]')
})
test('uses native CSS.escape when the runtime provides it', () => {
Object.defineProperty(globalThis, 'CSS', {
configurable: true,
value: {
escape(value: string) {
return `native-${value}`
}
}
})
expect(testIdSelector('id')).toBe('[data-test-id="native-id"]')
})
})