Files
creator-hub/web/tests/ai-settings-page.test.cjs
T
rogee f345e368df
douyin-release-gate / verify (push) Failing after 19m29s
feat: configure OpenAI-compatible AI services and discover models
2026-10-07 17:17:33 +08:00

122 lines
9.1 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const {readFileSync} = require('node:fs');
const {resolve} = require('node:path');
const Module = require('node:module');
const ts = require('typescript');
const React = require('react');
const defaults = {lookback_days:30, new_work_interval_seconds:1800, metric_initial_interval_seconds:3600, metric_multiplier:2, metric_max_interval_seconds:86400, metric_age_seconds:2592000, ai_base_url:'https://example.com/v1', ai_api_key:'saved-key', ai_model:'model-a', updated_at:'server-only'};
function deferred() { let resolve, reject; const promise = new Promise((yes, no) => {resolve=yes; reject=no;}); return {promise, resolve, reject}; }
function harness(options={}) {
const states=[], effects=[], queued=[], timers=new Map(), calls=[], saves=[], notices=[], logs=[];
let cursor=0, timerID=0;
const values={};
const form={setFieldsValue(value){Object.assign(values,value);}, getFieldValue(name){return values[name];}, setFieldValue(name,value){values[name]=value;}};
const antd={};
for(const name of ['Alert','Button','Card','Flex','Input','InputNumber','Select','Tabs']) antd[name]=()=>null;
antd.Input.Password=()=>null;
antd.Typography={Paragraph:()=>null,Text:()=>null};
antd.Form=()=>null; antd.Form.Item=()=>null;
antd.Form.useForm=()=>[form];
antd.Form.useWatch=(name)=>values[name];
antd.App={useApp:()=>({message:{success:v=>notices.push(v),error:v=>notices.push(v)}})};
const file=resolve(__dirname,'../src/pages/creator/settings/index.tsx');
const loaded=new Module(file,module); loaded.filename=file;
loaded.require=id=>{
if(id==='@test/timers') return {setTimeout(fn){const id=++timerID; timers.set(id,fn); return id;},clearTimeout(id){timers.delete(id);}};
if(id==='react') return {
useState(initial){const i=cursor++; if(!(i in states)) states[i]=typeof initial==='function'?initial():initial; return [states[i],value=>{states[i]=typeof value==='function'?value(states[i]):value;}];},
useRef(initial){const i=cursor++; if(!(i in states)) states[i]={current:initial}; return states[i];},
useCallback(callback,deps){const i=cursor++; const prior=states[i]; if(!prior||deps.some((v,j)=>!Object.is(v,prior.deps[j]))) states[i]={callback,deps}; return states[i].callback;},
useEffect(callback,deps){const i=cursor++; const prior=effects[i]; if(!prior||deps.some((v,j)=>!Object.is(v,prior.deps[j]))) {prior?.cleanup?.(); effects[i]={deps}; queued.push(()=>{effects[i].cleanup=callback();});}},
};
if(id==='antd') return antd;
if(id==='@ant-design/icons') return {ReloadOutlined:()=>null};
if(id==='@/utils/helpers') return {conflictMessage:(e,f)=>e?.message||f};
if(id==='@/services/api') return {
creatorGet:async()=>{if(options.loadError) throw new Error(options.loadError); return options.settings||defaults;},
creatorAction:async(path,payload)=>{calls.push({path,payload}); return options.models ? options.models(payload,calls.length) : {models:['model-a','model-b']};},
creatorUpdate:async(path,payload)=>{saves.push({path,payload}); if(options.saveError) throw new Error(options.saveError); return payload;},
};
return require(id);
};
const code=ts.transpileModule(readFileSync(file,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX,target:ts.ScriptTarget.ES2020}}).outputText;
loaded._compile(`const {setTimeout,clearTimeout}=require('@test/timers');\n${code}`,file);
const h={antd,form,values,calls,saves,notices,logs,
render(){cursor=0; return loaded.exports.default();},
async flush(){for(let i=0;i<5;i++){h.render(); while(queued.length) queued.shift()(); for(let j=0;j<12;j++) await Promise.resolve();} return h.render();},
async tick(){const pending=[...timers.values()]; timers.clear(); pending.forEach(fn=>fn()); return h.flush();},
async change(changed){Object.assign(values,changed); const tree=h.render(); find(tree,antd.Form)?.props.onValuesChange?.(changed,values); return h.flush();},
dispose(){for(const effect of effects) effect?.cleanup?.();},
};
return h;
}
function all(node,predicate){const result=[]; function visit(value){if(Array.isArray(value)) return value.forEach(visit); if(!React.isValidElement(value)) return; if(predicate(value)) result.push(value); visit(value.props.children); visit(value.props.label); if(Array.isArray(value.props.items)) value.props.items.forEach(item=>visit(item.children));} visit(node); return result;}
function text(node){if(node==null) return ''; if(typeof node==='string'||typeof node==='number') return String(node); if(Array.isArray(node)) return node.map(text).join(''); return React.isValidElement(node)?text(node.props.children):'';}
const find=(tree,type)=>all(tree,n=>n.type===type)[0];
const alerts=(h,tree)=>all(tree,n=>n.type===h.antd.Alert).map(n=>`${text(n.props.title)} ${text(n.props.description)}`).join('\n');
const refresh=(h,tree)=>all(tree,n=>n.type===h.antd.Button).find(n=>text(n.props.children)==='刷新模型');
test('AI service uses password input, searchable discovered models and persists all settings',async t=>{
const h=harness(); t.after(h.dispose); await h.flush(); let tree=await h.tick();
assert.deepEqual(h.calls,[{path:'/creator/settings/models',payload:{base_url:defaults.ai_base_url,api_key:defaults.ai_api_key}}]);
assert.ok(find(tree,h.antd.Input.Password));
const select=find(tree,h.antd.Select); assert.ok(select.props.showSearch); assert.equal(select.props.mode,undefined);
assert.deepEqual(select.props.options.map(o=>o.value),['model-a','model-b']);
const fields=all(tree,n=>n.type===h.antd.Form.Item).map(n=>n.props.name);
assert.ok(fields.includes('ai_base_url')); assert.ok(fields.includes('ai_api_key')); assert.ok(fields.includes('ai_model'));
assert.ok(!fields.includes('ai_provider')); assert.ok(!fields.includes('ai_configured'));
assert.ok(find(tree,h.antd.Tabs).props.items.every(item=>item.forceRender));
await find(tree,h.antd.Form).props.onFinish({...h.values}); await h.flush();
assert.equal(h.saves[0].payload.ai_api_key,'saved-key'); assert.equal(h.saves[0].payload.ai_model,'model-a');
assert.equal(h.saves[0].payload.lookback_days,30); assert.equal(h.saves[0].payload.updated_at,undefined);
assert.equal(h.notices[0],'采集与 AI 配置已保存。');
});
test('connection edits clear selection and stale replies cannot override new models',async t=>{
const old=deferred(); const h=harness({models:(_,n)=>n===1?old.promise:{models:['new-model']}}); t.after(h.dispose);
await h.flush(); await h.tick();
await h.change({ai_base_url:'https://other.example/v1',ai_api_key:'new-key'});
assert.equal(h.values.ai_model,undefined);
let tree=await h.tick(); assert.deepEqual(find(tree,h.antd.Select).props.options.map(o=>o.value),['new-model']);
old.resolve({models:['stale-model']}); tree=await h.flush();
assert.deepEqual(find(tree,h.antd.Select).props.options.map(o=>o.value),['new-model']);
assert.equal(h.calls[1].payload.api_key,'new-key');
});
test('failed discovery is explicit and refresh recovers without a manual model fallback',async t=>{
const h=harness({models:(_,n)=>n===1?Promise.reject(new Error('HTTP 401: invalid key')):{models:['model-a']}}); t.after(h.dispose);
await h.flush(); let tree=await h.tick(); assert.match(alerts(h,tree),/401/);
assert.equal(find(tree,h.antd.Select).props.disabled,true); assert.deepEqual(find(tree,h.antd.Select).props.options,[]);
refresh(h,tree).props.onClick(); await h.flush(); tree=await h.tick();
assert.equal(alerts(h,tree),''); assert.equal(find(tree,h.antd.Select).props.disabled,false);
});
test('empty discovery and unavailable saved model are explicit',async t=>{
for(const models of [[],['different-model']]){
const h=harness({models:()=>({models})}); t.after(h.dispose); await h.flush(); const tree=await h.tick();
assert.ok(alerts(h,tree)); assert.equal(h.values.ai_model,undefined);
}
});
test('unconfigured AI does not fetch and collection settings remain saveable',async t=>{
const h=harness({settings:{...defaults,ai_base_url:'',ai_api_key:'',ai_model:''}}); t.after(h.dispose);
await h.flush(); const tree=await h.tick(); assert.equal(h.calls.length,0);
assert.equal(find(tree,h.antd.Select).props.disabled,true);
await find(tree,h.antd.Form).props.onFinish({...h.values}); assert.equal(h.saves.length,1);
});
test('partial connection or a model not from discovery cannot be saved',async t=>{
const h=harness(); t.after(h.dispose); await h.flush(); await h.tick();
await h.change({ai_api_key:'changed'}); let tree=h.render();
await find(tree,h.antd.Form).props.onFinish({...h.values,ai_model:'model-a'}); assert.equal(h.saves.length,0);
await h.tick(); tree=h.render(); await find(tree,h.antd.Form).props.onFinish({...h.values,ai_model:'invented-model'}); assert.equal(h.saves.length,0);
});
test('load and save failures remain visible',async t=>{
const broken=harness({loadError:'settings unavailable'}); t.after(broken.dispose);
assert.match(alerts(broken,await broken.flush()),/settings unavailable/);
const h=harness({saveError:'database unavailable'}); t.after(h.dispose); await h.flush(); const tree=await h.tick();
await find(tree,h.antd.Form).props.onFinish({...h.values}); assert.match(h.notices.join(' '),/database unavailable/);
});