mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
test(plugin): cover market settings lifecycle (#661)
This commit is contained in:
@@ -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/a,invalid\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/a,https://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')
|
||||
|
||||
Reference in New Issue
Block a user