From f550110be47927fff10216bf12a777e2b3cee655 Mon Sep 17 00:00:00 2001 From: qiuguohua Date: Wed, 19 Aug 2026 17:23:37 +0800 Subject: [PATCH 1/9] fix(build): validate build options at CLI/MCP entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI and MCP paths bypassed the panel's UI validation, so invalid params (e.g. malformed android packageName) silently reached the build. Add verifyBuildOptions in core/builder that runs pluginManager.checkBuildOptions and is invoked from Launcher.build (CLI) and BuilderApi.build (MCP). Pink stays untouched — it already validates via its UI layer. Errors with a fixedValue are downgraded to warn to preserve the existing default-fallback behaviour from createBuildTask.checkOptions. Tests: 9 cases covering skipCheck, all-valid, warn-only, error aggregation, error-with-fixedValue downgrade, hard error without fixedValue, missing message fallback, checker exception, and undefined options. --- src/api/builder/builder.ts | 9 +- src/commands/build.ts | 3 + src/core/builder/index.ts | 45 ++++++ .../builder/test/verify-build-options.spec.ts | 151 ++++++++++++++++++ src/core/launcher.ts | 10 +- 5 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 src/core/builder/test/verify-build-options.spec.ts 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..5365b8389 100644 --- a/src/core/builder/index.ts +++ b/src/core/builder/index.ts @@ -29,6 +29,51 @@ export async function init(platform?: string[]) { } } +/** + * 使用平台注册的 verifyRules 对构建参数做严格校验。 + * 仅供 api 层(CLI/MCP)在调用 build() 前显式调用;Pink 走自己的 UI 校验,不会经过这里。 + * skipCheck 为 true 时跳过。 + * 通过返回 null;不通过返回 { code: PARAM_ERROR, reason }。 + */ +export async function verifyBuildOptions( + platform: string, + options?: IBuildCommandOption, +): Promise<{ code: Exclude; reason: string } | null> { + if (options?.skipCheck) { + return null; + } + try { + const results = await pluginManager.checkBuildOptions(platform, (options || {}) 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'}`; + // 参考 createBuildTask 里 checkOptions 的做法:如果规则返回了 fixedValue(通常是平台配置的 default), + // 说明用户漏传/传错但平台自带 fallback,此时降级为 warn,不阻塞构建;仅在 fixedValue 缺失(不可修复)时才 error。 + if (r.level === 'warn' || r.fixedValue !== undefined) { + 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/test/verify-build-options.spec.ts b/src/core/builder/test/verify-build-options.spec.ts new file mode 100644 index 000000000..c89386cf6 --- /dev/null +++ b/src/core/builder/test/verify-build-options.spec.ts @@ -0,0 +1,151 @@ +const checkBuildOptionsMock = jest.fn(); + +jest.mock('../manager/plugin', () => ({ + pluginManager: { + checkBuildOptions: checkBuildOptionsMock, + }, +})); + +// 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: {} })); + +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(); + 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(); + }); + + 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 时降级为 warn(平台 default 可兜底,不阻塞构建)', async () => { + checkBuildOptionsMock.mockResolvedValue({ + // 模拟 android: 用户没传 packageName,规则失败但平台 default='com.cocos.game' 作为 fixedValue + packageName: { + valid: false, + level: 'error', + message: 'Required', + fixedValue: 'com.cocos.game', + }, + }); + + const result = await verifyBuildOptions('android', { platform: 'android' } as any); + + expect(result).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('packageName: Required')); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('error 且没有 fixedValue 时才真正阻塞构建', async () => { + checkBuildOptionsMock.mockResolvedValue({ + packageName: { + valid: false, + level: 'error', + message: 'Invalid package name specified', + // 没有 fixedValue —— 用户传的值非法且平台无 default + }, + }); + + const result = await verifyBuildOptions('android', {} as any); + + expect(result).not.toBeNull(); + expect(result!.code).toBe(BuildExitCode.PARAM_ERROR); + expect(result!.reason).toContain('packageName: Invalid package name specified'); + }); + + 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(); + // 应该传空对象进去,而不是 undefined + expect(checkBuildOptionsMock).toHaveBeenCalledWith('windows', {}); + }); +}); 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); } From e43290930a41566339056608c0bfe2b0ff2d9c9c Mon Sep 17 00:00:00 2001 From: qiuguohua Date: Wed, 19 Aug 2026 17:32:36 +0800 Subject: [PATCH 2/9] fix(android): validate keystore fields when useDebugKeystore is off Editor's getVerifyMap for android checks keystorePath / keystorePassword / keystoreAlias / keystoreAliasPassword must be non-empty when useDebugKeystore is false. CLI's android platform config had no verifyRules for these fields, so releases with a missing custom keystore silently proceeded until the packaging step failed. Add a shared 'keystoreRequired' rule to android verifyRuleMap that short-circuits when useDebugKeystore is true; wire it into the four keystore fields via verifyRules. Regex packageName rule unchanged. --- src/core/builder/platforms/android/src/config.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/core/builder/platforms/android/src/config.ts b/src/core/builder/platforms/android/src/config.ts index 3a6af9b54..dc6867e66 100644 --- a/src/core/builder/platforms/android/src/config.ts +++ b/src/core/builder/platforms/android/src/config.ts @@ -28,6 +28,17 @@ const config: IPlatformBuildPluginConfig = { }, message: 'Invalid package name specified', }, + // 当 useDebugKeystore=false 时,keystore 相关字段不能为空 + // 迁移自 editor 的 getVerifyMap(keystorePath/Password/Alias/AliasPassword) + keystoreRequired: { + func: (value: unknown, options: any) => { + if (options?.packages?.android?.useDebugKeystore) { + return true; + } + return value !== null && value !== undefined && value !== ''; + }, + message: 'Required when useDebugKeystore is disabled', + }, }, options: { ...baseNativeCommonOptions, @@ -127,21 +138,25 @@ const config: IPlatformBuildPluginConfig = { label: 'i18n:android.KEYSTORE.keystore_path', type: 'string', default: '', + verifyRules: ['keystoreRequired'], }, keystorePassword: { label: 'i18n:android.KEYSTORE.keystore_password', type: 'string', default: '', + verifyRules: ['keystoreRequired'], }, keystoreAlias: { label: 'i18n:android.KEYSTORE.keystore_alias', type: 'string', default: '', + verifyRules: ['keystoreRequired'], }, keystoreAliasPassword: { label: 'i18n:android.KEYSTORE.keystore_alias_password', type: 'string', default: '', + verifyRules: ['keystoreRequired'], }, appBundle: { label: 'i18n:android.options.app_bundle', From 6a2f62fd011618700f2d647cb91ee0a419fb56d3 Mon Sep 17 00:00:00 2001 From: qiuguohua Date: Wed, 19 Aug 2026 17:33:17 +0800 Subject: [PATCH 3/9] fix(google-play): validate keystore fields when useDebugKeystore is off Mirror of the android change: editor's google-play getVerifyMap requires keystorePath / keystorePassword / keystoreAlias / keystoreAliasPassword to be non-empty when useDebugKeystore is false. CLI had no verifyRules for these fields. Add 'keystoreRequired' rule reading options.packages['google-play'].useDebugKeystore, wire it into the four keystore fields via verifyRules. --- .../builder/platforms/google-play/src/config.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/core/builder/platforms/google-play/src/config.ts b/src/core/builder/platforms/google-play/src/config.ts index 6085f1d9f..155fb93a0 100644 --- a/src/core/builder/platforms/google-play/src/config.ts +++ b/src/core/builder/platforms/google-play/src/config.ts @@ -28,6 +28,17 @@ const config: IPlatformBuildPluginConfig = { }, message: 'Invalid package name specified', }, + // 当 useDebugKeystore=false 时,keystore 相关字段不能为空 + // 迁移自 editor 的 getVerifyMap(keystorePath/Password/Alias/AliasPassword) + keystoreRequired: { + func: (value: unknown, options: any) => { + if (options?.packages?.['google-play']?.useDebugKeystore) { + return true; + } + return value !== null && value !== undefined && value !== ''; + }, + message: 'Required when useDebugKeystore is disabled', + }, }, options: { swappy: { @@ -102,21 +113,25 @@ const config: IPlatformBuildPluginConfig = { label: 'i18n:google-play.KEYSTORE.keystore_path', type: 'string', default: '', + verifyRules: ['keystoreRequired'], }, keystorePassword: { label: 'i18n:google-play.KEYSTORE.keystore_password', type: 'string', default: '', + verifyRules: ['keystoreRequired'], }, keystoreAlias: { label: 'i18n:google-play.KEYSTORE.keystore_alias', type: 'string', default: '', + verifyRules: ['keystoreRequired'], }, keystoreAliasPassword: { label: 'i18n:google-play.KEYSTORE.keystore_alias_password', type: 'string', default: '', + verifyRules: ['keystoreRequired'], }, resizeableActivity: { label: 'i18n:google-play.options.resizeable_activity', From 05197fcc01ce5aa049855e8245eca204bf9f9e15 Mon Sep 17 00:00:00 2001 From: qiuguohua Date: Wed, 19 Aug 2026 17:33:51 +0800 Subject: [PATCH 4/9] fix(harmonyos-next): tighten packageName rule to match editor Editor's harmonyos-next packageName check has four gates: not empty, regex format, length 7..128, and no HarmonyOS reserved keywords in any segment (openharmony/harmonyos/harmony/system/ohos/oh). CLI only ran the regex, so package names like 'com.harmony.app' would pass the CLI and later fail at the HAP signing / AGC upload step. Extend the packageName rule func to include the length check and the token-aware keyword check (dot-segments, word-boundary, case-insensitive). appABIs rule unchanged. --- .../platforms/harmonyos-next/src/config.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/core/builder/platforms/harmonyos-next/src/config.ts b/src/core/builder/platforms/harmonyos-next/src/config.ts index 44d73ed05..ac67d10a3 100644 --- a/src/core/builder/platforms/harmonyos-next/src/config.ts +++ b/src/core/builder/platforms/harmonyos-next/src/config.ts @@ -21,7 +21,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', }, From 59be2be9f7df70b64c9e8b2f381ad47bdb50bc22 Mon Sep 17 00:00:00 2001 From: qiuguohua Date: Wed, 19 Aug 2026 17:34:31 +0800 Subject: [PATCH 5/9] fix(build): finish platform option validation and add coverage Entry-layer validation (verifyBuildOptions): - merge the platform defaults into the options before checking, so a field the caller omits falls back to its platform default instead of failing - an explicitly passed invalid value is now a hard failure (PARAM_ERROR, exit code 32). The previous "downgrade to a warning when the rule reports a fixedValue" path is removed, so a build can no longer start with a silently substituted value. Pass skipCheck to bypass validation entirely - resolve renderPipeline from the engine config: it is a project setting that checkProjectSetting only fills inside the build task, so any rule depending on it (apiLevelRenderPipeline) could never fire at the entry. This matches the editor, which reads the project profile directly Rules migrated from the editor verificationFunc / getVerifyMap into verifyRuleMap, the only validation entry the CLI has: - ios: targetVersionStyle / targetVersionTaskFlow / targetVersionMin, orientation, osTarget - windows, harmonyos-next: renderBackEnd (at least one supported backend) - ohos, harmonyos-next: orientation; harmonyos-next: deviceTypes, appABIs - harmonyos-next renderBackEnd now exposes vulkan / gles3 / gles2 and declares an object-level default, so the value is no longer undefined Fixes found while migrating: - android / google-play / huawei-agc: the linked gates (useDebugKeystore, JobSystem, resizeableActivity, androidInstant) read options.packages[options.platform] instead of a hardcoded packages.android. huawei-agc inherits the android config, so the hardcoded path made every huawei-agc build fail with PARAM_ERROR on the keystore rules - android / google-play: apiLevelTbb read packages.native.JobSystem, a path that does not exist in the CLI, so the rule never fired - ios: use a segment-wise version compare, the previous helper padded only the first separator and judged '9.10' >= '11.0' - validator-manager: empty values also reach rules whose name ends with Required, which conditional required rules need - google-play: log why the API level list is empty (no SDK path, no platforms directory, or nothing installed at or above the Google Play minimum API level 24) Tests: platform rule specs, an entry-layer integration spec that drives the real pluginManager with the real platform configs, and the validator empty-value short-circuit convention. --- src/core/builder/index.ts | 30 +- .../builder/platforms/android/src/config.ts | 149 ++++- .../builder/platforms/android/src/hooks.ts | 9 +- .../platforms/google-play/src/config.ts | 136 +++- .../platforms/google-play/src/hooks.ts | 11 +- .../platforms/google-play/src/utils.ts | 50 -- .../google-play/src/view/build-config-host.ts | 27 +- .../platforms/harmonyos-next/src/config.ts | 60 +- .../platforms/harmonyos-next/src/type.ts | 4 +- src/core/builder/platforms/ios/src/config.ts | 65 +- src/core/builder/platforms/ohos/src/config.ts | 11 + .../builder/platforms/windows/src/config.ts | 13 + src/core/builder/share/validator-manager.ts | 6 +- .../test/platform-verify-rules.spec.ts | 580 ++++++++++++++++++ .../validator-manager-shortcircuit.spec.ts | 82 +++ .../verify-build-options-integration.spec.ts | 212 +++++++ .../builder/test/verify-build-options.spec.ts | 55 +- 17 files changed, 1362 insertions(+), 138 deletions(-) create mode 100644 src/core/builder/test/platform-verify-rules.spec.ts create mode 100644 src/core/builder/test/validator-manager-shortcircuit.spec.ts create mode 100644 src/core/builder/test/verify-build-options-integration.spec.ts diff --git a/src/core/builder/index.ts b/src/core/builder/index.ts index 5365b8389..28a805240 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,7 @@ 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'; export { clearCache } from './cache'; export type { BuildCacheScope, ClearCacheResult } from './cache'; @@ -33,7 +34,10 @@ export async function init(platform?: string[]) { * 使用平台注册的 verifyRules 对构建参数做严格校验。 * 仅供 api 层(CLI/MCP)在调用 build() 前显式调用;Pink 走自己的 UI 校验,不会经过这里。 * skipCheck 为 true 时跳过。 - * 通过返回 null;不通过返回 { code: PARAM_ERROR, reason }。 + * + * 语义:先把平台 default 合进 options 再校验——用户漏传的字段会用平台默认值兜底通过; + * 只有用户明确传了非法值、或字段本身默认值就不合法(例如 iOS/Mac 的 packageName 默认空但 required) + * 时才会失败。任何 rule 失败(除 level='warn' 显式声明的)都硬阻塞,返回 { code: PARAM_ERROR, reason }。 */ export async function verifyBuildOptions( platform: string, @@ -43,7 +47,23 @@ export async function verifyBuildOptions( return null; } try { - const results = await pluginManager.checkBuildOptions(platform, (options || {}) as any); + // 与 createBuildTask 里 checkOptions 一致:先用平台 default 兜住漏传字段 + const defaultOptions = await pluginManager.getOptionsByPlatform(platform); + const merged = defaultsDeep(JSON.parse(JSON.stringify(options || {})), defaultOptions); + merged.platform = platform; + // 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)) { @@ -52,9 +72,7 @@ export async function verifyBuildOptions( continue; } const line = ` - ${key}: ${r.message || 'invalid'}`; - // 参考 createBuildTask 里 checkOptions 的做法:如果规则返回了 fixedValue(通常是平台配置的 default), - // 说明用户漏传/传错但平台自带 fallback,此时降级为 warn,不阻塞构建;仅在 fixedValue 缺失(不可修复)时才 error。 - if (r.level === 'warn' || r.fixedValue !== undefined) { + if (r.level === 'warn') { warnings.push(line); } else { errors.push(line); diff --git a/src/core/builder/platforms/android/src/config.ts b/src/core/builder/platforms/android/src/config.ts index dc6867e66..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,16 +34,133 @@ const config: IPlatformBuildPluginConfig = { }, message: 'Invalid package name specified', }, - // 当 useDebugKeystore=false 时,keystore 相关字段不能为空 - // 迁移自 editor 的 getVerifyMap(keystorePath/Password/Alias/AliasPassword) + // 当 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 (options?.packages?.android?.useDebugKeystore) { + 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 value !== null && value !== undefined && value !== ''; + return typeof value === 'string' && value.startsWith('http'); }, - message: 'Required when useDebugKeystore is disabled', + message: 'remoteUrl should start with http when androidInstant is enabled', }, }, options: { @@ -73,6 +196,7 @@ const config: IPlatformBuildPluginConfig = { gles3: true, gles2: true, }, + verifyRules: ['renderBackEnd'], }, packageName: { label: 'i18n:android.options.package_name', @@ -84,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', @@ -92,6 +217,7 @@ const config: IPlatformBuildPluginConfig = { items: { type: 'string' }, default: ['arm64-v8a'], hidden: true, + verifyRules: ['appABIs'], }, resizeableActivity: { label: 'i18n:android.options.resizeable_activity', @@ -102,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', @@ -128,6 +255,7 @@ const config: IPlatformBuildPluginConfig = { landscapeRight: true, landscapeLeft: true, }, + verifyRules: ['orientation'], }, useDebugKeystore: { label: 'i18n:android.KEYSTORE.use_debug_keystore', @@ -138,25 +266,25 @@ const config: IPlatformBuildPluginConfig = { label: 'i18n:android.KEYSTORE.keystore_path', type: 'string', default: '', - verifyRules: ['keystoreRequired'], + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, keystorePassword: { label: 'i18n:android.KEYSTORE.keystore_password', type: 'string', default: '', - verifyRules: ['keystoreRequired'], + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, keystoreAlias: { label: 'i18n:android.KEYSTORE.keystore_alias', type: 'string', default: '', - verifyRules: ['keystoreRequired'], + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, keystoreAliasPassword: { label: 'i18n:android.KEYSTORE.keystore_alias_password', type: 'string', default: '', - verifyRules: ['keystoreRequired'], + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, appBundle: { label: 'i18n:android.options.app_bundle', @@ -180,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 155fb93a0..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,16 +33,121 @@ const config: IPlatformBuildPluginConfig = { }, message: 'Invalid package name specified', }, - // 当 useDebugKeystore=false 时,keystore 相关字段不能为空 - // 迁移自 editor 的 getVerifyMap(keystorePath/Password/Alias/AliasPassword) + // 当 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 (options?.packages?.['google-play']?.useDebugKeystore) { + 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 value !== null && value !== undefined && value !== ''; + return typeof value === 'string' && value.startsWith('http'); }, - message: 'Required when useDebugKeystore is disabled', + message: 'remoteUrl should start with http when androidInstant is enabled', }, }, options: { @@ -79,6 +189,7 @@ const config: IPlatformBuildPluginConfig = { gles2: true, }, hidden: true, + verifyRules: ['renderBackEnd'], }, packageName: { label: 'i18n:google-play.options.package_name', @@ -95,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', @@ -103,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', @@ -113,25 +226,25 @@ const config: IPlatformBuildPluginConfig = { label: 'i18n:google-play.KEYSTORE.keystore_path', type: 'string', default: '', - verifyRules: ['keystoreRequired'], + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, keystorePassword: { label: 'i18n:google-play.KEYSTORE.keystore_password', type: 'string', default: '', - verifyRules: ['keystoreRequired'], + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, keystoreAlias: { label: 'i18n:google-play.KEYSTORE.keystore_alias', type: 'string', default: '', - verifyRules: ['keystoreRequired'], + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, keystoreAliasPassword: { label: 'i18n:google-play.KEYSTORE.keystore_alias_password', type: 'string', default: '', - verifyRules: ['keystoreRequired'], + verifyRules: ['keystoreRequired', 'keystoreNotEmpty'], }, resizeableActivity: { label: 'i18n:google-play.options.resizeable_activity', @@ -143,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', @@ -176,6 +290,7 @@ const config: IPlatformBuildPluginConfig = { landscapeLeft: true, }, hidden: true, + verifyRules: ['orientation'], }, appBundle: { label: 'i18n:google-play.options.app_bundle', @@ -205,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 ac67d10a3..7424422e8 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', @@ -44,6 +51,26 @@ 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; + } + 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)', + }, + 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, @@ -52,27 +79,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', @@ -139,6 +167,7 @@ const config: IPlatformBuildPluginConfig = { landscapeRight: true, landscapeLeft: true, }, + verifyRules: ['orientation'], }, deviceTypes: { default: { @@ -146,6 +175,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..7a2aedf86 --- /dev/null +++ b/src/core/builder/test/platform-verify-rules.spec.ts @@ -0,0 +1,580 @@ +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); + expect(rules.renderBackEnd.func({ vulkan: true }, {})).toBe(true); + expect(rules.renderBackEnd.func({ gles2: true }, {})).toBe(true); + 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 index c89386cf6..e2858cbe9 100644 --- a/src/core/builder/test/verify-build-options.spec.ts +++ b/src/core/builder/test/verify-build-options.spec.ts @@ -1,8 +1,10 @@ const checkBuildOptionsMock = jest.fn(); +const getOptionsByPlatformMock = jest.fn(async () => ({})); jest.mock('../manager/plugin', () => ({ pluginManager: { checkBuildOptions: checkBuildOptionsMock, + getOptionsByPlatform: getOptionsByPlatformMock, }, })); @@ -36,6 +38,8 @@ describe('verifyBuildOptions', () => { beforeEach(() => { checkBuildOptionsMock.mockReset(); + getOptionsByPlatformMock.mockReset(); + getOptionsByPlatformMock.mockResolvedValue({}); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); }); @@ -49,6 +53,7 @@ describe('verifyBuildOptions', () => { const result = await verifyBuildOptions('windows', { skipCheck: true } as any); expect(result).toBeNull(); expect(checkBuildOptionsMock).not.toHaveBeenCalled(); + expect(getOptionsByPlatformMock).not.toHaveBeenCalled(); }); it('所有字段合法时返回 null', async () => { @@ -91,39 +96,53 @@ describe('verifyBuildOptions', () => { expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('outputName: auto filled')); }); - it('error 但带 fixedValue 时降级为 warn(平台 default 可兜底,不阻塞构建)', async () => { + it('error 都硬阻塞,fixedValue 不再降级(区别于之前的语义)', async () => { checkBuildOptionsMock.mockResolvedValue({ - // 模拟 android: 用户没传 packageName,规则失败但平台 default='com.cocos.game' 作为 fixedValue 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).toBeNull(); - expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('packageName: Required')); - expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(result).not.toBeNull(); + expect(result!.code).toBe(BuildExitCode.PARAM_ERROR); + expect(result!.reason).toContain('packageName: Required'); }); - it('error 且没有 fixedValue 时才真正阻塞构建', async () => { - checkBuildOptionsMock.mockResolvedValue({ - packageName: { - valid: false, - level: 'error', - message: 'Invalid package name specified', - // 没有 fixedValue —— 用户传的值非法且平台无 default - }, + 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', {} as any); + const result = await verifyBuildOptions('android', { platform: 'android' } as any); + expect(result).toBeNull(); + expect(getOptionsByPlatformMock).toHaveBeenCalledWith('android'); + }); - expect(result).not.toBeNull(); - expect(result!.code).toBe(BuildExitCode.PARAM_ERROR); - expect(result!.reason).toContain('packageName: Invalid package name specified'); + 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 () => { @@ -145,7 +164,5 @@ describe('verifyBuildOptions', () => { checkBuildOptionsMock.mockResolvedValue({}); const result = await verifyBuildOptions('windows'); expect(result).toBeNull(); - // 应该传空对象进去,而不是 undefined - expect(checkBuildOptionsMock).toHaveBeenCalledWith('windows', {}); }); }); From 81d938d0aed3438ba05e82090a0f181ef41d8103 Mon Sep 17 00:00:00 2001 From: qiuguohua Date: Thu, 20 Aug 2026 18:54:01 +0800 Subject: [PATCH 6/9] test(scene): expose Scene in cc mock for prefab UI handling suite PR #872 added `if (node instanceof Scene)` in scene-process/service/component.ts after PR #831 landed the test with a `jest.mock('cc', ...)` factory that had no Scene export. Both merged the same day, so the interaction only surfaced on main. Add Scene to the factory so the instanceof check resolves. --- .../scene/test/service-core/component-prefab-ui-handling.test.ts | 1 + 1 file changed, 1 insertion(+) 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, From b51f5ef21fb29bdab3205f2d7e00b83dc0432c39 Mon Sep 17 00:00:00 2001 From: qiuguohua Date: Fri, 21 Aug 2026 10:10:51 +0800 Subject: [PATCH 7/9] fix(build): fill runtime-computed defaults before entry-level verify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verifyBuildOptions was rejecting builds whose common options relied on values computed from project state at build time: - taskName defaults to '' with verifyRules ['required']; createBuildTask falls back to platform, but that fallback runs inside build() — after verifyBuildOptions had already returned PARAM_ERROR. - startScene / scenes defaults are '' / [] but the real defaults come from asset-db via getDefaultStartScene / getDefaultScenes; the old checkOptions path recovered via fixedValue, which the new entry gate intentionally does not consume. Fill these three fields on the merged options before running the schema check, matching what the downstream build path already does. Wrap the asset-db calls in try/catch so unit / uninitialized-engine paths keep working. Add unit coverage for the taskName / startScene / scenes normalization, including the user-provided values still winning and the asset-db-throws fallback path. --- src/core/builder/index.ts | 24 ++++++ .../builder/test/verify-build-options.spec.ts | 86 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/src/core/builder/index.ts b/src/core/builder/index.ts index 28a805240..e5113acce 100644 --- a/src/core/builder/index.ts +++ b/src/core/builder/index.ts @@ -14,6 +14,7 @@ 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'; @@ -51,6 +52,29 @@ export async function verifyBuildOptions( 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) { diff --git a/src/core/builder/test/verify-build-options.spec.ts b/src/core/builder/test/verify-build-options.spec.ts index e2858cbe9..67ccf5524 100644 --- a/src/core/builder/test/verify-build-options.spec.ts +++ b/src/core/builder/test/verify-build-options.spec.ts @@ -1,5 +1,7 @@ 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: { @@ -24,6 +26,12 @@ jest.mock('../../base/console', () => ({ })); 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'; @@ -40,6 +48,10 @@ describe('verifyBuildOptions', () => { 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(() => {}); }); @@ -165,4 +177,78 @@ describe('verifyBuildOptions', () => { 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(); + }); }); From 520ea768eed62667b955b7cad492fd58fa222ed7 Mon Sep 17 00:00:00 2001 From: qiuguohua Date: Fri, 21 Aug 2026 15:04:47 +0800 Subject: [PATCH 8/9] docs(harmonyos-next): note why renderBackEnd only allows gles3 vulkan / gles2 haven't been validated on HarmonyOS Next yet, so renderBackEnd's verifyRule intentionally keeps 'supported' at ['gles3']. Leave the full ['vulkan', 'gles3', 'gles2'] list in a comment as a reminder to open it back up once those two backends are proven stable. --- src/core/builder/platforms/harmonyos-next/src/config.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/builder/platforms/harmonyos-next/src/config.ts b/src/core/builder/platforms/harmonyos-next/src/config.ts index 7424422e8..4606d74f0 100644 --- a/src/core/builder/platforms/harmonyos-next/src/config.ts +++ b/src/core/builder/platforms/harmonyos-next/src/config.ts @@ -57,11 +57,13 @@ const config: IPlatformBuildPluginConfig = { if (!value || typeof value !== 'object') { return false; } - const supported = ['vulkan', 'gles3', 'gles2']; + // 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 (vulkan / gles3 / gles2)', + message: 'renderBackEnd must have at least one supported backend enabled (gles3)', }, orientation: { func: hasEnabledEntry, From 6ec2e564eab41d7cf4320c0fadbbf1712a9c5e8b Mon Sep 17 00:00:00 2001 From: qiuguohua Date: Fri, 21 Aug 2026 15:16:15 +0800 Subject: [PATCH 9/9] test(harmonyos-next): align renderBackEnd spec with gles3-only supported list The docs commit narrowed renderBackEnd's `supported` array to ['gles3'] but this spec still expected vulkan / gles2 alone to pass. Flip those two assertions to false and leave a comment pointing at where the expectation should be opened back up when vulkan / gles2 are validated. --- src/core/builder/test/platform-verify-rules.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/core/builder/test/platform-verify-rules.spec.ts b/src/core/builder/test/platform-verify-rules.spec.ts index 7a2aedf86..51c9e5fd1 100644 --- a/src/core/builder/test/platform-verify-rules.spec.ts +++ b/src/core/builder/test/platform-verify-rules.spec.ts @@ -554,8 +554,10 @@ describe('platform verifyRuleMap migrated from editor', () => { it('三条规则都是"至少开一项"', () => { expect(rules.renderBackEnd.func({ vulkan: false, gles3: false, gles2: false }, {})).toBe(false); expect(rules.renderBackEnd.func({ gles3: true }, {})).toBe(true); - expect(rules.renderBackEnd.func({ vulkan: true }, {})).toBe(true); - expect(rules.renderBackEnd.func({ gles2: 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);