Files
Genarrative/apps/ai-game-creator-shell/scripts/build-release.test.mjs
suzmii 16c905b51b
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 7m21s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m27s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 7m28s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m46s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m53s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m14s
Project CI / Backend tests (pull_request) Successful in 8m6s
Project CI / Native shell tests (pull_request) Successful in 8m11s
Project CI / Repository checks (pull_request) Successful in 5m57s
Project CI / Frontend tests (pull_request) Successful in 7m53s
Project CI / AI game creator shell web tests (pull_request) Successful in 4m38s
修复发布目标与插件能力门禁并同步版本0.1.67
统一显式目标对应的版本读取、构建渠道、产物目录和更新清单
无原生适配器时隐藏Cocos插件并阻止自动启动
同步单架构更新规范并增加发布和插件回归测试
更新版本与锁文件到0.1.67并记录Mac安装包验证结果
2026-09-18 12:15:00 +08:00

723 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
import {
agcReleasePathPatterns,
buildRelease,
buildTauriBuildArguments,
collectRecentReleaseCommits,
collectReleaseCommits,
compareVersions,
createChannelConfig,
createLegacyUpdateManifest,
createUpdateManifest,
formatRecentReleaseNotes,
formatReleaseNotes,
generateUpdateManifest,
nextPatchVersion,
resolveManifestPlatformKeys,
resolvePreviousReleaseCommit,
resolveReleaseChannel,
resolveReleaseContext,
resolveRemoteHighWaterVersion,
runTauriBuild,
selectReleaseArtifact,
updateManifestUrl,
} from './build-release.mjs';
const windowsTarget = 'x86_64-pc-windows-msvc';
const universalTarget = 'universal-apple-darwin';
test('native sidecar builds reject universal targets and accept each macOS architecture', () => {
assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/);
assert.throws(
() => buildTauriBuildArguments(['--target=universal-apple-darwin']),
/单架构/,
);
for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) {
assert.deepEqual(buildTauriBuildArguments([], target), [
'build',
'--target',
target,
]);
}
});
function withEnv(overrides, run) {
const previous = new Map();
for (const [key, value] of Object.entries(overrides)) {
previous.set(key, process.env[key]);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
try {
return run();
} finally {
for (const [key, value] of previous) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}
function withSignedArtifact(fileName, run) {
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-'));
try {
const artifact = path.join(directory, fileName);
writeFileSync(artifact, 'installation package');
writeFileSync(`${artifact}.sig`, 'signature-content\n');
return run(artifact);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
function jsonResponse(body, status = 200) {
return {
status,
ok: status >= 200 && status < 300,
json: async () => body,
};
}
function withStubbedFetch(handler, run) {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url) => handler(String(url));
return Promise.resolve(run()).finally(() => {
globalThis.fetch = originalFetch;
});
}
test('selects an explicit release artifact when configured', () => {
const artifactPath = fileURLToPath(
new URL('../package.json', import.meta.url),
);
withEnv({ AGC_UPDATE_ARTIFACT: artifactPath }, () => {
assert.equal(selectReleaseArtifact([]), artifactPath);
});
});
test('does not select unsupported files', () => {
assert.equal(
selectReleaseArtifact(['/tmp/latest.json', '/tmp/readme.txt']),
null,
);
});
test('resolves the channel from the target platform and rejects mismatches', () => {
assert.equal(resolveReleaseChannel({}, windowsTarget), 'dev-win');
assert.equal(resolveReleaseChannel({}, universalTarget), 'dev-mac');
assert.equal(
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, universalTarget),
'dev-mac',
);
assert.throws(
() =>
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, windowsTarget),
/只能用于 darwin 目标/u,
);
assert.throws(
() =>
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'beta-win' }, windowsTarget),
/未知发布渠道/u,
);
});
test('channel manifest URL and build-time endpoint follow the channel', () => {
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
assert.equal(
updateManifestUrl('dev-win'),
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
);
assert.deepEqual(createChannelConfig('dev-mac'), {
plugins: {
updater: {
endpoints: [
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json',
],
},
},
});
});
});
test('macOS manifests only advertise the architecture actually built', () => {
assert.throws(() => resolveManifestPlatformKeys(universalTarget), /单架构/);
assert.deepEqual(resolveManifestPlatformKeys('aarch64-apple-darwin'), [
'darwin-aarch64',
]);
assert.deepEqual(resolveManifestPlatformKeys('x86_64-apple-darwin'), [
'darwin-x86_64',
]);
assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [
'windows-x86_64',
]);
});
test('release context resolves explicit targets before environment/default and fails closed', () => {
for (const args of [
['--target', 'aarch64-apple-darwin'],
['--target=aarch64-apple-darwin'],
['-t', 'aarch64-apple-darwin'],
]) {
for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) {
const context = resolveReleaseContext(args, env);
assert.equal(context.target, 'aarch64-apple-darwin');
assert.equal(context.channel, 'dev-mac');
assert.match(
context.bundleRoot.replaceAll('\\', '/'),
/target\/aarch64-apple-darwin\/release\/bundle$/,
);
assert.ok(Object.isFrozen(context));
}
assert.throws(
() => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }),
/只能用于 windows/,
);
}
assert.equal(resolveReleaseContext([], {}).target, windowsTarget);
assert.equal(
resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' })
.channel,
'dev-mac',
);
for (const args of [
['--target'],
['--target='],
['--target', '--no-bundle'],
['--target', windowsTarget, '--target=aarch64-apple-darwin'],
['--target', universalTarget],
['--target', 'unknown'],
])
assert.throws(() => resolveReleaseContext(args, {}));
});
test('explicit macOS target drives version lookup, Tauri endpoint, artifact and manifest together', async () => {
const calls = [];
const seenContexts = [];
await withStubbedFetch(
(url) => {
calls.push(url);
assert.match(url, /\/dev-mac\/latest\.json$/);
return jsonResponse({ version: '0.1.67' });
},
() =>
withEnv(
{ AGC_BUILD_TARGET: undefined, AGC_UPDATE_CHANNEL: undefined },
() =>
buildRelease(['--target', 'aarch64-apple-darwin'], {
prepareVersion: async (context) => {
seenContexts.push(context);
assert.equal(
await resolveRemoteHighWaterVersion(context.channel),
'0.1.67',
);
},
build: (args, context) => {
seenContexts.push(context);
runTauriBuild(args, context, {
spawn: (_binary, command) => {
const configIndex = command.lastIndexOf('--config');
const config = JSON.parse(
readFileSync(command[configIndex + 1], 'utf8'),
);
assert.match(
config.plugins.updater.endpoints[0],
/\/dev-mac\/latest\.json$/,
);
assert.ok(command.includes('aarch64-apple-darwin'));
assert.ok(
!command.includes('--features=cocos-editor-execute'),
);
return { status: 0 };
},
});
},
generateManifest: (context) => {
seenContexts.push(context);
withSignedArtifact('陶泥儿.app.tar.gz', (artifact) => {
assert.equal(
selectReleaseArtifact(
['/tmp/win.exe', artifact, '/tmp/mac.dmg'],
context.target,
),
artifact,
);
const manifest = createUpdateManifest(artifact, context);
assert.deepEqual(Object.keys(manifest.platforms), [
'darwin-aarch64',
]);
assert.match(
manifest.platforms['darwin-aarch64'].url,
/\/dev-mac\//,
);
});
},
}),
),
);
assert.equal(calls.length, 1, 'Mac 不应读取 Windows 迁移指针');
assert.equal(seenContexts.length, 3);
assert.ok(seenContexts.every((context) => context === seenContexts[0]));
});
test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-'));
try {
const artifact = path.join(root, '陶泥儿.app.tar.gz');
writeFileSync(artifact, 'mac package');
writeFileSync(`${artifact}.sig`, 'mac signature');
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
const context = {
...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}),
bundleRoot: root,
};
const result = await withStubbedFetch(
(url) => {
assert.match(url, /\/dev-mac\/latest\.json$/);
return jsonResponse({}, 404);
},
() => generateUpdateManifest(context),
);
assert.equal(result.artifact, artifact);
assert.equal(result.manifestPath, path.join(root, 'latest.json'));
assert.equal(result.legacyManifestPath, null);
assert.deepEqual(Object.keys(result.manifest.platforms), ['darwin-x86_64']);
assert.match(result.manifest.platforms['darwin-x86_64'].url, /\/dev-mac\//);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('invalid target or mismatched channel fails before any release side effect', async () => {
let touched = false;
const sideEffects = {
prepareVersion: () => {
touched = true;
},
build: () => {
touched = true;
},
generateManifest: () => {
touched = true;
},
};
await assert.rejects(
() => buildRelease(['--target', universalTarget], sideEffects),
/单架构/,
);
await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () =>
assert.rejects(
() => buildRelease(['--target=aarch64-apple-darwin'], sideEffects),
/只能用于 windows/,
),
);
assert.equal(touched, false);
});
test('Windows remains the default and explicit Windows overrides macOS environment', () => {
const files = ['/tmp/mac.app.tar.gz', '/tmp/windows.exe', '/tmp/mac.dmg'];
for (const context of [
resolveReleaseContext([], {}),
resolveReleaseContext(['--target', windowsTarget], {
AGC_BUILD_TARGET: 'aarch64-apple-darwin',
}),
]) {
assert.equal(context.channel, 'dev-win');
assert.equal(
selectReleaseArtifact(files, context.target),
'/tmp/windows.exe',
);
runTauriBuild(
['--target', windowsTarget, '--config', 'user-config.json'],
context,
{
spawn: (_binary, command) => {
assert.ok(command.includes('--features=cocos-editor-execute'));
assert.ok(command.includes('user-config.json'));
const configIndex = command.lastIndexOf('--config');
const config = JSON.parse(
readFileSync(command[configIndex + 1], 'utf8'),
);
assert.match(
config.plugins.updater.endpoints[0],
/\/dev-win\/latest\.json$/,
);
return { status: 0 };
},
},
);
}
});
test('no-bundle smoke skips version writes and manifest generation', async () => {
const steps = [];
await buildRelease(['--no-bundle', '--target=aarch64-apple-darwin'], {
prepareVersion: () => {
steps.push('version');
},
build: (_args, context) => {
steps.push(context.channel);
},
generateManifest: () => {
steps.push('manifest');
},
});
assert.deepEqual(steps, ['dev-mac']);
});
test('channel manifest carries version, platform keys and signature', () => {
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
const manifest = createUpdateManifest(artifact, {
channel: 'dev-win',
target: windowsTarget,
publishedAt: '2026-09-17T00:00:00.000Z',
});
assert.match(manifest.version, /^\d+\.\d+\.\d+$/u);
assert.equal(manifest.notes, '修复与改进');
assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z');
assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']);
assert.equal(
manifest.platforms['windows-x86_64'].signature,
'signature-content',
);
assert.match(
manifest.platforms['windows-x86_64'].url,
new RegExp(`/agc/dev-win/${manifest.version}/`, 'u'),
);
});
});
});
test('missing signature fails the channel manifest closed', () => {
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-'));
try {
const artifact = path.join(directory, '陶泥儿_0.1.48_x64-setup.exe');
writeFileSync(artifact, 'installation package');
assert.throws(
() =>
createUpdateManifest(artifact, {
channel: 'dev-win',
target: windowsTarget,
}),
/缺少更新包签名/u,
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test('legacy manifest keeps the sha256 contract of published clients', () => {
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
const legacy = createLegacyUpdateManifest(artifact, {
channel: 'dev-win',
});
assert.match(legacy.version, /^\d+\.\d+\.\d+$/u);
assert.equal(legacy.sha256.length, 64);
assert.equal(legacy.size, 'installation package'.length);
assert.match(legacy.downloadUrl, /\/agc\/dev-win\/[\d.]+\//u);
});
});
test('next release version follows the higher local or channel version', () => {
assert.equal(compareVersions('0.1.15', '0.1.12'), 1);
assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16');
assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19');
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
});
test('version high water keeps the legacy pointer during the migration window', async () => {
await withStubbedFetch(
(url) =>
url.endsWith('/agc/dev-win/latest.json')
? jsonResponse({}, 404)
: jsonResponse({ version: '0.1.57' }),
async () => {
assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57');
// 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。
assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58');
},
);
});
test('version high water takes the higher of channel and legacy pointer', async () => {
await withStubbedFetch(
(url) =>
url.endsWith('/agc/dev-win/latest.json')
? jsonResponse({ version: '0.1.60' })
: jsonResponse({ version: '0.1.57' }),
async () => {
assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60');
},
);
});
test('version high water ignores the windows migration pointer for other channels', async () => {
await withStubbedFetch(
(url) => {
assert.ok(
!url.endsWith('/agc/latest.json'),
'non-windows channel must not read the windows migration pointer',
);
return jsonResponse({ version: '0.1.12' });
},
async () => {
assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12');
},
);
});
test('release notes anchor prefers the explicit commit and falls back to the manifest', async () => {
await withStubbedFetch(
() => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }),
async () => {
assert.equal(
await resolvePreviousReleaseCommit('dev-win', {
override: '6017d46088c04199e99cf89f347b12d67591475e',
}),
'6017d46088c04199e99cf89f347b12d67591475e',
);
// 覆盖值非法时忽略,继续用清单里的 commit。
assert.equal(
await resolvePreviousReleaseCommit('dev-win', {
override: 'not-a-sha',
}),
'abcdef1234567890',
);
assert.equal(
await resolvePreviousReleaseCommit('dev-win', { override: ' ' }),
'abcdef1234567890',
);
},
);
await withStubbedFetch(
() => jsonResponse({ version: '0.1.61' }),
async () => {
assert.equal(
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
null,
);
},
);
});
test('release notes anchor degrades to null when the manifest cannot be read', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
throw new Error('fetch failed');
};
try {
assert.equal(
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
null,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('recent commit fallback marks that entries may repeat the previous release', () => {
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-recent-git-'));
const git = (...args) =>
execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
try {
git('init', '--quiet');
git('config', 'user.email', 'release@example.test');
git('config', 'user.name', 'release test');
mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
recursive: true,
});
for (const name of ['one', 'two']) {
writeFileSync(
path.join(directory, `apps/ai-game-creator-shell/${name}.rs`),
`fn ${name}() {}\n`,
);
git('add', '.');
git('commit', '--quiet', '-m', `客户端:${name}`);
}
writeFileSync(path.join(directory, 'README.md'), '# 文档\n');
git('add', '.');
git('commit', '--quiet', '-m', '文档:说明');
const recent = collectRecentReleaseCommits({ cwd: directory, limit: 5 });
assert.deepEqual(
recent.map((entry) => entry.subject),
['客户端:two', '客户端:one'],
);
const notes = formatRecentReleaseNotes(recent);
assert.match(
notes,
/^最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n- 客户端:two/u,
);
assert.equal(formatRecentReleaseNotes([]), '');
assert.equal(formatRecentReleaseNotes(null), '');
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test('release upload forces overwrite for artifact, signature and channel pointers', () => {
const source = readFileSync(
new URL('./release-upload.mjs', import.meta.url),
'utf8',
);
assert.equal(
(source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length,
4,
);
assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
assert.match(source, /agc\/latest\.json/u);
assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/u);
});
test('release notes list client commits with short sha and bound their size', () => {
const notes = formatReleaseNotes([
{ sha: 'a5fd25f1', subject: '客户端更新切换到官方更新插件' },
{ sha: '55af6014', subject: '修'.repeat(120) },
]);
const lines = notes.split('\n');
assert.equal(lines.length, 2);
assert.match(lines[0], /^- 客户端更新切换到官方更新插件(a5fd25f1)$/u);
const truncatedSubject = lines[1]
.replace(/^- /u, '')
.replace(/55af6014$/u, '');
assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`);
assert.match(truncatedSubject, /…$/u);
assert.match(lines[1], /55af6014$/u);
const many = formatReleaseNotes(
Array.from({ length: 20 }, (_, index) => ({
sha: `sha${index}`,
subject: `改动 ${index}`,
})),
);
assert.match(many, /- 其余 8 项客户端改动省略$/u);
assert.equal(formatReleaseNotes([]), '');
assert.equal(formatReleaseNotes(null), '');
});
test('release commits cover only client paths and skip merge commits', () => {
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-changelog-git-'));
const git = (...args) =>
execFileSync('git', args, { cwd: directory, encoding: 'utf8' });
try {
git('init', '--quiet');
git('config', 'user.email', 'release@example.test');
git('config', 'user.name', 'release test');
git('commit', '--allow-empty', '--quiet', '-m', '基点');
const base = git('rev-parse', 'HEAD').trim();
mkdirSync(path.join(directory, 'apps/ai-game-creator-shell'), {
recursive: true,
});
mkdirSync(path.join(directory, 'docs'), { recursive: true });
writeFileSync(
path.join(directory, 'apps/ai-game-creator-shell/main.rs'),
'fn main() {}\n',
);
git('add', '.');
git('commit', '--quiet', '-m', '客户端:新增更新插件接入');
writeFileSync(path.join(directory, 'docs/readme.md'), '# 文档\n');
git('add', '.');
git('commit', '--quiet', '-m', '文档:补充说明');
writeFileSync(
path.join(directory, 'apps/ai-game-creator-shell/other.rs'),
'fn other() {}\n',
);
git('add', '.');
git('commit', '--quiet', '-m', '客户端:修复版本回退');
git('checkout', '--quiet', '-b', 'side');
writeFileSync(
path.join(directory, 'apps/ai-game-creator-shell/side.rs'),
'fn side() {}\n',
);
git('add', '.');
git('commit', '--quiet', '-m', '客户端:侧分支改动');
git('checkout', '--quiet', 'master');
git('merge', '--quiet', '--no-ff', '--no-edit', 'side');
const commits = collectReleaseCommits(base, 'HEAD', { cwd: directory });
assert.ok(commits, '应能在临时仓库里收集提交');
const subjects = commits.map((entry) => entry.subject);
// 合并提交本身被 --no-merges 排除,但它带入的客户端改动仍然计入。
assert.deepEqual(subjects, [
'客户端:侧分支改动',
'客户端:修复版本回退',
'客户端:新增更新插件接入',
]);
assert.ok(commits.every((entry) => /^[0-9a-f]{7,}$/u.test(entry.sha)));
assert.equal(
collectReleaseCommits('1234567890abcdef', 'HEAD', { cwd: directory }),
null,
);
assert.equal(collectReleaseCommits(null, 'HEAD', { cwd: directory }), null);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test('scheduler path filter stays in sync with client release paths', () => {
const jenkinsfile = readFileSync(
new URL(
'../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
import.meta.url,
),
'utf8',
);
const filterLine = jenkinsfile
.split('\n')
.find((line) => line.includes('apps/ai-game-creator-shell/*|'));
assert.ok(filterLine, '调度管线里应存在发布范围过滤模式');
for (const pattern of agcReleasePathPatterns) {
const bashPattern = pattern.includes('/') ? `${pattern}*` : pattern;
assert.ok(
filterLine.includes(bashPattern),
`调度管线过滤缺少 ${bashPattern}`,
);
}
});
test('scheduler skips the full build only for non-deploy paths', () => {
const jenkinsfile = readFileSync(
new URL(
'../../../jenkins/Jenkinsfile.scheduled-revision-trigger',
import.meta.url,
),
'utf8',
);
const skipLine = jenkinsfile
.split('\n')
.find((line) => line.includes('docs/*|.codex/*|jenkins/*'));
assert.ok(skipLine, '调度管线里应存在 Full Build 跳过模式');
for (const pattern of [
'docs/*',
'.codex/*',
'jenkins/*',
'apps/ai-game-creator-shell/*',
'apps/mobile-shell/*',
'apps/desktop-shell/*',
'apps/preview-deployer-web/*',
'tools/*',
'*.md',
]) {
assert.ok(skipLine.includes(pattern), `Full Build 跳过模式缺少 ${pattern}`);
}
});