import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' const cli = fileURLToPath(new URL('./xuqm-rn.mjs', import.meta.url)) const workspaceRequire = createRequire(import.meta.url) test('embed creates a complete versioned manifest from a host fixture', () => { const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-')) const output = path.join(root, 'generated', 'rn-bundles') try { writeFileSync( path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '7.2.14', dependencies: { '@xuqm/rn-update': '0.5.0-alpha.5' }, }), ) writeFileSync(path.join(root, 'common.ts'), '') writeFileSync(path.join(root, 'app.ts'), '') writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n') writeFileSync( path.join(root, 'xuqm.config.json'), JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', mainModuleName: 'Fixture', pluginVersion: '1.0.0', appVersionRange: '>=7.2.14 <8.0.0', outputDir: './bundle', metroConfig: './metro.config.js', embeddedOutput: { android: './unused' }, modules: [ { id: 'common', type: 'common', entry: './common.ts' }, { id: 'app', type: 'app', entry: './app.ts' }, ], }), ) for (const moduleId of ['common', 'app']) { const directory = path.join(root, 'bundle', 'android', moduleId) mkdirSync(directory, { recursive: true }) writeFileSync(path.join(directory, `${moduleId}.android.bundle`), moduleId) } execFileSync(process.execPath, [cli, 'embed', 'android', '--skip-build', '--output', output], { cwd: root, stdio: 'pipe', }) const manifest = JSON.parse(readFileSync(path.join(output, 'manifest.json'), 'utf8')) assert.equal(manifest.appVersion, '7.2.14') assert.equal(manifest.bundleFormat, 'javascript') assert.equal(manifest.modules.common.moduleVersion, '1.0.0') assert.equal(manifest.modules.app.moduleVersion, '1.0.0') assert.equal(manifest.modules.app.commonVersionRange, '>=1.0.0 <2.0.0') assert.equal(manifest.modules.app.minNativeApiLevel, 1) assert.equal(manifest.modules.app.appVersionRange, '>=7.2.14 <8.0.0') assert.equal(readFileSync(path.join(output, 'app.android.bundle'), 'utf8'), 'app') } finally { rmSync(root, { recursive: true, force: true }) } }) test('Android Hermes hosts compile plugin bundles to bytecode', () => { const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-hermes-')) try { writeFileSync( path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0', dependencies: { '@xuqm/rn-update': '0.5.0-alpha.10' }, }), ) writeFileSync(path.join(root, 'common.ts'), '') mkdirSync(path.join(root, 'android'), { recursive: true }) writeFileSync(path.join(root, 'android', 'gradle.properties'), 'hermesEnabled=true\n') writeFileSync( path.join(root, 'xuqm.config.json'), JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', mainModuleName: 'Fixture', pluginVersion: '1.0.0', appVersionRange: '>=1.0.0 <2.0.0', outputDir: './bundle', embeddedOutput: { android: './generated' }, modules: [{ id: 'common', type: 'common', entry: './common.ts' }], }), ) const reactNativeDirectory = path.join(root, 'node_modules', 'react-native') const compilerDirectory = path.join(root, 'node_modules', 'hermes-compiler') mkdirSync(path.join(reactNativeDirectory, 'scripts'), { recursive: true }) mkdirSync( path.join( compilerDirectory, 'hermesc', process.platform === 'win32' ? 'win64-bin' : process.platform === 'darwin' ? 'osx-bin' : 'linux64-bin', ), { recursive: true }, ) writeFileSync( path.join(reactNativeDirectory, 'package.json'), JSON.stringify({ name: 'react-native', version: '0.86.0', dependencies: { 'hermes-compiler': '1.0.0' }, }), ) writeFileSync( path.join(reactNativeDirectory, 'cli.js'), ` const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); const output = args[args.indexOf('--bundle-output') + 1]; fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, 'javascript'); `, ) writeFileSync( path.join(compilerDirectory, 'package.json'), JSON.stringify({ name: 'hermes-compiler', version: '1.0.0' }), ) const compiler = path.join( compilerDirectory, 'hermesc', process.platform === 'win32' ? 'win64-bin' : process.platform === 'darwin' ? 'osx-bin' : 'linux64-bin', process.platform === 'win32' ? 'hermesc.exe' : 'hermesc', ) // 使用当前 React Native 实际依赖的 Hermes 编译器。文本脚本即使命名为 // hermesc.exe 也不是 Windows 可执行文件,会让 CI 只验证到测试替身而非真实链路。 const workspaceReactNativeManifest = workspaceRequire.resolve('react-native/package.json') const workspaceReactNativeRequire = createRequire(workspaceReactNativeManifest) const workspaceCompilerManifest = workspaceReactNativeRequire.resolve( 'hermes-compiler/package.json', ) const workspaceCompiler = path.join( path.dirname(workspaceCompilerManifest), 'hermesc', process.platform === 'win32' ? 'win64-bin' : process.platform === 'darwin' ? 'osx-bin' : 'linux64-bin', process.platform === 'win32' ? 'hermesc.exe' : 'hermesc', ) copyFileSync(workspaceCompiler, compiler) if (process.platform !== 'win32') chmodSync(compiler, 0o755) execFileSync(process.execPath, [cli, 'embed', 'android'], { cwd: root, stdio: 'pipe', }) const bytecode = readFileSync(path.join(root, 'generated', 'common.android.bundle')) assert.ok(bytecode.length > Buffer.byteLength('javascript')) assert.notEqual(bytecode.toString('utf8'), 'javascript') const manifest = JSON.parse(readFileSync(path.join(root, 'generated', 'manifest.json'), 'utf8')) assert.equal(manifest.bundleFormat, 'hermes-bytecode') } finally { rmSync(root, { recursive: true, force: true }) } }) test('build invokes the host-local React Native CLI with a consistent color environment', () => { const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-local-rn-')) try { writeFileSync( path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0', dependencies: { '@xuqm/rn-update': '0.5.0-alpha.8' }, }), ) writeFileSync(path.join(root, 'common.ts'), '') writeFileSync( path.join(root, 'xuqm.config.json'), JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', mainModuleName: 'Fixture', pluginVersion: '1.0.0', appVersionRange: '>=1.0.0 <2.0.0', outputDir: './bundle', embeddedOutput: { android: './generated' }, modules: [{ id: 'common', type: 'common', entry: './common.ts' }], }), ) const reactNativeDirectory = path.join(root, 'node_modules', 'react-native') mkdirSync(reactNativeDirectory, { recursive: true }) writeFileSync( path.join(reactNativeDirectory, 'cli.js'), ` const fs = require('node:fs'); const path = require('node:path'); const args = process.argv.slice(2); const output = args[args.indexOf('--bundle-output') + 1]; fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, 'bundle'); fs.writeFileSync(path.join(process.cwd(), 'child.json'), JSON.stringify({ args, forceColor: process.env.FORCE_COLOR ?? null, noColor: process.env.NO_COLOR ?? null, })); `, ) execFileSync(process.execPath, [cli, 'build', 'android'], { cwd: root, env: { ...process.env, FORCE_COLOR: '1', NO_COLOR: '1' }, stdio: 'pipe', }) const child = JSON.parse(readFileSync(path.join(root, 'child.json'), 'utf8')) assert.equal(child.args[0], 'bundle') assert.equal(child.forceColor, '0') assert.equal(child.noColor, null) } finally { rmSync(root, { recursive: true, force: true }) } }) test('building one buz first rebuilds startup and common module maps', () => { const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-buz-dependencies-')) try { writeFileSync( path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0', dependencies: { '@xuqm/rn-update': '0.5.0-alpha.10' }, }), ) for (const entry of ['startup.ts', 'common.ts', 'app.ts', 'orders.ts']) { writeFileSync(path.join(root, entry), '') } writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n') writeFileSync( path.join(root, 'xuqm.config.json'), JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', mainModuleName: 'Fixture', pluginVersion: '1.0.0', appVersionRange: '>=1.0.0 <2.0.0', outputDir: './bundle', metroConfig: './metro.config.js', embeddedOutput: { android: './generated' }, // 配置顺序不作为执行顺序;CLI 必须按依赖拓扑重排。 modules: [ { id: 'orders', type: 'buz', entry: './orders.ts' }, { id: 'app', type: 'app', entry: './app.ts' }, { id: 'common', type: 'common', entry: './common.ts' }, { id: 'startup', type: 'startup', entry: './startup.ts' }, ], }), ) const reactNativeDirectory = path.join(root, 'node_modules', 'react-native') mkdirSync(reactNativeDirectory, { recursive: true }) writeFileSync( path.join(reactNativeDirectory, 'cli.js'), ` const fs = require('node:fs'); const path = require('node:path'); const callsFile = path.join(process.cwd(), 'module-calls.json'); const calls = fs.existsSync(callsFile) ? JSON.parse(fs.readFileSync(callsFile, 'utf8')) : []; calls.push({ id: process.env.XUQM_MODULE_ID, index: process.env.XUQM_MODULE_INDEX, reset: process.env.XUQM_RESET_MODULE_CACHE, type: process.env.XUQM_MODULE_TYPE, }); fs.writeFileSync(callsFile, JSON.stringify(calls)); const args = process.argv.slice(2); const output = args[args.indexOf('--bundle-output') + 1]; fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, 'bundle'); `, ) execFileSync(process.execPath, [cli, 'build', 'android', '--module', 'orders'], { cwd: root, stdio: 'pipe', }) assert.deepEqual(JSON.parse(readFileSync(path.join(root, 'module-calls.json'), 'utf8')), [ { id: 'startup', index: '3', reset: 'true', type: 'startup' }, { id: 'common', index: '2', reset: 'false', type: 'common' }, { id: 'orders', index: '0', reset: 'false', type: 'buz' }, ]) } finally { rmSync(root, { recursive: true, force: true }) } }) test('start and run forward arguments through the host-local React Native CLI', () => { const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-run-rn-')) try { const reactNativeDirectory = path.join(root, 'node_modules', 'react-native') mkdirSync(reactNativeDirectory, { recursive: true }) writeFileSync( path.join(reactNativeDirectory, 'cli.js'), ` const fs = require('node:fs'); const callsFile = require('node:path').join(process.cwd(), 'calls.json'); const calls = fs.existsSync(callsFile) ? JSON.parse(fs.readFileSync(callsFile, 'utf8')) : []; calls.push({ args: process.argv.slice(2), forceColor: process.env.FORCE_COLOR ?? null, noColor: process.env.NO_COLOR ?? null, }); fs.writeFileSync(callsFile, JSON.stringify(calls)); `, ) const environment = { ...process.env, FORCE_COLOR: '1', NO_COLOR: '1' } execFileSync(process.execPath, [cli, 'start', '--reset-cache'], { cwd: root, env: environment, stdio: 'pipe', }) execFileSync(process.execPath, [cli, 'run', 'android', '--device', 'emulator-5554'], { cwd: root, env: environment, stdio: 'pipe', }) const calls = JSON.parse(readFileSync(path.join(root, 'calls.json'), 'utf8')) assert.deepEqual(calls[0].args, ['start', '--reset-cache']) assert.deepEqual(calls[1].args, ['run-android', '--device', 'emulator-5554']) assert.equal(calls[0].forceColor, '0') assert.equal(calls[1].forceColor, '0') assert.equal(calls[0].noColor, null) assert.equal(calls[1].noColor, null) } finally { rmSync(root, { recursive: true, force: true }) } })