fix(v2): 补齐 backend 单测依赖的两个 cjs 脚本
test_chat_repair.py:92 会调用 scripts/test_chat_stream.cjs,而该脚本不在 verify.py 的 9 个用例里,首轮抽取因此漏掉,导致 backend-unit 失败。 同时补上 test_resetdata_ui.cjs 使 cjs 测试集完整。 验证:scripts/verify.py 全部 9 项 PASS(backend-unit 124 tests OK)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
function load(relative, overrides = {}, globals = {}) {
|
||||
const compiled = ts.transpileModule(fs.readFileSync(path.join(root,relative),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText;
|
||||
const exports = {};
|
||||
vm.runInNewContext(compiled,{exports,require:id=>overrides[id]||require(id),process,fetch:global.fetch,Headers,Response,AbortController,TextDecoder,TextEncoder,setTimeout,clearTimeout,URL,...globals});
|
||||
return exports;
|
||||
}
|
||||
const {ChatEventDecoder,readChatStream} = load('src/lib/chat-stream.ts');
|
||||
const encode = x => new TextEncoder().encode(x);
|
||||
const event = (kind,data) => `event: ${kind}\r\ndata: ${JSON.stringify(data)}\r\n\r\n`;
|
||||
async function collect(iterator) {const result=[];for await(const item of iterator) result.push(item);return result;}
|
||||
function responder(chunks) {return async()=>new Response(new ReadableStream({start(controller){for(const chunk of chunks)controller.enqueue(encode(chunk));controller.close();}}),{headers:{'Content-Type':'text/event-stream'}});}
|
||||
|
||||
(async()=>{
|
||||
const original=event('token',{content:'你好'})+event('done',{content:'你好'});
|
||||
for(let split=0;split<=original.length;split++) {
|
||||
const parser=new ChatEventDecoder();
|
||||
const items=[...parser.push(original.slice(0,split)),...parser.push(original.slice(split))];
|
||||
assert.equal(items.length,2);assert.equal(items[0].event,'token');assert.equal(items[0].data.content,'你好');
|
||||
}
|
||||
const parser=new ChatEventDecoder();
|
||||
const items=[];for(const char of original)items.push(...parser.push(char));assert.equal(items.length,2);
|
||||
assert.equal(new ChatEventDecoder().push('event: token\ndata: {"content":\ndata: "multiline"}\n\n')[0].data.content,'multiline');
|
||||
assert.equal(new ChatEventDecoder().push(': heartbeat\r\n\r\n').length,0);
|
||||
assert.throws(()=>new ChatEventDecoder().push('event: token\ndata: invalid\n\n'),/格式/);
|
||||
const normal=await collect(readChatStream('/test',{}, {fetcher:responder([original.slice(0,8),original.slice(8)])}));
|
||||
assert.equal(normal[0].event,'token');assert.equal(normal[1].event,'done');
|
||||
const finalOnly=await collect(readChatStream('/test',{}, {fetcher:responder([event('done',{content:'final'})])}));
|
||||
assert.equal(finalOnly[0].event,'token');assert.equal(finalOnly[0].data.content,'final');
|
||||
await assert.rejects(()=>collect(readChatStream('/test',{}, {fetcher:responder([event('done',{content:''})])})),/正文/);
|
||||
await assert.rejects(()=>collect(readChatStream('/test',{}, {fetcher:responder([event('token',{content:'partial'})])})),/中断/);
|
||||
const error=await collect(readChatStream('/test',{}, {fetcher:responder([event('error',{error:'model failed'})])}));assert.equal(error[0].event,'error');
|
||||
let aborted=false;
|
||||
await assert.rejects(()=>collect(readChatStream('/test',{}, {idleMs:10,totalMs:100,fetcher:async(_url,{signal})=>new Promise((_resolve,reject)=>signal.addEventListener('abort',()=>{aborted=true;reject(new Error('aborted'));}))})),/超时/);
|
||||
assert.equal(aborted,true);
|
||||
await assert.rejects(()=>collect(readChatStream('/test',{}, {fetcher:async()=>new Response('bad',{status:502})})),/502/);
|
||||
await assert.rejects(()=>collect(readChatStream('/test',{}, {fetcher:async()=>new Response('{}',{headers:{'Content-Type':'application/json'}})})),/事件流/);
|
||||
const source=fs.readFileSync(path.join(root,'src/lib/api.ts'),'utf8');assert.ok(source.includes('const API_BASE = "/api/core"'));assert.ok(!source.includes(':8002'));
|
||||
|
||||
const NextResponse = class extends Response {static json(data,init){return new Response(JSON.stringify(data),{...init,headers:{'Content-Type':'application/json'}});}};
|
||||
const oldFetch=global.fetch, oldBase=process.env.RISK_AGENT_BACKEND_URL, oldCore=process.env.CORE_BACKEND_URL;
|
||||
const savedEnv = Object.fromEntries(['NODE_ENV','XINGBANG_REQUIRE_AUTH','XINGBANG_AUTH_USERS'].map(k=>[k,process.env[k]]));
|
||||
try {
|
||||
process.env.NODE_ENV='development';delete process.env.XINGBANG_REQUIRE_AUTH;delete process.env.XINGBANG_AUTH_USERS;
|
||||
delete process.env.CORE_BACKEND_URL;process.env.RISK_AGENT_BACKEND_URL='http://127.0.0.1:8003';
|
||||
let target, options;
|
||||
const upstream=new Response(new ReadableStream({start(c){c.enqueue(encode(event('token',{content:'stream'})));}}),{headers:{'Content-Type':'text/event-stream'}});
|
||||
global.fetch=async(url,opts)=>{target=url;options=opts;return upstream;};
|
||||
const route=load('src/app/api/core/[...path]/route.ts',{'next/server':{NextResponse}});
|
||||
const request={nextUrl:{hostname:'0.0.0.0',search:'?id=abc'},headers:new Headers({Host:'127.0.0.1:3000'}),method:'POST',arrayBuffer:async()=>encode('{}'),signal:new AbortController().signal};
|
||||
const response=await route.POST(request,{params:{path:['chat']}});
|
||||
assert.equal(target,'http://127.0.0.1:8003/api/chat?id=abc');assert.equal(response.body,upstream.body);assert.equal(options.signal,request.signal);
|
||||
const reader=response.body.getReader();assert.ok((await reader.read()).value.length);await reader.cancel();
|
||||
assert.equal((await route.GET({...request,headers:new Headers({Host:'example.org'})},{params:{path:['chat']}})).status,403);
|
||||
assert.equal((await route.GET({...request,headers:new Headers({Host:'localhost:3000',Origin:'https://evil.example'})},{params:{path:['chat']}})).status,403);
|
||||
assert.equal((await route.GET(request,{params:{path:['chat','..']}})).status,404);
|
||||
assert.equal((await route.GET(request,{params:{path:['unknown']}})).status,404);
|
||||
process.env.NODE_ENV='production';
|
||||
assert.equal((await route.GET(request,{params:{path:['sessions']}})).status,401);
|
||||
assert.equal((await route.GET({...request,headers:new Headers({Host:'cloud.example:3200','X-Xingbang-Demo-Role':'admin'})},{params:{path:['sessions']}})).status,401);
|
||||
for (const identity of [{id:'a',role:'admin'},{id:'r',role:'reviewer'},{id:'m',role:'manager'},{id:'p',role:'admin',preview:true},{}]) {
|
||||
let forwarded=false;
|
||||
global.fetch=async(url,opts)=>{
|
||||
if(url.endsWith('/access/context')) {assert.equal(opts.headers.Authorization,'Bearer test-admin');return Response.json(identity);}
|
||||
forwarded=true;return new Response(event('done',{content:'ok'}),{headers:{'Content-Type':'text/event-stream'}});
|
||||
};
|
||||
const secured=load('src/app/api/core/[...path]/route.ts',{'next/server':{NextResponse}});
|
||||
const r=await secured.POST({...request,headers:new Headers({Host:'cloud.example:3200',Authorization:'Bearer test-admin'})},{params:{path:['chat']}});
|
||||
const permitted=identity.role==='admin'&&!identity.preview&&!!identity.id;
|
||||
assert.equal(r.status,permitted?200:403);assert.equal(forwarded,permitted);
|
||||
}
|
||||
for (const status of [401,403,500]) {
|
||||
global.fetch=async()=>new Response('{}',{status});
|
||||
const secured=load('src/app/api/core/[...path]/route.ts',{'next/server':{NextResponse}});
|
||||
assert.equal((await secured.GET({...request,headers:new Headers({Authorization:'Bearer invalid'})},{params:{path:['sessions']}})).status,status===500?503:status);
|
||||
}
|
||||
let riskHeaders;
|
||||
global.fetch=async(_url,opts)=>{riskHeaders=opts.headers;return Response.json({role:'reviewer'});};
|
||||
const riskRoute=load('src/app/api/risk-agent/[...path]/route.ts',{'next/server':{NextResponse}});
|
||||
const riskResponse=await riskRoute.GET({...request,headers:new Headers({Host:'cloud.example:3200','X-Xingbang-Demo-Role':'reviewer'})},{params:{path:['access','context']}});
|
||||
assert.equal(riskResponse.status,200);
|
||||
assert.equal(riskHeaders['X-Xingbang-Demo-Role'],'reviewer');
|
||||
let forwardedHeaders;
|
||||
global.fetch=async(_url,opts)=>{forwardedHeaders=opts.headers;return Response.json({sessions:[]});};
|
||||
const {coreFetch}=load('src/lib/core-fetch.ts',{}, {window:{sessionStorage:{getItem:key=>key==='xingbang-access-token'?'existing-token':null}}});
|
||||
await coreFetch('/api/core/sessions',{headers:{'Content-Type':'application/json'}});
|
||||
assert.equal(forwardedHeaders.get('Authorization'),'Bearer existing-token');
|
||||
assert.equal(forwardedHeaders.get('Content-Type'),'application/json');
|
||||
assert.ok(source.includes('{fetcher:fetch}'));assert.ok(source.includes('coreFetch as fetch'));
|
||||
const {newRequestId}=load('src/lib/request-id.ts',{}, {crypto:{getRandomValues:require('node:crypto').webcrypto.getRandomValues.bind(require('node:crypto').webcrypto)}});
|
||||
assert.match(newRequestId(),/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/);assert.notEqual(newRequestId(),newRequestId());
|
||||
} finally {for(const [k,v] of Object.entries(savedEnv)){if(v===undefined)delete process.env[k];else process.env[k]=v;}global.fetch=oldFetch;if(oldBase===undefined)delete process.env.RISK_AGENT_BACKEND_URL;else process.env.RISK_AGENT_BACKEND_URL=oldBase;if(oldCore===undefined)delete process.env.CORE_BACKEND_URL;else process.env.CORE_BACKEND_URL=oldCore;}
|
||||
console.log('PASS: all SSE split boundaries, CRLF, multiline, final-only, empty, timeout, premature EOF, errors, streaming proxy and local-only guard');
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
@@ -0,0 +1,13 @@
|
||||
const fs=require("fs"),assert=require("assert");
|
||||
const page=fs.readFileSync("src/app/resetdata/page.tsx","utf8");
|
||||
const nav=fs.readFileSync("src/components/xingbang/AgentNav.tsx","utf8");
|
||||
const layout=fs.readFileSync("src/app/resetdata/layout.tsx","utf8");
|
||||
assert(page.includes('/demo/reset'),"reset page must call the scoped demo reset API");
|
||||
assert(page.includes('RESET-DEMO-A-B-C'),"reset confirmation must remain explicit");
|
||||
assert(page.includes('window.confirm'),"reset must have a human confirmation step");
|
||||
assert(page.includes('window.alert'),"reset result must be visible without adding page controls");
|
||||
assert((page.match(/<button/g)||[]).length===1,"reset page must contain exactly one button");
|
||||
assert(!page.includes('AgentNav'),"reset page must not expose application navigation");
|
||||
assert(!nav.includes('/resetdata')&&!nav.includes('演示复位'),"navigation must not expose reset page");
|
||||
assert(layout.includes('index:false')&&layout.includes('follow:false'),"reset page must opt out of search indexing");
|
||||
console.log("resetdata UI checks passed");
|
||||
Reference in New Issue
Block a user