| 1 | import { JSDOM } from 'jsdom'; |
| 2 | import React, { act } from 'react'; |
| 3 | import assert from 'node:assert/strict'; |
| 4 | const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost', pretendToBeVisual: true }); |
| 5 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage, Node: dom.window.Node, HTMLElement: dom.window.HTMLElement, requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), IS_REACT_ACT_ENVIRONMENT: true }); |
| 6 | window.HTMLDialogElement.prototype.showModal = function() { this.setAttribute('open',''); this.querySelector('input')?.focus(); }; |
| 7 | const { createRoot } = await import('react-dom/client'); |
| 8 | await import('../components/ProviderModelDialog'); |
| 9 | const { ProviderEditor } = await import('../components/SettingsPanel'); |
| 10 | const { LocaleProvider } = await import('../lib/i18n'); |
| 11 | const root = createRoot(document.getElementById('root')!); |
| 12 | let reject = true, saved: any; |
| 13 | const provider = { name:'custom', kind:'openai', baseUrl:'https://example.com/v1', models:['model-a'], default:'model-a', apiKeyEnv:'TEST_KEY', keySet:true, added:true, builtIn:false, visionModels:[], supportedEfforts:[], modelsUrl:'', balanceUrl:'', contextWindow:1000000, modelOverrides:[{model:'model-a',reasoningProtocol:'deepseek',supportedEfforts:['high'],defaultEffort:'high',maxOutputTokens:8192}] }; |
| 14 | await act(async () => root.render(<LocaleProvider><ProviderEditor initial={provider as any} kinds={['openai']} busy={false} onCancel={()=>{}} onSave={async p => { if (reject) throw new Error('save failed'); saved=p; }}/></LocaleProvider>)); |
| 15 | const save = document.querySelector('.provider-editor-footer .btn--primary') as HTMLButtonElement; |
| 16 | const edit = () => (document.querySelector('.provider-model-draft__option button') as HTMLButtonElement).click(); |
| 17 | async function change(input: HTMLInputElement,value:string) { await act(async()=>{Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value')!.set!.call(input,value);input.dispatchEvent(new window.Event('input',{bubbles:true}));}); } |
| 18 | async function changeSelect(select: HTMLSelectElement,value:string) { await act(async()=>{Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype,'value')!.set!.call(select,value);select.dispatchEvent(new window.Event('change',{bubbles:true}));}); } |
| 19 | const dialog = () => document.querySelector('dialog')!; |
| 20 | const submit = async () => act(async()=>{dialog().querySelector('form')!.dispatchEvent(new window.Event('submit',{bubbles:true,cancelable:true}));}); |
| 21 | assert.equal(save.disabled,true); |
| 22 | await act(async()=>{edit();await new Promise(r=>setTimeout(r,30));}); |
| 23 | assert.ok(dialog()); |
| 24 | assert.equal(dialog().querySelector('.provider-model-dialog__id input'),null,'existing model ID is immutable'); |
| 25 | assert.ok(!document.querySelector('.provider-model-draft__list')!.contains(dialog())); |
| 26 | await change(dialog().querySelector('input[type="number"]')!,'128000'); |
| 27 | assert.equal(save.disabled,true,'dialog changes remain isolated until applied'); |
| 28 | await act(async()=>dialog().dispatchEvent(new window.Event('cancel',{cancelable:true}))); |
| 29 | assert.equal(document.querySelector('dialog'),null); |
| 30 | assert.equal(save.disabled,true,'cancel discards dialog edits'); |
| 31 | await act(async()=>edit()); |
| 32 | const numbers=dialog().querySelectorAll<HTMLInputElement>('input[type="number"]'); |
| 33 | assert.equal(numbers[0].value,'','cancel restored inherited context'); |
| 34 | await change(numbers[0],'128000');await change(numbers[1],'4096'); |
| 35 | const image=dialog().querySelector<HTMLInputElement>('aside input:not(:disabled)')!; |
| 36 | await act(async()=>image.click()); |
| 37 | assert.equal(dialog().querySelectorAll('aside input:disabled').length,4,'text is locked; video and PDF unavailable'); |
| 38 | await changeSelect(dialog().querySelector('select')!, ''); |
| 39 | await submit(); |
| 40 | assert.equal(save.disabled,false); |
| 41 | assert.equal(document.querySelector('.provider-context-badge')!.textContent,'128K'); |
| 42 | await act(async()=>save.click()); |
| 43 | assert.equal(save.disabled,false,'failed save retains complete model draft'); |
| 44 | reject=false;await act(async()=>save.click()); |
| 45 | assert.equal(save.disabled,true); |
| 46 | assert.equal(saved.modelOverrides[0].contextWindow,128000); |
| 47 | assert.equal(saved.modelOverrides[0].maxOutputTokens,4096); |
| 48 | assert.equal(saved.modelOverrides[0].vision,true); |
| 49 | assert.equal(saved.modelOverrides[0].reasoningProtocol,'deepseek','preserves unrelated reasoning configuration'); |
| 50 | assert.deepEqual(saved.modelOverrides[0].supportedEfforts,['high'],'automatic default preserves configured effort options'); |
| 51 | assert.equal(saved.modelOverrides[0].defaultEffort,'','automatic default is stored without a model default'); |
| 52 | await act(async()=>edit()); |
| 53 | const highLevel = Array.from(dialog().querySelectorAll<HTMLInputElement>('.provider-model-dialog__effort-card input[type="checkbox"]')).find(input => input.getAttribute('aria-label') === 'high' || input.parentElement?.textContent?.trim() === 'high'); |
| 54 | assert.ok(highLevel, 'configured effort checkbox is rendered'); |
| 55 | await act(async()=>highLevel!.click()); |
| 56 | await submit();await act(async()=>save.click()); |
| 57 | assert.deepEqual(saved.modelOverrides[0].supportedEfforts,[],'removing the default effort clears the invalid option'); |
| 58 | assert.equal(saved.modelOverrides[0].defaultEffort,'','removing the default effort falls back to automatic'); |
| 59 | await act(async()=>edit()); |
| 60 | await act(async()=>{ |
| 61 | dialog().querySelectorAll<HTMLButtonElement>('section label button').forEach(button=>button.click()); |
| 62 | (dialog().querySelector('aside > button') as HTMLButtonElement).click(); |
| 63 | }); |
| 64 | await submit();await act(async()=>save.click()); |
| 65 | assert.equal(saved.modelOverrides[0].maxOutputTokens,0,'reset output restores inheritance'); |
| 66 | assert.equal(saved.modelOverrides[0].contextWindow,0,'reset context restores inheritance'); |
| 67 | assert.equal(saved.modelOverrides[0].vision,null,'automatic capability removes manual override'); |
| 68 | const add=document.querySelector('.provider-model-toolbar .provider-model-draft__tools > button:nth-child(2)') as HTMLButtonElement; |
| 69 | await act(async()=>add.click()); |
| 70 | await change(dialog().querySelector('.provider-model-dialog__id input')!,'model-a');await submit(); |
| 71 | assert.ok(dialog().querySelector('[role="alert"]'),'duplicate IDs rejected'); |
| 72 | assert.equal(dialog().querySelector('[role="alert"]')!.textContent,'This model ID is already added.'); |
| 73 | await change(dialog().querySelector('.provider-model-dialog__id input')!,'Model-A');await submit(); |
| 74 | assert.equal(document.querySelectorAll('.provider-model-draft__option').length,2); |
| 75 | assert.equal(document.querySelectorAll('.provider-model-draft__option input:checked').length,2,'added model enabled in draft'); |
| 76 | await act(async()=>save.click()); |
| 77 | assert.deepEqual(saved.models,['model-a','Model-A'],'save preserves exact model IDs'); |
| 78 | // Deletion is a draft edit and clears only the exact model's state. |
| 79 | const deletionProvider = {...provider, models:['model-a','Model-A'], visionModels:['model-a','Model-A'], |
| 80 | modelCapabilities:[{model:'model-a',state:'supported',inputModalities:['text','image']},{model:'Model-A',state:'supported',inputModalities:['text','image']}], |
| 81 | modelOverrides:['model-a','Model-A'].map(model=>({...provider.modelOverrides[0],model,contextWindow:128000,vision:true}))}; |
| 82 | saved=undefined; |
| 83 | await act(async()=>root.render(<LocaleProvider><ProviderEditor key="deletion" initial={deletionProvider as any} kinds={['openai']} busy={false} onCancel={()=>{}} onSave={p=>{saved=p;}}/></LocaleProvider>)); |
| 84 | await act(async()=>edit()); |
| 85 | await act(async()=>(dialog().querySelector('footer .btn--danger') as HTMLButtonElement).click()); |
| 86 | assert.equal(document.querySelector('dialog'),null); |
| 87 | assert.equal(document.querySelectorAll('.provider-model-draft__option').length,1); |
| 88 | assert.equal(saved,undefined,'delete does not persist until the connection is saved'); |
| 89 | assert.ok(document.querySelector('.provider-model-draft__option')!.textContent!.includes('Model-A')); |
| 90 | const deletionSave=document.querySelector('.provider-editor-footer .btn--primary') as HTMLButtonElement; |
| 91 | await act(async()=>deletionSave.click()); |
| 92 | assert.deepEqual(saved.models,['Model-A']); |
| 93 | assert.deepEqual(saved.visionModels,['Model-A']); |
| 94 | assert.deepEqual(saved.modelOverrides.map((item:any)=>item.model),['Model-A']); |
| 95 | assert.deepEqual(saved.modelCapabilities.map((item:any)=>item.model),['Model-A']); |
| 96 | await act(async()=>(document.querySelector('.provider-model-toolbar .provider-model-draft__tools > button:nth-child(2)') as HTMLButtonElement).click()); |
| 97 | assert.equal(dialog().querySelector('footer .btn--danger'),null,'add dialog has no delete action'); |
| 98 | await change(dialog().querySelector('.provider-model-dialog__id input')!,'model-a');await submit(); |
| 99 | await act(async()=>deletionSave.click()); |
| 100 | assert.deepEqual(saved.visionModels,['Model-A'],'re-adding does not revive legacy vision'); |
| 101 | const restored=saved.modelOverrides.find((item:any)=>item.model==='model-a'); |
| 102 | assert.equal(restored?.contextWindow ?? 0,0); |
| 103 | assert.equal(restored?.maxOutputTokens ?? 0,0); |
| 104 | assert.equal(restored?.vision ?? null,null); |
| 105 | // Built-in capabilities are visible without turning them into user overrides. |
| 106 | const inheritedProvider = {...provider, modelOverrides: [], modelCapabilities: [{model:'model-a', state:'unknown', inputModalities:['text'], reasoning:{state:'supported', options:[{id:'high',name:'High'},{id:'max',name:'Max'}], default:'high'}}]}; |
| 107 | saved=undefined; |
| 108 | await act(async()=>root.render(<LocaleProvider><ProviderEditor key="inherited" initial={inheritedProvider as any} kinds={['openai']} busy={false} onCancel={()=>{}} onSave={p=>{saved=p;}}/></LocaleProvider>)); |
| 109 | await act(async()=>edit()); |
| 110 | assert.equal(dialog().querySelectorAll('.provider-model-dialog__effort-card input:checked').length,2,'inherited levels are displayed'); |
| 111 | await change(dialog().querySelector('input[type="number"]')!,'128000'); |
| 112 | await submit(); |
| 113 | await act(async()=>(document.querySelector('.provider-editor-footer .btn--primary') as HTMLButtonElement).click()); |
| 114 | assert.deepEqual(saved.modelOverrides[0].supportedEfforts,[],'editing context does not freeze built-in levels'); |
| 115 | assert.equal(saved.modelOverrides[0].defaultEffort,'','editing context does not freeze the default'); |
| 116 | const unknownProvider = {...inheritedProvider, modelCapabilities:[{model:'model-a',state:'unknown',inputModalities:['text'],reasoning:{state:'unknown',options:[]}}]}; |
| 117 | await act(async()=>root.render(<LocaleProvider><ProviderEditor key="unknown" initial={unknownProvider as any} kinds={['openai']} busy={false} onCancel={()=>{}} onSave={()=>{}}/></LocaleProvider>)); |
| 118 | await act(async()=>edit()); |
| 119 | assert.match(dialog().textContent!, /Reasoning levels have not been identified\. Use Automatic/,'unknown metadata has a recovery explanation'); |
| 120 | await act(async()=>root.unmount()); |
| 121 | console.log('PASS: unified add/edit, isolated cancel, duplicate validation, complete model overrides and failed/successful save'); |
| 122 |