runtime-payload-smoke.mjs 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. /** Exercise filtered Desktop native and HTML dependencies under its Electron Node runtime. */
  2. import assert from 'node:assert/strict'
  3. import { execFileSync } from 'node:child_process'
  4. import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
  5. import { rm } from 'node:fs/promises'
  6. import { createRequire } from 'node:module'
  7. import { tmpdir } from 'node:os'
  8. import { delimiter, dirname, join, resolve } from 'node:path'
  9. const runtime = process.argv[2]
  10. assert.ok(runtime, 'Pass the filtered resources/dsh directory')
  11. const root = resolve(runtime)
  12. const descriptor = JSON.parse(readFileSync(join(root, 'desktop-runtime.json'), 'utf8'))
  13. assert.equal(process.versions.node, descriptor.release.nodeVersion, 'Run with the Electron Node runtime version')
  14. assert.equal(process.platform, descriptor.platform)
  15. assert.equal(process.arch, descriptor.arch)
  16. const requireRuntime = createRequire(join(root, 'package.json'))
  17. const scratch = mkdtempSync(join(tmpdir(), 'dsh-runtime-payload-'))
  18. /** Run a package script with only the shipped node launcher available on PATH. */
  19. function checkPnpm() {
  20. const resources = dirname(root)
  21. const bin = join(resources, 'runtime', 'bin')
  22. const pnpm = join(resources, 'runtime', 'pnpm', 'bin', 'pnpm.mjs')
  23. writeFileSync(join(scratch, 'package.json'), JSON.stringify({
  24. name: 'desktop-node-script-smoke', private: true, scripts: { check: 'node check.cjs' },
  25. }))
  26. writeFileSync(join(scratch, 'check.cjs'), `
  27. const assert = require('node:assert/strict')
  28. assert.equal(process.execPath, ${JSON.stringify(process.execPath)})
  29. assert.ok(process.versions.electron)
  30. assert.ok(process.execArgv.includes('--expose-internals'))
  31. assert.equal(typeof require('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function')
  32. console.log('desktop-node-script-ok')
  33. `)
  34. const environment = Object.fromEntries(Object.entries(process.env).filter(([name]) => /^(?:systemroot|windir|comspec)$/iu.test(name)))
  35. const systemBin = process.platform === 'win32' ? join(process.env.SystemRoot, 'System32') : '/usr/bin:/bin'
  36. const output = execFileSync(process.execPath, ['--expose-internals', pnpm, 'run', 'check'], {
  37. cwd: scratch, encoding: 'utf8', timeout: 45_000,
  38. env: { ...environment, ELECTRON_RUN_AS_NODE: '1', DSH_DESKTOP_NODE_EXECUTABLE: process.execPath,
  39. PATH: `${bin}${delimiter}${systemBin}`, HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch },
  40. })
  41. assert.match(output, /desktop-node-script-ok/u)
  42. }
  43. /** Spawn only a fixed Node program and await the terminal's drained exit event. */
  44. async function checkPty() {
  45. const pty = requireRuntime('node-pty')
  46. const script = join(scratch, 'pty.cjs')
  47. writeFileSync(script, "process.stdout.write('runtime-payload-pty-ok\\n')\n", { flag: 'wx', mode: 0o600 })
  48. const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => (
  49. /^(?:path|systemroot|windir|comspec|ELECTRON_RUN_AS_NODE)$/iu.test(name)
  50. )))
  51. Object.assign(env, { HOME: scratch, USERPROFILE: scratch, TMP: scratch, TEMP: scratch, TMPDIR: scratch })
  52. env.DSH_DESKTOP_NODE_EXECUTABLE = process.execPath
  53. env.PATH = `${join(dirname(root), 'runtime', 'bin')}${delimiter}${env.PATH ?? env.Path ?? ''}`
  54. // A Windows GUI executable needs a console-owning shell when launched inside ConPTY.
  55. const executable = process.platform === 'win32' ? process.env.ComSpec : process.execPath
  56. const args = process.platform === 'win32' ? ['/d', '/c', 'node', script] : [script]
  57. const terminal = pty.spawn(executable, args, { cwd: scratch, env, cols: 80, rows: 24 })
  58. let output = ''
  59. let exited = false
  60. let timedOut = false
  61. let exitSubscription
  62. const exit = new Promise(resolveExit => {
  63. exitSubscription = terminal.onExit(event => {
  64. exited = true
  65. resolveExit(event)
  66. })
  67. })
  68. const dataSubscription = terminal.onData(data => { output += data })
  69. let timer
  70. try {
  71. const deadline = new Promise((_, reject) => {
  72. timer = setTimeout(() => {
  73. timedOut = true
  74. reject(new Error('Packaged PTY did not exit within 45 seconds'))
  75. }, 45_000)
  76. })
  77. const result = await Promise.race([exit, deadline])
  78. assert.equal(timedOut, false)
  79. assert.ok(result.signal === undefined || result.signal === 0, 'PTY exited without a signal')
  80. assert.equal(result.exitCode, 0)
  81. assert.match(output, /runtime-payload-pty-ok/u)
  82. } finally {
  83. clearTimeout(timer)
  84. dataSubscription.dispose()
  85. try {
  86. // node-pty's Windows natural-exit event closes output but leaves its ConPTY worker owned by kill().
  87. if (!exited || process.platform === 'win32') terminal.kill()
  88. await exit
  89. } finally {
  90. exitSubscription.dispose()
  91. }
  92. }
  93. }
  94. /** Resolve one system function through Koffi's packaged native module. */
  95. function checkKoffi() {
  96. const koffi = requireRuntime('koffi')
  97. const library = koffi.load(process.platform === 'win32' ? 'kernel32.dll' : null)
  98. try {
  99. const getPid = process.platform === 'win32'
  100. ? library.func('uint32_t __stdcall GetCurrentProcessId(void)')
  101. : library.func('int getpid(void)')
  102. assert.equal(getPid(), process.pid)
  103. } finally {
  104. library.unload()
  105. }
  106. }
  107. /** Encode and decode a pixel through the packaged libvips binary. */
  108. async function checkSharp() {
  109. const sharp = requireRuntime('sharp')
  110. const pixel = Buffer.from([17, 103, 231])
  111. const png = await sharp(pixel, { raw: { width: 1, height: 1, channels: 3 } }).png().toBuffer()
  112. const decoded = await sharp(png).raw().toBuffer({ resolveWithObject: true })
  113. assert.equal(decoded.info.width, 1)
  114. assert.equal(decoded.info.height, 1)
  115. assert.equal(decoded.info.channels, 3)
  116. assert.deepEqual(decoded.data, pixel)
  117. }
  118. /** Exercise Domino parsing through the HTML converter and GFM plugin used by web_fetch. */
  119. function checkHtml() {
  120. const Turndown = requireRuntime('turndown')
  121. const { gfm } = requireRuntime('@joplin/turndown-plugin-gfm')
  122. const converter = new Turndown({ bulletListMarker: '-' })
  123. converter.use(gfm)
  124. const markdown = converter.turndown('<p>A &amp; B &copy;</p><ul><li>first</li><li>second</li></ul>'
  125. + '<table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>x</td><td>7</td></tr></tbody></table>')
  126. assert.match(markdown, /A & B ©/u)
  127. assert.match(markdown, /-\s+first\n-\s+second/u)
  128. assert.match(markdown, /\| Name \| Value \|/u)
  129. assert.match(markdown, /\| x\s+\| 7\s+\|/u)
  130. }
  131. try {
  132. checkPnpm()
  133. checkKoffi()
  134. await checkSharp()
  135. checkHtml()
  136. await checkPty()
  137. } finally {
  138. // This private tree contains only fixture files; Windows may release handles after terminal exit.
  139. await rm(scratch, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 })
  140. }
  141. // Natural event-loop drain includes node-pty's worker and console-list helper teardown.
  142. process.once('beforeExit', () => {
  143. console.log(JSON.stringify({ node: process.versions.node, platform: process.platform, arch: process.arch,
  144. koffi: true, sharp: true, html: true, pty: true, pnpm: true }))
  145. })