environment.test.mjs 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. import assert from 'node:assert/strict'
  2. import { spawnSync } from 'node:child_process'
  3. import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  4. import path from 'node:path'
  5. import { test } from 'node:test'
  6. import { runInNewContext } from 'node:vm'
  7. import ts from 'typescript'
  8. import { environmentNames, outputDirectory, readPublicConfig, resolveEnvironment } from '../config/environments.ts'
  9. function loadMode(directory, mode, overrides = {}) {
  10. // Taro's loader mutates process.env. Keep each mode isolated, just like the CLI.
  11. const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('TARO_APP_')))
  12. const result = spawnSync(process.execPath, ['-e', `
  13. const { dotenvParse } = require('@tarojs/helper')
  14. process.stdout.write(JSON.stringify(dotenvParse(process.argv[1], ['TARO_APP_'], process.argv[2])))
  15. `, directory, mode], { encoding: 'utf8', env: { ...inherited, NODE_ENV: 'production', ...overrides } })
  16. assert.equal(result.status, 0, result.stderr)
  17. return JSON.parse(result.stdout)
  18. }
  19. function withEnvDirectory(run) {
  20. const cache = path.resolve('.cache')
  21. mkdirSync(cache, { recursive: true })
  22. const directory = mkdtempSync(path.join(cache, 'env-test-'))
  23. try { run(directory) } finally { rmSync(directory, { recursive: true, force: true }) }
  24. }
  25. test('environments share one output directory per platform and root import points to WeChat output', () => {
  26. assert.deepEqual(environmentNames, ['development', 'test', 'production'])
  27. assert.equal(outputDirectory('weapp'), 'dist')
  28. assert.equal(outputDirectory('h5'), 'dist-h5')
  29. const project = JSON.parse(readFileSync('project.config.json', 'utf8'))
  30. assert.equal(project.miniprogramRoot, `${outputDirectory('weapp')}/`)
  31. assert.equal(resolveEnvironment('production').isProduction, true)
  32. assert.equal(resolveEnvironment('development').isProduction, false)
  33. assert.ok(environmentNames.every((name) => resolveEnvironment(name).dataSource === 'local'))
  34. })
  35. test('explicit modes load different env files even with production build optimization', () => {
  36. // Test checked-in defaults without depending on a developer's personal .local files.
  37. withEnvDirectory((directory) => {
  38. for (const name of environmentNames) copyFileSync(`.env.${name}`, path.join(directory, `.env.${name}`))
  39. const labels = ['开发环境', '测试环境', '正式环境']
  40. for (const [index, name] of environmentNames.entries()) {
  41. const values = loadMode(directory, name)
  42. const config = readPublicConfig(values)
  43. assert.equal(config.label, labels[index])
  44. assert.equal(config.enableDebug, name !== 'production')
  45. assert.equal(config.apiBaseUrl, '')
  46. assert.equal(values.TARO_APP_ID, 'touristappid')
  47. }
  48. })
  49. })
  50. test('dotenv priority supports personal overrides, process variables and mode isolation', () => {
  51. withEnvDirectory((directory) => {
  52. writeFileSync(path.join(directory, '.env'), 'TARO_APP_VALUE=base\nTARO_APP_SHARED=shared\nPRIVATE_VALUE=hidden\n')
  53. writeFileSync(path.join(directory, '.env.local'), 'TARO_APP_VALUE=local\nTARO_APP_LOCAL=local\n')
  54. writeFileSync(path.join(directory, '.env.test'), 'TARO_APP_VALUE=test\n')
  55. writeFileSync(path.join(directory, '.env.test.local'), 'TARO_APP_VALUE=test-local\n')
  56. writeFileSync(path.join(directory, '.env.production'), 'TARO_APP_VALUE=production\n')
  57. const testValues = loadMode(directory, 'test')
  58. assert.equal(testValues.TARO_APP_VALUE, 'test-local')
  59. assert.equal(testValues.TARO_APP_SHARED, 'shared')
  60. assert.equal(testValues.TARO_APP_LOCAL, 'local')
  61. assert.equal(testValues.PRIVATE_VALUE, undefined)
  62. assert.equal(loadMode(directory, 'test', { TARO_APP_VALUE: 'process' }).TARO_APP_VALUE, 'process')
  63. assert.equal(loadMode(directory, 'production').TARO_APP_VALUE, 'production')
  64. assert.equal(loadMode(directory, 'development').TARO_APP_VALUE, 'local')
  65. })
  66. })
  67. test('public config validates strings and converts false without JavaScript truthiness', () => {
  68. const values = { TARO_APP_ENV_LABEL: '测试环境', TARO_APP_API_BASE_URL: '', TARO_APP_ENABLE_DEBUG: 'false' }
  69. assert.equal(readPublicConfig(values).enableDebug, false)
  70. assert.equal(readPublicConfig({ ...values, TARO_APP_ENABLE_DEBUG: 'true' }).enableDebug, true)
  71. assert.throws(() => readPublicConfig({ ...values, TARO_APP_ENABLE_DEBUG: 'yes' }), /must be true or false/)
  72. assert.throws(() => readPublicConfig({ ...values, TARO_APP_ENV_LABEL: '' }), /LABEL is required/)
  73. assert.throws(() => readPublicConfig({ ...values, TARO_APP_API_BASE_URL: 'invalid' }), /absolute HTTP/)
  74. assert.throws(() => readPublicConfig({ ...values, TARO_APP_API_BASE_URL: 'ftp://example.invalid' }), /must use HTTP/)
  75. assert.equal(readPublicConfig({ ...values, TARO_APP_API_BASE_URL: ' https://example.invalid/api ' }).apiBaseUrl, 'https://example.invalid/api')
  76. })
  77. test('missing or misspelled environments fail instead of silently building production', () => {
  78. for (const input of [undefined, '', 'prod', 'staging', '../production']) {
  79. assert.throws(() => resolveEnvironment(input), /Invalid APP_ENV/)
  80. }
  81. assert.throws(() => outputDirectory('unknown'), /Unsupported platform/)
  82. })
  83. test('build launcher rejects invalid combinations before invoking Taro', () => {
  84. for (const args of [['build', 'weapp', 'typo'], ['watch', 'weapp', 'all'], ['build', 'unknown', 'production']]) {
  85. const result = spawnSync(process.execPath, ['scripts/taro.mjs', ...args], { encoding: 'utf8' })
  86. assert.equal(result.status, 1)
  87. assert.match(result.stderr, /Invalid APP_ENV|only available|Usage:/)
  88. }
  89. })
  90. test('available WeChat build artifacts contain the matching environment and page configuration', {
  91. skip: !existsSync(`${outputDirectory('weapp')}/app.json`),
  92. }, () => {
  93. const directory = outputDirectory('weapp')
  94. const metadata = JSON.parse(readFileSync(`${directory}/build-info.json`, 'utf8'))
  95. const name = resolveEnvironment(metadata.environment).name
  96. const processOverrides = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('TARO_APP_')))
  97. const values = loadMode(process.cwd(), name, processOverrides)
  98. const expected = readPublicConfig(values)
  99. if (process.env.EXPECTED_APP_ENV) assert.equal(name, process.env.EXPECTED_APP_ENV)
  100. assert.equal(metadata.platform, 'weapp')
  101. assert.equal(metadata.dataSource, 'local')
  102. assert.equal(metadata.label, expected.label)
  103. const app = JSON.parse(readFileSync(`${directory}/app.json`, 'utf8'))
  104. assert.deepEqual(app.pages, ['pages/home/index', 'pages/profile/index', 'pages/upload/index', 'pages/messages/index', 'pages/explanations/index', 'pages/library/index'])
  105. for (const page of app.pages) {
  106. for (const extension of ['js', 'json', 'wxml', 'wxss']) {
  107. assert.ok(existsSync(`${directory}/${page}.${extension}`), `Missing WeChat page artifact: ${page}.${extension}`)
  108. }
  109. }
  110. for (const item of app.tabBar.list) {
  111. assert.ok(existsSync(`${directory}/${item.iconPath}`))
  112. assert.ok(existsSync(`${directory}/${item.selectedIconPath}`))
  113. }
  114. const project = JSON.parse(readFileSync(`${directory}/project.config.json`, 'utf8'))
  115. assert.equal(project.miniprogramRoot, './')
  116. assert.equal(project.projectname, `math.miniapp-${name}`)
  117. assert.equal(project.appid, values.TARO_APP_ID)
  118. // Locate and evaluate the compiled config itself. Its chunk changes when a
  119. // component stops sharing the config, and optimized builds rename variables.
  120. let config
  121. for (const file of readdirSync(directory, { recursive: true })) {
  122. if (!file.endsWith('.js') || file === 'taro.js') continue
  123. const source = ts.createSourceFile(file, readFileSync(path.join(directory, file), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
  124. const visit = (node) => {
  125. if (ts.isObjectLiteralExpression(node)) {
  126. const keys = new Set(node.properties.map((property) => property.name?.text))
  127. if (['name', 'label', 'version', 'isProduction', 'apiBaseUrl', 'enableDebug'].every((key) => keys.has(key))) {
  128. config = runInNewContext(`(${node.getText(source)})`, {})
  129. return
  130. }
  131. }
  132. ts.forEachChild(node, visit)
  133. }
  134. visit(source)
  135. if (config) break
  136. }
  137. assert.ok(config, 'Compiled application config must be present in the WeChat bundle')
  138. assert.equal(config.name, name)
  139. assert.equal(config.label, expected.label)
  140. assert.equal(config.enableDebug, expected.enableDebug)
  141. assert.equal(config.apiBaseUrl, expected.apiBaseUrl)
  142. })
  143. test('WeChat demo media are emitted as package files, never inline video data URLs', {
  144. skip: !existsSync(`${outputDirectory('weapp')}/app.json`),
  145. }, () => {
  146. const directory = outputDirectory('weapp')
  147. for (const name of ['sample-paper.png', 'sample-answer.png', 'explanation-0.mp4', 'explanation-1.mp4', 'explanation-2.mp4', 'explanation-3.mp4']) {
  148. assert.ok(existsSync(`${directory}/assets/${name}`), `Missing package asset: ${name}`)
  149. }
  150. const page = readFileSync(`${directory}/pages/explanations/index.js`, 'utf8')
  151. assert.ok(!page.includes('data:video/'), 'WeChat videos must use a local package path')
  152. assert.match(page, /assets\/explanation-0\.mp4/)
  153. })
  154. test('WeChat text actions remove the native button pseudo-element border with a class selector', {
  155. skip: !existsSync(`${outputDirectory('weapp')}/app.json`),
  156. }, () => {
  157. const directory = outputDirectory('weapp')
  158. const styles = readdirSync(directory, { recursive: true })
  159. .filter((file) => file.endsWith('.wxss'))
  160. .map((file) => readFileSync(path.join(directory, file), 'utf8'))
  161. .join('\n')
  162. // Native components need a class selector; ancestor + button selectors do
  163. // not reliably override WeChat's built-in button::after border.
  164. // Minified builds normalize ::after to its equivalent :after spelling.
  165. assert.match(styles, /\.text-button::?after\s*\{[^}]*border:\s*0(?:[;\s}])/)
  166. })