diff --git a/.github/workflows/pr-ci-e2e-test.yml b/.github/workflows/pr-ci-e2e-test.yml index 76bbaf8a0..8b259e74d 100644 --- a/.github/workflows/pr-ci-e2e-test.yml +++ b/.github/workflows/pr-ci-e2e-test.yml @@ -47,13 +47,19 @@ jobs: cd packages/test npx playwright install --with-deps chromium - - name: Run Playwright Tests - run: pnpm test + - name: Run Playwright E2E Tests + run: pnpm -F tiny-robot-test test:e2e + + - name: Run Playwright Component Tests + if: ${{ !cancelled() }} + run: pnpm -F tiny-robot-test test:ct - name: Upload Playwright Test Report uses: actions/upload-artifact@v6 if: always() with: name: playwright-report-${{ github.sha }} - path: packages/test/playwright-report/ + path: | + packages/test/playwright-report/ + packages/test/component/playwright-report/ retention-days: 13 diff --git a/.gitignore b/.gitignore index 462db155f..480d4bf83 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,8 @@ test-results playwright-report blob-report -playwright/.cache +**/playwright/.cache/ +**/playwright/.cache-development-mode/ package-lock.json pnpm-lock.yaml yarn.lock diff --git a/packages/components/src/extension-manager/components/ExtensionCard.vue b/packages/components/src/extension-manager/components/ExtensionCard.vue new file mode 100644 index 000000000..65afc1f16 --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionCard.vue @@ -0,0 +1,301 @@ + + + + + + + + + {{ name.slice(0, 1) }} + + + + + + {{ name }} + + + {{ description }} + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/components/src/extension-manager/components/ExtensionCardActions.vue b/packages/components/src/extension-manager/components/ExtensionCardActions.vue new file mode 100644 index 000000000..484129ac5 --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionCardActions.vue @@ -0,0 +1,227 @@ + + + + + + + + + + + + + {{ action.label }} + + + + + + + + + {{ action.label }} + + + + + + diff --git a/packages/components/src/extension-manager/components/ExtensionCardGrid.vue b/packages/components/src/extension-manager/components/ExtensionCardGrid.vue new file mode 100644 index 000000000..f6fbbd4a0 --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionCardGrid.vue @@ -0,0 +1,118 @@ + + + + + + {{ emptyText }} + + + + + + + + + + + + diff --git a/packages/components/src/extension-manager/components/ExtensionCardMoreMenu.vue b/packages/components/src/extension-manager/components/ExtensionCardMoreMenu.vue new file mode 100644 index 000000000..75a001e47 --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionCardMoreMenu.vue @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + {{ action.label }} + + + + + + + + + diff --git a/packages/components/src/extension-manager/components/ExtensionCardPopover.vue b/packages/components/src/extension-manager/components/ExtensionCardPopover.vue new file mode 100644 index 000000000..3f6e8fa7e --- /dev/null +++ b/packages/components/src/extension-manager/components/ExtensionCardPopover.vue @@ -0,0 +1,174 @@ + + + + + + + + + + + + + diff --git a/packages/components/src/extension-manager/components/index.ts b/packages/components/src/extension-manager/components/index.ts new file mode 100644 index 000000000..5728c159f --- /dev/null +++ b/packages/components/src/extension-manager/components/index.ts @@ -0,0 +1,2 @@ +export { default as ExtensionCard } from './ExtensionCard.vue' +export { default as ExtensionCardGrid } from './ExtensionCardGrid.vue' diff --git a/packages/components/src/extension-manager/composables/index.ts b/packages/components/src/extension-manager/composables/index.ts new file mode 100644 index 000000000..de1a16f3e --- /dev/null +++ b/packages/components/src/extension-manager/composables/index.ts @@ -0,0 +1,2 @@ +export { useAsChild } from './useAsChild' +export { useExtensionCardPopoverPosition } from './useExtensionCardPopoverPosition' diff --git a/packages/components/src/extension-manager/composables/useAsChild.ts b/packages/components/src/extension-manager/composables/useAsChild.ts new file mode 100644 index 000000000..3691d7273 --- /dev/null +++ b/packages/components/src/extension-manager/composables/useAsChild.ts @@ -0,0 +1,53 @@ +import type { VNode, VNodeProps } from 'vue' +import { cloneVNode, Comment, Fragment, isVNode, Suspense, Teleport, Text } from 'vue' + +interface UseAsChildOptions { + getSlot: () => ((props: TSlotProps) => VNode[]) | undefined + componentName?: string +} + +type AsChildInjectedProps = VNodeProps & Record + +const flattenAsChildVNodes = (nodes: VNode[]): VNode[] => { + return nodes.flatMap((node) => { + if (node.type === Comment) return [] + if (node.type === Text && typeof node.children === 'string' && node.children.trim() === '') return [] + if (node.type !== Fragment || !Array.isArray(node.children)) return [node] + + return flattenAsChildVNodes(node.children.filter(isVNode)) + }) +} + +const isSupportedAsChildVNode = (node: VNode) => { + if (node.type === Teleport || node.type === Suspense) return false + + return typeof node.type === 'string' || typeof node.type === 'object' || typeof node.type === 'function' +} + +export function useAsChild({ getSlot, componentName }: UseAsChildOptions) { + const warningPrefix = componentName ? `[TinyRobot] ${componentName} with asChild` : '[TinyRobot] asChild' + + const renderAsChild = (slotProps: TSlotProps, injectedProps: AsChildInjectedProps = {}): VNode | null => { + const children = flattenAsChildVNodes(getSlot()?.(slotProps) ?? []) + + if (children.length !== 1) { + if (import.meta.env.DEV) { + console.warn(`${warningPrefix} expects exactly one child, but received ${children.length}.`) + } + return null + } + + if (!isSupportedAsChildVNode(children[0])) { + if (import.meta.env.DEV) { + console.warn(`${warningPrefix} received an unsupported child node.`) + } + return null + } + + return cloneVNode(children[0], injectedProps, true) + } + + return { + renderAsChild, + } +} diff --git a/packages/components/src/extension-manager/composables/useExtensionCardPopoverPosition.ts b/packages/components/src/extension-manager/composables/useExtensionCardPopoverPosition.ts new file mode 100644 index 000000000..a8877d60f --- /dev/null +++ b/packages/components/src/extension-manager/composables/useExtensionCardPopoverPosition.ts @@ -0,0 +1,84 @@ +import { useEventListener } from '@vueuse/core' +import type { Ref } from 'vue' +import { nextTick, ref, watch } from 'vue' +import type { ExtensionCardPopoverPlacement } from '../internal.type' + +interface ExtensionCardPopoverPosition { + x: number + y: number + placement: ExtensionCardPopoverPlacement +} + +interface UseExtensionCardPopoverPositionOptions { + triggerRef: Ref + popoverRef: Ref + open: Ref + placement: Readonly> +} + +export const useExtensionCardPopoverPosition = ({ + triggerRef, + popoverRef, + open, + placement, +}: UseExtensionCardPopoverPositionOptions) => { + const position = ref() + + const updatePosition = () => { + if (!open.value || !triggerRef.value || !popoverRef.value) return + + const triggerRect = triggerRef.value.getBoundingClientRect() + const popoverRect = popoverRef.value.getBoundingClientRect() + const viewportPadding = 8 + const gap = 8 + const maxLeft = Math.max( + viewportPadding, + document.documentElement.clientWidth - popoverRect.width - viewportPadding, + ) + const maxTop = Math.max( + viewportPadding, + document.documentElement.clientHeight - popoverRect.height - viewportPadding, + ) + const left = Math.min(maxLeft, Math.max(viewportPadding, triggerRect.right - popoverRect.width)) + const bottomTop = triggerRect.bottom + gap + const topTop = triggerRect.top - popoverRect.height - gap + const fitsBottom = bottomTop + popoverRect.height <= document.documentElement.clientHeight - viewportPadding + const fitsTop = topTop >= viewportPadding + let resolvedPlacement = placement.value + + if (resolvedPlacement === 'bottom-end' && !fitsBottom && fitsTop) { + resolvedPlacement = 'top-end' + } else if (resolvedPlacement === 'top-end' && !fitsTop && fitsBottom) { + resolvedPlacement = 'bottom-end' + } + + const preferredTop = resolvedPlacement === 'bottom-end' ? bottomTop : topTop + const top = Math.min(maxTop, Math.max(viewportPadding, preferredTop)) + + position.value = { + x: left, + y: top, + placement: resolvedPlacement, + } + } + + watch( + [open, placement], + async ([isOpen]) => { + if (!isOpen) return + await nextTick() + updatePosition() + }, + { flush: 'post' }, + ) + + useEventListener('resize', updatePosition, { passive: true }) + useEventListener('scroll', updatePosition, { + capture: true, + passive: true, + }) + + return { + position, + } +} diff --git a/packages/components/src/extension-manager/index.ts b/packages/components/src/extension-manager/index.ts new file mode 100644 index 000000000..f502a2aa1 --- /dev/null +++ b/packages/components/src/extension-manager/index.ts @@ -0,0 +1,30 @@ +import type { App } from 'vue' +import { ExtensionCard as ExtensionCardComp, ExtensionCardGrid as ExtensionCardGridComp } from './components' + +export * from './index.type' + +ExtensionCardComp.name = 'TrExtensionCard' + +const extensionCardInstall = function (app: App) { + app.component(ExtensionCardComp.name!, ExtensionCardComp) +} + +const ExtensionCard = ExtensionCardComp as typeof ExtensionCardComp & { + install: typeof extensionCardInstall +} + +ExtensionCard.install = extensionCardInstall + +ExtensionCardGridComp.name = 'TrExtensionCardGrid' + +const extensionCardGridInstall = function (app: App) { + app.component(ExtensionCardGridComp.name!, ExtensionCardGridComp) +} + +const ExtensionCardGrid = ExtensionCardGridComp as typeof ExtensionCardGridComp & { + install: typeof extensionCardGridInstall +} + +ExtensionCardGrid.install = extensionCardGridInstall + +export { ExtensionCard, ExtensionCardGrid } diff --git a/packages/components/src/extension-manager/index.type.ts b/packages/components/src/extension-manager/index.type.ts new file mode 100644 index 000000000..368875f74 --- /dev/null +++ b/packages/components/src/extension-manager/index.type.ts @@ -0,0 +1,166 @@ +import type { Component, VNode } from 'vue' + +export type ExtensionCardOverflowMenuPlacement = 'bottom-end' | 'top-end' + +export interface ExtensionCardActionBase { + id: string + label: string + icon?: Component + hidden?: boolean + disabled?: boolean + danger?: boolean +} + +export interface ExtensionCardSwitchAction extends ExtensionCardActionBase { + type: 'switch' + checked: boolean +} + +export interface ExtensionCardButtonAction extends ExtensionCardActionBase { + type: 'button' +} + +export interface ExtensionCardCustomAction extends ExtensionCardActionBase { + type: 'custom' + data?: unknown +} + +export type ExtensionCardAction = ExtensionCardSwitchAction | ExtensionCardButtonAction | ExtensionCardCustomAction + +/** Actions passed from Card to its internal renderers after visibility is resolved. */ +type DistributiveOmit = T extends unknown ? Omit : never +export type ExtensionCardRenderableAction = DistributiveOmit + +export interface ExtensionCardActionEvent { + id: string + type: ExtensionCardAction['type'] + checked?: boolean + payload?: unknown +} + +export interface ExtensionCardProps { + name: string + description?: string + icon?: string | Component + actions?: ExtensionCardAction[] + primaryActionsLimit?: number + progress?: number | 'indeterminate' + nameClickable?: boolean + overflowMenuLabel?: string + overflowMenuPlacement?: ExtensionCardOverflowMenuPlacement + overflowMenuShowIcons?: boolean +} + +export interface ExtensionCardSlots { + 'primary-action'?: (props: { + action: Extract + trigger: (payload?: unknown) => void + }) => VNode[] +} + +export interface ExtensionCardEmits { + (e: 'name-click', event: MouseEvent | KeyboardEvent): void + (e: 'action', payload: ExtensionCardActionEvent): void +} + +export type ExtensionCardGridItem = ExtensionCardProps & { + id: string +} + +export interface ExtensionCardGridProps extends Pick< + ExtensionCardProps, + 'primaryActionsLimit' | 'nameClickable' | 'overflowMenuLabel' | 'overflowMenuPlacement' | 'overflowMenuShowIcons' +> { + items: ExtensionCardGridItem[] + emptyText?: string +} + +export interface ExtensionCardGridSlots { + item?: (props: { item: ExtensionCardGridItem; index: number }) => VNode[] + empty?: () => VNode[] +} + +export interface ExtensionCardGridActionEvent { + itemId: string + action: ExtensionCardActionEvent +} + +export interface ExtensionCardGridNameClickEvent { + itemId: string + event: MouseEvent | KeyboardEvent +} + +export interface ExtensionCardGridEmits { + (e: 'action', payload: ExtensionCardGridActionEvent): void + (e: 'name-click', payload: ExtensionCardGridNameClickEvent): void +} + +export type ExtensionManagerSectionKey = 'installed' | 'available' + +export interface ExtensionManagerTagOption { + value: string + label: string +} + +export type ExtensionManagerItem = ExtensionCardGridItem & { + installed?: boolean + tags?: string[] +} + +export interface ExtensionManagerTab { + id: string + label: string + items: ExtensionManagerItem[] +} + +export interface ExtensionManagerProps { + tabs: ExtensionManagerTab[] + activeTab?: string + defaultActiveTab?: string + title?: string + emptyText?: string +} + +export interface ExtensionManagerSlots { + 'header-actions'?: () => VNode[] + tab?: (props: { tab: ExtensionManagerTab; active: boolean; select: () => void }) => VNode[] + item?: (props: { + tab: ExtensionManagerTab + sectionKey: ExtensionManagerSectionKey + item: ExtensionCardGridItem + index: number + }) => VNode[] + empty?: (props: { tab: ExtensionManagerTab; sectionKey: ExtensionManagerSectionKey; title: string }) => VNode[] +} + +export interface ExtensionManagerTabChangeEvent { + tabId: string +} + +export interface ExtensionManagerSectionToggleEvent { + tabId: string + sectionKey: ExtensionManagerSectionKey + expanded: boolean +} + +export interface ExtensionManagerActionEvent { + tabId: string + sectionKey: ExtensionManagerSectionKey + itemId: string + action: ExtensionCardActionEvent +} + +export interface ExtensionManagerNameClickEvent { + tabId: string + sectionKey: ExtensionManagerSectionKey + itemId: string + event: MouseEvent | KeyboardEvent +} + +export interface ExtensionManagerEmits { + (e: 'update:active-tab', tabId: string | undefined): void + (e: 'tab-change', event: ExtensionManagerTabChangeEvent): void + (e: 'section-toggle', event: ExtensionManagerSectionToggleEvent): void + (e: 'action', event: ExtensionManagerActionEvent): void + (e: 'name-click', event: ExtensionManagerNameClickEvent): void +} diff --git a/packages/components/src/extension-manager/internal.type.ts b/packages/components/src/extension-manager/internal.type.ts new file mode 100644 index 000000000..cf7b78942 --- /dev/null +++ b/packages/components/src/extension-manager/internal.type.ts @@ -0,0 +1 @@ +export type ExtensionCardPopoverPlacement = 'bottom-end' | 'top-end' diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index ff084365d..b2ade282c 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -22,6 +22,7 @@ import ThemeProvider from './theme-provider' import Welcome from './welcome' import McpServerPicker from './mcp-server-picker' import McpAddForm from './mcp-add-form' +import { ExtensionCard, ExtensionCardGrid } from './extension-manager' import { ActionButton, SubmitButton, @@ -54,6 +55,7 @@ export * from './theme-provider/index.type' export * from './welcome/index.type' export * from './mcp-server-picker/index.type' export * from './mcp-add-form/index.type' +export * from './extension-manager/index.type' export { useSenderContentRegistration } from './shared/composables/useSenderContentRegistration' export type { SenderContentRegister } from './shared/composables/useSenderContentRegistration' @@ -100,6 +102,8 @@ const components = [ Welcome, McpServerPicker, McpAddForm, + ExtensionCard, + ExtensionCardGrid, ActionButton, SubmitButton, ClearButton, @@ -171,6 +175,10 @@ export { McpServerPicker as TrMcpServerPicker, McpAddForm, McpAddForm as TrMcpAddForm, + ExtensionCard, + ExtensionCard as TrExtensionCard, + ExtensionCardGrid, + ExtensionCardGrid as TrExtensionCardGrid, ActionButton, ActionButton as TrActionButton, SubmitButton, diff --git a/packages/components/src/shared/composables/index.ts b/packages/components/src/shared/composables/index.ts index f26145074..4002c3deb 100644 --- a/packages/components/src/shared/composables/index.ts +++ b/packages/components/src/shared/composables/index.ts @@ -3,5 +3,6 @@ export * from './useAutoScroll' export * from './useControllableState' export * from './useSenderContentRegistration' export * from './useSlotRefs' +export * from './useStableId' export * from './useTeleportTarget' export * from './useTouchDevice' diff --git a/packages/components/src/shared/composables/useStableId.ts b/packages/components/src/shared/composables/useStableId.ts new file mode 100644 index 000000000..16bb864e3 --- /dev/null +++ b/packages/components/src/shared/composables/useStableId.ts @@ -0,0 +1,25 @@ +import * as Vue from 'vue' + +type VueWithUseId = typeof Vue & { + useId?: () => string +} + +const appIdCounters = new WeakMap() + +export function useStableId() { + const nativeUseId = (Vue as VueWithUseId).useId + if (nativeUseId) { + return nativeUseId() + } + + const instance = Vue.getCurrentInstance() + if (!instance) { + return '' + } + + const appContext = instance.appContext + const id = appIdCounters.get(appContext) ?? 0 + appIdCounters.set(appContext, id + 1) + + return `tr-${id}` +} diff --git a/packages/test/component/extension-manager/ExtensionCard.fixture.vue b/packages/test/component/extension-manager/ExtensionCard.fixture.vue new file mode 100644 index 000000000..d4d90d2d0 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCard.fixture.vue @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + {{ lastEvent?.id }} + {{ lastEvent?.type }} + {{ eventChecked }} + + diff --git a/packages/test/component/extension-manager/ExtensionCard.spec.ts b/packages/test/component/extension-manager/ExtensionCard.spec.ts new file mode 100644 index 000000000..d295897af --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCard.spec.ts @@ -0,0 +1,138 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ExtensionCardFixture from './ExtensionCard.fixture.vue' + +test.describe('standalone ExtensionCard', () => { + test('renders direct presentation props without an item', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('presentation-card') + + await expect(card.locator('.tr-extension-card__name')).toHaveText('Item name') + await expect(card.locator('.tr-extension-card__description')).toHaveText('Item description') + await expect(card.locator('img')).toHaveAttribute('src', 'https://example.com/item-icon.png') + await expect(card.locator('img')).toHaveAttribute('alt', 'Item name') + }) + + test('renders a component icon without an image element', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('component-icon-card') + + await expect(card.locator('img')).toHaveCount(0) + await expect(card.locator('svg')).toHaveCount(1) + }) + + test('filters hidden actions before partitioning and keeps disabled slots in the primary region', async ({ + mount, + }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('actions-card') + const primaryRegion = card.locator('.tr-extension-card-primary-actions') + const overflowRegion = card.locator('.tr-extension-card__more-action') + const overflowMenu = overflowRegion.locator('.tr-extension-card__more-menu') + + await expect(primaryRegion.getByRole('switch', { name: '启用扩展' })).toBeVisible() + await expect(primaryRegion.getByRole('button', { name: '禁用操作' })).toBeDisabled() + await expect(primaryRegion.getByRole('button', { name: '隐藏操作' })).toHaveCount(0) + await expect(primaryRegion.getByRole('button', { name: '安装' })).toHaveCount(0) + await expect(primaryRegion.getByRole('button', { name: '检查' })).toHaveCount(0) + await expect(overflowRegion.getByRole('button', { name: '扩展操作' })).toBeVisible() + await expect(overflowMenu).toBeHidden() + + await overflowRegion.getByRole('button', { name: '扩展操作' }).click() + await expect(primaryRegion.getByRole('switch', { name: '启用扩展' })).toBeVisible() + await expect(primaryRegion.getByRole('button', { name: '禁用操作' })).toBeDisabled() + await expect(primaryRegion.getByRole('button', { name: '安装' })).toHaveCount(0) + await expect(primaryRegion.getByRole('button', { name: '检查' })).toHaveCount(0) + await expect(overflowMenu).toBeVisible() + await expect(overflowMenu.getByRole('button', { name: '安装' })).toBeVisible() + await expect(overflowMenu.getByRole('button', { name: '检查' })).toBeVisible() + await expect(overflowMenu.getByRole('switch', { name: '启用扩展' })).toHaveCount(0) + await expect(overflowMenu.getByRole('button', { name: '禁用操作' })).toHaveCount(0) + await expect(overflowMenu.getByRole('button', { name: '隐藏操作' })).toHaveCount(0) + }) + + test('falls back to a normal button for a custom primary action without a slot', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('custom-fallback-card') + + await expect(card.getByRole('button', { name: '自定义操作' })).toBeVisible() + await card.getByRole('button', { name: '自定义操作' }).click() + await expect(component.getByTestId('event-id')).toHaveText('fallback') + await expect(component.getByTestId('event-type')).toHaveText('custom') + }) + + test('uses overflowMenuLabel and emits switch state from the overflow menu', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('overflow-switch-card') + const trigger = card.getByRole('button', { name: '扩展操作' }) + + await expect(trigger).toBeVisible() + await expect(trigger).toHaveAttribute('title', '扩展操作') + await trigger.click() + + const switchItem = card.getByRole('button', { name: '停止跟踪更新' }) + + await expect(switchItem).toHaveAttribute('aria-pressed', 'true') + await expect(switchItem.locator('.tr-extension-card__more-menu-item-check')).toHaveCount(0) + await switchItem.click() + await expect(component.getByTestId('event-id')).toHaveText('overflow-enabled') + await expect(component.getByTestId('event-type')).toHaveText('switch') + await expect(component.getByTestId('event-checked')).toHaveText('false') + + await trigger.click() + await expect(card.getByRole('button', { name: '跟踪更新' })).toHaveAttribute('aria-pressed', 'false') + }) + + test('renders custom overflow actions as buttons and keeps danger styling', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('overflow-switch-card') + + await card.getByRole('button', { name: '扩展操作' }).click() + + const customItem = card.getByRole('button', { name: '自定义溢出操作' }) + const dangerItem = card.getByRole('button', { name: '危险操作' }) + + await expect(customItem).toBeVisible() + await expect(dangerItem).toHaveClass(/is-danger/) + await customItem.click() + await expect(component.getByTestId('event-id')).toHaveText('overflow-custom') + await expect(component.getByTestId('event-type')).toHaveText('custom') + }) + + test('hides overflow action icons while preserving labels and events', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('overflow-without-icons-card') + + await card.getByRole('button', { name: '无图标溢出菜单' }).click() + + const menu = card.locator('.tr-extension-card__more-menu') + await expect(menu.getByRole('button', { name: '带图标操作' })).toBeVisible() + await expect(menu.getByRole('button', { name: '无图标操作' })).toBeVisible() + await expect(menu.locator('svg')).toHaveCount(0) + + await menu.getByRole('button', { name: '无图标操作' }).click() + await expect(component.getByTestId('event-id')).toHaveText('overflow-text') + await expect(component.getByTestId('event-type')).toHaveText('button') + }) + + test('renders indeterminate and clamped determinate progress', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + + await expect(component.getByTestId('progress-card').locator('.tr-extension-card__progress-bar')).toHaveClass( + /is-indeterminate/, + ) + await expect( + component.getByTestId('high-progress-card').locator('.tr-extension-card__progress-bar'), + ).toHaveAttribute('style', /width: 100%/) + await expect( + component.getByTestId('low-progress-card').locator('.tr-extension-card__progress-bar'), + ).toHaveAttribute('style', /width: 0%/) + }) + + test('renders a non-clickable name as text', async ({ mount }) => { + const component = await mount(ExtensionCardFixture) + const card = component.getByTestId('presentation-card') + + await expect(card.getByRole('button', { name: 'Item name' })).toHaveCount(0) + await expect(card.locator('.tr-extension-card__name')).toHaveText('Item name') + }) +}) diff --git a/packages/test/component/extension-manager/ExtensionCardActionEvent.fixture.vue b/packages/test/component/extension-manager/ExtensionCardActionEvent.fixture.vue new file mode 100644 index 000000000..25a392b11 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCardActionEvent.fixture.vue @@ -0,0 +1,61 @@ + + + + + + Ignore switch updates + + + + + + {{ action.label }} + + + + + {{ lastEvent?.id }} + {{ lastEvent?.type }} + {{ eventChecked }} + {{ eventPayload }} + + diff --git a/packages/test/component/extension-manager/ExtensionCardActionEvent.spec.ts b/packages/test/component/extension-manager/ExtensionCardActionEvent.spec.ts new file mode 100644 index 000000000..b3aec7a54 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCardActionEvent.spec.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ExtensionCardActionEventFixture from './ExtensionCardActionEvent.fixture.vue' + +test.describe('standalone ExtensionCard action events', () => { + test('emits the next checked value for a controlled switch', async ({ mount }) => { + const component = await mount(ExtensionCardActionEventFixture) + const toggle = component.getByRole('switch', { name: '扩展开关' }) + + await expect(toggle).toBeChecked() + await component.locator('.tr-extension-card-primary-actions__switch-track').click() + await expect(toggle).not.toBeChecked() + await expect(component.getByTestId('event-id')).toHaveText('toggle-extension') + await expect(component.getByTestId('event-type')).toHaveText('switch') + await expect(component.getByTestId('event-checked')).toHaveText('false') + + await component.locator('.tr-extension-card-primary-actions__switch-track').click() + await expect(toggle).toBeChecked() + await expect(component.getByTestId('event-checked')).toHaveText('true') + }) + + test('keeps the native checked state controlled by props when switch updates are not committed', async ({ + mount, + }) => { + const component = await mount(ExtensionCardActionEventFixture) + const toggle = component.getByRole('switch', { name: '扩展开关' }) + + await component.getByTestId('ignore-switch-updates').click() + await component.locator('.tr-extension-card-primary-actions__switch-track').click() + + await expect(component.getByTestId('event-checked')).toHaveText('false') + await expect(toggle).toBeChecked() + }) + + test('emits a button action with its presentation type', async ({ mount }) => { + const component = await mount(ExtensionCardActionEventFixture) + + await component.getByRole('button', { name: '安装' }).click() + await expect(component.getByTestId('event-id')).toHaveText('install-extension') + await expect(component.getByTestId('event-type')).toHaveText('button') + }) + + test('emits a custom primary action with its payload', async ({ mount }) => { + const component = await mount(ExtensionCardActionEventFixture) + + await component.getByRole('button', { name: '检查' }).click() + await expect(component.getByTestId('event-id')).toHaveText('inspect-extension') + await expect(component.getByTestId('event-type')).toHaveText('custom') + await expect(component.getByTestId('event-payload')).toHaveText('{"origin":"fixture"}') + }) + + test('emits an overflow action through the same event shape', async ({ mount }) => { + const component = await mount(ExtensionCardActionEventFixture) + + await component.getByRole('button', { name: '扩展操作菜单' }).click() + await component.getByRole('button', { name: '删除' }).click() + await expect(component.getByTestId('event-id')).toHaveText('delete-extension') + await expect(component.getByTestId('event-type')).toHaveText('button') + }) +}) diff --git a/packages/test/component/extension-manager/ExtensionCardGrid.fixture.vue b/packages/test/component/extension-manager/ExtensionCardGrid.fixture.vue new file mode 100644 index 000000000..8ef4180a6 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCardGrid.fixture.vue @@ -0,0 +1,193 @@ + + + + + + + + + + + + {{ serializeItem(item) }} + {{ index }} + + + + + + + + + Custom empty slot + + + + + + + + + + + + + + + + + + + + + + {{ actionSummary }} + {{ lastNameClick?.itemId }} + diff --git a/packages/test/component/extension-manager/ExtensionCardGrid.spec.ts b/packages/test/component/extension-manager/ExtensionCardGrid.spec.ts new file mode 100644 index 000000000..f673533e7 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCardGrid.spec.ts @@ -0,0 +1,195 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ExtensionCardGridFixture from './ExtensionCardGrid.fixture.vue' + +test.describe('standalone ExtensionCardGrid', () => { + test('renders flat items as default Cards and keeps the Grid id on the item li', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const grid = component.getByTestId('default-grid') + const alphaItem = grid.locator(':scope > li[data-card-id="alpha"]') + const alphaCard = alphaItem.locator(':scope > *') + + await expect(grid).toHaveJSProperty('tagName', 'UL') + await expect(grid.locator(':scope > li')).toHaveCount(2) + await expect(grid.locator(':scope > li[data-card-id]')).toHaveCount(2) + await expect(alphaItem).toHaveCount(1) + await expect(grid.locator(':scope > li[data-card-id="alpha"]')).toHaveCount(1) + await expect(alphaCard).toHaveCount(1) + await expect(alphaCard.getByText('Alpha extension', { exact: true })).toBeVisible() + await expect(alphaCard.getByText('Alpha description', { exact: true })).toBeVisible() + await expect(alphaCard.getByRole('switch', { name: 'Enable Alpha' })).toBeVisible() + await expect(alphaCard.getByRole('button', { name: 'Install Alpha' })).toBeVisible() + await expect(alphaCard.getByRole('button', { name: 'Inspect Alpha' })).toBeVisible() + }) + + test('passes the full flat item and index to the item slot while retaining Grid-owned markup', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const grid = component.getByTestId('slot-grid') + const alphaItem = grid.locator(':scope > li[data-card-id="alpha"]') + const betaItem = grid.locator(':scope > li[data-card-id="beta"]') + + await expect(grid).toHaveJSProperty('tagName', 'UL') + await expect(grid.locator(':scope > li')).toHaveCount(2) + await expect(grid.locator(':scope > li[data-card-id]')).toHaveCount(2) + await expect(alphaItem.locator(':scope > [data-testid="slot-item-alpha"]')).toHaveCount(1) + const alphaValue = JSON.parse((await alphaItem.getByTestId('slot-item-alpha-value').textContent()) ?? '{}') + const betaValue = JSON.parse((await betaItem.getByTestId('slot-item-beta-value').textContent()) ?? '{}') + + expect(alphaValue.id).toBe('alpha') + expect(alphaValue.name).toBe('Alpha extension') + expect(alphaValue.actions?.[2]?.data).toEqual({ origin: 'grid-fixture', nested: { enabled: true } }) + await expect(alphaItem.getByTestId('slot-item-alpha-index')).toHaveText('0') + expect(betaValue.id).toBe('beta') + expect(betaValue.name).toBe('Beta extension') + await expect(betaItem.getByTestId('slot-item-beta-index')).toHaveText('1') + }) + + test('uses the Grid fallback while preserving item overrides including explicit zero', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const grid = component.getByTestId('fallback-grid') + + await expect(grid.getByRole('button', { name: 'Fallback primary' })).toHaveCount(1) + await expect(grid.locator('[data-card-id="fallback"] [aria-label="更多操作"]')).toHaveCount(1) + await expect(grid.getByRole('button', { name: 'Override primary 1' })).toHaveCount(1) + await expect(grid.getByRole('button', { name: 'Override primary 2' })).toHaveCount(1) + await expect(grid.getByRole('button', { name: 'Zero overflow' })).toHaveCount(0) + await expect(grid.locator('[data-card-id="zero"] [aria-label="更多操作"]')).toHaveCount(1) + + const fallbackCard = grid.locator('[data-card-id="fallback"]') + await fallbackCard.getByRole('button', { name: '更多操作' }).click() + const fallbackMenu = fallbackCard.locator('.tr-extension-card-popover__content') + await expect(fallbackMenu).toBeVisible() + await expect(fallbackMenu.locator('.tr-extension-card__more-menu-item-icon-slot')).toHaveCount(1) + await expect + .poll(async () => + fallbackMenu.evaluate((element) => { + const trigger = element.parentElement?.querySelector('[popovertarget]') + return trigger + ? Number.parseFloat(getComputedStyle(element).top) >= trigger.getBoundingClientRect().bottom + : false + }), + ) + .toBe(true) + }) + + test('uses the Grid name-clickable fallback while preserving an explicit false item override', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const grid = component.getByTestId('name-clickable-fallback-grid') + + await expect( + grid.locator('[data-card-id="fallback-name"]').getByRole('button', { name: 'Fallback name' }), + ).toHaveCount(1) + await expect( + grid.locator('[data-card-id="disabled-name"]').getByRole('button', { name: 'Disabled name' }), + ).toHaveCount(0) + }) + + test('uses the Card name-clickable default when the standalone Grid does not provide a fallback', async ({ + mount, + }) => { + const component = await mount(ExtensionCardGridFixture) + const grid = component.getByTestId('implicit-name-clickable-grid') + + await expect(grid.getByRole('button', { name: 'Implicit name' })).toHaveCount(1) + }) + + test('uses Grid overflow fallbacks and preserves item-level label, placement, and false icon override', async ({ + mount, + }) => { + const component = await mount(ExtensionCardGridFixture) + const grid = component.getByTestId('menu-fallback-grid') + const fallbackCard = grid.locator('[data-card-id="menu-fallback"]') + const overrideCard = grid.locator('[data-card-id="menu-override"]') + + await fallbackCard.getByRole('button', { name: 'Grid actions' }).click() + const fallbackMenu = fallbackCard.locator('.tr-extension-card-popover__content') + await expect(fallbackMenu).toBeVisible() + await expect(fallbackMenu.getByRole('button', { name: 'Fallback menu action' })).toBeVisible() + await expect(fallbackMenu.locator('.tr-extension-card__more-menu-item-icon')).toHaveCount(1) + await expect + .poll(async () => + fallbackMenu.evaluate((element) => { + const trigger = element.parentElement?.querySelector('[popovertarget]') + return trigger + ? Number.parseFloat(getComputedStyle(element).top) < trigger.getBoundingClientRect().top + : false + }), + ) + .toBe(true) + + await overrideCard.getByRole('button', { name: 'Item actions' }).click() + const overrideMenu = overrideCard.locator('.tr-extension-card-popover__content') + await expect(overrideMenu).toBeVisible() + await expect(overrideMenu.locator('.tr-extension-card__more-menu-item-icon')).toHaveCount(0) + await expect + .poll(async () => + overrideMenu.evaluate((element) => { + const trigger = element.parentElement?.querySelector('[popovertarget]') + return trigger + ? Number.parseFloat(getComputedStyle(element).top) >= trigger.getBoundingClientRect().bottom + : false + }), + ) + .toBe(true) + }) + + test('wraps default Card actions and controlled name clicks with the item id', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const card = component.getByTestId('default-grid').locator(':scope > li[data-card-id="alpha"] > *') + + await card.getByRole('button', { name: 'Install Alpha' }).click() + await card.getByRole('button', { name: 'Inspect Alpha' }).click() + + await expect(component.getByTestId('action-events')).toHaveText( + 'alpha:install-alpha:button:|alpha:inspect-alpha:custom:', + ) + + await card.getByRole('button', { name: 'Alpha extension' }).click() + await expect(component.getByTestId('name-click-item-id')).toHaveText('alpha') + }) + + test('renders one empty li and gives the empty slot precedence over emptyText', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + + const defaultEmptyGrid = component.getByTestId('default-empty-grid') + await expect(defaultEmptyGrid).toHaveJSProperty('tagName', 'UL') + await expect(defaultEmptyGrid.locator(':scope > li')).toHaveCount(1) + await expect(defaultEmptyGrid.locator(':scope > li')).toHaveText('暂无内容') + + const textEmptyGrid = component.getByTestId('text-empty-grid') + await expect(textEmptyGrid).toHaveJSProperty('tagName', 'UL') + await expect(textEmptyGrid.locator(':scope > li')).toHaveCount(1) + await expect(textEmptyGrid.locator(':scope > li')).toHaveText('Nothing to show') + + const slotEmptyGrid = component.getByTestId('slot-empty-grid') + await expect(slotEmptyGrid).toHaveJSProperty('tagName', 'UL') + await expect(slotEmptyGrid.locator(':scope > li')).toHaveCount(1) + await expect(slotEmptyGrid.getByTestId('custom-empty')).toHaveText('Custom empty slot') + await expect(slotEmptyGrid.getByText('Fallback empty text', { exact: true })).toHaveCount(0) + }) + + test('uses the container width and card minimum-width variable to derive tracks', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const readRenderedTrackCount = (testId: string) => + component.getByTestId(testId).evaluate((element) => { + const style = getComputedStyle(element) + if (style.display !== 'grid' || style.gridTemplateColumns === 'none') return 0 + + const leftPositions = Array.from(element.children, (child) => Math.round(child.getBoundingClientRect().left)) + return new Set(leftPositions).size + }) + + await expect.poll(() => readRenderedTrackCount('default-min-width-grid')).toBe(2) + await expect.poll(() => readRenderedTrackCount('narrow-min-width-grid')).toBe(1) + await expect.poll(() => readRenderedTrackCount('custom-min-width-grid')).toBe(3) + }) + + test('preserves input item order', async ({ mount }) => { + const component = await mount(ExtensionCardGridFixture) + const itemIds = await component + .getByTestId('default-grid') + .locator(':scope > li[data-card-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-card-id'))) + + expect(itemIds).toEqual(['alpha', 'beta']) + }) +}) diff --git a/packages/test/component/extension-manager/ExtensionCardPopover.fixture.vue b/packages/test/component/extension-manager/ExtensionCardPopover.fixture.vue new file mode 100644 index 000000000..8c08f1cd2 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCardPopover.fixture.vue @@ -0,0 +1,51 @@ + + + + + 外部区域 + + + + + 默认触发器 + + + 默认触发器内容 + + + + + + + + 原生触发器 + + + 原生触发器内容 + + + + + + + + + + + 组件触发器内容 + + + + + diff --git a/packages/test/component/extension-manager/ExtensionCardPopover.spec.ts b/packages/test/component/extension-manager/ExtensionCardPopover.spec.ts new file mode 100644 index 000000000..5de0e3da4 --- /dev/null +++ b/packages/test/component/extension-manager/ExtensionCardPopover.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from '@playwright/experimental-ct-vue' +import ExtensionCardPopoverFixture from './ExtensionCardPopover.fixture.vue' + +test.describe('ExtensionCardPopover CT', () => { + test('默认触发器暴露浮层状态并可以通过键盘打开', async ({ mount }) => { + const component = await mount(ExtensionCardPopoverFixture) + const trigger = component.getByRole('button', { name: '默认触发器' }) + const controlledPopoverId = await trigger.getAttribute('aria-controls') + + expect(controlledPopoverId).toBeTruthy() + await expect(component.locator(`#${controlledPopoverId}`)).toContainText('默认触发器内容') + await expect(trigger).toHaveAttribute('aria-expanded', 'false') + + await trigger.focus() + await expect(trigger).toBeFocused() + + await trigger.press('Enter') + await expect(trigger).toHaveAttribute('aria-expanded', 'true') + await expect(component.getByText('默认触发器内容')).toBeVisible() + }) + + test('原生元素作为唯一触发节点且能够打开浮层', async ({ mount }) => { + const component = await mount(ExtensionCardPopoverFixture) + const section = component.getByTestId('native-trigger-section') + + await expect(section.locator(':scope > [data-testid="native-trigger"]')).toHaveCount(1) + await expect(section.locator(':scope > div.tr-extension-card-popover__trigger')).toHaveCount(0) + + await component.getByTestId('native-trigger').click() + await expect(component.getByText('原生触发器内容')).toBeVisible() + }) + + test('单根 Vue 组件作为唯一触发节点且能够打开浮层', async ({ mount }) => { + const component = await mount(ExtensionCardPopoverFixture) + const section = component.getByTestId('component-trigger-section') + + await expect(section.locator(':scope > [data-testid="component-trigger"]')).toHaveCount(1) + await expect(section.locator(':scope > div.tr-extension-card-popover__trigger')).toHaveCount(0) + + await component.getByTestId('component-trigger').click() + await expect(component.getByText('组件触发器内容')).toBeVisible() + }) + + test('不支持原生 Popover API 时仍可打开,并响应 Escape 和外部点击关闭', async ({ mount, page }) => { + await page.evaluate(() => { + Object.defineProperties(HTMLElement.prototype, { + showPopover: { configurable: true, value: undefined }, + hidePopover: { configurable: true, value: undefined }, + togglePopover: { configurable: true, value: undefined }, + }) + }) + + const component = await mount(ExtensionCardPopoverFixture) + + await expect(component.locator('[popover]')).toHaveCount(0) + await expect(component.locator('[popovertarget]')).toHaveCount(0) + await expect(component.locator('[popovertargetaction]')).toHaveCount(0) + + const trigger = component.getByTestId('native-trigger') + const content = component.getByText('原生触发器内容') + + await expect(content).toBeHidden() + + await trigger.click() + await expect(content).toBeVisible() + + await page.keyboard.press('Escape') + await expect(content).toBeHidden() + + await trigger.click() + await expect(content).toBeVisible() + + await component.getByTestId('outside-target').click() + await expect(content).toBeHidden() + }) +}) diff --git a/packages/test/package.json b/packages/test/package.json index 11eb2dd65..cc80b4487 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vue-tsc && vite build", "preview": "vite preview", - "test": "playwright test", + "test": "pnpm run test:e2e && pnpm run test:ct", + "test:e2e": "playwright test", + "test:ct": "playwright test -c playwright-ct.config.ts", "test:ui": "playwright test --ui", "test:codegen": "playwright codegen localhost:3333", "test:headed": "playwright test --headed", @@ -21,9 +23,12 @@ "vue": "^3.3.11" }, "devDependencies": { - "@playwright/test": "^1.40.0", + "@playwright/experimental-ct-vue": "1.62.0", + "@playwright/test": "1.62.0", "@types/node": "^22.13.17", "@vitejs/plugin-vue": "^4.5.2", + "@vitejs/plugin-vue-jsx": "^4.1.2", + "@vue/compiler-dom": "3.5.29", "typescript": "^5.2.2", "vite": "^5.0.8", "vue-tsc": "^2.2.8" diff --git a/packages/test/playwright-ct.config.ts b/packages/test/playwright-ct.config.ts new file mode 100644 index 000000000..f58524e7e --- /dev/null +++ b/packages/test/playwright-ct.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/experimental-ct-vue' +import vue from '@vitejs/plugin-vue' +import vuejsx from '@vitejs/plugin-vue-jsx' + +process.env.NODE_ENV = 'development' + +export default defineConfig({ + testDir: './component', + testMatch: '**/*.spec.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 4 : undefined, + reporter: [['html', { open: 'never', outputFolder: 'component/playwright-report' }]], + outputDir: 'component/test-results', + use: { + ctPort: 3100, + ctCacheDir: 'playwright/.cache-development-mode', + ctViteConfig: { + mode: 'development', + plugins: [vue(), vuejsx()], + }, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/packages/test/playwright.config.ts b/packages/test/playwright.config.ts index 3f8131d01..79ff5bc44 100644 --- a/packages/test/playwright.config.ts +++ b/packages/test/playwright.config.ts @@ -5,6 +5,7 @@ import { defineConfig, devices } from '@playwright/test' */ export default defineConfig({ testDir: './', + testIgnore: 'component/**', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ diff --git a/packages/test/playwright/index.html b/packages/test/playwright/index.html new file mode 100644 index 000000000..35842aadc --- /dev/null +++ b/packages/test/playwright/index.html @@ -0,0 +1,12 @@ + + + + + + Tiny Robot Component Tests + + + + + + diff --git a/packages/test/playwright/index.ts b/packages/test/playwright/index.ts new file mode 100644 index 000000000..653d0eb19 --- /dev/null +++ b/packages/test/playwright/index.ts @@ -0,0 +1 @@ +import '@opentiny/tiny-robot/dist/style.css' diff --git a/packages/test/vite.config.ts b/packages/test/vite.config.ts index 76e3ff32d..7fde75e70 100644 --- a/packages/test/vite.config.ts +++ b/packages/test/vite.config.ts @@ -1,8 +1,9 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' export default defineConfig({ - plugins: [vue()], + plugins: [vue(), vueJsx()], server: { port: 3333, host: true,