387a1c26e3
清理已跟踪原始日志与个人本地配置 修复前后端有效门禁、测试和构建问题 补齐生产 Jenkins、SpacetimeDB 本地命令与文档约束 收紧图片编辑器状态、附件与媒体引用测试 排除已下线旧创作入口测试并清理 warning
435 lines
14 KiB
JavaScript
435 lines
14 KiB
JavaScript
import {spawnSync} from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
|
|
const appConfigPath = new URL('../app.json', import.meta.url);
|
|
const packagePath = new URL('../package.json', import.meta.url);
|
|
const configPluginsPackagePath = new URL(
|
|
'../../../node_modules/@expo/config-plugins/package.json',
|
|
import.meta.url,
|
|
);
|
|
const expoPrivacyInfoPluginPath = new URL(
|
|
'../../../node_modules/@expo/config-plugins/build/ios/PrivacyInfo.js',
|
|
import.meta.url,
|
|
);
|
|
const sharedContractPath = new URL(
|
|
'../../../packages/shared/src/contracts/hostBridge.ts',
|
|
import.meta.url,
|
|
);
|
|
const shellRoot = new URL('../', import.meta.url);
|
|
|
|
const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo;
|
|
const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
const configPluginsPackageConfig = JSON.parse(
|
|
fs.readFileSync(configPluginsPackagePath, 'utf8'),
|
|
);
|
|
const expoPrivacyInfoPluginSource = fs.readFileSync(
|
|
expoPrivacyInfoPluginPath,
|
|
'utf8',
|
|
);
|
|
const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8');
|
|
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
|
|
const result = spawnSync(
|
|
npmCommand,
|
|
['exec', 'expo', 'config', '--', '--type', 'public', '--json'],
|
|
{
|
|
cwd: shellRoot,
|
|
encoding: 'utf8',
|
|
},
|
|
);
|
|
|
|
if (result.error) {
|
|
throw new Error(`failed to start Expo config smoke: ${result.error.message}`);
|
|
}
|
|
|
|
if (result.signal) {
|
|
throw new Error(`Expo config smoke was terminated by signal ${result.signal}`);
|
|
}
|
|
|
|
if ((result.status ?? 0) !== 0) {
|
|
process.stdout.write(result.stdout ?? '');
|
|
process.stderr.write(result.stderr ?? '');
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
|
|
function parseExpoConfigOutput(output) {
|
|
const trimmed = output.trim();
|
|
if (trimmed.startsWith('{')) {
|
|
return JSON.parse(trimmed);
|
|
}
|
|
|
|
const jsonStart = output.indexOf('{');
|
|
if (jsonStart === -1) {
|
|
throw new Error('Expo config smoke did not print JSON output');
|
|
}
|
|
|
|
return JSON.parse(output.slice(jsonStart));
|
|
}
|
|
|
|
const expoConfig = parseExpoConfigOutput(result.stdout ?? '');
|
|
|
|
function assertEqual(actual, expected, label) {
|
|
if (actual !== expected) {
|
|
throw new Error(
|
|
`Expo config ${label} drifted: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertIncludes(list, expected, label) {
|
|
if (!Array.isArray(list) || !list.includes(expected)) {
|
|
throw new Error(`Expo config ${label} missing ${expected}`);
|
|
}
|
|
}
|
|
|
|
function assertSameList(actual, expected, label) {
|
|
if (
|
|
!Array.isArray(actual) ||
|
|
actual.length !== expected.length ||
|
|
actual.some((value, index) => value !== expected[index])
|
|
) {
|
|
throw new Error(
|
|
`Expo config ${label} drifted: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertSameSet(actual, expected, label) {
|
|
if (
|
|
!Array.isArray(actual) ||
|
|
actual.length !== expected.length ||
|
|
expected.some((value) => !actual.includes(value))
|
|
) {
|
|
throw new Error(
|
|
`Expo config ${label} drifted: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertPrivacyManifest(privacyManifests, label) {
|
|
if (!privacyManifests) {
|
|
throw new Error(`${label} missing iOS privacy manifest`);
|
|
}
|
|
|
|
assertEqual(
|
|
privacyManifests.NSPrivacyTracking,
|
|
false,
|
|
`${label} privacy tracking flag`,
|
|
);
|
|
assertSameList(
|
|
privacyManifests.NSPrivacyCollectedDataTypes ?? [],
|
|
[],
|
|
`${label} collected data types`,
|
|
);
|
|
assertSameList(
|
|
privacyManifests.NSPrivacyTrackingDomains ?? [],
|
|
[],
|
|
`${label} tracking domains`,
|
|
);
|
|
|
|
const expectedAccessedApiTypes = new Map([
|
|
[
|
|
'NSPrivacyAccessedAPICategoryFileTimestamp',
|
|
['0A2A.1', '3B52.1', 'C617.1'],
|
|
],
|
|
['NSPrivacyAccessedAPICategoryDiskSpace', ['85F4.1', 'E174.1']],
|
|
['NSPrivacyAccessedAPICategorySystemBootTime', ['35F9.1']],
|
|
['NSPrivacyAccessedAPICategoryUserDefaults', ['CA92.1']],
|
|
]);
|
|
const accessedApiTypes = privacyManifests.NSPrivacyAccessedAPITypes ?? [];
|
|
if (accessedApiTypes.length !== expectedAccessedApiTypes.size) {
|
|
throw new Error(`${label} accessed API type count drifted`);
|
|
}
|
|
|
|
for (const [apiType, reasons] of expectedAccessedApiTypes) {
|
|
const entry = accessedApiTypes.find(
|
|
(candidate) => candidate.NSPrivacyAccessedAPIType === apiType,
|
|
);
|
|
if (!entry) {
|
|
throw new Error(`${label} missing ${apiType}`);
|
|
}
|
|
assertSameList(
|
|
entry.NSPrivacyAccessedAPITypeReasons ?? [],
|
|
reasons,
|
|
`${label} reasons for ${apiType}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function findPlugin(name) {
|
|
return expoConfig.plugins?.find((plugin) =>
|
|
Array.isArray(plugin) ? plugin[0] === name : plugin === name,
|
|
);
|
|
}
|
|
|
|
function extractNumberConstExport(source, exportName) {
|
|
const match = source.match(
|
|
new RegExp(`export const ${exportName}\\s*=\\s*(\\d+);`),
|
|
);
|
|
if (!match) {
|
|
throw new Error(`unable to read ${exportName}`);
|
|
}
|
|
|
|
return Number(match[1]);
|
|
}
|
|
|
|
function extractStringConstExport(source, exportName) {
|
|
const match = source.match(
|
|
new RegExp(`export const ${exportName}\\s*=\\s*'([^']+)';`),
|
|
);
|
|
if (!match) {
|
|
throw new Error(`unable to read ${exportName}`);
|
|
}
|
|
|
|
return match[1];
|
|
}
|
|
|
|
const sharedHostBridgeVersion = extractNumberConstExport(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_VERSION',
|
|
);
|
|
const sharedPublicWebOrigin = extractStringConstExport(
|
|
sharedContractSource,
|
|
'HOST_BRIDGE_PUBLIC_WEB_ORIGIN',
|
|
);
|
|
const sharedPublicWebOriginUrl = new URL(sharedPublicWebOrigin);
|
|
if (sharedPublicWebOriginUrl.protocol !== 'https:') {
|
|
throw new Error('shared HostBridge public web origin must use https for mobile app links');
|
|
}
|
|
const sharedPublicWebHost = sharedPublicWebOriginUrl.hostname;
|
|
const sharedPublicWebAssociatedDomain = `applinks:${sharedPublicWebHost}`;
|
|
|
|
assertEqual(expoConfig.name, 'Genarrative', 'name');
|
|
assertEqual(expoConfig.slug, 'genarrative-mobile-shell', 'slug');
|
|
assertEqual(expoConfig.scheme, 'genarrative', 'scheme');
|
|
assertEqual(expoConfig.version, packageConfig.version, 'version');
|
|
assertEqual(expoConfig.version, appConfig.version, 'app version');
|
|
assertEqual(expoConfig.orientation, 'default', 'orientation');
|
|
assertEqual(expoConfig.userInterfaceStyle, 'automatic', 'userInterfaceStyle');
|
|
assertSameList(expoConfig.assetBundlePatterns, ['**/*'], 'asset bundle patterns');
|
|
assertEqual(expoConfig.icon, './assets/icon.png', 'icon');
|
|
assertEqual(expoConfig.splash?.image, './assets/icon.png', 'splash image');
|
|
assertEqual(expoConfig.splash?.resizeMode, 'contain', 'splash resize mode');
|
|
assertEqual(expoConfig.splash?.backgroundColor, '#fffdf9', 'splash background');
|
|
assertEqual(expoConfig.updates?.enabled, false, 'OTA updates enabled flag');
|
|
if (Object.keys(expoConfig.updates ?? {}).some((key) => key !== 'enabled')) {
|
|
throw new Error('Expo config OTA update metadata must stay empty');
|
|
}
|
|
if ('runtimeVersion' in expoConfig) {
|
|
throw new Error('Expo config runtimeVersion must not be set without a real OTA release channel');
|
|
}
|
|
if ('releaseChannel' in expoConfig || 'channel' in expoConfig) {
|
|
throw new Error('Expo config release channel must not be set without a real release process');
|
|
}
|
|
assertEqual(
|
|
expoConfig.extra?.genarrativeHostBridgeVersion,
|
|
sharedHostBridgeVersion,
|
|
'HostBridge version',
|
|
);
|
|
|
|
assertEqual(
|
|
expoConfig.ios?.bundleIdentifier,
|
|
'world.genarrative.mobile',
|
|
'iOS bundle identifier',
|
|
);
|
|
assertEqual(expoConfig.ios?.buildNumber, '1', 'iOS build number');
|
|
assertSameList(
|
|
expoConfig.ios?.associatedDomains,
|
|
[sharedPublicWebAssociatedDomain],
|
|
'iOS associated domains',
|
|
);
|
|
assertEqual(
|
|
expoConfig.ios?.infoPlist?.ITSAppUsesNonExemptEncryption,
|
|
false,
|
|
'iOS encryption export flag',
|
|
);
|
|
assertEqual(
|
|
expoConfig.ios?.infoPlist?.NSAppTransportSecurity?.NSAllowsArbitraryLoads,
|
|
false,
|
|
'iOS ATS arbitrary loads',
|
|
);
|
|
assertEqual(
|
|
expoConfig.ios?.infoPlist?.NSMicrophoneUsageDescription,
|
|
'允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。',
|
|
'iOS microphone permission text',
|
|
);
|
|
assertPrivacyManifest(appConfig.ios?.privacyManifests, 'Expo source config');
|
|
|
|
const configPluginsMajor = Number(
|
|
String(configPluginsPackageConfig.version).split('.')[0],
|
|
);
|
|
if (!Number.isFinite(configPluginsMajor) || configPluginsMajor < 50) {
|
|
throw new Error('Expo config plugins must support built-in iOS privacy manifests');
|
|
}
|
|
for (const snippet of [
|
|
'function withPrivacyInfo(config)',
|
|
'config.ios?.privacyManifests',
|
|
'setPrivacyInfo(projectConfig, privacyManifests)',
|
|
'PrivacyInfo.xcprivacy',
|
|
'mergePrivacyInfo(existing, privacyManifests)',
|
|
]) {
|
|
if (!expoPrivacyInfoPluginSource.includes(snippet)) {
|
|
throw new Error(`Expo config plugins PrivacyInfo support missing ${snippet}`);
|
|
}
|
|
}
|
|
|
|
assertEqual(
|
|
expoConfig.android?.package,
|
|
'world.genarrative.mobile',
|
|
'Android package',
|
|
);
|
|
assertEqual(expoConfig.android?.versionCode, 1, 'Android versionCode');
|
|
assertEqual(
|
|
expoConfig.android?.usesCleartextTraffic,
|
|
false,
|
|
'Android cleartext traffic',
|
|
);
|
|
assertEqual(expoConfig.android?.allowBackup, false, 'Android backup flag');
|
|
assertEqual(
|
|
expoConfig.android?.softwareKeyboardLayoutMode,
|
|
'resize',
|
|
'Android software keyboard layout mode',
|
|
);
|
|
assertSameSet(
|
|
expoConfig.android?.permissions ?? [],
|
|
[
|
|
'android.permission.CAMERA',
|
|
'android.permission.POST_NOTIFICATIONS',
|
|
'android.permission.RECORD_AUDIO',
|
|
],
|
|
'Android explicit permissions',
|
|
);
|
|
if (appConfig.android?.permissions?.includes('android.permission.CAMERA')) {
|
|
throw new Error('Expo source config must not hard-code Android CAMERA permission');
|
|
}
|
|
assertIncludes(
|
|
expoConfig.android?.blockedPermissions,
|
|
'android.permission.MANAGE_EXTERNAL_STORAGE',
|
|
'Android external download blocked permissions',
|
|
);
|
|
assertIncludes(
|
|
expoConfig.android?.blockedPermissions,
|
|
'android.permission.READ_EXTERNAL_STORAGE',
|
|
'Android external download blocked permissions',
|
|
);
|
|
if (expoConfig.android?.blockedPermissions?.includes('android.permission.RECORD_AUDIO')) {
|
|
throw new Error('Expo config Android permissions must not block RECORD_AUDIO needed by same-origin H5 microphone gameplay');
|
|
}
|
|
if (expoConfig.android?.blockedPermissions?.includes('android.permission.POST_NOTIFICATIONS')) {
|
|
throw new Error('Expo config Android permissions must not block POST_NOTIFICATIONS needed by local notification.showLocal delivery');
|
|
}
|
|
assertIncludes(
|
|
expoConfig.android?.blockedPermissions,
|
|
'android.permission.REQUEST_INSTALL_PACKAGES',
|
|
'Android external install blocked permissions',
|
|
);
|
|
assertIncludes(
|
|
expoConfig.android?.blockedPermissions,
|
|
'android.permission.WRITE_EXTERNAL_STORAGE',
|
|
'Android external download blocked permissions',
|
|
);
|
|
for (const permission of [
|
|
'android.permission.RECEIVE_BOOT_COMPLETED',
|
|
'android.permission.SCHEDULE_EXACT_ALARM',
|
|
'android.permission.USE_EXACT_ALARM',
|
|
]) {
|
|
assertIncludes(
|
|
expoConfig.android?.blockedPermissions,
|
|
permission,
|
|
'Android background notification blocked permissions',
|
|
);
|
|
}
|
|
assertEqual(
|
|
expoConfig.android?.adaptiveIcon?.foregroundImage,
|
|
'./assets/icon.png',
|
|
'Android adaptive icon foreground',
|
|
);
|
|
assertEqual(
|
|
expoConfig.android?.adaptiveIcon?.backgroundColor,
|
|
'#fffdf9',
|
|
'Android adaptive icon background',
|
|
);
|
|
|
|
const appLinkFilters = expoConfig.android?.intentFilters ?? [];
|
|
if (appLinkFilters.length !== 1) {
|
|
throw new Error('Expo config Android app link filter must be the only intent filter');
|
|
}
|
|
|
|
const [appLinkFilter] = appLinkFilters;
|
|
assertEqual(appLinkFilter.action, 'VIEW', 'Android app link action');
|
|
assertEqual(appLinkFilter.autoVerify, true, 'Android app link autoVerify');
|
|
assertSameList(
|
|
appLinkFilter.category,
|
|
['BROWSABLE', 'DEFAULT'],
|
|
'Android app link categories',
|
|
);
|
|
if (
|
|
!Array.isArray(appLinkFilter.data) ||
|
|
appLinkFilter.data.length !== 1 ||
|
|
appLinkFilter.data[0]?.scheme !== 'https' ||
|
|
appLinkFilter.data[0]?.host !== sharedPublicWebHost ||
|
|
Object.keys(appLinkFilter.data[0] ?? {}).some(
|
|
(key) => key !== 'scheme' && key !== 'host',
|
|
)
|
|
) {
|
|
throw new Error(
|
|
`Expo config Android app link data must only bind ${sharedPublicWebOrigin}`,
|
|
);
|
|
}
|
|
|
|
const imagePickerPlugin = findPlugin('expo-image-picker');
|
|
if (!Array.isArray(imagePickerPlugin)) {
|
|
throw new Error('Expo config image picker plugin is missing options');
|
|
}
|
|
assertEqual(
|
|
imagePickerPlugin[1]?.photosPermission,
|
|
'允许 Genarrative 读取你选择的图片,用于导入创作素材和参考图。',
|
|
'image picker photo permission text',
|
|
);
|
|
assertEqual(
|
|
imagePickerPlugin[1]?.cameraPermission,
|
|
'允许 Genarrative 使用相机拍摄创作素材和参考图。',
|
|
'image picker camera permission text',
|
|
);
|
|
assertEqual(
|
|
imagePickerPlugin[1]?.microphonePermission,
|
|
'允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。',
|
|
'image picker microphone permission',
|
|
);
|
|
|
|
const cameraPlugin = findPlugin('expo-camera');
|
|
if (!Array.isArray(cameraPlugin)) {
|
|
throw new Error('Expo config camera plugin is missing options');
|
|
}
|
|
assertEqual(
|
|
cameraPlugin[1]?.cameraPermission,
|
|
'允许 Genarrative 使用相机扫描二维码。',
|
|
'camera permission text',
|
|
);
|
|
assertEqual(
|
|
cameraPlugin[1]?.microphonePermission,
|
|
'允许 Genarrative 使用麦克风运行需要实时声音输入的玩法。',
|
|
'camera microphone permission',
|
|
);
|
|
assertEqual(
|
|
cameraPlugin[1]?.recordAudioAndroid,
|
|
true,
|
|
'camera Android record audio flag',
|
|
);
|
|
|
|
const notificationsPlugin = findPlugin('expo-notifications');
|
|
if (!Array.isArray(notificationsPlugin)) {
|
|
throw new Error('Expo config notifications plugin is missing options');
|
|
}
|
|
assertEqual(
|
|
notificationsPlugin[1]?.enableBackgroundRemoteNotifications,
|
|
false,
|
|
'background remote notifications',
|
|
);
|
|
|
|
if (findPlugin('expo-updates')) {
|
|
throw new Error('Expo config must not include expo-updates without a real release channel');
|
|
}
|
|
|
|
console.log('[mobile-shell:expo-config] OK');
|