test(plugin): cover market settings lifecycle (#661)

This commit is contained in:
InfinityPacer
2026-08-11 06:25:03 +08:00
committed by GitHub
parent bd691bb404
commit e14b39c574
7 changed files with 395 additions and 21 deletions
@@ -1,5 +1,6 @@
<script lang="ts" setup>
import api from '@/api'
import type { ApiResponse } from '@/api/types'
import draggable from 'vuedraggable'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
@@ -24,21 +25,42 @@ const repoText = ref('')
const newRepoUrl = ref('')
const editingIndex = ref<number | null>(null)
const editingUrl = ref('')
const loadingRepos = ref(true)
const loadReposFailed = ref(false)
const saving = ref(false)
const syncingSources = ref(false)
const emit = defineEmits(['save', 'close'])
const emit = defineEmits(['save', 'close', 'changed'])
const parsedTextRepos = computed(() => parseRepoInput(repoText.value))
const activeRepoCount = computed(() =>
editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length,
)
const saveDisabled = computed(
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
() =>
loadingRepos.value ||
loadReposFailed.value ||
saving.value ||
syncingSources.value ||
activeRepoCount.value === 0 ||
(editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
)
/** 判断仓库地址是否为可保存的 HTTP URL。 */
function isValidRepoUrl(url: string) {
return /^https?:\/\//i.test(url)
if (/\s/.test(url)) return false
try {
const parsedUrl = new URL(url)
return (
['http:', 'https:'].includes(parsedUrl.protocol) &&
Boolean(parsedUrl.hostname) &&
!parsedUrl.hostname.includes('%')
)
} catch {
return false
}
}
/** 将粘贴的仓库地址文本解析为有效、无效和重复地址列表。 */
@@ -110,25 +132,36 @@ function switchEditorMode(mode: EditorMode | undefined) {
/** 加载插件市场仓库配置。 */
async function queryMarketRepoSetting() {
loadingRepos.value = true
loadReposFailed.value = false
try {
const result: { [key: string]: any } = await api.get('system/setting/public/PLUGIN_MARKET')
if (result && result.data && result.data.value) {
repoList.value = parseRepoInput(result.data.value).repos
syncTextFromList()
}
const result = (await api.get('system/setting/public/PLUGIN_MARKET')) as unknown as ApiResponse<{
value?: string
}>
if (!result.success) throw new Error(result.message || '')
repoList.value = parseRepoInput(result.data?.value || '').repos
syncTextFromList()
} catch (error) {
console.log(error)
loadReposFailed.value = true
} finally {
loadingRepos.value = false
}
}
/** 保存插件市场仓库配置。 */
async function saveHandle() {
if (saving.value) return
try {
const reposToSave = normalizeCurrentRepos()
if (!reposToSave) return
saving.value = true
const repoStringToSave = reposToSave.join(',')
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)
const result = (await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)) as unknown as ApiResponse<unknown>
if (result.success) {
$toast.success(t('dialog.pluginMarketSetting.saveSuccess'))
@@ -136,6 +169,9 @@ async function saveHandle() {
} else $toast.error(t('dialog.pluginMarketSetting.saveFailed', { message: result?.message }))
} catch (error) {
console.log(error)
$toast.error(t('dialog.pluginMarketSetting.saveFailed', { message: error instanceof Error ? error.message : '' }))
} finally {
saving.value = false
}
}
@@ -143,7 +179,12 @@ async function saveHandle() {
async function syncPluginSources() {
try {
syncingSources.value = true
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})
const result = (await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})) as unknown as ApiResponse<{
added_count?: number
repos?: string[]
total_count?: number
value?: string
}>
if (result.success) {
const repos = Array.isArray(result.data?.repos)
@@ -151,6 +192,7 @@ async function syncPluginSources() {
: parseRepoInput(result.data?.value || '').repos
repoList.value = repos
syncTextFromList()
emit('changed')
$toast.success(
t('dialog.pluginMarketSetting.syncSuccess', {
added: result.data?.added_count ?? 0,
@@ -292,7 +334,21 @@ onMounted(() => {
</VCardItem>
<VDivider />
<VCardText class="plugin-market-dialog-body pt-4">
<div class="plugin-market-toolbar">
<div v-if="loadingRepos" class="plugin-market-empty text-center text-medium-emphasis">
<VProgressCircular indeterminate color="primary" class="mb-2" />
<div>{{ t('common.loadingText') }}</div>
</div>
<VAlert v-else-if="loadReposFailed" type="error" variant="tonal" class="plugin-market-load-error">
<div class="d-flex flex-wrap align-center justify-space-between ga-2">
<span>{{ t('common.serverConnectionFailed') }}</span>
<VBtn prepend-icon="mdi-refresh" size="small" variant="tonal" @click="queryMarketRepoSetting">
{{ t('common.retry') }}
</VBtn>
</div>
</VAlert>
<div v-if="!loadingRepos && !loadReposFailed" class="plugin-market-toolbar">
<div class="plugin-market-toolbar-hint">
<VIcon icon="mdi-information-outline" size="18" />
<span>{{ t('dialog.pluginMarketSetting.repoCountHint', { count: activeRepoCount }) }}</span>
@@ -333,7 +389,7 @@ onMounted(() => {
</div>
</div>
<div v-if="editorMode === 'list'" class="plugin-market-list-panel">
<div v-if="!loadingRepos && !loadReposFailed && editorMode === 'list'" class="plugin-market-list-panel">
<div class="plugin-market-input">
<VTextField
v-model="newRepoUrl"
@@ -441,7 +497,7 @@ onMounted(() => {
</div>
</div>
<div v-else class="plugin-market-text-panel">
<div v-else-if="!loadingRepos && !loadReposFailed" class="plugin-market-text-panel">
<div class="plugin-market-textarea-field">
<VIcon icon="mdi-text-box-edit-outline" class="plugin-market-textarea-icon" />
<textarea
@@ -484,7 +540,7 @@ onMounted(() => {
variant="tonal"
prepend-icon="mdi-cloud-sync-outline"
:loading="syncingSources"
:disabled="syncingSources"
:disabled="syncingSources || saving || loadingRepos || loadReposFailed"
@click="syncPluginSources"
>
{{ t('dialog.pluginMarketSetting.syncSources') }}
@@ -496,6 +552,7 @@ onMounted(() => {
@click="saveHandle"
prepend-icon="mdi-content-save-check"
class="px-5"
:loading="saving"
:disabled="saveDisabled"
>
{{ t('dialog.pluginMarketSetting.save') }}
@@ -1,7 +1,113 @@
import PluginMarketSettingDialog from '@/components/dialog/PluginMarketSettingDialog.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { readFileSync } from 'node:fs'
import { cwd } from 'node:process'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { defineComponent, h } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
toastWarning: vi.fn(),
}))
vi.mock('@/api', () => ({
default: {
get: mocks.apiGet,
post: mocks.apiPost,
},
}))
vi.mock('vue-toastification', () => ({
useToast: () => ({
error: mocks.toastError,
success: mocks.toastSuccess,
warning: mocks.toastWarning,
}),
}))
vi.mock('vuedraggable', async () => {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name: 'DraggableStub',
props: {
itemKey: { type: Function, required: true },
modelValue: { type: Array, required: true },
},
setup(props, { slots }) {
return () =>
h(
'div',
props.modelValue.map((element, index) =>
h('div', { 'data-repo-key': props.itemKey(element) }, slots.item?.({ element, index })),
),
)
},
}),
}
})
const DialogStub = defineComponent({
name: 'VDialog',
template: '<div role="dialog"><slot /></div>',
})
const CloseButtonStub = defineComponent({
name: 'VDialogCloseBtn',
emits: ['click'],
template: '<button type="button" @click="$emit(\'click\')">关闭</button>',
})
const IconButtonStub = defineComponent({
name: 'IconBtn',
props: { icon: String },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'aria-label': props.icon, onClick: () => emit('click'), type: 'button' })
},
})
function createDeferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((done, fail) => {
resolve = done
reject = fail
})
return { promise, reject, resolve }
}
async function renderDialog() {
const changed = vi.fn()
const close = vi.fn()
const save = vi.fn()
const result = await renderWithProviders(PluginMarketSettingDialog, {
props: {
onChanged: changed,
onClose: close,
onSave: save,
},
global: {
components: {
VDialogCloseBtn: CloseButtonStub,
},
stubs: {
IconBtn: IconButtonStub,
VDialog: DialogStub,
},
},
})
return { ...result, changed, close, save }
}
const dialogSource = readFileSync(resolve(cwd(), 'src/components/dialog/PluginMarketSettingDialog.vue'), 'utf8')
@@ -15,6 +121,206 @@ function getStyleRule(selector: string) {
return dialogSource.slice(ruleStart, ruleEnd)
}
describe('PluginMarketSettingDialog behavior', () => {
beforeEach(() => {
mocks.apiGet.mockReset().mockResolvedValue({ data: { value: '' }, success: true })
mocks.apiPost.mockReset().mockResolvedValue({ success: true })
mocks.toastError.mockReset()
mocks.toastSuccess.mockReset()
mocks.toastWarning.mockReset()
vi.spyOn(console, 'log').mockImplementation(() => {})
})
it('parses configured repositories with stable deduplication and supports list edits', async () => {
const firstRepo = 'https://github.com/example/first'
const secondRepo = 'https://github.com/example/second.git'
mocks.apiGet.mockResolvedValueOnce({
data: { value: ` ${firstRepo}${secondRepo}\n${firstRepo} ` },
success: true,
})
const user = userEvent.setup()
await renderDialog()
expect(await screen.findByText('example/first')).toBeInTheDocument()
expect(screen.getByText('example/second')).toBeInTheDocument()
expect(screen.getAllByText(firstRepo)).toHaveLength(1)
await user.type(screen.getByPlaceholderText('输入插件仓库地址'), 'not-a-url')
await user.click(screen.getByRole('button', { name: '添加仓库' }))
expect(mocks.toastError).toHaveBeenCalledWith('请输入有效的URL地址')
await user.clear(screen.getByPlaceholderText('输入插件仓库地址'))
const NativeURL = URL
function BrowserLikeURL(input: string | URL, base?: string | URL) {
if (String(input) === 'https://not a valid') {
return { hostname: 'not%20a%20valid', protocol: 'https:' } as URL
}
return new NativeURL(input, base)
}
vi.stubGlobal('URL', BrowserLikeURL)
await user.type(screen.getByPlaceholderText('输入插件仓库地址'), 'https://not a valid')
await user.click(screen.getByRole('button', { name: '添加仓库' }))
expect(mocks.toastError).toHaveBeenCalledTimes(2)
vi.stubGlobal('URL', NativeURL)
await user.clear(screen.getByPlaceholderText('输入插件仓库地址'))
await user.type(screen.getByPlaceholderText('输入插件仓库地址'), firstRepo)
await user.click(screen.getByRole('button', { name: '添加仓库' }))
expect(mocks.toastError).toHaveBeenCalledWith('该地址已存在')
await user.click(screen.getAllByRole('button', { name: 'mdi-pencil' })[1])
const editingInput = screen.getAllByRole('textbox')[1]
await user.type(editingInput, '/discarded')
await fireEvent.keyUp(editingInput, { key: 'Escape' })
expect(screen.queryByDisplayValue(`${secondRepo}/discarded`)).not.toBeInTheDocument()
await user.click(screen.getAllByRole('button', { name: 'mdi-pencil' })[1])
const resumedEditingInput = screen.getAllByRole('textbox')[1]
await user.clear(resumedEditingInput)
await user.type(resumedEditingInput, 'https://git.example.com/team/renamed')
await fireEvent.keyUp(resumedEditingInput, { key: 'Enter' })
expect(screen.getAllByText('https://git.example.com/team/renamed')).toHaveLength(2)
await user.click(screen.getAllByRole('button', { name: 'mdi-delete' })[0])
expect(screen.queryByText(firstRepo)).not.toBeInTheDocument()
})
it('keeps a failed initial query distinct from an empty setting and retries in place', async () => {
mocks.apiGet
.mockRejectedValueOnce(new Error('load failed'))
.mockResolvedValueOnce({ data: { value: '' }, success: true })
const user = userEvent.setup()
await renderDialog()
expect(await screen.findByText('服务器连接失败')).toBeInTheDocument()
expect(screen.queryByText('暂无插件仓库地址')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '重试' }))
expect(await screen.findByText('暂无插件仓库地址')).toBeInTheDocument()
expect(screen.queryByText('服务器连接失败')).not.toBeInTheDocument()
expect(mocks.apiGet).toHaveBeenCalledTimes(2)
})
it('normalizes text input, rejects invalid entries and saves the JSON string payload', async () => {
mocks.apiGet.mockResolvedValueOnce({ data: { value: 'https://github.com/example/original' }, success: true })
const user = userEvent.setup()
const { close, save } = await renderDialog()
await screen.findByText('example/original')
await user.click(screen.getByRole('tab', { name: '文本维护' }))
const textInput = screen.getByRole('textbox')
await user.clear(textInput)
await user.type(
textInput,
'https://github.com/example/ainvalid\nhttps://github.com/example/a,https://github.com/example/b',
)
expect(screen.getByText('文本中有 1 个无效地址,请修正后保存。')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
await user.click(screen.getByRole('tab', { name: '列表维护' }))
expect(mocks.toastWarning).toHaveBeenCalledWith('已忽略 1 个无效地址')
await user.click(screen.getByRole('tab', { name: '文本维护' }))
const normalizedTextInput = screen.getByRole('textbox')
await user.clear(normalizedTextInput)
await user.type(
normalizedTextInput,
'https://github.com/example/ahttps://github.com/example/a\nhttps://github.com/example/b',
)
expect(screen.getByText('重复地址会在保存时自动去重。')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => {
expect(mocks.apiPost).toHaveBeenCalledWith(
'system/setting/PLUGIN_MARKET',
'https://github.com/example/a,https://github.com/example/b',
)
})
expect(save).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '关闭' }))
expect(close).toHaveBeenCalledOnce()
})
it('locks save while pending and reports both business and HTTP failures without closing', async () => {
mocks.apiGet.mockResolvedValue({ data: { value: 'https://github.com/example/repo' }, success: true })
const pendingSave = createDeferred<{ success: boolean; message?: string }>()
mocks.apiPost.mockReturnValueOnce(pendingSave.promise)
const user = userEvent.setup()
const { save } = await renderDialog()
await screen.findByText('example/repo')
await user.click(screen.getByRole('button', { name: '保存' }))
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
pendingSave.resolve({ success: false, message: 'rejected' })
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件仓库保存失败:rejected'))
expect(save).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()
mocks.apiPost.mockRejectedValueOnce(new Error('network failed'))
await user.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('插件仓库保存失败:network failed'))
expect(save).not.toHaveBeenCalled()
})
it('publishes a persistent change after source sync and always releases its loading state', async () => {
mocks.apiGet.mockResolvedValue({ data: { value: 'https://github.com/example/original' }, success: true })
const pendingSync = createDeferred<{
success: boolean
data: { added_count: number; repos: string[]; total_count: number }
}>()
mocks.apiPost.mockReturnValueOnce(pendingSync.promise)
const user = userEvent.setup()
const { changed, close, save } = await renderDialog()
await screen.findByText('example/original')
await user.click(screen.getByRole('button', { name: '同步插件源' }))
expect(screen.getByRole('button', { name: '同步插件源' })).toBeDisabled()
pendingSync.resolve({
success: true,
data: {
added_count: 1,
repos: ['https://github.com/example/original', 'https://github.com/example/source'],
total_count: 2,
},
})
expect(await screen.findByText('example/source')).toBeInTheDocument()
expect(changed).toHaveBeenCalledOnce()
expect(save).not.toHaveBeenCalled()
expect(close).not.toHaveBeenCalled()
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/PLUGIN_MARKET/sync-wiki', {})
expect(screen.getByRole('button', { name: '同步插件源' })).toBeEnabled()
})
it('keeps the dialog open when source sync reports a business or HTTP failure', async () => {
mocks.apiGet.mockResolvedValue({ data: { value: 'https://github.com/example/original' }, success: true })
mocks.apiPost
.mockResolvedValueOnce({ success: false, message: 'source rejected' })
.mockRejectedValueOnce(new Error('source offline'))
const user = userEvent.setup()
const { changed } = await renderDialog()
await screen.findByText('example/original')
await user.click(screen.getByRole('button', { name: '同步插件源' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('同步插件源失败:source rejected'))
expect(changed).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: '同步插件源' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith('同步插件源失败:source offline'))
expect(changed).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '同步插件源' })).toBeEnabled()
})
})
describe('PluginMarketSettingDialog theme surfaces', () => {
it('uses shared theme tokens for the view switch and editor containers', () => {
const modeSwitchRule = getStyleRule('.plugin-market-mode-switch')