XuqmGroup-RNSDK/packages/update/scripts/xuqm-rn.test.mjs

522 行
19 KiB
JavaScript

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<String> getBuildId\(\)/)
assert.match(gradle, /@Input\s+abstract Property<String> 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',
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',
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',
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',
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)
} 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',
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.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 })
}
})