208 lines
14 KiB
JavaScript
208 lines
14 KiB
JavaScript
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('<p>浏览器表单正文</p>');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
|
|
})
|