const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const html = fs.readFileSync(path.join(__dirname, '..', '..', 'channels.html'), 'utf8'); const protocolSource = fs.readFileSync(path.join(__dirname, 'channels-protocols.js'), 'utf8'); const source = fs.readFileSync(path.join(__dirname, 'channels-modals.js'), 'utf8'); const CHANNEL_TYPES = ['anthropic', 'codex', 'openai', 'gemini']; const KEY_STRATEGIES = ['sequential', 'round_robin']; function createClassList() { const names = new Set(); return { add(...tokens) { tokens.filter(Boolean).forEach((token) => names.add(token)); }, remove(...tokens) { tokens.forEach((token) => names.delete(token)); }, contains(token) { return names.has(token); } }; } function createElement(props = {}) { const listeners = new Map(); const attributes = new Map(); const element = { id: props.id || '', name: props.name || '', type: props.type || '', value: props.value || '', checked: !!props.checked, disabled: !!props.disabled, hidden: !!props.hidden, dataset: props.dataset || {}, style: props.style || {}, textContent: props.textContent || '', children: [], classList: createClassList(), appendChild(child) { this.children.push(child); return child; }, addEventListener(type, handler) { const current = listeners.get(type) || []; current.push(handler); listeners.set(type, current); }, async dispatchEvent(event) { const nextEvent = event || {}; const type = nextEvent.type; if (!type) return; nextEvent.target = nextEvent.target || element; nextEvent.currentTarget = element; if (typeof nextEvent.preventDefault !== 'function') { nextEvent.preventDefault = () => {}; } const handlers = listeners.get(type) || []; for (const handler of handlers) { await handler(nextEvent); } }, setAttribute(name, value) { attributes.set(name, String(value)); }, getAttribute(name) { return attributes.get(name); }, reset() {}, focus() {} }; return element; } function createDeferred() { let resolve; let reject; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } function createHarness({ channel = null, apiKeys = [{ api_key: 'sk-test' }], channelCheckIntervalHours = 24, channelCheckIntervalResponse = null, apiKeysResponse = null, saveResponse = null, duplicateResponses = null } = {}) { let protocolTransformInputs = []; let protocolTransformModeInputs = []; const elements = {}; const radiosByName = new Map(); const fetchCalls = []; const dataFetchCalls = []; const duplicateResponseQueue = Array.isArray(duplicateResponses) ? duplicateResponses.slice() : null; let afterSavePayload = null; let nextTimerId = 1; const timers = new Map(); function registerRadio(name, value, checked = false) { const radio = createElement({ name, value, type: 'radio', checked }); if (!radiosByName.has(name)) { radiosByName.set(name, []); } radiosByName.get(name).push(radio); return radio; } function setCheckedRadio(name, value) { for (const radio of radiosByName.get(name) || []) { radio.checked = radio.value === value; } } function getCheckedRadio(name) { return (radiosByName.get(name) || []).find((radio) => radio.checked) || null; } function getRadio(name, value) { return (radiosByName.get(name) || []).find((radio) => radio.value === value) || null; } function queryInputs(selector) { const match = selector.match(/^input\[name="([^"]+)"\](?:\[value="([^"]+)"\])?(?::checked)?$/); if (!match) return []; const [, name, value] = match; const checkedOnly = selector.endsWith(':checked'); const pool = name === 'protocolTransform' ? protocolTransformInputs : name === 'protocolTransformMode' ? protocolTransformModeInputs : radiosByName.get(name) || []; return pool.filter((input) => { if (value && input.value !== value) return false; if (checkedOnly && !input.checked) return false; return true; }); } function parseProtocolTransformInputs(markup) { const inputs = []; const regex = //g; let match; while ((match = regex.exec(markup))) { inputs.push(createElement({ name: 'protocolTransform', type: 'checkbox', value: match[1], checked: /\bchecked\b/.test(match[2]), disabled: /\bdisabled\b/.test(match[2]) })); } protocolTransformInputs = inputs; } function parseProtocolTransformModeInputs(markup) { const inputs = []; const regex = //g; let match; while ((match = regex.exec(markup))) { inputs.push(createElement({ name: 'protocolTransformMode', type: 'radio', value: match[1], checked: /\bchecked\b/.test(match[2]), disabled: /\bdisabled\b/.test(match[2]) })); } protocolTransformModeInputs = inputs; } elements.protocolTransformsContainer = createElement({ id: 'protocolTransformsContainer' }); Object.defineProperty(elements.protocolTransformsContainer, 'innerHTML', { get() { return this._innerHTML || ''; }, set(value) { this._innerHTML = value; parseProtocolTransformInputs(String(value || '')); } }); elements.protocolTransformModeContainer = createElement({ id: 'protocolTransformModeContainer' }); Object.defineProperty(elements.protocolTransformModeContainer, 'innerHTML', { get() { return this._innerHTML || ''; }, set(value) { this._innerHTML = value; parseProtocolTransformModeInputs(String(value || '')); } }); elements.channelTypeRadios = createElement({ id: 'channelTypeRadios', dataset: {} }); elements.channelForm = createElement({ id: 'channelForm', dataset: {} }); elements.channelName = createElement({ id: 'channelName', value: channel ? channel.name : '协议转换渠道' }); elements.channelUrl = createElement({ id: 'channelUrl', value: '' }); elements.channelApiKey = createElement({ id: 'channelApiKey', value: '' }); elements.channelPriority = createElement({ id: 'channelPriority', value: channel ? String(channel.priority || 0) : '0' }); elements.channelRPMLimit = createElement({ id: 'channelRPMLimit', value: channel ? String(channel.rpm_limit || 0) : '0' }); elements.channelDailyCostLimit = createElement({ id: 'channelDailyCostLimit', value: channel ? String(channel.daily_cost_limit || 0) : '0' }); elements.channelCostMultiplier = createElement({ id: 'channelCostMultiplier', value: channel && channel.cost_multiplier ? String(channel.cost_multiplier) : '1' }); elements.channelEnabled = createElement({ id: 'channelEnabled', type: 'checkbox', checked: channel ? channel.enabled !== false : true }); elements.channelScheduledCheckEnabled = createElement({ id: 'channelScheduledCheckEnabled', type: 'checkbox', checked: !!(channel && channel.scheduled_check_enabled), dataset: {} }); elements.channelScheduledCheckModel = createElement({ id: 'channelScheduledCheckModel', value: channel ? channel.scheduled_check_model || '' : '' }); elements.channelScheduledCheckModelInput = createElement({ id: 'channelScheduledCheckModelInput', value: '' }); elements.channelScheduledCheckModelDropdown = createElement({ id: 'channelScheduledCheckModelDropdown', dataset: {}, style: {} }); elements.channelScheduledCheckModelWrapper = createElement({ id: 'channelScheduledCheckModelWrapper', hidden: false }); elements.channelScheduledCheckEnabledWrapper = createElement({ id: 'channelScheduledCheckEnabledWrapper', hidden: false }); elements.channelScheduledCheckModelHint = createElement({ id: 'channelScheduledCheckModelHint', textContent: '' }); elements.channelDuplicateHint = createElement({ id: 'channelDuplicateHint', hidden: true, textContent: '' }); elements.channelModal = createElement({ id: 'channelModal' }); elements.channelSaveBtn = createElement({ id: 'channelSaveBtn', disabled: false }); elements.inlineEyeIcon = createElement({ id: 'inlineEyeIcon', style: {} }); elements.inlineEyeOffIcon = createElement({ id: 'inlineEyeOffIcon', style: {} }); elements.modelFilterInput = createElement({ id: 'modelFilterInput', value: '' }); elements.redirectCount = createElement({ id: 'redirectCount', textContent: '0' }); elements.redirectTableBody = createElement({ id: 'redirectTableBody' }); elements.selectAllModels = createElement({ id: 'selectAllModels', type: 'checkbox', checked: false }); CHANNEL_TYPES.forEach((type, index) => { registerRadio('channelType', type, index === 0); }); KEY_STRATEGIES.forEach((type, index) => { registerRadio('keyStrategy', type, index === 0); }); const sandbox = { console, alert() {}, confirm() { return true; }, editingChannelId: null, channelFormDirty: false, channels: channel ? [channel] : [], currentChannelKeyCooldowns: [], redirectTableData: channel && Array.isArray(channel.models) ? channel.models.map((model) => ({ model: model.model || '', redirect_model: model.redirect_model || '' })) : [{ model: 'claude-3-7-sonnet', redirect_model: '' }], selectedModelIndices: new Set(), selectedURLIndices: new Set(), inlineURLTableData: channel ? String(channel.url || '').split('\n').filter(Boolean) : ['https://api.example.com'], inlineKeyTableData: apiKeys.map((key) => key.api_key || key), inlineKeyVisible: true, currentModelFilter: '', deletingChannelRequest: null, selectedChannelIds: new Set(), filters: { channelType: 'all' }, resetChannelFormDirty() { sandbox.channelFormDirty = false; }, markChannelFormDirty() { sandbox.channelFormDirty = true; }, renderInlineKeyTable() {}, renderInlineURLTable() {}, renderRedirectTable() {}, fetchURLStats() {}, clearChannelsCache() {}, loadChannels: async () => {}, saveChannelsFilters() {}, normalizeSelectedChannelID(value) { return String(value); }, setInlineURLTableData(value) { sandbox.inlineURLTableData = String(value || '').split('\n').filter(Boolean); }, getValidInlineURLs() { return sandbox.inlineURLTableData.filter((url) => url && url.trim()); }, createSearchableCombobox(config) { return { setValue(value, label) { elements.channelScheduledCheckModel.value = value; elements.channelScheduledCheckModelInput.value = label; }, refresh() {}, getInput() { return elements.channelScheduledCheckModelInput; } }; }, fetchDataWithAuth: async (requestPath) => { dataFetchCalls.push(requestPath); if (requestPath === '/admin/settings/channel_check_interval_hours') { if (channelCheckIntervalResponse) { return await channelCheckIntervalResponse; } return { value: channelCheckIntervalHours }; } if (channel && requestPath === `/admin/channels/${channel.id}/keys`) { if (apiKeysResponse) { return await apiKeysResponse; } return apiKeys; } throw new Error(`unexpected fetchDataWithAuth: ${requestPath}`); }, fetchAPIWithAuth: async (requestPath, options) => { fetchCalls.push({ path: requestPath, options }); if (requestPath === '/admin/channels/check-duplicate' && duplicateResponseQueue && duplicateResponseQueue.length > 0) { const nextResponse = duplicateResponseQueue.shift(); return typeof nextResponse === 'function' ? nextResponse(requestPath, options) : nextResponse; } if ((requestPath === '/admin/channels' || requestPath === `/admin/channels/${channel?.id}`) && saveResponse) { return await saveResponse; } return { success: true }; }, setTimeout(callback) { const timerId = nextTimerId++; timers.set(timerId, callback); return timerId; }, clearTimeout(timerId) { timers.delete(timerId); }, document: { body: {}, createDocumentFragment() { return { children: [], appendChild(child) { this.children.push(child); } }; }, getElementById(id) { return elements[id] || null; }, querySelector(selector) { return queryInputs(selector)[0] || null; }, querySelectorAll(selector) { return queryInputs(selector); } }, window: { t(key) { const labels = { 'channels.protocolTransformAnthropic': 'Claude Code', 'channels.protocolBadgeAnthropic': 'Claude', 'channels.protocolTransformCodex': 'Codex', 'channels.protocolTransformOpenAI': 'OpenAI', 'channels.protocolTransformGemini': 'Gemini', 'channels.protocolTransformNative': '原生', 'channels.protocolTransformModeLocal': 'ccLoad(实验性)', 'channels.protocolTransformModeUpstream': '上游', 'channels.duplicateModelsNotAllowed': 'duplicate models', 'channels.duplicateChannelHint': 'duplicate channels: {list}{extra}', 'channels.duplicateChannelHintMore': ' and {count} more', 'channels.duplicateChannelHintSeparator': ', ', 'channels.fillAllRequired': 'fill required', 'channels.channelAdded': 'added', 'channels.channelUpdated': 'updated', 'channels.copySuffix': 'Copy', 'channels.scheduledCheckModelDefault': '默认首个模型', 'channels.scheduledCheckModelHint': '仅用于定时检测,留空表示默认首个模型', 'channels.scheduledCheckModelFallback': '当前检测模型已失效,已回退为默认首个模型', 'channels.saveFailed': 'save failed', 'channels.fetchModelsSuccess': 'fetch models success', 'channels.fetchModelsFailed': 'fetch models failed', 'channels.addedCommonModels': 'added common models', 'channels.noPresetModels': 'no preset models', 'channels.unsavedChanges': 'unsaved changes' }; let text = labels[key] || key; const params = arguments[1] || {}; Object.entries(params).forEach(([paramKey, value]) => { text = text.replaceAll(`{${paramKey}}`, String(value)); }); return text; }, initDelegatedActions() {}, ChannelTypeManager: { async renderChannelTypeRadios(_containerId, currentType) { setCheckedRadio('channelType', currentType || 'anthropic'); } }, ChannelModalHooks: { async afterSave(payload) { afterSavePayload = payload; } }, TemplateEngine: { render(_templateId, data = {}) { return { dataset: { model: data.model || '', redirectModel: data.redirect_model || '' }, querySelector() { return null; } }; } }, showSuccess() {}, showError(message) { throw new Error(message); }, i18nText(key, fallback) { const result = sandbox.window.t(key); return result && result !== key ? result : fallback; } }, TemplateEngine: { render(_templateId, data = {}) { return { dataset: { model: data.model || '', redirectModel: data.redirect_model || '' }, querySelector() { return null; } }; } } }; vm.createContext(sandbox); vm.runInContext(`${protocolSource}\n${source}\nthis.__protocolTransformsTest = {\n initChannelEditorActions,\n renderProtocolTransformOptions,\n renderProtocolTransformModeOptions,\n getSelectedProtocolTransforms,\n getSelectedProtocolTransformMode,\n syncProtocolTransformModeForURLs,\n editChannel,\n copyChannel,\n saveChannel,\n refreshChannelDuplicateHint,\n scheduleChannelDuplicateHintCheck\n};`, sandbox); return { api: sandbox.__protocolTransformsTest, elements, fetchCalls, dataFetchCalls, getAfterSavePayload: () => afterSavePayload, getProtocolTransformInput(value) { return protocolTransformInputs.find((input) => input.value === value) || null; }, getProtocolTransformValues() { return protocolTransformInputs.map((input) => ({ value: input.value, checked: input.checked, disabled: input.disabled })); }, getProtocolTransformModeInput(value) { return protocolTransformModeInputs.find((input) => input.value === value) || null; }, getRadio, setCheckedRadio, setEditingChannelId(value) { sandbox.editingChannelId = value; }, getCurrentKeyCooldowns() { return sandbox.currentChannelKeyCooldowns.map((item) => ({ ...item })); }, setInlineURLs(urls) { sandbox.inlineURLTableData = Array.isArray(urls) ? urls.slice() : []; }, async runTimers() { const callbacks = Array.from(timers.values()); timers.clear(); await Promise.all(callbacks.map((callback) => callback())); }, async changeChannelType(nextType) { const target = getRadio('channelType', nextType); assert.ok(target, `missing channelType radio: ${nextType}`); setCheckedRadio('channelType', nextType); await elements.channelTypeRadios.dispatchEvent({ type: 'change', target }); }, async submitForm() { await elements.channelForm.dispatchEvent({ type: 'submit' }); } }; } test('channels 编辑弹窗按两行两列展示协议配置', () => { assert.match(html, /id="protocolTransformsContainer"/); assert.match(html, /id="protocolTransformModeContainer"/); assert.match( html, /class="channel-editor-primary-row"[\s\S]*?class="channel-editor-primary-field channel-editor-primary-field--name"[\s\S]*?data-i18n="channels\.channelName"[\s\S]*?class="channel-editor-primary-field channel-editor-primary-field--type"[\s\S]*?data-i18n="channels\.modal\.upstreamProtocol"[\s\S]*?id="channelTypeRadios"/ ); assert.match( html, /class="channel-editor-primary-row"[\s\S]*?class="channel-editor-primary-field channel-editor-primary-field--transforms"[\s\S]*?data-i18n="channels\.modal\.protocolTransforms"[\s\S]*?id="protocolTransformsContainer"[\s\S]*?class="channel-editor-primary-field channel-editor-primary-field--mode"[\s\S]*?data-i18n="channels\.modal\.protocolTransformMode"[\s\S]*?id="protocolTransformModeContainer"/ ); }); test('Gemini 协议转换选项在标签后内联渲染实验性提示', () => { const harness = createHarness(); harness.api.renderProtocolTransformOptions('anthropic', ['gemini']); assert.match( harness.elements.protocolTransformsContainer.innerHTML, /value="gemini"[\s\S]*?channel-editor-radio-option-copy--with-hint[\s\S]*?>Gemini<\/span>[\s\S]*?class="channel-editor-radio-hint"[\s\S]*?data-i18n="channels\.modal\.protocolTransformsHint"[\s\S]*?额外暴露协议,不含原生上游协议/ ); }); test('切换 channel type 后会禁用并剔除原生 protocol transform,仅保留额外协议', async () => { const harness = createHarness(); harness.api.initChannelEditorActions(); harness.api.renderProtocolTransformOptions('anthropic', ['openai', 'codex']); assert.deepEqual( harness.getProtocolTransformValues().map((item) => item.value), ['anthropic', 'codex', 'openai', 'gemini'] ); assert.equal(harness.getProtocolTransformInput('anthropic').disabled, true); assert.equal(harness.getProtocolTransformInput('anthropic').checked, false); assert.equal(harness.getProtocolTransformInput('openai').checked, true); assert.equal(harness.getProtocolTransformInput('codex').checked, true); assert.equal(harness.getProtocolTransformInput('gemini').checked, false); await harness.changeChannelType('openai'); assert.deepEqual( harness.getProtocolTransformValues().map((item) => item.value), ['anthropic', 'codex', 'openai', 'gemini'] ); assert.equal(harness.getProtocolTransformInput('openai').disabled, true); assert.equal(harness.getProtocolTransformInput('openai').checked, false); assert.equal(harness.getProtocolTransformInput('codex').checked, true); assert.equal(harness.getProtocolTransformInput('anthropic').checked, false); assert.equal(harness.getProtocolTransformInput('gemini').checked, false); assert.deepEqual( JSON.parse(JSON.stringify(harness.api.getSelectedProtocolTransforms('openai'))), ['codex'] ); }); test('所有渠道类型都渲染完整四协议集合,仅禁用原生协议', () => { const harness = createHarness(); for (const channelType of CHANNEL_TYPES) { harness.api.renderProtocolTransformOptions(channelType, ['anthropic', 'codex', 'openai', 'gemini']); assert.deepEqual( harness.getProtocolTransformValues().map((item) => item.value), ['anthropic', 'codex', 'openai', 'gemini'] ); for (const protocol of CHANNEL_TYPES) { const input = harness.getProtocolTransformInput(protocol); assert.ok(input, `missing protocol transform input: ${protocol}`); assert.equal(input.disabled, protocol === channelType); assert.equal(input.checked, protocol !== channelType); } } }); test('编辑渠道时会回填 protocol_transforms,并禁用原生协议选项', async () => { const harness = createHarness({ channel: { id: 7, name: 'edited-channel', url: 'https://api.example.com', channel_type: 'gemini', protocol_transform_mode: 'upstream', protocol_transforms: ['openai', 'anthropic'], key_strategy: 'sequential', priority: 9, daily_cost_limit: 0, enabled: true, scheduled_check_enabled: false, scheduled_check_model: '', models: [{ model: 'gpt-5.4', redirect_model: '' }] }, apiKeys: [{ api_key: 'sk-live' }] }); await harness.api.editChannel(7); assert.equal(harness.getRadio('channelType', 'gemini').checked, true); assert.equal(harness.getProtocolTransformInput('gemini').disabled, true); assert.equal(harness.getProtocolTransformInput('gemini').checked, false); assert.equal(harness.getProtocolTransformInput('anthropic').checked, true); assert.equal(harness.getProtocolTransformInput('openai').checked, true); assert.equal(harness.getProtocolTransformModeInput('upstream').checked, true); assert.deepEqual( harness.getProtocolTransformValues().filter((item) => item.checked).map((item) => item.value).sort(), ['anthropic', 'openai'] ); }); test('编辑渠道时会回填 API Key 禁用状态', async () => { const harness = createHarness({ channel: { id: 7, name: 'edited-channel', url: 'https://api.example.com', channel_type: 'anthropic', protocol_transform_mode: 'upstream', protocol_transforms: [], key_strategy: 'sequential', priority: 9, daily_cost_limit: 0, enabled: true, scheduled_check_enabled: false, scheduled_check_model: '', models: [{ model: 'gpt-5.4', redirect_model: '' }] }, apiKeys: [ { key_index: 0, api_key: 'sk-live', cooldown_until: 0, disabled: false }, { key_index: 1, api_key: 'sk-disabled', cooldown_until: 0, disabled: true } ] }); await harness.api.editChannel(7); assert.deepEqual(harness.getCurrentKeyCooldowns(), [ { key_index: 0, cooldown_remaining_ms: 0, disabled: false }, { key_index: 1, cooldown_remaining_ms: 0, disabled: true } ]); }); test('编辑渠道会并行读取定时检测配置和 API Keys,避免串行等待', async () => { const setting = createDeferred(); const keys = createDeferred(); const harness = createHarness({ channel: { id: 7, name: 'edited-channel', url: 'https://api.example.com', channel_type: 'gemini', protocol_transform_mode: 'upstream', protocol_transforms: ['openai'], key_strategy: 'sequential', priority: 9, daily_cost_limit: 0, enabled: true, scheduled_check_enabled: false, scheduled_check_model: '', models: [{ model: 'gpt-5.4', redirect_model: '' }] }, channelCheckIntervalResponse: setting.promise, apiKeysResponse: keys.promise }); const editPromise = harness.api.editChannel(7); await Promise.resolve(); await Promise.resolve(); assert.deepEqual(harness.dataFetchCalls, [ '/admin/settings/channel_check_interval_hours', '/admin/channels/7/keys' ]); setting.resolve({ value: 24 }); keys.resolve([{ api_key: 'sk-live' }]); await editPromise; }); test('URL 含 # 时转换方式禁用上游并强制选择 ccLoad', () => { const harness = createHarness(); harness.setInlineURLs(['https://api.example.com/v1/chat/completions#']); harness.api.renderProtocolTransformModeOptions('upstream'); assert.equal(harness.getProtocolTransformModeInput('upstream').disabled, true); assert.equal(harness.getProtocolTransformModeInput('upstream').checked, false); assert.equal(harness.getProtocolTransformModeInput('local').checked, true); assert.equal(harness.api.getSelectedProtocolTransformMode(), 'local'); }); test('保存含 # URL 的渠道时 payload 不能提交 upstream 转换方式', async () => { const harness = createHarness(); harness.api.initChannelEditorActions(); harness.setInlineURLs(['https://api.example.com/v1/chat/completions#']); harness.setCheckedRadio('channelType', 'openai'); harness.api.renderProtocolTransformOptions('openai', ['anthropic']); harness.elements.protocolTransformModeContainer.innerHTML = ` `; await harness.submitForm(); const payload = JSON.parse(harness.fetchCalls[1].options.body); assert.equal(payload.protocol_transform_mode, 'local'); }); test('重复渠道提前提示会忽略调度前未完成的旧检测结果', async () => { const staleResult = createDeferred(); const currentResult = createDeferred(); const harness = createHarness({ duplicateResponses: [staleResult.promise, currentResult.promise] }); harness.setInlineURLs(['https://stale.example.com']); const staleRefresh = harness.api.refreshChannelDuplicateHint(); assert.equal(harness.fetchCalls.length, 1); harness.setInlineURLs(['https://current.example.com']); harness.api.scheduleChannelDuplicateHintCheck(); assert.equal(harness.fetchCalls.length, 1); staleResult.resolve({ success: true, data: { duplicates: [{ name: 'stale-channel', channel_type: 'anthropic', url: 'https://stale.example.com' }] } }); await staleRefresh; assert.equal(harness.elements.channelDuplicateHint.hidden, true); assert.equal(harness.elements.channelDuplicateHint.textContent, ''); const currentRefresh = harness.runTimers(); assert.equal(harness.fetchCalls.length, 2); currentResult.resolve({ success: true, data: { duplicates: [{ name: 'current-channel', channel_type: 'anthropic', url: 'https://current.example.com' }] } }); await currentRefresh; assert.equal(harness.elements.channelDuplicateHint.hidden, false); assert.match(harness.elements.channelDuplicateHint.textContent, /current-channel/); assert.doesNotMatch(harness.elements.channelDuplicateHint.textContent, /stale-channel/); }); test('复制渠道会按复制后的 URL 触发重复渠道提前检测', async () => { const harness = createHarness({ channel: { id: 7, name: 'source-channel', url: 'https://api.example.com', channel_type: 'anthropic', protocol_transform_mode: 'upstream', protocol_transforms: [], key_strategy: 'sequential', priority: 9, daily_cost_limit: 0, enabled: true, scheduled_check_enabled: false, scheduled_check_model: '', models: [{ model: 'claude-3-7-sonnet', redirect_model: '' }] }, apiKeys: [{ api_key: 'sk-live' }], duplicateResponses: [{ success: true, data: { duplicates: [] } }] }); await harness.api.copyChannel(7, 'source-channel'); await harness.runTimers(); const duplicateCall = harness.fetchCalls.find((call) => call.path === '/admin/channels/check-duplicate'); assert.ok(duplicateCall); assert.deepEqual(JSON.parse(duplicateCall.options.body), { channel_type: 'anthropic', urls: ['https://api.example.com'] }); }); test('保存渠道时 payload 带上 protocol_transforms', async () => { const harness = createHarness(); harness.api.initChannelEditorActions(); harness.setCheckedRadio('channelType', 'gemini'); harness.api.renderProtocolTransformOptions('gemini', ['anthropic', 'openai']); harness.elements.protocolTransformModeContainer.innerHTML = ` `; await harness.submitForm(); assert.equal(harness.fetchCalls.length, 2); assert.equal(harness.fetchCalls[0].path, '/admin/channels/check-duplicate'); const { path: requestPath, options } = harness.fetchCalls[1]; assert.equal(requestPath, '/admin/channels'); assert.equal(options.method, 'POST'); const payload = JSON.parse(options.body); assert.deepEqual(payload.protocol_transforms, ['anthropic', 'openai']); assert.equal(payload.protocol_transform_mode, 'upstream'); assert.equal(payload.channel_type, 'gemini'); assert.deepEqual(JSON.parse(JSON.stringify(harness.getAfterSavePayload())), { isNewChannel: true, newChannelType: 'gemini', savedChannelId: null, response: { success: true } }); }); test('保存渠道提交后立即禁用保存按钮,避免慢请求期间没有反馈', async () => { const save = createDeferred(); const harness = createHarness({ channel: { id: 7, name: 'edited-channel', url: 'https://api.example.com', channel_type: 'anthropic', protocol_transform_mode: 'upstream', protocol_transforms: [], key_strategy: 'sequential', priority: 9, daily_cost_limit: 0, enabled: true, scheduled_check_enabled: false, scheduled_check_model: '', models: [{ model: 'claude-3-7-sonnet', redirect_model: '' }] }, saveResponse: save.promise }); harness.api.initChannelEditorActions(); harness.setEditingChannelId(7); harness.api.renderProtocolTransformOptions('anthropic', []); harness.api.renderProtocolTransformModeOptions('upstream'); const submitPromise = harness.submitForm(); await Promise.resolve(); assert.equal(harness.elements.channelSaveBtn.disabled, true); save.resolve({ success: true }); await submitPromise; assert.equal(harness.elements.channelSaveBtn.disabled, false); });