import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' import { createHash } from 'node:crypto' import { chmodSync, copyFileSync, existsSync, 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' import { strFromU8, unzipSync } from 'fflate' const cli = fileURLToPath(new URL('./xuqm-rn.mjs', import.meta.url)) const workspaceRequire = createRequire(import.meta.url) test('Gradle models and forwards the exact embedded release identity', () => { const gradle = readFileSync( fileURLToPath(new URL('../android/xuqm-bundles.gradle', import.meta.url)), 'utf8', ) assert.match(gradle, /@Input\s+abstract Property getBuildId\(\)/) assert.match(gradle, /@Input\s+abstract Property getBugCollectMode\(\)/) assert.match(gradle, /environment\("XUQM_BUILD_ID", buildId\.get\(\)\)/) assert.match(gradle, /environment\("XUQM_BUGCOLLECT_MODE", bugCollectMode\.get\(\)\)/) }) 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.modules.json'), JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', packageName: 'com.example.fixture', iosBundleId: 'com.example.fixture.ios', 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) if (moduleId === 'app') { mkdirSync(path.join(directory, 'drawable-mdpi'), { recursive: true }) writeFileSync(path.join(directory, 'drawable-mdpi', 'fixture_icon.png'), 'png') } } 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, 2) assert.equal(manifest.modules.app.appVersionRange, '>=7.2.14 <8.0.0') assert.equal(manifest.modules.app.sha256, createHash('sha256').update('app').digest('hex')) assert.equal(manifest.modules.app.byteLength, 3) assert.deepEqual(manifest.modules.app.assetFiles, ['drawable-mdpi/fixture_icon.png']) assert.equal(readFileSync(path.join(output, 'app.android.bundle'), 'utf8'), 'app') assert.equal( readFileSync(path.join(output, 'drawable-mdpi', 'fixture_icon.png'), 'utf8'), 'png', ) } 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.modules.json'), JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', packageName: 'com.example.fixture', 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]; const sourceMap = args[args.indexOf('--sourcemap-output') + 1]; fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, 'javascript'); fs.writeFileSync(sourceMap, JSON.stringify({ version: 3, sources: ['/fixture/common.ts'], names: [], mappings: '', })); `, ) writeFileSync( path.join(reactNativeDirectory, 'scripts', 'compose-source-maps.js'), ` const fs = require('node:fs'); const args = process.argv.slice(2); const output = args[args.indexOf('-o') + 1]; fs.copyFileSync(args[0], output); `, ) 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', env: { ...process.env, XUQM_BUILD_ID: 'release-fixture-1', XUQM_BUGCOLLECT_MODE: 'disabled', }, }) 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') assert.equal(manifest.buildId, 'release-fixture-1') assert.equal(manifest.bugCollectMode, 'disabled') const packaged = unzipSync( new Uint8Array( readFileSync(path.join(root, 'bundle/android/common/common.android.xuqm.zip')), ), ) const packagedManifest = JSON.parse(strFromU8(packaged['rn-manifest.json'])) assert.equal(packagedManifest.buildId, manifest.buildId) assert.equal(packagedManifest.bugCollectMode, manifest.bugCollectMode) } finally { rmSync(root, { recursive: true, force: true }) } }) test('embed rejects assets owned by more than one module', () => { const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-asset-ownership-')) 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 moduleId of ['common', 'app']) { writeFileSync(path.join(root, `${moduleId}.ts`), '') const directory = path.join(root, 'bundle', 'android', moduleId, 'drawable-mdpi') mkdirSync(directory, { recursive: true }) writeFileSync( path.join(root, 'bundle', 'android', moduleId, `${moduleId}.android.bundle`), moduleId, ) writeFileSync(path.join(directory, 'shared.png'), 'png') } writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n') writeFileSync( path.join(root, 'xuqm.modules.json'), JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', packageName: 'com.example.fixture', mainModuleName: 'Fixture', pluginVersion: '1.0.0', appVersionRange: '>=1.0.0 <2.0.0', outputDir: './bundle', metroConfig: './metro.config.js', embeddedOutput: { android: './generated' }, modules: [ { id: 'common', type: 'common', entry: './common.ts' }, { id: 'app', type: 'app', entry: './app.ts' }, ], }), ) assert.throws( () => execFileSync(process.execPath, [cli, 'embed', 'android', '--skip-build'], { cwd: root, stdio: 'pipe', }), /Command failed/, ) } 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.modules.json'), JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', packageName: 'com.example.fixture', iosBundleId: 'com.example.fixture.ios', 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]; const sourceMap = args[args.indexOf('--sourcemap-output') + 1]; fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, 'bundle'); fs.writeFileSync(sourceMap, JSON.stringify({ version: 3, sources: ['/fixture/common.ts'], names: [], mappings: '', })); 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) const androidPackage = unzipSync( new Uint8Array( readFileSync(path.join(root, 'bundle/android/common/common.android.xuqm.zip')), ), ) const androidManifest = JSON.parse(strFromU8(androidPackage['rn-manifest.json'])) assert.equal(androidManifest.packageName, 'com.example.fixture') execFileSync(process.execPath, [cli, 'build', 'ios'], { cwd: root, stdio: 'pipe', }) const iosPackage = unzipSync( new Uint8Array(readFileSync(path.join(root, 'bundle/ios/common/common.ios.xuqm.zip'))), ) const iosManifest = JSON.parse(strFromU8(iosPackage['rn-manifest.json'])) assert.equal(iosManifest.platform, 'ios') assert.equal(iosManifest.packageName, 'com.example.fixture.ios') } 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), '') } mkdirSync(path.join(root, 'shared'), { recursive: true }) writeFileSync(path.join(root, 'shared', 'shared.ts'), 'export const shared = true\n') writeFileSync(path.join(root, 'shared', 'ignored.test.ts'), 'throw new Error()\n') writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n') writeFileSync( path.join(root, 'xuqm.modules.json'), JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', packageName: 'com.example.fixture', 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', ownershipRoots: ['./shared'], }, { 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')) : []; const args = process.argv.slice(2); calls.push({ entry: args[args.indexOf('--entry-file') + 1], 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 output = args[args.indexOf('--bundle-output') + 1]; const sourceMap = args[args.indexOf('--sourcemap-output') + 1]; fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, 'bundle'); fs.writeFileSync(sourceMap, JSON.stringify({ version: 3, sources: ['/fixture/' + process.env.XUQM_MODULE_ID + '.ts'], names: [], mappings: '', })); `, ) execFileSync(process.execPath, [cli, 'build', 'android', '--module', 'orders'], { cwd: root, stdio: 'pipe', }) const calls = JSON.parse(readFileSync(path.join(root, 'module-calls.json'), 'utf8')) assert.equal(calls[0].entry, './startup.ts') assert.match(calls[1].entry, /\.xuqm-cache\/ownership\/common\.android\.js$/) assert.equal(calls[2].entry, './orders.ts') assert.deepEqual( calls.map(({ entry: _entry, ...call }) => call), [ { id: 'startup', index: '3', reset: 'true', type: 'startup' }, { id: 'common', index: '2', reset: 'false', type: 'common' }, { id: 'orders', index: '0', reset: 'false', type: 'buz' }, ], ) const ownershipEntry = readFileSync(calls[1].entry, 'utf8') assert.match(ownershipEntry, /shared\/shared\.ts/) assert.doesNotMatch(ownershipEntry, /ignored\.test\.ts/) assert.equal( existsSync(path.join(root, 'bundle/android/startup/startup.android.xuqm.zip')), false, ) const commonPackage = unzipSync( new Uint8Array( readFileSync(path.join(root, 'bundle/android/common/common.android.xuqm.zip')), ), ) const packageManifest = JSON.parse(strFromU8(commonPackage['rn-manifest.json'])) assert.equal(packageManifest.schemaVersion, 1) assert.equal(packageManifest.packageName, 'com.example.fixture') assert.equal(packageManifest.moduleId, 'common') assert.equal(packageManifest.bundleFormat, 'javascript') assert.deepEqual(packageManifest.assets, []) assert.equal(strFromU8(commonPackage['common.android.bundle']), 'bundle') } 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 }) } })