chore(vue): standardize test id helpers

This commit is contained in:
Danila Poyarkov 2026-05-15 12:07:58 +03:00
parent e2eaf85cff
commit 0749bfa755
8 changed files with 98 additions and 31 deletions

View file

@ -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,

View file

@ -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",

View file

@ -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'

View file

@ -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)
}

View file

@ -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',

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { inputNumberValue } from '@open-pencil/vue'
import { inputNumberValue, testId as testIdAttr, type TestIdProps } from '@open-pencil/vue'
import { usePickerSliderUI } from './ui/picker-slider'
type PickerSliderDisplay = {
@ -11,6 +11,21 @@ type PickerSliderDisplay = {
parse?: (value: number) => number
}
type PickerSliderProps = TestIdProps & {
label: string
modelValue: number
min: number
max: number
step?: number
display?: PickerSliderDisplay
gradientStyle?: string
checkerboard?: boolean
thumbFill?: string
ui?: Partial<
Record<'root' | 'label' | 'track' | 'gradient' | 'range' | 'thumb' | 'input', string>
>
}
const {
label,
modelValue,
@ -23,21 +38,7 @@ const {
thumbFill = '#fff',
testId,
ui
} = defineProps<{
label: string
modelValue: number
min: number
max: number
step?: number
display?: PickerSliderDisplay
gradientStyle?: string
checkerboard?: boolean
thumbFill?: string
testId?: string
ui?: Partial<
Record<'root' | 'label' | 'track' | 'gradient' | 'range' | 'thumb' | 'input', string>
>
}>()
} = defineProps<PickerSliderProps>()
const emit = defineEmits<{
'update:modelValue': [value: number]
@ -63,7 +64,7 @@ function thumbLeft(): string {
</script>
<template>
<div :class="cls.root" :data-test-id="testId">
<div :class="cls.root" v-bind="testIdAttr(testId)">
<span :class="cls.label">{{ label }}</span>
<div :class="cls.track">
<div :class="cls.gradient" :style="gradientStyle" />

View file

@ -11,6 +11,7 @@ import {
SelectTrigger,
SelectViewport
} from 'reka-ui'
import { testId as testIdAttr, type TestIdProps } from '@open-pencil/vue'
import { useSelectUI } from '@/components/ui/select'
@ -32,12 +33,13 @@ interface GroupedSelectUi {
separator?: string
}
const { groups, displayValue, ui, testId } = defineProps<{
groups: SelectGroupDef<T>[]
displayValue: string
ui?: GroupedSelectUi
testId?: string
}>()
const { groups, displayValue, ui, testId } = defineProps<
TestIdProps & {
groups: SelectGroupDef<T>[]
displayValue: string
ui?: GroupedSelectUi
}
>()
const modelValue = defineModel<T>({ required: true })
@ -55,7 +57,7 @@ const separator = ui?.separator ?? 'mx-1 my-1 h-px bg-border'
<template>
<SelectRoot v-model="modelValue">
<SelectTrigger :data-test-id="testId" :class="select.trigger">
<SelectTrigger v-bind="testIdAttr(testId)" :class="select.trigger">
<slot name="value">{{ displayValue }}</slot>
<icon-lucide-chevron-down class="size-2.5 shrink-0 text-muted" />
</SelectTrigger>

View file

@ -11,6 +11,8 @@ import {
SelectViewport
} from 'reka-ui'
import { testId as testIdAttr, type TestIdProps } from '@open-pencil/vue'
import { useSelectUI } from '@/components/ui/select'
import type { SelectUi } from '@/components/ui/select'
@ -24,12 +26,13 @@ const {
placeholder,
ui,
testId = 'app-select-trigger'
} = defineProps<{
options: { value: T; label: string }[]
placeholder?: string
ui?: AppSelectUi
testId?: string
}>()
} = defineProps<
TestIdProps & {
options: { value: T; label: string }[]
placeholder?: string
ui?: AppSelectUi
}
>()
const modelValue = defineModel<T>({ required: true })
@ -44,7 +47,7 @@ const indicator = ui?.indicator ?? 'absolute left-1.5 inline-flex items-center j
<template>
<SelectRoot v-model="modelValue">
<SelectTrigger :data-test-id="testId" :class="select.trigger">
<SelectTrigger v-bind="testIdAttr(testId)" :class="select.trigger">
<SelectValue :placeholder="placeholder" />
<icon-lucide-chevron-down class="ml-1 size-3 shrink-0 text-muted" />
</SelectTrigger>