feat: add Husky hooks and fix provider tutorial UI/logic

- Add Husky pre-commit and pre-push hooks for linting, type checking, and build validation
- Fix visual hierarchy bug in the provider onboarding tutorial
- Remove feedback modal
- Move MinIO bucket creation logic to before app startup
- Wire MiniMax audio through voice generation pipeline
- Fix scene insertion issues
- Fix portal tutorial modal and harden panel variant task flow
This commit is contained in:
saturn
2026-03-09 02:42:35 +08:00
parent 881ed44996
commit fba480ae6e
34 changed files with 721 additions and 247 deletions

View File

@@ -0,0 +1,169 @@
import * as React from 'react'
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { UseProviderCardStateResult } from '@/app/[locale]/profile/components/api-config/provider-card/hooks/useProviderCardState'
import { ProviderCardShell } from '@/app/[locale]/profile/components/api-config/provider-card/ProviderCardShell'
import type { ProviderTutorial } from '@/app/[locale]/profile/components/api-config/types'
const portalMocks = vi.hoisted(() => {
return {
currentPortalTarget: null as unknown,
createPortalMock: vi.fn((node: React.ReactNode, target: unknown) => {
const targetLabel = target === portalMocks.currentPortalTarget ? 'body' : 'unknown'
return createElement('div', { 'data-portal-target': targetLabel }, node)
}),
}
})
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom')
return {
...actual,
createPortal: portalMocks.createPortalMock,
}
})
function createState(tutorial: ProviderTutorial): UseProviderCardStateResult {
return {
providerKey: 'ark',
isPresetProvider: true,
showBaseUrlEdit: false,
tutorial,
groupedModels: {},
hasModels: false,
isEditing: false,
isEditingUrl: false,
showKey: false,
tempKey: '',
tempUrl: '',
showTutorial: true,
showAddForm: null,
newModel: {
name: '',
modelId: '',
enableCustomPricing: false,
priceInput: '',
priceOutput: '',
basePrice: '',
optionPricesJson: '',
},
batchMode: false,
editingModelId: null,
editModel: {
name: '',
modelId: '',
enableCustomPricing: false,
priceInput: '',
priceOutput: '',
basePrice: '',
optionPricesJson: '',
},
maskedKey: '',
isPresetModel: () => false,
isDefaultModel: () => false,
setShowKey: () => undefined,
setShowTutorial: () => undefined,
setShowAddForm: () => undefined,
setBatchMode: () => undefined,
setNewModel: () => undefined,
setEditModel: () => undefined,
setTempKey: () => undefined,
setTempUrl: () => undefined,
startEditKey: () => undefined,
startEditUrl: () => undefined,
handleSaveKey: () => Promise.resolve(),
handleCancelEdit: () => undefined,
handleSaveUrl: () => undefined,
handleCancelUrlEdit: () => undefined,
handleEditModel: () => undefined,
handleCancelEditModel: () => undefined,
handleSaveModel: () => Promise.resolve(),
handleAddModel: () => Promise.resolve(),
handleCancelAdd: () => undefined,
needsCustomPricing: false,
keyTestStatus: 'idle',
keyTestSteps: [],
handleForceSaveKey: () => undefined,
handleTestOnly: () => undefined,
handleDismissTest: () => undefined,
isModelSavePending: false,
assistantEnabled: false,
isAssistantOpen: false,
assistantSavedEvent: null,
assistantChat: {
messages: [],
input: '',
status: 'ready',
pending: false,
error: undefined,
setInput: () => undefined,
send: async () => undefined,
clear: () => undefined,
},
openAssistant: () => undefined,
closeAssistant: () => undefined,
handleAssistantSend: () => Promise.resolve(),
}
}
describe('ProviderCardShell tutorial modal', () => {
afterEach(() => {
vi.clearAllMocks()
portalMocks.currentPortalTarget = null
Reflect.deleteProperty(globalThis, 'React')
Reflect.deleteProperty(globalThis, 'document')
})
it('mounts the tutorial modal through a portal to document.body', () => {
const fakeDocument = {
body: { nodeName: 'BODY' },
}
Reflect.set(globalThis, 'React', React)
portalMocks.currentPortalTarget = fakeDocument.body
Reflect.set(globalThis, 'document', fakeDocument)
const tutorial: ProviderTutorial = {
providerId: 'ark',
steps: [
{
text: 'ark_step1',
url: 'https://example.com/ark-key',
},
],
}
const state = createState(tutorial)
const t = (key: string): string => {
if (key === 'tutorial.button') return '开通教程'
if (key === 'tutorial.title') return '开通教程'
if (key === 'tutorial.subtitle') return '按照以下步骤完成配置'
if (key === 'tutorial.steps.ark_step1') return '进入控制台创建 API Key'
if (key === 'tutorial.openLink') return '点击打开'
if (key === 'tutorial.close') return '关闭'
return key
}
const html = renderToStaticMarkup(
createElement(
ProviderCardShell,
{
provider: {
id: 'ark',
name: '阿里云百炼',
hasApiKey: true,
},
onDeleteProvider: () => undefined,
t,
state,
},
createElement('div', null, 'provider-body'),
),
)
expect(portalMocks.createPortalMock).toHaveBeenCalledTimes(1)
expect(portalMocks.createPortalMock.mock.calls[0]?.[1]).toBe(fakeDocument.body)
expect(html).toContain('data-portal-target="body"')
expect(html).toContain('进入控制台创建 API Key')
expect(html).toContain('href="https://example.com/ark-key"')
})
})

View File

@@ -0,0 +1,72 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import LLMStageStreamCard from '@/components/llm-console/LLMStageStreamCard'
const messages = {
progress: {
status: {
completed: '已完成',
failed: '失败',
processing: '进行中',
queued: '排队中',
pending: '未开始',
},
stageCard: {
stage: '阶段',
realtimeStream: '实时流',
currentStage: '当前阶段',
outputTitle: 'AI 实时输出 · {stage}',
waitingModelOutput: '等待模型输出...',
reasoningNotProvided: '该步骤未返回思考过程',
},
runtime: {
llm: {
processing: '模型处理中...',
},
},
},
} as const
const renderWithIntl = (node: ReactElement) => {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('LLMStageStreamCard error rendering', () => {
it('renders the error without any feedback action entry', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(LLMStageStreamCard, {
title: '内容到剧本',
stages: [{
id: 'story_to_script',
title: '内容到剧本',
status: 'failed',
progress: 0,
}],
activeStageId: 'story_to_script',
outputText: '',
errorMessage: 'Failed to fetch',
}),
)
expect(html).toContain('Failed to fetch')
expect(html).not.toContain('复制错误详情')
expect(html).not.toContain('打开问题反馈表单')
expect(html).not.toContain('Copy error detail')
expect(html).not.toContain('Open feedback form')
})
})

View File

@@ -0,0 +1,115 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import Navbar from '@/components/Navbar'
const useSessionMock = vi.fn()
vi.mock('next-auth/react', () => ({
useSession: () => useSessionMock(),
}))
vi.mock('next/image', () => ({
default: ({ alt, ...props }: { alt: string } & Record<string, unknown>) => createElement('img', { alt, ...props }),
}))
vi.mock('@/components/LanguageSwitcher', () => ({
default: () => createElement('div', null, 'LanguageSwitcher'),
}))
vi.mock('@/hooks/common/useGithubReleaseUpdate', () => ({
useGithubReleaseUpdate: () => ({
currentVersion: '0.3.0',
update: null,
shouldPulse: false,
showModal: false,
openModal: () => undefined,
dismissCurrentUpdate: () => undefined,
checkNow: async () => undefined,
}),
}))
vi.mock('@/i18n/navigation', () => ({
Link: ({
href,
children,
...props
}: {
href: string | { pathname: string }
children: React.ReactNode
} & Record<string, unknown>) => {
const resolvedHref = typeof href === 'string' ? href : href.pathname
return createElement('a', { href: resolvedHref, ...props }, children)
},
}))
const messages = {
nav: {
workspace: '工作区',
assetHub: '资产中心',
profile: '设置中心',
downloadLogs: '下载日志',
signin: '登录',
signup: '注册',
},
common: {
appName: 'waoowaoo',
betaVersion: 'Beta v{version}',
updateNotice: {
openDialog: '打开更新弹窗',
updateTag: '更新',
checkUpdate: '检查更新',
upToDate: '已是最新版本',
},
},
} as const
const renderWithIntl = (node: ReactElement) => {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('Navbar download logs entry', () => {
beforeEach(() => {
useSessionMock.mockReset()
})
it('renders the download logs entry on the far-right action group for signed-in users', () => {
Reflect.set(globalThis, 'React', React)
useSessionMock.mockReturnValue({
data: { user: { name: 'Earth' } },
status: 'authenticated',
})
const html = renderWithIntl(createElement(Navbar))
expect(html).toContain('下载日志')
expect(html).toContain('href="/api/admin/download-logs"')
expect(html).toContain('download=""')
})
it('does not render the download logs entry for signed-out users', () => {
Reflect.set(globalThis, 'React', React)
useSessionMock.mockReturnValue({
data: null,
status: 'unauthenticated',
})
const html = renderWithIntl(createElement(Navbar))
expect(html).not.toContain('下载日志')
expect(html).not.toContain('/api/admin/download-logs')
})
})

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { resolveInsertPanelUserInput } from '@/lib/novel-promotion/insert-panel'
describe('insert panel user input normalization', () => {
it('uses localized default instruction when AI analyze sends empty input', () => {
expect(resolveInsertPanelUserInput({ userInput: '' }, 'zh')).toBe(
'请根据前后镜头自动分析并插入一个自然衔接的新分镜。',
)
expect(resolveInsertPanelUserInput({ userInput: ' ' }, 'en')).toBe(
'Automatically analyze the surrounding panels and insert a naturally connected new panel.',
)
})
it('prefers explicit user input over fallback prompt or default', () => {
expect(resolveInsertPanelUserInput({
userInput: ' 添加一个特写反应镜头 ',
prompt: 'unused prompt',
}, 'zh')).toBe('添加一个特写反应镜头')
})
it('falls back to prompt when userInput is missing', () => {
expect(resolveInsertPanelUserInput({
prompt: ' Insert a pause beat between these panels. ',
}, 'en')).toBe('Insert a pause beat between these panels.')
})
})

View File

@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ensureStorageReady } from '@/lib/storage/bootstrap'
type MockCommand = {
readonly type: 'HeadBucketCommand' | 'CreateBucketCommand'
readonly input: Record<string, unknown>
}
const {
sendMock,
s3ClientMock,
headBucketCommandMock,
createBucketCommandMock,
} = vi.hoisted(() => ({
sendMock: vi.fn<(command: MockCommand) => Promise<unknown>>(),
s3ClientMock: vi.fn(() => ({ send: undefined as unknown })),
headBucketCommandMock: vi.fn((input: Record<string, unknown>): MockCommand => ({
type: 'HeadBucketCommand',
input,
})),
createBucketCommandMock: vi.fn((input: Record<string, unknown>): MockCommand => ({
type: 'CreateBucketCommand',
input,
})),
}))
s3ClientMock.mockImplementation(() => ({ send: sendMock }))
vi.mock('@aws-sdk/client-s3', () => ({
S3Client: s3ClientMock,
HeadBucketCommand: headBucketCommandMock,
CreateBucketCommand: createBucketCommandMock,
}))
describe('storage bootstrap', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.MINIO_ENDPOINT = 'http://127.0.0.1:9000'
process.env.MINIO_REGION = 'us-east-1'
process.env.MINIO_BUCKET = 'waoowaoo'
process.env.MINIO_ACCESS_KEY = 'minioadmin'
process.env.MINIO_SECRET_KEY = 'minioadmin'
process.env.MINIO_FORCE_PATH_STYLE = 'true'
})
it('skips startup storage initialization when STORAGE_TYPE=local', async () => {
await expect(ensureStorageReady({ storageType: 'local' })).resolves.toBe('skipped')
expect(s3ClientMock).not.toHaveBeenCalled()
})
it('verifies the MinIO bucket during startup when it already exists', async () => {
sendMock.mockResolvedValueOnce({})
await expect(ensureStorageReady({ storageType: 'minio' })).resolves.toBe('existing')
expect(s3ClientMock).toHaveBeenCalledWith({
endpoint: 'http://127.0.0.1:9000',
region: 'us-east-1',
forcePathStyle: true,
credentials: {
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin',
},
})
expect(headBucketCommandMock).toHaveBeenCalledWith({ Bucket: 'waoowaoo' })
expect(createBucketCommandMock).not.toHaveBeenCalled()
})
it('creates the MinIO bucket during startup when HeadBucket reports it missing', async () => {
sendMock
.mockRejectedValueOnce(Object.assign(new Error('missing bucket'), {
name: 'NotFound',
$metadata: { httpStatusCode: 404 },
}))
.mockResolvedValueOnce({})
await expect(ensureStorageReady({ storageType: 'minio' })).resolves.toBe('created')
expect(headBucketCommandMock).toHaveBeenCalledWith({ Bucket: 'waoowaoo' })
expect(createBucketCommandMock).toHaveBeenCalledWith({ Bucket: 'waoowaoo' })
expect(sendMock).toHaveBeenNthCalledWith(2, {
type: 'CreateBucketCommand',
input: { Bucket: 'waoowaoo' },
})
})
it('fails fast when MinIO returns a non-bucket-missing error at startup', async () => {
const startupError = Object.assign(new Error('MinIO unavailable'), {
name: 'TimeoutError',
$metadata: { httpStatusCode: 503 },
})
sendMock.mockRejectedValueOnce(startupError)
await expect(ensureStorageReady({ storageType: 'minio' })).rejects.toBe(startupError)
expect(createBucketCommandMock).not.toHaveBeenCalled()
})
})

View File

@@ -1,11 +0,0 @@
import { describe, it, expect } from 'vitest'
import { USER_FEEDBACK_FORM_URL } from '@/lib/feedback'
describe('USER_FEEDBACK_FORM_URL', () => {
it('should point to the Feishu feedback form', () => {
expect(USER_FEEDBACK_FORM_URL).toBe(
'https://ox2p5ferjnr.feishu.cn/share/base/form/shrcno200ar2SsTgGiSDYHLmNuc',
)
})
})