Compare commits

..

7 Commits

Author SHA1 Message Date
GitHub Action
1f818ba529 [automated] Apply ESLint and Oxfmt fixes 2026-03-28 11:43:15 +00:00
bymyself
8680791f4a fix: remove unused emit declaration and eslint-disable directive 2026-03-28 04:40:10 -07:00
bymyself
c831562ec1 fix: suppress Vue ESLint rules for inline test component stubs 2026-03-28 03:39:29 -07:00
bymyself
35f79b7cd3 feat: add PrimeVue component stubs to test-utils (COMP-03) 2026-03-27 22:00:33 -07:00
GitHub Action
90a819bc87 [automated] Apply ESLint and Oxfmt fixes 2026-03-27 20:30:19 +00:00
bymyself
69239ca2d5 test: add standardized test-utils with Pinia + i18n defaults
Recreates src/utils/test-utils.ts (removed in #10471) as the canonical
shared render wrapper for VTL component tests.

Provides:
- renderWithDefaults(): auto-configures Pinia (stubActions: false),
  vue-i18n (English), and common directive stubs (tooltip)
- Optional userEvent instance (opt out via setupUser: false)
- Re-exports screen from @testing-library/vue

Eliminates duplicated createTestingPinia + createI18n boilerplate
across 97+ test files.

Part of: COMP-02 (standardize Pinia store test mocking patterns)
2026-03-27 13:27:06 -07:00
Christian Byrne
e809d74192 perf: disable Sentry event target wrapping to reduce DOM event overhead (#10472)
## Summary

Disable Sentry `browserApiErrorsIntegration` event target wrapping for
cloud builds to eliminate 231.7ms of `sentryWrapped` overhead during
canvas interaction.

## Changes

- **What**: Configure `browserApiErrorsIntegration({ eventTarget: false
})` in the cloud Sentry init path. This prevents Sentry from wrapping
every `addEventListener` callback in try/catch, which was the #1 hot
function during multi-cluster panning (100 profiling samples). Error
capturing still works via `window.onerror` and `unhandledrejection`.

## Review Focus

- Confirm that disabling event target wrapping is acceptable for cloud
error monitoring — Sentry still captures unhandled errors, just not
errors thrown inside individual event handler callbacks.
- Non-cloud builds already had `integrations: []` /
`defaultIntegrations: false`, so no change there.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10472-perf-disable-Sentry-event-target-wrapping-to-reduce-DOM-event-overhead-32d6d73d365081cdb455e47aee34dcc6)
by [Unito](https://www.unito.io)
2026-03-26 22:06:47 -07:00
11 changed files with 367 additions and 241 deletions

View File

@@ -74,9 +74,7 @@ export const TestIds = {
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button',
formDropdownMenu: 'form-dropdown-menu',
formDropdownTrigger: 'form-dropdown-trigger'
subgraphEnterButton: 'subgraph-enter-button'
},
builder: {
ioItem: 'builder-io-item',

View File

@@ -4,7 +4,6 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
/**
* Default workflow widget inputs as [nodeId, widgetName] tuples.
@@ -144,12 +143,15 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
const dropdownButton = imageRow.locator('button:has(> span)').first()
await dropdownButton.click()
const menu = comfyPage.page
.getByTestId(TestIds.widgets.formDropdownMenu)
// The unstyled PrimeVue Popover renders with role="dialog".
// Locate the one containing the image grid (filter buttons like "All", "Inputs").
const popover = comfyPage.page
.getByRole('dialog')
.filter({ has: comfyPage.page.getByRole('button', { name: 'All' }) })
.first()
await expect(menu).toBeVisible({ timeout: 5000 })
await expect(popover).toBeVisible({ timeout: 5000 })
const isInViewport = await menu.evaluate((el) => {
const isInViewport = await popover.evaluate((el) => {
const rect = el.getBoundingClientRect()
return (
rect.top >= 0 &&
@@ -160,7 +162,7 @@ test.describe('App mode dropdown clipping', { tag: '@ui' }, () => {
})
expect(isInViewport).toBe(true)
const isClipped = await menu.evaluate(isClippedByAnyAncestor)
const isClipped = await popover.evaluate(isClippedByAnyAncestor)
expect(isClipped).toBe(false)
})
})

View File

@@ -1,116 +0,0 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { TestIds } from '../../../../fixtures/selectors'
test.describe(
'FormDropdown positioning in Vue nodes',
{ tag: ['@widget', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})
test('dropdown menu appears directly below the trigger', async ({
comfyPage
}) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
await expect(node).toBeVisible()
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
const triggerBox = await trigger.first().boundingBox()
const menuBox = await menu.boundingBox()
expect(triggerBox).toBeTruthy()
expect(menuBox).toBeTruthy()
// Menu top should be near the trigger bottom (within 20px tolerance for padding)
expect(menuBox!.y).toBeGreaterThanOrEqual(
triggerBox!.y + triggerBox!.height - 5
)
expect(menuBox!.y).toBeLessThanOrEqual(
triggerBox!.y + triggerBox!.height + 20
)
// Menu left should be near the trigger left (within 10px tolerance)
expect(menuBox!.x).toBeGreaterThanOrEqual(triggerBox!.x - 10)
expect(menuBox!.x).toBeLessThanOrEqual(triggerBox!.x + 10)
})
test('dropdown menu appears correctly at different zoom levels', async ({
comfyPage
}) => {
for (const zoom of [0.75, 1.5]) {
// Set zoom via canvas
await comfyPage.page.evaluate((scale) => {
const canvas = window.app!.canvas
canvas.ds.scale = scale
canvas.setDirty(true, true)
}, zoom)
await comfyPage.nextFrame()
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
await expect(node).toBeVisible()
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(
TestIds.widgets.formDropdownMenu
)
await expect(menu).toBeVisible({ timeout: 5000 })
const triggerBox = await trigger.first().boundingBox()
const menuBox = await menu.boundingBox()
expect(triggerBox).toBeTruthy()
expect(menuBox).toBeTruthy()
// Menu top should still be near trigger bottom regardless of zoom
expect(menuBox!.y).toBeGreaterThanOrEqual(
triggerBox!.y + triggerBox!.height - 5
)
expect(menuBox!.y).toBeLessThanOrEqual(
triggerBox!.y + triggerBox!.height + 20 * zoom
)
// Close dropdown before next iteration
await comfyPage.page.keyboard.press('Escape')
await expect(menu).not.toBeVisible()
}
})
test('dropdown closes on outside click', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
// Click outside the node
await comfyPage.page.mouse.click(10, 10)
await expect(menu).not.toBeVisible()
})
test('dropdown closes on Escape key', async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeByTitle('Load Image')
const trigger = node.getByTestId(TestIds.widgets.formDropdownTrigger)
await trigger.first().click()
const menu = comfyPage.page.getByTestId(TestIds.widgets.formDropdownMenu)
await expect(menu).toBeVisible({ timeout: 5000 })
await comfyPage.page.keyboard.press('Escape')
await expect(menu).not.toBeVisible()
})
}
)

View File

@@ -67,7 +67,14 @@ Sentry.init({
replaysOnErrorSampleRate: 0,
// Only set these for non-cloud builds
...(isCloud
? {}
? {
integrations: [
// Disable event target wrapping to reduce overhead on high-frequency
// DOM events (pointermove, mousemove, wheel). Sentry still captures
// errors via window.onerror and unhandledrejection.
Sentry.browserApiErrorsIntegration({ eventTarget: false })
]
}
: {
integrations: [],
autoSessionTracking: false,

View File

@@ -41,6 +41,11 @@ const MockFormDropdownInput = {
'<button class="mock-dropdown-trigger" @click="$emit(\'select-click\', $event)">Open</button>'
}
const MockPopover = {
name: 'Popover',
template: '<div><slot /></div>'
}
interface MountDropdownOptions {
searcher?: (
query: string,
@@ -60,17 +65,13 @@ function mountDropdown(
plugins: [PrimeVue, i18n],
stubs: {
FormDropdownInput: MockFormDropdownInput,
Popover: MockPopover,
FormDropdownMenu: MockFormDropdownMenu
}
}
})
}
async function openDropdown(wrapper: ReturnType<typeof mountDropdown>) {
await wrapper.find('.mock-dropdown-trigger').trigger('click')
await flushPromises()
}
function getMenuItems(
wrapper: ReturnType<typeof mountDropdown>
): FormDropdownItem[] {
@@ -86,7 +87,7 @@ describe('FormDropdown', () => {
createItem('input-0', 'video1.mp4'),
createItem('input-1', 'video2.mp4')
])
await openDropdown(wrapper)
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(2)
@@ -105,7 +106,7 @@ describe('FormDropdown', () => {
it('updates when items change but IDs stay the same', async () => {
const wrapper = mountDropdown([createItem('1', 'alpha')])
await openDropdown(wrapper)
await flushPromises()
await wrapper.setProps({ items: [createItem('1', 'beta')] })
await flushPromises()
@@ -115,7 +116,7 @@ describe('FormDropdown', () => {
it('updates when switching between empty and non-empty items', async () => {
const wrapper = mountDropdown([])
await openDropdown(wrapper)
await flushPromises()
expect(getMenuItems(wrapper)).toHaveLength(0)
@@ -153,10 +154,7 @@ describe('FormDropdown', () => {
await flushPromises()
expect(searcher).not.toHaveBeenCalled()
await openDropdown(wrapper)
expect(searcher).toHaveBeenCalled()
expect(getMenuItems(wrapper).map((item) => item.id)).toEqual(['3', '4'])
})
it('runs filtering when dropdown opens', async () => {
@@ -171,7 +169,8 @@ describe('FormDropdown', () => {
)
await flushPromises()
await openDropdown(wrapper)
await wrapper.find('.mock-dropdown-trigger').trigger('click')
await flushPromises()
expect(searcher).toHaveBeenCalled()
expect(getMenuItems(wrapper).map((item) => item.id)).toEqual(['keep'])

View File

@@ -1,12 +1,11 @@
<script setup lang="ts">
import { computedAsync, onClickOutside, refDebounced } from '@vueuse/core'
import type { CSSProperties } from 'vue'
import { computed, inject, ref, useTemplateRef } from 'vue'
import { computedAsync, refDebounced } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { cn } from '@/utils/tailwindUtil'
import type {
FilterOption,
@@ -17,7 +16,6 @@ import type {
import FormDropdownInput from './FormDropdownInput.vue'
import FormDropdownMenu from './FormDropdownMenu.vue'
import { defaultSearcher, getDefaultSortOptions } from './shared'
import { MENU_HEIGHT, MENU_WIDTH } from './types'
import type { FormDropdownItem, LayoutMode, SortOption } from './types'
interface Props {
@@ -53,6 +51,7 @@ interface Props {
}
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
const {
placeholder,
@@ -96,10 +95,8 @@ const baseModelSelected = defineModel<Set<string>>('baseModelSelected', {
const isOpen = defineModel<boolean>('isOpen', { default: false })
const toastStore = useToastStore()
const popoverRef = ref<InstanceType<typeof Popover>>()
const triggerRef = useTemplateRef('triggerRef')
const dropdownRef = useTemplateRef('dropdownRef')
const shouldTeleport = inject(OverlayAppendToKey, undefined) === 'body'
const maxSelectable = computed(() => {
if (multiple === true) return Infinity
@@ -145,59 +142,18 @@ function internalIsSelected(item: FormDropdownItem, index: number): boolean {
return isSelected(selected.value, item, index)
}
const MENU_HEIGHT_WITH_GAP = MENU_HEIGHT + 8
const openUpward = ref(false)
const fixedPosition = ref({ top: 0, left: 0 })
const teleportStyle = computed<CSSProperties | undefined>(() => {
if (!shouldTeleport) return undefined
const pos = fixedPosition.value
return openUpward.value
? {
position: 'fixed',
left: `${pos.left}px`,
bottom: `${window.innerHeight - pos.top}px`,
paddingBottom: '0.5rem'
}
: {
position: 'fixed',
left: `${pos.left}px`,
top: `${pos.top}px`,
paddingTop: '0.5rem'
}
})
function toggleDropdown() {
const toggleDropdown = (event: Event) => {
if (disabled) return
if (!isOpen.value && triggerRef.value) {
const rect = triggerRef.value.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
openUpward.value =
spaceBelow < MENU_HEIGHT_WITH_GAP && spaceAbove > spaceBelow
if (shouldTeleport) {
fixedPosition.value = {
top: openUpward.value
? Math.max(MENU_HEIGHT_WITH_GAP, rect.top)
: Math.min(rect.bottom, window.innerHeight - MENU_HEIGHT_WITH_GAP),
left: Math.min(rect.right, window.innerWidth - MENU_WIDTH)
}
}
if (popoverRef.value && triggerRef.value) {
popoverRef.value.toggle?.(event, triggerRef.value)
isOpen.value = !isOpen.value
}
isOpen.value = !isOpen.value
}
function closeDropdown() {
isOpen.value = false
}
onClickOutside(triggerRef, closeDropdown, { ignore: [dropdownRef] })
function handleEscape(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeDropdown()
const closeDropdown = () => {
if (popoverRef.value) {
popoverRef.value.hide?.()
isOpen.value = false
}
}
@@ -236,7 +192,7 @@ function handleSelection(item: FormDropdownItem, index: number) {
</script>
<template>
<div ref="triggerRef" class="relative" @keydown="handleEscape">
<div ref="triggerRef">
<FormDropdownInput
:files
:is-open
@@ -251,41 +207,42 @@ function handleSelection(item: FormDropdownItem, index: number) {
@select-click="toggleDropdown"
@file-change="handleFileChange"
/>
<Teleport to="body" :disabled="!shouldTeleport">
<div
v-if="isOpen"
ref="dropdownRef"
:class="
cn(
'z-50 rounded-lg border-none bg-transparent p-0 shadow-lg',
!shouldTeleport && 'absolute left-0',
!shouldTeleport &&
(openUpward ? 'bottom-full pb-2' : 'top-full pt-2')
)
"
:style="teleportStyle"
>
<FormDropdownMenu
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-ownership-filter
:ownership-options
:show-base-model-filter
:base-model-options
:disabled
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable
@close="closeDropdown"
@item-click="handleSelection"
/>
</div>
</Teleport>
<Popover
ref="popoverRef"
:dismissable="true"
:close-on-escape="true"
:append-to="overlayProps.appendTo"
unstyled
:pt="{
root: {
class: 'absolute z-50'
},
content: {
class: ['bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg']
}
}"
@hide="isOpen = false"
>
<FormDropdownMenu
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
v-model:sort-selected="sortSelected"
v-model:search-query="searchQuery"
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-ownership-filter
:ownership-options
:show-base-model-filter
:base-model-options
:disabled
:items="sortedItems"
:is-selected="internalIsSelected"
:max-selectable
@close="closeDropdown"
@item-click="handleSelection"
/>
</Popover>
</div>
</template>

View File

@@ -61,7 +61,6 @@ const theButtonStyle = computed(() =>
"
>
<button
data-testid="form-dropdown-trigger"
:class="
cn(
theButtonStyle,

View File

@@ -97,7 +97,6 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
<template>
<div
data-testid="form-dropdown-menu"
class="flex h-[640px] w-103 flex-col rounded-lg bg-component-node-background pt-4 outline -outline-offset-1 outline-node-component-border"
>
<FormDropdownMenuFilter

View File

@@ -28,10 +28,5 @@ export interface SortOption<TId extends string = string> {
export type LayoutMode = 'list' | 'grid' | 'list-small'
/** Height of FormDropdownMenu in pixels (matches h-[640px] in template). */
export const MENU_HEIGHT = 640
/** Width of FormDropdownMenu in pixels (matches w-103 = 26rem = 416px in template). */
export const MENU_WIDTH = 412
export const AssetKindKey: InjectionKey<ComputedRef<AssetKind | undefined>> =
Symbol('assetKind')

View File

@@ -0,0 +1,106 @@
/* eslint-disable vue/one-component-per-file, vue/no-reserved-component-names */
import { describe, expect, it, vi } from 'vitest'
import { render, screen, stubs } from '@/utils/test-utils'
import { defineComponent, h } from 'vue'
const TestButton = defineComponent({
props: { label: { type: String, required: true } },
setup(props) {
return () => h('button', { 'data-testid': 'test-btn' }, props.label)
}
})
describe('test-utils', () => {
it('renders a component with default plugins', () => {
render(TestButton, { props: { label: 'Click me' } })
expect(screen.getByTestId('test-btn')).toHaveTextContent('Click me')
})
it('provides a userEvent instance by default', () => {
const { user } = render(TestButton, { props: { label: 'Click' } })
expect(user).toBeDefined()
})
it('allows opting out of userEvent', () => {
const { user } = render(TestButton, {
props: { label: 'Click' },
setupUser: false
})
expect(user).toBeUndefined()
})
})
describe('stubs', () => {
describe('Button', () => {
it('renders as a button element with label', () => {
const Wrapper = defineComponent({
components: { Button: stubs.Button },
setup() {
return () => h(stubs.Button, { label: 'Save' })
}
})
render(Wrapper)
expect(screen.getByRole('button')).toHaveTextContent('Save')
})
it('sets disabled when loading is true', () => {
const Wrapper = defineComponent({
setup() {
return () => h(stubs.Button, { label: 'Save', loading: true })
}
})
render(Wrapper)
expect(screen.getByRole('button')).toBeDisabled()
})
it('emits click event', async () => {
const onClick = vi.fn()
const Wrapper = defineComponent({
setup() {
return () => h(stubs.Button, { label: 'Go', onClick })
}
})
const { user } = render(Wrapper)
await user!.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalled()
})
})
describe('Skeleton', () => {
it('renders with data-testid', () => {
const Wrapper = defineComponent({
setup() {
return () => h(stubs.Skeleton)
}
})
render(Wrapper)
expect(screen.getByTestId('skeleton')).toBeDefined()
})
})
describe('Dialog', () => {
it('renders children when visible', () => {
const Wrapper = defineComponent({
setup() {
return () =>
h(stubs.Dialog, { visible: true }, () => h('p', 'Dialog body'))
}
})
render(Wrapper)
expect(screen.getByRole('dialog')).toHaveTextContent('Dialog body')
})
it('renders nothing when not visible', () => {
const Wrapper = defineComponent({
setup() {
return () =>
h(stubs.Dialog, { visible: false }, () => h('p', 'Hidden'))
}
})
render(Wrapper)
expect(screen.queryByRole('dialog')).toBeNull()
})
})
})

180
src/utils/test-utils.ts Normal file
View File

@@ -0,0 +1,180 @@
/* eslint-disable vue/one-component-per-file, vue/require-prop-types, vue/no-reserved-component-names */
import type { RenderResult } from '@testing-library/vue'
import type { ComponentMountingOptions } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
/**
* Creates the default set of Vue plugins for component tests.
*
* - Pinia with `stubActions: false` (actions execute, but are spied)
* - vue-i18n with English locale
*
* Pass additional plugins via the `plugins` option in `renderWithDefaults`.
*/
function createDefaultPlugins() {
return [
createTestingPinia({ stubActions: false }),
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
]
}
/**
* Common directive stubs for components that use PrimeVue/custom directives.
* Prevents "Failed to resolve directive" warnings in test output.
*/
const defaultDirectiveStubs: Record<string, () => void> = {
tooltip: () => {}
}
/**
* PrimeVue component stubs for unit/component tests.
*
* Use via `global.stubs` in render options:
* ```ts
* render(MyComponent, { global: { stubs: { Button: stubs.Button } } })
* ```
*
* Or use `renderWithDefaults` which auto-applies these as defaults.
*/
const ButtonStub = defineComponent({
name: 'Button',
props: [
'disabled',
'loading',
'variant',
'size',
'label',
'icon',
'severity'
],
emits: ['click'],
setup(props, { slots, emit }) {
return () =>
h(
'button',
{
disabled: props.disabled || props.loading,
'data-testid': props.label,
'data-icon': props.icon,
onClick: () => emit('click')
},
slots.default?.() ?? props.label
)
}
})
const SkeletonStub = defineComponent({
name: 'Skeleton',
setup() {
return () => h('div', { 'data-testid': 'skeleton' })
}
})
const TagStub = defineComponent({
name: 'Tag',
props: ['value', 'severity'],
setup(props, { slots }) {
return () =>
h('span', { 'data-testid': 'tag' }, slots.default?.() ?? props.value)
}
})
const BadgeStub = defineComponent({
name: 'Badge',
props: ['value', 'severity'],
setup(props) {
return () => h('span', { 'data-testid': 'badge' }, props.value)
}
})
const MessageStub = defineComponent({
name: 'Message',
props: ['severity', 'closable'],
setup(_, { slots }) {
return () =>
h('div', { 'data-testid': 'message', role: 'alert' }, slots.default?.())
}
})
const DialogStub = defineComponent({
name: 'Dialog',
props: ['visible', 'modal', 'header'],
setup(props, { slots }) {
return () =>
props.visible
? h('div', { role: 'dialog', 'data-testid': 'dialog' }, [
props.header ? h('div', props.header) : null,
slots.default?.()
])
: null
}
})
const stubs = {
Button: ButtonStub,
Skeleton: SkeletonStub,
Tag: TagStub,
Badge: BadgeStub,
Message: MessageStub,
Dialog: DialogStub
} as const
type RenderWithDefaultsResult = RenderResult & {
user: ReturnType<typeof userEvent.setup> | undefined
}
/**
* Renders a Vue component with standard test infrastructure pre-configured:
* - Pinia testing store (actions execute but are spied)
* - vue-i18n with English messages
* - Common directive stubs (tooltip)
* - Optional userEvent instance
*
* @example
* ```ts
* import { render, screen } from '@/utils/test-utils'
*
* it('renders button text', async () => {
* const { user } = render(MyComponent, { props: { label: 'Click' } })
* expect(screen.getByRole('button')).toHaveTextContent('Click')
* await user!.click(screen.getByRole('button'))
* })
* ```
*/
function renderWithDefaults<C>(
component: C,
options?: ComponentMountingOptions<C> & { setupUser?: boolean }
): RenderWithDefaultsResult {
const { setupUser = true, global: globalOptions, ...rest } = options ?? {}
const user = setupUser ? userEvent.setup() : undefined
const result = render(
component as Parameters<typeof render>[0],
{
global: {
...globalOptions,
plugins: [...createDefaultPlugins(), ...(globalOptions?.plugins ?? [])],
directives: {
...defaultDirectiveStubs,
...globalOptions?.directives
}
},
...rest
} as Parameters<typeof render>[1]
)
return { ...result, user }
}
export { renderWithDefaults as render, screen, stubs }