Merge branch 'feat/model-plugins-implementing' into deploy/dev

This commit is contained in:
yyh
2026-03-13 21:49:51 +08:00
27 changed files with 2703 additions and 553 deletions

View 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')
})
})

View File

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

View File

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

View File

@ -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: {

View File

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

View File

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

View File

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

View File

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

View 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()
})
})
})

View File

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

View File

@ -20,6 +20,8 @@ const config: KnipConfig = {
'@iconify-json/*',
'@storybook/addon-onboarding',
'@voidzero-dev/vite-plus-core',
],
rules: {
files: 'warn',

View File

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

File diff suppressed because it is too large Load Diff

View 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
}

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

View 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
}

View File

@ -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,
],
}
: {}),
},
},
}