mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 17:08:03 +08:00
Merge branch 'feat/model-plugins-implementing' into deploy/dev
This commit is contained in:
115
web/__tests__/component-coverage-filters.test.ts
Normal file
115
web/__tests__/component-coverage-filters.test.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectComponentCoverageExcludedFiles,
|
||||
COMPONENT_COVERAGE_EXCLUDE_LABEL,
|
||||
getComponentCoverageExclusionReasons,
|
||||
} from '../scripts/component-coverage-filters.mjs'
|
||||
|
||||
describe('component coverage filters', () => {
|
||||
describe('getComponentCoverageExclusionReasons', () => {
|
||||
it('should exclude type-only files by basename', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/share/text-generation/types.ts',
|
||||
'export type ShareMode = "run-once" | "run-batch"',
|
||||
),
|
||||
).toContain('type-only')
|
||||
})
|
||||
|
||||
it('should exclude pure barrel files', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/base/amplitude/index.ts',
|
||||
[
|
||||
'export { default } from "./AmplitudeProvider"',
|
||||
'export { resetUser, trackEvent } from "./utils"',
|
||||
].join('\n'),
|
||||
),
|
||||
).toContain('pure-barrel')
|
||||
})
|
||||
|
||||
it('should exclude generated files from marker comments', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/base/icons/src/vender/workflow/Answer.tsx',
|
||||
[
|
||||
'// GENERATE BY script',
|
||||
'// DON NOT EDIT IT MANUALLY',
|
||||
'export default function Icon() {',
|
||||
' return null',
|
||||
'}',
|
||||
].join('\n'),
|
||||
),
|
||||
).toContain('generated')
|
||||
})
|
||||
|
||||
it('should exclude pure static files with exported constants only', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/workflow/note-node/constants.ts',
|
||||
[
|
||||
'import { NoteTheme } from "./types"',
|
||||
'export const CUSTOM_NOTE_NODE = "custom-note"',
|
||||
'export const THEME_MAP = {',
|
||||
' [NoteTheme.blue]: { title: "bg-blue-100" },',
|
||||
'}',
|
||||
].join('\n'),
|
||||
),
|
||||
).toContain('pure-static')
|
||||
})
|
||||
|
||||
it('should keep runtime logic files tracked', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/workflow/nodes/trigger-schedule/default.ts',
|
||||
[
|
||||
'const validate = (value: string) => value.trim()',
|
||||
'export const nodeDefault = {',
|
||||
' value: validate("x"),',
|
||||
'}',
|
||||
].join('\n'),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectComponentCoverageExcludedFiles', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs)
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
tempDirs.length = 0
|
||||
})
|
||||
|
||||
it('should collect excluded files for coverage config and keep runtime files out', () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'component-coverage-filters-'))
|
||||
tempDirs.push(rootDir)
|
||||
|
||||
fs.mkdirSync(path.join(rootDir, 'barrel'), { recursive: true })
|
||||
fs.mkdirSync(path.join(rootDir, 'icons'), { recursive: true })
|
||||
fs.mkdirSync(path.join(rootDir, 'static'), { recursive: true })
|
||||
fs.mkdirSync(path.join(rootDir, 'runtime'), { recursive: true })
|
||||
|
||||
fs.writeFileSync(path.join(rootDir, 'barrel', 'index.ts'), 'export { default } from "./Button"\n')
|
||||
fs.writeFileSync(path.join(rootDir, 'icons', 'generated-icon.tsx'), '// @generated\nexport default function Icon() { return null }\n')
|
||||
fs.writeFileSync(path.join(rootDir, 'static', 'constants.ts'), 'export const COLORS = { primary: "#fff" }\n')
|
||||
fs.writeFileSync(path.join(rootDir, 'runtime', 'config.ts'), 'export const config = makeConfig()\n')
|
||||
fs.writeFileSync(path.join(rootDir, 'runtime', 'types.ts'), 'export type Config = { value: string }\n')
|
||||
|
||||
expect(collectComponentCoverageExcludedFiles(rootDir, { pathPrefix: 'app/components' })).toEqual([
|
||||
'app/components/barrel/index.ts',
|
||||
'app/components/icons/generated-icon.tsx',
|
||||
'app/components/runtime/types.ts',
|
||||
'app/components/static/constants.ts',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('should describe the excluded coverage categories', () => {
|
||||
expect(COMPONENT_COVERAGE_EXCLUDE_LABEL).toBe('type-only files, pure barrel files, generated files, pure static files')
|
||||
})
|
||||
})
|
||||
@ -1,7 +1,13 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { InSiteMessageActionItem } from './index'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import InSiteMessage from './index'
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('InSiteMessage', () => {
|
||||
const originalLocation = window.location
|
||||
|
||||
@ -18,9 +24,10 @@ describe('InSiteMessage', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
const renderComponent = (actions: InSiteMessageActionItem[], props?: Partial<React.ComponentProps<typeof InSiteMessage>>) => {
|
||||
const renderComponent = (actions: InSiteMessageActionItem[], props?: Partial<ComponentProps<typeof InSiteMessage>>) => {
|
||||
return render(
|
||||
<InSiteMessage
|
||||
notificationId="test-notification-id"
|
||||
title="Title\\nLine"
|
||||
subtitle="Subtitle\\nLine"
|
||||
main="Main content"
|
||||
@ -34,8 +41,8 @@ describe('InSiteMessage', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render title, subtitle, markdown content, and action buttons', () => {
|
||||
const actions: InSiteMessageActionItem[] = [
|
||||
{ action: 'close', text: 'Close', type: 'default' },
|
||||
{ action: 'link', text: 'Learn more', type: 'primary', data: 'https://example.com' },
|
||||
{ action: 'close', action_name: 'dismiss', text: 'Close', type: 'default' },
|
||||
{ action: 'link', action_name: 'learn_more', text: 'Learn more', type: 'primary', data: 'https://example.com' },
|
||||
]
|
||||
|
||||
renderComponent(actions, { className: 'custom-message' })
|
||||
@ -56,7 +63,7 @@ describe('InSiteMessage', () => {
|
||||
})
|
||||
|
||||
it('should fallback to default header background when headerBgUrl is empty string', () => {
|
||||
const actions: InSiteMessageActionItem[] = [{ action: 'close', text: 'Close', type: 'default' }]
|
||||
const actions: InSiteMessageActionItem[] = [{ action: 'close', action_name: 'dismiss', text: 'Close', type: 'default' }]
|
||||
|
||||
const { container } = renderComponent(actions, { headerBgUrl: '' })
|
||||
const header = container.querySelector('div[style]')
|
||||
@ -68,7 +75,7 @@ describe('InSiteMessage', () => {
|
||||
describe('Actions', () => {
|
||||
it('should call onAction and hide component when close action is clicked', () => {
|
||||
const onAction = vi.fn()
|
||||
const closeAction: InSiteMessageActionItem = { action: 'close', text: 'Close', type: 'default' }
|
||||
const closeAction: InSiteMessageActionItem = { action: 'close', action_name: 'dismiss', text: 'Close', type: 'default' }
|
||||
|
||||
renderComponent([closeAction], { onAction })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
@ -80,6 +87,7 @@ describe('InSiteMessage', () => {
|
||||
it('should open a new tab when link action data is a string', () => {
|
||||
const linkAction: InSiteMessageActionItem = {
|
||||
action: 'link',
|
||||
action_name: 'confirm',
|
||||
text: 'Open link',
|
||||
type: 'primary',
|
||||
data: 'https://example.com',
|
||||
@ -103,6 +111,7 @@ describe('InSiteMessage', () => {
|
||||
|
||||
const linkAction: InSiteMessageActionItem = {
|
||||
action: 'link',
|
||||
action_name: 'confirm',
|
||||
text: 'Open self',
|
||||
type: 'primary',
|
||||
data: { href: 'https://example.com/self', target: '_self' },
|
||||
@ -118,6 +127,7 @@ describe('InSiteMessage', () => {
|
||||
it('should not trigger navigation when link data is invalid', () => {
|
||||
const linkAction: InSiteMessageActionItem = {
|
||||
action: 'link',
|
||||
action_name: 'confirm',
|
||||
text: 'Broken link',
|
||||
type: 'primary',
|
||||
data: { rel: 'noopener' },
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { MarkdownWithDirective } from '@/app/components/base/markdown-with-directive'
|
||||
import { cn } from '@/utils/classnames'
|
||||
@ -10,12 +11,14 @@ type InSiteMessageButtonType = 'primary' | 'default'
|
||||
|
||||
export type InSiteMessageActionItem = {
|
||||
action: InSiteMessageAction
|
||||
action_name: string // for tracing and analytics
|
||||
data?: unknown
|
||||
text: string
|
||||
type: InSiteMessageButtonType
|
||||
}
|
||||
|
||||
type InSiteMessageProps = {
|
||||
notificationId: string
|
||||
actions: InSiteMessageActionItem[]
|
||||
className?: string
|
||||
headerBgUrl?: string
|
||||
@ -52,6 +55,7 @@ function normalizeLinkData(data: unknown): { href: string, rel?: string, target?
|
||||
const DEFAULT_HEADER_BG_URL = '/in-site-message/header-bg.svg'
|
||||
|
||||
function InSiteMessage({
|
||||
notificationId,
|
||||
actions,
|
||||
className,
|
||||
headerBgUrl = DEFAULT_HEADER_BG_URL,
|
||||
@ -70,7 +74,17 @@ function InSiteMessage({
|
||||
}
|
||||
}, [headerBgUrl])
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent('in_site_message_show', {
|
||||
notification_id: notificationId,
|
||||
})
|
||||
}, [notificationId])
|
||||
|
||||
const handleAction = (item: InSiteMessageActionItem) => {
|
||||
trackEvent('in_site_message_action', {
|
||||
notification_id: notificationId,
|
||||
action: item.action_name,
|
||||
})
|
||||
onAction?.(item)
|
||||
|
||||
if (item.action === 'close') {
|
||||
|
||||
@ -15,11 +15,16 @@ const {
|
||||
mockNotificationDismiss: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.isCloudEdition
|
||||
},
|
||||
}))
|
||||
vi.mock(import('@/config'), async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
get IS_CLOUD_EDITION() {
|
||||
return mockConfig.isCloudEdition
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
|
||||
@ -75,6 +75,7 @@ function InSiteMessageNotification() {
|
||||
const fallbackActions: InSiteMessageActionItem[] = [
|
||||
{
|
||||
type: 'default',
|
||||
action_name: 'dismiss',
|
||||
text: t('operation.close', { ns: 'common' }),
|
||||
action: 'close',
|
||||
},
|
||||
@ -96,6 +97,7 @@ function InSiteMessageNotification() {
|
||||
return (
|
||||
<InSiteMessage
|
||||
key={notification.notification_id}
|
||||
notificationId={notification.notification_id}
|
||||
title={notification.title}
|
||||
subtitle={notification.subtitle}
|
||||
headerBgUrl={notification.title_pic_url}
|
||||
|
||||
@ -0,0 +1,208 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { ConfigurationMethodEnum, ModelTypeEnum, PreferredProviderTypeEnum } from '../declarations'
|
||||
import { useChangeProviderPriority } from './use-change-provider-priority'
|
||||
|
||||
const mockUpdateModelList = vi.fn()
|
||||
const mockUpdateModelProviders = vi.fn()
|
||||
const mockNotify = vi.fn()
|
||||
const mockQueryKey = vi.fn(({ input }: { input: { params: { provider: string } } }) => ['model-providers', 'models', input.params.provider])
|
||||
const mockChangePreferredProviderType = vi.fn()
|
||||
const mockMutationOptions = vi.fn((options: Record<string, unknown>) => ({
|
||||
mutationFn: (variables: unknown) => mockChangePreferredProviderType(variables),
|
||||
...options,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: {
|
||||
notify: (...args: unknown[]) => mockNotify(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
modelProviders: {
|
||||
models: {
|
||||
queryKey: (options: { input: { params: { provider: string } } }) => mockQueryKey(options),
|
||||
},
|
||||
changePreferredProviderType: {
|
||||
mutationOptions: (options: Record<string, unknown>) => mockMutationOptions(options),
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useUpdateModelList: () => mockUpdateModelList,
|
||||
useUpdateModelProviders: () => mockUpdateModelProviders,
|
||||
}))
|
||||
|
||||
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
|
||||
provider: 'langgenius/openai/openai',
|
||||
configurate_methods: [
|
||||
ConfigurationMethodEnum.customizableModel,
|
||||
ConfigurationMethodEnum.predefinedModel,
|
||||
],
|
||||
supported_model_types: [ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding],
|
||||
label: { en_US: 'OpenAI' },
|
||||
icon_small: { en_US: 'https://example.com/icon.png' },
|
||||
provider_credential_schema: { credential_form_schemas: [] },
|
||||
model_credential_schema: {
|
||||
model: {
|
||||
label: { en_US: 'Model' },
|
||||
placeholder: { en_US: 'Select model' },
|
||||
},
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
...overrides,
|
||||
} as ModelProvider)
|
||||
|
||||
const createTestQueryClient = () => new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const createWrapper = (queryClient: QueryClient) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children)
|
||||
)
|
||||
}
|
||||
|
||||
describe('useChangeProviderPriority', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockChangePreferredProviderType.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('when changing provider priority', () => {
|
||||
it('should submit the selected preferred provider type for the current provider', async () => {
|
||||
const queryClient = createTestQueryClient()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined)
|
||||
const provider = createProvider()
|
||||
const { result } = renderHook(() => useChangeProviderPriority(provider), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleChangePriority(PreferredProviderTypeEnum.custom)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChangePreferredProviderType).toHaveBeenCalledWith({
|
||||
params: { provider: 'langgenius/openai/openai' },
|
||||
body: { preferred_provider_type: PreferredProviderTypeEnum.custom },
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockQueryKey).toHaveBeenCalledWith({
|
||||
input: {
|
||||
params: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(mockMutationOptions).toHaveBeenCalled()
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['model-providers', 'models', 'langgenius/openai/openai'],
|
||||
exact: true,
|
||||
refetchType: 'none',
|
||||
})
|
||||
expect(mockUpdateModelProviders).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateModelList).toHaveBeenCalledTimes(2)
|
||||
expect(mockUpdateModelList).toHaveBeenNthCalledWith(1, ModelTypeEnum.textGeneration)
|
||||
expect(mockUpdateModelList).toHaveBeenNthCalledWith(2, ModelTypeEnum.textEmbedding)
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'common.actionMsg.modifiedSuccessfully',
|
||||
})
|
||||
expect(result.current.isChangingPriority).toBe(false)
|
||||
})
|
||||
|
||||
it('should tolerate an undefined provider and still submit a request without refreshing model lists', async () => {
|
||||
const queryClient = createTestQueryClient()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined)
|
||||
const { result } = renderHook(() => useChangeProviderPriority(undefined), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleChangePriority(PreferredProviderTypeEnum.system)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChangePreferredProviderType).toHaveBeenCalledWith({
|
||||
params: { provider: '' },
|
||||
body: { preferred_provider_type: PreferredProviderTypeEnum.system },
|
||||
})
|
||||
})
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['model-providers', 'models', ''],
|
||||
exact: true,
|
||||
refetchType: 'none',
|
||||
})
|
||||
expect(mockUpdateModelProviders).toHaveBeenCalledTimes(1)
|
||||
expect(mockUpdateModelList).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the mutation is not successful immediately', () => {
|
||||
it('should show an error toast when the mutation fails', async () => {
|
||||
const queryClient = createTestQueryClient()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined)
|
||||
mockChangePreferredProviderType.mockRejectedValueOnce(new Error('network error'))
|
||||
const { result } = renderHook(() => useChangeProviderPriority(createProvider()), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleChangePriority(PreferredProviderTypeEnum.custom)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'common.actionMsg.modifiedUnsuccessfully',
|
||||
})
|
||||
})
|
||||
|
||||
expect(invalidateQueries).not.toHaveBeenCalled()
|
||||
expect(mockUpdateModelProviders).not.toHaveBeenCalled()
|
||||
expect(mockUpdateModelList).not.toHaveBeenCalled()
|
||||
expect(result.current.isChangingPriority).toBe(false)
|
||||
})
|
||||
|
||||
it('should expose the pending mutation state while the request is in flight', async () => {
|
||||
let resolveMutation: (() => void) | undefined
|
||||
mockChangePreferredProviderType.mockImplementationOnce(() => new Promise<void>((resolve) => {
|
||||
resolveMutation = resolve
|
||||
}))
|
||||
|
||||
const queryClient = createTestQueryClient()
|
||||
const { result } = renderHook(() => useChangeProviderPriority(createProvider()), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleChangePriority(PreferredProviderTypeEnum.custom)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isChangingPriority).toBe(true)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
resolveMutation?.()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isChangingPriority).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,88 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useTrialCredits } from './use-trial-credits'
|
||||
|
||||
const mockUseCurrentWorkspace = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useCurrentWorkspace: () => mockUseCurrentWorkspace(),
|
||||
}))
|
||||
|
||||
describe('useTrialCredits', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseCurrentWorkspace.mockReturnValue({
|
||||
data: {
|
||||
trial_credits: 100,
|
||||
trial_credits_used: 40,
|
||||
next_credit_reset_date: '2026-04-01',
|
||||
},
|
||||
isPending: false,
|
||||
})
|
||||
})
|
||||
|
||||
describe('when workspace data is available', () => {
|
||||
it('should return the remaining credits and reset date', () => {
|
||||
const { result } = renderHook(() => useTrialCredits())
|
||||
|
||||
expect(result.current).toEqual({
|
||||
credits: 60,
|
||||
totalCredits: 100,
|
||||
isExhausted: false,
|
||||
isLoading: false,
|
||||
nextCreditResetDate: '2026-04-01',
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep the hook out of loading state during a background refetch', () => {
|
||||
mockUseCurrentWorkspace.mockReturnValue({
|
||||
data: {
|
||||
trial_credits: 80,
|
||||
trial_credits_used: 20,
|
||||
next_credit_reset_date: '2026-05-01',
|
||||
},
|
||||
isPending: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTrialCredits())
|
||||
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
expect(result.current.credits).toBe(60)
|
||||
expect(result.current.isExhausted).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when workspace data is missing or exhausted', () => {
|
||||
it('should report loading while the first workspace request is pending', () => {
|
||||
mockUseCurrentWorkspace.mockReturnValue({
|
||||
data: undefined,
|
||||
isPending: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTrialCredits())
|
||||
|
||||
expect(result.current).toEqual({
|
||||
credits: 0,
|
||||
totalCredits: 0,
|
||||
isExhausted: true,
|
||||
isLoading: true,
|
||||
nextCreditResetDate: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('should clamp negative remaining credits to zero', () => {
|
||||
mockUseCurrentWorkspace.mockReturnValue({
|
||||
data: {
|
||||
trial_credits: 10,
|
||||
trial_credits_used: 99,
|
||||
next_credit_reset_date: undefined,
|
||||
},
|
||||
isPending: false,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useTrialCredits())
|
||||
|
||||
expect(result.current.credits).toBe(0)
|
||||
expect(result.current.isExhausted).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -16,6 +16,7 @@ import {
|
||||
genModelNameFormSchema,
|
||||
genModelTypeFormSchema,
|
||||
modelTypeFormat,
|
||||
providerToPluginId,
|
||||
removeCredentials,
|
||||
saveCredentials,
|
||||
savePredefinedLoadBalancingConfig,
|
||||
@ -47,6 +48,16 @@ describe('utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerToPluginId', () => {
|
||||
it('should return the plugin id prefix when the provider key contains a provider segment', () => {
|
||||
expect(providerToPluginId('langgenius/openai/openai')).toBe('langgenius/openai')
|
||||
})
|
||||
|
||||
it('should return an empty string when the provider key has no plugin prefix', () => {
|
||||
expect(providerToPluginId('openai')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('modelTypeFormat', () => {
|
||||
it('should format text embedding type', () => {
|
||||
expect(modelTypeFormat(ModelTypeEnum.textEmbedding)).toBe('TEXT EMBEDDING')
|
||||
|
||||
248
web/app/components/workflow/nodes/llm/panel.spec.tsx
Normal file
248
web/app/components/workflow/nodes/llm/panel.spec.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import type { LLMNodeType } from './types'
|
||||
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import type { PanelProps } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { defaultPlan } from '@/app/components/billing/config'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelTypeEnum,
|
||||
PreferredProviderTypeEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { BlockEnum } from '../../types'
|
||||
import Panel from './panel'
|
||||
|
||||
const mockUseConfig = vi.fn()
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./use-config', () => ({
|
||||
default: (...args: unknown[]) => mockUseConfig(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
|
||||
default: () => <div data-testid="model-parameter-modal" />,
|
||||
}))
|
||||
|
||||
vi.mock('./components/config-prompt', () => ({
|
||||
default: () => <div data-testid="config-prompt" />,
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/config-vision', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/memory-config', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../_base/components/variable/var-reference-picker', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./components/reasoning-format-config', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('./components/structure-output', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
|
||||
default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
|
||||
VarItem: () => null,
|
||||
}))
|
||||
|
||||
type MockUseConfigReturn = ReturnType<typeof mockUseConfig>
|
||||
|
||||
const modelProviderSelector = vi.mocked(useProviderContextSelector)
|
||||
|
||||
const createProviderContextState = (modelProviders: ModelProvider[]): ProviderContextState => ({
|
||||
modelProviders,
|
||||
refreshModelProviders: vi.fn(),
|
||||
textGenerationModelList: [],
|
||||
supportRetrievalMethods: [],
|
||||
isAPIKeySet: true,
|
||||
plan: defaultPlan,
|
||||
isFetchedPlan: true,
|
||||
enableBilling: false,
|
||||
onPlanInfoChanged: vi.fn(),
|
||||
enableReplaceWebAppLogo: false,
|
||||
modelLoadBalancingEnabled: false,
|
||||
datasetOperatorEnabled: false,
|
||||
enableEducationPlan: false,
|
||||
isEducationWorkspace: false,
|
||||
isEducationAccount: false,
|
||||
allowRefreshEducationVerify: false,
|
||||
educationAccountExpireAt: null,
|
||||
isLoadingEducationAccountInfo: false,
|
||||
isFetchingEducationAccountInfo: false,
|
||||
webappCopyrightEnabled: false,
|
||||
licenseLimit: {
|
||||
workspace_members: {
|
||||
size: 0,
|
||||
limit: 0,
|
||||
},
|
||||
},
|
||||
refreshLicenseLimit: vi.fn(),
|
||||
isAllowTransferWorkspace: false,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
|
||||
humanInputEmailDeliveryEnabled: false,
|
||||
})
|
||||
|
||||
const createMockModelProvider = (provider: string): ModelProvider => ({
|
||||
provider,
|
||||
label: { en_US: provider, zh_Hans: provider },
|
||||
help: {
|
||||
title: { en_US: provider, zh_Hans: provider },
|
||||
url: { en_US: '', zh_Hans: '' },
|
||||
},
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
supported_model_types: [ModelTypeEnum.textGeneration],
|
||||
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
|
||||
provider_credential_schema: {
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
model_credential_schema: {
|
||||
model: {
|
||||
label: { en_US: '', zh_Hans: '' },
|
||||
placeholder: { en_US: '', zh_Hans: '' },
|
||||
},
|
||||
credential_form_schemas: [],
|
||||
},
|
||||
preferred_provider_type: PreferredProviderTypeEnum.system,
|
||||
custom_configuration: {
|
||||
status: CustomConfigurationStatusEnum.active,
|
||||
},
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [],
|
||||
},
|
||||
})
|
||||
|
||||
const baseNodeData: LLMNodeType = {
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
desc: '',
|
||||
model: {
|
||||
provider: 'openai',
|
||||
name: 'gpt-4o',
|
||||
mode: AppModeEnum.CHAT,
|
||||
completion_params: {},
|
||||
},
|
||||
prompt_template: [],
|
||||
context: {
|
||||
enabled: false,
|
||||
variable_selector: [],
|
||||
},
|
||||
vision: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
|
||||
const panelProps = {} as PanelProps
|
||||
|
||||
const buildUseConfigResult = (overrides?: Partial<MockUseConfigReturn>) => ({
|
||||
readOnly: false,
|
||||
inputs: baseNodeData,
|
||||
isChatModel: true,
|
||||
isChatMode: true,
|
||||
isCompletionModel: false,
|
||||
shouldShowContextTip: false,
|
||||
isVisionModel: false,
|
||||
handleModelChanged: vi.fn(),
|
||||
hasSetBlockStatus: false,
|
||||
handleCompletionParamsChange: vi.fn(),
|
||||
handleContextVarChange: vi.fn(),
|
||||
filterInputVar: vi.fn(),
|
||||
filterVar: vi.fn(),
|
||||
availableVars: [],
|
||||
availableNodesWithParent: [],
|
||||
isShowVars: false,
|
||||
handlePromptChange: vi.fn(),
|
||||
handleAddEmptyVariable: vi.fn(),
|
||||
handleAddVariable: vi.fn(),
|
||||
handleVarListChange: vi.fn(),
|
||||
handleVarNameChange: vi.fn(),
|
||||
handleSyeQueryChange: vi.fn(),
|
||||
handleMemoryChange: vi.fn(),
|
||||
handleVisionResolutionEnabledChange: vi.fn(),
|
||||
handleVisionResolutionChange: vi.fn(),
|
||||
isModelSupportStructuredOutput: false,
|
||||
structuredOutputCollapsed: false,
|
||||
setStructuredOutputCollapsed: vi.fn(),
|
||||
handleStructureOutputEnableChange: vi.fn(),
|
||||
handleStructureOutputChange: vi.fn(),
|
||||
filterJinja2InputVar: vi.fn(),
|
||||
handleReasoningFormatChange: vi.fn(),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderPanel = (data?: Partial<LLMNodeType>) => {
|
||||
return render(
|
||||
<Panel
|
||||
id="llm-node"
|
||||
data={{ ...baseNodeData, ...data }}
|
||||
panelProps={panelProps}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('LLM Panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
modelProviderSelector.mockImplementation(selector => selector(
|
||||
createProviderContextState([createMockModelProvider('openai')]),
|
||||
))
|
||||
mockUseConfig.mockReturnValue(buildUseConfigResult())
|
||||
})
|
||||
|
||||
describe('Model Warning Dot', () => {
|
||||
it('should not show the model warning dot when the node only has a connection checklist issue', () => {
|
||||
renderPanel()
|
||||
|
||||
const modelField = screen.getByText('workflow.nodes.llm.model').parentElement
|
||||
expect(modelField?.querySelector('.bg-text-warning-secondary')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the model warning dot when the model is not configured', () => {
|
||||
mockUseConfig.mockReturnValue(buildUseConfigResult({
|
||||
inputs: {
|
||||
...baseNodeData,
|
||||
model: {
|
||||
...baseNodeData.model,
|
||||
provider: '',
|
||||
name: '',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
renderPanel({
|
||||
model: {
|
||||
...baseNodeData.model,
|
||||
provider: '',
|
||||
name: '',
|
||||
},
|
||||
})
|
||||
|
||||
const modelField = screen.getByText('workflow.nodes.llm.model').parentElement
|
||||
expect(modelField?.querySelector('.bg-text-warning-secondary')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -15,8 +15,9 @@ import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/compo
|
||||
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
|
||||
import Split from '@/app/components/workflow/nodes/_base/components/split'
|
||||
import VarList from '@/app/components/workflow/nodes/_base/components/variable/var-list'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params'
|
||||
import { extractPluginId } from '../../utils/plugin'
|
||||
import ConfigVision from '../_base/components/config-vision'
|
||||
import MemoryConfig from '../_base/components/memory-config'
|
||||
import VarReferencePicker from '../_base/components/variable/var-reference-picker'
|
||||
@ -32,7 +33,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
data,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const hasChecklistWarning = useStore(s => s.checklistItems.some(item => item.id === id))
|
||||
const modelProviders = useProviderContextSelector(s => s.modelProviders)
|
||||
const {
|
||||
readOnly,
|
||||
inputs,
|
||||
@ -69,6 +70,10 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
} = useConfig(id, data)
|
||||
|
||||
const model = inputs.model
|
||||
const installedPluginIds = new Set(modelProviders.map(provider => extractPluginId(provider.provider)))
|
||||
const hasModelWarning = !model?.provider
|
||||
|| !model?.name
|
||||
|| (Boolean(model.provider) && !installedPluginIds.has(extractPluginId(model.provider)))
|
||||
|
||||
const handleModelChange = useCallback((model: {
|
||||
provider: string
|
||||
@ -104,7 +109,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
|
||||
<Field
|
||||
title={t(`${i18nPrefix}.model`, { ns: 'workflow' })}
|
||||
required
|
||||
warningDot={hasChecklistWarning}
|
||||
warningDot={hasModelWarning}
|
||||
>
|
||||
<ModelParameterModal
|
||||
popupClassName="!w-[387px]"
|
||||
|
||||
@ -20,6 +20,8 @@ const config: KnipConfig = {
|
||||
'@iconify-json/*',
|
||||
|
||||
'@storybook/addon-onboarding',
|
||||
|
||||
'@voidzero-dev/vite-plus-core',
|
||||
],
|
||||
rules: {
|
||||
files: 'warn',
|
||||
|
||||
@ -50,9 +50,9 @@
|
||||
"start:vinext": "vinext start",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest --watch",
|
||||
"test": "vp test",
|
||||
"test:coverage": "vp test --coverage",
|
||||
"test:watch": "vp test --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"type-check:tsgo": "tsgo --noEmit",
|
||||
"uglify-embed": "node ./bin/uglify-embed"
|
||||
@ -213,6 +213,7 @@
|
||||
"@vitejs/plugin-react": "6.0.0",
|
||||
"@vitejs/plugin-rsc": "0.5.21",
|
||||
"@vitest/coverage-v8": "4.1.0",
|
||||
"@voidzero-dev/vite-plus-core": "0.1.11",
|
||||
"agentation": "2.3.2",
|
||||
"autoprefixer": "10.4.27",
|
||||
"code-inspector-plugin": "1.4.4",
|
||||
@ -241,9 +242,10 @@
|
||||
"typescript": "5.9.3",
|
||||
"uglify-js": "3.19.3",
|
||||
"vinext": "https://pkg.pr.new/vinext@18fe3ea",
|
||||
"vite": "8.0.0",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@0.1.11",
|
||||
"vite-plugin-inspect": "11.3.3",
|
||||
"vitest": "4.1.0",
|
||||
"vite-plus": "0.1.11",
|
||||
"vitest": "npm:@voidzero-dev/vite-plus-test@0.1.11",
|
||||
"vitest-canvas-mock": "1.1.3"
|
||||
},
|
||||
"pnpm": {
|
||||
@ -293,6 +295,8 @@
|
||||
"svgo@>=3.0.0,<3.3.3": "3.3.3",
|
||||
"tar@<=7.5.10": "7.5.11",
|
||||
"typed-array-buffer": "npm:@nolyfill/typed-array-buffer@^1.0.44",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@0.1.11",
|
||||
"vitest": "npm:@voidzero-dev/vite-plus-test@0.1.11",
|
||||
"which-typed-array": "npm:@nolyfill/which-typed-array@^1.0.44"
|
||||
},
|
||||
"ignoredBuiltDependencies": [
|
||||
|
||||
1293
web/pnpm-lock.yaml
generated
1293
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
560
web/scripts/check-components-diff-coverage.mjs
Normal file
560
web/scripts/check-components-diff-coverage.mjs
Normal file
@ -0,0 +1,560 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
collectComponentCoverageExcludedFiles,
|
||||
COMPONENT_COVERAGE_EXCLUDE_LABEL,
|
||||
} from './component-coverage-filters.mjs'
|
||||
import {
|
||||
COMPONENTS_GLOBAL_THRESHOLDS,
|
||||
EXCLUDED_COMPONENT_MODULES,
|
||||
getComponentModuleThreshold,
|
||||
} from './components-coverage-thresholds.mjs'
|
||||
|
||||
const APP_COMPONENTS_PREFIX = 'web/app/components/'
|
||||
const APP_COMPONENTS_COVERAGE_PREFIX = 'app/components/'
|
||||
const SHARED_TEST_PREFIX = 'web/__tests__/'
|
||||
const STRICT_TEST_FILE_TOUCH = process.env.STRICT_COMPONENT_TEST_TOUCH === 'true'
|
||||
const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ')
|
||||
|
||||
const repoRoot = repoRootFromCwd()
|
||||
const webRoot = path.join(repoRoot, 'web')
|
||||
const excludedComponentCoverageFiles = new Set(
|
||||
collectComponentCoverageExcludedFiles(path.join(webRoot, 'app/components'), { pathPrefix: 'web/app/components' }),
|
||||
)
|
||||
const baseSha = process.env.BASE_SHA?.trim()
|
||||
const headSha = process.env.HEAD_SHA?.trim() || 'HEAD'
|
||||
const coverageFinalPath = path.join(webRoot, 'coverage', 'coverage-final.json')
|
||||
|
||||
if (!baseSha || /^0+$/.test(baseSha)) {
|
||||
appendSummary([
|
||||
'### app/components Diff Coverage',
|
||||
'',
|
||||
'Skipped diff coverage check because `BASE_SHA` was not available.',
|
||||
])
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(coverageFinalPath)) {
|
||||
console.error(`Coverage report not found at ${coverageFinalPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const coverage = JSON.parse(fs.readFileSync(coverageFinalPath, 'utf8'))
|
||||
const changedFiles = getChangedFiles(baseSha, headSha)
|
||||
const changedComponentSourceFiles = changedFiles.filter(isAnyComponentSourceFile)
|
||||
const changedSourceFiles = changedComponentSourceFiles.filter(isTrackedComponentSourceFile)
|
||||
const changedExcludedSourceFiles = changedComponentSourceFiles.filter(isExcludedComponentSourceFile)
|
||||
const changedTestFiles = changedFiles.filter(isRelevantTestFile)
|
||||
|
||||
if (changedSourceFiles.length === 0) {
|
||||
appendSummary(buildSkipSummary(changedExcludedSourceFiles))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const coverageEntries = new Map()
|
||||
for (const [file, entry] of Object.entries(coverage)) {
|
||||
const repoRelativePath = normalizeToRepoRelative(entry.path ?? file)
|
||||
if (!isTrackedComponentSourceFile(repoRelativePath))
|
||||
continue
|
||||
|
||||
coverageEntries.set(repoRelativePath, entry)
|
||||
}
|
||||
|
||||
const fileCoverageRows = []
|
||||
const moduleCoverageMap = new Map()
|
||||
|
||||
for (const [file, entry] of coverageEntries.entries()) {
|
||||
const stats = getCoverageStats(entry)
|
||||
const moduleName = getModuleName(file)
|
||||
fileCoverageRows.push({ file, moduleName, ...stats })
|
||||
mergeCoverageStats(moduleCoverageMap, moduleName, stats)
|
||||
}
|
||||
|
||||
const overallCoverage = sumCoverageStats(fileCoverageRows)
|
||||
const diffChanges = getChangedLineMap(baseSha, headSha)
|
||||
const diffRows = []
|
||||
|
||||
for (const [file, changedLines] of diffChanges.entries()) {
|
||||
if (!isTrackedComponentSourceFile(file))
|
||||
continue
|
||||
|
||||
const entry = coverageEntries.get(file)
|
||||
const lineHits = entry ? getLineHits(entry) : {}
|
||||
const executableChangedLines = [...changedLines]
|
||||
.filter(line => !entry || lineHits[line] !== undefined)
|
||||
.sort((a, b) => a - b)
|
||||
|
||||
if (executableChangedLines.length === 0) {
|
||||
diffRows.push({
|
||||
file,
|
||||
moduleName: getModuleName(file),
|
||||
total: 0,
|
||||
covered: 0,
|
||||
uncoveredLines: [],
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const uncoveredLines = executableChangedLines.filter(line => (lineHits[line] ?? 0) === 0)
|
||||
diffRows.push({
|
||||
file,
|
||||
moduleName: getModuleName(file),
|
||||
total: executableChangedLines.length,
|
||||
covered: executableChangedLines.length - uncoveredLines.length,
|
||||
uncoveredLines,
|
||||
})
|
||||
}
|
||||
|
||||
const diffTotals = diffRows.reduce((acc, row) => {
|
||||
acc.total += row.total
|
||||
acc.covered += row.covered
|
||||
return acc
|
||||
}, { total: 0, covered: 0 })
|
||||
|
||||
const diffCoveragePct = percentage(diffTotals.covered, diffTotals.total)
|
||||
const diffFailures = diffRows.filter(row => row.uncoveredLines.length > 0)
|
||||
const overallThresholdFailures = getThresholdFailures(overallCoverage, COMPONENTS_GLOBAL_THRESHOLDS)
|
||||
const moduleCoverageRows = [...moduleCoverageMap.entries()]
|
||||
.map(([moduleName, stats]) => ({
|
||||
moduleName,
|
||||
stats,
|
||||
thresholds: getComponentModuleThreshold(moduleName),
|
||||
}))
|
||||
.map(row => ({
|
||||
...row,
|
||||
failures: row.thresholds ? getThresholdFailures(row.stats, row.thresholds) : [],
|
||||
}))
|
||||
const moduleThresholdFailures = moduleCoverageRows
|
||||
.filter(row => row.failures.length > 0)
|
||||
.flatMap(row => row.failures.map(failure => ({
|
||||
moduleName: row.moduleName,
|
||||
...failure,
|
||||
})))
|
||||
const hasRelevantTestChanges = changedTestFiles.length > 0
|
||||
const missingTestTouch = !hasRelevantTestChanges
|
||||
|
||||
appendSummary(buildSummary({
|
||||
overallCoverage,
|
||||
overallThresholdFailures,
|
||||
moduleCoverageRows,
|
||||
moduleThresholdFailures,
|
||||
diffRows,
|
||||
diffFailures,
|
||||
diffCoveragePct,
|
||||
changedSourceFiles,
|
||||
changedTestFiles,
|
||||
missingTestTouch,
|
||||
}))
|
||||
|
||||
if (diffFailures.length > 0 && process.env.CI) {
|
||||
for (const failure of diffFailures.slice(0, 20)) {
|
||||
const firstLine = failure.uncoveredLines[0] ?? 1
|
||||
console.log(`::error file=${failure.file},line=${firstLine}::Uncovered changed lines: ${formatLineRanges(failure.uncoveredLines)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
overallThresholdFailures.length > 0
|
||||
|| moduleThresholdFailures.length > 0
|
||||
|| diffFailures.length > 0
|
||||
|| (STRICT_TEST_FILE_TOUCH && missingTestTouch)
|
||||
) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function buildSummary({
|
||||
overallCoverage,
|
||||
overallThresholdFailures,
|
||||
moduleCoverageRows,
|
||||
moduleThresholdFailures,
|
||||
diffRows,
|
||||
diffFailures,
|
||||
diffCoveragePct,
|
||||
changedSourceFiles,
|
||||
changedTestFiles,
|
||||
missingTestTouch,
|
||||
}) {
|
||||
const lines = [
|
||||
'### app/components Diff Coverage',
|
||||
'',
|
||||
`Compared \`${baseSha.slice(0, 12)}\` -> \`${headSha.slice(0, 12)}\``,
|
||||
'',
|
||||
`Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``,
|
||||
`Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``,
|
||||
'',
|
||||
'| Check | Result | Details |',
|
||||
'|---|---:|---|',
|
||||
`| Overall tracked lines | ${formatPercent(overallCoverage.lines)} | ${overallCoverage.lines.covered}/${overallCoverage.lines.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.lines}% |`,
|
||||
`| Overall tracked statements | ${formatPercent(overallCoverage.statements)} | ${overallCoverage.statements.covered}/${overallCoverage.statements.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.statements}% |`,
|
||||
`| Overall tracked functions | ${formatPercent(overallCoverage.functions)} | ${overallCoverage.functions.covered}/${overallCoverage.functions.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.functions}% |`,
|
||||
`| Overall tracked branches | ${formatPercent(overallCoverage.branches)} | ${overallCoverage.branches.covered}/${overallCoverage.branches.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.branches}% |`,
|
||||
`| Changed executable lines | ${formatPercent({ covered: diffTotals.covered, total: diffTotals.total })} | ${diffTotals.covered}/${diffTotals.total} |`,
|
||||
'',
|
||||
]
|
||||
|
||||
if (overallThresholdFailures.length > 0) {
|
||||
lines.push('Overall thresholds failed:')
|
||||
for (const failure of overallThresholdFailures)
|
||||
lines.push(`- ${failure.metric}: ${failure.actual.toFixed(2)}% < ${failure.expected}%`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (moduleThresholdFailures.length > 0) {
|
||||
lines.push('Module thresholds failed:')
|
||||
for (const failure of moduleThresholdFailures)
|
||||
lines.push(`- ${failure.moduleName} ${failure.metric}: ${failure.actual.toFixed(2)}% < ${failure.expected}%`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
const moduleRows = moduleCoverageRows
|
||||
.map(({ moduleName, stats, thresholds, failures }) => ({
|
||||
moduleName,
|
||||
lines: percentage(stats.lines.covered, stats.lines.total),
|
||||
statements: percentage(stats.statements.covered, stats.statements.total),
|
||||
functions: percentage(stats.functions.covered, stats.functions.total),
|
||||
branches: percentage(stats.branches.covered, stats.branches.total),
|
||||
thresholds,
|
||||
failures,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.failures.length !== b.failures.length)
|
||||
return b.failures.length - a.failures.length
|
||||
|
||||
return a.lines - b.lines || a.moduleName.localeCompare(b.moduleName)
|
||||
})
|
||||
|
||||
lines.push('<details><summary>Module coverage</summary>')
|
||||
lines.push('')
|
||||
lines.push('| Module | Lines | Statements | Functions | Branches | Thresholds | Status |')
|
||||
lines.push('|---|---:|---:|---:|---:|---|---|')
|
||||
for (const row of moduleRows) {
|
||||
const thresholdLabel = row.thresholds
|
||||
? `L${row.thresholds.lines}/S${row.thresholds.statements}/F${row.thresholds.functions}/B${row.thresholds.branches}`
|
||||
: 'n/a'
|
||||
const status = row.thresholds ? (row.failures.length > 0 ? 'fail' : 'pass') : 'info'
|
||||
lines.push(`| ${row.moduleName} | ${row.lines.toFixed(2)}% | ${row.statements.toFixed(2)}% | ${row.functions.toFixed(2)}% | ${row.branches.toFixed(2)}% | ${thresholdLabel} | ${status} |`)
|
||||
}
|
||||
lines.push('</details>')
|
||||
lines.push('')
|
||||
|
||||
const changedRows = diffRows
|
||||
.filter(row => row.total > 0)
|
||||
.sort((a, b) => {
|
||||
const aPct = percentage(rowCovered(a), rowTotal(a))
|
||||
const bPct = percentage(rowCovered(b), rowTotal(b))
|
||||
return aPct - bPct || a.file.localeCompare(b.file)
|
||||
})
|
||||
|
||||
lines.push('<details><summary>Changed file coverage</summary>')
|
||||
lines.push('')
|
||||
lines.push('| File | Module | Changed executable lines | Coverage | Uncovered lines |')
|
||||
lines.push('|---|---|---:|---:|---|')
|
||||
for (const row of changedRows) {
|
||||
const rowPct = percentage(row.covered, row.total)
|
||||
lines.push(`| ${row.file.replace('web/', '')} | ${row.moduleName} | ${row.total} | ${rowPct.toFixed(2)}% | ${formatLineRanges(row.uncoveredLines)} |`)
|
||||
}
|
||||
lines.push('</details>')
|
||||
lines.push('')
|
||||
|
||||
if (missingTestTouch) {
|
||||
lines.push(`Warning: tracked source files changed under \`web/app/components/\`, but no test files changed under \`web/app/components/**\` or \`web/__tests__/\`.`)
|
||||
if (STRICT_TEST_FILE_TOUCH)
|
||||
lines.push('`STRICT_COMPONENT_TEST_TOUCH=true` is enabled, so this warning fails the check.')
|
||||
lines.push('')
|
||||
}
|
||||
else {
|
||||
lines.push(`Relevant test files changed: ${changedTestFiles.length}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (diffFailures.length > 0) {
|
||||
lines.push('Uncovered changed lines:')
|
||||
for (const row of diffFailures) {
|
||||
lines.push(`- ${row.file.replace('web/', '')}: ${formatLineRanges(row.uncoveredLines)}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push(`Changed source files checked: ${changedSourceFiles.length}`)
|
||||
lines.push(`Changed executable line coverage: ${diffCoveragePct.toFixed(2)}%`)
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function buildSkipSummary(changedExcludedSourceFiles) {
|
||||
const lines = [
|
||||
'### app/components Diff Coverage',
|
||||
'',
|
||||
`Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``,
|
||||
`Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``,
|
||||
'',
|
||||
]
|
||||
|
||||
if (changedExcludedSourceFiles.length > 0) {
|
||||
lines.push('Only excluded component modules or type-only files changed, so diff coverage check was skipped.')
|
||||
lines.push(`Skipped files: ${changedExcludedSourceFiles.length}`)
|
||||
}
|
||||
else {
|
||||
lines.push('No source changes under tracked `web/app/components/`. Diff coverage check skipped.')
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function getChangedFiles(base, head) {
|
||||
const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', `${base}...${head}`, '--', 'web/app/components', 'web/__tests__'])
|
||||
return output
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function getChangedLineMap(base, head) {
|
||||
const diff = execGit(['diff', '--unified=0', '--no-color', '--diff-filter=ACMR', `${base}...${head}`, '--', 'web/app/components'])
|
||||
const lineMap = new Map()
|
||||
let currentFile = null
|
||||
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('+++ b/')) {
|
||||
currentFile = line.slice(6).trim()
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentFile || !isTrackedComponentSourceFile(currentFile))
|
||||
continue
|
||||
|
||||
const match = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
|
||||
if (!match)
|
||||
continue
|
||||
|
||||
const start = Number(match[1])
|
||||
const count = match[2] ? Number(match[2]) : 1
|
||||
if (count === 0)
|
||||
continue
|
||||
|
||||
const linesForFile = lineMap.get(currentFile) ?? new Set()
|
||||
for (let offset = 0; offset < count; offset += 1)
|
||||
linesForFile.add(start + offset)
|
||||
lineMap.set(currentFile, linesForFile)
|
||||
}
|
||||
|
||||
return lineMap
|
||||
}
|
||||
|
||||
function isAnyComponentSourceFile(filePath) {
|
||||
return filePath.startsWith(APP_COMPONENTS_PREFIX)
|
||||
&& /\.(?:ts|tsx)$/.test(filePath)
|
||||
&& !isTestLikePath(filePath)
|
||||
}
|
||||
|
||||
function isTrackedComponentSourceFile(filePath) {
|
||||
return isAnyComponentSourceFile(filePath)
|
||||
&& !isExcludedComponentSourceFile(filePath)
|
||||
}
|
||||
|
||||
function isExcludedComponentSourceFile(filePath) {
|
||||
return isAnyComponentSourceFile(filePath)
|
||||
&& (
|
||||
EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath))
|
||||
|| excludedComponentCoverageFiles.has(filePath)
|
||||
)
|
||||
}
|
||||
|
||||
function isRelevantTestFile(filePath) {
|
||||
return filePath.startsWith(SHARED_TEST_PREFIX)
|
||||
|| (filePath.startsWith(APP_COMPONENTS_PREFIX) && isTestLikePath(filePath) && !isExcludedComponentTestFile(filePath))
|
||||
}
|
||||
|
||||
function isExcludedComponentTestFile(filePath) {
|
||||
if (!filePath.startsWith(APP_COMPONENTS_PREFIX))
|
||||
return false
|
||||
|
||||
return EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath))
|
||||
}
|
||||
|
||||
function isTestLikePath(filePath) {
|
||||
return /(?:^|\/)__tests__\//.test(filePath)
|
||||
|| /(?:^|\/)__mocks__\//.test(filePath)
|
||||
|| /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath)
|
||||
|| /\.stories\.(?:ts|tsx)$/.test(filePath)
|
||||
|| /\.d\.ts$/.test(filePath)
|
||||
}
|
||||
|
||||
function getCoverageStats(entry) {
|
||||
const lineHits = getLineHits(entry)
|
||||
const statementHits = Object.values(entry.s ?? {})
|
||||
const functionHits = Object.values(entry.f ?? {})
|
||||
const branchHits = Object.values(entry.b ?? {}).flat()
|
||||
|
||||
return {
|
||||
lines: {
|
||||
covered: Object.values(lineHits).filter(count => count > 0).length,
|
||||
total: Object.keys(lineHits).length,
|
||||
},
|
||||
statements: {
|
||||
covered: statementHits.filter(count => count > 0).length,
|
||||
total: statementHits.length,
|
||||
},
|
||||
functions: {
|
||||
covered: functionHits.filter(count => count > 0).length,
|
||||
total: functionHits.length,
|
||||
},
|
||||
branches: {
|
||||
covered: branchHits.filter(count => count > 0).length,
|
||||
total: branchHits.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getLineHits(entry) {
|
||||
if (entry.l && Object.keys(entry.l).length > 0)
|
||||
return entry.l
|
||||
|
||||
const lineHits = {}
|
||||
for (const [statementId, statement] of Object.entries(entry.statementMap ?? {})) {
|
||||
const line = statement?.start?.line
|
||||
if (!line)
|
||||
continue
|
||||
|
||||
const hits = entry.s?.[statementId] ?? 0
|
||||
const previous = lineHits[line]
|
||||
lineHits[line] = previous === undefined ? hits : Math.max(previous, hits)
|
||||
}
|
||||
|
||||
return lineHits
|
||||
}
|
||||
|
||||
function sumCoverageStats(rows) {
|
||||
const total = createEmptyCoverageStats()
|
||||
for (const row of rows)
|
||||
addCoverageStats(total, row)
|
||||
return total
|
||||
}
|
||||
|
||||
function mergeCoverageStats(map, moduleName, stats) {
|
||||
const existing = map.get(moduleName) ?? createEmptyCoverageStats()
|
||||
addCoverageStats(existing, stats)
|
||||
map.set(moduleName, existing)
|
||||
}
|
||||
|
||||
function addCoverageStats(target, source) {
|
||||
for (const metric of ['lines', 'statements', 'functions', 'branches']) {
|
||||
target[metric].covered += source[metric].covered
|
||||
target[metric].total += source[metric].total
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyCoverageStats() {
|
||||
return {
|
||||
lines: { covered: 0, total: 0 },
|
||||
statements: { covered: 0, total: 0 },
|
||||
functions: { covered: 0, total: 0 },
|
||||
branches: { covered: 0, total: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function getThresholdFailures(stats, thresholds) {
|
||||
const failures = []
|
||||
for (const metric of ['lines', 'statements', 'functions', 'branches']) {
|
||||
const actual = percentage(stats[metric].covered, stats[metric].total)
|
||||
const expected = thresholds[metric]
|
||||
if (actual < expected) {
|
||||
failures.push({
|
||||
metric,
|
||||
actual,
|
||||
expected,
|
||||
})
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
function getModuleName(filePath) {
|
||||
const relativePath = filePath.slice(APP_COMPONENTS_PREFIX.length)
|
||||
if (!relativePath)
|
||||
return '(root)'
|
||||
|
||||
const segments = relativePath.split('/')
|
||||
return segments.length === 1 ? '(root)' : segments[0]
|
||||
}
|
||||
|
||||
function normalizeToRepoRelative(filePath) {
|
||||
if (!filePath)
|
||||
return ''
|
||||
|
||||
if (filePath.startsWith(APP_COMPONENTS_PREFIX) || filePath.startsWith(SHARED_TEST_PREFIX))
|
||||
return filePath
|
||||
|
||||
if (filePath.startsWith(APP_COMPONENTS_COVERAGE_PREFIX))
|
||||
return `web/${filePath}`
|
||||
|
||||
const absolutePath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(webRoot, filePath)
|
||||
|
||||
return path.relative(repoRoot, absolutePath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function formatLineRanges(lines) {
|
||||
if (!lines || lines.length === 0)
|
||||
return ''
|
||||
|
||||
const ranges = []
|
||||
let start = lines[0]
|
||||
let end = lines[0]
|
||||
|
||||
for (let index = 1; index < lines.length; index += 1) {
|
||||
const current = lines[index]
|
||||
if (current === end + 1) {
|
||||
end = current
|
||||
continue
|
||||
}
|
||||
|
||||
ranges.push(start === end ? `${start}` : `${start}-${end}`)
|
||||
start = current
|
||||
end = current
|
||||
}
|
||||
|
||||
ranges.push(start === end ? `${start}` : `${start}-${end}`)
|
||||
return ranges.join(', ')
|
||||
}
|
||||
|
||||
function percentage(covered, total) {
|
||||
if (total === 0)
|
||||
return 100
|
||||
return (covered / total) * 100
|
||||
}
|
||||
|
||||
function formatPercent(metric) {
|
||||
return `${percentage(metric.covered, metric.total).toFixed(2)}%`
|
||||
}
|
||||
|
||||
function appendSummary(lines) {
|
||||
const content = `${lines.join('\n')}\n`
|
||||
if (process.env.GITHUB_STEP_SUMMARY)
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content)
|
||||
console.log(content)
|
||||
}
|
||||
|
||||
function execGit(args) {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}
|
||||
|
||||
function repoRootFromCwd() {
|
||||
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function rowCovered(row) {
|
||||
return row.covered
|
||||
}
|
||||
|
||||
function rowTotal(row) {
|
||||
return row.total
|
||||
}
|
||||
316
web/scripts/component-coverage-filters.mjs
Normal file
316
web/scripts/component-coverage-filters.mjs
Normal file
@ -0,0 +1,316 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
|
||||
const TS_TSX_FILE_PATTERN = /\.(?:ts|tsx)$/
|
||||
const TYPE_COVERAGE_EXCLUDE_BASENAMES = new Set([
|
||||
'type',
|
||||
'types',
|
||||
'declarations',
|
||||
])
|
||||
const GENERATED_FILE_COMMENT_PATTERNS = [
|
||||
/@generated/i,
|
||||
/\bauto-?generated\b/i,
|
||||
/\bgenerated by\b/i,
|
||||
/\bgenerate by\b/i,
|
||||
/\bdo not edit\b/i,
|
||||
/\bdon not edit\b/i,
|
||||
]
|
||||
const PARSER_OPTIONS = {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
}
|
||||
|
||||
const collectedExcludedFilesCache = new Map()
|
||||
|
||||
export const COMPONENT_COVERAGE_EXCLUDE_LABEL = 'type-only files, pure barrel files, generated files, pure static files'
|
||||
|
||||
export function isTypeCoverageExcludedComponentFile(filePath) {
|
||||
return TYPE_COVERAGE_EXCLUDE_BASENAMES.has(getPathBaseNameWithoutExtension(filePath))
|
||||
}
|
||||
|
||||
export function getComponentCoverageExclusionReasons(filePath, sourceCode) {
|
||||
if (!isEligibleComponentSourceFilePath(filePath))
|
||||
return []
|
||||
|
||||
const reasons = []
|
||||
if (isTypeCoverageExcludedComponentFile(filePath))
|
||||
reasons.push('type-only')
|
||||
|
||||
if (typeof sourceCode !== 'string' || sourceCode.length === 0)
|
||||
return reasons
|
||||
|
||||
if (isGeneratedComponentFile(sourceCode))
|
||||
reasons.push('generated')
|
||||
|
||||
const ast = parseComponentFile(sourceCode)
|
||||
if (!ast)
|
||||
return reasons
|
||||
|
||||
if (isPureBarrelComponentFile(ast))
|
||||
reasons.push('pure-barrel')
|
||||
else if (isPureStaticComponentFile(ast))
|
||||
reasons.push('pure-static')
|
||||
|
||||
return reasons
|
||||
}
|
||||
|
||||
export function collectComponentCoverageExcludedFiles(rootDir, options = {}) {
|
||||
const normalizedRootDir = path.resolve(rootDir)
|
||||
const pathPrefix = normalizePathPrefix(options.pathPrefix ?? '')
|
||||
const cacheKey = `${normalizedRootDir}::${pathPrefix}`
|
||||
const cached = collectedExcludedFilesCache.get(cacheKey)
|
||||
if (cached)
|
||||
return cached
|
||||
|
||||
const files = []
|
||||
walkComponentFiles(normalizedRootDir, (absolutePath) => {
|
||||
const relativePath = path.relative(normalizedRootDir, absolutePath).split(path.sep).join('/')
|
||||
const prefixedPath = pathPrefix ? `${pathPrefix}/${relativePath}` : relativePath
|
||||
const sourceCode = fs.readFileSync(absolutePath, 'utf8')
|
||||
if (getComponentCoverageExclusionReasons(prefixedPath, sourceCode).length > 0)
|
||||
files.push(prefixedPath)
|
||||
})
|
||||
|
||||
files.sort((a, b) => a.localeCompare(b))
|
||||
collectedExcludedFilesCache.set(cacheKey, files)
|
||||
return files
|
||||
}
|
||||
|
||||
function normalizePathPrefix(pathPrefix) {
|
||||
return pathPrefix.replace(/\\/g, '/').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function walkComponentFiles(currentDir, onFile) {
|
||||
if (!fs.existsSync(currentDir))
|
||||
return
|
||||
|
||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(currentDir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === '__tests__' || entry.name === '__mocks__')
|
||||
continue
|
||||
walkComponentFiles(entryPath, onFile)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isEligibleComponentSourceFilePath(entry.name))
|
||||
continue
|
||||
|
||||
onFile(entryPath)
|
||||
}
|
||||
}
|
||||
|
||||
function isEligibleComponentSourceFilePath(filePath) {
|
||||
return TS_TSX_FILE_PATTERN.test(filePath)
|
||||
&& !isTestLikePath(filePath)
|
||||
}
|
||||
|
||||
function isTestLikePath(filePath) {
|
||||
return /(?:^|\/)__tests__\//.test(filePath)
|
||||
|| /(?:^|\/)__mocks__\//.test(filePath)
|
||||
|| /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath)
|
||||
|| /\.stories\.(?:ts|tsx)$/.test(filePath)
|
||||
|| /\.d\.ts$/.test(filePath)
|
||||
}
|
||||
|
||||
function getPathBaseNameWithoutExtension(filePath) {
|
||||
if (!filePath)
|
||||
return ''
|
||||
|
||||
const normalizedPath = filePath.replace(/\\/g, '/')
|
||||
const fileName = normalizedPath.split('/').pop() ?? ''
|
||||
return fileName.replace(TS_TSX_FILE_PATTERN, '')
|
||||
}
|
||||
|
||||
function isGeneratedComponentFile(sourceCode) {
|
||||
const leadingText = sourceCode.split('\n').slice(0, 5).join('\n')
|
||||
return GENERATED_FILE_COMMENT_PATTERNS.some(pattern => pattern.test(leadingText))
|
||||
}
|
||||
|
||||
function parseComponentFile(sourceCode) {
|
||||
try {
|
||||
return tsParser.parse(sourceCode, PARSER_OPTIONS)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isPureBarrelComponentFile(ast) {
|
||||
let hasRuntimeReExports = false
|
||||
|
||||
for (const statement of ast.body) {
|
||||
if (statement.type === 'ExportAllDeclaration') {
|
||||
hasRuntimeReExports = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (statement.type === 'ExportNamedDeclaration' && statement.source) {
|
||||
hasRuntimeReExports = hasRuntimeReExports || statement.exportKind !== 'type'
|
||||
continue
|
||||
}
|
||||
|
||||
if (statement.type === 'TSInterfaceDeclaration' || statement.type === 'TSTypeAliasDeclaration')
|
||||
continue
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return hasRuntimeReExports
|
||||
}
|
||||
|
||||
function isPureStaticComponentFile(ast) {
|
||||
const importedStaticBindings = collectImportedStaticBindings(ast.body)
|
||||
const staticBindings = new Set()
|
||||
let hasRuntimeValue = false
|
||||
|
||||
for (const statement of ast.body) {
|
||||
if (statement.type === 'ImportDeclaration')
|
||||
continue
|
||||
|
||||
if (statement.type === 'TSInterfaceDeclaration' || statement.type === 'TSTypeAliasDeclaration')
|
||||
continue
|
||||
|
||||
if (statement.type === 'ExportAllDeclaration')
|
||||
return false
|
||||
|
||||
if (statement.type === 'ExportNamedDeclaration' && statement.source)
|
||||
return false
|
||||
|
||||
if (statement.type === 'ExportDefaultDeclaration') {
|
||||
if (!isStaticExpression(statement.declaration, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
hasRuntimeValue = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (statement.type === 'ExportNamedDeclaration' && statement.declaration) {
|
||||
if (!handleStaticDeclaration(statement.declaration, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
hasRuntimeValue = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (statement.type === 'ExportNamedDeclaration' && statement.specifiers.length > 0) {
|
||||
const allStaticSpecifiers = statement.specifiers.every((specifier) => {
|
||||
if (specifier.type !== 'ExportSpecifier' || specifier.exportKind === 'type')
|
||||
return false
|
||||
return specifier.local.type === 'Identifier' && staticBindings.has(specifier.local.name)
|
||||
})
|
||||
if (!allStaticSpecifiers)
|
||||
return false
|
||||
hasRuntimeValue = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (!handleStaticDeclaration(statement, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
hasRuntimeValue = true
|
||||
}
|
||||
|
||||
return hasRuntimeValue
|
||||
}
|
||||
|
||||
function handleStaticDeclaration(statement, staticBindings, importedStaticBindings) {
|
||||
if (statement.type !== 'VariableDeclaration' || statement.kind !== 'const')
|
||||
return false
|
||||
|
||||
for (const declarator of statement.declarations) {
|
||||
if (declarator.id.type !== 'Identifier' || !declarator.init)
|
||||
return false
|
||||
|
||||
if (!isStaticExpression(declarator.init, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
|
||||
staticBindings.add(declarator.id.name)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function collectImportedStaticBindings(statements) {
|
||||
const importedBindings = new Set()
|
||||
|
||||
for (const statement of statements) {
|
||||
if (statement.type !== 'ImportDeclaration')
|
||||
continue
|
||||
|
||||
const importSource = String(statement.source.value ?? '')
|
||||
const isTypeLikeSource = isTypeCoverageExcludedComponentFile(importSource)
|
||||
const importIsStatic = statement.importKind === 'type' || isTypeLikeSource
|
||||
if (!importIsStatic)
|
||||
continue
|
||||
|
||||
for (const specifier of statement.specifiers) {
|
||||
if (specifier.local?.type === 'Identifier')
|
||||
importedBindings.add(specifier.local.name)
|
||||
}
|
||||
}
|
||||
|
||||
return importedBindings
|
||||
}
|
||||
|
||||
function isStaticExpression(node, staticBindings, importedStaticBindings) {
|
||||
switch (node.type) {
|
||||
case 'Literal':
|
||||
return true
|
||||
case 'Identifier':
|
||||
return staticBindings.has(node.name) || importedStaticBindings.has(node.name)
|
||||
case 'TemplateLiteral':
|
||||
return node.expressions.every(expression => isStaticExpression(expression, staticBindings, importedStaticBindings))
|
||||
case 'ArrayExpression':
|
||||
return node.elements.every(element => !element || isStaticExpression(element, staticBindings, importedStaticBindings))
|
||||
case 'ObjectExpression':
|
||||
return node.properties.every((property) => {
|
||||
if (property.type === 'SpreadElement')
|
||||
return isStaticExpression(property.argument, staticBindings, importedStaticBindings)
|
||||
|
||||
if (property.type !== 'Property' || property.method)
|
||||
return false
|
||||
|
||||
if (property.computed && !isStaticExpression(property.key, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
|
||||
if (property.shorthand)
|
||||
return property.value.type === 'Identifier' && staticBindings.has(property.value.name)
|
||||
|
||||
return isStaticExpression(property.value, staticBindings, importedStaticBindings)
|
||||
})
|
||||
case 'UnaryExpression':
|
||||
return isStaticExpression(node.argument, staticBindings, importedStaticBindings)
|
||||
case 'BinaryExpression':
|
||||
case 'LogicalExpression':
|
||||
return isStaticExpression(node.left, staticBindings, importedStaticBindings)
|
||||
&& isStaticExpression(node.right, staticBindings, importedStaticBindings)
|
||||
case 'ConditionalExpression':
|
||||
return isStaticExpression(node.test, staticBindings, importedStaticBindings)
|
||||
&& isStaticExpression(node.consequent, staticBindings, importedStaticBindings)
|
||||
&& isStaticExpression(node.alternate, staticBindings, importedStaticBindings)
|
||||
case 'MemberExpression':
|
||||
return isStaticMemberExpression(node, staticBindings, importedStaticBindings)
|
||||
case 'ChainExpression':
|
||||
return isStaticExpression(node.expression, staticBindings, importedStaticBindings)
|
||||
case 'TSAsExpression':
|
||||
case 'TSSatisfiesExpression':
|
||||
case 'TSTypeAssertion':
|
||||
case 'TSNonNullExpression':
|
||||
return isStaticExpression(node.expression, staticBindings, importedStaticBindings)
|
||||
case 'ParenthesizedExpression':
|
||||
return isStaticExpression(node.expression, staticBindings, importedStaticBindings)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isStaticMemberExpression(node, staticBindings, importedStaticBindings) {
|
||||
if (!isStaticExpression(node.object, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
|
||||
if (!node.computed)
|
||||
return node.property.type === 'Identifier'
|
||||
|
||||
return isStaticExpression(node.property, staticBindings, importedStaticBindings)
|
||||
}
|
||||
128
web/scripts/components-coverage-thresholds.mjs
Normal file
128
web/scripts/components-coverage-thresholds.mjs
Normal file
@ -0,0 +1,128 @@
|
||||
// Floors were set from the app/components baseline captured on 2026-03-13,
|
||||
// with a small buffer to avoid CI noise on existing code.
|
||||
export const EXCLUDED_COMPONENT_MODULES = new Set([
|
||||
'devtools',
|
||||
'provider',
|
||||
])
|
||||
|
||||
export const COMPONENTS_GLOBAL_THRESHOLDS = {
|
||||
lines: 58,
|
||||
statements: 58,
|
||||
functions: 58,
|
||||
branches: 54,
|
||||
}
|
||||
|
||||
export const COMPONENT_MODULE_THRESHOLDS = {
|
||||
'app': {
|
||||
lines: 45,
|
||||
statements: 45,
|
||||
functions: 50,
|
||||
branches: 35,
|
||||
},
|
||||
'app-sidebar': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
},
|
||||
'apps': {
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
functions: 85,
|
||||
branches: 80,
|
||||
},
|
||||
'base': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 90,
|
||||
branches: 95,
|
||||
},
|
||||
'billing': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 95,
|
||||
},
|
||||
'custom': {
|
||||
lines: 70,
|
||||
statements: 70,
|
||||
functions: 70,
|
||||
branches: 80,
|
||||
},
|
||||
'datasets': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
},
|
||||
'develop': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
},
|
||||
'explore': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 85,
|
||||
},
|
||||
'goto-anything': {
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
functions: 90,
|
||||
branches: 90,
|
||||
},
|
||||
'header': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 95,
|
||||
},
|
||||
'plugins': {
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
functions: 90,
|
||||
branches: 85,
|
||||
},
|
||||
'rag-pipeline': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
},
|
||||
'share': {
|
||||
lines: 15,
|
||||
statements: 15,
|
||||
functions: 20,
|
||||
branches: 20,
|
||||
},
|
||||
'signin': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 95,
|
||||
},
|
||||
'tools': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 90,
|
||||
branches: 90,
|
||||
},
|
||||
'workflow': {
|
||||
lines: 15,
|
||||
statements: 15,
|
||||
functions: 10,
|
||||
branches: 10,
|
||||
},
|
||||
'workflow-app': {
|
||||
lines: 20,
|
||||
statements: 20,
|
||||
functions: 25,
|
||||
branches: 15,
|
||||
},
|
||||
}
|
||||
|
||||
export function getComponentModuleThreshold(moduleName) {
|
||||
return COMPONENT_MODULE_THRESHOLDS[moduleName] ?? null
|
||||
}
|
||||
@ -1,22 +1,29 @@
|
||||
/// <reference types="vitest/config" />
|
||||
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import vinext from 'vinext'
|
||||
import { defineConfig } from 'vite'
|
||||
import Inspect from 'vite-plugin-inspect'
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector'
|
||||
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr'
|
||||
import { collectComponentCoverageExcludedFiles } from './scripts/component-coverage-filters.mjs'
|
||||
import { EXCLUDED_COMPONENT_MODULES } from './scripts/components-coverage-thresholds.mjs'
|
||||
|
||||
const projectRoot = path.dirname(fileURLToPath(import.meta.url))
|
||||
const isCI = !!process.env.CI
|
||||
const coverageScope = process.env.VITEST_COVERAGE_SCOPE
|
||||
const browserInitializerInjectTarget = path.resolve(projectRoot, 'app/components/browser-initializer.tsx')
|
||||
const excludedAppComponentsCoveragePaths = [...EXCLUDED_COMPONENT_MODULES]
|
||||
.map(moduleName => `app/components/${moduleName}/**`)
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isTest = mode === 'test'
|
||||
const isStorybook = process.env.STORYBOOK === 'true'
|
||||
|| process.argv.some(arg => arg.toLowerCase().includes('storybook'))
|
||||
const isAppComponentsCoverage = coverageScope === 'app-components'
|
||||
const excludedComponentCoverageFiles = isAppComponentsCoverage
|
||||
? collectComponentCoverageExcludedFiles(path.join(projectRoot, 'app/components'), { pathPrefix: 'app/components' })
|
||||
: []
|
||||
|
||||
return {
|
||||
plugins: isTest
|
||||
@ -82,6 +89,21 @@ export default defineConfig(({ mode }) => {
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'],
|
||||
...(isAppComponentsCoverage
|
||||
? {
|
||||
include: ['app/components/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'app/components/**/*.d.ts',
|
||||
'app/components/**/*.spec.{ts,tsx}',
|
||||
'app/components/**/*.test.{ts,tsx}',
|
||||
'app/components/**/__tests__/**',
|
||||
'app/components/**/__mocks__/**',
|
||||
'app/components/**/*.stories.{ts,tsx}',
|
||||
...excludedComponentCoverageFiles,
|
||||
...excludedAppComponentsCoveragePaths,
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user