first commit

This commit is contained in:
Longtail committed 2026-10-31 16:15:04 +08:00
commit 1e6504b4a2
41 files changed
+4964

No files matched your search

+12
View File
@@ -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`,服务启动时自动导入;没有作为测试残留移走或删除。
+13
View File
@@ -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;
});
+91
View File
@@ -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;});
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

+15
View File
@@ -0,0 +1,15 @@
# 原创导入测试稿
这是项目自有的离线测试稿,用于验证正文、段落和图片顺序。
![](image001.png)
第二段保留原文标点,以及普通文字。
![](image002.png)
最后一段说明图片上传不等于发表文章。
![](image003.png)
来源:项目测试素材,如有侵权,请联系删除。
+172
View File
@@ -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![](missing.jpg)', 'a.md', new Map()), /找不到配图/);
assert.throws(() => core.compile('# 标题\n\n![](https://x/a.jpg)', '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![](big.png)', '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![](图%20(1).jpg)\n![](<图 (1).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("![](child's.jpg)", 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,'');
});
+19
View File
@@ -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()
+75
View File
@@ -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()
+165
View File
@@ -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()
+155
View File
@@ -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正文 ![](' + image + ')\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()
+22
View File
@@ -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 待提供,自动核对暂不实现;因此当前不宣称全流程批量处理已完成在线验收。