const fs = require('node:fs') const os = require('node:os') const path = require('node:path') const { spawn } = require('node:child_process') const edge = process.env.JIO_FORUM_BROWSER || 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' const base = process.env.JIO_FORUM_BASE_URL || 'http://localhost:9081' const lockedUrl = process.env.JIO_FORUM_LOCKED_URL || '' const multiVoteUrl = process.env.JIO_FORUM_MULTI_VOTE_URL || '' const browserPassword = process.env.JIO_FORUM_TEST_PASSWORD || '' const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'jio-forum-browser-')) const browser = spawn(edge, [ '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank', ], { stdio: 'ignore', windowsHide: true }) const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const until = async (fn, timeout = 15000) => { const end = Date.now() + timeout while (Date.now() < end) { const result = await fn().catch(() => null) if (result) return result await sleep(100) } throw new Error('browser timeout') } async function run() { const portFile = path.join(profile, 'DevToolsActivePort') const port = await until(async () => fs.existsSync(portFile) && Number(fs.readFileSync(portFile, 'utf8').split('\n')[0])) const target = await until(async () => { const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json() return list.find((item) => item.type === 'page')?.webSocketDebuggerUrl }) const socket = new WebSocket(target) await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }) socket.addEventListener('error', reject, { once: true }) }) let nextId = 0 const pending = new Map() const problems = [] socket.addEventListener('message', ({ data }) => { const item = JSON.parse(data) if (item.id && pending.has(item.id)) { const { resolve, reject } = pending.get(item.id) pending.delete(item.id) item.error ? reject(new Error(item.error.message)) : resolve(item.result) } if (item.method === 'Runtime.exceptionThrown') problems.push(item.params.exceptionDetails.text) if (item.method === 'Runtime.consoleAPICalled' && item.params.type === 'error') { problems.push(item.params.args.map((arg) => arg.value || arg.description || '').join(' ')) } if (item.method === 'Network.loadingFailed' && !item.params.canceled) problems.push(item.params.errorText) }) const call = (method, params = {}) => new Promise((resolve, reject) => { const id = ++nextId pending.set(id, { resolve, reject }) socket.send(JSON.stringify({ id, method, params })) }) await Promise.all([call('Page.enable'), call('Runtime.enable'), call('Network.enable')]) const check = async (width, height, url, label) => { await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 }) await call('Page.navigate', { url: base + url }) await until(async () => { const result = await call('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true }) return result.result.value === 'complete' }) await sleep(800) const result = await call('Runtime.evaluate', { expression: `({forum:!!document.getElementById('forum'),posts:document.querySelectorAll('.jio-forum__post').length,locked:document.body.textContent.includes('内容已隐藏'),leaked:document.documentElement.innerHTML.includes('需要积分查看的正文'),overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth,width:innerWidth})`, returnByValue: true, }) const state = result.result.value if (!state.forum || state.overflow || (url === '/forums/' && state.posts < 1) || (url === lockedUrl && (!state.locked || state.leaked))) { throw new Error(`${label}: ${JSON.stringify(state)}`) } console.log(`${label}: ${JSON.stringify(state)}`) } try { await check(1440, 900, '/forums/', 'desktop home') await check(390, 844, '/forums/', 'mobile home') if (lockedUrl) { await check(390, 844, lockedUrl, 'mobile locked post') await call('Runtime.evaluate', { expression: `document.body.classList.remove('dark-theme');document.documentElement.classList.remove('dark')` }) await sleep(650) const before = await call('Runtime.evaluate', { expression: `getComputedStyle(document.querySelector('.jio-forum__locked')).backgroundColor`, returnByValue: true }) await call('Runtime.evaluate', { expression: `document.body.classList.add('dark-theme');document.documentElement.classList.add('dark')` }) await sleep(650) const after = await call('Runtime.evaluate', { expression: `getComputedStyle(document.querySelector('.jio-forum__locked')).backgroundColor`, returnByValue: true }) if (before.result.value === after.result.value) throw new Error(`forum dark mode does not change locked surface: ${before.result.value}`) console.log(`dark mode: ${JSON.stringify({ light: before.result.value, dark: after.result.value })}`) } if (browserPassword) { await call('Page.navigate', { url: base + '/sign/?tab=signin' }) await until(async () => { const state = await call('Runtime.evaluate', { expression: 'document.readyState === "complete" && !!window.jioData?.restNonce', returnByValue: true }) return state.result.value }) const login = await call('Runtime.evaluate', { expression: `(async () => { const response=await fetch('/wp-json/jio/v1/auth/login',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-WP-Nonce':window.jioData.restNonce},body:JSON.stringify({target:'forum_admin',password:${JSON.stringify(browserPassword)},redirect_to:${JSON.stringify(base + '/wp-admin/')}})});return {status:response.status,body:await response.json()}})()`, awaitPromise: true, returnByValue: true, }) if (login.result.value?.status !== 200) throw new Error(`login failed: ${JSON.stringify(login.result.value)}`) for (const [width, height, label] of [[1440, 900, 'desktop admin'], [390, 844, 'mobile admin']]) { await call('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: width < 600 }) await call('Page.navigate', { url: base + '/wp-admin/admin.php?page=jio-settings#/forum/general' }) const state = await until(async () => { const result = await call('Runtime.evaluate', { expression: `({app:!!document.querySelector('#jio-admin-app'),ready:document.querySelector('#jio-admin-app')?.textContent.includes('启用论坛'),width:innerWidth,overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth})`, returnByValue: true }) return result.result.value?.ready && result.result.value }, 20000) if (!state.app || state.overflow) throw new Error(`${label}: ${JSON.stringify(state)}`) console.log(`${label}: ${JSON.stringify(state)}`) } if (multiVoteUrl) { await call('Page.navigate', { url: base + multiVoteUrl }) await until(async () => { const result = await call('Runtime.evaluate', { expression: `document.querySelectorAll('.jio-forum__vote input[type=checkbox]').length >= 2`, returnByValue: true }) return result.result.value }) await call('Runtime.evaluate', { expression: `(() => {const form=document.querySelector('[data-forum-form=vote]');const boxes=form.querySelectorAll('input[type=checkbox]');boxes[0].checked=true;boxes[1].checked=true;form.requestSubmit();return true})()`, returnByValue: true }) const voted = await until(async () => { const result = await call('Runtime.evaluate', { expression: `({done:document.querySelector('.jio-forum__vote')?.textContent.includes('已投票'),error:document.querySelector('.jio-forum__vote .jio-forum__message')?.textContent||''})`, returnByValue: true }) return result.result.value?.done && result.result.value }, 12000) if (voted.error) throw new Error(`multiple vote browser error: ${voted.error}`) console.log(`multiple vote browser: ${JSON.stringify(voted)}`) } await call('Page.navigate', { url: base + '/posts-edit/' }) const editorState = await until(async () => { const result = await call('Runtime.evaluate', { expression: `({form:!!document.querySelector('[data-forum-form=post]'),editor:!!document.querySelector('#jio_forum_content'),upload:!!document.querySelector('[data-forum-upload=image]')})`, returnByValue: true }) return result.result.value?.form && result.result.value }) if (!editorState.editor || !editorState.upload) throw new Error(`forum editor unavailable: ${JSON.stringify(editorState)}`) console.log(`forum editor: ${JSON.stringify(editorState)}`) const editorLayout = await call('Runtime.evaluate', { expression: `({overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth,css:[...document.styleSheets].some((sheet)=>sheet.href?.includes('/forum.css'))})`, returnByValue: true }) if (editorLayout.result.value?.overflow || !editorLayout.result.value?.css) throw new Error(`mobile editor layout: ${JSON.stringify(editorLayout.result.value)}`) console.log(`mobile editor layout: ${JSON.stringify(editorLayout.result.value)}`) const upload = await call('Runtime.evaluate', { expression: `(async()=>{const source=await fetch('/wp-content/themes/jio/assets/img/topic.jpg');const blob=await source.blob();const body=new FormData();body.append('file',new File([blob],'topic.jpg',{type:'image/jpeg'}));const response=await fetch('/wp-json/jio/v1/forum/upload/image',{method:'POST',credentials:'same-origin',headers:{'X-WP-Nonce':window.jioForum.nonce},body});const payload=await response.json();return {status:response.status,id:payload.data?.id,url:payload.data?.url,message:payload.message}})()`, awaitPromise: true, returnByValue: true, }) if (upload.result.value?.status !== 200 || !upload.result.value?.url) throw new Error(`forum upload failed: ${JSON.stringify(upload.result.value)}`) console.log(`forum upload: ${JSON.stringify({ status: upload.result.value.status, id: upload.result.value.id })}`) const submitted = await call('Runtime.evaluate', { expression: `(() => { const form=document.querySelector('[data-forum-form=post]');form.querySelector('[name=title]').value='浏览器表单测试';const plate=form.querySelector('[name=plate_id]');plate.value=plate.querySelector('option[value]:not([value=""])')?.value||'';const editor=window.tinymce?.get('jio_forum_content');if(editor&&!editor.isHidden())editor.setContent('
浏览器表单正文
');else form.querySelector('[name=content]').value='浏览器表单正文';form.requestSubmit();return {plate:plate.value} })()`, returnByValue: true, }) if (!submitted.result.value?.plate) throw new Error('no publishable plate in editor') const saved = await until(async () => { const result = await call('Runtime.evaluate', { expression: `({url:location.pathname,error:document.querySelector('.jio-forum__form .jio-forum__message')?.textContent||''})`, returnByValue: true }) return result.result.value?.url.startsWith('/forum-post/') && result.result.value }, 12000) console.log(`browser form submit: ${JSON.stringify(saved)}`) await call('Page.navigate', { url: base + '/user/forum/' }) const center = await until(async () => { const result = await call('Runtime.evaluate', { expression: `({panel:!!document.querySelector('#panel-forum'),posts:document.querySelector('#panel-forum')?.textContent.includes('帖子'),plates:document.querySelector('#panel-forum')?.textContent.includes('版块'),css:[...document.styleSheets].some((sheet)=>sheet.href?.includes('/forum.css'))})`, returnByValue: true }) return result.result.value?.panel && result.result.value }) if (!center.posts || !center.plates || !center.css) throw new Error(`forum user panel unavailable: ${JSON.stringify(center)}`) console.log(`user center: ${JSON.stringify(center)}`) await call('Page.navigate', { url: base + '/author/forum_admin/' }) const publicHome = await until(async () => { const result = await call('Runtime.evaluate', { expression: `({forum:!!document.querySelector('#panel-forum'),css:[...document.styleSheets].some((sheet)=>sheet.href?.includes('/forum.css'))})`, returnByValue: true }) return result.result.value?.forum && result.result.value }) if (!publicHome.css) throw new Error(`public user home CSS missing: ${JSON.stringify(publicHome)}`) console.log(`public user home: ${JSON.stringify(publicHome)}`) } if (problems.length) throw new Error(`browser errors: ${problems.join('; ')}`) } finally { call('Browser.close').catch(() => {}) await sleep(150) socket.close() } } const cleanup = async () => { const target = path.resolve(profile) const tempRoot = path.resolve(os.tmpdir()) + path.sep if (target.startsWith(tempRoot) && path.basename(target).startsWith('jio-forum-browser-')) { for (let attempt = 0; attempt < 20; attempt++) { try { fs.rmSync(target, { recursive: true, force: true }) return } catch (error) { if (error.code !== 'EBUSY' && error.code !== 'EPERM') throw error await sleep(250) } } throw new Error('browser profile is still in use') } } run().then(async () => { browser.kill() await cleanup() }).catch(async (error) => { console.error(error) browser.kill() await cleanup().catch((cleanupError) => console.error(cleanupError)) process.exitCode = 1 })