test: 统一前端测试夹具与发布工具链 (#704)

This commit is contained in:
InfinityPacer
2026-08-24 06:43:36 +08:00
committed by GitHub
parent 5847f9e936
commit 058b8f2299
36 changed files with 470 additions and 317 deletions
+18 -6
View File
@@ -8,12 +8,16 @@ on:
paths:
- 'package.json'
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v7
- name: Release version
id: release_version
@@ -22,22 +26,30 @@ jobs:
echo "frontend_version=v$frontend_version" >> $GITHUB_ENV
- name: Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v7
with:
node-version: '20'
cache: 'yarn'
node-version: '24'
cache: yarn
cache-dependency-path: yarn.lock
- name: Enable project Yarn version
run: |
corepack enable
corepack install --global yarn@1.22.22
- name: Install dependencies
run: yarn --frozen-lockfile
- name: Download Icons
run: |
pwd
curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp -
curl -fsSL --retry 3 "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp -
mv /tmp/MoviePilot-Plugins-main/icons public/plugin_icon
rm -rf /tmp/MoviePilot-Plugins-main
- name: Build frontend
id: build_frontend
run: |
yarn
yarn build
echo "$frontend_version" > dist/version.txt
zip -r dist.zip dist
+1
View File
@@ -1,5 +1,6 @@
dist
node_modules
/.worktrees/
# 自动生成文件的格式由各自生成器决定。
auto-imports.d.ts
+16 -4
View File
@@ -1,8 +1,9 @@
# MoviePilot-Frontend
*[中文](README.md) | English*
_[中文](README.md) | English_
Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS version required: >= `v20.12.1`.
Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot). The minimum supported Node.js version is
`20.19`; Node.js `24` is recommended.
## Features
@@ -17,7 +18,7 @@ Frontend project for [MoviePilot](https://github.com/jxxghp/MoviePilot), NodeJS
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.volar) (disable Vetur).
### Configure Vite
### Configure Vite
See [Vite Configuration Reference](https://vitejs.dev/config/).
@@ -39,6 +40,17 @@ yarn dev
yarn build
```
### Unit Tests
```sh
yarn test:run
yarn test:coverage
```
See the [unit testing architecture](docs/testing.md) for test organization, shared facilities, HTTP fixtures, and
coverage conventions. See the [frontend code quality evolution](docs/code-quality.md) for ESLint, Prettier, Node.js
compatibility, and incremental CI gates.
### Static Deployment
1. Host the `dist` static files using a web server like `nginx`. Refer to `public/nginx.conf` for nginx configuration.
@@ -47,7 +59,7 @@ yarn build
```shell
node dist/service.js
```
```
### Module Federation
+5
View File
@@ -65,6 +65,11 @@ ESLint 迁移优先于全仓格式治理,基础设施迁移不夹带批量业
`eslint-suppressions.json` 只冻结迁移时已经确认的文件、规则和数量。新增问题不得加入 baseline;修复存量问题后运行 `yarn lint:suppressions:prune`,并把裁剪结果与代码修复一同提交。日常开发和 CI 不得使用 `--suppress-all`
baseline 是刻意设计的渐进治理机制,不等于被抑制的违规已被认可为长期编码规范。2026-08-24 的基线快照为
141 个文件、564 项受控违规,其中 `@typescript-eslint/no-explicit-any` 300 项、`vue/no-mutating-props`
82 项、`@typescript-eslint/no-unused-vars` 64 项。生产 PR 修改到某个受控文件时,应审计并偿还该文件内
不改变业务语义且可安全处理的存量,随后 prune;纯测试、文档或 CI 变更不为清理未触及生产文件扩大 diff。
迁移完成的最低验收为:
```sh
+21
View File
@@ -45,6 +45,7 @@ tests/
- `tests/support/render.ts` 提供带 Vuetify、i18n、Router 和 Pinia 的标准渲染入口。
- `tests/support/factories/` 按业务对象提供最小有效测试数据工厂。
- `tests/support/msw/handlers/` 按业务域定义 HTTP handler`server.ts` 只负责 MSW server 实例。
- `tests/support/msw/response.ts` 显式构造主程序普通 API 的成功或业务失败 envelope。
- spec 通过 `@tests/*` 访问共享测试设施,通过 `@/*` 访问生产源码。
## 工具职责
@@ -65,11 +66,31 @@ tests/
- 每个用例保持独立,不依赖文件执行顺序;timer、mock、storage、DOM 和未完成请求由全局 setup 恢复。
- 不使用大面积快照或覆盖率占位用例。
## HTTP 响应夹具
主程序普通 API 使用固定的 `{ success, message, data }` envelope。MSW handler 必须通过
`apiJson(data)``apiFailureJson(message, data)` 显式声明成功或业务失败,不得在全局 setup 中改写
`HttpResponse.json()`、自动包装裸数据或补齐缺失字段。这样测试夹具与当前后端协议不一致时会直接失败,
不会由兼容层掩盖。
HTTP 4xx/5xx 的原始错误体、插件自定义端点和其他明确不使用主程序 envelope 的协议继续直接调用
`HttpResponse.json()`。是否使用 envelope 由端点契约决定,不根据状态码或载荷形状自动猜测。
## 测试性能
- 根据逐文件和逐用例耗时日志识别热点,不按文件行数或用例数量机械拆分。
- 业务测试可以对与断言无关的第三方重型 UI 边界使用局部 test double,但必须保留 props、emits、
`v-model`、可访问名称及被测业务使用的值类型;不得以直接赋值组件私有状态替代用户交互。
- 优先减少动画、布局、定位和重复挂载等 jsdom 无法证明的成本。真实组件集成仍由代表性组件测试或浏览器回归负责。
- 优化前后使用同一命令和 reporter 比较 focused 文件与热点用例;只有保持断言和业务契约后取得稳定收益才保留优化。
## 新增测试
1. 业务测试在被测源码所在目录的 `__tests__/` 中创建同名 `*.spec.ts`;工具链配置契约测试放在 `tests/config/`
2. 纯函数、store 和无渲染模块直接使用 Vitest;Vue 组件使用标准渲染入口。
3. 需要 HTTP 请求时,在 `tests/support/msw/handlers/<domain>.ts` 增加对应 handler。
主程序普通 API 响应使用 `apiJson()` / `apiFailureJson()`;只有明确的非 envelope 协议才直接使用
`HttpResponse.json()`
4. 需要结构化业务数据时,在 `tests/support/factories/` 增加最小工厂。
5. 核心覆盖范围发生变化时,同步更新 `vite.config.ts``coverage.include`
6. 提交前按影响面运行测试、类型检查、lint 和生产构建;覆盖率报告按需本地执行。
+1 -1
View File
@@ -157,7 +157,7 @@
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0"
},
"packageManager": "yarn@1.22.18",
"packageManager": "yarn@1.22.22",
"engines": {
"node": ">=20.19"
}
@@ -13,6 +13,7 @@ import {
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h, reactive, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -206,8 +207,8 @@ function installSearchHandlers(
tv: tvSiteListUrl,
}[mediaType]
server.use(
http.get(siteListUrl, () => HttpResponse.json(sites)),
http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })),
http.get(siteListUrl, () => apiJson(sites)),
http.get(selectedSitesUrl, () => apiJson({ value: selected })),
)
}
@@ -548,7 +549,7 @@ describe('MediaCard', () => {
it('falls back to global search when site settings cannot provide active selections', async () => {
server.use(
http.get(movieSiteListUrl, () => HttpResponse.json({ message: 'temporary failure' }, { status: 500 })),
http.get(selectedSitesUrl, () => HttpResponse.json({ success: true })),
http.get(selectedSitesUrl, () => apiJson(null)),
)
const media = createMediaInfo({ title: '站点失败搜索', tmdb_id: 9503 })
const { container } = await renderCard(media)
@@ -607,7 +608,7 @@ describe('MediaCard', () => {
it('opens active sites with an empty selection when the saved setting fails', async () => {
server.use(
http.get(movieSiteListUrl, () =>
HttpResponse.json([
apiJson([
{
domain: 'fallback.example',
downloader: 'default',
@@ -655,7 +656,7 @@ describe('MediaCard', () => {
subscribeListRequest,
),
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: { best_version: 0 } }, success: true }),
apiJson({ value: { best_version: 0 } }),
),
)
const { container } = await renderCard(media)
@@ -703,7 +704,7 @@ describe('MediaCard', () => {
{ id: 92, media_id: 'other', media_source: 'bilibili', season: 5, type: '电视剧' },
]),
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: {} }, success: true }),
apiJson({ value: {} }),
),
)
const { container } = await renderCard(media)
@@ -758,7 +759,7 @@ describe('MediaCard', () => {
mediaExistsHandler({ data: { item: {} }, success: true }),
subscribeListHandler(subscribes),
http.get(new URL('system/setting/public/DefaultTvSubscribeConfig', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: {} }, success: true }),
apiJson({ value: {} }),
),
)
const { container } = await renderCard(media)
@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -185,12 +186,12 @@ function createDeferred<T>() {
function directoriesHandler(directories: TransferDirectoryConf[]) {
return http.get(new URL('system/setting/public/Directories', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: directories }, success: true }),
apiJson({ value: directories }),
)
}
function downloadersHandler(downloaders: Array<{ name: string; type: string }> = []) {
return http.get(new URL('download/clients', API_BASE_URL).href, () => HttpResponse.json(downloaders))
return http.get(new URL('download/clients', API_BASE_URL).href, () => apiJson(downloaders))
}
function downloadHandler(
@@ -201,7 +202,9 @@ function downloadHandler(
) {
return http.post(new URL(endpoint, API_BASE_URL).href, async ({ request }) => {
await onRequest(await request.json())
return HttpResponse.json(await response, { status })
const body = await response
if (status >= 400) return HttpResponse.json(body, { status })
return HttpResponse.json(body, { status })
})
}
@@ -320,7 +323,7 @@ describe('AddDownloadDialog submissions', () => {
it('submits a directly entered media ID through v-model', async () => {
const submitted = vi.fn()
server.use(downloadHandler('download/add', { data: null, success: true }, 200, submitted))
server.use(downloadHandler('download/add', { data: null, message: '', success: true }, 200, submitted))
const user = userEvent.setup()
await renderDialog({
@@ -376,7 +379,7 @@ describe('AddDownloadDialog submissions', () => {
expect(submitted).toHaveBeenCalledOnce()
expect(mocks.startNProgress).toHaveBeenCalledOnce()
deferred.resolve({ data: null, success: true })
deferred.resolve({ data: null, message: '', success: true })
await waitFor(() => expect(events.done).toHaveBeenCalledWith(torrent.enclosure))
expect(mocks.toastSuccess).toHaveBeenCalledWith('测试站 测试种子 下载成功!')
@@ -386,7 +389,7 @@ describe('AddDownloadDialog submissions', () => {
it('submits the selected album namespace for a music torrent without media context', async () => {
const submitted = vi.fn()
server.use(downloadHandler('download/add', { data: null, success: true }, 200, submitted))
server.use(downloadHandler('download/add', { data: null, message: '', success: true }, 200, submitted))
const user = userEvent.setup()
await renderDialog({
@@ -493,7 +496,7 @@ describe('AddDownloadDialog submissions', () => {
it('uses the source-native identity carried by a torrent without auxiliary ID fallback', async () => {
const submitted = vi.fn()
server.use(downloadHandler('download/add', { data: null, success: true }, 200, submitted))
server.use(downloadHandler('download/add', { data: null, message: '', success: true }, 200, submitted))
const torrent = createTorrent({ media_id: 'tt0111161', media_source: 'imdb' })
const user = userEvent.setup()
@@ -509,7 +512,7 @@ describe('AddDownloadDialog submissions', () => {
it('uses download/ for an existing media without locking unrelated optional fields', async () => {
const submitted = vi.fn()
server.use(downloadHandler('download/', { data: null, success: true }, 200, submitted))
server.use(downloadHandler('download/', { data: null, message: '', success: true }, 200, submitted))
const media = createMedia()
const torrent = createTorrent()
const user = userEvent.setup()
@@ -540,7 +543,7 @@ describe('AddDownloadDialog submissions', () => {
success: false,
})
}
return HttpResponse.json({ data: { download_id: 'collection-download' }, success: true })
return apiJson({ download_id: 'collection-download' })
}),
)
mocks.confirm.mockResolvedValue(true)
@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -151,7 +152,7 @@ function createDeferred<T>() {
function directoriesHandler(directories: TransferDirectoryConf[]) {
return http.get(new URL('system/setting/public/Directories', API_BASE_URL).href, () =>
HttpResponse.json({ data: { value: directories }, success: true }),
apiJson({ value: directories }),
)
}
@@ -162,7 +163,9 @@ function subtitleDownloadHandler(
) {
return http.post(new URL('download/subtitle', API_BASE_URL).href, async ({ request }) => {
await onRequest(await request.json())
return HttpResponse.json(await response, { status })
const body = await response
if (status >= 400) return HttpResponse.json(body, { status })
return HttpResponse.json(body, { status })
})
}
@@ -277,7 +280,7 @@ describe('AddSubtitleDownloadDialog submissions', () => {
it('submits a directly entered media ID through v-model', async () => {
const submitted = vi.fn()
server.use(subtitleDownloadHandler({ data: null, success: true }, 200, submitted))
server.use(subtitleDownloadHandler({ data: null, message: '', success: true }, 200, submitted))
const user = userEvent.setup()
await renderDialog({ mediaId: null, recognizeSource: 'douban' })
@@ -294,7 +297,7 @@ describe('AddSubtitleDownloadDialog submissions', () => {
it('reveals advanced options and submits the selected media source identity', async () => {
const submitted = vi.fn()
server.use(subtitleDownloadHandler({ data: null, success: true }, 200, submitted))
server.use(subtitleDownloadHandler({ data: null, message: '', success: true }, 200, submitted))
const user = userEvent.setup()
await renderDialog({ mediaId: '84', mediaSource: 'themoviedb' })
@@ -348,7 +351,7 @@ describe('AddSubtitleDownloadDialog submissions', () => {
expect(submitted).toHaveBeenCalledOnce()
expect(mocks.startNProgress).toHaveBeenCalledOnce()
deferred.resolve({ data: null, success: true })
deferred.resolve({ data: null, message: '', success: true })
await waitFor(() => expect(events.done).toHaveBeenCalledWith(expectedEnclosure))
expect(mocks.toastSuccess).toHaveBeenCalledWith('字幕站 测试字幕 字幕下载成功!')
@@ -358,7 +361,7 @@ describe('AddSubtitleDownloadDialog submissions', () => {
it('submits the exact-search identity passed by the resource result', async () => {
const submitted = vi.fn()
server.use(subtitleDownloadHandler({ data: null, success: true }, 200, submitted))
server.use(subtitleDownloadHandler({ data: null, message: '', success: true }, 200, submitted))
const user = userEvent.setup()
await renderDialog({ mediaId: '84', mediaSource: 'themoviedb', recognizeSource: 'douban' })
@@ -11,6 +11,7 @@ import {
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -207,9 +208,9 @@ describe('DownloadHistoryDialog', () => {
http.get(downloadApiUrls.history, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
if (page === '2') return HttpResponse.json([second])
return HttpResponse.json([])
if (page === '1') return apiJson([first])
if (page === '2') return apiJson([second])
return apiJson([])
}),
)
const user = userEvent.setup()
@@ -262,7 +263,7 @@ describe('DownloadHistoryDialog', () => {
http.get(downloadApiUrls.history, () => {
requestCount += 1
if (requestCount === 1) return HttpResponse.json({}, { status: 500 })
return HttpResponse.json([item])
return apiJson([item])
}),
)
const user = userEvent.setup()
@@ -7,6 +7,7 @@ import { addSiteHandler, siteApiUrls } from '@tests/support/msw/handlers/site'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
@@ -183,7 +184,7 @@ describe('SiteImportDialog', () => {
server.use(
http.post(siteApiUrls.list, () => {
requestIndex += 1
if (requestIndex === 1) return HttpResponse.json({ success: true })
if (requestIndex === 1) return apiJson(null)
return HttpResponse.json({ message: '第二站请求失败', success: false }, { status: 500 })
}),
)
@@ -9,6 +9,7 @@ import { siteApiUrls, siteCategoriesHandler, siteResourcesHandler } from '@tests
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h, type Component, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -128,13 +129,13 @@ describe('SiteResourceDialog', () => {
const keyword = new URL(request.url).searchParams.get('keyword')
if (keyword) {
latestRequested()
return HttpResponse.json(await latestResponse.promise)
return apiJson(await latestResponse.promise)
}
oldRequested()
await oldResponse.promise
return oldStatus === 200
? HttpResponse.json([createTorrentInfo({ title: '旧条件结果' })])
? apiJson([createTorrentInfo({ title: '旧条件结果' })])
: HttpResponse.json({ detail: 'stale failure' }, { status: oldStatus })
}),
)
@@ -176,7 +177,7 @@ describe('SiteResourceDialog', () => {
attempts += 1
if (attempts === 1) return HttpResponse.json({ detail: 'temporary failure' }, { status: 500 })
return HttpResponse.json([createTorrentInfo({ title: '重试恢复结果' })])
return apiJson([createTorrentInfo({ title: '重试恢复结果' })])
}),
)
const user = userEvent.setup()
@@ -207,7 +208,7 @@ describe('SiteResourceDialog', () => {
siteCategoriesHandler(501, []),
http.get(siteApiUrls.resources(501), () => {
attempts += 1
if (attempts === 1) return HttpResponse.json([createTorrentInfo({ title: '已有资源' })])
if (attempts === 1) return apiJson([createTorrentInfo({ title: '已有资源' })])
return HttpResponse.json({ detail: 'temporary failure' }, { status: 500 })
}),
@@ -283,8 +284,8 @@ describe('SiteResourceDialog', () => {
siteCategoriesHandler(501, []),
http.get(siteApiUrls.resources(501), async () => {
requestCount += 1
if (requestCount === 1) return HttpResponse.json([])
return HttpResponse.json(await nextResponse.promise)
if (requestCount === 1) return apiJson([])
return apiJson(await nextResponse.promise)
}),
)
const user = userEvent.setup()
@@ -6,6 +6,7 @@ import { server } from '@tests/support/msw/server'
import { subscribeApiUrls, subscribeFilesHandler } from '@tests/support/msw/handlers/subscribe'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
@@ -437,7 +438,7 @@ describe('SubscribeFilesDialog', () => {
http.get(subscribeApiUrls.filesById(3102), () => {
requestCount += 1
if (requestCount === 1) return HttpResponse.json({}, { status: 500 })
return HttpResponse.json(info as unknown as JsonBodyType)
return apiJson(info as unknown as JsonBodyType)
}),
)
const user = userEvent.setup()
@@ -456,7 +457,7 @@ describe('SubscribeFilesDialog', () => {
const deferred = createDeferred<JsonBodyType>()
server.use(
http.get(subscribeApiUrls.filesById(3116), async () => {
return HttpResponse.json(await deferred.promise)
return apiJson(await deferred.promise)
}),
)
@@ -13,6 +13,7 @@ import {
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -246,9 +247,9 @@ describe('SubscribeHistoryDialog', () => {
const url = new URL(request.url)
const page = url.searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
if (page === '2') return HttpResponse.json([second])
return HttpResponse.json([])
if (page === '1') return apiJson([first])
if (page === '2') return apiJson([second])
return apiJson([])
}),
)
const user = userEvent.setup()
@@ -283,7 +284,7 @@ describe('SubscribeHistoryDialog', () => {
const url = new URL(request.url)
requestedPages.push(url.searchParams.get('page') ?? '')
if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([recovered])
return apiJson([recovered])
}),
)
const user = userEvent.setup()
@@ -305,7 +306,7 @@ describe('SubscribeHistoryDialog', () => {
server.use(
http.get(subscribeApiUrls.historyByType('电影'), async () => {
requested()
return HttpResponse.json(await pending.promise)
return apiJson(await pending.promise)
}),
)
@@ -329,13 +330,13 @@ describe('SubscribeHistoryDialog', () => {
],
['音乐', createHistory({ name: '重新订阅专辑', type: '音乐' }), '正在重新订阅 重新订阅专辑...'],
] as const)('shows the %s pending copy and emits save only after success', async (type, item, progressText) => {
const pending = createDeferred<{ success: boolean }>()
const pending = createDeferred<null>()
let payload: JsonBodyType | undefined
server.use(
subscribeHistoryHandler(type, [item]),
http.post(subscribeApiUrls.create, async ({ request }) => {
payload = (await request.json()) as JsonBodyType
return HttpResponse.json(await pending.promise)
return apiJson(await pending.promise)
}),
)
const user = userEvent.setup()
@@ -346,7 +347,7 @@ describe('SubscribeHistoryDialog', () => {
expect(await screen.findByRole('status')).toHaveTextContent(progressText)
expect(events.save).not.toHaveBeenCalled()
pending.resolve({ success: true })
pending.resolve(null)
await waitFor(() => expect(events.save).toHaveBeenCalledOnce())
expect(payload).toEqual(item)
+3 -2
View File
@@ -5,7 +5,8 @@ import { useUserStore } from '@/stores/user'
import { screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { server } from '@tests/support/msw/server'
import { http, HttpResponse } from 'msw'
import { http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h } from 'vue'
import { describe, expect, it } from 'vitest'
@@ -31,7 +32,7 @@ function createNavItem(overrides: Partial<PluginSidebarNavItem> = {}): PluginSid
}
function sidebarNavHandler(items: PluginSidebarNavItem[]) {
return http.get(SIDEBAR_NAV_URL, () => HttpResponse.json(items))
return http.get(SIDEBAR_NAV_URL, () => apiJson(items))
}
async function renderAppCenter(items: PluginSidebarNavItem[], permissions: Record<string, unknown> = {}) {
+5 -3
View File
@@ -12,6 +12,7 @@ import {
} from '@tests/support/msw/handlers/discover'
import { server } from '@tests/support/msw/server'
import { HttpResponse, http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h, ref, unref, type ComputedRef, type Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -364,7 +365,7 @@ describe('discover page', () => {
server.use(
http.get(discoverApiUrls.sources, () => {
requested()
return HttpResponse.json(sources)
return apiJson(sources)
}),
)
await renderDiscover()
@@ -388,7 +389,8 @@ describe('discover page', () => {
server.use(
http.get(discoverApiUrls.sources, () => {
requested()
return HttpResponse.json(status === 200 ? [createSource('缓存来源', 'cached')] : [], { status })
if (status >= 400) return HttpResponse.json([], { status })
return apiJson([createSource('缓存来源', 'cached')])
}),
)
await renderDiscover()
@@ -417,7 +419,7 @@ describe('discover page', () => {
server.use(
http.get(discoverApiUrls.sources, () => {
requested()
return HttpResponse.json(sources)
return apiJson(sources)
}),
)
await renderDiscover()
+4 -3
View File
@@ -11,7 +11,8 @@ import {
saveRecommendConfigHandler,
} from '@tests/support/msw/handlers/recommend'
import { server } from '@tests/support/msw/server'
import { HttpResponse, http } from 'msw'
import { http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, nextTick, ref, type Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -111,7 +112,7 @@ async function renderKeptAliveRecommend() {
function dynamicRecommendSources(getSources: () => RecommendSource[], onRequest = vi.fn()) {
return http.get(recommendApiUrls.sources, () => {
onRequest()
return HttpResponse.json(getSources())
return apiJson(getSources())
})
}
@@ -183,7 +184,7 @@ describe('recommend page', () => {
http.get(recommendApiUrls.sources, async () => {
requested()
await responseGate
return HttpResponse.json([{ api_path: 'recommend/completed', name: '请求完成来源', type: '扩展' }])
return apiJson([{ api_path: 'recommend/completed', name: '请求完成来源', type: '扩展' }])
}),
)
@@ -6,7 +6,8 @@ import { createMediaInfo } from '@tests/support/factories/media'
import { recommendApiUrls, recommendMediaHandler } from '@tests/support/msw/handlers/recommend'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { http, HttpResponse } from 'msw'
import { http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, ref } from 'vue'
import { describe, expect, it, vi } from 'vitest'
@@ -148,7 +149,7 @@ describe('MediaRecommend', () => {
expect(await screen.findByText('快照推荐')).toBeInTheDocument()
await waitFor(() => expect(requested).toHaveBeenCalledOnce())
resolveRequest?.(HttpResponse.json([createMediaInfo({ title: '刷新推荐' })]))
resolveRequest?.(apiJson([createMediaInfo({ title: '刷新推荐' })]))
expect(await screen.findByText('刷新推荐')).toBeInTheDocument()
second.unmount()
})
@@ -324,7 +325,7 @@ describe('MediaRecommend', () => {
await fireEvent.click(screen.getByRole('button', { name: '停用慢请求推荐' }))
setInterval.mockClear()
resolveRequest?.(HttpResponse.json([createMediaInfo({ title: '迟到推荐' })]))
resolveRequest?.(apiJson([createMediaInfo({ title: '迟到推荐' })]))
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
await new Promise(resolve => window.setTimeout(resolve, 0))
@@ -380,7 +381,7 @@ describe('MediaRecommend', () => {
await fireEvent.click(screen.getByRole('button', { name: '恢复切源推荐' }))
expect(setInterval).not.toHaveBeenCalledWith(expect.any(Function), 8000)
resolveMovies?.(HttpResponse.json([createMediaInfo({ title: '切源完成' })]))
resolveMovies?.(apiJson([createMediaInfo({ title: '切源完成' })]))
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
await screen.findByText('切源完成')
@@ -435,7 +436,7 @@ describe('MediaRecommend', () => {
expect(await screen.findByText('初始结果')).toBeInTheDocument()
await waitFor(() => expect(resolveMovies).toBeTypeOf('function'))
resolveMovies?.(HttpResponse.json([createMediaInfo({ title: '过期结果' })]))
resolveMovies?.(apiJson([createMediaInfo({ title: '过期结果' })]))
await waitFor(() => expect(getActiveRequestsCount()).toBe(0))
await new Promise(resolve => window.setTimeout(resolve, 0))
@@ -6,6 +6,7 @@ import { createMediaInfo } from '@tests/support/factories/media'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -178,7 +179,7 @@ describe('MediaCardListView', () => {
server.use(
http.get(LIST_URL, ({ request }) => {
requests.push(new URL(request.url))
return HttpResponse.json([createMediaInfo({ title: '内部页码结果' })])
return apiJson([createMediaInfo({ title: '内部页码结果' })])
}),
)
@@ -198,7 +199,7 @@ describe('MediaCardListView', () => {
server.use(
http.get(LIST_URL, ({ request }) => {
requests.push(new URL(request.url))
return HttpResponse.json([createMediaInfo({ title: '多来源结果' })])
return apiJson([createMediaInfo({ title: '多来源结果' })])
}),
)
@@ -216,7 +217,7 @@ describe('MediaCardListView', () => {
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
return HttpResponse.json([createMediaInfo({ title: '单页媒体' })])
return apiJson([createMediaInfo({ title: '单页媒体' })])
}),
)
@@ -233,7 +234,7 @@ describe('MediaCardListView', () => {
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
return HttpResponse.json([createMediaInfo({ title: page === '1' ? '未满屏第一页' : '未满屏第二页' })])
return apiJson([createMediaInfo({ title: page === '1' ? '未满屏第一页' : '未满屏第二页' })])
}),
)
@@ -260,7 +261,7 @@ describe('MediaCardListView', () => {
{ media_id: 'media-43', title: '不同 media_id' },
]
const response = [base, { ...base, title: '完全重复项' }, ...variants.map(variant => ({ ...base, ...variant }))]
server.use(http.get(LIST_URL, () => HttpResponse.json(response as unknown as JsonBodyType)))
server.use(http.get(LIST_URL, () => apiJson(response as unknown as JsonBodyType)))
await renderList()
@@ -285,7 +286,7 @@ describe('MediaCardListView', () => {
tmdb_id: undefined,
}),
)
server.use(http.get(LIST_URL, () => HttpResponse.json(response as unknown as JsonBodyType)))
server.use(http.get(LIST_URL, () => apiJson(response as unknown as JsonBodyType)))
await renderList()
@@ -302,7 +303,7 @@ describe('MediaCardListView', () => {
createMediaInfo({ media_id: undefined, media_source: undefined, title: '无标识媒体 A', tmdb_id: undefined }),
createMediaInfo({ media_id: undefined, media_source: undefined, title: '无标识媒体 B', tmdb_id: undefined }),
]
server.use(http.get(LIST_URL, () => HttpResponse.json(response as unknown as JsonBodyType)))
server.use(http.get(LIST_URL, () => apiJson(response as unknown as JsonBodyType)))
await renderList()
@@ -321,9 +322,9 @@ describe('MediaCardListView', () => {
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first, second] as unknown as JsonBodyType)
if (page === '2') return HttpResponse.json([{ ...first, title: '第二页全重复' }] as unknown as JsonBodyType)
return HttpResponse.json([later] as unknown as JsonBodyType)
if (page === '1') return apiJson([first, second] as unknown as JsonBodyType)
if (page === '2') return apiJson([{ ...first, title: '第二页全重复' }] as unknown as JsonBodyType)
return apiJson([later] as unknown as JsonBodyType)
}),
)
@@ -345,10 +346,10 @@ describe('MediaCardListView', () => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') {
return HttpResponse.json([first, { ...first, title: '页内重复 A' }, second] as unknown as JsonBodyType)
return apiJson([first, { ...first, title: '页内重复 A' }, second] as unknown as JsonBodyType)
}
if (page === '2') return HttpResponse.json([between] as unknown as JsonBodyType)
return HttpResponse.json([{ ...second }, { ...first }] as unknown as JsonBodyType)
if (page === '2') return apiJson([between] as unknown as JsonBodyType)
return apiJson([{ ...second }, { ...first }] as unknown as JsonBodyType)
}),
)
@@ -362,7 +363,7 @@ describe('MediaCardListView', () => {
it('marks an empty first page as the pagination terminal state', async () => {
setScrollHeight(() => 900)
server.use(http.get(LIST_URL, () => HttpResponse.json([])))
server.use(http.get(LIST_URL, () => apiJson([])))
await renderList()
@@ -372,7 +373,7 @@ describe('MediaCardListView', () => {
it('renders HTTP 200 plus an empty array as ordinary no-data rather than a network error', async () => {
setScrollHeight(() => 900)
server.use(http.get(LIST_URL, () => HttpResponse.json([])))
server.use(http.get(LIST_URL, () => apiJson([])))
await renderList()
@@ -389,7 +390,7 @@ describe('MediaCardListView', () => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([createMediaInfo({ title: '首载重试成功' })])
return apiJson([createMediaInfo({ title: '首载重试成功' })])
}),
)
const user = userEvent.setup()
@@ -412,10 +413,10 @@ describe('MediaCardListView', () => {
http.get(LIST_URL, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([createMediaInfo({ title: '保留第一页' })])
if (page === '1') return apiJson([createMediaInfo({ title: '保留第一页' })])
pageTwoAttempts += 1
if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([createMediaInfo({ title: '第二页重试成功' })])
return apiJson([createMediaInfo({ title: '第二页重试成功' })])
}),
)
const user = userEvent.setup()
@@ -438,7 +439,7 @@ describe('MediaCardListView', () => {
http.get(LIST_URL, async ({ request }) => {
requests.push(new URL(request.url))
await gate.promise
return HttpResponse.json([createMediaInfo({ title: '在途请求结果' })])
return apiJson([createMediaInfo({ title: '在途请求结果' })])
}),
)
@@ -32,6 +32,7 @@ import {
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -137,8 +138,8 @@ interface RenderDetailOptions {
function installSiteHandlers(sites: Site[] = [], selected: number[] = [], type = '电影') {
server.use(
http.get(type === '电视剧' ? tvSiteListUrl : movieSiteListUrl, () => HttpResponse.json(sites)),
http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })),
http.get(type === '电视剧' ? tvSiteListUrl : movieSiteListUrl, () => apiJson(sites)),
http.get(selectedSitesUrl, () => apiJson({ value: selected })),
)
}
@@ -593,8 +594,8 @@ describe('MediaDetailView detail and actions', () => {
const site = createSubscribeSite({ id: 92, is_active: true, name: '空设置站点' })
await renderDetail()
server.use(
http.get(movieSiteListUrl, () => HttpResponse.json([site])),
http.get(selectedSitesUrl, () => HttpResponse.json({ data: {}, success: true })),
http.get(movieSiteListUrl, () => apiJson([site])),
http.get(selectedSitesUrl, () => apiJson({})),
)
await fireEvent.click(screen.getByRole('button', { name: /搜索字幕/ }))
@@ -1112,7 +1113,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
staleRequest()
const response = await staleSeasons.promise
staleResponseReturned.resolve()
return HttpResponse.json(response)
return apiJson(response)
}),
mediaGroupSeasonsHandler('group-b', [createMediaSeason({ season_number: 2 })]),
)
@@ -1163,12 +1164,12 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
staleMissingRequest()
const response = await staleMissing.promise
staleMissingReturned.resolve()
return HttpResponse.json(response)
return apiJson(response)
}
if (payload.episode_group === 'group-b') {
return HttpResponse.json([createNotExistMediaInfo({ episodes: [2], season: 1, total_episode: 2 })])
return apiJson([createNotExistMediaInfo({ episodes: [2], season: 1, total_episode: 2 })])
}
return HttpResponse.json([])
return apiJson([])
}),
http.post(mediaApiUrls.existsRemote, async ({ request }) => {
const payload = (await request.json()) as Record<string, unknown>
@@ -1176,13 +1177,13 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
staleRemoteRequest()
const response = await staleRemoteExists.promise
staleRemoteReturned.resolve()
return HttpResponse.json(response)
return apiJson(response)
}
return HttpResponse.json(payload.episode_group === 'group-b' ? { 1: [2] } : {})
return apiJson(payload.episode_group === 'group-b' ? { 1: [2] } : {})
}),
http.get(new URL('tmdb/8712/1', API_BASE_URL).href, ({ request }) => {
const group = new URL(request.url).searchParams.get('episode_group')
return HttpResponse.json([
return apiJson([
createTmdbEpisode({ episode_number: 1, name: `${group} 第一集`, season_number: 1 }),
createTmdbEpisode({ episode_number: 2, name: `${group} 第二集`, season_number: 1 }),
])
@@ -1254,10 +1255,10 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
staleRequest()
const response = await staleEpisodes.promise
staleResponseReturned.resolve()
return HttpResponse.json(response)
return apiJson(response)
}
currentRequest()
return HttpResponse.json([createTmdbEpisode({ episode_number: 1, name: 'B 组第一集', season_number: 1 })])
return apiJson([createTmdbEpisode({ episode_number: 1, name: 'B 组第一集', season_number: 1 })])
}),
)
@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -112,9 +113,7 @@ describe('PersonCardListView', () => {
it('loads people with the configured prefetch margin', async () => {
server.use(
http.get(LIST_URL, () =>
HttpResponse.json([{ id: 101, name: '探索人物', source: 'themoviedb' } satisfies Person]),
),
http.get(LIST_URL, () => apiJson([{ id: 101, name: '探索人物', source: 'themoviedb' } satisfies Person])),
)
await renderList()
@@ -129,7 +128,7 @@ describe('PersonCardListView', () => {
server.use(
http.get(LIST_URL, ({ request }) => {
requests.push(new URL(request.url))
return HttpResponse.json([{ id: 303, name: '多来源人物', source: 'themoviedb' } satisfies Person])
return apiJson([{ id: 303, name: '多来源人物', source: 'themoviedb' } satisfies Person])
}),
)
@@ -146,7 +145,7 @@ describe('PersonCardListView', () => {
http.get(LIST_URL, () => {
requests++
if (requests === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([{ id: 202, name: '人物重试结果', source: 'themoviedb' } satisfies Person])
return apiJson([{ id: 202, name: '人物重试结果', source: 'themoviedb' } satisfies Person])
}),
)
const user = userEvent.setup()
@@ -4,6 +4,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h, type PropType, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -75,7 +76,8 @@ const PersonCardStub = defineComponent({
function personResponse(people: Person[], status = 200, onRequest: () => void = () => {}) {
return http.get(API_URL, () => {
onRequest()
return HttpResponse.json(people, { status })
if (status >= 400) return HttpResponse.json(people, { status })
return apiJson(people, { status })
})
}
@@ -9,6 +9,7 @@ import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { apiFailureJson, apiJson } from '@tests/support/msw/response'
import { computed, defineComponent, h, nextTick, unref, type ComputedRef, type PropType, type Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -384,20 +385,18 @@ interface ListResponses {
function registerListHandlers(responses: ListResponses = {}) {
server.use(
http.get(apiUrls.order, () =>
HttpResponse.json({ data: { value: responses.order ?? [] }, success: true } as JsonBodyType),
),
http.get(apiUrls.folders, async () => HttpResponse.json((await responses.folders?.()) ?? {})),
http.get(apiUrls.order, () => apiJson({ value: responses.order ?? [] })),
http.get(apiUrls.folders, async () => apiJson((await responses.folders?.()) ?? {})),
http.get(apiUrls.list, async ({ request }) => {
const state = new URL(request.url).searchParams.get('state')
const plugins = state === 'installed' ? await responses.installed?.() : await responses.market?.()
return HttpResponse.json((plugins ?? []) as unknown as JsonBodyType, {
return apiJson((plugins ?? []) as unknown as JsonBodyType, {
status: state === 'installed' ? (responses.installedStatus ?? 200) : (responses.marketStatus ?? 200),
})
}),
http.get(apiUrls.statistic, async () => HttpResponse.json((await responses.statistic?.()) ?? {})),
http.get(apiUrls.statistic, async () => apiJson((await responses.statistic?.()) ?? {})),
http.get(apiUrls.runtime, async () =>
HttpResponse.json(
apiJson(
(await responses.runtime?.()) ?? {
failed_count: 0,
generation: 0,
@@ -406,10 +405,10 @@ function registerListHandlers(responses: ListResponses = {}) {
},
),
),
http.get(apiUrls.sidebar, () => HttpResponse.json([])),
http.get(apiUrls.sidebar, () => apiJson([])),
http.get(apiUrls.rating, async ({ request }) => {
const ids = new URL(request.url).searchParams.get('plugin_ids')?.split(',').filter(Boolean) ?? []
return HttpResponse.json(((await responses.rating?.(ids)) ?? {}) as unknown as JsonBodyType)
return apiJson(((await responses.rating?.(ids)) ?? {}) as unknown as JsonBodyType)
}),
)
}
@@ -760,7 +759,7 @@ describe('PluginCardListView loading and request ownership', () => {
const state = new URL(request.url).searchParams.get('state')
const plugins =
state === 'installed' ? [createPlugin({ id: 'Recovered', installed: true, plugin_name: '重试恢复插件' })] : []
return HttpResponse.json(plugins as unknown as JsonBodyType)
return apiJson(plugins as unknown as JsonBodyType)
}),
)
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
@@ -844,7 +843,7 @@ describe('PluginCardListView loading and request ownership', () => {
const state = new URL(request.url).searchParams.get('state')
const plugins =
state === 'market' ? [createPlugin({ id: 'MarketRecovered', plugin_name: '市场重试恢复插件' })] : []
return HttpResponse.json(plugins as unknown as JsonBodyType)
return apiJson(plugins as unknown as JsonBodyType)
}),
)
await fireEvent.click(screen.getByRole('button', { name: '重试' }))
@@ -1217,7 +1216,7 @@ describe('PluginCardListView installed filtering and host callbacks', () => {
getDialogEvents().changed()
await waitFor(() => expect(marketRequests).toBeGreaterThan(requestsAfterSave))
server.use(http.get(apiUrls.install('Available'), () => HttpResponse.json({ data: null, success: true })))
server.use(http.get(apiUrls.install('Available'), () => apiJson(null)))
await fireEvent.click(screen.getByRole('button', { name: 'installed-Available' }))
await waitFor(() => expect(sidebarStore.ensureSidebarNav).toHaveBeenCalledWith(true))
await waitForRequestsToFinish()
@@ -1237,7 +1236,7 @@ describe('PluginCardListView installed filtering and host callbacks', () => {
http.get(apiUrls.install('MarketInstall'), async () => {
await installGate.promise
installed = true
return HttpResponse.json({ data: null, success: true })
return apiJson(null)
}),
)
@@ -1272,7 +1271,7 @@ describe('PluginCardListView search installation', () => {
http.get(apiUrls.install('SearchPlugin'), () => {
installRequests += 1
return mode === 'business'
? HttpResponse.json({ message: 'Rejected', success: false })
? apiFailureJson('Rejected')
: HttpResponse.json({ message: 'HTTP failure' }, { status: 500 })
}),
)
@@ -1330,7 +1329,7 @@ describe('PluginCardListView search installation', () => {
http.get(apiUrls.install('SearchPlugin'), ({ request }) => {
installUrl = new URL(request.url)
installed = true
return HttpResponse.json({ data: null, success: true })
return apiJson(null)
}),
)
const sidebarStore = usePluginSidebarNavStore(pinia)
@@ -1370,7 +1369,7 @@ describe('PluginCardListView search installation', () => {
http.get(apiUrls.install('PendingPlugin'), async () => {
await installGate.promise
installed = true
return HttpResponse.json({ data: null, success: true })
return apiJson(null)
}),
)
@@ -1400,7 +1399,7 @@ describe('PluginCardListView search installation', () => {
http.get(apiUrls.install('DuplicatePlugin'), async () => {
installRequests += 1
await installGate.promise
return HttpResponse.json({ data: null, success: true })
return apiJson(null)
}),
)
@@ -1432,11 +1431,7 @@ describe('PluginCardListView search installation', () => {
market: () => [target],
})
await waitForRequestsToFinish()
server.use(
http.get(apiUrls.install('SlowRollbackPlugin'), () =>
HttpResponse.json({ message: '依赖安装失败', success: false }),
),
)
server.use(http.get(apiUrls.install('SlowRollbackPlugin'), () => apiFailureJson('依赖安装失败')))
getDynamicButtonConfig().onClick()
await getDialogEvents()['open-plugin'](target)
@@ -1453,9 +1448,7 @@ describe('PluginCardListView search installation', () => {
const target = createPlugin({ id: 'FailedPlugin', plugin_name: '失败插件' })
await renderList({ installed: () => [stable], market: () => [target] })
await waitForRequestsToFinish()
server.use(
http.get(apiUrls.install('FailedPlugin'), () => HttpResponse.json({ message: '依赖安装失败', success: false })),
)
server.use(http.get(apiUrls.install('FailedPlugin'), () => apiFailureJson('依赖安装失败')))
getDynamicButtonConfig().onClick()
await getDialogEvents()['open-plugin'](target)
@@ -1525,7 +1518,7 @@ describe('PluginCardListView folders and persistence', () => {
})
await screen.findByText('plugin:已安装插件')
await waitForRequestsToFinish()
server.use(http.post(apiUrls.folders, () => HttpResponse.json({ message: '保存被拒绝', success: false })))
server.use(http.post(apiUrls.folders, () => apiFailureJson('保存被拒绝')))
getDynamicMenuItem('plugin.newFolder').action()
const events = getDialogEvents()
@@ -1543,7 +1536,7 @@ describe('PluginCardListView folders and persistence', () => {
await renderList({ folders: () => ({ Existing: [] }) })
await screen.findByText('folder:Existing')
await waitForRequestsToFinish()
server.use(http.post(apiUrls.folders, () => HttpResponse.json({ data: null, success: true })))
server.use(http.post(apiUrls.folders, () => apiJson(null)))
getDynamicMenuItem('plugin.newFolder').action()
const events = getDialogEvents()
@@ -1568,7 +1561,7 @@ describe('PluginCardListView folders and persistence', () => {
})
await screen.findByText('folder:Tools')
await waitForRequestsToFinish()
server.use(http.post(apiUrls.folders, () => HttpResponse.json({ data: null, success: true })))
server.use(http.post(apiUrls.folders, () => apiJson(null)))
await fireEvent.click(screen.getByRole('button', { name: 'configure-folder-Tools' }))
await waitFor(() => expect(screen.getByLabelText('folder-color-Tools')).toHaveTextContent('#ff0000'))
@@ -1597,7 +1590,7 @@ describe('PluginCardListView folders and persistence', () => {
saveAttempt += 1
return saveAttempt === 2
? HttpResponse.json({ message: 'HTTP failure' }, { status: 500 })
: HttpResponse.json({ message: 'Rejected', success: false })
: apiFailureJson('Rejected')
}),
)
@@ -1626,9 +1619,7 @@ describe('PluginCardListView folders and persistence', () => {
await waitForRequestsToFinish()
server.use(
http.post(apiUrls.folders, () =>
saveSucceeds
? HttpResponse.json({ data: null, success: true })
: HttpResponse.json({ message: 'Rejected' }, { status: 500 }),
saveSucceeds ? apiJson(null) : HttpResponse.json({ message: 'Rejected' }, { status: 500 }),
),
)
@@ -1664,11 +1655,9 @@ describe('PluginCardListView folders and persistence', () => {
server.use(
http.post(apiUrls.order, async ({ request }) => {
savedOrder = await request.json()
return orderSucceeds
? HttpResponse.json({ data: null, success: true })
: HttpResponse.json({ message: 'Rejected', success: false })
return orderSucceeds ? apiJson(null) : apiFailureJson('Rejected')
}),
http.post(apiUrls.folders, () => HttpResponse.json({ data: null, success: true })),
http.post(apiUrls.folders, () => apiJson(null)),
)
getHeaderButton('mdi-sort-variant').action?.()
@@ -1712,17 +1701,17 @@ describe('PluginCardListView folders and persistence', () => {
server.use(
http.get(apiUrls.order, () => {
orderReads += 1
return HttpResponse.json({ data: { value: persistedOrder }, success: true })
return apiJson({ value: persistedOrder })
}),
http.get(apiUrls.folders, () => {
folderReads += 1
return HttpResponse.json({ Tools: [] })
return apiJson({ Tools: [] })
}),
http.post(apiUrls.order, async ({ request }) => {
persistedOrder = (await request.json()) as unknown[]
return HttpResponse.json({ data: null, success: true })
return apiJson(null)
}),
http.post(apiUrls.folders, () => HttpResponse.json({ message: 'Rejected', success: false })),
http.post(apiUrls.folders, () => apiFailureJson('Rejected')),
)
getHeaderButton('mdi-sort-variant').action?.()
@@ -1762,15 +1751,15 @@ describe('PluginCardListView folders and persistence', () => {
server.use(
http.get(apiUrls.order, () => {
orderReads += 1
return HttpResponse.json({ data: { value: persistedOrder }, success: true })
return apiJson({ value: persistedOrder })
}),
http.get(apiUrls.folders, () => {
folderReads += 1
return HttpResponse.json({ Tools: ['Plugin-A', 'Plugin-B'] })
return apiJson({ Tools: ['Plugin-A', 'Plugin-B'] })
}),
http.post(apiUrls.order, async ({ request }) => {
persistedOrder = (await request.json()) as unknown[]
return HttpResponse.json({ data: null, success: true })
return apiJson(null)
}),
http.post(apiUrls.folders, () => HttpResponse.json({ message: 'Rejected' }, { status: 500 })),
)
@@ -1800,12 +1789,8 @@ describe('PluginCardListView folders and persistence', () => {
await screen.findByText('folder:Tools')
await waitForRequestsToFinish()
server.use(
http.post(apiUrls.order, () => HttpResponse.json({ data: null, success: true })),
http.post(apiUrls.folders, () =>
folderSaveSucceeds
? HttpResponse.json({ data: null, success: true })
: HttpResponse.json({ message: 'Rejected', success: false }),
),
http.post(apiUrls.order, () => apiJson(null)),
http.post(apiUrls.folders, () => (folderSaveSucceeds ? apiJson(null) : apiFailureJson('Rejected'))),
)
getHeaderButton('mdi-sort-variant').action?.()
@@ -1845,12 +1830,8 @@ describe('PluginCardListView folders and persistence', () => {
await screen.findByText('folder:Tools')
await waitForRequestsToFinish()
server.use(
http.post(apiUrls.order, () =>
orderSucceeds
? HttpResponse.json({ data: null, success: true })
: HttpResponse.json({ message: 'Rejected', success: false }),
),
http.post(apiUrls.folders, () => HttpResponse.json({ data: null, success: true })),
http.post(apiUrls.order, () => (orderSucceeds ? apiJson(null) : apiFailureJson('Rejected'))),
http.post(apiUrls.folders, () => apiJson(null)),
)
await fireEvent.click(screen.getByRole('button', { name: 'open-folder-Tools' }))
@@ -306,6 +306,62 @@ function createModelFieldStub(name: string) {
})
}
/** 保留选择值契约,排除 Vuetify 菜单动画和定位对设置业务测试的影响。 */
const SelectFieldStub = defineComponent({
name: 'VSelectStub',
props: {
disabled: { type: Boolean, default: false },
itemTitle: { type: String, default: 'title' },
itemValue: { type: String, default: 'value' },
items: { type: Array, default: () => [] },
label: { type: String, default: '' },
modelValue: { default: null },
multiple: { type: Boolean, default: false },
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const itemValue = (item: unknown) => {
if (!item || typeof item !== 'object') return item
return (item as Record<string, unknown>)[props.itemValue]
}
const itemTitle = (item: unknown) => {
if (!item || typeof item !== 'object') return String(item ?? '')
return String((item as Record<string, unknown>)[props.itemTitle] ?? '')
}
const findItemValue = (value: string) => props.items.map(itemValue).find(item => String(item) === value)
return () =>
h('label', [
h('span', props.label),
h(
'select',
{
'aria-label': props.label,
disabled: props.disabled,
multiple: props.multiple,
onChange: (event: Event) => {
const select = event.target as HTMLSelectElement
if (props.multiple) {
const selectedValues = Array.from(select.selectedOptions, option => findItemValue(option.value))
emit('update:modelValue', selectedValues)
return
}
emit('update:modelValue', findItemValue(select.value))
},
...(props.multiple ? {} : { value: String(props.modelValue ?? '') }),
},
props.items.map(item => {
const value = itemValue(item)
const selected = props.multiple
? (props.modelValue as unknown[] | null)?.some(modelValue => String(modelValue) === String(value))
: String(props.modelValue ?? '') === String(value)
return h('option', { selected, value: String(value) }, itemTitle(item))
}),
),
])
},
})
const CronFieldStub = createModelFieldStub('VCronFieldStub')
const PathFieldStub = createModelFieldStub('VPathFieldStub')
@@ -314,7 +370,7 @@ async function renderSettings(props: { active?: boolean } = {}) {
props,
global: {
components: { VCronField: CronFieldStub, VPathField: PathFieldStub },
stubs: { VDialogCloseBtn: true },
stubs: { VDialogCloseBtn: true, VSelect: SelectFieldStub },
},
stubActions: false,
})
@@ -327,7 +383,7 @@ function getBasicCard() {
}
function getSettingsCard(title: string) {
const card = screen.getByText(title).closest('.v-card')
const card = screen.getByText(title, { selector: '.v-card-title' }).closest('.v-card')
expect(card).not.toBeNull()
return within(card as HTMLElement)
}
@@ -340,7 +396,17 @@ async function openAdvancedTab(tab: string) {
async function selectOption(label: string, option: string) {
const user = userEvent.setup()
await user.click(screen.getByLabelText(label))
const control = screen.getByLabelText(label)
if (control instanceof HTMLSelectElement) {
if (control.multiple) {
const nextOption = within(control).getByRole('option', { name: option }) as HTMLOptionElement
await user.selectOptions(control, nextOption.value)
} else {
await user.selectOptions(control, option)
}
return
}
await user.click(control)
await user.click(await screen.findByRole('option', { name: option }))
}
@@ -954,8 +1020,12 @@ describe('AccountSettingSystem', () => {
]) {
await fireEvent.click(dialog.getByLabelText(label))
}
await user.click(dialog.getByLabelText('Fanart语言'))
await user.click(await screen.findByRole('option', { name: '日文' }))
const fanartLanguages = dialog.getByLabelText('Fanart语言')
expect(fanartLanguages).toHaveValue(['zh', 'en'])
await selectOption('Fanart语言', '日文')
await waitFor(() => expect(fanartLanguages).toHaveValue(['zh', 'en', 'ja']))
await user.deselectOptions(fanartLanguages, 'en')
await waitFor(() => expect(fanartLanguages).toHaveValue(['zh', 'ja']))
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
@@ -963,7 +1033,7 @@ describe('AccountSettingSystem', () => {
expect.objectContaining({
ACOUSTID_API_KEY: 'acoustid-key',
FANART_ENABLE: true,
FANART_LANG: 'zh,en,ja',
FANART_LANG: 'zh,ja',
MEDIA_RECOGNIZE_SHARE: false,
META_CACHE_EXPIRE: '48',
MUSIC_COVER_PROXY: 'https://music.example',
@@ -8,6 +8,7 @@ import { subscribeApiUrls, subscribeListHandler } from '@tests/support/msw/handl
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -142,7 +143,8 @@ function sequenceSubscribeList(responses: Array<{ body: Subscribe[]; status?: nu
onRequest()
const response = responses[Math.min(index, responses.length - 1)]
index += 1
return HttpResponse.json(response.body, { status: response.status ?? 200 })
if ((response.status ?? 200) >= 400) return HttpResponse.json(response.body, { status: response.status })
return apiJson(response.body, { status: response.status ?? 200 })
})
}
@@ -8,6 +8,7 @@ import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { HttpResponse, http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -196,7 +197,7 @@ describe('SubscribePopularView', () => {
: url.searchParams.get('sort_type') === 'time'
? '最新热门结果'
: '默认热门结果'
return HttpResponse.json([createSubscribeMovie({ title })])
return apiJson([createSubscribeMovie({ title })])
}),
)
const user = userEvent.setup()
@@ -232,9 +233,9 @@ describe('SubscribePopularView', () => {
http.get(subscribeApiUrls.popular, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
if (page === '2') return HttpResponse.json([second])
return HttpResponse.json([])
if (page === '1') return apiJson([first])
if (page === '2') return apiJson([second])
return apiJson([])
}),
)
const user = userEvent.setup()
@@ -260,11 +261,11 @@ describe('SubscribePopularView', () => {
http.get(subscribeApiUrls.popular, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
if (page === '1') return apiJson([first])
if (page === '2') {
return HttpResponse.json([{ ...first, title: '第一页跨页重复项' }, second])
return apiJson([{ ...first, title: '第一页跨页重复项' }, second])
}
return HttpResponse.json([
return apiJson([
{ ...second, title: '第二页乱序重复项' },
{ ...first, title: '第一页乱序重复项' },
{ ...first, title: '第一页页内重复项' },
@@ -320,7 +321,7 @@ describe('SubscribePopularView', () => {
http.get(subscribeApiUrls.popular, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
return HttpResponse.json(page === '1' ? [first] : [second])
return apiJson(page === '1' ? [first] : [second])
}),
)
@@ -371,7 +372,7 @@ describe('SubscribePopularView', () => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([createSubscribeMovie({ title: '热门首载重试成功' })])
return apiJson([createSubscribeMovie({ title: '热门首载重试成功' })])
}),
)
const user = userEvent.setup()
@@ -395,10 +396,10 @@ describe('SubscribePopularView', () => {
http.get(subscribeApiUrls.popular, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
if (page === '1') return apiJson([first])
pageTwoAttempts += 1
if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([second])
return apiJson([second])
}),
)
const user = userEvent.setup()
@@ -427,9 +428,9 @@ describe('SubscribePopularView', () => {
if (!url.searchParams.has('genre_id')) {
await gate.promise
staleResponse()
return HttpResponse.json([createSubscribeMovie({ title: '过期热门结果' })])
return apiJson([createSubscribeMovie({ title: '过期热门结果' })])
}
return HttpResponse.json([createSubscribeMovie({ title: '新筛选热门结果' })])
return apiJson([createSubscribeMovie({ title: '新筛选热门结果' })])
}),
)
const user = userEvent.setup()
@@ -8,6 +8,7 @@ import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { flushPromises } from '@vue/test-utils'
import { HttpResponse, http } from 'msw'
import { apiJson } from '@tests/support/msw/response'
import { defineComponent, h, onMounted, ref, type PropType } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -213,7 +214,7 @@ describe('SubscribeShareView', () => {
: url.searchParams.get('sort_type') === 'count'
? '热门分享结果'
: '默认分享结果'
return HttpResponse.json([createSubscribeShare({ share_title: shareTitle })])
return apiJson([createSubscribeShare({ share_title: shareTitle })])
}),
)
const user = userEvent.setup()
@@ -249,9 +250,9 @@ describe('SubscribeShareView', () => {
http.get(subscribeApiUrls.shares, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
if (page === '2') return HttpResponse.json([second])
return HttpResponse.json([])
if (page === '1') return apiJson([first])
if (page === '2') return apiJson([second])
return apiJson([])
}),
)
const user = userEvent.setup()
@@ -280,7 +281,7 @@ describe('SubscribeShareView', () => {
http.get(subscribeApiUrls.shares, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
return HttpResponse.json(page === '1' ? [first] : [second])
return apiJson(page === '1' ? [first] : [second])
}),
)
@@ -332,7 +333,7 @@ describe('SubscribeShareView', () => {
const url = new URL(request.url)
requests.push(url)
const keyword = url.searchParams.get('name') ?? ''
return HttpResponse.json([
return apiJson([
createSubscribeShare({ share_title: keyword === '新关键字' ? '新关键字分享' : '旧关键字分享' }),
])
}),
@@ -371,7 +372,7 @@ describe('SubscribeShareView', () => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (requestedPages.length === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([createSubscribeShare({ share_title: '分享首载重试成功' })])
return apiJson([createSubscribeShare({ share_title: '分享首载重试成功' })])
}),
)
const user = userEvent.setup()
@@ -395,10 +396,10 @@ describe('SubscribeShareView', () => {
http.get(subscribeApiUrls.shares, ({ request }) => {
const page = new URL(request.url).searchParams.get('page') ?? ''
requestedPages.push(page)
if (page === '1') return HttpResponse.json([first])
if (page === '1') return apiJson([first])
pageTwoAttempts += 1
if (pageTwoAttempts === 1) return HttpResponse.json({ detail: 'failed' }, { status: 500 })
return HttpResponse.json([second])
return apiJson([second])
}),
)
const user = userEvent.setup()
@@ -427,9 +428,9 @@ describe('SubscribeShareView', () => {
if (url.searchParams.get('name') === '旧关键字') {
await gate.promise
staleResponse()
return HttpResponse.json([createSubscribeShare({ share_title: '过期关键字分享' })])
return apiJson([createSubscribeShare({ share_title: '过期关键字分享' })])
}
return HttpResponse.json([createSubscribeShare({ share_title: '新关键字实时分享' })])
return apiJson([createSubscribeShare({ share_title: '新关键字实时分享' })])
}),
)
+23
View File
@@ -0,0 +1,23 @@
import { HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import { apiFailureJson, apiJson } from '../support/msw/response'
describe('API response fixtures', () => {
it('keeps raw MSW responses unchanged for HTTP errors and plugin protocols', async () => {
const response = HttpResponse.json([{ id: 1 }])
await expect(response.json()).resolves.toEqual([{ id: 1 }])
})
it('builds the complete main-program success envelope explicitly', async () => {
const response = apiJson({ id: 1 })
await expect(response.json()).resolves.toEqual({ data: { id: 1 }, message: '', success: true })
})
it('builds the complete main-program business-failure envelope explicitly', async () => {
const response = apiFailureJson('rejected', { id: 1 })
await expect(response.json()).resolves.toEqual({ data: { id: 1 }, message: 'rejected', success: false })
})
})
+17
View File
@@ -6,6 +6,7 @@ const workflowPath = resolve(process.cwd(), '.github/workflows/test.yml')
const releaseWorkflowPath = resolve(process.cwd(), '.github/workflows/build.yml')
const testingGuidePath = resolve(process.cwd(), 'docs/testing.md')
const codeQualityGuidePath = resolve(process.cwd(), 'docs/code-quality.md')
const prettierIgnorePath = resolve(process.cwd(), '.prettierignore')
describe('前端测试 workflow', () => {
it('在 PR 与 v3 push 上运行,并将变更文件格式检查限制为 PR', () => {
@@ -59,9 +60,25 @@ describe('前端测试 workflow', () => {
const workflow = readFileSync(releaseWorkflowPath, 'utf8')
expect(workflow).toContain('name: Build Moviepilot-Frontend v3')
expect(workflow).toContain('permissions:\n contents: write')
expect(workflow).toContain('push:\n branches:\n - v3')
expect(workflow).toContain(" - 'package.json'")
expect(workflow).toContain('timeout-minutes: 30')
expect(workflow).toContain('uses: actions/checkout@v7')
expect(workflow).toContain('uses: actions/setup-node@v7')
expect(workflow).toContain("node-version: '24'")
expect(workflow).toContain('cache-dependency-path: yarn.lock')
expect(workflow).toContain('corepack install --global yarn@1.22.22')
expect(workflow).toContain('run: yarn --frozen-lockfile')
expect(workflow).toContain('echo "frontend_version=v$frontend_version"')
expect(workflow).not.toContain('actions/checkout@v3')
expect(workflow).not.toContain('actions/setup-node@v3')
expect(workflow).not.toContain(' - v2')
})
it('全仓格式检查排除仓内 linked worktree', () => {
const prettierIgnore = readFileSync(prettierIgnorePath, 'utf8')
expect(prettierIgnore).toContain('/.worktrees/')
})
})
-56
View File
@@ -1,7 +1,6 @@
import '@testing-library/jest-dom/vitest'
import { abortAllRequests } from '@/utils/requestOptimizer'
import { cleanup } from '@testing-library/vue'
import { HttpResponse, type HttpResponseInit, type JsonBodyType } from 'msw'
import { afterAll, afterEach, beforeAll, vi } from 'vitest'
import { createDataApiMock as buildDataApiMock } from './support/apiMock'
import { server } from './support/msw/server'
@@ -13,61 +12,6 @@ declare global {
globalThis.createDataApiMock = buildDataApiMock
const originalJsonResponse = HttpResponse.json.bind(HttpResponse)
/** 判断测试夹具是否已经表达了业务响应状态。 */
function isLegacyApiEnvelope(body: unknown): body is Record<string, unknown> & { success: boolean } {
return (
body !== null &&
typeof body === 'object' &&
!Array.isArray(body) &&
typeof (body as { success?: unknown }).success === 'boolean'
)
}
/** 识别 MSW 夹具中历史遗留的 Axios `{ data }` 响应壳。 */
function isLegacyDataWrapper(body: unknown): body is { data: unknown } {
if (!body || typeof body !== 'object' || Array.isArray(body)) return false
return Object.keys(body).length === 1 && 'data' in body
}
/**
* envelope
*
* 使 Axios adapterHTTP
* 便 detailBlob
*/
function installApiEnvelopeFixtureAdapter() {
Object.defineProperty(HttpResponse, 'json', {
configurable: true,
value: <BodyType extends JsonBodyType>(body?: BodyType | null, init: HttpResponseInit = {}) => {
const status = init.status ?? 200
if (status >= 400) return originalJsonResponse(body, init)
if (isLegacyApiEnvelope(body)) {
const envelope = body as Record<string, unknown> & { success: boolean }
return originalJsonResponse(
{
...envelope,
message: typeof envelope.message === 'string' ? envelope.message : '',
data: Object.hasOwn(envelope, 'data') ? envelope.data : null,
},
init,
)
}
if (isLegacyDataWrapper(body)) {
return originalJsonResponse({ success: true, message: '', data: body.data }, init)
}
return originalJsonResponse({ success: true, message: '', data: body ?? null }, init)
},
writable: true,
})
}
installApiEnvelopeFixtureAdapter()
class ResizeObserverStub implements ResizeObserver {
disconnect() {}
observe() {}
+7 -3
View File
@@ -1,5 +1,6 @@
import type { DiscoverSource } from '@/api/types'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { apiJson } from '../response'
const API_BASE_URL = 'http://localhost/api/v1/'
@@ -21,7 +22,8 @@ export function discoverSourcesHandler(
) {
return http.get(discoverApiUrls.sources, async () => {
await onRequest()
return HttpResponse.json(sources as unknown as JsonBodyType, { status })
if (status >= 400) return HttpResponse.json(sources as unknown as JsonBodyType, { status })
return apiJson(sources, { status })
})
}
@@ -32,7 +34,8 @@ export function discoverOrderConfigHandler(
) {
return http.get(discoverApiUrls.orderConfig, async () => {
await onRequest()
return HttpResponse.json({ data: { value: order }, success: status < 400 }, { status })
if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status })
return apiJson({ value: order }, { status })
})
}
@@ -43,6 +46,7 @@ export function saveDiscoverOrderHandler(
return http.post(discoverApiUrls.orderConfig, async ({ request }) => {
const order = (await request.json()) as DiscoverTabConfigItem[]
await onSave(order)
return HttpResponse.json({ success: status < 400 }, { status })
if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status })
return apiJson(null, { status })
})
}
+15 -7
View File
@@ -1,5 +1,6 @@
import type { DownloadHistory, DownloadingInfo } from '@/api/types'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { apiFailureJson, apiJson } from '../response'
const API_BASE_URL = 'http://localhost/api/v1/'
@@ -15,8 +16,15 @@ export const downloadApiUrls = {
history: new URL('history/download', API_BASE_URL).href,
}
function jsonResponse(body: JsonBodyType, status: number) {
return HttpResponse.json(body, { status })
function dataResponse(body: JsonBodyType, status: number) {
if (status >= 400) return HttpResponse.json(body, { status })
return apiJson(body, { status })
}
function mutationResponse(response: DownloadMutationResponse, status: number) {
if (status >= 400) return HttpResponse.json(response, { status })
if (!response.success) return apiFailureJson(response.message ?? '', null, { status })
return apiJson(null, { status })
}
/** 拦截下载任务快照查询,并保留下载器查询参数供断言。 */
@@ -29,7 +37,7 @@ export function downloadingListHandler(
const url = new URL(request.url)
await onRequest(url)
const body = typeof response === 'function' ? await response(url) : response
return jsonResponse(body as unknown as JsonBodyType, status)
return dataResponse(body as unknown as JsonBodyType, status)
})
}
@@ -44,7 +52,7 @@ export function downloadActionHandler(
return http.get(downloadApiUrls.action(operation, hash), async ({ request }) => {
const url = new URL(request.url)
await onRequest(url)
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -58,7 +66,7 @@ export function deleteDownloadHandler(
return http.delete(downloadApiUrls.delete(hash), async ({ request }) => {
const url = new URL(request.url)
await onRequest(url)
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -72,7 +80,7 @@ export function downloadHistoryHandler(
const url = new URL(request.url)
await onRequest(url)
const body = typeof response === 'function' ? await response(url) : response
return jsonResponse(body as unknown as JsonBodyType, status)
return dataResponse(body as unknown as JsonBodyType, status)
})
}
@@ -84,6 +92,6 @@ export function deleteDownloadHistoryHandler(
) {
return http.delete(downloadApiUrls.history, async ({ request }) => {
await onRequest((await request.json()) as DownloadHistory)
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
+17 -9
View File
@@ -1,5 +1,6 @@
import type { MediaInfo, MediaSeason, NotExistMediaInfo, TmdbEpisode } from '@/api/types'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { apiFailureJson, apiJson } from '../response'
const API_BASE_URL = 'http://localhost/api/v1/'
@@ -14,6 +15,11 @@ export const mediaApiUrls = {
seasons: new URL('media/seasons', API_BASE_URL).href,
}
function dataResponse(body: JsonBodyType, status: number) {
if (status >= 400) return HttpResponse.json(body, { status })
return apiJson(body, { status })
}
export function mediaExistsHandler(
response: { data?: Record<string, unknown>; message?: string; success: boolean },
status = 200,
@@ -21,7 +27,8 @@ export function mediaExistsHandler(
) {
return http.get(mediaApiUrls.exists, async ({ request }) => {
await onRequest(new URL(request.url))
return HttpResponse.json({ message: '', ...response } as JsonBodyType, { status })
if (!response.success) return apiFailureJson(response.message ?? '', response.data ?? null, { status })
return apiJson(response.data ?? null, { status })
})
}
@@ -33,7 +40,7 @@ export function mediaDetailsHandler(
) {
return http.get(mediaApiUrls.details(String(mediaId)), ({ request }) => {
onRequest(new URL(request.url))
return HttpResponse.json(response as unknown as JsonBodyType, { status })
return dataResponse(response as unknown as JsonBodyType, status)
})
}
@@ -44,7 +51,7 @@ export function mediaRemoteExistsHandler(
) {
return http.post(mediaApiUrls.existsRemote, async ({ request }) => {
await onRequest((await request.json()) as Record<string, unknown>)
return HttpResponse.json(response as JsonBodyType, { status })
return dataResponse(response as JsonBodyType, status)
})
}
@@ -56,7 +63,8 @@ export function mediaPlayHandler(
) {
return http.get(mediaApiUrls.play(itemId), () => {
onRequest()
return HttpResponse.json(response as JsonBodyType, { status })
if (!response.success) return apiFailureJson(response.message ?? '', response.data ?? null, { status })
return apiJson(response.data ?? null, { status })
})
}
@@ -69,7 +77,7 @@ export function tmdbSeasonEpisodesHandler(
) {
return http.get(new URL(`tmdb/${tmdbId}/${season}`, API_BASE_URL).href, ({ request }) => {
onRequest(new URL(request.url))
return HttpResponse.json(response as unknown as JsonBodyType, { status })
return dataResponse(response as unknown as JsonBodyType, status)
})
}
@@ -80,7 +88,7 @@ export function mediaSeasonsHandler(
) {
return http.get(mediaApiUrls.seasons, async ({ request }) => {
await onRequest(new URL(request.url))
return HttpResponse.json(response as unknown as JsonBodyType, { status })
return dataResponse(response as unknown as JsonBodyType, status)
})
}
@@ -92,7 +100,7 @@ export function mediaEpisodeGroupsHandler(
) {
return http.get(mediaApiUrls.episodeGroups(tmdbId), async ({ request }) => {
await onRequest(new URL(request.url))
return HttpResponse.json(response as JsonBodyType, { status })
return dataResponse(response as JsonBodyType, status)
})
}
@@ -104,7 +112,7 @@ export function mediaGroupSeasonsHandler(
) {
return http.get(mediaApiUrls.groupSeasons(episodeGroup), async ({ request }) => {
await onRequest(new URL(request.url))
return HttpResponse.json(response as unknown as JsonBodyType, { status })
return dataResponse(response as unknown as JsonBodyType, status)
})
}
@@ -116,6 +124,6 @@ export function mediaNotExistsHandler(
return http.post(mediaApiUrls.notExists, async ({ request }) => {
const payload = (await request.json()) as Record<string, unknown>
await onRequest(payload, new URL(request.url))
return HttpResponse.json(response as unknown as JsonBodyType, { status })
return dataResponse(response as unknown as JsonBodyType, status)
})
}
+12 -18
View File
@@ -1,5 +1,6 @@
import type { RecommendSource } from '@/api/types'
import { HttpResponse, http, type JsonBodyType } from 'msw'
import { apiJson } from '../response'
const API_BASE_URL = 'http://localhost/api/v1/'
@@ -9,36 +10,28 @@ export const recommendApiUrls = {
sources: new URL('recommend/source', API_BASE_URL).href,
}
export function recommendSourcesHandler(
sources: RecommendSource[],
status = 200,
onRequest: () => void = () => {},
) {
export function recommendSourcesHandler(sources: RecommendSource[], status = 200, onRequest: () => void = () => {}) {
return http.get(recommendApiUrls.sources, () => {
onRequest()
return HttpResponse.json(sources, { status })
if (status >= 400) return HttpResponse.json(sources, { status })
return apiJson(sources, { status })
})
}
export function recommendConfigHandler(
config: JsonBodyType,
status = 200,
onRequest: () => void = () => {},
) {
export function recommendConfigHandler(config: JsonBodyType, status = 200, onRequest: () => void = () => {}) {
return http.get(recommendApiUrls.config, () => {
onRequest()
return HttpResponse.json({ data: { value: config } }, { status })
if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status })
return apiJson({ value: config }, { status })
})
}
export function saveRecommendConfigHandler(
onSave: (config: Record<string, boolean>) => void = () => {},
status = 200,
) {
export function saveRecommendConfigHandler(onSave: (config: Record<string, boolean>) => void = () => {}, status = 200) {
return http.post(recommendApiUrls.config, async ({ request }) => {
const config = (await request.json()) as Record<string, boolean>
onSave(config)
return HttpResponse.json({ success: status < 400 }, { status })
if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status })
return apiJson(null, { status })
})
}
@@ -50,6 +43,7 @@ export function recommendMediaHandler(
) {
return http.get(recommendApiUrls.media(sourcePath), () => {
onRequest()
return HttpResponse.json(response as JsonBodyType, { status })
if (status >= 400) return HttpResponse.json(response as JsonBodyType, { status })
return apiJson(response, { status })
})
}
+44 -33
View File
@@ -9,6 +9,7 @@ import type {
TransferDirectoryConf,
} from '@/api/types'
import { HttpResponse, http, type JsonBodyType, type RequestHandler } from 'msw'
import { apiFailureJson, apiJson } from '../response'
const API_BASE_URL = 'http://localhost/api/v1/'
@@ -65,8 +66,15 @@ export const subscribeApiUrls = {
update: new URL('subscribe/', API_BASE_URL).href,
}
function jsonResponse(body: JsonBodyType, status: number) {
return HttpResponse.json(body, { status })
function dataResponse(body: JsonBodyType, status: number) {
if (status >= 400) return HttpResponse.json(body, { status })
return apiJson(body, { status })
}
function mutationResponse(response: SubscribeMutationResponse, status: number) {
if (status >= 400) return HttpResponse.json(response, { status })
if (!response.success) return apiFailureJson(response.message ?? '', response.data ?? null, { status })
return apiJson(response.data ?? null, { status })
}
export function subscribeListHandler(
@@ -76,7 +84,7 @@ export function subscribeListHandler(
) {
return http.get(subscribeApiUrls.list, ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse(response, status)
return dataResponse(response, status)
})
}
@@ -87,7 +95,7 @@ export function popularSubscribesHandler(
) {
return http.get(subscribeApiUrls.popular, async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response as unknown as JsonBodyType, status)
return dataResponse(response as unknown as JsonBodyType, status)
})
}
@@ -98,7 +106,7 @@ export function subscribeSharesHandler(
) {
return http.get(subscribeApiUrls.shares, async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response as unknown as JsonBodyType, status)
return dataResponse(response as unknown as JsonBodyType, status)
})
}
@@ -110,7 +118,7 @@ export function shareSubscribeHandler(
return http.post(subscribeApiUrls.share, async ({ request }) => {
const payload = (await request.json()) as SubscribeShare
await onShare(payload)
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -122,7 +130,7 @@ export function forkSubscribeHandler(
return http.post(subscribeApiUrls.fork, async ({ request }) => {
const payload = (await request.json()) as SubscribeShare
await onFork(payload)
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -133,7 +141,8 @@ export function followSubscribersSettingHandler(
) {
return http.get(subscribeApiUrls.followSubscribers, async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse({ data: { value: users }, success: status < 400 }, status)
if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status })
return apiJson({ value: users }, { status })
})
}
@@ -144,7 +153,7 @@ export function followSubscriberHandler(
) {
return http.post(subscribeApiUrls.follow, async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -155,7 +164,7 @@ export function unfollowSubscriberHandler(
) {
return http.delete(subscribeApiUrls.follow, async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -167,7 +176,7 @@ export function deleteSubscribeShareHandler(
) {
return http.delete(subscribeApiUrls.shareById(id), async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -178,7 +187,7 @@ export function subscribeShareStatisticsHandler(
) {
return http.get(subscribeApiUrls.shareStatistics, async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response as unknown as JsonBodyType, status)
return dataResponse(response as unknown as JsonBodyType, status)
})
}
@@ -190,7 +199,7 @@ export function subscribeFilesHandler(
) {
return http.get(subscribeApiUrls.filesById(id), async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response, status)
return dataResponse(response, status)
})
}
@@ -202,7 +211,7 @@ export function subscribeHistoryHandler(
) {
return http.get(subscribeApiUrls.historyByType(type), async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response as unknown as JsonBodyType, status)
return dataResponse(response as unknown as JsonBodyType, status)
})
}
@@ -214,7 +223,7 @@ export function deleteSubscribeHistoryHandler(
) {
return http.delete(subscribeApiUrls.historyById(id), async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -226,7 +235,8 @@ export function subscribeOrderConfigHandler(
) {
return http.get(subscribeApiUrls.orderConfig(type), ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse({ data: { value }, success: status < 400 }, status)
if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status })
return apiJson({ value }, { status })
})
}
@@ -239,7 +249,7 @@ export function saveSubscribeOrderConfigHandler(
return http.post(subscribeApiUrls.orderConfig(type), async ({ request }) => {
const payload = (await request.json()) as { id: number }[]
await onSave(payload, new URL(request.url))
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -251,7 +261,7 @@ export function updateSubscribeStatusHandler(
) {
return http.put(subscribeApiUrls.statusById(id), async ({ request }) => {
await onRequest(new URL(request.url))
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -263,7 +273,7 @@ export function searchSubscribeByIdHandler(
) {
return http.get(subscribeApiUrls.searchById(id), ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -275,7 +285,7 @@ export function resetSubscribeByIdHandler(
) {
return http.get(subscribeApiUrls.resetById(id), ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -287,7 +297,7 @@ export function createSubscribeHandler(
return http.post(subscribeApiUrls.create, async ({ request }) => {
const payload = (await request.json()) as Record<string, unknown>
onCreate(payload)
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -299,7 +309,7 @@ export function updateSubscribeHandler(
return http.put(subscribeApiUrls.update, async ({ request }) => {
const payload = (await request.json()) as Record<string, unknown>
onUpdate(payload)
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -311,7 +321,7 @@ export function querySubscribeByMediaHandler(
) {
return http.get(subscribeApiUrls.queryByMedia(mediaId), ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse(subscribe as JsonBodyType, status)
return dataResponse(subscribe as JsonBodyType, status)
})
}
@@ -323,7 +333,7 @@ export function deleteSubscribeByMediaHandler(
) {
return http.delete(subscribeApiUrls.deleteByMedia(mediaId), ({ request }) => {
onRequest(new URL(request.url))
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -335,7 +345,7 @@ export function subscribeDetailsHandler(
) {
return http.get(subscribeApiUrls.details(id), () => {
onRequest()
return jsonResponse(subscribe as unknown as JsonBodyType, status)
return dataResponse(subscribe as unknown as JsonBodyType, status)
})
}
@@ -347,7 +357,7 @@ export function deleteSubscribeByIdHandler(
) {
return http.delete(subscribeApiUrls.deleteById(id), () => {
onRequest()
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -359,7 +369,8 @@ export function defaultSubscribeConfigHandler(
) {
return http.get(subscribeApiUrls.defaultConfig(type), () => {
onRequest()
return jsonResponse({ data: { value: config }, success: status < 400 }, status)
if (status >= 400) return HttpResponse.json({ detail: 'failed' }, { status })
return apiJson({ value: config }, { status })
})
}
@@ -372,7 +383,7 @@ export function saveDefaultSubscribeConfigHandler(
return http.post(subscribeApiUrls.defaultConfig(type, true), async ({ request }) => {
const payload = (await request.json()) as Record<string, unknown>
onSave(payload)
return jsonResponse(response, status)
return mutationResponse(response, status)
})
}
@@ -409,23 +420,23 @@ export function subscribeDialogOptionHandlers(options: SubscribeDialogOptions =
return [
http.get(subscribeApiUrls.sites, () => {
onSites()
return jsonResponse(sites as unknown as JsonBodyType, 200)
return dataResponse(sites as unknown as JsonBodyType, 200)
}),
http.get(subscribeApiUrls.downloaders, () => {
onDownloaders()
return jsonResponse(downloaders as unknown as JsonBodyType, 200)
return dataResponse(downloaders as unknown as JsonBodyType, 200)
}),
http.get(subscribeApiUrls.directories, () => {
onDirectories()
return jsonResponse({ data: { value: directories }, success: true }, 200)
return apiJson({ value: directories })
}),
http.get(subscribeApiUrls.filterRuleGroups, () => {
onFilterRuleGroups()
return jsonResponse({ data: { value: filterRuleGroups }, success: true }, 200)
return apiJson({ value: filterRuleGroups })
}),
http.get(subscribeApiUrls.episodeGroups(tmdbId), () => {
onEpisodeGroups()
return jsonResponse(episodeGroups as unknown as JsonBodyType, 200)
return dataResponse(episodeGroups as unknown as JsonBodyType, 200)
}),
]
}
+18
View File
@@ -0,0 +1,18 @@
import type { ApiResponse } from '@/api/types'
import { HttpResponse, type HttpResponseInit, type JsonBodyType } from 'msw'
/** 构造主程序普通 API 的固定三段式响应。 */
export function apiEnvelope<T>(data: T | null, message = ''): ApiResponse<T> {
return { data, message, success: true }
}
/** 返回主程序普通 API 的成功响应;调用点必须显式选择该协议。 */
export function apiJson<T>(data: T | null, init: HttpResponseInit = {}) {
return HttpResponse.json(apiEnvelope(data) as unknown as JsonBodyType, init)
}
/** 返回主程序普通 API 的业务失败响应。 */
export function apiFailureJson<T = null>(message: string, data: T | null = null, init: HttpResponseInit = {}) {
const body: ApiResponse<T> = { data, message, success: false }
return HttpResponse.json(body as unknown as JsonBodyType, init)
}