diff --git a/src/api/builder/builder.ts b/src/api/builder/builder.ts index c93180398..150a8eae3 100644 --- a/src/api/builder/builder.ts +++ b/src/api/builder/builder.ts @@ -1,4 +1,4 @@ -import { build, createBuildTemplate as createCoreBuildTemplate, executeBuildStageTask, queryDefaultBuildConfigByPlatform } from '../../core/builder'; +import { build, createBuildTemplate as createCoreBuildTemplate, executeBuildStageTask, queryDefaultBuildConfigByPlatform, verifyBuildOptions } from '../../core/builder'; import { HttpStatusCode, COMMON_STATUS, CommonResultType } from '../base/schema-base'; import { BuildExitCode } from '../../core/builder/@types/protected'; import { description, param, result, title, tool } from '../decorator/decorator'; @@ -17,6 +17,13 @@ export class BuilderApi { data: null, }; try { + const checkFail = await verifyBuildOptions(platform, options as any); + if (checkFail) { + ret.code = COMMON_STATUS.FAIL; + ret.data = checkFail as unknown as TBuildResultData; + ret.reason = checkFail.reason; + return ret; + } const res = await build(platform, options); ret.data = res as TBuildResultData; if (res.code !== BuildExitCode.BUILD_SUCCESS) { diff --git a/src/commands/build.ts b/src/commands/build.ts index 85c7c7e2a..fd52dff59 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -97,6 +97,9 @@ export class BuildCommand extends BaseCommand { if (result.code === BuildExitCode.BUILD_SUCCESS) { console.log(chalk.green('✓ Build completed successfully! Build Dest: ' + result.dest)); } else { + if (result.reason) { + console.error(chalk.red(result.reason)); + } console.error(chalk.red('✗ Build failed!')); } process.exit(result.code); diff --git a/src/core/builder/index.ts b/src/core/builder/index.ts index 30398dce4..e5113acce 100644 --- a/src/core/builder/index.ts +++ b/src/core/builder/index.ts @@ -2,7 +2,7 @@ import { readJSONSync } from 'fs-extra'; import i18n from '../base/i18n'; import { BuildExitCode, BuildStageProgressCallback, IBuildCommandOption, IBuildResultData, IBuildStageOptions, IBuildTaskOption, IBundleBuildOptions, IPreviewSettingsResult, Platform } from './@types/private'; import { pluginManager } from './manager/plugin'; -import { cloneConfigValue, formatMSTime } from './share/utils'; +import { cloneConfigValue, defaultsDeep, formatMSTime } from './share/utils'; import { newConsole } from '../base/console'; import { basename, extname, isAbsolute, join } from 'path'; import assetManager from '../assets/manager/asset'; @@ -13,6 +13,8 @@ import utils from '../base/utils'; import { middlewareService } from '../../server/middleware/core'; import BuildMiddleware from './build.middleware'; import { BuildGlobalInfo } from './share/global'; +import { Engine } from '../engine'; +import { getDefaultScenes, getDefaultStartScene } from './share/common-options-validator'; export { clearCache } from './cache'; export type { BuildCacheScope, ClearCacheResult } from './cache'; @@ -29,6 +31,91 @@ export async function init(platform?: string[]) { } } +/** + * 使用平台注册的 verifyRules 对构建参数做严格校验。 + * 仅供 api 层(CLI/MCP)在调用 build() 前显式调用;Pink 走自己的 UI 校验,不会经过这里。 + * skipCheck 为 true 时跳过。 + * + * 语义:先把平台 default 合进 options 再校验——用户漏传的字段会用平台默认值兜底通过; + * 只有用户明确传了非法值、或字段本身默认值就不合法(例如 iOS/Mac 的 packageName 默认空但 required) + * 时才会失败。任何 rule 失败(除 level='warn' 显式声明的)都硬阻塞,返回 { code: PARAM_ERROR, reason }。 + */ +export async function verifyBuildOptions( + platform: string, + options?: IBuildCommandOption, +): Promise<{ code: Exclude; reason: string } | null> { + if (options?.skipCheck) { + return null; + } + try { + // 与 createBuildTask 里 checkOptions 一致:先用平台 default 兜住漏传字段 + const defaultOptions = await pluginManager.getOptionsByPlatform(platform); + const merged = defaultsDeep(JSON.parse(JSON.stringify(options || {})), defaultOptions); + merged.platform = platform; + // taskName 是 common option 里 default='' + verifyRules=['required'], + // 老流程靠 createBuildTask 里 `options.taskName = options.taskName || platform` 兜底, + // 而入口校验早于 build(),这里必须复刻同样的归一化,否则 required 规则永远拦。 + merged.taskName = merged.taskName || platform; + // scenes / startScene 的合法默认值是从 asset-db 现算的(getDefaultScenes / getDefaultStartScene), + // 不是 commonOptionConfigs 里的静态 '' / []。老流程里 checkOptions 靠 fixedValue 自动回落到这两个函数, + // 新入口校验对 error 硬阻塞(不消费 fixedValue),因此必须在校验前先按同样逻辑把项目默认场景填进来。 + try { + if (!merged.startScene) { + const defaultStartScene = getDefaultStartScene(); + if (defaultStartScene) { + merged.startScene = defaultStartScene; + } + } + if (!Array.isArray(merged.scenes) || merged.scenes.length === 0) { + const defaultScenes = getDefaultScenes(); + if (defaultScenes.length) { + merged.scenes = defaultScenes; + } + } + } catch { + // asset-db 未初始化时忽略(单测/引擎未加载),交给下游的 required-like 规则处理 + } + // renderPipeline 是项目设置而非平台选项,构建阶段才由 checkProjectSetting 填进 options。 + // 入口校验早于构建,这里按编辑器的做法直接读工程配置补上,否则依赖它的规则(apiLevelRenderPipeline)恒不触发。 + if (!merged.renderPipeline) { + try { + const renderPipeline = Engine.getConfig().renderPipeline; + if (renderPipeline) { + merged.renderPipeline = renderPipeline; + } + } catch { + // Engine 未初始化(如单测/未加载引擎)时忽略,不影响其余规则 + } + } + const results = await pluginManager.checkBuildOptions(platform, merged as any); + const errors: string[] = []; + const warnings: string[] = []; + for (const key of Object.keys(results)) { + const r = results[key]; + if (r.valid) { + continue; + } + const line = ` - ${key}: ${r.message || 'invalid'}`; + if (r.level === 'warn') { + warnings.push(line); + } else { + errors.push(line); + } + } + if (warnings.length) { + console.warn(`Build option warnings:\n${warnings.join('\n')}`); + } + if (errors.length) { + const reason = `Build option errors:\n${errors.join('\n')}`; + console.error(reason); + return { code: BuildExitCode.PARAM_ERROR, reason }; + } + } catch (e) { + console.warn('Failed to run build option checks:', e); + } + return null; +} + function getBuilderLogRoot() { const projectTempDir = builderConfig.projectTempDir; return basename(projectTempDir) === 'builder' ? projectTempDir : join(projectTempDir, 'builder'); diff --git a/src/core/builder/platforms/android/src/config.ts b/src/core/builder/platforms/android/src/config.ts index 3a6af9b54..81f7bbae0 100644 --- a/src/core/builder/platforms/android/src/config.ts +++ b/src/core/builder/platforms/android/src/config.ts @@ -3,6 +3,12 @@ import { IPlatformBuildPluginConfig } from '../../../@types/protected'; import { commonOptions, baseNativeCommonOptions } from '../../native-common'; +// huawei-agc 直接 spread 本配置来复用整套规则,此时选项落在 packages['huawei-agc'] 而不是 packages.android, +// 所以联动条件必须按 options.platform 取包名(checkBuildOptions 会写入该字段),否则 huawei-agc 上所有 gate 都读到 undefined。 +function pkgOptions(options: any): any { + return options?.packages?.[options?.platform] || options?.packages?.android || {}; +} + const config: IPlatformBuildPluginConfig = { ...commonOptions, displayName: 'Android', @@ -28,6 +34,134 @@ const config: IPlatformBuildPluginConfig = { }, message: 'Invalid package name specified', }, + // 当 useDebugKeystore=false 时,keystore 相关字段(keystorePath/Password/Alias/AliasPassword) + // 需校验:keystoreRequired 卡 undefined/null(真的没设),keystoreNotEmpty 卡 ''(设了但空)。 + // 迁移自 editor 的 getVerifyMap,两条规则区分语义。 + // gate 用 !== false:checkBuildOption 逐字段校验时不会合并平台默认值(createVerifyOptions 只 clone 调用方传入的 options), + // 字段缺失时必须按声明的默认值 true 处理,否则会误报"keystore 不能为空"。 + keystoreRequired: { + func: (value: unknown, options: any) => { + if (pkgOptions(options).useDebugKeystore !== false) { + return true; + } + return value !== null && value !== undefined; + }, + message: 'Required when useDebugKeystore is false (field must be set for the custom release keystore; set useDebugKeystore to true to use the built-in debug keystore)', + }, + keystoreNotEmpty: { + func: (value: unknown, options: any) => { + if (pkgOptions(options).useDebugKeystore !== false) { + return true; + } + return value !== ''; + }, + message: 'Cannot be empty when useDebugKeystore is false (a value is needed for the custom release keystore; set useDebugKeystore to true to use the built-in debug keystore)', + }, + // apiLevel 的分支校验(editor 侧 checkAndroidAPILevels 逻辑拆解,每个分支独立 rule 保留精确 message) + // checkAndroidAPILevels 现在只剩 huawei-agc/hooks.ts 在调,用作 silent auto-fix;android 自己已完全走这套 rule + apiLevelIsNumber: { + func: (value: unknown) => typeof value === 'number' ? !isNaN(value) : !isNaN(Number(value)), + message: 'API Level must be a number', + }, + apiLevelInstant: { + func: (value: unknown, options: any) => { + if (!pkgOptions(options).androidInstant) { + return true; + } + return Number(value) >= 23; + }, + message: 'When Android Instant App is enabled, the minimum API Level required is 23.', + }, + apiLevelTbb: { + func: (value: unknown, options: any) => { + // CLI 的 JobSystem 由 baseNativeCommonOptions 声明在各平台自己的 options 里,不存在 packages.native(那是编辑器的形态) + if (pkgOptions(options).JobSystem !== 'tbb') { + return true; + } + return Number(value) >= 21; + }, + message: 'When TBB is enabled, the minimum API Level required is 21.', + }, + apiLevelRenderPipeline: { + func: (value: unknown, options: any) => { + // 与 editor 一致的延迟渲染管线 uuid + if (options?.renderPipeline !== '5d45ba66-829a-46d3-948e-2ed3fa7ee421') { + return true; + } + return Number(value) >= 21; + }, + message: 'When Deferred Render Pipeline is enabled, the minimum API Level required is 21.', + }, + apiLevelMin19: { + func: (value: unknown) => Number(value) >= 19, + message: 'The minimum API Level required is 19.', + }, + // 迁移自 editor verificationFunc 剩余 case:appABIs / renderBackEnd / orientation + // 空数组 [] 和空对象 {} 都不会被 validator-manager 的空值 short-circuit 拦截,规则会正常 fire。 + appABIs: { + func: (value: unknown) => Array.isArray(value) && value.length > 0, + message: 'appABIs must include at least one ABI', + }, + renderBackEnd: { + func: (value: unknown) => { + if (!value || typeof value !== 'object') { + return false; + } + // 只认可 android/google-play 支持的 backend key;至少 1 个开启才算合法。 + // 这一层比 editor 严格(editor 侧只查"任一 truthy",会漏掉 {metal:true} 这种平台不匹配的写法)。 + const supported = ['vulkan', 'gles3', 'gles2']; + const v = value as Record; + return supported.some((k) => !!v[k]); + }, + message: 'renderBackEnd must have at least one supported backend enabled (vulkan / gles3 / gles2 for android)', + }, + orientation: { + func: (value: unknown) => { + if (!value || typeof value !== 'object') { + return false; + } + return Object.values(value as Record).some((v) => !!v); + }, + message: 'orientation must have at least one direction enabled', + }, + // maxAspectRatio 只在 resizeableActivity=false 时校验(editor 逻辑一致),Required 结尾使空值也能走进 func + maxAspectRatioRequired: { + func: (value: unknown, options: any) => { + if (pkgOptions(options).resizeableActivity !== false) { + return true; + } + if (typeof value !== 'string' || value.trim() === '') { + return false; + } + const trimmed = value.trim(); + const LOWER_BOUND = 1.33; // https://developer.android.com/guide/practices/screens-distribution#MaxAspectRatio + const optMatch = trimmed.match(/^(\d+(?:\.\d+)?)(?:\s*\(\s*\d+\s*:\s*\d+\s*\))?$/); + if (optMatch) { + return Number.parseFloat(optMatch[1]) >= LOWER_BOUND; + } + const fracMatch = trimmed.match(/^(\d+)\s*:\s*(\d+)$/); + if (fracMatch) { + const w = Number.parseInt(fracMatch[1], 10); + const h = Number.parseInt(fracMatch[2], 10); + return w > 0 && h > 0 && w / h >= LOWER_BOUND; + } + return false; + }, + message: 'maxAspectRatio must be a number, "w:h", or "n.n (w:h)" with value >= 1.33 (required when resizeableActivity is false)', + }, + // remoteUrl 只在 androidInstant=true 且非空时要求 http 前缀(editor 里空值也是允许的) + remoteUrlHttp: { + func: (value: unknown, options: any) => { + if (!pkgOptions(options).androidInstant) { + return true; + } + if (value === '' || value === null || value === undefined) { + return true; + } + return typeof value === 'string' && value.startsWith('http'); + }, + message: 'remoteUrl should start with http when androidInstant is enabled', + }, }, options: { ...baseNativeCommonOptions, @@ -62,6 +196,7 @@ const config: IPlatformBuildPluginConfig = { gles3: true, gles2: true, }, + verifyRules: ['renderBackEnd'], }, packageName: { label: 'i18n:android.options.package_name', @@ -73,7 +208,8 @@ const config: IPlatformBuildPluginConfig = { label: 'i18n:android.options.apiLevel', type: 'number', default: 35, - verifyRules: ['required'], + // 顺序敏感(validator 短路):required → 是数字 → 分支特定下限 → 通用下限 19 + verifyRules: ['required', 'apiLevelIsNumber', 'apiLevelInstant', 'apiLevelTbb', 'apiLevelRenderPipeline', 'apiLevelMin19'], }, appABIs: { label: 'i18n:android.options.appABIs', @@ -81,6 +217,7 @@ const config: IPlatformBuildPluginConfig = { items: { type: 'string' }, default: ['arm64-v8a'], hidden: true, + verifyRules: ['appABIs'], }, resizeableActivity: { label: 'i18n:android.options.resizeable_activity', @@ -91,6 +228,7 @@ const config: IPlatformBuildPluginConfig = { label: 'i18n:android.options.max_aspect_ratio', type: 'string', default: '2.4', + verifyRules: ['maxAspectRatioRequired'], }, orientation: { label: 'i18n:android.options.screen_orientation', @@ -117,6 +255,7 @@ const config: IPlatformBuildPluginConfig = { landscapeRight: true, landscapeLeft: true, }, + verifyRules: ['orientation'], }, useDebugKeystore: { label: 'i18n:android.KEYSTORE.use_debug_keystore', @@ -127,21 +266,25 @@ const config: IPlatformBuildPluginConfig = { label: 'i18n:android.KEYSTORE.keystore_path', type: 'string', default: '', + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, keystorePassword: { label: 'i18n:android.KEYSTORE.keystore_password', type: 'string', default: '', + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, keystoreAlias: { label: 'i18n:android.KEYSTORE.keystore_alias', type: 'string', default: '', + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, keystoreAliasPassword: { label: 'i18n:android.KEYSTORE.keystore_alias_password', type: 'string', default: '', + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, appBundle: { label: 'i18n:android.options.app_bundle', @@ -165,6 +308,7 @@ const config: IPlatformBuildPluginConfig = { type: 'string', hidden: true, default: '', + verifyRules: ['remoteUrlHttp'], }, isSoFileCompressed: { label: 'i18n:android.options.compress_so_files', diff --git a/src/core/builder/platforms/android/src/hooks.ts b/src/core/builder/platforms/android/src/hooks.ts index 5fedb5d7e..73cd03edc 100644 --- a/src/core/builder/platforms/android/src/hooks.ts +++ b/src/core/builder/platforms/android/src/hooks.ts @@ -3,7 +3,7 @@ import { join } from 'path'; import { IBuildResult, IAndroidInternalBuildOptions } from './type'; import { BuilderCache, IBuilder } from '../../../@types/protected'; -import { generateAndroidOptions, checkAndroidAPILevels } from './utils'; +import { generateAndroidOptions } from './utils'; import * as nativeCommonHook from '../../native-common/hooks'; import { GlobalPaths } from '../../../../../global'; @@ -26,11 +26,8 @@ export async function onAfterInit(this: IBuilder, options: IAndroidInternalBuild const renderBackEnd = android.renderBackEnd; - const res = await checkAndroidAPILevels(android.apiLevel, options); - if (!res.valid) { - console.error(res.message); - typeof res.fixedValue === 'number' && (android.apiLevel = res.fixedValue); - } + // apiLevel 校验已由 api 层的 verifyBuildOptions 走 verifyRuleMap 处理,非法直接阻断构建; + // 这里不再 silent auto-fix。 if (android.useDebugKeystore) { android.keystorePath = join(GlobalPaths.staticDir, 'tools/keystore/debug.keystore'); diff --git a/src/core/builder/platforms/google-play/src/config.ts b/src/core/builder/platforms/google-play/src/config.ts index 6085f1d9f..4188c458d 100644 --- a/src/core/builder/platforms/google-play/src/config.ts +++ b/src/core/builder/platforms/google-play/src/config.ts @@ -3,6 +3,11 @@ import { IPlatformBuildPluginConfig } from '../../../@types/protected'; import { commonOptions } from '../../native-common'; +// 与 android 一致:联动条件按 options.platform 取包名,不要硬编码 packages['google-play'] +function pkgOptions(options: any): any { + return options?.packages?.[options?.platform] || options?.packages?.['google-play'] || {}; +} + const config: IPlatformBuildPluginConfig = { ...commonOptions, displayName: 'i18n:google-play.title', @@ -28,6 +33,122 @@ const config: IPlatformBuildPluginConfig = { }, message: 'Invalid package name specified', }, + // 当 useDebugKeystore=false 时,keystore 相关字段(keystorePath/Password/Alias/AliasPassword) + // 需校验:keystoreRequired 卡 undefined/null(真的没设),keystoreNotEmpty 卡 ''(设了但空)。 + // 迁移自 editor 的 getVerifyMap,两条规则区分语义。 + // gate 用 !== false:checkBuildOption 逐字段校验时不会合并平台默认值(createVerifyOptions 只 clone 调用方传入的 options), + // 字段缺失时必须按声明的默认值 true 处理,否则会误报"keystore 不能为空"。 + keystoreRequired: { + func: (value: unknown, options: any) => { + if (pkgOptions(options).useDebugKeystore !== false) { + return true; + } + return value !== null && value !== undefined; + }, + message: 'Required when useDebugKeystore is false (field must be set for the custom release keystore; set useDebugKeystore to true to use the built-in debug keystore)', + }, + keystoreNotEmpty: { + func: (value: unknown, options: any) => { + if (pkgOptions(options).useDebugKeystore !== false) { + return true; + } + return value !== ''; + }, + message: 'Cannot be empty when useDebugKeystore is false (a value is needed for the custom release keystore; set useDebugKeystore to true to use the built-in debug keystore)', + }, + // apiLevel 校验:Google Play 政策最低 API 24(apiLevelMin24 兜底政策), + // 同时保留引擎子系统的技术约束(tbb/延迟渲染管线 >= 21)。数值上被 24 覆盖, + // 联动规则不会真正 fire,但保留可以表达 constraint 来源(Google Play 政策 vs 引擎特性)。 + apiLevelIsNumber: { + func: (value: unknown) => typeof value === 'number' ? !isNaN(value) : !isNaN(Number(value)), + message: 'API Level must be a number', + }, + apiLevelMin24: { + func: (value: unknown) => Number(value) >= 24, + message: 'Google Play requires the minimum API Level to be 24.', + }, + apiLevelTbb: { + func: (value: unknown, options: any) => { + if (pkgOptions(options).JobSystem !== 'tbb') { + return true; + } + return Number(value) >= 21; + }, + message: 'When TBB is enabled, the minimum API Level required is 21.', + }, + apiLevelRenderPipeline: { + func: (value: unknown, options: any) => { + if (options?.renderPipeline !== '5d45ba66-829a-46d3-948e-2ed3fa7ee421') { + return true; + } + return Number(value) >= 21; + }, + message: 'When Deferred Render Pipeline is enabled, the minimum API Level required is 21.', + }, + // 迁移自 editor verificationFunc 剩余 case:appABIs / renderBackEnd / orientation + appABIs: { + func: (value: unknown) => Array.isArray(value) && value.length > 0, + message: 'appABIs must include at least one ABI', + }, + renderBackEnd: { + func: (value: unknown) => { + if (!value || typeof value !== 'object') { + return false; + } + // 只认可 google-play 支持的 backend key;至少 1 个开启才算合法。 + const supported = ['vulkan', 'gles3', 'gles2']; + const v = value as Record; + return supported.some((k) => !!v[k]); + }, + message: 'renderBackEnd must have at least one supported backend enabled (vulkan / gles3 / gles2 for google-play)', + }, + orientation: { + func: (value: unknown) => { + if (!value || typeof value !== 'object') { + return false; + } + return Object.values(value as Record).some((v) => !!v); + }, + message: 'orientation must have at least one direction enabled', + }, + // maxAspectRatio 只在 resizeableActivity=false 时校验,Required 结尾使空值也能走进 func + maxAspectRatioRequired: { + func: (value: unknown, options: any) => { + if (pkgOptions(options).resizeableActivity !== false) { + return true; + } + if (typeof value !== 'string' || value.trim() === '') { + return false; + } + const trimmed = value.trim(); + const LOWER_BOUND = 1.33; + const optMatch = trimmed.match(/^(\d+(?:\.\d+)?)(?:\s*\(\s*\d+\s*:\s*\d+\s*\))?$/); + if (optMatch) { + return Number.parseFloat(optMatch[1]) >= LOWER_BOUND; + } + const fracMatch = trimmed.match(/^(\d+)\s*:\s*(\d+)$/); + if (fracMatch) { + const w = Number.parseInt(fracMatch[1], 10); + const h = Number.parseInt(fracMatch[2], 10); + return w > 0 && h > 0 && w / h >= LOWER_BOUND; + } + return false; + }, + message: 'maxAspectRatio must be a number, "w:h", or "n.n (w:h)" with value >= 1.33 (required when resizeableActivity is false)', + }, + // remoteUrl 只在 androidInstant=true 且非空时要求 http 前缀 + remoteUrlHttp: { + func: (value: unknown, options: any) => { + if (!pkgOptions(options).androidInstant) { + return true; + } + if (value === '' || value === null || value === undefined) { + return true; + } + return typeof value === 'string' && value.startsWith('http'); + }, + message: 'remoteUrl should start with http when androidInstant is enabled', + }, }, options: { swappy: { @@ -68,6 +189,7 @@ const config: IPlatformBuildPluginConfig = { gles2: true, }, hidden: true, + verifyRules: ['renderBackEnd'], }, packageName: { label: 'i18n:google-play.options.package_name', @@ -84,7 +206,8 @@ const config: IPlatformBuildPluginConfig = { label: 'i18n:google-play.options.apiLevel', type: 'number', default: 35, - verifyRules: ['required'], + // Google Play 最低 API 24;min24 兜政策提示先命中,tbb/renderPipeline 保留技术约束语义(被 24 覆盖,实际不会 fire) + verifyRules: ['required', 'apiLevelIsNumber', 'apiLevelMin24', 'apiLevelTbb', 'apiLevelRenderPipeline'], }, appABIs: { label: 'i18n:google-play.options.appABIs', @@ -92,6 +215,7 @@ const config: IPlatformBuildPluginConfig = { items: { type: 'string' }, default: ['arm64-v8a'], hidden: true, + verifyRules: ['appABIs'], }, useDebugKeystore: { label: 'i18n:google-play.KEYSTORE.use_debug_keystore', @@ -102,21 +226,25 @@ const config: IPlatformBuildPluginConfig = { label: 'i18n:google-play.KEYSTORE.keystore_path', type: 'string', default: '', + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, keystorePassword: { label: 'i18n:google-play.KEYSTORE.keystore_password', type: 'string', default: '', + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, keystoreAlias: { label: 'i18n:google-play.KEYSTORE.keystore_alias', type: 'string', default: '', + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, keystoreAliasPassword: { label: 'i18n:google-play.KEYSTORE.keystore_alias_password', type: 'string', default: '', + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, resizeableActivity: { label: 'i18n:google-play.options.resizeable_activity', @@ -128,6 +256,7 @@ const config: IPlatformBuildPluginConfig = { label: 'i18n:google-play.options.max_aspect_ratio', type: 'string', default: '2.4', + verifyRules: ['maxAspectRatioRequired'], }, orientation: { label: 'i18n:google-play.options.screen_orientation', @@ -161,6 +290,7 @@ const config: IPlatformBuildPluginConfig = { landscapeLeft: true, }, hidden: true, + verifyRules: ['orientation'], }, appBundle: { label: 'i18n:google-play.options.app_bundle', @@ -190,6 +320,7 @@ const config: IPlatformBuildPluginConfig = { type: 'string', default: '', hidden: true, + verifyRules: ['remoteUrlHttp'], }, playGames: { type: 'boolean', diff --git a/src/core/builder/platforms/google-play/src/hooks.ts b/src/core/builder/platforms/google-play/src/hooks.ts index d95bb9f70..b7734c862 100644 --- a/src/core/builder/platforms/google-play/src/hooks.ts +++ b/src/core/builder/platforms/google-play/src/hooks.ts @@ -3,7 +3,7 @@ import { join } from 'path'; import { IBuildResult, IGooglePlayInternalBuildOptions } from './type'; import { BuilderCache, IBuilder } from '../../../@types/protected'; -import { checkAndroidAPILevels, generateAndroidOptions } from './utils'; +import { generateAndroidOptions } from './utils'; import * as nativeCommonHook from '../../native-common/hooks'; import { GlobalPaths } from '../../../../../global'; import { getCustomIconInfo } from './custom-icon'; @@ -30,13 +30,8 @@ export async function onAfterInit(this: IBuilder, options: IGooglePlayInternalBu options.packages['google-play'] = googlePlay; const renderBackEnd = googlePlay.renderBackEnd; - const res = await checkAndroidAPILevels(googlePlay.apiLevel, options); - if (!res.valid) { - console.error(res.message); - if (typeof res.fixedValue === 'number') { - googlePlay.apiLevel = res.fixedValue; - } - } + // apiLevel 校验已由 api 层的 verifyBuildOptions 走 verifyRuleMap 处理,非法直接阻断构建; + // 这里不再 silent auto-fix。 if (googlePlay.useDebugKeystore) { googlePlay.keystorePath = join(GlobalPaths.staticDir, '../tools/keystore/debug.keystore'); diff --git a/src/core/builder/platforms/google-play/src/utils.ts b/src/core/builder/platforms/google-play/src/utils.ts index 0cbd310d4..6fd398a4c 100644 --- a/src/core/builder/platforms/google-play/src/utils.ts +++ b/src/core/builder/platforms/google-play/src/utils.ts @@ -4,7 +4,6 @@ import { existsSync, statSync, readdirSync } from 'fs-extra'; import { dirname, join, normalize } from 'path'; import { platform } from 'os'; import { IGooglePlayInternalBuildOptions } from './type'; -import { BuildCheckResult } from '../../../@types/protected'; export function checkPackageNameValidity(packageName: string) { return /^[a-zA-Z]\w*(\.[a-zA-Z]\w*)+$/.test(packageName); @@ -14,55 +13,6 @@ export function checkIsEmpty(value: any) { return value === null || value === undefined || value === ''; } -export async function checkAndroidAPILevels(value: number, options: IGooglePlayInternalBuildOptions): Promise { - const res: BuildCheckResult = { - valid: true, - }; - if (checkIsEmpty(value)) { - res.valid = false; - res.level = 'error'; - res.message = 'API Level cannot be empty'; - return res; - } - if (isNaN(value)) { - res.valid = false; - res.level = 'error'; - res.message = 'API Level must be a number'; - return res; - } - const APIVersion = value; - if (options.packages['google-play'].androidInstant && APIVersion < 23) { - res.valid = false; - res.level = 'error'; - res.message = 'When Android Instant App is enabled, the minimum API Level required is 23.'; - res.fixedValue = 23; - return res; - } - if ((options.packages as any).native?.JobSystem === 'tbb' && APIVersion < 21) { - res.valid = false; - res.level = 'error'; - res.message = 'When TBB is enabled, the minimum API Level required is 21.'; - res.fixedValue = 21; - return res; - } - if (options.renderPipeline === '5d45ba66-829a-46d3-948e-2ed3fa7ee421' && APIVersion < 21) { - res.valid = false; - res.level = 'error'; - res.message = 'When Deferred Render Pipeline is enabled, the minimum API Level required is 21.'; - res.fixedValue = 21; - return res; - } - if (APIVersion < 19) { - res.valid = false; - res.level = 'error'; - res.message = 'The minimum API Level required is 19.'; - res.fixedValue = 19; - return res; - } - - return res; -} - function findSdkPath(): string { const envSdk = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; if (envSdk) { diff --git a/src/core/builder/platforms/google-play/src/view/build-config-host.ts b/src/core/builder/platforms/google-play/src/view/build-config-host.ts index e83857ec9..73c12f28a 100644 --- a/src/core/builder/platforms/google-play/src/view/build-config-host.ts +++ b/src/core/builder/platforms/google-play/src/view/build-config-host.ts @@ -181,21 +181,36 @@ function getAPILevel(apiLevelStr: string): number { async function getAndroidAPILevels(): Promise { const sdkPath = await findSdkPath(); if (!sdkPath) { + console.warn('Android SDK path not found, cannot list available API Levels.'); return []; } const platformPath = sdkPath+'/'+'platforms'; if (!existsDir(platformPath)) { + console.warn(`Android SDK platforms directory not found: ${platformPath}`); return []; } - return fs.readdirSync(platformPath) - .filter((name) => { - const apiLevel = getAPILevel(name); - return apiLevel >= 19 && existsDir(path.join(platformPath, name)); - }) - .map((name) => Number.parseInt(name.split('-')[1], 10)) + const installed = fs.readdirSync(platformPath) + .filter((name) => getAPILevel(name) > 0 && existsDir(path.join(platformPath, name))) + .map((name) => getAPILevel(name)) .sort((a, b) => b - a); + + // Google Play 政策最低 API 24,UI 下拉只列可用的 + const levels = installed.filter((apiLevel) => apiLevel >= 24); + if (!levels.length) { + console.warn( + `No installed Android SDK platform meets the Google Play minimum API Level 24 (${platformPath}).` + + ` Installed: ${installed.length ? installed.map((l) => `android-${l}`).join(', ') : 'none'}.` + + ' Install a platform with API Level 24 or above to build for Google Play.', + ); + } else if (installed.length !== levels.length) { + console.log( + 'Android SDK platforms below API Level 24 are excluded for Google Play: ' + + installed.filter((apiLevel) => apiLevel < 24).map((l) => `android-${l}`).join(', '), + ); + } + return levels; } function fileImageSrc(filePath: string): string { diff --git a/src/core/builder/platforms/harmonyos-next/src/config.ts b/src/core/builder/platforms/harmonyos-next/src/config.ts index 44d73ed05..4606d74f0 100644 --- a/src/core/builder/platforms/harmonyos-next/src/config.ts +++ b/src/core/builder/platforms/harmonyos-next/src/config.ts @@ -3,6 +3,13 @@ import { IPlatformBuildPluginConfig } from '../../../@types/protected'; import { commonOptions, baseNativeCommonOptions } from '../../native-common'; +function hasEnabledEntry(value: unknown): boolean { + if (!value || typeof value !== 'object') { + return false; + } + return Object.values(value as Record).some((v) => !!v); +} + const config: IPlatformBuildPluginConfig = { ...commonOptions, displayName: 'HarmonyOS Next', @@ -21,7 +28,22 @@ const config: IPlatformBuildPluginConfig = { packageName: { func: (str: string) => { // refer: https://developer.huawei.com/consumer/cn/doc/app/agc-help-createharmonyapp-0000001945392297 - return /^(?:[a-zA-Z](?:\w*[0-9a-zA-Z])?)(?:\.[0-9a-zA-Z](?:\w*[0-9a-zA-Z])?){2,}$/.test(str); + if (!/^(?:[a-zA-Z](?:\w*[0-9a-zA-Z])?)(?:\.[0-9a-zA-Z](?:\w*[0-9a-zA-Z])?){2,}$/.test(str)) { + return false; + } + if (str.length < 7 || str.length > 128) { + return false; + } + // HarmonyOS 保留关键字,任一段 token 命中即拒;对齐 editor 的 findKeywordsTokenAware + const KEYWORDS = ['openharmony', 'harmonyos', 'harmony', 'system', 'ohos', 'oh']; + for (const seg of str.toLowerCase().split('.')) { + for (const kw of KEYWORDS) { + if (new RegExp(`(?:^|_)${kw}(?:$|_)`).test(seg)) { + return false; + } + } + } + return true; }, message: 'Invalid package name specified', }, @@ -29,6 +51,28 @@ const config: IPlatformBuildPluginConfig = { func: (value: unknown) => Array.isArray(value) && value.length > 0, message: 'i18n:harmonyos-next.tips.at_least_one', }, + // 迁移自 editor 的 verificationFunc:renderBackEnd / orientation / deviceTypes 都是"至少开一项" + renderBackEnd: { + func: (value: unknown) => { + if (!value || typeof value !== 'object') { + return false; + } + // vulkan / gles2 尚未在 HarmonyOS Next 上完整验证,暂不允许开启; + // 后续跑通稳定性验证后再放开成 ['vulkan', 'gles3', 'gles2']。 + const supported = ['gles3']; + const v = value as Record; + return supported.some((k) => !!v[k]); + }, + message: 'renderBackEnd must have at least one supported backend enabled (gles3)', + }, + orientation: { + func: hasEnabledEntry, + message: 'orientation must have at least one direction enabled', + }, + deviceTypes: { + func: hasEnabledEntry, + message: 'deviceTypes must have at least one device type enabled', + }, }, options: { ...baseNativeCommonOptions, @@ -37,27 +81,28 @@ const config: IPlatformBuildPluginConfig = { description: 'i18n:harmonyos-next.options.render_back_end', type: 'object', properties: { - // TODO OHOS 暂时隐藏其他后端选项 - // vulkan: { - // label: 'VULKAN', - // default: false, - // render: { - // ui: 'ui-checkbox', - // }, - // }, + vulkan: { + label: 'VULKAN', + type: 'boolean', + default: false, + }, gles3: { label: 'GLES3', type: 'boolean', default: true, }, - // gles2: { - // label: 'GLES2', - // default: false, - // render: { - // ui: 'ui-checkbox', - // }, - // }, + gles2: { + label: 'GLES2', + type: 'boolean', + default: false, + }, + }, + default: { + vulkan: false, + gles3: true, + gles2: false, }, + verifyRules: ['renderBackEnd'], }, jsEngine: { label: 'i18n:harmonyos-next.options.js_engine', @@ -124,6 +169,7 @@ const config: IPlatformBuildPluginConfig = { landscapeRight: true, landscapeLeft: true, }, + verifyRules: ['orientation'], }, deviceTypes: { default: { @@ -131,6 +177,7 @@ const config: IPlatformBuildPluginConfig = { }, label: 'i18n:harmonyos-next.options.device_types', type: 'object', + verifyRules: ['deviceTypes'], properties: { phone: { label: 'i18n:harmonyos-next.options.device_phone', diff --git a/src/core/builder/platforms/harmonyos-next/src/type.ts b/src/core/builder/platforms/harmonyos-next/src/type.ts index 1f217f36b..0960160c8 100644 --- a/src/core/builder/platforms/harmonyos-next/src/type.ts +++ b/src/core/builder/platforms/harmonyos-next/src/type.ts @@ -30,9 +30,9 @@ export interface IOptions extends INativeOption { appABIs: IAppABI[]; renderBackEnd: { - // vulkan: boolean; + vulkan: boolean; gles3: boolean; - // gles2: boolean; + gles2: boolean; }; jsEngine: IJsEngine useAotOptimization: boolean; diff --git a/src/core/builder/platforms/ios/src/config.ts b/src/core/builder/platforms/ios/src/config.ts index 69623f5e9..33d6db5c4 100644 --- a/src/core/builder/platforms/ios/src/config.ts +++ b/src/core/builder/platforms/ios/src/config.ts @@ -6,6 +6,32 @@ import { checkPackageNameValidity } from './utils'; const astcTypes: ITextureCompressType[] = ['astc_4x4', 'astc_5x5', 'astc_6x6', 'astc_8x8', 'astc_10x5', 'astc_10x10', 'astc_12x12']; +// JobSystem 由 baseNativeCommonOptions 声明在平台自己的 options 里,取值时按 options.platform 定位包名 +function pkgOptions(options: any): any { + return options?.packages?.[options?.platform] || options?.packages?.ios || {}; +} + +function hasEnabledEntry(value: unknown): boolean { + if (!value || typeof value !== 'object') { + return false; + } + return Object.values(value as Record).some((v) => !!v); +} + +// 逐段比较,不用 utils.compareVersion:后者把版本号拼成一个数字(只替换第一个 '.'),'9.10' 会被判成 >= '11.0' +function versionGte(value: string, min: string): boolean { + const left = value.split('.').map((s) => Number.parseInt(s, 10)); + const right = min.split('.').map((s) => Number.parseInt(s, 10)); + for (let i = 0; i < Math.max(left.length, right.length); i++) { + const a = left[i] || 0; + const b = right[i] || 0; + if (a !== b) { + return a > b; + } + } + return true; +} + const config: IPlatformBuildPluginConfig = { ...commonOptions, displayName: 'iOS', @@ -28,6 +54,33 @@ const config: IPlatformBuildPluginConfig = { }, message: 'Invalid executable name specified', }, + // 迁移自 editor 的 verificationFunc(CLI 侧 utils.ts 里有同名实现但没有接入校验流) + targetVersionStyle: { + // 2~3 段,x.x(.x) 的形式,每段范围分别为 1-99 / 0-99 / 0-99 + func: (value: unknown) => /^([1-9]\d|[1-9])(\.([1-9]\d|\d)){1,2}$/.test(String(value)), + message: 'targetVersion must look like "12.0" or "12.0.1"', + }, + targetVersionTaskFlow: { + func: (value: unknown, options: any) => { + if (pkgOptions(options).JobSystem !== 'taskFlow') { + return true; + } + return versionGte(String(value), '12.0'); + }, + message: 'When TaskFlow is enabled, the minimum target version required is 12.0.', + }, + targetVersionMin: { + func: (value: unknown) => versionGte(String(value), '11.0'), + message: 'The minimum target version required is 11.0.', + }, + orientation: { + func: hasEnabledEntry, + message: 'orientation must have at least one direction enabled', + }, + osTarget: { + func: hasEnabledEntry, + message: 'osTarget must have at least one target enabled', + }, }, commonOptions: { polyfills: { @@ -79,8 +132,9 @@ const config: IPlatformBuildPluginConfig = { landscapeLeft: true, }, properties: { - - } + + }, + verifyRules: ['orientation'], }, osTarget: { type: 'object', @@ -90,7 +144,8 @@ const config: IPlatformBuildPluginConfig = { }, properties: { - } + }, + verifyRules: ['osTarget'], }, developerTeam: { label: 'i18n:ios.options.developerTeam', @@ -99,7 +154,9 @@ const config: IPlatformBuildPluginConfig = { }, targetVersion: { default: '12.0', - type: 'string' + type: 'string', + // 顺序敏感(validator 短路):required → 格式 → TaskFlow 下限 12.0 → 通用下限 11.0 + verifyRules: ['required', 'targetVersionStyle', 'targetVersionTaskFlow', 'targetVersionMin'], }, }, hooks: './src/hooks', diff --git a/src/core/builder/platforms/ohos/src/config.ts b/src/core/builder/platforms/ohos/src/config.ts index c22baa60e..9320696ae 100644 --- a/src/core/builder/platforms/ohos/src/config.ts +++ b/src/core/builder/platforms/ohos/src/config.ts @@ -27,6 +27,16 @@ const config: IPlatformBuildPluginConfig = { }, message: 'Invalid package name specified', }, + // 迁移自 editor 的 verificationFunc:至少开启一个方向 + orientation: { + func: (value: unknown) => { + if (!value || typeof value !== 'object') { + return false; + } + return Object.values(value as Record).some((v) => !!v); + }, + message: 'orientation must have at least one direction enabled', + }, }, hooks: './src/hooks', @@ -70,6 +80,7 @@ const config: IPlatformBuildPluginConfig = { landscapeRight: true, landscapeLeft: true, }, + verifyRules: ['orientation'], }, }, diff --git a/src/core/builder/platforms/windows/src/config.ts b/src/core/builder/platforms/windows/src/config.ts index 9c2fe162d..cd09c1bf7 100644 --- a/src/core/builder/platforms/windows/src/config.ts +++ b/src/core/builder/platforms/windows/src/config.ts @@ -21,6 +21,18 @@ const config: IPlatformBuildPluginConfig = { }, message: 'Invalid executable name specified', }, + // 迁移自 editor 的 verificationFunc:至少开启一个后端 + renderBackEnd: { + func: (value: unknown) => { + if (!value || typeof value !== 'object') { + return false; + } + const supported = ['vulkan', 'gles3', 'gles2']; + const v = value as Record; + return supported.some((k) => !!v[k]); + }, + message: 'renderBackEnd must have at least one supported backend enabled (vulkan / gles3 / gles2 for windows)', + }, }, options: { ...baseNativeCommonOptions, @@ -55,6 +67,7 @@ const config: IPlatformBuildPluginConfig = { gles3: true, gles2: true, }, + verifyRules: ['renderBackEnd'], }, targetPlatform: { label: 'i18n:windows.options.targetPlatform', diff --git a/src/core/builder/share/validator-manager.ts b/src/core/builder/share/validator-manager.ts index 58db6a211..db90e48a1 100644 --- a/src/core/builder/share/validator-manager.ts +++ b/src/core/builder/share/validator-manager.ts @@ -39,8 +39,10 @@ class ValidatorManager { } try { - // 非必选参数空值时不做校验 - if (['', undefined, null].includes(value) && !rules.includes('required')) { + // 非必选参数空值时不做校验;识别字面量 'required' 以及以 'Required' 结尾的自定义规则 + // (如 keystoreRequired),后者常见于"取决于其他字段的条件必填"场景。 + const isEmpty = ['', undefined, null].includes(value); + if (isEmpty && !rules.some((r) => r === 'required' || /Required$/.test(r))) { return ''; } for (const rule of rules) { diff --git a/src/core/builder/test/platform-verify-rules.spec.ts b/src/core/builder/test/platform-verify-rules.spec.ts new file mode 100644 index 000000000..51c9e5fd1 --- /dev/null +++ b/src/core/builder/test/platform-verify-rules.spec.ts @@ -0,0 +1,582 @@ +import androidConfig from '../platforms/android/src/config'; +import googlePlayConfig from '../platforms/google-play/src/config'; +import harmonyosNextConfig from '../platforms/harmonyos-next/src/config'; +import huaweiAgcConfig from '../platforms/huawei-agc/src/config'; +import iosConfig from '../platforms/ios/src/config'; +import windowsConfig from '../platforms/windows/src/config'; +import ohosConfig from '../platforms/ohos/src/config'; +import { validatorManager } from '../share/validator-manager'; + +describe('platform verifyRuleMap migrated from editor', () => { + describe('android keystore rules', () => { + const required = androidConfig.verifyRuleMap!.keystoreRequired; + const notEmpty = androidConfig.verifyRuleMap!.keystoreNotEmpty; + + it('useDebugKeystore=true 时两条规则都放行任何值', () => { + const options = { packages: { android: { useDebugKeystore: true } } }; + for (const rule of [required, notEmpty]) { + expect(rule.func('', options)).toBe(true); + expect(rule.func(undefined as any, options)).toBe(true); + expect(rule.func(null as any, options)).toBe(true); + expect(rule.func('/foo', options)).toBe(true); + } + }); + + it('useDebugKeystore=false 时 keystoreRequired 拒 undefined/null(未设置),放行空串(交给 keystoreNotEmpty)', () => { + const options = { packages: { android: { useDebugKeystore: false } } }; + expect(required.func(undefined as any, options)).toBe(false); + expect(required.func(null as any, options)).toBe(false); + expect(required.func('', options)).toBe(true); + expect(required.func('/keystores/release.keystore', options)).toBe(true); + }); + + it('useDebugKeystore=false 时 keystoreNotEmpty 拒空串(设了但空),放行 undefined/null(交给 keystoreRequired)', () => { + const options = { packages: { android: { useDebugKeystore: false } } }; + expect(notEmpty.func('', options)).toBe(false); + expect(notEmpty.func(undefined as any, options)).toBe(true); + expect(notEmpty.func(null as any, options)).toBe(true); + expect(notEmpty.func('/keystores/release.keystore', options)).toBe(true); + }); + + it('四个 keystore 字段都同时挂了 keystoreRequired 和 keystoreNotEmpty', () => { + const opts = androidConfig.options as any; + for (const key of ['keystorePath', 'keystorePassword', 'keystoreAlias', 'keystoreAliasPassword']) { + expect(opts[key].verifyRules).toEqual(['keystoreRequired', 'keystoreNotEmpty']); + } + }); + + // checkBuildOption 逐字段校验时不合并平台默认值,useDebugKeystore 可能整个缺席,此时必须按默认值 true 放行 + it('useDebugKeystore 缺席时两条规则都放行(不能 fail-closed)', () => { + for (const options of [ + { packages: { android: {} } }, + { packages: {} }, + {}, + ]) { + for (const rule of [required, notEmpty]) { + expect(rule.func('', options)).toBe(true); + expect(rule.func(undefined as any, options)).toBe(true); + expect(rule.func(null as any, options)).toBe(true); + } + } + }); + + it('只有显式 false 才触发校验,其他 falsy 值不算', () => { + expect(notEmpty.func('', { packages: { android: { useDebugKeystore: false } } })).toBe(false); + expect(notEmpty.func('', { packages: { android: { useDebugKeystore: undefined } } })).toBe(true); + expect(notEmpty.func('', { packages: { android: { useDebugKeystore: null } } })).toBe(true); + }); + + it('两条规则 message 语义有区分', () => { + expect(required.message).toMatch(/Required/); + expect(notEmpty.message).toMatch(/Cannot be empty/); + }); + }); + + describe('google-play keystore rules', () => { + const required = googlePlayConfig.verifyRuleMap!.keystoreRequired; + const notEmpty = googlePlayConfig.verifyRuleMap!.keystoreNotEmpty; + + it('useDebugKeystore=true 时两条规则都放行任何值', () => { + const options = { packages: { 'google-play': { useDebugKeystore: true } } }; + for (const rule of [required, notEmpty]) { + expect(rule.func('', options)).toBe(true); + expect(rule.func(undefined as any, options)).toBe(true); + expect(rule.func('/foo', options)).toBe(true); + } + }); + + it('useDebugKeystore=false 时语义区分与 android 一致', () => { + const options = { packages: { 'google-play': { useDebugKeystore: false } } }; + // keystoreRequired 只关心 undefined/null + expect(required.func(undefined as any, options)).toBe(false); + expect(required.func(null as any, options)).toBe(false); + expect(required.func('', options)).toBe(true); + // keystoreNotEmpty 只关心空串 + expect(notEmpty.func('', options)).toBe(false); + expect(notEmpty.func(undefined as any, options)).toBe(true); + expect(notEmpty.func('release.keystore', options)).toBe(true); + }); + + it('四个 keystore 字段都同时挂了 keystoreRequired 和 keystoreNotEmpty', () => { + const opts = googlePlayConfig.options as any; + for (const key of ['keystorePath', 'keystorePassword', 'keystoreAlias', 'keystoreAliasPassword']) { + expect(opts[key].verifyRules).toEqual(['keystoreRequired', 'keystoreNotEmpty']); + } + }); + + it('useDebugKeystore 缺席时两条规则都放行(与 android 一致)', () => { + const options = { platform: 'google-play', packages: { 'google-play': {} } }; + for (const rule of [required, notEmpty]) { + expect(rule.func('', options)).toBe(true); + expect(rule.func(undefined as any, options)).toBe(true); + } + }); + }); + + describe('harmonyos-next.packageName', () => { + const rule = harmonyosNextConfig.verifyRuleMap!.packageName; + + it('合法包名通过', () => { + expect(rule.func('com.example.game', {})).toBe(true); + expect(rule.func('com.company.myapp2024', {})).toBe(true); + }); + + it('不符合正则的拒绝', () => { + expect(rule.func('nopoint', {})).toBe(false); + expect(rule.func('1com.example.game', {})).toBe(false); + expect(rule.func('com..example', {})).toBe(false); + }); + + it('长度 < 7 的拒绝', () => { + expect(rule.func('a.b.c', {})).toBe(false); + }); + + it('长度 > 128 的拒绝', () => { + const long = 'com.' + 'x'.repeat(130); + expect(rule.func(long, {})).toBe(false); + }); + + it('包含 HarmonyOS 保留关键字任一 token 的拒绝', () => { + expect(rule.func('com.harmony.app', {})).toBe(false); + expect(rule.func('com.harmonyos.game', {})).toBe(false); + expect(rule.func('com.openharmony.demo', {})).toBe(false); + expect(rule.func('com.system.myapp', {})).toBe(false); + expect(rule.func('com.ohos.pkg', {})).toBe(false); + // 保留字必须作为独立 token(下划线或段边界);作为普通子串不触发 + expect(rule.func('com.example.harmonyish', {})).toBe(true); + expect(rule.func('com.myoh.app', {})).toBe(true); + }); + + it('保留字被下划线包围的 token 也识别', () => { + expect(rule.func('com.my_harmony_app.pkg', {})).toBe(false); + expect(rule.func('com.foo.my_oh_bar', {})).toBe(false); + }); + }); + + describe('android apiLevel sub-rules', () => { + const rules = androidConfig.verifyRuleMap!; + + it('apiLevelIsNumber 接受数字/数字字符串,拒 NaN/文本', () => { + expect(rules.apiLevelIsNumber.func(23, {})).toBe(true); + expect(rules.apiLevelIsNumber.func('23', {})).toBe(true); + expect(rules.apiLevelIsNumber.func(NaN, {})).toBe(false); + expect(rules.apiLevelIsNumber.func('foo', {})).toBe(false); + }); + + it('apiLevelInstant: androidInstant=false 时任何值通过;true 时 <23 拒', () => { + const off = { packages: { android: { androidInstant: false } } }; + expect(rules.apiLevelInstant.func(15, off)).toBe(true); + const on = { packages: { android: { androidInstant: true } } }; + expect(rules.apiLevelInstant.func(22, on)).toBe(false); + expect(rules.apiLevelInstant.func(23, on)).toBe(true); + expect(rules.apiLevelInstant.func(35, on)).toBe(true); + }); + + it('apiLevelTbb: JobSystem!==tbb 通过;===tbb 时 <21 拒(JobSystem 落在 packages[platform])', () => { + const withJob = (JobSystem: string) => ({ platform: 'android', packages: { android: { JobSystem } } }); + expect(rules.apiLevelTbb.func(15, withJob('other'))).toBe(true); + expect(rules.apiLevelTbb.func(15, { platform: 'android', packages: {} })).toBe(true); + expect(rules.apiLevelTbb.func(20, withJob('tbb'))).toBe(false); + expect(rules.apiLevelTbb.func(21, withJob('tbb'))).toBe(true); + }); + + it('apiLevelRenderPipeline: 非延迟渲染管线 uuid 通过;命中且 <21 拒', () => { + const deferredUuid = '5d45ba66-829a-46d3-948e-2ed3fa7ee421'; + expect(rules.apiLevelRenderPipeline.func(15, { renderPipeline: 'other' })).toBe(true); + expect(rules.apiLevelRenderPipeline.func(15, {})).toBe(true); + expect(rules.apiLevelRenderPipeline.func(20, { renderPipeline: deferredUuid })).toBe(false); + expect(rules.apiLevelRenderPipeline.func(21, { renderPipeline: deferredUuid })).toBe(true); + }); + + it('apiLevelMin19: <19 拒,>=19 通过', () => { + expect(rules.apiLevelMin19.func(18, {})).toBe(false); + expect(rules.apiLevelMin19.func(19, {})).toBe(true); + expect(rules.apiLevelMin19.func(35, {})).toBe(true); + }); + + it('apiLevel 字段串起了 required + 5 条子规则,顺序不变', () => { + const apiLevel = (androidConfig.options as any).apiLevel; + expect(apiLevel.verifyRules).toEqual([ + 'required', + 'apiLevelIsNumber', + 'apiLevelInstant', + 'apiLevelTbb', + 'apiLevelRenderPipeline', + 'apiLevelMin19', + ]); + }); + }); + + describe('google-play apiLevel sub-rules', () => { + const rules = googlePlayConfig.verifyRuleMap!; + + it('apiLevelMin24: <24 拒,>=24 通过(Google Play 政策)', () => { + expect(rules.apiLevelMin24.func(19, {})).toBe(false); + expect(rules.apiLevelMin24.func(23, {})).toBe(false); + expect(rules.apiLevelMin24.func(24, {})).toBe(true); + expect(rules.apiLevelMin24.func(35, {})).toBe(true); + }); + + it('apiLevelTbb: 保留引擎技术约束(数值上被 24 覆盖,不会 fire,仅文档化)', () => { + const withJob = (JobSystem: string) => ({ platform: 'google-play', packages: { 'google-play': { JobSystem } } }); + expect(rules.apiLevelTbb.func(24, withJob('other'))).toBe(true); + expect(rules.apiLevelTbb.func(24, { platform: 'google-play', packages: {} })).toBe(true); + expect(rules.apiLevelTbb.func(20, withJob('tbb'))).toBe(false); + expect(rules.apiLevelTbb.func(24, withJob('tbb'))).toBe(true); + }); + + it('apiLevelRenderPipeline: 保留引擎技术约束(数值上被 24 覆盖)', () => { + const deferredUuid = '5d45ba66-829a-46d3-948e-2ed3fa7ee421'; + expect(rules.apiLevelRenderPipeline.func(24, { renderPipeline: 'other' })).toBe(true); + expect(rules.apiLevelRenderPipeline.func(20, { renderPipeline: deferredUuid })).toBe(false); + expect(rules.apiLevelRenderPipeline.func(24, { renderPipeline: deferredUuid })).toBe(true); + }); + + it('apiLevel 字段串起了 required + isNumber + Min24 + tbb + renderPipeline(min24 先兜底政策提示)', () => { + const apiLevel = (googlePlayConfig.options as any).apiLevel; + expect(apiLevel.verifyRules).toEqual([ + 'required', + 'apiLevelIsNumber', + 'apiLevelMin24', + 'apiLevelTbb', + 'apiLevelRenderPipeline', + ]); + }); + + it('apiLevelInstant 未挂载(editor 有,本轮未加)', () => { + expect(rules.apiLevelInstant).toBeUndefined(); + }); + }); + + // ============ 迁移自 editor verificationFunc 其余 case ============ + + describe('android.appABIs / renderBackEnd / orientation("至少一项"约束)', () => { + const rules = androidConfig.verifyRuleMap!; + + it('appABIs: 空数组拒;非空数组通过;非数组类型拒', () => { + expect(rules.appABIs.func([], {})).toBe(false); + expect(rules.appABIs.func(['arm64-v8a'], {})).toBe(true); + expect(rules.appABIs.func(undefined as any, {})).toBe(false); + expect(rules.appABIs.func({} as any, {})).toBe(false); + }); + + it('renderBackEnd: 无一 backend 开启拒;至少 1 个支持的 backend 开启通过', () => { + expect(rules.renderBackEnd.func({ vulkan: false, gles3: false, gles2: false }, {})).toBe(false); + expect(rules.renderBackEnd.func({ vulkan: false, gles3: true, gles2: false }, {})).toBe(true); + expect(rules.renderBackEnd.func({} as any, {})).toBe(false); + expect(rules.renderBackEnd.func(null as any, {})).toBe(false); + }); + + it('renderBackEnd: 不支持的 backend key(如 metal)单独 true 不算合法', () => { + // 用户传 { metal: true } 给 android,虽然 truthy 但不是 android 支持的 backend → 拒 + expect(rules.renderBackEnd.func({ metal: true }, {})).toBe(false); + // 混合:即使 metal:true,只要有一个 android 支持的 backend 开启就通过 + expect(rules.renderBackEnd.func({ metal: true, gles3: true }, {})).toBe(true); + }); + + it('orientation: 无一方向开启拒;至少 1 个 true 通过', () => { + expect(rules.orientation.func({ portrait: false, landscapeRight: false, landscapeLeft: false }, {})).toBe(false); + expect(rules.orientation.func({ portrait: false, landscapeRight: true, landscapeLeft: false }, {})).toBe(true); + expect(rules.orientation.func(null as any, {})).toBe(false); + }); + + it('三个字段各挂对应的 verifyRules', () => { + const opts = androidConfig.options as any; + expect(opts.appABIs.verifyRules).toEqual(['appABIs']); + expect(opts.renderBackEnd.verifyRules).toEqual(['renderBackEnd']); + expect(opts.orientation.verifyRules).toEqual(['orientation']); + }); + }); + + describe('android.maxAspectRatioRequired(resizeableActivity=false 时的格式/下限)', () => { + const rule = androidConfig.verifyRuleMap!.maxAspectRatioRequired; + + it('resizeableActivity 缺省 / true 时任何值都放行(含空/非法)', () => { + expect(rule.func('', { packages: { android: {} } })).toBe(true); + expect(rule.func('garbage', { packages: { android: { resizeableActivity: true } } })).toBe(true); + }); + + it('resizeableActivity=false + 合法值(小数)通过', () => { + const options = { packages: { android: { resizeableActivity: false } } }; + expect(rule.func('2.4', options)).toBe(true); + expect(rule.func('1.33', options)).toBe(true); + }); + + it('resizeableActivity=false + "w:h" 比例通过', () => { + const options = { packages: { android: { resizeableActivity: false } } }; + expect(rule.func('4:3', options)).toBe(true); + expect(rule.func('16:9', options)).toBe(true); + }); + + it('resizeableActivity=false + "n.n (w:h)" 组合通过', () => { + const options = { packages: { android: { resizeableActivity: false } } }; + expect(rule.func('1.78 (16:9)', options)).toBe(true); + }); + + it('resizeableActivity=false 时低于 1.33 的值拒', () => { + const options = { packages: { android: { resizeableActivity: false } } }; + expect(rule.func('1.0', options)).toBe(false); + expect(rule.func('3:4', options)).toBe(false); // 0.75 < 1.33 + expect(rule.func('1:1', options)).toBe(false); // 1.0 < 1.33 + expect(rule.func('0:0', options)).toBe(false); // 分子分母为 0 + expect(rule.func('16:0', options)).toBe(false); + }); + + it('resizeableActivity=true(默认)时 0:0 / 1:1 这类值也放行——该字段此时不写入 manifest', () => { + const options = { packages: { android: { resizeableActivity: true } } }; + expect(rule.func('0:0', options)).toBe(true); + expect(rule.func('1:1', options)).toBe(true); + }); + + it('resizeableActivity=false 时空值 / 非法格式拒(Required 结尾使空值也进 func)', () => { + const options = { packages: { android: { resizeableActivity: false } } }; + expect(rule.func('', options)).toBe(false); + expect(rule.func(' ', options)).toBe(false); + expect(rule.func('abc', options)).toBe(false); + expect(rule.func(null as any, options)).toBe(false); + }); + + it('maxAspectRatio 字段挂了 maxAspectRatioRequired', () => { + expect((androidConfig.options as any).maxAspectRatio.verifyRules).toEqual(['maxAspectRatioRequired']); + }); + }); + + describe('android.remoteUrlHttp(androidInstant=true 时的 http 前缀)', () => { + const rule = androidConfig.verifyRuleMap!.remoteUrlHttp; + + it('androidInstant=false 时任何值都放行', () => { + expect(rule.func('ftp://foo', { packages: { android: { androidInstant: false } } })).toBe(true); + expect(rule.func('', { packages: { android: {} } })).toBe(true); + }); + + it('androidInstant=true 时空值放行(editor 保持)', () => { + const options = { packages: { android: { androidInstant: true } } }; + expect(rule.func('', options)).toBe(true); + expect(rule.func(undefined as any, options)).toBe(true); + }); + + it('androidInstant=true 时非空值必须 http 前缀', () => { + const options = { packages: { android: { androidInstant: true } } }; + expect(rule.func('http://foo.com', options)).toBe(true); + expect(rule.func('https://foo.com', options)).toBe(true); + expect(rule.func('ftp://foo.com', options)).toBe(false); + expect(rule.func('foo.com', options)).toBe(false); + }); + + it('remoteUrl 字段挂了 remoteUrlHttp', () => { + expect((androidConfig.options as any).remoteUrl.verifyRules).toEqual(['remoteUrlHttp']); + }); + }); + + describe('google-play 也补齐同套规则', () => { + const rules = googlePlayConfig.verifyRuleMap!; + + it('appABIs / renderBackEnd / orientation 语义与 android 一致(含"未知 backend key 不算合法")', () => { + expect(rules.appABIs.func([], {})).toBe(false); + expect(rules.appABIs.func(['arm64-v8a'], {})).toBe(true); + expect(rules.renderBackEnd.func({ vulkan: false, gles3: false, gles2: false }, {})).toBe(false); + expect(rules.renderBackEnd.func({ vulkan: true }, {})).toBe(true); + expect(rules.renderBackEnd.func({ metal: true }, {})).toBe(false); + expect(rules.orientation.func({ portrait: false, landscapeRight: false, landscapeLeft: false, upsideDown: false }, {})).toBe(false); + expect(rules.orientation.func({ landscapeLeft: true }, {})).toBe(true); + }); + + it('maxAspectRatioRequired 读的是 packages["google-play"].resizeableActivity', () => { + expect(rules.maxAspectRatioRequired.func('', { packages: { 'google-play': { resizeableActivity: false } } })).toBe(false); + expect(rules.maxAspectRatioRequired.func('2.4', { packages: { 'google-play': { resizeableActivity: false } } })).toBe(true); + expect(rules.maxAspectRatioRequired.func('garbage', { packages: { 'google-play': { resizeableActivity: true } } })).toBe(true); + }); + + it('remoteUrlHttp 读的是 packages["google-play"].androidInstant', () => { + expect(rules.remoteUrlHttp.func('ftp://a', { packages: { 'google-play': { androidInstant: true } } })).toBe(false); + expect(rules.remoteUrlHttp.func('http://a', { packages: { 'google-play': { androidInstant: true } } })).toBe(true); + expect(rules.remoteUrlHttp.func('ftp://a', { packages: { 'google-play': { androidInstant: false } } })).toBe(true); + }); + + it('五个字段都挂上对应 verifyRules', () => { + const opts = googlePlayConfig.options as any; + expect(opts.appABIs.verifyRules).toEqual(['appABIs']); + expect(opts.renderBackEnd.verifyRules).toEqual(['renderBackEnd']); + expect(opts.orientation.verifyRules).toEqual(['orientation']); + expect(opts.maxAspectRatio.verifyRules).toEqual(['maxAspectRatioRequired']); + expect(opts.remoteUrl.verifyRules).toEqual(['remoteUrlHttp']); + }); + }); + + describe('huawei-agc 复用 android 配置(走 validatorManager 的真实流)', () => { + // 与 plugin.ts 注册规则时的 key 一致:platform + pkgName + const PKG = 'huawei-agchuawei-agc'; + const KEYSTORE_KEYS = ['keystorePath', 'keystorePassword', 'keystoreAlias', 'keystoreAliasPassword']; + + beforeAll(() => { + for (const [name, rule] of Object.entries(huaweiAgcConfig.verifyRuleMap!)) { + validatorManager.addRule(name, rule as any, PKG); + } + }); + + function check(key: string, value: unknown, pkgOptions: Record) { + const rules = (huaweiAgcConfig.options as any)[key].verifyRules as string[]; + return validatorManager.check(value, rules, { + platform: 'huawei-agc', + packages: { 'huawei-agc': pkgOptions }, + }, PKG); + } + + it('整套 android 规则被继承下来(spread androidConfig)', () => { + expect(huaweiAgcConfig.verifyRuleMap).toBe(androidConfig.verifyRuleMap); + for (const key of KEYSTORE_KEYS) { + expect((huaweiAgcConfig.options as any)[key].verifyRules).toEqual(['keystoreRequired', 'keystoreNotEmpty']); + } + expect((huaweiAgcConfig.options as any).maxAspectRatio.verifyRules).toEqual(['maxAspectRatioRequired']); + }); + + it('useDebugKeystore=true(默认)时 keystore 字段留空不报错', async () => { + for (const key of KEYSTORE_KEYS) { + await expect(check(key, '', { useDebugKeystore: true })).resolves.toBe(''); + await expect(check(key, undefined, { useDebugKeystore: true })).resolves.toBe(''); + } + }); + + it('useDebugKeystore=false 时 keystore 字段留空照样报错', async () => { + for (const key of KEYSTORE_KEYS) { + await expect(check(key, '', { useDebugKeystore: false })).resolves.toMatch(/Cannot be empty/); + } + await expect(check('keystorePath', '/release.keystore', { useDebugKeystore: false })).resolves.toBe(''); + }); + + // 模拟 checkBuildOption 的形态:createVerifyOptions 只 clone 调用方传入的 options,不合并平台默认值 + it('调用方只传被校验字段(useDebugKeystore 缺席)时不误报', async () => { + for (const key of KEYSTORE_KEYS) { + await expect(check(key, '', {})).resolves.toBe(''); + } + }); + + it('resizeableActivity 默认 true 时 maxAspectRatio 不校验,false 时校验', async () => { + await expect(check('maxAspectRatio', '1:1', { resizeableActivity: true })).resolves.toBe(''); + await expect(check('maxAspectRatio', '1:1', { resizeableActivity: false })).resolves.toMatch(/1\.33/); + }); + + it('apiLevel 的联动分支也按 huawei-agc 包名生效', async () => { + await expect(check('apiLevel', 22, { androidInstant: true })).resolves.toMatch(/23/); + await expect(check('apiLevel', 23, { androidInstant: true })).resolves.toBe(''); + await expect(check('apiLevel', 20, { JobSystem: 'tbb' })).resolves.toMatch(/21/); + await expect(check('apiLevel', 18, {})).resolves.toMatch(/19/); + }); + + it('remoteUrl 只在 androidInstant=true 时要求 http 前缀', async () => { + await expect(check('remoteUrl', 'ftp://a', { androidInstant: false })).resolves.toBe(''); + await expect(check('remoteUrl', 'ftp://a', { androidInstant: true })).resolves.toMatch(/http/); + }); + }); + + // ============ 对齐 editor verificationFunc 的剩余平台 ============ + + describe('ios targetVersion / orientation / osTarget', () => { + const rules = iosConfig.verifyRuleMap!; + const opts = iosConfig.options as any; + + it('targetVersionStyle 只接受 x.x / x.x.x(每段范围对齐 editor 正则)', () => { + expect(rules.targetVersionStyle.func('12.0', {})).toBe(true); + expect(rules.targetVersionStyle.func('12.0.1', {})).toBe(true); + expect(rules.targetVersionStyle.func('12', {})).toBe(false); + expect(rules.targetVersionStyle.func('012.0', {})).toBe(false); + expect(rules.targetVersionStyle.func('12.0.1.2', {})).toBe(false); + expect(rules.targetVersionStyle.func('abc', {})).toBe(false); + }); + + it('targetVersionMin: 低于 11.0 拒;逐段比较,不会把 9.10 误判成 >= 11.0', () => { + expect(rules.targetVersionMin.func('11.0', {})).toBe(true); + expect(rules.targetVersionMin.func('12.0', {})).toBe(true); + expect(rules.targetVersionMin.func('10.9', {})).toBe(false); + expect(rules.targetVersionMin.func('9.10', {})).toBe(false); + }); + + it('targetVersionTaskFlow: JobSystem=taskFlow 时下限提到 12.0', () => { + const on = { platform: 'ios', packages: { ios: { JobSystem: 'taskFlow' } } }; + expect(rules.targetVersionTaskFlow.func('11.0', on)).toBe(false); + expect(rules.targetVersionTaskFlow.func('12.0', on)).toBe(true); + expect(rules.targetVersionTaskFlow.func('11.0', { platform: 'ios', packages: { ios: {} } })).toBe(true); + }); + + it('orientation / osTarget 至少开一项', () => { + expect(rules.orientation.func({ portrait: false, landscapeLeft: false }, {})).toBe(false); + expect(rules.orientation.func({ portrait: false, landscapeLeft: true }, {})).toBe(true); + expect(rules.orientation.func(null as any, {})).toBe(false); + expect(rules.osTarget.func({ iphoneos: false, simulator: false }, {})).toBe(false); + expect(rules.osTarget.func({ iphoneos: false, simulator: true }, {})).toBe(true); + }); + + it('三个字段都挂上 verifyRules,默认值全部合法', () => { + expect(opts.targetVersion.verifyRules).toEqual(['required', 'targetVersionStyle', 'targetVersionTaskFlow', 'targetVersionMin']); + expect(opts.orientation.verifyRules).toEqual(['orientation']); + expect(opts.osTarget.verifyRules).toEqual(['osTarget']); + expect(rules.targetVersionStyle.func(opts.targetVersion.default, {})).toBe(true); + expect(rules.targetVersionMin.func(opts.targetVersion.default, {})).toBe(true); + expect(rules.orientation.func(opts.orientation.default, {})).toBe(true); + expect(rules.osTarget.func(opts.osTarget.default, {})).toBe(true); + }); + }); + + describe('windows renderBackEnd', () => { + const rule = windowsConfig.verifyRuleMap!.renderBackEnd; + + it('全关拒,至少一个支持的后端开启通过', () => { + expect(rule.func({ vulkan: false, gles3: false, gles2: false }, {})).toBe(false); + expect(rule.func({ vulkan: true }, {})).toBe(true); + expect(rule.func({ metal: true }, {})).toBe(false); + expect(rule.func(null as any, {})).toBe(false); + }); + + it('字段挂上 verifyRules,默认值合法', () => { + const opts = windowsConfig.options as any; + expect(opts.renderBackEnd.verifyRules).toEqual(['renderBackEnd']); + expect(rule.func(opts.renderBackEnd.default, {})).toBe(true); + }); + }); + + describe('ohos orientation', () => { + const rule = ohosConfig.verifyRuleMap!.orientation; + + it('全关拒,至少一个方向开启通过,默认值合法', () => { + expect(rule.func({ portrait: false, landscapeRight: false, landscapeLeft: false }, {})).toBe(false); + expect(rule.func({ portrait: true }, {})).toBe(true); + expect(rule.func(null as any, {})).toBe(false); + const opts = ohosConfig.options as any; + expect(opts.orientation.verifyRules).toEqual(['orientation']); + expect(rule.func(opts.orientation.default, {})).toBe(true); + }); + }); + + describe('harmonyos-next renderBackEnd / orientation / deviceTypes', () => { + const rules = harmonyosNextConfig.verifyRuleMap!; + const opts = harmonyosNextConfig.options as any; + + it('三条规则都是"至少开一项"', () => { + expect(rules.renderBackEnd.func({ vulkan: false, gles3: false, gles2: false }, {})).toBe(false); + expect(rules.renderBackEnd.func({ gles3: true }, {})).toBe(true); + // vulkan / gles2 目前刻意未收进 supported——后端还没在 HarmonyOS Next 上验证稳定, + // 单独开这两个(不叠 gles3)应当被判为无效;等验证通过再放开时同步放开这里的期望。 + expect(rules.renderBackEnd.func({ vulkan: true }, {})).toBe(false); + expect(rules.renderBackEnd.func({ gles2: true }, {})).toBe(false); + expect(rules.renderBackEnd.func({ metal: true }, {})).toBe(false); + expect(rules.orientation.func({ portrait: false, landscapeLeft: false }, {})).toBe(false); + expect(rules.orientation.func({ landscapeLeft: true }, {})).toBe(true); + expect(rules.deviceTypes.func({ phone: false, default: false }, {})).toBe(false); + expect(rules.deviceTypes.func({ default: true }, {})).toBe(true); + expect(rules.deviceTypes.func(null as any, {})).toBe(false); + }); + + it('三个字段都挂上 verifyRules', () => { + expect(opts.renderBackEnd.verifyRules).toEqual(['renderBackEnd']); + expect(opts.orientation.verifyRules).toEqual(['orientation']); + expect(opts.deviceTypes.verifyRules).toEqual(['deviceTypes']); + }); + + it('三个字段默认值都合法;renderBackEnd 默认只开 gles3', () => { + expect(rules.orientation.func(opts.orientation.default, {})).toBe(true); + expect(rules.deviceTypes.func(opts.deviceTypes.default, {})).toBe(true); + expect(opts.renderBackEnd.default).toEqual({ vulkan: false, gles3: true, gles2: false }); + expect(rules.renderBackEnd.func(opts.renderBackEnd.default, {})).toBe(true); + }); + }); +}); diff --git a/src/core/builder/test/validator-manager-shortcircuit.spec.ts b/src/core/builder/test/validator-manager-shortcircuit.spec.ts new file mode 100644 index 000000000..5cf018e29 --- /dev/null +++ b/src/core/builder/test/validator-manager-shortcircuit.spec.ts @@ -0,0 +1,82 @@ +import { validatorManager } from '../share/validator-manager'; + +describe('validatorManager.check short-circuit', () => { + const pkg = 'test-shortcircuit'; + + beforeAll(() => { + validatorManager.addRule('formatCheck', { + func: (value: string) => /^ok/.test(String(value)), + message: 'bad format', + }, pkg); + validatorManager.addRule('mustNotBeEmpty', { + func: (value: unknown) => value !== '' && value !== null && value !== undefined, + message: 'must not be empty', + }, pkg); + // 命名以 Required 结尾——按新约定应该绕开空值 short-circuit + validatorManager.addRule('customRequired', { + func: (value: unknown) => value !== '' && value !== null && value !== undefined, + message: 'custom required', + }, pkg); + // 条件必填:只在 flag=false 时要求非空 + validatorManager.addRule('conditionalRequired', { + func: (value: unknown, options: any) => { + if (options?.useDebug) { + return true; + } + return value !== '' && value !== null && value !== undefined; + }, + message: 'conditional required', + }, pkg); + }); + + describe('空值 short-circuit(旧行为,保留)', () => { + it('空值 + 普通规则 → 直接跳过(不 fire)', async () => { + const err = await validatorManager.check('', ['formatCheck'], {}, pkg); + expect(err).toBe(''); + }); + + it('空值 + rules 含字面量 required → 不 short-circuit(fire 内置 required)', async () => { + const err = await validatorManager.check('', ['required'], {}, pkg); + expect(err).toBeTruthy(); + }); + + it('非空值 + 普通规则 → 正常校验', async () => { + const bad = await validatorManager.check('bad', ['formatCheck'], {}, pkg); + expect(bad).toBe('bad format'); + const ok = await validatorManager.check('ok!', ['formatCheck'], {}, pkg); + expect(ok).toBe(''); + }); + }); + + describe('*Required 命名约定(新增)', () => { + it('空值 + 以 Required 结尾的自定义规则 → 绕过 short-circuit,规则被执行', async () => { + const err = await validatorManager.check('', ['customRequired'], {}, pkg); + expect(err).toBe('custom required'); + }); + + it('非空值 + 以 Required 结尾的规则 → 正常通过', async () => { + const err = await validatorManager.check('value', ['customRequired'], {}, pkg); + expect(err).toBe(''); + }); + + it('条件必填:flag=true 时空值也通过', async () => { + const err = await validatorManager.check('', ['conditionalRequired'], { useDebug: true }, pkg); + expect(err).toBe(''); + }); + + it('条件必填:flag=false 时空值应该报错', async () => { + const err = await validatorManager.check('', ['conditionalRequired'], { useDebug: false }, pkg); + expect(err).toBe('conditional required'); + }); + + it('规则名中间含 Required 但不是结尾 → 仍然 short-circuit', async () => { + validatorManager.addRule('RequiredButMiddle', { + func: () => false, + message: 'should not fire', + }, pkg); + const err = await validatorManager.check('', ['RequiredButMiddle'], {}, pkg); + // 结尾不是 Required(结尾是 Middle),维持旧的 short-circuit 语义 + expect(err).toBe(''); + }); + }); +}); diff --git a/src/core/builder/test/verify-build-options-integration.spec.ts b/src/core/builder/test/verify-build-options-integration.spec.ts new file mode 100644 index 000000000..41cd05ed0 --- /dev/null +++ b/src/core/builder/test/verify-build-options-integration.spec.ts @@ -0,0 +1,212 @@ +import lodash from 'lodash'; + +// 与 verify-build-options.spec.ts 的分工:那份把 pluginManager 整个桩掉,只测 verifyBuildOptions 自己的分流; +// 这份不桩 pluginManager,用真实平台 config 跑通「合并平台默认值 → checkBuildOptions → 真实 verifyRuleMap」整条链。 +const projectStore: Record = { common: {} }; + +jest.mock('../share/builder-config', () => ({ + __esModule: true, + default: { + commonOptionConfigs: {}, + // 用内存 store 模拟项目 build profile:internalRegister 会把各平台 options 的 default 写进来, + // getOptionsByPlatform 再读出来,与真实流程一致 + setProject: jest.fn(async (key: string, value: unknown) => { + lodash.set(projectStore, key, value); + }), + getProject: jest.fn(async (key: string) => lodash.get(projectStore, key)), + buildTemplateDir: '', + init: jest.fn(), + }, +})); + +jest.mock('../../base/i18n', () => ({ + __esModule: true, + default: { + t: (key: string) => key, + transI18nName: (key: string) => key, + setLanguage: jest.fn(), + registerLanguagePatch: jest.fn(), + }, +})); +jest.mock('../../configuration', () => ({ configurationRegistry: { register: jest.fn() } })); +jest.mock('../../../global', () => ({ + GlobalPaths: { + workspace: '/tmp/cocos-cli-test-ws', + enginePath: '/tmp/cocos-cli-test-engine', + project: '/tmp/cocos-cli-test-project', + }, +})); +jest.mock('../../../server/middleware/core', () => ({ middlewareService: { register: jest.fn() } })); +jest.mock('../build.middleware', () => ({ __esModule: true, default: {} })); +jest.mock('../../base/console', () => ({ + newConsole: { + createLogSinkRestorer: () => () => {}, + buildStart: jest.fn(), + buildComplete: jest.fn(), + progress: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, +})); +jest.mock('../../assets/manager/asset', () => ({ __esModule: true, default: {} })); + +let mockEngineRenderPipeline: string | undefined; +jest.mock('../../engine', () => ({ + Engine: { + getConfig: () => ({ renderPipeline: mockEngineRenderPipeline }), + }, +})); + +import { BuildExitCode } from '../@types/protected'; +import androidConfig from '../platforms/android/src/config'; +import iosConfig from '../platforms/ios/src/config'; + +const DEFERRED_PIPELINE_UUID = '5d45ba66-829a-46d3-948e-2ed3fa7ee421'; + +describe('verifyBuildOptions 走真实 pluginManager + 平台 config', () => { + let verifyBuildOptions: typeof import('../index').verifyBuildOptions; + let consoleErrorSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + + beforeAll(async () => { + const { pluginManager } = await import('../manager/plugin'); + const pool = (pluginManager as any).platformRegisterInfoPool as Map; + for (const [platform, config] of [['android', androidConfig], ['ios', iosConfig]] as const) { + pool.set(platform, { platform, path: `/plugins/${platform}`, type: 'register', config }); + await pluginManager.register(platform); + } + ({ verifyBuildOptions } = await import('../index')); + }); + + beforeEach(() => { + mockEngineRenderPipeline = undefined; + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + it('注册后平台默认值能兜住漏传字段:只传 platform 也能通过', async () => { + await expect(verifyBuildOptions('android', {} as any)).resolves.toBeNull(); + }); + + // ===== 问题 2:值非法 + 默认值合法,现在硬失败,不再回落默认值 ===== + + it('apiLevel 非法时返回 PARAM_ERROR,不会静默抬到默认值 35', async () => { + const userOptions = { packages: { android: { apiLevel: 10 } } }; + const result = await verifyBuildOptions('android', userOptions as any); + + expect(result).not.toBeNull(); + expect(result!.code).toBe(BuildExitCode.PARAM_ERROR); + expect(result!.reason).toContain('apiLevel'); + expect(result!.reason).toContain('19'); + // 入口层只判定不修改:调用方传进来的对象保持原值,构建也不会拿 35 继续跑 + expect(userOptions.packages.android.apiLevel).toBe(10); + }); + + it('packageName 非法时返回 PARAM_ERROR(同类:默认值 com.cocos.game 合法也不回落)', async () => { + const result = await verifyBuildOptions('android', { + packages: { android: { packageName: '123abc' } }, + } as any); + + expect(result!.code).toBe(BuildExitCode.PARAM_ERROR); + expect(result!.reason).toContain('packageName'); + }); + + it('skipCheck 是逃生门:非法值也直接放过', async () => { + await expect(verifyBuildOptions('android', { + skipCheck: true, + packages: { android: { apiLevel: 10 } }, + } as any)).resolves.toBeNull(); + }); + + // ===== 问题 2 类型 B:值非法 + 默认值也非法 ===== + + it('useDebugKeystore=false + keystore 留空 → PARAM_ERROR,逐条列出 4 个字段', async () => { + const result = await verifyBuildOptions('android', { + packages: { android: { useDebugKeystore: false } }, + } as any); + + expect(result!.code).toBe(BuildExitCode.PARAM_ERROR); + for (const key of ['keystorePath', 'keystorePassword', 'keystoreAlias', 'keystoreAliasPassword']) { + expect(result!.reason).toContain(key); + } + expect(result!.reason).toContain('Cannot be empty'); + }); + + it('useDebugKeystore=false + keystore 填全 → 通过', async () => { + const result = await verifyBuildOptions('android', { + packages: { + android: { + useDebugKeystore: false, + keystorePath: '/keystores/release.keystore', + keystorePassword: 'pwd', + keystoreAlias: 'alias', + keystoreAliasPassword: 'pwd', + }, + }, + } as any); + expect(result).toBeNull(); + }); + + it('ios 不传 packageName 时因默认值为空而失败(唯一一类"默认值本身不合法")', async () => { + const result = await verifyBuildOptions('ios', {} as any); + + expect(result!.code).toBe(BuildExitCode.PARAM_ERROR); + expect(result!.reason).toContain('packageName'); + }); + + // ===== 联动规则在整条链上确实生效 ===== + + it('androidInstant=true 时 apiLevel 22 被拦(联动 gate 读的是合并后的 options)', async () => { + const result = await verifyBuildOptions('android', { + packages: { android: { androidInstant: true, apiLevel: 22 } }, + } as any); + + expect(result!.code).toBe(BuildExitCode.PARAM_ERROR); + expect(result!.reason).toContain('23'); + }); + + // renderPipeline 是项目设置,构建阶段才由 checkProjectSetting 填进 options; + // 入口层按编辑器的做法直接读工程配置,所以调用方显式传入和工程设置两条来源都要生效 + it('调用方显式传延迟渲染管线 uuid 时 apiLevel 20 被拦', async () => { + const blocked = await verifyBuildOptions('android', { + renderPipeline: DEFERRED_PIPELINE_UUID, + packages: { android: { apiLevel: 20 } }, + } as any); + expect(blocked!.code).toBe(BuildExitCode.PARAM_ERROR); + expect(blocked!.reason).toContain('21'); + + const passed = await verifyBuildOptions('android', { + packages: { android: { apiLevel: 21 } }, + } as any); + expect(passed).toBeNull(); + }); + + it('工程配置开了延迟渲染管线时,调用方不传也能拦住 apiLevel 20', async () => { + mockEngineRenderPipeline = DEFERRED_PIPELINE_UUID; + const blocked = await verifyBuildOptions('android', { + packages: { android: { apiLevel: 20 } }, + } as any); + expect(blocked!.code).toBe(BuildExitCode.PARAM_ERROR); + expect(blocked!.reason).toContain('21'); + }); + + it('工程配置是其他渲染管线时 apiLevel 20 放行', async () => { + mockEngineRenderPipeline = 'fd8ec536-a354-4a17-9c74-4f3883c378c8'; + await expect(verifyBuildOptions('android', { + packages: { android: { apiLevel: 20 } }, + } as any)).resolves.toBeNull(); + }); + + it('调用方显式传的 renderPipeline 优先于工程配置', async () => { + mockEngineRenderPipeline = DEFERRED_PIPELINE_UUID; + await expect(verifyBuildOptions('android', { + renderPipeline: 'fd8ec536-a354-4a17-9c74-4f3883c378c8', + packages: { android: { apiLevel: 20 } }, + } as any)).resolves.toBeNull(); + }); +}); diff --git a/src/core/builder/test/verify-build-options.spec.ts b/src/core/builder/test/verify-build-options.spec.ts new file mode 100644 index 000000000..67ccf5524 --- /dev/null +++ b/src/core/builder/test/verify-build-options.spec.ts @@ -0,0 +1,254 @@ +const checkBuildOptionsMock = jest.fn(); +const getOptionsByPlatformMock = jest.fn(async () => ({})); +const getDefaultScenesMock = jest.fn(() => [] as Array<{ url: string; uuid: string; bundle: string }>); +const getDefaultStartSceneMock = jest.fn(() => undefined as string | undefined); + +jest.mock('../manager/plugin', () => ({ + pluginManager: { + checkBuildOptions: checkBuildOptionsMock, + getOptionsByPlatform: getOptionsByPlatformMock, + }, +})); + +// index.ts 顶层会 import 大量与本 spec 无关的模块(builder-config、middleware、newConsole...), +// 桩掉这些副作用重的依赖,只为拿到 verifyBuildOptions 这个纯函数。 +jest.mock('../share/builder-config', () => ({ __esModule: true, default: { init: jest.fn() } })); +jest.mock('../../../server/middleware/core', () => ({ middlewareService: { register: jest.fn() } })); +jest.mock('../build.middleware', () => ({ __esModule: true, default: {} })); +jest.mock('../../base/console', () => ({ + newConsole: { + createLogSinkRestorer: () => () => {}, + buildStart: jest.fn(), + buildComplete: jest.fn(), + progress: jest.fn(), + error: jest.fn(), + }, +})); +jest.mock('../../base/i18n', () => ({ __esModule: true, default: { t: (k: string) => k, transI18nName: (k: string) => k } })); +jest.mock('../../assets/manager/asset', () => ({ __esModule: true, default: {} })); +// getDefaultScenes / getDefaultStartScene 会读 assetManager;单测里桩成可控的返回值, +// 让 verifyBuildOptions 的场景兜底逻辑可以被独立断言。 +jest.mock('../share/common-options-validator', () => ({ + getDefaultScenes: getDefaultScenesMock, + getDefaultStartScene: getDefaultStartSceneMock, +})); + +import { BuildExitCode } from '../@types/protected'; + +describe('verifyBuildOptions', () => { + let verifyBuildOptions: typeof import('../index').verifyBuildOptions; + let consoleErrorSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + + beforeAll(async () => { + ({ verifyBuildOptions } = await import('../index')); + }); + + beforeEach(() => { + checkBuildOptionsMock.mockReset(); + getOptionsByPlatformMock.mockReset(); + getOptionsByPlatformMock.mockResolvedValue({}); + getDefaultScenesMock.mockReset(); + getDefaultScenesMock.mockReturnValue([]); + getDefaultStartSceneMock.mockReset(); + getDefaultStartSceneMock.mockReturnValue(undefined); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + + it('skipCheck 为 true 时跳过校验,不调用 pluginManager', async () => { + const result = await verifyBuildOptions('windows', { skipCheck: true } as any); + expect(result).toBeNull(); + expect(checkBuildOptionsMock).not.toHaveBeenCalled(); + expect(getOptionsByPlatformMock).not.toHaveBeenCalled(); + }); + + it('所有字段合法时返回 null', async () => { + checkBuildOptionsMock.mockResolvedValue({ + name: { valid: true }, + mode: { valid: true }, + }); + const result = await verifyBuildOptions('windows', {} as any); + expect(result).toBeNull(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('只有 warn 级别问题时不阻塞构建,仅打印警告', async () => { + checkBuildOptionsMock.mockResolvedValue({ + name: { valid: false, level: 'warn', message: 'name is empty' }, + }); + const result = await verifyBuildOptions('windows', {} as any); + expect(result).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('name is empty')); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('存在 error 级别问题时返回 PARAM_ERROR 并汇总所有字段', async () => { + checkBuildOptionsMock.mockResolvedValue({ + name: { valid: false, level: 'error', message: 'Required' }, + packageName: { valid: false, message: 'Invalid package name specified' }, + debug: { valid: true }, + outputName: { valid: false, level: 'warn', message: 'auto filled' }, + }); + + const result = await verifyBuildOptions('android', { platform: 'android' } as any); + + expect(result).not.toBeNull(); + expect(result!.code).toBe(BuildExitCode.PARAM_ERROR); + expect(result!.reason).toContain('name: Required'); + expect(result!.reason).toContain('packageName: Invalid package name specified'); + // warn 级别不能混进 error 列表 + expect(result!.reason).not.toContain('outputName'); + // warnings 走单独打印 + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('outputName: auto filled')); + }); + + it('error 都硬阻塞,fixedValue 不再降级(区别于之前的语义)', async () => { + checkBuildOptionsMock.mockResolvedValue({ + packageName: { + valid: false, + level: 'error', + message: 'Required', + // 即使 checkBuildOptions 返回了 fixedValue,也应该硬阻塞—— + // 用户没传的场景通过 defaultsDeep 兜底后不会进到这里 + fixedValue: 'com.cocos.game', + }, + }); + + const result = await verifyBuildOptions('android', { platform: 'android' } as any); + + expect(result).not.toBeNull(); + expect(result!.code).toBe(BuildExitCode.PARAM_ERROR); + expect(result!.reason).toContain('packageName: Required'); + }); + + it('用户漏传的字段会被平台 default 兜底通过(defaultsDeep 语义)', async () => { + // 模拟:用户没传 android.packageName;getOptionsByPlatform 返回带有 default 的完整选项 + getOptionsByPlatformMock.mockResolvedValue({ + packages: { android: { packageName: 'com.cocos.game' } }, + }); + // checkBuildOptions 接到 merged 后应该看到 packageName='com.cocos.game',规则通过 + checkBuildOptionsMock.mockImplementation(async (_p, opts: any) => { + expect(opts.packages?.android?.packageName).toBe('com.cocos.game'); + return { packageName: { valid: true } }; + }); + + const result = await verifyBuildOptions('android', { platform: 'android' } as any); + expect(result).toBeNull(); + expect(getOptionsByPlatformMock).toHaveBeenCalledWith('android'); + }); + + it('用户传的值优先于 default(defaultsDeep 不覆盖已存在值)', async () => { + getOptionsByPlatformMock.mockResolvedValue({ + packages: { android: { packageName: 'com.cocos.game' } }, + }); + checkBuildOptionsMock.mockImplementation(async (_p, opts: any) => { + expect(opts.packages?.android?.packageName).toBe('com.myapp'); + return { packageName: { valid: true } }; + }); + + const userOptions = { platform: 'android', packages: { android: { packageName: 'com.myapp' } } }; + const result = await verifyBuildOptions('android', userOptions as any); + expect(result).toBeNull(); + }); + + it('message 缺失时兜底成 invalid', async () => { + checkBuildOptionsMock.mockResolvedValue({ + weird: { valid: false, level: 'error' }, + }); + const result = await verifyBuildOptions('windows', {} as any); + expect(result!.reason).toContain('weird: invalid'); + }); + + it('checkBuildOptions 抛异常时降级为 warn,不阻塞构建', async () => { + checkBuildOptionsMock.mockRejectedValue(new Error('plugin blew up')); + const result = await verifyBuildOptions('windows', {} as any); + expect(result).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalledWith('Failed to run build option checks:', expect.any(Error)); + }); + + it('options 为 undefined 时不崩溃', async () => { + checkBuildOptionsMock.mockResolvedValue({}); + const result = await verifyBuildOptions('windows'); + expect(result).toBeNull(); + }); + + it('taskName 空时兜底成 platform(复刻 createBuildTask 的归一化,避免 required 规则误伤)', async () => { + // getOptionsByPlatform 里 taskName 默认就是 '',如果不兜底,required 规则会永远拦下来 + getOptionsByPlatformMock.mockResolvedValue({ taskName: '' }); + checkBuildOptionsMock.mockImplementation(async (_p, opts: any) => { + expect(opts.taskName).toBe('web-desktop'); + return { taskName: { valid: true } }; + }); + + const result = await verifyBuildOptions('web-desktop', {} as any); + expect(result).toBeNull(); + }); + + it('调用方显式传的 taskName 不会被平台名覆盖', async () => { + getOptionsByPlatformMock.mockResolvedValue({ taskName: '' }); + checkBuildOptionsMock.mockImplementation(async (_p, opts: any) => { + expect(opts.taskName).toBe('nightly-build'); + return {}; + }); + + await verifyBuildOptions('web-desktop', { taskName: 'nightly-build' } as any); + }); + + it('startScene 空且 asset-db 有可用场景时兜底成 getDefaultStartScene()', async () => { + getDefaultStartSceneMock.mockReturnValue('scene-uuid-1'); + checkBuildOptionsMock.mockImplementation(async (_p, opts: any) => { + expect(opts.startScene).toBe('scene-uuid-1'); + return { startScene: { valid: true } }; + }); + + await verifyBuildOptions('web-desktop', {} as any); + expect(getDefaultStartSceneMock).toHaveBeenCalled(); + }); + + it('调用方显式传的 startScene 不被兜底覆盖', async () => { + getDefaultStartSceneMock.mockReturnValue('scene-uuid-default'); + checkBuildOptionsMock.mockImplementation(async (_p, opts: any) => { + expect(opts.startScene).toBe('scene-uuid-user'); + return {}; + }); + + await verifyBuildOptions('web-desktop', { startScene: 'scene-uuid-user' } as any); + }); + + it('scenes 空数组时兜底成 getDefaultScenes()', async () => { + getDefaultScenesMock.mockReturnValue([{ url: 'db://a.scene', uuid: 'a', bundle: '' }]); + checkBuildOptionsMock.mockImplementation(async (_p, opts: any) => { + expect(opts.scenes).toEqual([{ url: 'db://a.scene', uuid: 'a', bundle: '' }]); + return { scenes: { valid: true } }; + }); + + await verifyBuildOptions('web-desktop', {} as any); + expect(getDefaultScenesMock).toHaveBeenCalled(); + }); + + it('调用方显式传的 scenes 不被兜底覆盖', async () => { + getDefaultScenesMock.mockReturnValue([{ url: 'db://default.scene', uuid: 'd', bundle: '' }]); + checkBuildOptionsMock.mockImplementation(async (_p, opts: any) => { + expect(opts.scenes).toEqual([{ url: 'db://user.scene', uuid: 'u', bundle: '' }]); + return {}; + }); + + await verifyBuildOptions('web-desktop', { + scenes: [{ url: 'db://user.scene', uuid: 'u', bundle: '' }], + } as any); + }); + + it('asset-db 未初始化(getDefaultStartScene / getDefaultScenes 抛异常)时不崩,继续走后续校验', async () => { + getDefaultStartSceneMock.mockImplementation(() => { throw new Error('asset-db not ready'); }); + getDefaultScenesMock.mockImplementation(() => { throw new Error('asset-db not ready'); }); + checkBuildOptionsMock.mockResolvedValue({ name: { valid: true } }); + + await expect(verifyBuildOptions('web-desktop', {} as any)).resolves.toBeNull(); + }); +}); diff --git a/src/core/launcher.ts b/src/core/launcher.ts index c4e3a43a2..5f4a4ff60 100644 --- a/src/core/launcher.ts +++ b/src/core/launcher.ts @@ -1,5 +1,5 @@ import { join } from 'path'; -import { BuildExitCode, IBuildCommandOption, Platform } from './builder/@types/protected'; +import { BuildExitCode, IBuildCommandOption, IBuildResultData, Platform } from './builder/@types/protected'; import utils from './base/utils'; import { newConsole } from './base/console'; import { startServer, getServerUrl } from '../server'; @@ -222,13 +222,17 @@ export default class Launcher { * @param platform * @param options */ - async build(platform: Platform, options: Partial) { + async build(platform: Platform, options: Partial): Promise { GlobalConfig.mode = 'simple'; // 先导入项目 await this.import(); // 执行构建流程 - const { init, build } = await import('./builder'); + const { init, build, verifyBuildOptions } = await import('./builder'); await init([platform]); + const checkFail = await verifyBuildOptions(platform, options as any); + if (checkFail) { + return checkFail; + } return await build(platform, options); } diff --git a/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts b/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts index d97548a56..2891db226 100644 --- a/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts +++ b/src/core/scene/test/service-core/component-prefab-ui-handling.test.ts @@ -63,6 +63,7 @@ jest.mock('cc', () => ({ MeshCollider: class MeshCollider {}, Node: MockNode, RigidBody: class RigidBody {}, + Scene: class Scene {}, UITransform: MockUITransform, js: { getClassById: mockGetClassById,