ci(engineering): Gitea Actions 门禁 + 前端测试设施 + 版本注入修复
- .gitea/workflows/ci.yml:后端 gofmt/vet/test + 前端 lint/test/build 双 job 门禁 - vitest + @testing-library + jsdom 落地,14 个命脉用例:useListQuery 乱序丢弃/卸载中止、 http.ts 401 单飞刷新+队列重放+失败登出+silent 直拒、审计防抖、StepDeploy 部署目标、query-utils - 版本注入断裂修复:main 补 buildTime/gitCommit 符号,build.sh/Dockerfile 注入 main.version, /health 返回真实版本,前端 package.json 版本对齐 2.0.0 - gofmt 清零(8 个存量未格式化文件)
This commit is contained in:
1 parent
2975452c21
commit
4f74fe6c3f
23 files changed
+1399
-77
No files matched your search
@@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/** 登录态命脉:401 单飞刷新 + 队列重放。改坏它 = 全站不可用。
|
||||
* http.ts 有模块级 isRefreshing 状态,每个用例 resetModules 重新加载。 */
|
||||
async function freshHttp(tokenState: { access: string | null; refresh: string | null }) {
|
||||
vi.resetModules()
|
||||
vi.doMock('@/lib/auth-storage', () => ({
|
||||
getAccessToken: () => tokenState.access,
|
||||
getRefreshToken: () => tokenState.refresh,
|
||||
saveTokenPair: vi.fn((pair: { accessToken: string }) => { tokenState.access = pair.accessToken }),
|
||||
clearAuth: vi.fn(),
|
||||
}))
|
||||
const mod = await import('./http')
|
||||
return { http: mod.http, tokenState }
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return {
|
||||
status,
|
||||
ok: status < 400,
|
||||
headers: { get: (name: string) => (name.toLowerCase() === 'content-type' ? 'application/json' : null) },
|
||||
text: async () => JSON.stringify(body),
|
||||
json: async () => body,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
describe('http 401 单飞刷新', () => {
|
||||
const fakeLocation = { href: 'http://localhost/logs', pathname: '/logs', search: '' }
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'location', { value: fakeLocation, writable: true, configurable: true })
|
||||
vi.stubGlobal('fetch', vi.fn())
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.doUnmock('@/lib/auth-storage')
|
||||
})
|
||||
|
||||
it('三个并发 401 只刷新一次,全部以新 token 重放成功', async () => {
|
||||
const state = { access: 'old', refresh: 'r1' }
|
||||
const { http } = await freshHttp(state)
|
||||
const fetchMock = vi.mocked(fetch)
|
||||
fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
if (url.includes('/auth/refresh')) return jsonResponse({ code: 0, data: { accessToken: 'new', refreshToken: 'r2', expiresAt: 1 } })
|
||||
const auth = (init?.headers as Record<string, string>)?.Authorization ?? ''
|
||||
if (auth.includes('old')) return jsonResponse({ code: 401, message: 'expired' }, 401)
|
||||
return jsonResponse({ code: 0, message: 'ok', data: { fine: auth.includes('new') } })
|
||||
})
|
||||
const results = await Promise.all([http.get('/x1'), http.get('/x2'), http.get('/x3')])
|
||||
const refreshCalls = fetchMock.mock.calls.filter(([u]) => String(u).includes('/auth/refresh'))
|
||||
expect(refreshCalls).toHaveLength(1)
|
||||
expect(results.map(r => (r as { fine: boolean }).fine)).toEqual([true, true, true])
|
||||
})
|
||||
|
||||
it('refresh 失败:清凭证并跳转登录', async () => {
|
||||
const state = { access: 'old', refresh: 'bad' }
|
||||
const { http } = await freshHttp(state)
|
||||
vi.mocked(fetch).mockImplementation(async (input: RequestInfo | URL) => {
|
||||
if (String(input).includes('/auth/refresh')) return jsonResponse({ code: 401, message: 'no' }, 401)
|
||||
return jsonResponse({ code: 401, message: 'expired' }, 401)
|
||||
})
|
||||
await expect(http.get('/x')).rejects.toThrow()
|
||||
expect(fakeLocation.href).toContain('/login?redirect=')
|
||||
})
|
||||
|
||||
it('silent 请求 401 直接失败,不触发刷新', async () => {
|
||||
const state = { access: 'old', refresh: 'r1' }
|
||||
const { http } = await freshHttp(state)
|
||||
vi.mocked(fetch).mockImplementation(async () => jsonResponse({ code: 401, message: 'expired' }, 401))
|
||||
await expect(http.get('/x', { silent: true })).rejects.toThrow()
|
||||
expect(vi.mocked(fetch).mock.calls.filter(([u]) => String(u).includes('/auth/refresh'))).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useDebouncedValue } from './useDebouncedValue'
|
||||
|
||||
describe('useDebouncedValue 防抖', () => {
|
||||
beforeEach(() => vi.useFakeTimers())
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('快速连续输入只会在静默后更新一次', async () => {
|
||||
const { result, rerender } = renderHook(({ value }) => useDebouncedValue(value, 300), { initialProps: { value: 'a' } })
|
||||
rerender({ value: 'ab' })
|
||||
act(() => vi.advanceTimersByTime(100))
|
||||
rerender({ value: 'abc' })
|
||||
expect(result.current).toBe('a')
|
||||
act(() => vi.advanceTimersByTime(300))
|
||||
expect(result.current).toBe('abc')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useListQuery } from './useListQuery'
|
||||
|
||||
/** 全站列表数据正确性都托付给这个 hook:乱序响应必须被丢弃,卸载必须中止在途请求 */
|
||||
describe('useListQuery 竞态防护', () => {
|
||||
it('慢的旧页响应不得覆盖快的新页响应(版本号丢弃)', async () => {
|
||||
let resolvePage1: ((v: { list: unknown[]; total: number }) => void) | undefined
|
||||
const fetcher = vi.fn((params: { page: number }) =>
|
||||
params.page === 1
|
||||
? new Promise(resolve => { resolvePage1 = resolve })
|
||||
: Promise.resolve({ list: ['page2'], total: 1 }),
|
||||
)
|
||||
const { result } = renderHook(() => useListQuery(fetcher))
|
||||
// 翻到第 2 页(快响应),第 1 页的慢响应仍挂起
|
||||
await act(async () => { result.current.setPage(2) })
|
||||
await waitFor(() => expect(result.current.list).toEqual(['page2']))
|
||||
// 迟到的 page1 响应到达
|
||||
await act(async () => { resolvePage1?.({ list: ['stale-page1'], total: 1 }) })
|
||||
expect(result.current.list).toEqual(['page2'])
|
||||
})
|
||||
|
||||
it('卸载后中止在途请求', async () => {
|
||||
const fetcher = vi.fn(() => new Promise(() => {}))
|
||||
const { unmount } = renderHook(() => useListQuery(fetcher))
|
||||
const signal = (fetcher.mock.calls[0] as unknown[])[1] as AbortSignal
|
||||
unmount()
|
||||
expect(signal.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { applyLabelFilter, extractSelector, hasLabelFilter, removeLabelFilter, replaceSelector } from './query-utils'
|
||||
|
||||
/** 快捷查询「选 App/Pod 即插入执行」依赖这组纯函数正确合并/清理标签 */
|
||||
describe('query-utils', () => {
|
||||
it('replaceSelector 保留管道过滤', () => {
|
||||
expect(replaceSelector('{app="a"} |= "err"', '{app="b"}')).toBe('{app="b"} |= "err"')
|
||||
})
|
||||
it('replaceSelector 无选择器时前置', () => {
|
||||
expect(replaceSelector('', '{app="a"}')).toBe('{app="a"}')
|
||||
})
|
||||
it('applyLabelFilter 同名标签替换旧值', () => {
|
||||
expect(applyLabelFilter('{app="a", ns="x"}', 'app', 'b')).toBe('{ns="x", app="b"}')
|
||||
})
|
||||
it('removeLabelFilter 删除指定标签并保留其余', () => {
|
||||
expect(removeLabelFilter('{namespace="p", app="w", pod="p1"}', 'app')).toBe('{namespace="p", pod="p1"}')
|
||||
expect(removeLabelFilter('{app="w"}', 'app')).toBe('{}')
|
||||
})
|
||||
it('hasLabelFilter 识别已有标签', () => {
|
||||
expect(hasLabelFilter('{namespace="p", app="w"}', 'namespace')).toBe(true)
|
||||
expect(hasLabelFilter('{app="w"}', 'namespace')).toBe(false)
|
||||
})
|
||||
it('extractSelector 取第一个 {...}', () => {
|
||||
expect(extractSelector('{a="1"} |= "x"')).toBe('{a="1"}')
|
||||
expect(extractSelector('plain')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import '@/i18n'
|
||||
|
||||
/** P0 回归:创建后自动部署必须瞄准「刚创建的实例」,
|
||||
* 绝不盲取 list[0] —— 并发创建/排序变化会对错误实例发起真实部署。 */
|
||||
interface FakeDeployment { id: number; name: string; status: string }
|
||||
const { deployMock, listMock } = vi.hoisted(() => ({
|
||||
deployMock: vi.fn<(id: number) => Promise<void>>(),
|
||||
listMock: vi.fn<(params?: unknown) => Promise<{ list: FakeDeployment[]; total: number }>>(),
|
||||
}))
|
||||
listMock.mockImplementation(() => Promise.resolve({ list: [], total: 0 }))
|
||||
deployMock.mockImplementation(() => Promise.resolve())
|
||||
vi.mock('@/api', () => ({
|
||||
deploymentApi: { list: (...args: unknown[]) => listMock(...(args as [unknown?])), deploy: deployMock },
|
||||
}))
|
||||
vi.mock('./../../deployments/components/DeploymentFormDialog', () => ({
|
||||
DeploymentFormDialog: ({ onCreated }: { onCreated: (created?: { id: number; name: string }) => void }) => (
|
||||
<button type="button" onClick={() => onCreated()}>trigger-oncreated</button>
|
||||
),
|
||||
}))
|
||||
|
||||
import { StepDeploy } from './StepDeploy'
|
||||
|
||||
const props = {
|
||||
runningLoki: null,
|
||||
clusters: [],
|
||||
hosts: [],
|
||||
storages: [],
|
||||
onChanged: () => {},
|
||||
} as Parameters<typeof StepDeploy>[0]
|
||||
|
||||
describe('StepDeploy 自动部署目标', () => {
|
||||
it('无 created 参数时按「id 最大且 pending」匹配,不取 list[0]', async () => {
|
||||
listMock.mockResolvedValue({
|
||||
list: [
|
||||
{ id: 7, name: 'older-but-first', status: 'pending' },
|
||||
{ id: 3, name: 'running-old', status: 'running' },
|
||||
{ id: 99, name: 'just-created', status: 'pending' },
|
||||
],
|
||||
total: 3,
|
||||
})
|
||||
render(<StepDeploy {...props} />)
|
||||
await userEvent.click(screen.getByText('trigger-oncreated'))
|
||||
await vi.waitFor(() => expect(deployMock).toHaveBeenCalled())
|
||||
expect(deployMock).toHaveBeenCalledWith(99)
|
||||
})
|
||||
|
||||
it('list 拿不到 pending 实例时不误触发部署', async () => {
|
||||
deployMock.mockClear()
|
||||
listMock.mockResolvedValue({ list: [{ id: 1, name: 'r', status: 'running' }], total: 1 })
|
||||
render(<StepDeploy {...props} />)
|
||||
await userEvent.click(screen.getByText('trigger-oncreated'))
|
||||
await vi.waitFor(() => expect(listMock).toHaveBeenCalled())
|
||||
expect(deployMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
Reference in new issue
Block a user