verify-demo-browser-smoke.mjs 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062
  1. import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs'
  2. import { createServer } from 'node:http'
  3. import { createRequire } from 'node:module'
  4. import { delimiter, extname, join, normalize, relative, resolve, sep } from 'node:path'
  5. import { pathToFileURL } from 'node:url'
  6. const outputDir = resolve(
  7. process.env.DEMO_BROWSER_SMOKE_OUTPUT_DIR ||
  8. process.env.DEMO_OUTPUT_DIR ||
  9. 'apps/viewer-demo/dist'
  10. )
  11. const externalUrl = process.env.DEMO_BROWSER_SMOKE_URL
  12. const timeout = Number(process.env.DEMO_BROWSER_SMOKE_TIMEOUT || 45000)
  13. const sampleTimeout = Number(process.env.DEMO_BROWSER_SMOKE_SAMPLE_TIMEOUT || Math.max(timeout, 60000))
  14. const sourceExamplesRoot = resolve('apps/viewer-demo/public/example')
  15. const examplesRoot = resolve(
  16. process.env.DEMO_BROWSER_SMOKE_EXAMPLE_DIR ||
  17. (externalUrl && existsSync(sourceExamplesRoot)
  18. ? sourceExamplesRoot
  19. : existsSync(join(outputDir, 'example'))
  20. ? join(outputDir, 'example')
  21. : sourceExamplesRoot)
  22. )
  23. const require = createRequire(import.meta.url)
  24. const sampleSmokeConfig = {
  25. hiddenStateSelectors: [
  26. '.cad-state[hidden]',
  27. '.drawing-state[hidden]',
  28. '.epub-state[hidden]',
  29. '.model-state[hidden]',
  30. '.ofd-state[hidden]',
  31. '.pdf-nav-pane[hidden]',
  32. '.pdf-state[hidden]',
  33. '.pptx-error[hidden]',
  34. '.pptx-loading[hidden]',
  35. '.umd-state[hidden]',
  36. '.xmind-state[hidden]'
  37. ],
  38. loadingSelectors: [
  39. '.archive-state:not(.archive-hidden)',
  40. '.cad-state:not(.error):not([hidden])',
  41. '.drawing-state:not(.error):not([hidden])',
  42. '.eda-state',
  43. '.email-state',
  44. '.epub-state:not(.error):not([hidden])',
  45. '.excel-wrapper .loading:not(.hidden)',
  46. '.model-state:not(.error):not([hidden])',
  47. '.ofd-state:not(.error):not([hidden])',
  48. '.pdf-state:not(.pdf-state--error):not([hidden])',
  49. '.pptx-loading:not([hidden])',
  50. '.sheet-loading:not(.hidden)',
  51. '.state-panel.loading-panel:not(.hidden)',
  52. '.typst-loading',
  53. '.umd-state:not(.error):not([hidden])',
  54. '.xmind-state:not(.error):not([hidden])'
  55. ],
  56. errorSelectors: [
  57. '.archive-error:not(.archive-hidden)',
  58. '.cad-state.error:not([hidden])',
  59. '.drawing-state.error:not([hidden])',
  60. '.epub-state.error:not([hidden])',
  61. '.model-state.error:not([hidden])',
  62. '.ofd-state.error:not([hidden])',
  63. '.pdf-state.pdf-state--error:not([hidden])',
  64. '.pptx-error:not([hidden])',
  65. '.state-panel.error-panel:not(.hidden)',
  66. '.typst-error',
  67. '.umd-state.error:not([hidden])',
  68. '.xmind-state.error:not([hidden])'
  69. ],
  70. meaningfulSelectors: [
  71. '.archive-entry',
  72. '.archive-nested-target > *',
  73. '.cad-shell',
  74. '.docx-wrapper',
  75. '.drawing-viewer svg',
  76. '.email-viewer',
  77. '.epub-viewer iframe',
  78. '.excel-wrapper',
  79. '.file-viewer .content:not(.hidden)',
  80. '.geo-viewer',
  81. '.markdown-body',
  82. '.model-viewer canvas',
  83. '.ofd-viewer canvas',
  84. '.pdfViewer .page',
  85. '.pptx-slide',
  86. '.typst-page-shell',
  87. '.umd-viewer',
  88. '.xmind-node',
  89. 'audio',
  90. 'canvas',
  91. 'code',
  92. 'iframe',
  93. 'img',
  94. 'pre',
  95. 'svg',
  96. 'table',
  97. 'video'
  98. ],
  99. allowedNotices: [
  100. {
  101. sample: 'model.step',
  102. includes: 'STEP / ISO 10303'
  103. }
  104. ]
  105. }
  106. const verifyXMindPanInteraction = async (page, samplePath) => {
  107. const result = await page.evaluate(async () => {
  108. const stage = document.querySelector('.xmind-stage')
  109. const zoomBox = document.querySelector('.xmind-zoom-box')
  110. const nodeCount = document.querySelectorAll('.xmind-node').length
  111. if (!(stage instanceof HTMLElement) || !(zoomBox instanceof HTMLElement)) {
  112. return {
  113. ok: false,
  114. reason: 'missing XMind stage or zoom box',
  115. nodeCount
  116. }
  117. }
  118. const readTransform = () => window.getComputedStyle(zoomBox).transform || zoomBox.style.transform
  119. const readFirstNodeRect = () => {
  120. const node = document.querySelector('.xmind-node')
  121. if (!(node instanceof HTMLElement)) {
  122. return null
  123. }
  124. const rect = node.getBoundingClientRect()
  125. return {
  126. left: Number(rect.left.toFixed(2)),
  127. top: Number(rect.top.toFixed(2))
  128. }
  129. }
  130. const rectChanged = (beforeRect, afterRect) => Boolean(
  131. beforeRect &&
  132. afterRect &&
  133. (Math.abs(beforeRect.left - afterRect.left) > 8 || Math.abs(beforeRect.top - afterRect.top) > 8)
  134. )
  135. const rect = stage.getBoundingClientRect()
  136. const startX = rect.left + rect.width * 0.52
  137. const startY = rect.top + rect.height * 0.48
  138. const waitFrame = () => new Promise(resolve => requestAnimationFrame(resolve))
  139. const resetView = async () => {
  140. stage.dispatchEvent(new MouseEvent('dblclick', {
  141. bubbles: true,
  142. button: 0,
  143. buttons: 0,
  144. cancelable: true,
  145. clientX: startX,
  146. clientY: startY
  147. }))
  148. await waitFrame()
  149. await waitFrame()
  150. }
  151. const dispatchPointer = (target, type, clientX, clientY, buttons, pointerId, pointerType = 'mouse') => {
  152. target.dispatchEvent(new PointerEvent(type, {
  153. bubbles: true,
  154. button: 0,
  155. buttons,
  156. cancelable: true,
  157. clientX,
  158. clientY,
  159. pointerId,
  160. pointerType
  161. }))
  162. }
  163. const pointerDrag = async (pointerId, moveButtons = 1, startTarget = stage) => {
  164. await resetView()
  165. const before = readTransform()
  166. const beforeNodeRect = readFirstNodeRect()
  167. stage.focus({ preventScroll: true })
  168. dispatchPointer(startTarget, 'pointerdown', startX, startY, 1, pointerId)
  169. dispatchPointer(stage, 'pointermove', startX + 180, startY + 96, moveButtons, pointerId)
  170. await waitFrame()
  171. dispatchPointer(stage, 'pointermove', startX + 220, startY + 118, moveButtons, pointerId)
  172. await waitFrame()
  173. dispatchPointer(stage, 'pointerup', startX + 220, startY + 118, 0, pointerId)
  174. await waitFrame()
  175. const afterNodeRect = readFirstNodeRect()
  176. return {
  177. before,
  178. after: readTransform(),
  179. beforeNodeRect,
  180. afterNodeRect,
  181. nodeMoved: rectChanged(beforeNodeRect, afterNodeRect)
  182. }
  183. }
  184. const wheelPan = async () => {
  185. await resetView()
  186. const before = readTransform()
  187. const beforeNodeRect = readFirstNodeRect()
  188. stage.dispatchEvent(new WheelEvent('wheel', {
  189. bubbles: true,
  190. cancelable: true,
  191. deltaX: 96,
  192. deltaY: 48,
  193. clientX: startX,
  194. clientY: startY
  195. }))
  196. await waitFrame()
  197. const afterNodeRect = readFirstNodeRect()
  198. return {
  199. before,
  200. after: readTransform(),
  201. beforeNodeRect,
  202. afterNodeRect,
  203. nodeMoved: rectChanged(beforeNodeRect, afterNodeRect)
  204. }
  205. }
  206. const normalDrag = await pointerDrag(2319, 1)
  207. const zeroButtonsDrag = await pointerDrag(2320, 0)
  208. const nodeStartDrag = await pointerDrag(2323, 1, document.querySelector('.xmind-node') || stage)
  209. const wheelFallbackPan = await wheelPan()
  210. return {
  211. ok: nodeCount > 0 &&
  212. normalDrag.before !== normalDrag.after &&
  213. normalDrag.nodeMoved &&
  214. zeroButtonsDrag.before !== zeroButtonsDrag.after &&
  215. zeroButtonsDrag.nodeMoved &&
  216. nodeStartDrag.before !== nodeStartDrag.after &&
  217. nodeStartDrag.nodeMoved &&
  218. wheelFallbackPan.before !== wheelFallbackPan.after &&
  219. wheelFallbackPan.nodeMoved,
  220. before: normalDrag.before,
  221. after: wheelFallbackPan.after,
  222. normalDrag,
  223. zeroButtonsDrag,
  224. nodeStartDrag,
  225. wheelFallbackPan,
  226. nodeCount,
  227. reason: normalDrag.before === normalDrag.after
  228. ? 'XMind transform did not change after normal pointer drag'
  229. : !normalDrag.nodeMoved
  230. ? 'XMind node position did not change after normal pointer drag'
  231. : zeroButtonsDrag.before === zeroButtonsDrag.after
  232. ? 'XMind transform did not change after WebView-style zero-buttons pointer drag'
  233. : !zeroButtonsDrag.nodeMoved
  234. ? 'XMind node position did not change after WebView-style zero-buttons pointer drag'
  235. : nodeStartDrag.before === nodeStartDrag.after
  236. ? 'XMind transform did not change after node-start pointer drag'
  237. : !nodeStartDrag.nodeMoved
  238. ? 'XMind node position did not change after node-start pointer drag'
  239. : wheelFallbackPan.before === wheelFallbackPan.after
  240. ? 'XMind transform did not change after wheel pan'
  241. : !wheelFallbackPan.nodeMoved
  242. ? 'XMind node position did not change after wheel pan'
  243. : ''
  244. }
  245. })
  246. if (!result.ok) {
  247. throw new Error(`Sample ${samplePath} failed XMind pan smoke: ${JSON.stringify(result, null, 2)}`)
  248. }
  249. const dragTarget = await page.evaluate(async () => {
  250. const stage = document.querySelector('.xmind-stage')
  251. const zoomBox = document.querySelector('.xmind-zoom-box')
  252. if (!(stage instanceof HTMLElement) || !(zoomBox instanceof HTMLElement)) {
  253. return null
  254. }
  255. const waitFrame = () => new Promise(resolve => requestAnimationFrame(resolve))
  256. const rect = stage.getBoundingClientRect()
  257. const startX = rect.left + rect.width * 0.52
  258. const startY = rect.top + rect.height * 0.48
  259. stage.dispatchEvent(new MouseEvent('dblclick', {
  260. bubbles: true,
  261. button: 0,
  262. buttons: 0,
  263. cancelable: true,
  264. clientX: startX,
  265. clientY: startY
  266. }))
  267. await waitFrame()
  268. await waitFrame()
  269. return {
  270. before: window.getComputedStyle(zoomBox).transform || zoomBox.style.transform,
  271. startX,
  272. startY
  273. }
  274. })
  275. if (!dragTarget) {
  276. throw new Error(`Sample ${samplePath} failed XMind real mouse smoke: missing drag target`)
  277. }
  278. await page.mouse.move(dragTarget.startX, dragTarget.startY)
  279. await page.mouse.down()
  280. await page.mouse.move(dragTarget.startX + 160, dragTarget.startY + 86, { steps: 8 })
  281. await page.mouse.up()
  282. await page.waitForTimeout(80)
  283. const realMouse = await page.evaluate(before => {
  284. const zoomBox = document.querySelector('.xmind-zoom-box')
  285. const after = zoomBox instanceof HTMLElement
  286. ? window.getComputedStyle(zoomBox).transform || zoomBox.style.transform
  287. : ''
  288. return {
  289. ok: Boolean(after) && before !== after,
  290. before,
  291. after
  292. }
  293. }, dragTarget.before)
  294. if (!realMouse.ok) {
  295. throw new Error(`Sample ${samplePath} failed XMind real mouse smoke: ${JSON.stringify(realMouse, null, 2)}`)
  296. }
  297. }
  298. const verifyTypstRenderInteraction = async (page, samplePath) => {
  299. const result = await page.evaluate(() => {
  300. const pageShells = [...document.querySelectorAll('.typst-page-shell')]
  301. const svgs = [...document.querySelectorAll('.typst-page-shell svg')]
  302. const firstPage = pageShells[0]
  303. const firstSvg = svgs[0]
  304. const pageRect = firstPage?.getBoundingClientRect()
  305. const svgRect = firstSvg?.getBoundingClientRect()
  306. const statusText = document.querySelector('.typst-toolbar em')?.textContent || ''
  307. const hasVectorContent = svgs.some(svg => svg.querySelector('path,text,use,g,rect,circle,ellipse,line,polyline,polygon'))
  308. return {
  309. ok: pageShells.length > 0 &&
  310. svgs.length > 0 &&
  311. Boolean(pageRect && pageRect.width > 80 && pageRect.height > 80) &&
  312. Boolean(svgRect && svgRect.width > 80 && svgRect.height > 80) &&
  313. (/已渲染|Rendered/i).test(statusText) &&
  314. hasVectorContent,
  315. pageCount: pageShells.length,
  316. svgCount: svgs.length,
  317. pageRect: pageRect ? {
  318. width: Number(pageRect.width.toFixed(2)),
  319. height: Number(pageRect.height.toFixed(2))
  320. } : null,
  321. svgRect: svgRect ? {
  322. width: Number(svgRect.width.toFixed(2)),
  323. height: Number(svgRect.height.toFixed(2))
  324. } : null,
  325. statusText,
  326. hasVectorContent
  327. }
  328. })
  329. if (!result.ok) {
  330. throw new Error(`Sample ${samplePath} failed Typst WASM render smoke: ${JSON.stringify(result, null, 2)}`)
  331. }
  332. }
  333. const verifyDrawingRenderInteraction = async (page, samplePath) => {
  334. const result = await page.evaluate(async () => {
  335. const canvas = document.querySelector('.drawing-canvas')
  336. if (!(canvas instanceof HTMLElement)) {
  337. return { ok: false, reason: 'missing drawing canvas' }
  338. }
  339. const waitFrame = () => new Promise(resolve => requestAnimationFrame(resolve))
  340. await waitFrame()
  341. await waitFrame()
  342. const renderedMode = canvas.dataset.drawingRendered || ''
  343. const svg = canvas.querySelector('svg')
  344. const mxgraph = canvas.querySelector('.drawing-mxgraph')
  345. const svgRect = svg?.getBoundingClientRect()
  346. const mxRect = mxgraph?.getBoundingClientRect()
  347. const visibleSvg = Boolean(svgRect && svgRect.width > 80 && svgRect.height > 80)
  348. const visibleMxGraph = Boolean(mxRect && mxRect.width > 80 && mxRect.height > 80)
  349. const hasShapes = Boolean(svg?.querySelector('path,text,rect,circle,ellipse,line,polyline,polygon,use,g')) ||
  350. Boolean(mxgraph && mxgraph.children.length > 0)
  351. const zoomLabel = document.querySelector('.drawing-actions span')?.textContent || ''
  352. return {
  353. ok: Boolean(renderedMode) &&
  354. (visibleSvg || visibleMxGraph) &&
  355. hasShapes &&
  356. /%$/.test(zoomLabel.trim()),
  357. renderedMode,
  358. visibleSvg,
  359. visibleMxGraph,
  360. hasShapes,
  361. zoomLabel,
  362. svgRect: svgRect ? {
  363. width: Number(svgRect.width.toFixed(2)),
  364. height: Number(svgRect.height.toFixed(2))
  365. } : null,
  366. mxRect: mxRect ? {
  367. width: Number(mxRect.width.toFixed(2)),
  368. height: Number(mxRect.height.toFixed(2))
  369. } : null
  370. }
  371. })
  372. if (!result.ok) {
  373. throw new Error(`Sample ${samplePath} failed drawing render smoke: ${JSON.stringify(result, null, 2)}`)
  374. }
  375. }
  376. const verifyCadRenderInteraction = async (page, samplePath) => {
  377. const result = await page.evaluate(async () => {
  378. const stage = document.querySelector('.cad-stage')
  379. const zoomText = document.querySelector('.cad-zoom')
  380. const zoomInButton = [...document.querySelectorAll('.cad-tools button')]
  381. .find(button => {
  382. const label = `${button.getAttribute('title') || ''} ${button.textContent || ''}`.trim().toLowerCase()
  383. return label.includes('放大') || label.includes('zoom in') || label.endsWith('+')
  384. })
  385. if (!(stage instanceof HTMLElement) || !(zoomText instanceof HTMLElement) || !(zoomInButton instanceof HTMLButtonElement)) {
  386. return {
  387. ok: false,
  388. reason: 'missing CAD stage, zoom label, or zoom button',
  389. hasStage: stage instanceof HTMLElement,
  390. hasZoomText: zoomText instanceof HTMLElement,
  391. hasZoomInButton: zoomInButton instanceof HTMLButtonElement
  392. }
  393. }
  394. const waitFrame = () => new Promise(resolve => requestAnimationFrame(resolve))
  395. const readCanvases = () => [...stage.querySelectorAll('canvas')]
  396. .map(canvas => {
  397. const rect = canvas.getBoundingClientRect()
  398. return {
  399. width: Number(rect.width.toFixed(2)),
  400. height: Number(rect.height.toFixed(2)),
  401. backingWidth: canvas.width,
  402. backingHeight: canvas.height
  403. }
  404. })
  405. .filter(rect => rect.width > 80 && rect.height > 80)
  406. const beforeZoom = zoomText.textContent || ''
  407. const beforeCanvases = readCanvases()
  408. zoomInButton.click()
  409. await waitFrame()
  410. await waitFrame()
  411. const afterZoom = zoomText.textContent || ''
  412. const afterCanvases = readCanvases()
  413. const nativeStageActive = Boolean(document.querySelector('.cad-native-stage.is-active,.cad-viewer-native-host.is-active,.dwfv-root'))
  414. const hasVisibleCanvas = beforeCanvases.length > 0 || afterCanvases.length > 0
  415. const zoomChanged = beforeZoom !== afterZoom
  416. const wrapperZoomRequired = !nativeStageActive
  417. return {
  418. ok: hasVisibleCanvas &&
  419. beforeZoom.trim() !== '' &&
  420. afterZoom.trim() !== '' &&
  421. (!wrapperZoomRequired || zoomChanged),
  422. beforeZoom,
  423. afterZoom,
  424. beforeCanvases,
  425. afterCanvases,
  426. nativeStageActive,
  427. wrapperZoomRequired,
  428. zoomChanged
  429. }
  430. })
  431. if (!result.ok) {
  432. throw new Error(`Sample ${samplePath} failed CAD render/zoom smoke: ${JSON.stringify(result, null, 2)}`)
  433. }
  434. }
  435. const verifyGdsLayoutRenderInteraction = async (page, samplePath) => {
  436. const expectedLabel = samplePath.toLowerCase().endsWith('.gds') ? 'GDSII' : 'OASIS'
  437. const result = await page.evaluate(() => {
  438. const layoutSvg = document.querySelector('.eda-layout-svg')
  439. const tree = document.querySelector('.eda-tree')
  440. if (!(layoutSvg instanceof SVGSVGElement) || !(tree instanceof HTMLElement)) {
  441. return {
  442. ok: false,
  443. reason: 'missing GDSII layout SVG or EDA tree',
  444. hasLayoutSvg: layoutSvg instanceof SVGSVGElement,
  445. hasTree: tree instanceof HTMLElement
  446. }
  447. }
  448. const rect = layoutSvg.getBoundingClientRect()
  449. const geometryCount = layoutSvg.querySelectorAll('polygon,polyline,circle,text').length
  450. const treeRowCount = tree.querySelectorAll('button').length
  451. const ariaLabel = layoutSvg.getAttribute('aria-label') || ''
  452. return {
  453. ok: rect.width > 100 &&
  454. rect.height > 100 &&
  455. geometryCount > 0 &&
  456. treeRowCount > 0 &&
  457. ariaLabel.includes(window.__fileViewerExpectedEdaLayoutLabel || 'GDSII'),
  458. rect: {
  459. width: Number(rect.width.toFixed(2)),
  460. height: Number(rect.height.toFixed(2))
  461. },
  462. geometryCount,
  463. treeRowCount,
  464. ariaLabel
  465. }
  466. })
  467. if (!result.ok) {
  468. throw new Error(`Sample ${samplePath} failed ${expectedLabel} layout render smoke: ${JSON.stringify(result, null, 2)}`)
  469. }
  470. }
  471. const verifyArchiveNestedPreviewInteraction = async (page, samplePath) => {
  472. const nestedChecks = [
  473. {
  474. labels: ['code.ts'],
  475. selector: '.archive-nested-target .code-viewer pre code.hljs'
  476. },
  477. {
  478. labels: ['sample.pdf', 'prince-sample.pdf'],
  479. selector: '.archive-nested-target .pdfViewer .page, .archive-nested-target .pdf-page, .archive-nested-target canvas'
  480. },
  481. {
  482. labels: ['sample.docx', 'calibre-demo.docx'],
  483. selector: '.archive-nested-target .docx-wrapper, .archive-nested-target .docx-document, .archive-nested-target [data-docx-root]'
  484. }
  485. ]
  486. for (const check of nestedChecks) {
  487. const clicked = await page.evaluate(labels => {
  488. const entries = [...document.querySelectorAll('.archive-entry')]
  489. const labelSet = new Set(labels)
  490. const target = entries.find(entry => labelSet.has(entry.querySelector('.entry-copy strong')?.textContent?.trim() || ''))
  491. if (!(target instanceof HTMLButtonElement)) {
  492. return null
  493. }
  494. target.click()
  495. return target.querySelector('.entry-copy strong')?.textContent?.trim() || null
  496. }, check.labels)
  497. if (!clicked) {
  498. throw new Error(`Sample ${samplePath} archive smoke is missing nested entry ${check.labels.join(' or ')}.`)
  499. }
  500. await page.waitForSelector(check.selector, { timeout: sampleTimeout })
  501. await page.waitForFunction(
  502. () => {
  503. const state = document.querySelector('.archive-state:not(.archive-hidden)')
  504. if (!(state instanceof HTMLElement)) {
  505. return true
  506. }
  507. const style = window.getComputedStyle(state)
  508. const rect = state.getBoundingClientRect()
  509. return state.hidden ||
  510. style.display === 'none' ||
  511. style.visibility === 'hidden' ||
  512. Number(style.opacity) === 0 ||
  513. rect.width === 0 ||
  514. rect.height === 0
  515. },
  516. undefined,
  517. { timeout: sampleTimeout }
  518. ).catch(error => {
  519. throw new Error(`Sample ${samplePath} archive nested preview did not finish loading for ${clicked}.\n${error.message}`)
  520. })
  521. if (check.expectedText) {
  522. await page.waitForFunction(
  523. ({ expectedText }) => {
  524. const target = document.querySelector('.archive-nested-target')
  525. return Boolean(target?.textContent?.includes(expectedText))
  526. },
  527. check,
  528. { timeout: sampleTimeout }
  529. )
  530. }
  531. const result = await page.evaluate(({ selector, expectedText }) => {
  532. const nestedTarget = document.querySelector('.archive-nested-target')
  533. const text = (nestedTarget?.textContent || '').replace(/\s+/g, ' ').trim()
  534. const visibleError = document.querySelector('.archive-error:not(.archive-hidden)')
  535. const selected = document.querySelector('.archive-entry.active')
  536. return {
  537. ok: Boolean(document.querySelector(selector)) &&
  538. !visibleError &&
  539. !text.includes('不支持.') &&
  540. (!expectedText || text.includes(expectedText)),
  541. selected: selected?.textContent?.replace(/\s+/g, ' ').trim().slice(0, 160),
  542. text: text.slice(0, 360),
  543. visibleError: visibleError?.textContent?.replace(/\s+/g, ' ').trim()
  544. }
  545. }, check)
  546. if (!result.ok) {
  547. throw new Error(`Sample ${samplePath} failed archive nested preview smoke for ${clicked}: ${JSON.stringify(result, null, 2)}`)
  548. }
  549. }
  550. }
  551. const verifyFormatSpecificInteraction = async (page, samplePath) => {
  552. const normalized = samplePath.toLowerCase()
  553. if (normalized.endsWith('.xmind')) {
  554. await verifyXMindPanInteraction(page, samplePath)
  555. }
  556. if (normalized.endsWith('.typ') || normalized.endsWith('.typst')) {
  557. await verifyTypstRenderInteraction(page, samplePath)
  558. }
  559. if (normalized.endsWith('.drawio') || normalized.endsWith('.dio')) {
  560. await verifyDrawingRenderInteraction(page, samplePath)
  561. }
  562. if (/\.(?:dwg|dxf|dwf|dwfx|xps)$/.test(normalized)) {
  563. await verifyCadRenderInteraction(page, samplePath)
  564. }
  565. if (/\.(?:gds|oas|oasis)$/.test(normalized)) {
  566. await page.evaluate(label => {
  567. window.__fileViewerExpectedEdaLayoutLabel = label
  568. }, normalized.endsWith('.gds') ? 'GDSII' : 'OASIS')
  569. await verifyGdsLayoutRenderInteraction(page, samplePath)
  570. }
  571. if (/archive\.(?:zip|tar\.gz)$/.test(normalized)) {
  572. await verifyArchiveNestedPreviewInteraction(page, samplePath)
  573. }
  574. }
  575. const mimeTypes = new Map([
  576. ['.css', 'text/css; charset=utf-8'],
  577. ['.gif', 'image/gif'],
  578. ['.html', 'text/html; charset=utf-8'],
  579. ['.ico', 'image/x-icon'],
  580. ['.jpg', 'image/jpeg'],
  581. ['.jpeg', 'image/jpeg'],
  582. ['.js', 'text/javascript; charset=utf-8'],
  583. ['.json', 'application/json; charset=utf-8'],
  584. ['.md', 'text/markdown; charset=utf-8'],
  585. ['.mjs', 'text/javascript; charset=utf-8'],
  586. ['.png', 'image/png'],
  587. ['.svg', 'image/svg+xml; charset=utf-8'],
  588. ['.txt', 'text/plain; charset=utf-8'],
  589. ['.wasm', 'application/wasm'],
  590. ['.woff', 'font/woff'],
  591. ['.woff2', 'font/woff2']
  592. ])
  593. const fail = message => {
  594. console.error(`[demo-browser-smoke] ${message}`)
  595. process.exit(1)
  596. }
  597. const importPlaywright = async () => {
  598. try {
  599. return await import('playwright')
  600. } catch (error) {
  601. const candidatePaths = process.env.PATH
  602. ?.split(delimiter)
  603. .filter(pathEntry => pathEntry.endsWith(`${sep}node_modules${sep}.bin`))
  604. .map(binDir => resolve(binDir, '..'))
  605. .filter(pathEntry => existsSync(pathEntry)) || []
  606. for (const candidatePath of candidatePaths) {
  607. try {
  608. const playwrightEntry = require.resolve('playwright', { paths: [candidatePath] })
  609. return await import(pathToFileURL(playwrightEntry).href)
  610. } catch {
  611. // Keep probing package roots injected by npm exec / npx.
  612. }
  613. }
  614. fail([
  615. 'Missing playwright module.',
  616. 'Run with: npm exec --yes --package playwright -- node scripts/verify-demo-browser-smoke.mjs',
  617. `Original error: ${error instanceof Error ? error.message : String(error)}`
  618. ].join('\n'))
  619. }
  620. }
  621. const assertBuildOutput = () => {
  622. for (const file of ['index.html', 'compare.html']) {
  623. const filePath = join(outputDir, file)
  624. if (!existsSync(filePath) || !statSync(filePath).isFile()) {
  625. fail(`Missing demo build output ${filePath}. Run pnpm build-only first.`)
  626. }
  627. }
  628. }
  629. const startStaticServer = async () => {
  630. assertBuildOutput()
  631. const server = createServer((request, response) => {
  632. const requestUrl = new URL(request.url || '/', 'http://127.0.0.1')
  633. const decodedPath = decodeURIComponent(requestUrl.pathname)
  634. const normalizedPath = normalize(decodedPath).replace(/^(\.\.(\/|\\|$))+/, '')
  635. const relativePath = normalizedPath === '/' ? 'index.html' : normalizedPath.replace(/^[/\\]+/, '')
  636. const filePath = resolve(outputDir, relativePath)
  637. if (!filePath.startsWith(outputDir) || relative(outputDir, filePath).startsWith('..')) {
  638. response.writeHead(403)
  639. response.end('Forbidden')
  640. return
  641. }
  642. if (!existsSync(filePath) || !statSync(filePath).isFile()) {
  643. response.writeHead(404)
  644. response.end('Not Found')
  645. return
  646. }
  647. response.writeHead(200, {
  648. 'Content-Type': mimeTypes.get(extname(filePath).toLowerCase()) || 'application/octet-stream'
  649. })
  650. createReadStream(filePath).pipe(response)
  651. })
  652. await new Promise((resolveServer, rejectServer) => {
  653. server.once('error', rejectServer)
  654. server.listen(0, '127.0.0.1', resolveServer)
  655. })
  656. const address = server.address()
  657. if (!address || typeof address === 'string') {
  658. fail('Unable to resolve demo smoke server address.')
  659. }
  660. return {
  661. server,
  662. url: `http://127.0.0.1:${address.port}`
  663. }
  664. }
  665. const launchChromium = async chromium => {
  666. try {
  667. return await chromium.launch({ headless: true })
  668. } catch (error) {
  669. try {
  670. return await chromium.launch({ channel: 'chrome', headless: true })
  671. } catch {
  672. throw error
  673. }
  674. }
  675. }
  676. const createFailureRecorder = (page, baseUrl) => {
  677. const failures = []
  678. const baseOrigin = new URL(baseUrl).origin
  679. page.on('pageerror', error => {
  680. failures.push(`pageerror: ${error.message}`)
  681. })
  682. page.on('console', message => {
  683. if (message.type() === 'error') {
  684. failures.push(`console.error: ${message.text()}`)
  685. }
  686. })
  687. page.on('response', response => {
  688. const responseUrl = new URL(response.url())
  689. if (responseUrl.origin === baseOrigin && response.status() >= 400) {
  690. failures.push(`HTTP ${response.status()}: ${response.url()}`)
  691. }
  692. })
  693. return failures
  694. }
  695. const assertNoBrowserFailures = (failures, context) => {
  696. if (failures.length) {
  697. fail([context, ...failures].join('\n'))
  698. }
  699. }
  700. const waitForBodyText = async (page, requiredTexts, context) => {
  701. await page.waitForFunction(
  702. texts => texts.every(text => document.body.innerText.includes(text)),
  703. requiredTexts,
  704. { timeout }
  705. ).catch(error => {
  706. throw new Error(`${context} did not render expected text: ${requiredTexts.join(', ')}\n${error.message}`)
  707. })
  708. }
  709. const waitForAnyBodyText = async (page, acceptedTexts, context) => {
  710. await page.waitForFunction(
  711. texts => texts.some(text => document.body.innerText.includes(text)),
  712. acceptedTexts,
  713. { timeout }
  714. ).catch(error => {
  715. throw new Error(`${context} did not render any expected text: ${acceptedTexts.join(', ')}\n${error.message}`)
  716. })
  717. }
  718. const collectSampleFiles = directory => {
  719. if (!existsSync(directory) || !statSync(directory).isDirectory()) {
  720. fail(`Missing demo sample directory ${directory}.`)
  721. }
  722. const files = []
  723. const walk = currentDirectory => {
  724. for (const entry of readdirSync(currentDirectory, { withFileTypes: true })) {
  725. if (entry.name.startsWith('.')) {
  726. continue
  727. }
  728. const entryPath = join(currentDirectory, entry.name)
  729. if (entry.isDirectory()) {
  730. walk(entryPath)
  731. } else if (entry.isFile()) {
  732. files.push(relative(directory, entryPath).split(sep).join('/'))
  733. }
  734. }
  735. }
  736. walk(directory)
  737. return files.sort((left, right) => left.localeCompare(right, 'en'))
  738. }
  739. const evaluateSampleSmokeState = async (page, samplePath) => page.evaluate(
  740. ({ config, sample }) => {
  741. const readElement = element => {
  742. const text = (element.textContent || '').replace(/\s+/g, ' ').trim()
  743. return {
  744. selector: element.dataset.smokeSelector || element.className || element.tagName.toLowerCase(),
  745. text: text.slice(0, 180)
  746. }
  747. }
  748. const isVisible = element => {
  749. if (!(element instanceof HTMLElement || element instanceof SVGElement) || element.hidden) {
  750. return false
  751. }
  752. const style = window.getComputedStyle(element)
  753. if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
  754. return false
  755. }
  756. const rect = element.getBoundingClientRect()
  757. return rect.width > 0 && rect.height > 0
  758. }
  759. const collectVisible = selectors => selectors.flatMap(selector =>
  760. [...document.querySelectorAll(selector)].filter(isVisible).map(element => ({
  761. ...readElement(element),
  762. selector
  763. }))
  764. )
  765. const hiddenLeaks = config.hiddenStateSelectors.flatMap(selector =>
  766. [...document.querySelectorAll(selector)]
  767. .filter(element => window.getComputedStyle(element).display !== 'none')
  768. .map(element => ({
  769. ...readElement(element),
  770. selector
  771. }))
  772. )
  773. const visibleLoading = collectVisible(config.loadingSelectors)
  774. const visibleErrors = collectVisible(config.errorSelectors)
  775. const hasContent = Boolean(document.querySelector('.file-viewer .content:not(.hidden)'))
  776. const hasMeaningful = config.meaningfulSelectors.some(selector =>
  777. [...document.querySelectorAll(selector)].some(isVisible)
  778. )
  779. const allowedNotice = config.allowedNotices.some(notice =>
  780. sample.endsWith(notice.sample) &&
  781. visibleErrors.some(error => error.text.includes(notice.includes))
  782. )
  783. return {
  784. allowedNotice,
  785. bodyText: (document.body.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 360),
  786. hasContent,
  787. hasMeaningful,
  788. hiddenLeaks,
  789. ready: hasContent &&
  790. hasMeaningful &&
  791. hiddenLeaks.length === 0 &&
  792. visibleLoading.length === 0 &&
  793. (visibleErrors.length === 0 || allowedNotice),
  794. visibleErrors,
  795. visibleLoading
  796. }
  797. },
  798. {
  799. config: sampleSmokeConfig,
  800. sample: samplePath
  801. }
  802. )
  803. const waitForSampleReady = async (page, samplePath) => {
  804. await page.waitForSelector('.file-viewer .content:not(.hidden)', { timeout: sampleTimeout })
  805. await page.waitForFunction(
  806. ({ config, sample }) => {
  807. const isVisible = element => {
  808. if (!(element instanceof HTMLElement || element instanceof SVGElement) || element.hidden) {
  809. return false
  810. }
  811. const style = window.getComputedStyle(element)
  812. if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
  813. return false
  814. }
  815. const rect = element.getBoundingClientRect()
  816. return rect.width > 0 && rect.height > 0
  817. }
  818. const hiddenLeaks = config.hiddenStateSelectors.some(selector =>
  819. [...document.querySelectorAll(selector)]
  820. .some(element => window.getComputedStyle(element).display !== 'none')
  821. )
  822. const visibleLoading = config.loadingSelectors.some(selector =>
  823. [...document.querySelectorAll(selector)].some(isVisible)
  824. )
  825. const visibleErrors = config.errorSelectors.flatMap(selector =>
  826. [...document.querySelectorAll(selector)].filter(isVisible)
  827. )
  828. const allowedNotice = config.allowedNotices.some(notice =>
  829. sample.endsWith(notice.sample) &&
  830. visibleErrors.some(error => (error.textContent || '').includes(notice.includes))
  831. )
  832. const hasContent = Boolean(document.querySelector('.file-viewer .content:not(.hidden)'))
  833. const hasMeaningful = config.meaningfulSelectors.some(selector =>
  834. [...document.querySelectorAll(selector)].some(isVisible)
  835. )
  836. return hasContent &&
  837. hasMeaningful &&
  838. !hiddenLeaks &&
  839. !visibleLoading &&
  840. (visibleErrors.length === 0 || allowedNotice)
  841. },
  842. {
  843. config: sampleSmokeConfig,
  844. sample: samplePath
  845. },
  846. { timeout: sampleTimeout }
  847. )
  848. }
  849. const verifyMainDemo = async (page, baseUrl, failures) => {
  850. await page.goto(`${baseUrl}/index.html?url=/example/markdown.md&smoke=demo-browser`, {
  851. waitUntil: 'domcontentloaded',
  852. timeout
  853. })
  854. await page.waitForSelector('.file-viewer .content:not(.hidden)', { timeout })
  855. await waitForBodyText(page, [
  856. 'markdown.md',
  857. 'Flyfish File Viewer Markdown Demo',
  858. 'Release Checklist'
  859. ], 'Main demo')
  860. assertNoBrowserFailures(failures, 'Main demo emitted browser errors.')
  861. failures.length = 0
  862. }
  863. const verifyCompareDemo = async (page, baseUrl, failures) => {
  864. const url = new URL(`${baseUrl}/compare.html`)
  865. url.searchParams.set('left', '/example/markdown.md')
  866. url.searchParams.set('right', '/example/text.txt')
  867. url.searchParams.set('smoke', 'demo-browser')
  868. await page.goto(url.href, {
  869. waitUntil: 'domcontentloaded',
  870. timeout
  871. })
  872. await page.waitForSelector('.compare-panel', { timeout })
  873. await page.waitForFunction(
  874. () => document.querySelectorAll('.compare-panel').length === 2 &&
  875. document.querySelectorAll('.compare-viewer .file-viewer').length === 2,
  876. undefined,
  877. { timeout }
  878. )
  879. await waitForAnyBodyText(page, [
  880. '文档比对',
  881. 'Document Compare'
  882. ], 'Compare demo title')
  883. await waitForBodyText(page, [
  884. 'Flyfish File Viewer Markdown Demo',
  885. 'File Viewer text sample'
  886. ], 'Compare demo')
  887. const searchPopoverCount = await page.locator('.compare-search-popover').count()
  888. if (searchPopoverCount !== 0) {
  889. fail('Compare search popover should be hidden before the keyboard shortcut is pressed.')
  890. }
  891. await page.keyboard.press('Control+F')
  892. await page.waitForSelector('.compare-search-popover input[type="search"]', { timeout })
  893. await page.keyboard.press('Escape')
  894. await page.waitForFunction(
  895. () => !document.querySelector('.compare-search-popover'),
  896. undefined,
  897. { timeout }
  898. )
  899. assertNoBrowserFailures(failures, 'Compare demo emitted browser errors.')
  900. failures.length = 0
  901. }
  902. const verifySampleFile = async (page, baseUrl, samplePath, failures) => {
  903. const url = new URL(`${baseUrl}/index.html`)
  904. url.searchParams.set('url', `/example/${samplePath}`)
  905. url.searchParams.set('smoke', `demo-browser-sample-${samplePath.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}`)
  906. await page.goto(url.href, {
  907. waitUntil: 'domcontentloaded',
  908. timeout: sampleTimeout
  909. })
  910. try {
  911. await waitForSampleReady(page, samplePath)
  912. } catch (error) {
  913. const state = await evaluateSampleSmokeState(page, samplePath).catch(() => null)
  914. throw new Error([
  915. `Sample ${samplePath} did not become ready.`,
  916. error instanceof Error ? error.message : String(error),
  917. state ? `State: ${JSON.stringify(state, null, 2)}` : ''
  918. ].filter(Boolean).join('\n'))
  919. }
  920. const state = await evaluateSampleSmokeState(page, samplePath)
  921. if (!state.ready) {
  922. throw new Error(`Sample ${samplePath} has visible renderer issues: ${JSON.stringify(state, null, 2)}`)
  923. }
  924. assertNoBrowserFailures(failures, `Sample ${samplePath} emitted browser errors.`)
  925. failures.length = 0
  926. await verifyFormatSpecificInteraction(page, samplePath)
  927. return state
  928. }
  929. const verifySampleMatrix = async (page, baseUrl, failures) => {
  930. const samples = collectSampleFiles(examplesRoot)
  931. let allowedNotices = 0
  932. for (const sample of samples) {
  933. const state = await verifySampleFile(page, baseUrl, sample, failures)
  934. if (state.allowedNotice) {
  935. allowedNotices += 1
  936. }
  937. }
  938. console.log(
  939. `[demo-browser-smoke] Verified ${samples.length} demo sample files from ${examplesRoot}` +
  940. (allowedNotices ? ` (${allowedNotices} documented notices).` : '.')
  941. )
  942. }
  943. const run = async () => {
  944. const playwrightModule = await importPlaywright()
  945. const { chromium } = playwrightModule.chromium ? playwrightModule : playwrightModule.default
  946. const serverHandle = externalUrl
  947. ? { server: null, url: externalUrl.replace(/\/$/, '') }
  948. : await startStaticServer()
  949. const browser = await launchChromium(chromium)
  950. const page = await browser.newPage({
  951. viewport: {
  952. width: 1440,
  953. height: 960
  954. }
  955. })
  956. const failures = createFailureRecorder(page, serverHandle.url)
  957. try {
  958. await verifyMainDemo(page, serverHandle.url, failures)
  959. await verifyCompareDemo(page, serverHandle.url, failures)
  960. await verifySampleMatrix(page, serverHandle.url, failures)
  961. } finally {
  962. await browser.close()
  963. await new Promise(resolveClose => serverHandle.server?.close(resolveClose) ?? resolveClose())
  964. }
  965. console.log(`[demo-browser-smoke] Verified main demo, compare page, and sample matrix at ${serverHandle.url}`)
  966. }
  967. run().catch(error => {
  968. const hint = String(error?.message || error)
  969. if (hint.includes('Executable doesn') || hint.includes('browserType.launch')) {
  970. console.error(
  971. [
  972. '[demo-browser-smoke] Playwright browser is not installed.',
  973. 'Run: npm exec --yes --package playwright -- playwright install chromium',
  974. `Original error: ${hint}`
  975. ].join('\n')
  976. )
  977. process.exit(1)
  978. }
  979. fail(error instanceof Error ? error.stack || error.message : String(error))
  980. })