const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const vm = require('node:vm'); const ts = require('typescript'); const React = require('react'); const source = ts.createSourceFile('SettingsPage.tsx', fs.readFileSync(require.resolve('../src/pages/settings/SettingsPage.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); const constants = source.statements.filter(node => ts.isVariableStatement(node) && ['AI_MODEL_DEFAULTS', 'AI_MODEL_OPTIONS', 'CUSTOM_AI_MODEL'].includes(node.declarationList.declarations[0].name.getText(source))); function compile(code, globals = {}) { const exports = {}; vm.runInNewContext(ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, { exports, require, ...globals }); return exports; } const catalogs = constants.map(node => `export ${node.getText(source)}`).join('\n'); const { AI_MODEL_OPTIONS, AI_MODEL_DEFAULTS, CUSTOM_AI_MODEL } = compile(catalogs); function find(node, predicate) { if (predicate(node)) return node; return ts.forEachChild(node, child => find(child, predicate)); } const section = find(source, node => ts.isJsxElement(node) && node.openingElement.tagName.getText(source) === 'section' && node.openingElement.attributes.getText(source).includes('border-t border-[#e2e8f0] pt-5') && node.getText(source).includes('AI 공급자')); function controls(provider, model, custom = false) { const state = { aiProvider: provider, aiModel: model, aiCustomModel: custom }; const context = { aiSettingLoading: false, saving: false, aiEndpointUrl: 'https://example.test', aiApiKey: '', aiHasApiKey: false, aiConfiguredProvider: null, AI_PROVIDER_LABELS: { OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen' }, DEFAULT_QWEN_ENDPOINT: 'https://example.test', KeyRound: () => null, setAiProvider(value) { state.aiProvider = value; }, setAiModel(value) { state.aiModel = value; }, setAiCustomModel(value) { state.aiCustomModel = value; }, setAiApiKey() {}, setAiEndpointUrl() {}, }; const render = () => compile(`${catalogs}\nexport const tree = (${section.getText(source)});`, { ...context, ...state }).tree; return { state, render }; } function elements(root, predicate) { if (!React.isValidElement(root)) return []; return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]; } for (const provider of Object.keys(AI_MODEL_OPTIONS)) { test(`${provider} offers current models and a custom model field without changing defaults`, () => { const h = controls(provider, AI_MODEL_DEFAULTS[provider]); let tree = h.render(); const select = elements(tree, node => node.type === 'select')[0]; assert.equal(select.props.value, AI_MODEL_DEFAULTS[provider]); assert.equal(elements(select, node => node.type === 'option' && node.props.value === CUSTOM_AI_MODEL).length, 1); assert.equal(elements(tree, node => node.type === 'input' && node.props.type === 'text').length, 0); select.props.onChange({ target: { value: CUSTOM_AI_MODEL } }); assert.equal(h.state.aiCustomModel, true); assert.equal(h.state.aiModel, ''); tree = h.render(); const input = elements(tree, node => node.type === 'input' && node.props.type === 'text')[0]; assert.equal(input.props.maxLength, 120); input.props.onChange({ target: { value: 'custom-model-2026' } }); assert.equal(h.state.aiModel, 'custom-model-2026'); assert.equal(elements(h.render(), node => node.type === 'select')[0].props.value, CUSTOM_AI_MODEL); elements(h.render(), node => node.type === 'select')[0].props.onChange({ target: { value: AI_MODEL_OPTIONS[provider][0].value } }); assert.equal(h.state.aiCustomModel, false); assert.equal(h.state.aiModel, AI_MODEL_OPTIONS[provider][0].value); assert.equal(new Set(AI_MODEL_OPTIONS[provider].map(option => option.value)).size, AI_MODEL_OPTIONS[provider].length); }); } test('saved unknown model IDs remain visible in custom mode', () => { const model = 'provider/private-model:revision-10'; const h = controls('QWEN', model, true); const tree = h.render(); assert.equal(elements(tree, node => node.type === 'select')[0].props.value, CUSTOM_AI_MODEL); assert.equal(elements(tree, node => node.type === 'input' && node.props.type === 'text')[0].props.value, model); assert.equal(AI_MODEL_OPTIONS.QWEN.some(option => option.value === model), false); }); test('the selected provider button preserves a custom draft', () => { const h = controls('OPENAI', 'custom-gpt', true); const providers = elements(h.render(), node => node.props.role === 'radio'); providers[0].props.onClick(); assert.equal(h.state.aiModel, 'custom-gpt'); assert.equal(h.state.aiCustomModel, true); providers[1].props.onClick(); assert.equal(h.state.aiProvider, 'ANTHROPIC'); assert.equal(h.state.aiModel, AI_MODEL_DEFAULTS.ANTHROPIC); assert.equal(h.state.aiCustomModel, false); }); test('official current API IDs are present', () => { for (const [provider, ids] of Object.entries({ OPENAI: ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna'], ANTHROPIC: ['claude-sonnet-5-5', 'claude-opus-5-5', 'claude-haiku-5-5', 'claude-fable-5-1'], GEMINI: ['gemini-4-argon', 'gemini-3.8-flash', 'gemini-3.5-flash-lite'], QWEN: ['qwen3.8-max', 'qwen3.8-flash'], })) for (const id of ids) assert.ok(AI_MODEL_OPTIONS[provider].some(option => option.value === id), id); });