import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import matter from 'gray-matter'; import {MODEL,extractSource,collectSources,discoverSources,validateDraft,writeDraft,saveArticle} from '../scripts/generate.mjs'; import {readArticles,articlePage} from '../scripts/site.mjs'; const source={id:1,title:'Original reporting',url:'https://example.org/report',text:'An original source. '.repeat(40),published:'2026-09-10',retrievedAt:'2026-09-12T12:00:00Z'}; const draft={title:'An accurate summary',description:'A short introduction.',category:'Technology',paragraphs:Array.from({length:3},()=>({text:'A factual paragraph grounded in the supplied original source.',sources:[1]}))}; test('extracts article content and removes navigation and scripts',()=>{ const s=extractSource(`Report

Reporting

${source.text}

`,source.url); assert.match(s.text,/Reporting/);assert.doesNotMatch(s.text,/Wrong navigation|ignore all rules/); assert.throws(()=>extractSource('Just a moment',source.url),/No readable/); }); test('local search sends the prompt and filters invalid or duplicate URLs',async()=>{ const fetchImpl=async input=>{ const url=new URL(input); assert.equal(url.origin,'http://localhost:8888'); assert.equal(url.pathname,'/search');assert.equal(url.searchParams.get('q'),'climate & energy'); assert.equal(url.searchParams.get('format'),'json'); return Response.json({results:[{url:source.url},{url:source.url+'#part'},{url:'javascript:bad'},{title:'Missing URL'}]}); }; assert.deepEqual(await discoverSources('climate & energy',{fetchImpl,apiKey:'',searxngUrl:'http://localhost:8888/search'}),[source.url]); }); test('search errors explain service startup and empty results',async()=>{ await assert.rejects(discoverSources('news',{apiKey:'',searxngUrl:'http://localhost:8888',fetchImpl:async()=>{throw new Error('Connection refused');}}),/npm run search:start/); await assert.rejects(discoverSources('news',{apiKey:'',searxngUrl:'http://localhost:8888',fetchImpl:async()=>Response.json({results:[],unresponsive_engines:[['google','timeout']]})}),/Unavailable engines: google/); }); test('Brave remains available when a key is supplied without a SearXNG override',async()=>{ const fetchImpl=async(url,options)=>{assert.match(url,/api.search.brave.com/);assert.equal(options.headers['X-Subscription-Token'],'test-key');return Response.json({web:{results:[{url:source.url}]}});}; assert.deepEqual(await discoverSources('news',{fetchImpl,apiKey:'test-key',searxngUrl:''}),[source.url]); }); test('refuses generation without readable sources',async()=>{ await assert.rejects(collectSources('news',[source.url],{fetchImpl:async()=>new Response('blocked',{status:403}),log:()=>{}}),/No readable sources/); }); test('rejects invented citation IDs, missing citations and URLs',()=>{ for(const paragraph of [{text:draft.paragraphs[0].text,sources:[2]},{text:draft.paragraphs[0].text,sources:[]},{text:'See https://invented.example for the details.',sources:[1]}]) assert.throws(()=>validateDraft({...draft,paragraphs:[paragraph,...draft.paragraphs.slice(1)]},[source])); }); test('uses only the smaller model and validates the Ollama response',async()=>{ let body; const fetchImpl=async(url,options)=>{assert.equal(url,'http://localhost:11434/api/chat');body=JSON.parse(options.body);return Response.json({message:{content:JSON.stringify(draft)}});}; assert.deepEqual(await writeDraft('news',[source],{fetchImpl,endpoint:'http://localhost:11434/'}),draft); assert.equal(body.model,'llama3.2:3b');assert.equal(body.model,MODEL);assert.equal(body.stream,false);assert.ok(body.format.properties.paragraphs); await assert.rejects(writeDraft('news',[source],{log:()=>{},fetchImpl:async()=>Response.json({message:{content:'invalid'}})}),/valid article JSON/); }); test('saved Markdown has source metadata, citations and a working custom route',async t=>{ const directory=await fs.mkdtemp(path.join(os.tmpdir(),'truenews-generation-'));t.after(()=>fs.rm(directory,{recursive:true,force:true})); const result=await saveArticle(draft,[source],'A short prompt',{directory,url:'/technology/test-article/'}); const {data,content}=matter(await fs.readFile(result.filename,'utf8')); assert.equal(data.model,MODEL);assert.equal(data.sources[0].url,source.url);assert.match(content,/## Sources/); const articles=await readArticles(directory);assert.equal(articles[0].url,result.url); assert.match(articlePage(articles[0],articles),/href="https:\/\/example.org\/report"/); assert.match(articles[0].html,/AI-generated/); await assert.rejects(saveArticle(draft,[source],'again',{directory,url:result.url}),/already uses/); assert.equal((await fs.readdir(directory)).length,1); }); test('schema restricts citation numbers to actual, possibly nonconsecutive source IDs',async()=>{ const sources=[{...source,id:2},{...source,id:7}]; const valid={...draft,paragraphs:draft.paragraphs.map(p=>({...p,sources:[7]}))}; const fetchImpl=async(_,options)=>{ const body=JSON.parse(options.body); const citations=body.format.properties.paragraphs.items.properties.sources; assert.deepEqual(citations.items.enum,[2,7]);assert.equal(citations.minItems,1); assert.match(body.messages[0].content,/Copy the id field/); return Response.json({message:{content:JSON.stringify(valid)}}); }; assert.deepEqual(await writeDraft('news',sources,{fetchImpl}),valid); }); test('invalid source citation triggers one correction before an article is accepted',async()=>{ let calls=0; const messages=[]; const invalid={...draft,paragraphs:draft.paragraphs.map(p=>({...p,sources:[0]}))}; const fetchImpl=async(_,options)=>{ calls++; const body=JSON.parse(options.body); if(calls===2){ assert.equal(body.messages.length,4); assert.match(body.messages.at(-1).content,/Paragraph 1 returned an unknown or missing source citation/); assert.match(body.messages.at(-1).content,/Do not guess a replacement citation/); } return Response.json({message:{content:JSON.stringify(calls===1?invalid:draft)}}); }; assert.deepEqual(await writeDraft('news',[source],{fetchImpl,log:m=>messages.push(m)}),draft); assert.equal(calls,2);assert.equal(messages.length,1); assert.deepEqual(invalid.paragraphs[0].sources,[0]); }); test('repeated missing citations stop after two attempts without accepting a draft',async()=>{ let calls=0; const invalid={...draft,paragraphs:draft.paragraphs.map(p=>({...p,sources:[]}))}; await assert.rejects(writeDraft('news',[source],{log:()=>{},fetchImpl:async()=>{calls++;return Response.json({message:{content:JSON.stringify(invalid)}});}}),/after 2 attempts.*No article was saved/); assert.equal(calls,2); }); test('truncated output is retried, but HTTP errors are not treated as validation errors',async()=>{ let calls=0; const fetchImpl=async()=>{calls++;return Response.json(calls===1?{done_reason:'length',message:{content:'{"title":'}}:{message:{content:JSON.stringify(draft)}});}; assert.deepEqual(await writeDraft('news',[source],{fetchImpl,log:()=>{}}),draft);assert.equal(calls,2); calls=0; await assert.rejects(writeDraft('news',[source],{fetchImpl:async()=>{calls++;return new Response('Model not found',{status:404});}}),/HTTP 404/); assert.equal(calls,1); }); test('token-limit recovery uses shorter bounds, a larger budget and no partial JSON history',async()=>{ const partial='{"title":"Unfinished output'; const requests=[]; const fetchImpl=async(_,options)=>{ requests.push(JSON.parse(options.body)); return Response.json(requests.length===1 ? {done_reason:'length',message:{content:partial}} : {message:{content:JSON.stringify(draft)}}); }; assert.deepEqual(await writeDraft('news',[source],{fetchImpl,log:()=>{}}),draft); const [first,retry]=requests; assert.equal(first.options.num_predict,2048); assert.equal(retry.options.num_predict,4096); assert.equal(retry.model,MODEL); assert.equal(retry.format.properties.paragraphs.minItems,3); assert.equal(retry.format.properties.paragraphs.maxItems,3); assert.equal(retry.format.properties.paragraphs.items.properties.text.maxLength,450); assert.deepEqual(retry.format.properties.paragraphs.items.properties.sources.items.enum,[1]); assert.equal(retry.messages.length,3); assert.ok(retry.messages.every(m=>m.role!=='assistant')); assert.ok(!JSON.stringify(retry.messages).includes(partial)); assert.match(retry.messages[0].content,/exactly 3 short paragraphs, about 100–160/); assert.ok(retry.messages[0].content.includes(JSON.stringify(retry.format))); }); test('complete validated JSON at the token limit is accepted without another request',async()=>{ let calls=0; const result=await writeDraft('news',[source],{fetchImpl:async()=>{calls++;return Response.json({done_reason:'length',message:{content:JSON.stringify(draft)}});}}); assert.deepEqual(result,draft);assert.equal(calls,1); });