study-browser-probe.mjs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import * as fs from 'node:fs'
  2. import * as path from 'node:path'
  3. import { pathToFileURL } from 'node:url'
  4. import assert from 'node:assert/strict'
  5. const runtimePath = path.resolve(process.argv[2])
  6. const runtime = JSON.parse(fs.readFileSync(runtimePath, 'utf8').replace(/^\uFEFF/, ''))
  7. const evidence = path.dirname(runtimePath)
  8. const { chromium } = await import(pathToFileURL(path.join(process.env.WEBNOVEL_PLAYWRIGHT, 'index.mjs')).href)
  9. const browser = await chromium.launch({ headless: true, executablePath: process.env.WEBNOVEL_CHROMIUM })
  10. const context = await browser.newContext({ viewport: { width: 1440, height: 960 } })
  11. const page = await context.newPage()
  12. const errors = []
  13. const requests = []
  14. const failedResponses = []
  15. const layoutSamples = []
  16. async function settleLayout() {
  17. await page.evaluate(async () => {
  18. const deadline = performance.now() + 5000
  19. let previous = '', stable = 0
  20. while (performance.now() < deadline) {
  21. await new Promise(resolve => requestAnimationFrame(resolve))
  22. const frame = document.querySelector('[data-shell-overlay]')?.parentElement
  23. const column = document.querySelector('.nw-native-writing')
  24. const geometry = JSON.stringify([frame, frame?.children[0], column].map(element => element?.getBoundingClientRect().toJSON()))
  25. const moving = document.getAnimations().some(animation => animation instanceof CSSTransition && animation.playState === 'running')
  26. stable = !moving && geometry === previous ? stable + 1 : 0
  27. if (stable >= 8) return
  28. previous = geometry
  29. }
  30. throw new Error('Writing layout did not settle')
  31. })
  32. }
  33. page.on('response', async response => {
  34. if (response.status() < 400) return
  35. const url = new URL(response.url()).pathname
  36. failedResponses.push({ url, status: response.status(), ...(url.startsWith('/webnovel/') ? { body: (await response.text()).slice(0,500) } : {}) })
  37. })
  38. page.on('request', request => { if (request.method() === 'POST') requests.push({ url: new URL(request.url()).pathname, body: request.postData()?.slice(0,800) }) })
  39. page.on('pageerror', error => errors.push(error.message))
  40. page.on('console', message => { if (message.type() === 'error') errors.push(message.text()) })
  41. try {
  42. const url = fs.readFileSync(path.join(runtime.root, 'stdout.log'), 'utf8').match(/dsh web: (http\S+)/)?.[1]
  43. if (!url) throw new Error('Acceptance host is not ready')
  44. await page.goto(url)
  45. await page.getByRole('tab', { name: '书房', exact: true }).waitFor({ state: 'visible', timeout: 30000 })
  46. const welcome = page.getByRole('button', { name: '继续', exact: true })
  47. await welcome.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {})
  48. if (await welcome.isVisible()) { await welcome.click(); await welcome.waitFor({ state: 'hidden' }) }
  49. if (['verify', 'production'].includes(process.argv[3])) {
  50. const launch = page.getByRole('button', { name: '打开书稿与资料', exact: true })
  51. const column = page.getByRole('complementary', { name: '书稿与资料', exact: true })
  52. await launch.click()
  53. await column.getByText('暂无打开的文档', { exact: true }).waitFor()
  54. for (const width of [1920, 1680, 1440, 1280, 1190, 1100, 1024, 960, 1100, 1440]) {
  55. await page.setViewportSize({ width, height: 900 })
  56. await settleLayout()
  57. const geometry = await column.evaluate(element => {
  58. const box = element.getBoundingClientRect()
  59. const frame = document.querySelector('[data-shell-overlay]')?.parentElement
  60. const center = frame.children[1].getBoundingClientRect()
  61. return { left: box.left, right: box.right, width: box.width, frameWidth: frame.getBoundingClientRect().width, sidebarWidth: frame.children[0].getBoundingClientRect().width, centerRight: center.right, overflow: document.documentElement.scrollWidth > window.innerWidth }
  62. })
  63. layoutSamples.push({ viewport: width, ...geometry })
  64. assert.ok(geometry.width >= 240, `Native writing width at viewport ${width}`)
  65. assert.ok(geometry.right <= width + 1)
  66. assert.ok(geometry.left >= 0)
  67. assert.equal(geometry.overflow, false)
  68. }
  69. // Native Sidebar owns resize, fullscreen and collapse; no custom frame reservation.
  70. await page.getByRole('button', { name: '收起右侧边栏', exact: true }).click()
  71. await column.waitFor({ state: 'hidden' })
  72. await launch.click(); await column.waitFor({ state: 'visible' })
  73. await page.getByRole('button', { name: '收起右侧边栏', exact: true }).click()
  74. await column.waitFor({ state: 'hidden' })
  75. assert.equal(await page.locator('[data-webnovel-writing-frame]').count(), 0)
  76. }
  77. if (process.argv[3] === 'production') {
  78. await page.screenshot({ path: path.join(evidence, 'production-desktop.png'), fullPage: true })
  79. assert.equal(await page.getByRole('tab', { name: '工作区', exact: true }).isVisible(), true)
  80. assert.equal(await page.getByRole('tab', { name: '书房', exact: true }).isVisible(), true)
  81. assert.equal(await page.getByRole('button', { name: '刷新书房', exact: true }).isVisible(), true)
  82. console.log(JSON.stringify({ productionClient: true, url: page.url().split('?')[0], layoutSamples, failedResponses, errors }))
  83. process.exitCode = errors.length ? 1 : 0
  84. } else {
  85. if (!await page.getByRole('tab', { name: '工作区', exact: true }).isVisible()) {
  86. const expand = page.getByRole('button', { name: '展开书房', exact: true })
  87. if (await expand.isVisible()) await expand.click()
  88. else await page.getByRole('button', { name: '打开侧边栏', exact: true }).click()
  89. }
  90. await page.getByRole('tab', { name: '工作区', exact: true }).click()
  91. await page.getByText('S5 临时验收书房', { exact: true }).click()
  92. await page.getByText('S5 书房验收', { exact: true }).click()
  93. await page.getByRole('tab', { name: '书房', exact: true }).click()
  94. await page.getByRole('button', { name: /验收作品甲/ }).waitFor()
  95. if (process.argv[3] === 'verify') {
  96. const checks = { blankSessionPanel: true, widthsAndNoOverlay: true, sidebarAndWritingToggle: true, keyboardAndPointerResize: true }
  97. const initialReport = JSON.parse(fs.readFileSync(path.join(evidence, 'browser-host-report.json'), 'utf8'))
  98. const firstBook = page.locator('.nw-book').filter({ hasText: '验收作品甲' })
  99. await firstBook.getByRole('button', { name: /验收作品甲/ }).click()
  100. await firstBook.getByRole('button', { name: /^草稿区/ }).click()
  101. await firstBook.getByRole('button', { name: '草稿', exact: true }).click()
  102. await firstBook.getByRole('button', { name: '卷01-来信', exact: true }).click()
  103. await firstBook.getByRole('button', { name: /^稿1.md/ }).click()
  104. await page.getByRole('tab', { name: '稿1.md', exact: true }).waitFor()
  105. await page.getByRole('button', { name: '关闭 稿1.md', exact: true }).click()
  106. checks.nestedBookTree = true
  107. await page.getByText('打开原文', { exact: true }).first().click()
  108. await page.getByRole('tab', { name: '稿1.md', exact: true }).waitFor()
  109. checks.nativeDocumentLink = true
  110. await page.getByRole('button', { name: '编辑', exact: true }).click()
  111. const editor = page.getByRole('textbox', { name: '文档正文', exact: true })
  112. await editor.click()
  113. await page.keyboard.press('Control+Home')
  114. await page.keyboard.press('Control+Shift+End')
  115. const selection = await editor.innerText()
  116. await page.getByRole('button', { name: /^引用到对话/ }).click()
  117. const composer = page.locator('[contenteditable="true"]:not([aria-label="文档正文"])').filter({ visible: true }).first()
  118. const quote = await composer.innerText()
  119. assert.ok(quote.includes('【书房原文引用】') && quote.includes('acceptance-a'))
  120. assert.ok(quote.includes(selection.trim().split('\n').filter(Boolean).at(-1)))
  121. assert.ok(quote.length > 1500)
  122. checks.fullQuote = true
  123. await composer.fill('')
  124. await page.getByRole('button', { name: '阅读', exact: true }).click()
  125. await page.getByRole('button', { name: '知识库', exact: true }).click()
  126. await page.getByRole('button', { name: '写作笔记.md', exact: true }).click()
  127. await page.getByRole('button', { name: '编辑', exact: true }).click()
  128. const sharedEditor = page.getByRole('textbox', { name: '文档正文', exact: true })
  129. await sharedEditor.click()
  130. await page.keyboard.press('Control+End')
  131. const marker = '浏览器保存验收 ' + Date.now()
  132. await page.keyboard.insertText('\n' + marker + '\n')
  133. await page.getByRole('button', { name: '保存', exact: true }).click()
  134. await page.getByText('文件已保存', { exact: true }).waitFor()
  135. await page.getByText('已交给主控,处理结果见对话', { exact: true }).waitFor()
  136. assert.ok(fs.readFileSync(path.join(runtime.workspace, '书房/知识库/写作笔记.md'), 'utf8').includes(marker))
  137. checks.authorSave = true
  138. await page.getByRole('button', { name: '改动', exact: true }).click()
  139. assert.ok((await page.locator('.nw-changes').innerText()).includes(marker))
  140. checks.savedDiff = true
  141. await page.getByRole('button', { name: '阅读', exact: true }).click()
  142. await page.getByRole('tab', { name: '工作区', exact: true }).click()
  143. const native = page.locator('.nw-native-workspaces')
  144. await native.getByText('S5 空工作范围', { exact: true }).hover()
  145. await native.getByRole('button', { name: '在“S5 空工作范围”中新建会话', exact: true }).click()
  146. await page.getByRole('tab', { name: '书房', exact: true }).click()
  147. await page.getByText(/当前工作范围还没有作品/).waitFor()
  148. assert.equal(await page.getByRole('tab', { name: '写作笔记.md', exact: true }).count(), 0)
  149. await page.getByRole('tab', { name: '工作区', exact: true }).click()
  150. await native.getByText('S5 书房验收', { exact: true }).click()
  151. await page.getByRole('tab', { name: '书房', exact: true }).click()
  152. await page.getByRole('tab', { name: '写作笔记.md', exact: true }).waitFor()
  153. checks.sessionIsolation = true
  154. await page.getByRole('button', { name: '编辑', exact: true }).click()
  155. const retainedEditor = page.getByRole('textbox', { name: '文档正文', exact: true })
  156. await retainedEditor.click()
  157. await page.keyboard.press('Control+End')
  158. await page.keyboard.insertText('\n收起后保留的编辑')
  159. const retainedText = await retainedEditor.innerText()
  160. await page.getByRole('button', { name: '收起编辑器', exact: true }).click()
  161. await settleLayout()
  162. await page.getByRole('button', { name: '打开书稿与资料', exact: true }).click()
  163. await settleLayout()
  164. assert.equal(await retainedEditor.innerText(), retainedText)
  165. await page.getByRole('button', { name: '撤销', exact: true }).click()
  166. assert.ok(!(await retainedEditor.innerText()).includes('收起后保留的编辑'))
  167. await page.getByRole('button', { name: '重做', exact: true }).click()
  168. assert.equal(await retainedEditor.innerText(), retainedText)
  169. await page.getByRole('button', { name: '撤销', exact: true }).click()
  170. await page.getByRole('button', { name: '阅读', exact: true }).click()
  171. checks.collapsePreservesEditing = true
  172. await page.screenshot({ path: path.join(evidence, 'study-desktop.png'), fullPage: true })
  173. await page.setViewportSize({ width: 1190, height: 900 })
  174. await settleLayout()
  175. await page.screenshot({ path: path.join(evidence, 'study-medium.png'), fullPage: true })
  176. await page.setViewportSize({ width: 1440, height: 960 })
  177. await settleLayout()
  178. await page.getByRole('tab', { name: '可视化', exact: true }).click()
  179. await page.getByRole('heading', { name: '章节进度' }).waitFor()
  180. await page.locator('.nw-chapters>button').first().waitFor()
  181. await page.screenshot({ path: path.join(evidence, 'study-chapters.png'), fullPage: true })
  182. checks.chapters = true
  183. await page.setViewportSize({ width: 960, height: 900 })
  184. await settleLayout()
  185. const narrow = page.getByRole('complementary', { name: '书稿与资料', exact: true })
  186. await narrow.waitFor()
  187. assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), false)
  188. await page.screenshot({ path: path.join(evidence, 'study-narrow-desktop.png'), fullPage: true })
  189. await narrow.getByRole('button', { name: '编辑', exact: true }).click()
  190. const narrowEditor = narrow.getByRole('textbox', { name: '文档正文', exact: true })
  191. await narrowEditor.click()
  192. await page.keyboard.press('Control+End')
  193. await page.keyboard.insertText('\n尚未保存的修改')
  194. page.once('dialog', dialog => dialog.dismiss())
  195. await narrow.getByRole('button', { name: '关闭 写作笔记.md', exact: true }).click()
  196. assert.ok(await narrow.getByRole('tab', { name: /写作笔记.md/ }).count())
  197. page.once('dialog', dialog => dialog.accept())
  198. await narrow.getByRole('button', { name: '关闭 写作笔记.md', exact: true }).click()
  199. await narrow.getByRole('tab', { name: '稿1.md', exact: true }).waitFor()
  200. checks.desktopWindowAndUnsavedGuard = true
  201. await narrow.getByRole('button', { name: '收起编辑器', exact: true }).click()
  202. await narrow.waitFor({ state: 'hidden' })
  203. await settleLayout()
  204. checks.closeRestoresHost = true
  205. const hostReport = JSON.parse(fs.readFileSync(path.join(evidence, 'browser-host-report.json'), 'utf8'))
  206. assert.ok(hostReport.savesReceived > initialReport.savesReceived)
  207. assert.equal(hostReport.customEvents, 0)
  208. checks.nativeFollowup = true
  209. assert.deepEqual(errors, [])
  210. fs.writeFileSync(path.join(evidence, 'browser-acceptance.json'), JSON.stringify({ checks, layoutSamples, hostReport, errors }, null, 2) + '\n')
  211. console.log(JSON.stringify({ checks, layoutSamples, hostReport, errors }, null, 2))
  212. }
  213. await page.screenshot({ path: path.join(evidence, 'study-browser-inspect.png'), fullPage: true })
  214. if (process.argv[3] !== 'verify') console.log(JSON.stringify({ url: page.url().split('?')[0], text: (await page.locator('body').innerText()).slice(0,12000), buttons: await page.getByRole('button').evaluateAll(elements => elements.map(element => ({ text: element.innerText, title: element.title, aria: element.getAttribute('aria-label') }))), errors }, null, 2))
  215. }
  216. } catch (error) {
  217. await page.screenshot({ path: path.join(evidence, 'study-browser-failure.png'), fullPage: true })
  218. console.log(JSON.stringify({ text: (await page.locator('body').innerText()).slice(0,10000), links: await page.locator('a').evaluateAll(elements => elements.map(element => ({ text: element.innerText, href: element.getAttribute('href'), html: element.outerHTML.slice(0,1000) }))), requests: requests.slice(-15), failedResponses, errors }, null, 2))
  219. throw error
  220. } finally {
  221. await browser.close()
  222. }