first commit
This commit is contained in:
41 files changed
+4964
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# 测试与归档
|
||||
|
||||
- `history/`:从 auto_news_upload 根目录移入的历史截图和演示任务 JSON,保留原内容。不是自动测试,不能用作本次测试结论。
|
||||
- `fixtures/article/`:自造 Markdown 和三张 1×1 PNG,其中两张内容相同,用于验证上传去重及插图顺序。
|
||||
- `test_queue.py`:持久队列、去重、重启、终止、未知提交和按篇数换号。
|
||||
- `test_controller.py`:本地接口权限、模拟下载到队列、静态页面路由。
|
||||
- `test_download_core.py`:复制的下载器原有离线测试,针对合并项目中的下载器副本运行。
|
||||
- `importer.test.cjs`:导入器回归与自动模式的停止/冲突保护。
|
||||
- `browser.test.cjs`:本地页面、移动布局与模拟编辑器;从不请求真实论坛。
|
||||
- `artifacts/`:本轮页面截图,属于生成产物。
|
||||
|
||||
原始受理去重记录继续保存在 `../发布去重记录.json`,服务启动时自动导入;没有作为测试残留移走或删除。
|
||||
@@ -0,0 +1,13 @@
|
||||
'use strict';
|
||||
const {test}=require('node:test');const assert=require('node:assert/strict');const fs=require('node:fs');const path=require('node:path');const vm=require('node:vm');
|
||||
test('终止后较早的 heartbeat 响应不能恢复流程',async()=>{
|
||||
const handlers=new Map(),requests=[],responses=[],values={pairToken:'test-token',pairPort:8770};
|
||||
class Event{constructor(type,options={}){this.type=type;this.detail=options.detail;}}
|
||||
const doc={readyState:'loading',addEventListener(type,fn){handlers.set(type,fn);},dispatchEvent(event){if(event.type==='dww11:local-response')responses.push(JSON.parse(event.detail));else handlers.get(event.type)?.(event);}};
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname,'../browser/local-bridge.user.js'),'utf8'),{document:doc,CustomEvent:Event,GM_getValue:(k,d)=>values[k]??d,GM_setValue:(k,v)=>values[k]=v,GM_addValueChangeListener:()=>{},GM_xmlhttpRequest:r=>requests.push(r)});
|
||||
const call=(id,route)=>handlers.get('dww11:local-request')(new Event('',{detail:JSON.stringify({id,route})}));
|
||||
const heartbeat=call('old','heartbeat');const stop=call('stop','stop');
|
||||
requests[0].onload({status:200,responseText:JSON.stringify({running:true,epoch:1})});await heartbeat;
|
||||
assert.equal(responses.find(r=>r.id==='old').value.running,false);assert.equal(values.halted,true);
|
||||
requests[1].onload({status:200,responseText:JSON.stringify({running:false,epoch:2})});await stop;
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
const {chromium}=require('playwright');
|
||||
const fs=require('node:fs');
|
||||
const path=require('node:path');
|
||||
const assert=require('node:assert/strict');
|
||||
const ROOT=path.resolve(__dirname,'..');
|
||||
const output=path.join(__dirname,'artifacts');fs.mkdirSync(output,{recursive:true});
|
||||
(async()=>{
|
||||
const browser=await chromium.launch({headless:true,executablePath:process.env.CHROME_PATH||'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'});
|
||||
try{
|
||||
const page=await browser.newPage({viewport:{width:1440,height:1100}}),errors=[];
|
||||
page.on('pageerror',error=>errors.push(error.message));
|
||||
await page.route('**/*',route=>new URL(route.request().url()).hostname==='127.0.0.1'?route.continue():route.abort());
|
||||
await page.goto('http://127.0.0.1:8771/');
|
||||
await page.getByText('原创导入测试稿',{exact:true}).waitFor();
|
||||
await page.screenshot({path:path.join(output,'desktop.png'),fullPage:true});
|
||||
await page.locator('#fid').fill('125');await page.locator('#minimum').fill('1');await page.locator('#maximum').fill('2');await page.locator('#interval').fill('10');await page.locator('#history-checked').check();
|
||||
await page.getByRole('button',{name:'保存处理规则'}).click();await page.getByText('处理规则已保存',{exact:true}).waitFor();
|
||||
await page.locator('#start').click();await page.getByText('流程运行中',{exact:true}).waitFor();
|
||||
await page.locator('#stop').click();await page.getByText('流程已暂停',{exact:true}).waitFor();
|
||||
await page.setViewportSize({width:390,height:844});await page.screenshot({path:path.join(output,'mobile.png'),fullPage:true});
|
||||
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,'mobile overflow');
|
||||
assert.deepEqual(errors,[]);console.log('PASS local UI: configuration, start/stop, desktop/mobile layout');
|
||||
await page.close();
|
||||
const mock=await browser.newPage();
|
||||
await mock.route('**/*',route=>route.fulfill({contentType:'text/html; charset=utf-8',body:'<!doctype html><a id="mumucms_username" href="/home.php?mod=space&uid=123">测试账号</a><input id="subject"><iframe id="e_iframe"></iframe><button id="postsubmit">发表帖子</button>'}));
|
||||
await mock.goto('https://www.dww11.com/forum.php?mod=post&action=newthread&fid=125');
|
||||
await mock.evaluate(()=>{
|
||||
window.wysiwyg=1;window.events=[];window.clicks=0;window.formats=0;
|
||||
window.job={id:'fixture',state:'preparing',title:'测试标题',source:'测试来源',fid:125,account:{uid:'123',name:'测试账号'}};
|
||||
window.flow={running:true,epoch:1,active:'fixture',config:{fid:125}};
|
||||
document.querySelector('#postsubmit').addEventListener('click',()=>window.clicks++);
|
||||
window.DWW11Importer={stagePackage:async()=>({warnings:[]}),importPrepared:async()=>{
|
||||
document.querySelector('#subject').value='测试标题';
|
||||
document.querySelector('#e_iframe').contentDocument.body.innerHTML='<p>正文第一段</p><img id="attachimg_1" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"><p>正文第二段</p><p>来源:测试来源,如有侵权,请联系删除。</p>';
|
||||
return {images:{'a.gif':'1'}};
|
||||
}};
|
||||
document.addEventListener('dww11:local-request',event=>{
|
||||
const {id,route,body}=JSON.parse(event.detail);let value={};
|
||||
window.events.push({route,body});
|
||||
if(route==='heartbeat'){window.flow.owner=body.owner;value=window.flow;}
|
||||
if(route==='next')value={action:window.job.state==='preparing'?'prepare':'resume',job:window.job,run:window.flow};
|
||||
if(route==='current')value={job:window.job,run:window.flow};
|
||||
if(route==='event'){window.job.state=body.state;value={ok:true};}
|
||||
if(route==='stop'){window.flow.running=false;value=window.flow;}
|
||||
document.dispatchEvent(new CustomEvent('dww11:local-response',{detail:JSON.stringify({id,value})}));
|
||||
});
|
||||
});
|
||||
await mock.addScriptTag({path:path.join(ROOT,'browser/formatter.js')});
|
||||
await mock.addScriptTag({path:path.join(ROOT,'browser/runner.js')});
|
||||
await mock.waitForFunction(()=>window.job.state==='awaiting_user_review');
|
||||
await mock.waitForTimeout(2400);
|
||||
assert.equal(await mock.evaluate(()=>window.clicks),0,'runner must never publish');
|
||||
assert.equal(await mock.evaluate(()=>document.querySelector('#e_iframe').contentDocument.body.querySelectorAll('[data-dww11-wx-source]').length),1);
|
||||
const before=await mock.evaluate(()=>document.querySelector('#e_iframe').contentDocument.body.innerHTML);
|
||||
await mock.evaluate(()=>window.DWW11Formatter.format('测试来源'));
|
||||
const after=await mock.evaluate(()=>document.querySelector('#e_iframe').contentDocument.body.innerHTML);
|
||||
assert.equal(after,before,'repeated formatting should be idempotent');
|
||||
await mock.locator('#postsubmit').click();
|
||||
await mock.waitForFunction(()=>window.job.state==='user_submitting');
|
||||
await mock.evaluate(()=>history.pushState({},'', '/thread/999.html'));
|
||||
await mock.waitForFunction(()=>window.job.state==='submit_unknown');
|
||||
assert.equal(await mock.evaluate(()=>window.flow.running),false);
|
||||
assert.equal(await mock.evaluate(()=>window.events.some(e=>e.route==='accept')),false,'unknown page must not count accepted');
|
||||
assert.equal(await mock.evaluate(()=>window.clicks),1);
|
||||
console.log('PASS browser fixture: import/format, source uniqueness, repeated formatting, manual-only publish, unknown result stops');
|
||||
await mock.close();
|
||||
const ext=await browser.newPage();
|
||||
await ext.route('**/*',route=>route.fulfill({contentType:'text/html; charset=utf-8',body:'<!doctype html><div class="topwp"><a href="#">立即登录</a></div>'}));
|
||||
await ext.goto('https://www.dww11.com/');
|
||||
await ext.evaluate(()=>{
|
||||
window.routes=[];window.chrome.runtime={sendMessage:async message=>{
|
||||
window.routes.push(message.route);
|
||||
if(message.route==='/hello')return {ok:true,data:{total:1,active:1,round:1,remaining:1,flow:null}};
|
||||
if(message.route==='/start')return new Promise(resolve=>window.finishStart=()=>resolve({ok:true,data:{flow:{id:'test-run',phase:'logout',paused:false}}}));
|
||||
if(message.route==='/suspend')return {ok:true,data:{}};
|
||||
throw new Error('Unexpected extension operation: '+message.route);
|
||||
}};
|
||||
});
|
||||
await ext.addScriptTag({path:path.join(ROOT,'../dww11-account-switch-browser-extension/content.js')});
|
||||
await ext.waitForFunction(()=>JSON.parse(document.querySelector('#dww11-extension-panel').dataset.autoNewsState).idle);
|
||||
await ext.locator('#dww11-extension-next').click();
|
||||
await ext.waitForFunction(()=>!!window.finishStart);
|
||||
await ext.evaluate(()=>{document.dispatchEvent(new CustomEvent('dww11:auto-stop'));window.finishStart();});
|
||||
await ext.waitForFunction(()=>window.routes.includes('/suspend'));
|
||||
assert.equal(await ext.evaluate(()=>JSON.parse(document.querySelector('#dww11-extension-panel').dataset.autoNewsState).paused),true);
|
||||
assert.deepEqual(await ext.evaluate(()=>window.routes),['/hello','/start','/suspend']);
|
||||
console.log('PASS extension bridge: stop during pending start prevents login and persists pause');
|
||||
await ext.close();
|
||||
}finally{await browser.close();}
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
# 原创导入测试稿
|
||||
|
||||
这是项目自有的离线测试稿,用于验证正文、段落和图片顺序。
|
||||
|
||||

|
||||
|
||||
第二段保留原文标点,以及普通文字。
|
||||
|
||||

|
||||
|
||||
最后一段说明图片上传不等于发表文章。
|
||||
|
||||

|
||||
|
||||
来源:项目测试素材,如有侵权,请联系删除。
|
||||
@@ -0,0 +1,172 @@
|
||||
// Offline verification only. No browser account, uploads or publication requests.
|
||||
'use strict';
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { webcrypto, createHash } = require('node:crypto');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const core = require('../browser/importer.js');
|
||||
const directory = path.join(__dirname, 'fixtures/article');
|
||||
const mdName = '原创导入测试稿.md';
|
||||
function articleFiles() {
|
||||
return fs.readdirSync(directory).map(name => {
|
||||
const blob = new Blob([fs.readFileSync(path.join(directory, name))], { type: name.endsWith('.png') ? 'image/png' : 'text/markdown' });
|
||||
blob.name = name; blob.webkitRelativePath = '文章/' + name; return blob;
|
||||
});
|
||||
}
|
||||
|
||||
test('测试文章:标题、3 张配图、插入顺序、来源与段落完整', async () => {
|
||||
const article = await core.prepareFiles(articleFiles());
|
||||
assert.equal(article.title, mdName.slice(0, -3));
|
||||
assert.deepEqual([...article.images.keys()], ['文章/image001.png', '文章/image002.png', '文章/image003.png']);
|
||||
const output = article.render(new Map([...article.images.keys()].map((p, i) => [p, String(101 + i)])));
|
||||
assert.deepEqual([...output.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g)].map(m => m[1]), ['101', '102', '103']);
|
||||
assert.equal(output.includes('# 原创导入测试稿'), false);
|
||||
const md = fs.readFileSync(path.join(directory, mdName), 'utf8');
|
||||
const originalBody = md.split('\n').slice(1).join('\n').trim().replace(/!\[\]\(image00(\d)\.png\)/g, (_, n) => `[attachimg]${100 + Number(n)}[/attachimg]`);
|
||||
assert.equal(output, originalBody);
|
||||
});
|
||||
|
||||
test('缺图、远程图片、超出目录、超大图片与超长标题在上传前被拒绝', () => {
|
||||
assert.throws(() => core.compile('# 标题\n\n', 'a.md', new Map()), /找不到配图/);
|
||||
assert.throws(() => core.compile('# 标题\n\n', 'a.md', new Map()), /目录内/);
|
||||
assert.throws(() => core.imagePath('a.md', '../secret.jpg'), /超出/);
|
||||
assert.throws(() => core.imagePath('a.md', '%2fetc/passwd'), /相对路径/);
|
||||
assert.throws(() => core.compile('# ' + '长'.repeat(81) + '\n正文', 'a.md', new Map()), /80/);
|
||||
assert.throws(() => core.compile('# 标题\n\n', 'a.md', new Map([['big.png', {size:20*1024*1024}]])), /20 MB/);
|
||||
});
|
||||
|
||||
test('子目录、Unicode 规范化、空格编码、括号、重复图片和格式转换', () => {
|
||||
const fileMap = new Map([['目录/图 (1).jpg', new Blob(['image'])]]);
|
||||
const article = core.compile('# 标题\n\n**粗体**和*斜体*\n\n.jpg)\n.jpg> "说明")\n\n[链接](https://example.com/a)\n\n- 一\n- 二\n\n> 引用', '目录/a.md', fileMap);
|
||||
assert.equal(article.images.size, 1);
|
||||
assert.match(article.render(new Map([['目录/图 (1).jpg', '100']])), /\[b\]粗体\[\/b\]/);
|
||||
assert.equal(core.normalizePath('cafe\u0301/a.jpg'), 'café/a.jpg');
|
||||
assert.equal(core.readLink("", 0).target, "child's.jpg");
|
||||
assert.throws(() => core.compile('# 标题\n![][a]\n[a]: x.jpg', 'a.md', fileMap), /引用式/);
|
||||
});
|
||||
|
||||
test('多个 Markdown 必须选择;无 Markdown 不接受', async () => {
|
||||
const files = articleFiles();
|
||||
const extra = new Blob(['# 另一篇\n正文']); extra.name = 'b.md'; extra.webkitRelativePath = '文章/b.md';
|
||||
const result = await core.prepareFiles([...files, extra]);
|
||||
assert.equal(result.candidates.length, 2);
|
||||
assert.equal((await core.prepareFiles([...files, extra], '文章/b.md')).title, '另一篇');
|
||||
await assert.rejects(core.prepareFiles(files.filter(f => !f.name.endsWith('.md'))), /没有/);
|
||||
});
|
||||
|
||||
test('下载程序对接:Python 文章包与目录直接导入完全一致', async () => {
|
||||
const data = JSON.parse(execFileSync('python3', [path.join(__dirname, '../modules/package.py'), directory], {encoding:'utf8'}));
|
||||
const packaged = await core.preparePackage(data);
|
||||
const direct = await core.prepareFiles(articleFiles());
|
||||
assert.equal(packaged.title, direct.title);
|
||||
assert.equal(packaged.body, direct.body);
|
||||
assert.equal(packaged.images.size, 3);
|
||||
for (const [p, blob] of packaged.images) assert.equal(blob.size, fs.statSync(path.join(directory, p)).size);
|
||||
});
|
||||
|
||||
test('服务器返回错误、登录 HTML 或混入脚本时不猜测附件编号', () => {
|
||||
assert.equal(core.parseAid(' 123456\n'), '123456');
|
||||
for (const value of ['-1', '0', '<html>123</html>', '<script>123</script>', '123error', '{"aid":123}']) assert.throws(() => core.parseAid(value));
|
||||
});
|
||||
|
||||
// Small DOM harness exercises the public API and site adapter without installing browser software.
|
||||
function harness({ failUpload = false, loseImages = false, existing = false } = {}) {
|
||||
const ids = new Map(), callbacks = new Map(), requests = [];
|
||||
class Element {
|
||||
constructor(id='') { this.id=id; this.style={}; this.value=''; this.disabled=false; this.checked=false; this.hidden=false; this.listeners=new Map(); this.children=[]; }
|
||||
attachShadow() { return this; }
|
||||
set innerHTML(value) { this._html=value; for (const m of value.matchAll(/\bid="([^"]+)"/g)) ids.set(m[1], new Element(m[1])); }
|
||||
get innerHTML() { return this._html || ''; }
|
||||
getElementById(id) { return ids.get(id); }
|
||||
appendChild(el) { this.children.push(el); return el; }
|
||||
replaceChildren(...els) { this.children=els; }
|
||||
addEventListener(name, fn) { this.listeners.set(name, fn); }
|
||||
removeEventListener(name) { this.listeners.delete(name); }
|
||||
dispatchEvent() {}
|
||||
click() { this.onclick?.(); }
|
||||
querySelectorAll() { return []; }
|
||||
}
|
||||
for (const id of ['subject', 'e_textarea', 'e_switchercheck', 'postform']) ids.set(id, new Element(id));
|
||||
ids.get('subject').value=existing ? '原标题' : '';
|
||||
ids.get('e_textarea').value=existing ? '原正文' : '';
|
||||
const doc = { body:new Element(), createElement: () => new Element(), getElementById:id=>ids.get(id),
|
||||
querySelector: s=>ids.get(s.replace(/^#/,'')), querySelectorAll:()=>[] };
|
||||
const win = { document:doc, location:{search:'?mod=post&action=newthread&fid=95',href:'https://www.dww11.com/forum.php?mod=post&action=newthread&fid=95',origin:'https://www.dww11.com'},
|
||||
wysiwyg:0, allowbbcode:1, allowimgcode:1, crypto:webcrypto,
|
||||
imgUpload:{settings:{upload_url:'https://www.dww11.com/misc.php?mod=swfupload&operation=upload&fid=95',post_params:{uid:'fake-user',hash:'fake-hash',type:'image'},file_size_limit:20480}},
|
||||
confirm:()=>true, dispatchEvent:()=>{}, addEventListener:()=>{}, editdoc:{body:new Element()} };
|
||||
win.getEditorContents=()=>win.wysiwyg ? win.editdoc.body.innerHTML : ids.get('e_textarea').value;
|
||||
win.switchEditor=mode=>{
|
||||
if (mode===win.wysiwyg) return;
|
||||
if (mode===1) win.editdoc.body.innerHTML=ids.get('e_textarea').value.replace(/\[attachimg\](\d+)\[\/attachimg\]/g, (_,aid)=>loseImages ? '' : `<img aid="attachimg_${aid}">`);
|
||||
else ids.get('e_textarea').value=win.editdoc.body.innerHTML;
|
||||
win.wysiwyg=mode;
|
||||
};
|
||||
win.updateImageList=(_action,aids)=>{ for (const id of aids.split('|')) ids.set('image_'+id,new Element()); };
|
||||
win.fetch=async (url,options)=>{
|
||||
requests.push({url,options});
|
||||
return {ok:true,text:async()=>failUpload ? '-1' : String(1000+requests.length)};
|
||||
};
|
||||
const context = {window:win,URL,URLSearchParams,Blob,FormData,AbortController,Uint8Array,Option:Element,Event:class {},CustomEvent:class {},
|
||||
MutationObserver:class {observe(){} disconnect(){}},atob,setTimeout,clearTimeout,console};
|
||||
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../browser/importer.js'),'utf8'), context);
|
||||
return {api:win.DWW11Importer, win, ids, requests};
|
||||
}
|
||||
|
||||
test('完整导入:暂存零请求、相同图片去重、3处附件映射、富文本结果、重试复用、没有发布请求', async () => {
|
||||
const h=harness();
|
||||
await h.api.stageFiles(articleFiles());
|
||||
assert.equal(h.requests.length,0);
|
||||
const result=await h.api.importPrepared();
|
||||
assert.equal(Object.keys(result.images).length,3);
|
||||
const uniqueImages=new Set(fs.readdirSync(directory).filter(p=>p.endsWith('.png')).map(p=>createHash('sha256').update(fs.readFileSync(path.join(directory,p))).digest('hex'))).size;
|
||||
assert.equal(h.requests.length,uniqueImages);
|
||||
assert.equal(h.ids.get('subject').value,mdName.slice(0,-3));
|
||||
assert.match(h.win.getEditorContents(),/attachimg_1001/);
|
||||
assert.equal(h.api.getState().busy,false);
|
||||
assert.equal(h.api.getBackup().content,'');
|
||||
await h.api.importPrepared();
|
||||
assert.equal(h.requests.length,uniqueImages);
|
||||
for (const req of h.requests) {
|
||||
assert.equal(new URL(req.url).pathname,'/misc.php');
|
||||
assert.equal(req.options.body.get('type'),'image');
|
||||
assert.ok(req.options.body.get('Filedata') instanceof Blob);
|
||||
}
|
||||
});
|
||||
|
||||
test('上传失败保留原文;富文本丢图片时回滚;取消覆盖不上传', async () => {
|
||||
for (const options of [{failUpload:true,existing:true},{loseImages:true,existing:true}]) {
|
||||
const h=harness(options);
|
||||
await h.api.stageFiles(articleFiles());
|
||||
await assert.rejects(h.api.importPrepared());
|
||||
assert.equal(h.ids.get('subject').value,'原标题');
|
||||
assert.equal(h.win.getEditorContents(),'原正文');
|
||||
assert.equal(h.api.getState().busy,false);
|
||||
}
|
||||
const h=harness({existing:true}); h.win.confirm=()=>false;
|
||||
await h.api.stageFiles(articleFiles());
|
||||
assert.equal((await h.api.importPrepared()).cancelled,true);
|
||||
assert.equal(h.requests.length,0);
|
||||
});
|
||||
|
||||
// New integration guards must not prompt or overwrite a user's draft.
|
||||
test('自动导入:非空编辑器停止,不弹窗、不上传', async () => {
|
||||
const h=harness({existing:true});h.win.confirm=()=>{throw new Error('unexpected prompt');};
|
||||
await h.api.stageFiles(articleFiles());
|
||||
await assert.rejects(h.api.importPrepared({automatic:true}),/编辑器已有内容/);
|
||||
assert.equal(h.requests.length,0);assert.equal(h.ids.get('subject').value,'原标题');
|
||||
});
|
||||
test('自动导入:预先终止不上传,不填写', async () => {
|
||||
const h=harness();await h.api.stageFiles(articleFiles());const c=new AbortController();c.abort(new Error('stopped'));
|
||||
await assert.rejects(h.api.importPrepared({automatic:true,signal:c.signal}),/stopped/);
|
||||
assert.equal(h.requests.length,0);assert.equal(h.ids.get('subject').value,'');
|
||||
});
|
||||
test('自动导入:上传中终止不会填写正文或继续下一张', async () => {
|
||||
const h=harness();await h.api.stageFiles(articleFiles());const c=new AbortController();
|
||||
const original=h.win.fetch;h.win.fetch=async (...args)=>{const result=await original(...args);c.abort(new Error('stopped'));return result;};
|
||||
await assert.rejects(h.api.importPrepared({automatic:true,signal:c.signal}),/stopped/);
|
||||
assert.equal(h.requests.length,1);assert.equal(h.ids.get('subject').value,'');
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Temporary local UI fixture; never contacts the news API or forum."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from http.server import ThreadingHTTPServer
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app import Controller, make_handler
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
controller = Controller(Path(temp) / 'data', Path(temp) / 'downloads', ledger=None)
|
||||
controller.store.enqueue(ROOT / 'test/fixtures/article')
|
||||
server = ThreadingHTTPServer(('127.0.0.1', 8771), make_handler(controller))
|
||||
print('UI fixture listening on 8771', flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import ThreadingHTTPServer
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app import Controller, make_handler
|
||||
|
||||
|
||||
class ControllerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tmp.name)
|
||||
self.controller = Controller(self.root / 'data', self.root / 'downloads', ledger=None)
|
||||
self.server = ThreadingHTTPServer(('127.0.0.1', 0), make_handler(self.controller))
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
self.url = 'http://127.0.0.1:' + str(self.server.server_port)
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown();self.server.server_close();self.thread.join();self.tmp.cleanup()
|
||||
|
||||
def request(self, path, body=None, token=None, origin=None, host=None):
|
||||
headers = {}
|
||||
if token: headers['X-Session-Token'] = token
|
||||
if origin: headers['Origin'] = origin
|
||||
if host: headers['Host'] = host
|
||||
if body is not None: headers['Content-Type'] = 'application/json'
|
||||
request = Request(self.url + path, data=None if body is None else json.dumps(body).encode(), headers=headers)
|
||||
with urlopen(request, timeout=5) as response:
|
||||
return response.status, response.read()
|
||||
|
||||
def test_auth_and_origin_boundaries(self):
|
||||
for path, kwargs in [('/api/state', {}), ('/api/state', {'token': self.controller.bridge_token}),
|
||||
('/api/state', {'token': self.controller.ui_token, 'origin': 'https://example.com'}),
|
||||
('/bridge/heartbeat', {'token': self.controller.ui_token})]:
|
||||
with self.assertRaises(HTTPError) as error:
|
||||
self.request(path, {}, **kwargs)
|
||||
self.assertEqual(error.exception.code, 403)
|
||||
with self.assertRaises(HTTPError):
|
||||
self.request('/api/session', host='attacker.example')
|
||||
code, body = self.request('/api/state', {}, token=self.controller.ui_token)
|
||||
self.assertEqual(code, 200)
|
||||
self.assertNotIn(self.controller.bridge_token, body.decode())
|
||||
|
||||
def test_static_and_unknown_routes(self):
|
||||
self.assertIn('新闻工作台', self.request('/')[1].decode())
|
||||
self.assertEqual(self.request('/browser/auto-news.user.js')[0], 200)
|
||||
with self.assertRaises(HTTPError):
|
||||
self.request('/../store.py')
|
||||
with self.assertRaises(HTTPError):
|
||||
self.request('/bridge/scan', {}, token=self.controller.bridge_token)
|
||||
|
||||
def test_download_to_queue_without_network(self):
|
||||
self.controller.manager.requester = lambda *_: {'code': 1, 'data': {'list': [dict(title='模拟新闻', src='模拟来源', content='<p>模拟正文</p>', url='https://example.com/news')]}}
|
||||
self.controller.ui('download', dict(key='fake-key', channel='模拟频道', target=1))
|
||||
self.controller.manager.thread.join(5)
|
||||
state = self.controller.ui('state', {})
|
||||
self.assertEqual(state['download']['saved'], 1)
|
||||
self.assertEqual(len(state['jobs']), 1)
|
||||
self.assertEqual(state['jobs'][0]['state'], 'queued')
|
||||
self.assertTrue(state['jobs'][0]['source_keys'])
|
||||
self.assertNotIn('fake-key', json.dumps(state))
|
||||
self.controller.scan(self.controller.manager.root)
|
||||
self.assertEqual(len(self.controller.store.state()['jobs']), 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,165 @@
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
import requests
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from app import downloader
|
||||
Downloader, make_handler, fingerprints = downloader.Downloader, downloader.make_handler, downloader.fingerprints
|
||||
from article_writer import save_article
|
||||
|
||||
|
||||
def article(i):
|
||||
return dict(title=f'新闻{i}', content=f'<p>第{i}篇新闻内容</p>', url=f'https://example.com/{i}', src='测试来源', time='2026-10-31')
|
||||
|
||||
|
||||
class DownloadTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def run_job(self, manager, target):
|
||||
manager.start('test-key', '头条', target)
|
||||
manager.thread.join(10)
|
||||
self.assertFalse(manager.thread.is_alive())
|
||||
return manager.snapshot()
|
||||
|
||||
def test_format_and_local_image(self):
|
||||
data = article(1)
|
||||
image = 'data:image/png;base64,' + base64.b64encode(b'\x89PNG\r\n\x1a\nexample').decode()
|
||||
data['content'] += f'<img src="{image}"><img src="{image}">'
|
||||
result = save_article(data, self.root)
|
||||
body = Path(result.markdown).read_text()
|
||||
self.assertTrue(body.startswith('# 新闻1\n\n第1篇新闻内容'))
|
||||
self.assertNotIn('发布时间:2026-10-31', body)
|
||||
self.assertNotIn('\n---\n', body)
|
||||
self.assertTrue(body.endswith('来源:测试来源,如有侵权,请联系删除。\n'))
|
||||
self.assertEqual(len(result.images), 1)
|
||||
self.assertEqual(body.count('image001.png'), 2)
|
||||
self.assertNotIn('data:image', body)
|
||||
second = save_article(data, self.root)
|
||||
self.assertEqual(Path(second.markdown).name, '新闻1 (2).md')
|
||||
|
||||
def test_bad_image_cleans_partial(self):
|
||||
data = article(1)
|
||||
data['content'] += '<img src="data:image/png;base64,aW52YWxpZA==">'
|
||||
with self.assertRaises(Exception):
|
||||
save_article(data, self.root)
|
||||
self.assertEqual(list(self.root.iterdir()), [])
|
||||
|
||||
def test_duplicate_then_fill_and_restart(self):
|
||||
items = [article(1), article(1), article(2), article(3)]
|
||||
calls = []
|
||||
def request(key, endpoint, params):
|
||||
calls.append(params.copy())
|
||||
return {'code': 1, 'data': {'list': items[params['start']:params['start']+params['num']]}}
|
||||
manager = Downloader(self.root, request)
|
||||
state = self.run_job(manager, 3)
|
||||
self.assertEqual((state['status'], state['saved'], state['skipped']), ('completed', 3, 1))
|
||||
self.assertEqual([c['start'] for c in calls], [0, 3])
|
||||
restarted = Downloader(self.root, request)
|
||||
state = self.run_job(restarted, 3)
|
||||
self.assertEqual((state['status'], state['saved'], state['skipped']), ('partial', 0, 4))
|
||||
self.assertEqual(len(list(self.root.glob('*/*.md'))), 3)
|
||||
|
||||
def test_offset_limit_and_partial_progress(self):
|
||||
offsets = []
|
||||
def request(key, endpoint, params):
|
||||
offsets.append(params['start'])
|
||||
return {'code': 1, 'data': {'list': [article(i) for i in range(params['start'], params['start']+params['num'])]}}
|
||||
state = self.run_job(Downloader(self.root, request), 500)
|
||||
self.assertEqual(state['status'], 'partial')
|
||||
self.assertEqual(state['saved'], 440)
|
||||
self.assertEqual(max(offsets), 400)
|
||||
|
||||
def test_error_and_key_redaction(self):
|
||||
def request(*args):
|
||||
return {'code': 10001, 'msg': 'invalid test-key'}
|
||||
manager = Downloader(self.root, request)
|
||||
state = self.run_job(manager, 10)
|
||||
self.assertEqual(state['status'], 'error')
|
||||
self.assertNotIn('test-key', json.dumps(state))
|
||||
|
||||
def test_stop_and_concurrent_start(self):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
def request(*args):
|
||||
entered.set()
|
||||
release.wait(2)
|
||||
return {'code': 1, 'data': {'list': [article(1)]}}
|
||||
manager = Downloader(self.root, request)
|
||||
manager.start('key', '头条', 10)
|
||||
self.assertTrue(entered.wait(2))
|
||||
with self.assertRaises(ValueError):
|
||||
manager.start('key', '头条', 10)
|
||||
manager.stop.set()
|
||||
release.set()
|
||||
manager.thread.join(3)
|
||||
self.assertEqual(manager.snapshot()['status'], 'cancelled')
|
||||
self.assertEqual(manager.snapshot()['saved'], 0)
|
||||
|
||||
def test_failed_save_can_retry_next_job(self):
|
||||
attempts = []
|
||||
def writer(data, root):
|
||||
attempts.append(1)
|
||||
if len(attempts) == 1:
|
||||
raise ValueError('模拟图片失败')
|
||||
return save_article(data, root)
|
||||
def request(key, endpoint, params):
|
||||
return {'code': 1, 'data': {'list': [article(1)] if params['start'] == 0 else []}}
|
||||
manager = Downloader(self.root, request, writer)
|
||||
self.assertEqual(self.run_job(manager, 1)['failed'], 1)
|
||||
self.assertEqual(self.run_job(manager, 1)['saved'], 1)
|
||||
|
||||
def test_fingerprint_content_and_tracking(self):
|
||||
a, b = article(1), article(2)
|
||||
b['content'] = a['content'].replace('<p>', '<div>').replace('</p>', '</div>')
|
||||
self.assertTrue(set(fingerprints(a)) & set(fingerprints(b)))
|
||||
b['content'] = '其他内容'
|
||||
b['url'] = a['url'] + '?utm_source=abc#fragment'
|
||||
self.assertTrue(set(fingerprints(a)) & set(fingerprints(b)))
|
||||
|
||||
def test_log_cap(self):
|
||||
manager = Downloader(self.root)
|
||||
for i in range(400):
|
||||
manager.log(str(i))
|
||||
self.assertEqual(len(manager.snapshot()['logs']), 300)
|
||||
self.assertEqual(manager.snapshot()['logs'][0]['message'], '100')
|
||||
|
||||
def test_http_routes_and_access(self):
|
||||
def request(key, endpoint, params=None):
|
||||
return {'code': 1, 'data': {'list': ['头条', '科技']}}
|
||||
manager = Downloader(self.root, request)
|
||||
server = ThreadingHTTPServer(('127.0.0.1', 0), make_handler(manager, 'token'))
|
||||
worker = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
worker.start()
|
||||
url = f'http://127.0.0.1:{server.server_port}'
|
||||
try:
|
||||
self.assertIn('新闻下载器', requests.get(url).text)
|
||||
self.assertEqual(requests.get(url+'/api/state').json()['status'], 'idle')
|
||||
self.assertEqual(requests.post(url+'/api/channels', json={'key': 'test'}).status_code, 403)
|
||||
self.assertEqual(requests.get(url, headers={'Host':'evil.example'}).status_code, 403)
|
||||
headers = {'X-Session-Token': 'token'}
|
||||
self.assertEqual(requests.post(url+'/api/channels', headers=headers, json={'key':'test'}).json()['channels'], ['头条','科技'])
|
||||
invalid = requests.post(url+'/api/start', headers=headers, json={'key':'test', 'target':0,'channel':'头条'})
|
||||
self.assertEqual(invalid.status_code, 400)
|
||||
invalid = requests.post(url+'/api/debug', headers=headers, json={'key':'test','endpoint':'index','num':41,'start':0})
|
||||
self.assertEqual(invalid.status_code, 400)
|
||||
debug = requests.post(url+'/api/debug', headers=headers, json={'key':'test','endpoint':'channel'})
|
||||
self.assertEqual(debug.status_code, 200)
|
||||
self.assertNotIn('key=test', debug.text)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
worker.join()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,155 @@
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from articles import inspect_article, short_title
|
||||
from store import Store
|
||||
|
||||
|
||||
class QueueTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tmp.name)
|
||||
self.article = self.root / 'article'
|
||||
self.article.mkdir()
|
||||
self.md = self.article / 'a.md'
|
||||
self.md.write_text('# 测试新闻\n\n正文内容。\n\n来源:测试来源,如有侵权,请联系删除。\n')
|
||||
self.store = Store(self.root / 'queue.sqlite3')
|
||||
self.store.configure(dict(fid=125, minimum=1, maximum=1, interval=0, history_checked=True))
|
||||
self.owner = dict(owner='tab-a', account=dict(uid='123', name='测试账号'), extension={'idle': True})
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def begin(self):
|
||||
self.store.enqueue(self.article)
|
||||
self.store.control('start')
|
||||
return self.store.next(self.owner)['job']
|
||||
|
||||
def transition(self, job, state, **extra):
|
||||
return self.store.event(dict(owner='tab-a', id=job['id'], state=state, epoch=self.store.run['epoch'], **extra))
|
||||
|
||||
def submit(self, job):
|
||||
for state in ('importing', 'formatting', 'awaiting_user_review'):
|
||||
self.transition(job, state)
|
||||
self.transition(job, 'user_submitting', snapshot={'title': job['title']})
|
||||
|
||||
def accept(self, job):
|
||||
return self.store.accept(dict(owner='tab-a', id=job['id'], post_url='https://www.dww11.com/thread/12345.html',
|
||||
author_uid='123', fid=125, title=job['title'], state='accepted_pending_moderation', evidence={'test': 'fixture only'}))
|
||||
|
||||
def test_stable_identity_and_original_preserved(self):
|
||||
before = self.md.read_bytes()
|
||||
item = inspect_article(self.article)
|
||||
copy = self.root / 'renamed'
|
||||
shutil.copytree(self.article, copy)
|
||||
(copy / 'a.md').write_text(self.md.read_text().replace('测试新闻', '改标题'))
|
||||
self.assertEqual(inspect_article(copy)['article_key'], item['article_key'])
|
||||
self.assertEqual(self.md.read_bytes(), before)
|
||||
self.assertEqual(item['source'], '测试来源')
|
||||
|
||||
def test_title_does_not_break_emoji(self):
|
||||
short = short_title('😀' * 50)
|
||||
self.assertTrue(short.endswith('...'))
|
||||
self.assertLessEqual(len(short.encode('utf-16-le')) // 2, 80)
|
||||
self.assertEqual(short_title('短标题'), '短标题')
|
||||
|
||||
def test_unsupported_images_stop_before_queue(self):
|
||||
(self.article / 'a.webp').write_bytes(b'webp')
|
||||
with self.assertRaisesRegex(ValueError, '暂不支持'):
|
||||
self.store.enqueue(self.article)
|
||||
self.assertEqual(self.store.state()['jobs'], [])
|
||||
|
||||
def test_missing_and_outside_image_rejected(self):
|
||||
for image in ('missing.png', '../outside.png', 'https://example.com/a.png'):
|
||||
self.md.write_text('# 标题\n\n正文 \n\n来源:测试,如有侵权,请联系删除。')
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.enqueue(self.article)
|
||||
|
||||
def test_no_duplicate_queue_or_cross_tab_claim(self):
|
||||
job = self.begin()
|
||||
self.assertIn('已在队列', self.store.enqueue(self.article))
|
||||
self.assertEqual(self.store.next({**self.owner, 'owner': 'tab-b'})['action'], 'other_tab')
|
||||
self.assertEqual(self.store.next(self.owner)['job']['id'], job['id'])
|
||||
|
||||
def test_stop_invalidates_old_continuation(self):
|
||||
job = self.begin()
|
||||
epoch = self.store.run['epoch']
|
||||
self.store.control('stop')
|
||||
self.store.control('start')
|
||||
with self.assertRaisesRegex(ValueError, '过期'):
|
||||
self.store.event(dict(owner='tab-a', id=job['id'], state='importing', epoch=epoch))
|
||||
|
||||
def test_restart_pauses_and_preserves_claim(self):
|
||||
job = self.begin()
|
||||
restarted = Store(self.root / 'queue.sqlite3')
|
||||
self.assertFalse(restarted.run['running'])
|
||||
self.assertEqual(restarted.run['active'], job['id'])
|
||||
self.assertEqual(restarted.run['batch']['target'], 1)
|
||||
|
||||
def test_accept_requires_manual_submission(self):
|
||||
job = self.begin()
|
||||
with self.assertRaisesRegex(ValueError, '人工提交'):
|
||||
self.accept(job)
|
||||
|
||||
def test_accept_is_atomic_and_idempotent(self):
|
||||
job = self.begin()
|
||||
self.submit(job)
|
||||
self.accept(job)
|
||||
self.accept(job)
|
||||
self.assertEqual(self.store.run['batch']['count'], 1)
|
||||
self.assertIn('已受理', self.store.enqueue(self.article))
|
||||
with self.assertRaisesRegex(ValueError, '已受理'):
|
||||
self.store.resolve(dict(id=job['id'], confirmed_not_accepted=True, retry=True))
|
||||
|
||||
def test_unknown_never_requeues_automatically(self):
|
||||
job = self.begin()
|
||||
self.submit(job)
|
||||
self.transition(job, 'submit_unknown')
|
||||
self.assertEqual(self.store.next(self.owner)['job']['state'], 'submit_unknown')
|
||||
self.assertEqual(self.store.run['batch']['count'], 0)
|
||||
self.assertIsNone(self.store.run['switch'])
|
||||
|
||||
def test_migrate_real_ledger_shape(self):
|
||||
record = dict(markdown_sha256=hashlib.sha256(self.md.read_bytes()).hexdigest(), block_repost=True,
|
||||
markdown_path=str(self.md), status='accepted_pending_moderation', post_id='12345')
|
||||
self.store.import_history([record])
|
||||
self.assertIn('已受理', self.store.enqueue(self.article))
|
||||
|
||||
def test_switch_only_after_target_and_with_more_work(self):
|
||||
job = self.begin()
|
||||
self.submit(job)
|
||||
self.accept(job)
|
||||
other = self.root / 'other';shutil.copytree(self.article, other)
|
||||
(other / 'a.md').write_text(self.md.read_text().replace('正文内容', '另一篇内容'))
|
||||
self.store.enqueue(other)
|
||||
self.assertEqual(self.store.next(self.owner)['action'], 'switch')
|
||||
self.assertEqual(self.store.next(self.owner)['action'], 'switch')
|
||||
self.store.switch_begin({'owner': 'tab-a'})
|
||||
with self.assertRaisesRegex(ValueError, '禁止重复'):
|
||||
self.store.switch_begin({'owner': 'tab-a'})
|
||||
self.assertEqual(self.store.next(self.owner)['action'], 'switch_wait')
|
||||
next_job = self.store.next({**self.owner, 'account': {'uid': '456', 'name': '新测试账号'}})
|
||||
self.assertEqual(next_job['action'], 'prepare')
|
||||
self.assertEqual(self.store.run['batch']['count'], 0)
|
||||
|
||||
def test_no_switch_when_queue_done(self):
|
||||
job = self.begin();self.submit(job);self.accept(job)
|
||||
self.assertEqual(self.store.next(self.owner)['action'], 'idle')
|
||||
self.assertIsNone(self.store.run['switch'])
|
||||
|
||||
def test_bad_result_does_not_increment(self):
|
||||
job = self.begin();self.submit(job)
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.accept(dict(owner='tab-a', id=job['id'], post_url='https://example.com/thread/12345.html'))
|
||||
self.assertEqual(self.store.run['batch']['count'], 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,22 @@
|
||||
# 本轮验证记录
|
||||
|
||||
日期:2026-10-31。测试均未使用真实账号,未向新闻 API 请求内容,未向论坛上传或发布。
|
||||
|
||||
| 范围 | 结果 |
|
||||
| --- | --- |
|
||||
| Python 下载器、队列与 HTTP 接口 | 27/27 通过 |
|
||||
| JavaScript 导入器与停止/覆盖保护 | 11/11 通过 |
|
||||
| 连接桥旧响应与终止竞态 | 1/1 通过 |
|
||||
| 本地前端 | 配置保存、开始/停止、桌面与手机布局通过,无页面脚本错误、无手机横向溢出 |
|
||||
| 模拟编辑器 | 导入→排版→待人工审核;来源唯一、重复排版保持一致;程序无自动发帖点击 |
|
||||
| 模拟人工发表与未知结果 | 真实测试点击被记录;候选结果页未适配时暂停,不调用受理接口 |
|
||||
| 模拟换号插件 | 启动请求尚未返回时终止,返回后调用暂停,不执行登录;状态明确 paused |
|
||||
| JavaScript 语法 | 合并脚本、连接脚本、更新后的插件 content.js 通过 |
|
||||
|
||||
浏览器使用本机 Chrome 的独立临时配置。对论坛域名的所有请求均被测试 HTML 拦截。后端使用临时目录和 SQLite,不写入正式任务库。
|
||||
|
||||
页面截图:`artifacts/desktop.png`、`artifacts/mobile.png`。
|
||||
|
||||
测试中曾发现模拟网页未声明 UTF-8,导致中文登录入口无法匹配;已修正测试响应编码并重新通过。真实网页编码未由此假定。
|
||||
|
||||
尚未验证:用户浏览器中油猴本地通信权限、真实网站当前的导入与附件转换、真实换号账号链路、结果页受理/审核/公开状态。按用户决定,结果页 HTML 待提供,自动核对暂不实现;因此当前不宣称全流程批量处理已完成在线验收。
|
||||
Reference in new issue
Block a user