From 6a888ea69277a74d1a91b3ad5fb1fefed68cb7f0 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Thu, 13 Aug 2026 18:33:03 +0300 Subject: [PATCH] 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 --- packages/vue/src/testing/test-id.ts | 69 +++++++++++++++++++++++- tests/engine/vue/testing/test-id.test.ts | 43 +++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/engine/vue/testing/test-id.test.ts diff --git a/packages/vue/src/testing/test-id.ts b/packages/vue/src/testing/test-id.ts index 794c9be47..c6d1b7bf9 100644 --- a/packages/vue/src/testing/test-id.ts +++ b/packages/vue/src/testing/test-id.ts @@ -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) + ) } diff --git a/tests/engine/vue/testing/test-id.test.ts b/tests/engine/vue/testing/test-id.test.ts new file mode 100644 index 000000000..b883a0188 --- /dev/null +++ b/tests/engine/vue/testing/test-id.test.ts @@ -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�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"]') + }) +})