// 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', '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, 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 ? '' : ``); 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,''); });