diff --git a/lint/plugin.js b/lint/plugin.js index 38faf6dfe..4db8b45c1 100644 --- a/lint/plugin.js +++ b/lint/plugin.js @@ -201,6 +201,45 @@ const noVueStyleBlocks = { } } +const TEST_ID_FORMAT = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/ + +const noInvalidTestIdAttributes = { + meta: { + docs: { + description: 'Enforce data-test-id spelling and kebab-case static test ids in Vue components' + } + }, + create(context) { + const file = normalizedFilename(context) + if (!file.endsWith('.vue')) return {} + + return { + Program(node) { + const source = context.sourceCode.getText() + const invalidAttr = source.match(/\bdata-testid\s*=/) + if (invalidAttr) { + context.report({ + node, + message: 'Use data-test-id instead of data-testid.' + }) + return + } + + const attrPattern = /\bdata-test-id\s*=\s*"([^"]+)"/g + for (const match of source.matchAll(attrPattern)) { + const id = match[1] + if (TEST_ID_FORMAT.test(id)) continue + context.report({ + node, + message: `Static data-test-id values must be kebab-case. Invalid id: "${id}".` + }) + return + } + } + } + } +} + const noDocumentQuerySelectorInVue = { meta: { docs: { @@ -1286,6 +1325,7 @@ const plugin = { 'no-inline-named-types': noInlineNamedTypes, 'no-structuredclone-scene-arrays': noStructuredCloneSceneArrays, 'no-vue-style-blocks': noVueStyleBlocks, + 'no-invalid-test-id-attributes': noInvalidTestIdAttributes, 'no-document-query-selector-in-vue': noDocumentQuerySelectorInVue, 'no-direct-selection-tool-state-mutation': noDirectSelectionToolStateMutation, 'no-math-random': noMathRandom, diff --git a/oxlint.json b/oxlint.json index 57c09f838..ea5c29985 100644 --- a/oxlint.json +++ b/oxlint.json @@ -112,6 +112,7 @@ ], "open-pencil/no-structuredclone-scene-arrays": "error", "open-pencil/no-vue-style-blocks": "error", + "open-pencil/no-invalid-test-id-attributes": "error", "open-pencil/no-document-query-selector-in-vue": "error", "open-pencil/no-direct-selection-tool-state-mutation": "error", "open-pencil/no-math-random": "error", diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 56e71500b..7d727be92 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -47,6 +47,8 @@ export { useToolbarState } from '#vue/primitives/Toolbar/useToolbarState' export { useNodeFontStatus } from '#vue/shared/font-status/use' export { usePropScrub } from '#vue/controls/prop-scrub/use' export { toolCursor } from '#vue/editor/tool-cursor' +export { testId, testIdSelector } from '#vue/testing/test-id' +export type { TestId, TestIdProps } from '#vue/testing/test-id' /** Property-panel composables. */ export { usePosition } from '#vue/controls/position/use' diff --git a/packages/vue/src/testing/test-id.ts b/packages/vue/src/testing/test-id.ts new file mode 100644 index 000000000..521690fb6 --- /dev/null +++ b/packages/vue/src/testing/test-id.ts @@ -0,0 +1,17 @@ +export type TestId = string + +export type TestIdProps = { + testId?: TestId +} + +export function testId(id?: TestId | null): { 'data-test-id'?: TestId } { + return id ? { 'data-test-id': id } : {} +} + +export function testIdSelector(id: TestId): string { + return `[data-test-id="${cssEscape(id)}"]` +} + +function cssEscape(value: string): string { + return CSS.escape(value) +} diff --git a/playwright.config.ts b/playwright.config.ts index c50532e1d..44a3e4758 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ }, use: { baseURL: 'http://localhost:1420', + testIdAttribute: 'data-test-id', viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2, colorScheme: 'dark', diff --git a/src/components/PickerSlider.vue b/src/components/PickerSlider.vue index 71aa053dd..0a6f1bbac 100644 --- a/src/components/PickerSlider.vue +++ b/src/components/PickerSlider.vue @@ -1,5 +1,5 @@