import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { createServer } from 'node:http'; import { readFile, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve, extname, join, dirname, basename } from 'node:path'; const project = resolve(import.meta.dirname, '..'); const errors = []; const server = createServer(async (req, res) => { try { const path = new URL(req.url, 'http://localhost').pathname; let content = await readFile(join(project, path)); if (path.endsWith('.html')) { // 使用真实编辑页和页面控制器,仅替换网络、导航及富文本依赖。 content = content.toString().replace(/]*>[\s\S]*?<\/script>/g, (tag) => /(?:wangeditor5\/index|rich-editor|md5|attachment-editor|upload-pages|media-display|feed-pages|article-pages|video-pages|memo-pages|growth-pages|relative-pages|album-pages|ceremony-admin-pages)\.js/.test(tag) ? tag : ''); } res.setHeader('Content-Type', { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.png': 'image/png' }[extname(path)] || 'application/octet-stream'); res.end(content); } catch { res.writeHead(404); res.end(); } }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); const origin = `http://127.0.0.1:${server.address().port}`; const userData = await mkdtemp(join(tmpdir(), 'jiapu-attachment-test-')); const chrome = spawn('C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', [ '--headless=new', '--no-sandbox', '--disable-gpu', '--disable-gpu-sandbox', '--use-angle=swiftshader', '--disable-features=Vulkan', '--remote-debugging-port=0', `--user-data-dir=${userData}`, 'about:blank' ], { windowsHide: true }); let socket; try { const endpoint = await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Chrome 启动超时')), 15000); chrome.stderr.on('data', chunk => { const match = chunk.toString().match(/DevTools listening on (ws:\/\/\S+)/); if (match) { clearTimeout(timer); resolve(match[1]); } }); chrome.on('error', reject); }); const tabs = await (await fetch(endpoint.replace('ws:', 'http:').replace(/\/devtools\/browser\/.*/, '/json'))).json(); socket = new WebSocket(tabs.find(tab => tab.type === 'page').webSocketDebuggerUrl); await new Promise(resolve => socket.addEventListener('open', resolve, { once: true })); const pending = new Map(); let id = 0; socket.addEventListener('message', event => { const message = JSON.parse(event.data); if (message.method === 'Runtime.exceptionThrown') errors.push(message.params.exceptionDetails); if (pending.has(message.id)) { pending.get(message.id)(message); pending.delete(message.id); } }); async function send(method, params = {}) { const requestId = ++id; const promise = new Promise(resolve => pending.set(requestId, resolve)); socket.send(JSON.stringify({ id: requestId, method, params })); const response = await promise; if (response.error) throw new Error(JSON.stringify(response.error)); return response.result; } async function evaluate(expression) { const result = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); return result.result.value; } await send('Runtime.enable'); await send('Page.enable'); await send('Page.addScriptToEvaluateOnNewDocument', { source: ` window.saved = []; window.alert = function () {}; window.NavigationUtil = { open: function () {} }; window.fixtureFiles = [ { ossId: '2062179707935264771', fileName: '原照片.png', mediaType: 'image/png', accessUrl: '${origin}/public/images/logo-mark.png' }, { ossId: '2062179707935264772', fileName: '说明.pdf', mediaType: 'application/pdf', accessUrl: '${origin}/document.pdf' } ]; const record = { genealogyId: '2', feedId: '3', feedContent: '原动态', feedType: 'text', articleId: '3', articleTitle: '谱文', articleContent: '正文', videoId: '3', videoTitle: '影像', durationSeconds: 5, memoId: '3', memoTitle: '备忘', memoContent: '正文', completed: '0', recordId: '3', recordTitle: '成长', recordContent: '正文', recordType: 'other', relativeId: '3', relativeName: '亲友', ceremonyId: '3', ceremonyTitle: '活动', ceremonyType: 'other', albumId: '3', albumName: '相册', status: '0', mediaFiles: fixtureFiles, coverFile: fixtureFiles[0], videoFile: { ossId: '2062179707935264773', fileName: '原视频.mp4', mediaType: 'video/mp4', accessUrl: '${origin}/video.mp4' } }; window.GenealogyApi = { defaultClient: { getToken: () => 'mock', genealogyDetail: async () => ({ canEditContent: true, canManage: true }), currentProfile: async () => ({ userId: '1' }), feedDetail: async () => record, articleDetail: async () => record, videoDetail: async () => record, memoDetail: async () => record, relativeRecordDetail: async () => record, growthRecordDetail: async () => record, ceremonyDetail: async () => record, albums: async () => [record], lineagePersonOptions: async () => [], updateFeed: async (g, f, body) => { saved.push(body); return record; }, updateArticle: async (g, f, body) => { saved.push(body); return record; }, updateVideo: async (g, f, body) => { saved.push(body); return record; }, initResumableUpload: async () => { await new Promise(r => setTimeout(r, 150)); return { instant: true, ossId: '2062179707935264774' }; } } }; ` }); async function open(page, query) { await send('Page.navigate', { url: `${origin}/${page}?genealogyId=2&${query}=3` }); for (let attempt = 0; attempt < 100; attempt++) { if (await evaluate('document.querySelectorAll(".attachment-edit-item").length > 0')) return; await new Promise(resolve => setTimeout(resolve, 50)); } throw new Error(page + ': 附件没有回显 ' + await evaluate('document.body.innerText')); } await open('profile-feed-edit.html', 'feedId'); assert.equal(await evaluate('AppRichEditor.init(document.querySelector("[name=feedContent]")).editor.getText().trim()'), '原动态', '编辑器应回显原正文'); assert.equal(await evaluate('document.querySelector("#feedMedia").value'), '2062179707935264771,2062179707935264772'); assert.equal(await evaluate('document.querySelectorAll(".attachment-edit-item img").length'), 1); await evaluate('AppRichEditor.init(document.querySelector("[name=feedContent]")).editor.setHtml("

仅改文字

"); document.querySelector("form").requestSubmit()'); assert.equal(await evaluate('saved[0].mediaOssIds'), '2062179707935264771,2062179707935264772'); await evaluate('document.querySelector(".attachment-edit-remove").click()'); assert.equal(await evaluate('document.querySelector("#feedMedia").value'), '2062179707935264772'); await evaluate(`const input=document.querySelector('#feedMediaFile'); const transfer=new DataTransfer(); transfer.items.add(new File(['image'], '新增.png', {type:'image/png'})); input.files=transfer.files; input.dispatchEvent(new Event('change',{bubbles:true})); document.querySelector('form').requestSubmit();`); assert.equal(await evaluate('saved.length'), 1, '上传中应阻止保存'); await evaluate('new Promise(r=>setTimeout(r,250))'); assert.equal(await evaluate('document.querySelector("#feedMedia").value'), '2062179707935264772,2062179707935264774'); await evaluate('document.querySelectorAll(".attachment-edit-remove").forEach(button=>button.click()); document.querySelector("form").requestSubmit()'); assert.equal(await evaluate('saved.at(-1).mediaOssIds'), ''); await open('profile-article-edit.html', 'articleId'); await evaluate('document.querySelector(".attachment-edit-remove").click(); document.querySelector("form").requestSubmit()'); assert.equal(await evaluate('saved.at(-1).coverOssId'), null); await open('profile-video-edit.html', 'videoId'); assert.equal(await evaluate('document.querySelectorAll(".attachment-edit-item video").length'), 1); assert.equal(await evaluate('document.querySelector("#video-oss-id").value'), '2062179707935264773'); await evaluate('document.querySelector("#video-cover-oss-id").closest(".editor-field").querySelector(".attachment-edit-remove").click(); document.querySelector("form").requestSubmit()'); assert.equal(await evaluate('saved.at(-1).coverOssId'), null); assert.equal(await evaluate('saved.at(-1).videoOssId'), '2062179707935264773'); await evaluate('document.querySelector(".attachment-edit-item video").parentElement.querySelector("button").click(); document.querySelector("form").requestSubmit()'); assert.equal(await evaluate('saved.length'), 1, '必填视频移除后不能保存,必须先重新上传'); for (const [page, query] of [['memo','memoId'], ['growth','recordId'], ['relative','relativeId'], ['album','albumId'], ['gift','ceremonyId']]) { await open('profile-' + page + '-edit.html', query); if (page === 'album') assert.equal(await evaluate('document.querySelector(".attachment-edit-remove").hidden'), true); if (['memo', 'growth', 'relative'].includes(page)) { await evaluate(`document.querySelector('[data-${page}-clear-media]').click()`); assert.equal(await evaluate('document.querySelector("[name=mediaOssIds]").value'), ''); assert.equal(await evaluate('document.querySelectorAll(".attachment-edit-item").length'), 0); } } await send('Emulation.setDeviceMetricsOverride', { width: 390, height: 844, deviceScaleFactor: 1, mobile: true }); await open('profile-feed-edit.html', 'feedId'); assert.equal(await evaluate('document.documentElement.scrollWidth <= innerWidth'), true, '移动端不能横向溢出'); await evaluate(`try { AttachmentEditor.setFiles('#feedMedia', [{fileName:'缺少标识.png'}]); } catch {} document.querySelector('form').requestSubmit();`); assert.equal(await evaluate('saved.length'), 0, '原附件信息不完整时应阻止保存'); assert.deepEqual(errors, []); console.log('PASS: 编辑页原附件回显、逐项移除、追加上传、上传中阻止保存、全部移除、封面 null、视频预览与移动端布局(模拟 API)'); } finally { if (socket) socket.close(); chrome.kill(); server.close(); await new Promise(resolve => chrome.exitCode !== null ? resolve() : chrome.once('exit', resolve)); if (dirname(userData) === resolve(tmpdir()) && basename(userData).startsWith('jiapu-attachment-test-')) { await rm(userData, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }