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:
parent
6d81d53d1d
commit
6a888ea692
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
43
tests/engine/vue/testing/test-id.test.ts
Normal file
43
tests/engine/vue/testing/test-id.test.ts
Normal 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"]')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue