import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import { test } from 'node:test' import { runInNewContext } from 'node:vm' import ts from 'typescript' import { environmentNames, outputDirectory, readPublicConfig, resolveEnvironment } from '../config/environments.ts' function loadMode(directory, mode, overrides = {}) { // Taro's loader mutates process.env. Keep each mode isolated, just like the CLI. const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('TARO_APP_'))) const result = spawnSync(process.execPath, ['-e', ` const { dotenvParse } = require('@tarojs/helper') process.stdout.write(JSON.stringify(dotenvParse(process.argv[1], ['TARO_APP_'], process.argv[2]))) `, directory, mode], { encoding: 'utf8', env: { ...inherited, NODE_ENV: 'production', ...overrides } }) assert.equal(result.status, 0, result.stderr) return JSON.parse(result.stdout) } function withEnvDirectory(run) { const cache = path.resolve('.cache') mkdirSync(cache, { recursive: true }) const directory = mkdtempSync(path.join(cache, 'env-test-')) try { run(directory) } finally { rmSync(directory, { recursive: true, force: true }) } } test('environments share one output directory per platform and root import points to WeChat output', () => { assert.deepEqual(environmentNames, ['development', 'test', 'production']) assert.equal(outputDirectory('weapp'), 'dist') assert.equal(outputDirectory('h5'), 'dist-h5') const project = JSON.parse(readFileSync('project.config.json', 'utf8')) assert.equal(project.miniprogramRoot, `${outputDirectory('weapp')}/`) assert.equal(resolveEnvironment('production').isProduction, true) assert.equal(resolveEnvironment('development').isProduction, false) assert.ok(environmentNames.every((name) => resolveEnvironment(name).dataSource === 'local')) }) test('explicit modes load different env files even with production build optimization', () => { // Test checked-in defaults without depending on a developer's personal .local files. withEnvDirectory((directory) => { for (const name of environmentNames) copyFileSync(`.env.${name}`, path.join(directory, `.env.${name}`)) const labels = ['开发环境', '测试环境', '正式环境'] for (const [index, name] of environmentNames.entries()) { const values = loadMode(directory, name) const config = readPublicConfig(values) assert.equal(config.label, labels[index]) assert.equal(config.enableDebug, name !== 'production') assert.equal(config.apiBaseUrl, '') assert.equal(values.TARO_APP_ID, 'touristappid') } }) }) test('dotenv priority supports personal overrides, process variables and mode isolation', () => { withEnvDirectory((directory) => { writeFileSync(path.join(directory, '.env'), 'TARO_APP_VALUE=base\nTARO_APP_SHARED=shared\nPRIVATE_VALUE=hidden\n') writeFileSync(path.join(directory, '.env.local'), 'TARO_APP_VALUE=local\nTARO_APP_LOCAL=local\n') writeFileSync(path.join(directory, '.env.test'), 'TARO_APP_VALUE=test\n') writeFileSync(path.join(directory, '.env.test.local'), 'TARO_APP_VALUE=test-local\n') writeFileSync(path.join(directory, '.env.production'), 'TARO_APP_VALUE=production\n') const testValues = loadMode(directory, 'test') assert.equal(testValues.TARO_APP_VALUE, 'test-local') assert.equal(testValues.TARO_APP_SHARED, 'shared') assert.equal(testValues.TARO_APP_LOCAL, 'local') assert.equal(testValues.PRIVATE_VALUE, undefined) assert.equal(loadMode(directory, 'test', { TARO_APP_VALUE: 'process' }).TARO_APP_VALUE, 'process') assert.equal(loadMode(directory, 'production').TARO_APP_VALUE, 'production') assert.equal(loadMode(directory, 'development').TARO_APP_VALUE, 'local') }) }) test('public config validates strings and converts false without JavaScript truthiness', () => { const values = { TARO_APP_ENV_LABEL: '测试环境', TARO_APP_API_BASE_URL: '', TARO_APP_ENABLE_DEBUG: 'false' } assert.equal(readPublicConfig(values).enableDebug, false) assert.equal(readPublicConfig({ ...values, TARO_APP_ENABLE_DEBUG: 'true' }).enableDebug, true) assert.throws(() => readPublicConfig({ ...values, TARO_APP_ENABLE_DEBUG: 'yes' }), /must be true or false/) assert.throws(() => readPublicConfig({ ...values, TARO_APP_ENV_LABEL: '' }), /LABEL is required/) assert.throws(() => readPublicConfig({ ...values, TARO_APP_API_BASE_URL: 'invalid' }), /absolute HTTP/) assert.throws(() => readPublicConfig({ ...values, TARO_APP_API_BASE_URL: 'ftp://example.invalid' }), /must use HTTP/) assert.equal(readPublicConfig({ ...values, TARO_APP_API_BASE_URL: ' https://example.invalid/api ' }).apiBaseUrl, 'https://example.invalid/api') }) test('missing or misspelled environments fail instead of silently building production', () => { for (const input of [undefined, '', 'prod', 'staging', '../production']) { assert.throws(() => resolveEnvironment(input), /Invalid APP_ENV/) } assert.throws(() => outputDirectory('unknown'), /Unsupported platform/) }) test('build launcher rejects invalid combinations before invoking Taro', () => { for (const args of [['build', 'weapp', 'typo'], ['watch', 'weapp', 'all'], ['build', 'unknown', 'production']]) { const result = spawnSync(process.execPath, ['scripts/taro.mjs', ...args], { encoding: 'utf8' }) assert.equal(result.status, 1) assert.match(result.stderr, /Invalid APP_ENV|only available|Usage:/) } }) test('available WeChat build artifacts contain the matching environment and page configuration', { skip: !existsSync(`${outputDirectory('weapp')}/app.json`), }, () => { const directory = outputDirectory('weapp') const metadata = JSON.parse(readFileSync(`${directory}/build-info.json`, 'utf8')) const name = resolveEnvironment(metadata.environment).name const processOverrides = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('TARO_APP_'))) const values = loadMode(process.cwd(), name, processOverrides) const expected = readPublicConfig(values) if (process.env.EXPECTED_APP_ENV) assert.equal(name, process.env.EXPECTED_APP_ENV) assert.equal(metadata.platform, 'weapp') assert.equal(metadata.dataSource, 'local') assert.equal(metadata.label, expected.label) const app = JSON.parse(readFileSync(`${directory}/app.json`, 'utf8')) assert.deepEqual(app.pages, ['pages/home/index', 'pages/profile/index']) for (const item of app.tabBar.list) { assert.ok(existsSync(`${directory}/${item.iconPath}`)) assert.ok(existsSync(`${directory}/${item.selectedIconPath}`)) } const project = JSON.parse(readFileSync(`${directory}/project.config.json`, 'utf8')) assert.equal(project.miniprogramRoot, './') assert.equal(project.projectname, `math.miniapp-${name}`) assert.equal(project.appid, values.TARO_APP_ID) // Locate and evaluate the compiled config itself. Its chunk changes when a // component stops sharing the config, and optimized builds rename variables. let config for (const file of readdirSync(directory, { recursive: true })) { if (!file.endsWith('.js') || file === 'taro.js') continue const source = ts.createSourceFile(file, readFileSync(path.join(directory, file), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) const visit = (node) => { if (ts.isObjectLiteralExpression(node)) { const keys = new Set(node.properties.map((property) => property.name?.text)) if (['name', 'label', 'version', 'isProduction', 'apiBaseUrl', 'enableDebug'].every((key) => keys.has(key))) { config = runInNewContext(`(${node.getText(source)})`, {}) return } } ts.forEachChild(node, visit) } visit(source) if (config) break } assert.ok(config, 'Compiled application config must be present in the WeChat bundle') assert.equal(config.name, name) assert.equal(config.label, expected.label) assert.equal(config.enableDebug, expected.enableDebug) assert.equal(config.apiBaseUrl, expected.apiBaseUrl) })