verify-wrapper-browser-smoke.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. import { createReadStream, existsSync, 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(process.env.WRAPPER_DEMO_OUTPUT_DIR || 'apps/component-demo/dist')
  7. const externalUrl = process.env.WRAPPER_DEMO_URL
  8. const timeout = Number(process.env.WRAPPER_BROWSER_SMOKE_TIMEOUT || 30000)
  9. const mimeTypes = new Map([
  10. ['.css', 'text/css; charset=utf-8'],
  11. ['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
  12. ['.html', 'text/html; charset=utf-8'],
  13. ['.ico', 'image/x-icon'],
  14. ['.js', 'text/javascript; charset=utf-8'],
  15. ['.json', 'application/json; charset=utf-8'],
  16. ['.png', 'image/png'],
  17. ['.wasm', 'application/wasm']
  18. ])
  19. const require = createRequire(import.meta.url)
  20. const fail = message => {
  21. console.error(`[wrapper-browser-smoke] ${message}`)
  22. process.exit(1)
  23. }
  24. const importPlaywright = async () => {
  25. try {
  26. return await import('playwright')
  27. } catch (error) {
  28. const candidatePaths = process.env.PATH
  29. ?.split(delimiter)
  30. .filter(pathEntry => pathEntry.endsWith(`${sep}node_modules${sep}.bin`))
  31. .map(binDir => resolve(binDir, '..'))
  32. .filter(pathEntry => existsSync(pathEntry)) || []
  33. for (const candidatePath of candidatePaths) {
  34. try {
  35. const playwrightEntry = require.resolve('playwright', { paths: [candidatePath] })
  36. return await import(pathToFileURL(playwrightEntry).href)
  37. } catch {
  38. // Continue probing npm exec / npx injected package roots.
  39. }
  40. }
  41. fail([
  42. 'Missing playwright module.',
  43. 'Run with: npm exec --yes --package playwright -- node scripts/verify-wrapper-browser-smoke.mjs',
  44. `Original error: ${error instanceof Error ? error.message : String(error)}`
  45. ].join('\n'))
  46. }
  47. }
  48. const startStaticServer = async () => {
  49. if (!existsSync(join(outputDir, 'manual-iife.html'))) {
  50. fail(`Missing wrapper demo build output: ${join(outputDir, 'manual-iife.html')}`)
  51. }
  52. const server = createServer((request, response) => {
  53. const requestUrl = new URL(request.url || '/', 'http://127.0.0.1')
  54. const decodedPath = decodeURIComponent(requestUrl.pathname)
  55. const normalizedPath = normalize(decodedPath).replace(/^(\.\.(\/|\\|$))+/, '')
  56. const relativePath = normalizedPath === '/' ? 'index.html' : normalizedPath.replace(/^[/\\]+/, '')
  57. const filePath = resolve(outputDir, relativePath)
  58. if (!filePath.startsWith(outputDir) || relative(outputDir, filePath).startsWith('..')) {
  59. response.writeHead(403)
  60. response.end('Forbidden')
  61. return
  62. }
  63. if (!existsSync(filePath) || !statSync(filePath).isFile()) {
  64. response.writeHead(404)
  65. response.end('Not Found')
  66. return
  67. }
  68. response.writeHead(200, {
  69. 'Content-Type': mimeTypes.get(extname(filePath).toLowerCase()) || 'application/octet-stream'
  70. })
  71. createReadStream(filePath).pipe(response)
  72. })
  73. await new Promise((resolveServer, rejectServer) => {
  74. server.once('error', rejectServer)
  75. server.listen(0, '127.0.0.1', resolveServer)
  76. })
  77. const address = server.address()
  78. if (!address || typeof address === 'string') {
  79. fail('Unable to resolve wrapper demo smoke server address.')
  80. }
  81. return {
  82. server,
  83. url: `http://127.0.0.1:${address.port}`
  84. }
  85. }
  86. const launchChromium = async chromium => {
  87. try {
  88. return await chromium.launch({ headless: true })
  89. } catch (error) {
  90. try {
  91. return await chromium.launch({ channel: 'chrome', headless: true })
  92. } catch {
  93. throw error
  94. }
  95. }
  96. }
  97. const assertNoBrowserFailures = (failures, context) => {
  98. if (failures.length) {
  99. fail([context, ...failures].join('\n'))
  100. }
  101. }
  102. const assertNativeViewerMounted = async (page, selector, label) => {
  103. await page.waitForSelector(selector, { timeout })
  104. let mounted
  105. try {
  106. mounted = await page.waitForFunction(
  107. targetSelector => {
  108. const host = document.querySelector(targetSelector)
  109. const viewerSelector = [
  110. '.file-viewer',
  111. '.file-render',
  112. '.docx-fit-viewer',
  113. '[class*="file-viewer"]',
  114. '[class*="file-render"]',
  115. '[data-viewer-zoom-provider]',
  116. '[data-rendered-document]'
  117. ].join(',')
  118. return Boolean(
  119. host &&
  120. (host.matches(viewerSelector) || host.querySelector(viewerSelector))
  121. )
  122. },
  123. selector,
  124. { timeout }
  125. )
  126. } catch (error) {
  127. const snapshot = await page.evaluate(targetSelector => {
  128. const host = document.querySelector(targetSelector)
  129. return {
  130. selector: targetSelector,
  131. title: document.title,
  132. bodyStatus: document.body.getAttribute('data-viewer-status') || '',
  133. hostExists: Boolean(host),
  134. hostClass: host?.getAttribute('class') || '',
  135. hostHtml: host?.outerHTML.slice(0, 800) || '',
  136. bodyText: document.body.innerText.slice(0, 800)
  137. }
  138. }, selector)
  139. fail([
  140. `${label} did not render a native viewer before timeout.`,
  141. JSON.stringify(snapshot, null, 2),
  142. error instanceof Error ? error.message : String(error)
  143. ].join('\n'))
  144. }
  145. if (!mounted) {
  146. fail(`${label} did not render a native viewer.`)
  147. }
  148. }
  149. const verifyWrapperIndexDemo = async (page, baseUrl, failures) => {
  150. await page.goto(`${baseUrl}/index.html`, {
  151. waitUntil: 'domcontentloaded',
  152. timeout
  153. })
  154. await page.waitForFunction(
  155. () => document.body.innerText.includes('React') && document.body.innerText.includes('Web'),
  156. { timeout }
  157. )
  158. await assertNativeViewerMounted(page, '[data-testid="react-viewer"]', 'React wrapper')
  159. await assertNativeViewerMounted(page, '[data-testid="web-viewer-host"]', 'Pure Web wrapper')
  160. assertNoBrowserFailures(failures, 'Wrapper index demo emitted browser errors.')
  161. failures.length = 0
  162. }
  163. const verifySingleWrapperDemo = async (page, baseUrl, config, failures) => {
  164. await page.goto(`${baseUrl}/${config.path}`, {
  165. waitUntil: 'domcontentloaded',
  166. timeout
  167. })
  168. await page.waitForFunction(
  169. wrapper => (
  170. document.body.getAttribute('data-component') ||
  171. document.body.getAttribute('data-wrapper')
  172. ) === wrapper,
  173. config.wrapper,
  174. { timeout }
  175. )
  176. await assertNativeViewerMounted(page, config.hostSelector, config.label)
  177. assertNoBrowserFailures(failures, `${config.label} demo emitted browser errors.`)
  178. failures.length = 0
  179. }
  180. const verifyIifeDemo = async (page, baseUrl, failures) => {
  181. await page.goto(`${baseUrl}/manual-iife.html`, {
  182. waitUntil: 'domcontentloaded',
  183. timeout
  184. })
  185. await page.waitForFunction(
  186. () => {
  187. const api = window.FlyfishFileViewerWeb
  188. return Boolean(
  189. api &&
  190. typeof api.mountViewer === 'function' &&
  191. typeof api.createViewerControllerHandle === 'function' &&
  192. typeof api.defineFileViewerElement === 'function' &&
  193. customElements.get('flyfish-file-viewer')
  194. )
  195. },
  196. { timeout }
  197. )
  198. await assertNativeViewerMounted(page, '#viewer', 'IIFE script tag demo')
  199. const status = await page.getAttribute('body', 'data-viewer-status')
  200. if (status === 'missing-global') {
  201. fail('IIFE global API was not available on window.')
  202. }
  203. assertNoBrowserFailures(failures, 'IIFE script tag demo emitted browser errors.')
  204. failures.length = 0
  205. }
  206. const verifyManualJsDemo = async (page, baseUrl, failures) => {
  207. await page.goto(`${baseUrl}/manual-js.html`, {
  208. waitUntil: 'domcontentloaded',
  209. timeout
  210. })
  211. await assertNativeViewerMounted(page, '#viewer', 'Manual JS demo')
  212. try {
  213. await page.waitForFunction(
  214. () => {
  215. const status = document.querySelector('#status')?.textContent?.trim()
  216. return Boolean(status && status !== 'loading')
  217. },
  218. { timeout }
  219. )
  220. } catch (error) {
  221. const snapshot = await page.evaluate(() => ({
  222. statusText: document.querySelector('#status')?.textContent?.trim() || '',
  223. bodyStatus: document.body.getAttribute('data-viewer-status') || '',
  224. hasGlobalApi: Boolean(window.FlyfishFileViewerWeb?.mountViewer),
  225. hostHtml: document.querySelector('#viewer')?.innerHTML.slice(0, 240) || ''
  226. }))
  227. fail([
  228. 'Manual JS native demo did not report a mounted status.',
  229. JSON.stringify(snapshot, null, 2),
  230. ...failures,
  231. error instanceof Error ? error.message : String(error)
  232. ].join('\n'))
  233. }
  234. assertNoBrowserFailures(failures, 'Manual JS native demo emitted browser errors.')
  235. failures.length = 0
  236. }
  237. const run = async () => {
  238. const playwrightModule = await importPlaywright()
  239. const { chromium } = playwrightModule.chromium ? playwrightModule : playwrightModule.default
  240. const serverHandle = externalUrl
  241. ? { server: null, url: externalUrl.replace(/\/$/, '') }
  242. : await startStaticServer()
  243. const browser = await launchChromium(chromium)
  244. const page = await browser.newPage()
  245. const failures = []
  246. page.on('pageerror', error => {
  247. failures.push(`pageerror: ${error.message}`)
  248. })
  249. page.on('console', message => {
  250. if (message.type() === 'error') {
  251. failures.push(`console.error: ${message.text()}`)
  252. }
  253. })
  254. page.on('response', response => {
  255. const responseUrl = new URL(response.url())
  256. if (responseUrl.origin === serverHandle.url && response.status() >= 400) {
  257. failures.push(`HTTP ${response.status()}: ${response.url()}`)
  258. }
  259. })
  260. try {
  261. await verifyWrapperIndexDemo(page, serverHandle.url, failures)
  262. await verifySingleWrapperDemo(page, serverHandle.url, {
  263. wrapper: 'jquery',
  264. hostSelector: '[data-testid="jquery-viewer-host"]',
  265. label: 'jQuery wrapper',
  266. path: 'jquery.html'
  267. }, failures)
  268. await verifySingleWrapperDemo(page, serverHandle.url, {
  269. wrapper: 'custom-element',
  270. hostSelector: '[data-testid="custom-element-viewer-host"]',
  271. label: 'Vanilla JS custom element wrapper',
  272. path: 'custom-element.html'
  273. }, failures)
  274. await verifySingleWrapperDemo(page, serverHandle.url, {
  275. wrapper: 'vue3',
  276. hostSelector: '[data-testid="vue3-viewer-host"]',
  277. label: 'Vue 3 wrapper',
  278. path: 'vue3.html'
  279. }, failures)
  280. await verifySingleWrapperDemo(page, serverHandle.url, {
  281. wrapper: 'svelte-action',
  282. hostSelector: '[data-testid="svelte-viewer-host"]',
  283. label: 'Svelte action wrapper',
  284. path: 'svelte-action.html'
  285. }, failures)
  286. await verifyManualJsDemo(page, serverHandle.url, failures)
  287. await verifyIifeDemo(page, serverHandle.url, failures)
  288. } finally {
  289. await browser.close()
  290. await new Promise(resolveClose => serverHandle.server?.close(resolveClose) ?? resolveClose())
  291. }
  292. console.log(`[wrapper-browser-smoke] Verified React, Web, Vanilla JS custom element, Vue 3, jQuery, Svelte action, manual JS, and script tag IIFE native demos at ${serverHandle.url}`)
  293. }
  294. run().catch(error => {
  295. const hint = String(error?.message || error)
  296. if (hint.includes('Executable doesn') || hint.includes('browserType.launch')) {
  297. console.error(
  298. [
  299. '[wrapper-browser-smoke] Playwright browser is not installed.',
  300. 'Run: npm exec --yes --package playwright -- playwright install chromium',
  301. `Original error: ${hint}`
  302. ].join('\n')
  303. )
  304. process.exit(1)
  305. }
  306. fail(error instanceof Error ? error.stack || error.message : String(error))
  307. })