// 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('# 标题\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', '123', '', '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, brokenImages = 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(selector) {
if (selector !== 'img') return [];
return [...this.innerHTML.matchAll(/
]*>/g)].map(m => ({
id:'', complete:true, naturalWidth:brokenImages ? 0 : 100,
getAttribute:name=>name==='aid'?m[1]:null,
addEventListener(){},removeEventListener(){}
}));
}
}
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 ? '' : `
`);
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,'');
});
test('真实站点 aid 属性:上传成功但图片响应失败不得报告完成', async () => {
const h=harness({brokenImages:true});await h.api.stageFiles(articleFiles());
await assert.rejects(h.api.importPrepared({automatic:true}),/网站附件图片无法显示/);
assert.equal(h.ids.get('subject').value,'');assert.equal(h.api.getState().result,null);
});
test('自动流程关闭图片检查后,不等待或拒绝无法显示的图片', async () => {
const h=harness({brokenImages:true});await h.api.stageFiles(articleFiles());
const result=await h.api.importPrepared({automatic:true,verifyImages:false});
assert.ok(result.attachmentOrder.length);assert.ok(h.ids.get('subject').value);
});
test('链接内图片、多行图文链接均参与上传并按原位置渲染', () => {
const md='# 标题\n\n[\n\n### 宣传标题\n\n说明](https://example.com/promo)\n\n';
const article=core.compile(md,'a.md',new Map([['image004.jpg',new Blob(['x'])],['image001.jpg',new Blob(['y'])]]));
assert.deepEqual([...article.images.keys()],['image004.jpg','image001.jpg']);
const rendered=article.render(new Map([['image004.jpg','104'],['image001.jpg','101']]));
assert.match(rendered,/\[url=https:\/\/example.com\/promo\]\[attachimg\]104\[\/attachimg\]/);
assert.deepEqual([...rendered.matchAll(/\[attachimg\](\d+)\[\/attachimg\]/g)].map(x=>x[1]),['104','101']);
assert.equal(rendered.includes('!['),false);
});
test('图片重复引用必须保留每个位置,不能只校验去重后的编号', async () => {
const img={id:'',getAttribute:n=>n==='aid'?'attachimg_12':null,complete:true,naturalWidth:100,addEventListener(){},removeEventListener(){}};
await core.verifyEditorImages({querySelectorAll:()=>[img,img]},['12','12']);
await assert.rejects(core.verifyEditorImages({querySelectorAll:()=>[img]},['12','12']),/数量或顺序/);
});
test('正文图片尚在加载时等待 load;终止后释放等待', async () => {
const make=()=>{const events=new Map();return {events,id:'',complete:false,naturalWidth:0,getAttribute:n=>n==='aid'?'attachimg_8':null,addEventListener:(n,f)=>events.set(n,f),removeEventListener:n=>events.delete(n)};};
const image=make();let finished=false;
const loaded=core.verifyEditorImages({querySelectorAll:()=>[image]},['8']).then(()=>finished=true);
await Promise.resolve();assert.equal(finished,false);
image.complete=true;image.naturalWidth=100;image.events.get('load')();await loaded;
assert.equal(image.events.size,0);
const pending=make(),controller=new AbortController();
const cancelled=core.verifyEditorImages({querySelectorAll:()=>[pending]},['8'],controller.signal);
controller.abort();await assert.rejects(cancelled,/终止/);assert.equal(pending.events.size,0);
});
test('标题按 UTF-8 预算截断,目录和文章包保持一致且重复处理稳定', async () => {
const cases = [
['A'.repeat(75), 'A'.repeat(75)],
['A'.repeat(76), 'A'.repeat(75) + '...'],
['中'.repeat(25), '中'.repeat(25)],
['中'.repeat(26), '中'.repeat(25) + '...'],
['A中,'.repeat(12), 'A中,'.repeat(10) + 'A中...'],
['😀'.repeat(20), '😀'.repeat(18) + '...'],
];
for (const [title, expected] of cases) {
assert.equal(core.shortTitle(title), expected);
assert.equal(core.shortTitle(expected), expected);
assert.ok(Buffer.byteLength(expected) <= 78);
const markdown = '# ' + title + '\n正文';
const direct = core.compile(markdown, 'a.md', new Map());
const packaged = await core.preparePackage({schemaVersion:1, markdown, title, images:[]});
assert.equal(direct.title, expected);
assert.equal(packaged.title, expected);
const python = execFileSync('python3', ['-c', 'import sys; from articles import short_title; print(short_title(sys.argv[1]), end="")', title], {cwd:path.join(__dirname, '..'), encoding:'utf8'});
assert.equal(python, expected);
}
});
test('长标题预览、实际填写和返回结果一致', async () => {
const h = harness();
const title = '新闻,'.repeat(20);
await h.api.stagePackage({schemaVersion:1, markdown:'# ' + title + '\n正文', images:[]});
const expected = '新闻,'.repeat(8) + '新...';
assert.equal(h.api.getState().title, expected);
const result = await h.api.importPrepared();
assert.equal(h.ids.get('subject').value, expected);
assert.equal(result.title, expected);
assert.equal(h.requests.length, 0);
});