Merge remote-tracking branch 'origin/master' into fix/agc-recent-project-status-retry
This commit is contained in:
@@ -21,7 +21,7 @@ import { prepareNsisToolsetForRelease } from './nsis-toolset.mjs';
|
||||
import { stageNodeRuntime } from './stage-node-runtime.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
// 提交摘要里的 pathspec 与 `git log` 都以仓库根为基准,不能在应用目录里执行。
|
||||
// Git 命令用于读取 release revision,必须在仓库根执行,不能在应用目录里执行。
|
||||
const repoRoot = path.resolve(appRoot, '..', '..');
|
||||
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
|
||||
function defaultTarget() {
|
||||
@@ -96,8 +96,8 @@ const defaultOssBaseUrl =
|
||||
export { resolveReleaseChannel } from './channel-identity.mjs';
|
||||
|
||||
/**
|
||||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
|
||||
* 保持一致 —— `build-release.test.mjs` 有守卫用例逐条比对两边。
|
||||
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定必须与这里保持一致,
|
||||
* `build-release.test.mjs` 有守卫用例逐条比对两边。
|
||||
*/
|
||||
export const agcReleasePathPatterns = [
|
||||
'apps/ai-game-creator-shell/',
|
||||
@@ -228,43 +228,6 @@ async function readManifestVersion(manifestUrl, label) {
|
||||
: parseVersion(manifest?.version, `${label} version`);
|
||||
}
|
||||
|
||||
/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */
|
||||
async function readRemoteChannelManifest(channel, target) {
|
||||
return fetchManifest(updateManifestUrl(channel, target), 'OSS 渠道清单');
|
||||
}
|
||||
|
||||
/**
|
||||
* 摘要锚点:上次发布对应的提交。
|
||||
*
|
||||
* 首选渠道清单里的 `commit`(发布产物自己的事实来源);清单缺该字段时(首次启用
|
||||
* 摘要、或更换渠道后清单还没带过 commit)回退到 CI 传入的 `AGC_UPDATE_PREVIOUS_COMMIT`
|
||||
* —— 它是上一次成功构建的 COMMIT_HASH,同样指向用户拿到的那个版本。
|
||||
*/
|
||||
export async function resolvePreviousReleaseCommit(
|
||||
channel = resolveReleaseChannel(),
|
||||
{
|
||||
override = process.env.AGC_UPDATE_PREVIOUS_COMMIT,
|
||||
target = defaultTarget(),
|
||||
} = {},
|
||||
) {
|
||||
const explicit = override?.trim();
|
||||
if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) {
|
||||
return explicit;
|
||||
}
|
||||
try {
|
||||
const manifest = await readRemoteChannelManifest(channel, target);
|
||||
const commit =
|
||||
typeof manifest?.commit === 'string' ? manifest.commit.trim() : '';
|
||||
return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null;
|
||||
} catch (error) {
|
||||
// 摘要只是附注:清单读不到(网络抖动等)不能把发布带崩,降级为「没有锚点」。
|
||||
console.warn(
|
||||
`[ai-game-creator-shell] 读取摘要锚点失败,本次不写自动摘要:${error.message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本高水位:渠道清单与旧协议迁移指针取较大值。
|
||||
*
|
||||
@@ -632,7 +595,7 @@ export function createUpdateManifest(
|
||||
pub_date: publishedAt,
|
||||
platforms,
|
||||
downloads,
|
||||
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
|
||||
// 非标准字段:更新插件会忽略,仅保留源码 revision 供线上排障。
|
||||
...(commit ? { commit } : {}),
|
||||
};
|
||||
}
|
||||
@@ -648,129 +611,6 @@ function readHeadCommit() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Git 的 %s 会把「标题后紧接说明行、没有空行」的整个首段拼成一行;
|
||||
* 更新摘要只允许展示原始提交消息的第一行,避免把说明暴露给用户。
|
||||
*/
|
||||
function commitMessageTitle(message) {
|
||||
return String(message ?? '')
|
||||
.split(/\r?\n/u, 1)[0]
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** 解析 `git log -z --format=%h%x09%B`,只保留每个 commit 的消息首行。 */
|
||||
function parseReleaseCommitLog(output) {
|
||||
return output
|
||||
.split('\0')
|
||||
.map((record) => record.trimEnd())
|
||||
.filter(Boolean)
|
||||
.map((record) => {
|
||||
const separator = record.indexOf('\t');
|
||||
if (separator < 0) return null;
|
||||
const sha = record.slice(0, separator);
|
||||
const subject = commitMessageTitle(record.slice(separator + 1));
|
||||
return sha && subject ? { sha, subject } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上一次发布到本次之间的客户端相关提交。
|
||||
*
|
||||
* 返回 null 表示无法判定(没有上一次 commit,或本地没有该提交),此时不生成摘要。
|
||||
*/
|
||||
export function collectReleaseCommits(
|
||||
previousCommit,
|
||||
headCommit = 'HEAD',
|
||||
{ cwd = repoRoot, paths = agcReleasePathPatterns } = {},
|
||||
) {
|
||||
if (!previousCommit) return null;
|
||||
try {
|
||||
for (const revision of [previousCommit, headCommit]) {
|
||||
execFileSync('git', ['rev-parse', '--verify', `${revision}^{commit}`], {
|
||||
cwd,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let output;
|
||||
try {
|
||||
output = execFileSync(
|
||||
'git',
|
||||
[
|
||||
'log',
|
||||
'-z',
|
||||
'--no-merges',
|
||||
'--format=%h%x09%B',
|
||||
`${previousCommit}..${headCommit}`,
|
||||
'--',
|
||||
...paths,
|
||||
],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return parseReleaseCommitLog(output);
|
||||
}
|
||||
|
||||
/** 自动更新摘要:逐条列客户端相关改动标题,超过上限时折叠并整体截断。 */
|
||||
export function formatReleaseNotes(
|
||||
commits,
|
||||
{ limit = 12, subjectLength = 80, maxLength = 900 } = {},
|
||||
) {
|
||||
if (!commits || commits.length === 0) return '';
|
||||
const lines = commits.slice(0, limit).map(({ subject }) => {
|
||||
const title = commitMessageTitle(subject);
|
||||
const trimmed =
|
||||
title.length > subjectLength
|
||||
? `${title.slice(0, subjectLength - 1)}…`
|
||||
: title;
|
||||
return `- ${trimmed}`;
|
||||
});
|
||||
if (commits.length > limit) {
|
||||
lines.push(`- 其余 ${commits.length - limit} 项客户端改动省略`);
|
||||
}
|
||||
const text = lines.join('\n');
|
||||
return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
|
||||
}
|
||||
|
||||
/** 无锚点时的兜底:列出最近的客户端相关提交,并注明可能与上一版重复。 */
|
||||
export function collectRecentReleaseCommits({
|
||||
cwd = repoRoot,
|
||||
paths = agcReleasePathPatterns,
|
||||
limit = 8,
|
||||
} = {}) {
|
||||
let output;
|
||||
try {
|
||||
output = execFileSync(
|
||||
'git',
|
||||
[
|
||||
'log',
|
||||
'-z',
|
||||
'--no-merges',
|
||||
`-n${limit}`,
|
||||
'--format=%h%x09%B',
|
||||
'--',
|
||||
...paths,
|
||||
],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const commits = parseReleaseCommitLog(output);
|
||||
return commits.length > 0 ? commits : null;
|
||||
}
|
||||
|
||||
export function formatRecentReleaseNotes(commits) {
|
||||
const notes = formatReleaseNotes(commits, { limit: 8 });
|
||||
if (!notes) return '';
|
||||
return `最近客户端改动(未定位到上一次发布提交,可能与上一版重复):\n${notes}`;
|
||||
}
|
||||
|
||||
/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
|
||||
export function createLegacyUpdateManifest(
|
||||
artifactPath,
|
||||
@@ -810,19 +650,10 @@ export async function generateUpdateManifest(
|
||||
version: readPackageJson().version,
|
||||
artifact,
|
||||
});
|
||||
const manualNotes = readReleaseNotes();
|
||||
const previousCommit = await resolvePreviousReleaseCommit(channel, {
|
||||
target,
|
||||
});
|
||||
const commits = collectReleaseCommits(previousCommit);
|
||||
const recentCommits = previousCommit ? null : collectRecentReleaseCommits();
|
||||
const notes =
|
||||
manualNotes ||
|
||||
formatReleaseNotes(commits) ||
|
||||
formatRecentReleaseNotes(recentCommits);
|
||||
if (!manualNotes && !notes) {
|
||||
const notes = readReleaseNotes();
|
||||
if (!notes) {
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'})`,
|
||||
'[ai-game-creator-shell] 未生成更新摘要;如需携带说明,请设置 AGC_UPDATE_RELEASE_NOTES',
|
||||
);
|
||||
}
|
||||
const manifest = createUpdateManifest(artifact, {
|
||||
@@ -857,11 +688,9 @@ export async function generateUpdateManifest(
|
||||
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
|
||||
console.log(`[ai-game-creator-shell] 首装包:${downloadArtifact}`);
|
||||
console.log(
|
||||
manualNotes
|
||||
notes
|
||||
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
|
||||
: notes && !previousCommit
|
||||
? `[ai-game-creator-shell] 更新摘要:无锚点,列出最近 ${recentCommits ? recentCommits.length : 0} 条客户端相关提交`
|
||||
: `[ai-game-creator-shell] 更新摘要:自动汇总 ${commits ? commits.length : 0} 条客户端相关提交(起点 ${previousCommit ?? '无'})`,
|
||||
: '[ai-game-creator-shell] 更新摘要:未生成',
|
||||
);
|
||||
console.log(`[ai-game-creator-shell] 更新摘要文件:${notesPath}`);
|
||||
if (legacyManifestPath) {
|
||||
@@ -878,8 +707,6 @@ export async function generateUpdateManifest(
|
||||
manifestPath,
|
||||
notes,
|
||||
notesPath,
|
||||
previousCommit,
|
||||
commits,
|
||||
legacyManifest,
|
||||
legacyManifestPath,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
@@ -16,18 +9,13 @@ import {
|
||||
agcReleasePathPatterns,
|
||||
buildRelease,
|
||||
buildTauriBuildArguments,
|
||||
collectRecentReleaseCommits,
|
||||
collectReleaseCommits,
|
||||
compareVersions,
|
||||
createChannelConfig,
|
||||
createLegacyUpdateManifest,
|
||||
createUpdateManifest,
|
||||
formatRecentReleaseNotes,
|
||||
formatReleaseNotes,
|
||||
generateUpdateManifest,
|
||||
nextPatchVersion,
|
||||
resolveManifestPlatformKeys,
|
||||
resolvePreviousReleaseCommit,
|
||||
resolveReleaseChannel,
|
||||
resolveReleaseContext,
|
||||
resolveReleasePartition,
|
||||
@@ -897,9 +885,15 @@ for (const channel of ['release', 'beta-2']) {
|
||||
assert.equal(result.channel, channel);
|
||||
assert.equal(result.target, target);
|
||||
assert.equal(result.manifest.version, packageVersion);
|
||||
assert.equal(result.notes, '');
|
||||
assert.equal(result.manifest.notes, undefined);
|
||||
assert.equal(
|
||||
readFileSync(result.notesPath, 'utf8'),
|
||||
'(本次没有可用的更新摘要)\n',
|
||||
);
|
||||
assert.equal(result.legacyManifestPath, null);
|
||||
assert.equal(result.legacyManifest, null);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(requests.length, 1);
|
||||
for (const entry of [
|
||||
...Object.values(result.manifest.platforms),
|
||||
...Object.values(result.manifest.downloads),
|
||||
@@ -973,96 +967,6 @@ test('version high water ignores the windows migration pointer for other channel
|
||||
);
|
||||
});
|
||||
|
||||
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', {
|
||||
override: '6017d46088c04199e99cf89f347b12d67591475e',
|
||||
}),
|
||||
'6017d46088c04199e99cf89f347b12d67591475e',
|
||||
);
|
||||
// 覆盖值非法时忽略,继续用清单里的 commit。
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev', {
|
||||
override: 'not-a-sha',
|
||||
}),
|
||||
'abcdef1234567890',
|
||||
);
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev', { override: ' ' }),
|
||||
'abcdef1234567890',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await withStubbedFetch(
|
||||
() => jsonResponse({ version: '0.1.61' }),
|
||||
async () => {
|
||||
assert.equal(
|
||||
await resolvePreviousReleaseCommit('dev', { 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', { 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 entry forwards the built artifacts and dry-run mode to the uploader', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
@@ -1077,112 +981,6 @@ test('release entry forwards the built artifacts and dry-run mode to the uploade
|
||||
assert.ok(source.includes('\n dryRun,\n'));
|
||||
});
|
||||
|
||||
test('release notes list client commit subjects only 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.equal(lines[0], '- 客户端更新切换到官方更新插件');
|
||||
const truncatedSubject = lines[1].replace(/^- /u, '');
|
||||
assert.equal(truncatedSubject.length, 80, `主题应截断到 80 字:${lines[1]}`);
|
||||
assert.match(truncatedSubject, /…$/u);
|
||||
|
||||
const many = formatReleaseNotes(
|
||||
Array.from({ length: 20 }, (_, index) => ({
|
||||
sha: `sha${index}`,
|
||||
subject: `改动 ${index}`,
|
||||
})),
|
||||
);
|
||||
assert.match(many, /- 其余 8 项客户端改动省略$/u);
|
||||
assert.equal(
|
||||
formatReleaseNotes([
|
||||
{
|
||||
sha: 'ignored',
|
||||
subject: '提交标题\n不应展示的说明一\n不应展示的说明二',
|
||||
},
|
||||
]),
|
||||
'- 提交标题',
|
||||
);
|
||||
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',
|
||||
);
|
||||
const commitMessagePath = path.join(
|
||||
directory,
|
||||
'.git',
|
||||
'commit-message.txt',
|
||||
);
|
||||
writeFileSync(
|
||||
commitMessagePath,
|
||||
'客户端:新增更新插件接入\n补充更新插件接入的详细说明\n',
|
||||
);
|
||||
git('add', '.');
|
||||
git('commit', '--quiet', '-F', commitMessagePath);
|
||||
|
||||
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);
|
||||
// 标题后没有空行时,git %s 会把说明行拼进标题;摘要必须取原始首行。
|
||||
// 合并提交本身被 --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(
|
||||
|
||||
@@ -81,8 +81,6 @@ test('CI pipeline is manual, publishes the macOS partition and never reuses a de
|
||||
'CARGO_BUILD_JOBS=${params.CARGO_BUILD_JOBS}',
|
||||
// Agent 工作区按约定匹配,不写死节点名:节点改名(-local → -01)后守卫仍成立。
|
||||
'"$HOME"/Library/Jenkins/agents/*/workspace/*',
|
||||
// 上一次发布的 commit 落在 master 上,取到它更新摘要才不会退化成「最近提交」。
|
||||
'refs/heads/master:refs/remotes/origin/master',
|
||||
]) {
|
||||
assert.ok(pipeline.includes(required), required);
|
||||
}
|
||||
|
||||
@@ -27,15 +27,7 @@ pub(crate) fn export_local_project_package_at(
|
||||
) -> Result<LocalProjectExportPackageResult, String> {
|
||||
validate_project_root(root)?;
|
||||
super::verification::validate_project_game_entry(root)?;
|
||||
ensure_project_export_package_dir(root, "exports")?;
|
||||
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
|
||||
if !readme_path.is_file() {
|
||||
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
|
||||
}
|
||||
let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?;
|
||||
if !readme_metadata.is_file() {
|
||||
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
|
||||
}
|
||||
ensure_project_export_readme(root)?;
|
||||
|
||||
let files = collect_project_export_package_files(root)?;
|
||||
let total_bytes = files.iter().map(|(_, _, size)| *size).sum::<u64>();
|
||||
@@ -140,6 +132,20 @@ pub(crate) fn export_local_project_package_at(
|
||||
/// 给足时间但必须有界),避免发布路径越过校验器允许的区间。
|
||||
pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300;
|
||||
|
||||
fn project_has_runnable_prototype(manifest: &GameCreationAppManifest) -> bool {
|
||||
let has_completed_prototype_task = manifest.tasks.iter().any(|task| {
|
||||
task.id == "code-prototype" && task.status == GameCreationAppTaskStatus::Completed
|
||||
});
|
||||
let has_running_preview = manifest.preview.as_ref().is_some_and(|preview| {
|
||||
preview.status == GameCreationAppPreviewStatus::Running
|
||||
&& preview
|
||||
.url
|
||||
.as_deref()
|
||||
.is_some_and(|url| !url.trim().is_empty())
|
||||
});
|
||||
has_completed_prototype_task || has_running_preview
|
||||
}
|
||||
|
||||
/// 找到声明了 `scripts.build` 的 npm 工作目录(项目根或 `game/` 子工程)。
|
||||
///
|
||||
/// 只读 `package.json`,不执行任何东西;真正的执行交给 `project.verify` 的受控
|
||||
@@ -256,6 +262,12 @@ pub(crate) async fn export_local_project_package_for_publish_at(
|
||||
if validate_project_game_entry(root).is_ok() {
|
||||
return export_local_project_package_at(root);
|
||||
}
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
if !project_has_runnable_prototype(&manifest) {
|
||||
return Err(
|
||||
"首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。".to_string(),
|
||||
);
|
||||
}
|
||||
let plan = resolve_publish_build_plan(root)?;
|
||||
// `game/` 子工程构建前必须先有依赖:缺 node_modules 时由发布流程自己补一次安装,
|
||||
// 否则作者要点两次(先 bootstrap 再发布)。
|
||||
@@ -477,6 +489,54 @@ pub(crate) fn list_local_project_export_packages_at(
|
||||
})
|
||||
}
|
||||
|
||||
/// 发布导出前确保 `exports/README.md` 存在。
|
||||
///
|
||||
/// 缺失时按项目 manifest 生成最小发布说明;已有文件原样保留,避免覆盖作者或 Agent 的正式文案。
|
||||
/// README 本身仍沿用试玩包的普通文件 / 符号链接安全口径。
|
||||
fn ensure_project_export_readme(root: &Path) -> Result<PathBuf, String> {
|
||||
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
|
||||
match fs::symlink_metadata(&readme_path) {
|
||||
Ok(metadata) => {
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("试玩包不能包含符号链接:exports/README.md".to_string());
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
return Err("exports/README.md 必须是普通文件".to_string());
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let content = render_project_export_readme(&manifest);
|
||||
write_game_creator_private_file(&readme_path, content.as_bytes(), "试玩包发布说明")?;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"读取 exports/README.md 元数据失败:{}: {error}",
|
||||
readme_path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(readme_path)
|
||||
}
|
||||
|
||||
fn render_project_export_readme(manifest: &GameCreationAppManifest) -> String {
|
||||
let title = manifest.name.trim();
|
||||
let title = if title.is_empty() {
|
||||
"试玩游戏"
|
||||
} else {
|
||||
title
|
||||
};
|
||||
let summary = manifest
|
||||
.goal
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("这是一个可直接在浏览器中试玩的游戏原型。");
|
||||
format!(
|
||||
"# {title} 试玩说明\n\n## 简介\n\n{summary}\n\n## 试玩方式\n\n- 解压试玩包后打开根目录的 `index.html`。\n- 使用游戏内提示开始试玩。\n\n## 反馈\n\n请记录问题发生步骤、预期结果和实际结果,便于快速定位。\n"
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn collect_project_export_package_files(
|
||||
root: &Path,
|
||||
) -> Result<Vec<(String, PathBuf, u64)>, String> {
|
||||
|
||||
@@ -4018,6 +4018,12 @@ writeFileSync('dist/assets/app.js', 'document.documentElement.dataset.autoBuild
|
||||
"#,
|
||||
)
|
||||
.expect("write build script");
|
||||
update_manifest_task_status_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
GameCreationAppTaskStatus::Completed,
|
||||
)
|
||||
.expect("mark prototype completed");
|
||||
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
|
||||
|
||||
let result = export_local_project_package_for_publish_at(&root)
|
||||
@@ -4045,13 +4051,21 @@ async fn publish_export_skips_build_when_playable_entry_exists() {
|
||||
.expect("project init");
|
||||
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
|
||||
.expect("write playable html");
|
||||
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
|
||||
|
||||
let result = export_local_project_package_for_publish_at(&root)
|
||||
.await
|
||||
.expect("已有可玩入口时直接导出");
|
||||
.expect("已有可玩入口且缺少 README 时应自动生成并直接导出");
|
||||
|
||||
assert!(result.package_relative_path.ends_with(".zip"));
|
||||
let readme = fs::read_to_string(root.join("exports/README.md")).expect("read generated readme");
|
||||
assert!(readme.contains("# 已构建发布项目 试玩说明"), "{readme}");
|
||||
let payload = read_local_project_export_package_at(&root, &result.package_relative_path)
|
||||
.expect("read publishable package");
|
||||
assert!(payload.files.iter().any(|file| file.path == "index.html"));
|
||||
assert!(payload
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "exports/README.md"));
|
||||
let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default();
|
||||
assert!(
|
||||
!log.contains("project.verify build"),
|
||||
@@ -4061,11 +4075,56 @@ async fn publish_export_skips_build_when_playable_entry_exists() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_export_blocks_build_until_runnable_prototype_is_completed() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-prototype-pending", "原型未完成项目")
|
||||
.expect("project init");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"name": "publish-prototype-pending-fixture",
|
||||
"private": true,
|
||||
"scripts": { "build": "node build-should-not-run.mjs" }
|
||||
}))
|
||||
.expect("serialize package json"),
|
||||
)
|
||||
.expect("write package json");
|
||||
fs::write(
|
||||
root.join("build-should-not-run.mjs"),
|
||||
"import { writeFileSync } from 'node:fs'; writeFileSync('build-ran.txt', 'yes');",
|
||||
)
|
||||
.expect("write build marker script");
|
||||
|
||||
let error = export_local_project_package_for_publish_at(&root)
|
||||
.await
|
||||
.expect_err("原型未完成时禁止触发用户项目构建");
|
||||
|
||||
assert!(
|
||||
error.contains("首个可运行原型尚未完成"),
|
||||
"错误应说明原型未完成:{error}"
|
||||
);
|
||||
assert!(!root.join("build-ran.txt").exists());
|
||||
let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default();
|
||||
assert!(
|
||||
!log.contains("project.verify build"),
|
||||
"原型未完成时不应执行项目构建:{log}"
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_export_reports_actionable_error_without_entry_or_build_script() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-no-entry", "缺少可玩入口项目")
|
||||
.expect("project init");
|
||||
update_manifest_task_status_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
GameCreationAppTaskStatus::Completed,
|
||||
)
|
||||
.expect("mark prototype completed");
|
||||
// 脚手架默认带 build 脚本;这里改成只有 check 脚本,模拟“没有可玩产物且没有构建脚本”。
|
||||
fs::write(
|
||||
root.join("game/package.json"),
|
||||
@@ -4103,6 +4162,10 @@ fn local_project_export_package_publish_payload_contains_bytes_and_file_digests(
|
||||
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
|
||||
|
||||
let exported = export_local_project_package_at(&root).expect("export package");
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("exports/README.md")).unwrap(),
|
||||
"publish notes"
|
||||
);
|
||||
let payload = read_local_project_export_package_at(&root, &exported.package_relative_path)
|
||||
.expect("read publish payload");
|
||||
|
||||
@@ -4251,18 +4314,29 @@ fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_project_export_package_requires_playable_html_and_readme() {
|
||||
fn local_project_export_package_generates_missing_readme_after_playable_html() {
|
||||
let root = unique_project_path();
|
||||
init_existing_html_project_at(&root, "project-1", "像素动作原型").expect("project init");
|
||||
let missing_readme =
|
||||
let missing_entry =
|
||||
export_local_project_package_at(&root).expect_err("default html is not playable");
|
||||
assert!(missing_readme.contains("游戏入口必须包含可渲染画布"));
|
||||
assert!(missing_entry.contains("游戏入口必须包含可渲染画布"));
|
||||
|
||||
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
|
||||
.expect("write playable html");
|
||||
let missing_readme =
|
||||
export_local_project_package_at(&root).expect_err("readme should be required");
|
||||
assert!(missing_readme.contains("exports/README.md"));
|
||||
fs::remove_dir_all(root.join("exports")).expect("remove exports dir");
|
||||
|
||||
let exported =
|
||||
export_local_project_package_at(&root).expect("missing readme should be generated");
|
||||
let readme = fs::read_to_string(root.join("exports/README.md")).expect("read generated readme");
|
||||
assert!(readme.contains("# 像素动作原型 试玩说明"), "{readme}");
|
||||
assert!(readme.contains("解压试玩包后打开根目录"), "{readme}");
|
||||
|
||||
let payload = read_local_project_export_package_at(&root, &exported.package_relative_path)
|
||||
.expect("read generated package");
|
||||
assert!(payload
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "exports/README.md"));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ import type {
|
||||
TauriInvoke,
|
||||
} from './app/types';
|
||||
import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel';
|
||||
import {
|
||||
GamePublishProgressDialog,
|
||||
type GamePublishProgressState,
|
||||
} from './components/game-distribution/GamePublishProgressDialog';
|
||||
import {
|
||||
agentConversationId,
|
||||
agentRuntimeStateFromResult,
|
||||
@@ -368,6 +372,8 @@ export function App({
|
||||
const [publishPackageResult, setPublishPackageResult] =
|
||||
useState<LocalProjectExportPackageResult | null>(null);
|
||||
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
|
||||
const [publishProgress, setPublishProgress] =
|
||||
useState<GamePublishProgressState | null>(null);
|
||||
// 发布灰度:只有命中的账号才把「发布到游戏广场」入口交给聊天头;读取失败按不开放处理。
|
||||
const [gamePublishAllowed, setGamePublishAllowed] = useState(false);
|
||||
const [projectChatError, setProjectChatError] = useState('');
|
||||
@@ -1126,40 +1132,6 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
async function queueProjectPolicyConfirmationIfNeeded(
|
||||
invoke: TauriInvoke,
|
||||
commandId: GameCreationAppCommandDescriptor['id'],
|
||||
projectPath: string,
|
||||
detail: string,
|
||||
readyMessage: string,
|
||||
onConfirm: () => void,
|
||||
) {
|
||||
const policyView = await invoke<ProjectPermissionPolicyView>(
|
||||
'read_project_permission_policy',
|
||||
{ projectPath },
|
||||
);
|
||||
if (policyView.policy.deniedCommands.includes(commandId)) {
|
||||
const message = `项目权限策略拒绝执行:${commandId}`;
|
||||
markProjectPolicyDenied(commandId, message);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: message },
|
||||
]);
|
||||
directProjectChatRef.current?.announce(message);
|
||||
return true;
|
||||
}
|
||||
if (!policyView.policy.confirmCommands.includes(commandId)) {
|
||||
return false;
|
||||
}
|
||||
requestProjectPolicyConfirmation(commandId, projectPath, detail, onConfirm);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: readyMessage },
|
||||
]);
|
||||
directProjectChatRef.current?.announce(readyMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function denyPendingCommandIfNeeded(
|
||||
commandId: GameCreationAppCommandDescriptor['id'],
|
||||
projectPath: string | null,
|
||||
@@ -1206,8 +1178,8 @@ export function App({
|
||||
/**
|
||||
* 导出试玩包并打开发布面板。
|
||||
*
|
||||
* 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出;
|
||||
* 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。
|
||||
* 发布不再走旧的聊天确认卡:点击后立即打开全屏进度弹窗并锁住工作区,
|
||||
* 导出失败在弹窗内回显;成功后才切换到发布资料面板。
|
||||
*/
|
||||
/**
|
||||
* 发布相关提示同时写工作台状态与 DirectProject 对话。
|
||||
@@ -1232,52 +1204,42 @@ export function App({
|
||||
announcePublishMessage('先打开一个项目再发布');
|
||||
return;
|
||||
}
|
||||
// 权限查询也可能慢或挂住,先回一条即时反馈,别让按钮看起来没反应。
|
||||
announcePublishMessage('正在检查发布权限…');
|
||||
const runExport = async () => {
|
||||
// 导出可能包含构建步骤,再回一条即时反馈。
|
||||
announcePublishMessage('正在构建并打包试玩包,请稍候…');
|
||||
try {
|
||||
const result = await invoke<LocalProjectExportPackageResult>(
|
||||
'export_local_project_package',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
announcePublishMessage(
|
||||
`已构建并打包试玩包:${result.packageRelativePath}`,
|
||||
);
|
||||
setPublishPackageResult(result);
|
||||
setPublishPanelOpen(true);
|
||||
appendLocalPermissionLog(
|
||||
nextProjectPath,
|
||||
'command.auto',
|
||||
'project.export_package',
|
||||
);
|
||||
} catch (error) {
|
||||
announcePublishMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const queued = await queueProjectPolicyConfirmationIfNeeded(
|
||||
invoke,
|
||||
'project.export_package',
|
||||
nextProjectPath,
|
||||
'导出试玩包并打开「发布到游戏广场」面板。',
|
||||
'导出试玩包需要确认,确认后继续。',
|
||||
() => void runExport(),
|
||||
);
|
||||
if (!queued) {
|
||||
await runExport();
|
||||
}
|
||||
} catch (error) {
|
||||
// 权限查询失败也必须回话:onClick 的 Promise 没有 catch 时用户只会看到
|
||||
// 「点了没反应」,这里把它收敛成聊天里的可读错误。
|
||||
const publishManifest = manifestRef.current;
|
||||
const hasCompletedPrototype = publishManifest.tasks.some(
|
||||
(task) => task.id === 'code-prototype' && task.status === 'completed',
|
||||
);
|
||||
const hasRunningPreview =
|
||||
publishManifest.preview?.status === 'running' &&
|
||||
Boolean(publishManifest.preview.url?.trim());
|
||||
if (!hasCompletedPrototype && !hasRunningPreview) {
|
||||
announcePublishMessage(
|
||||
`发布前权限检查失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
'首个可运行原型尚未完成,暂不能发布;请先完成可运行原型并通过运行验证。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setPublishProgress({
|
||||
phase: 'running',
|
||||
message: '正在构建并打包试玩包,请稍候…',
|
||||
});
|
||||
try {
|
||||
const result = await invoke<LocalProjectExportPackageResult>(
|
||||
'export_local_project_package',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
setPublishProgress(null);
|
||||
setPublishPackageResult(result);
|
||||
setPublishPanelOpen(true);
|
||||
appendLocalPermissionLog(
|
||||
nextProjectPath,
|
||||
'command.auto',
|
||||
'project.export_package',
|
||||
);
|
||||
} catch (error) {
|
||||
setPublishProgress({
|
||||
phase: 'failed',
|
||||
message: '发布失败',
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1723,20 +1685,40 @@ export function App({
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const paused = await queueProjectPolicyConfirmationIfNeeded(
|
||||
invoke,
|
||||
const policyView = await invoke<ProjectPermissionPolicyView>(
|
||||
'read_project_permission_policy',
|
||||
{ projectPath: input.projectPath },
|
||||
);
|
||||
if (policyView.policy.deniedCommands.includes('conversation.write')) {
|
||||
const message = '项目权限策略拒绝执行:conversation.write';
|
||||
markProjectPolicyDenied('conversation.write', message);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: message },
|
||||
]);
|
||||
directProjectChatRef.current?.announce(message);
|
||||
return false;
|
||||
}
|
||||
if (!policyView.policy.confirmCommands.includes('conversation.write')) {
|
||||
projectConversationWriteConfirmedRef.current = input.projectPath;
|
||||
return true;
|
||||
}
|
||||
requestProjectPolicyConfirmation(
|
||||
'conversation.write',
|
||||
input.projectPath,
|
||||
'写入 DirectProject 对话历史',
|
||||
'DirectProject 对话写入需要确认。',
|
||||
() => {
|
||||
projectConversationWriteConfirmedRef.current = input.projectPath;
|
||||
input.onConfirmed();
|
||||
},
|
||||
);
|
||||
if (paused) return false;
|
||||
projectConversationWriteConfirmedRef.current = input.projectPath;
|
||||
return true;
|
||||
const message = 'DirectProject 对话写入需要确认。';
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: message },
|
||||
]);
|
||||
directProjectChatRef.current?.announce(message);
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (localProjectPathRef.current === input.projectPath) {
|
||||
setProjectChatError(
|
||||
@@ -2351,6 +2333,10 @@ export function App({
|
||||
packageResult={publishPackageResult}
|
||||
onClose={() => setPublishPanelOpen(false)}
|
||||
/>
|
||||
<GamePublishProgressDialog
|
||||
progress={publishProgress}
|
||||
onClose={() => setPublishProgress(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2502,6 +2488,10 @@ export function App({
|
||||
packageResult={publishPackageResult}
|
||||
onClose={() => setPublishPanelOpen(false)}
|
||||
/>
|
||||
<GamePublishProgressDialog
|
||||
progress={publishProgress}
|
||||
onClose={() => setPublishProgress(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,7 @@ export function AppUpdateNotice() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="app-update-download"
|
||||
onClick={() => void handleDownload()}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { CircleAlert, LoaderCircle } from 'lucide-react';
|
||||
|
||||
import { ThemedModal } from '../modal/ThemedModal';
|
||||
|
||||
export type GamePublishProgressState =
|
||||
| {
|
||||
phase: 'running';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
phase: 'failed';
|
||||
message: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export function GamePublishProgressDialog({
|
||||
progress,
|
||||
onClose,
|
||||
}: {
|
||||
progress: GamePublishProgressState | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const failed = progress?.phase === 'failed';
|
||||
return (
|
||||
<ThemedModal
|
||||
open={progress !== null}
|
||||
ariaLabel={failed ? '发布失败' : '发布进度'}
|
||||
onClose={onClose}
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={failed}
|
||||
overlayClassName="game-publish-progress-overlay"
|
||||
panelClassName="game-publish-progress-dialog"
|
||||
panelStyle={{ background: '#fffaf7', color: '#4f362d' }}
|
||||
>
|
||||
<div
|
||||
className={`game-publish-progress-icon${failed ? ' is-failed' : ''}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{failed ? (
|
||||
<CircleAlert size={30} />
|
||||
) : (
|
||||
<LoaderCircle className="is-spinning" size={30} />
|
||||
)}
|
||||
</div>
|
||||
<h2>{failed ? '发布失败' : '正在发布'}</h2>
|
||||
<p role={failed ? 'alert' : 'status'}>
|
||||
{failed ? progress.detail : progress?.message}
|
||||
</p>
|
||||
{failed ? (
|
||||
<button type="button" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
) : (
|
||||
<small>发布完成前请保持客户端开启,页面暂时不可操作。</small>
|
||||
)}
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
@@ -678,7 +678,7 @@ export function RuntimeConfigDialog({
|
||||
const update = await checkForAppUpdate({ force: true });
|
||||
setAppUpdateStatus(
|
||||
update
|
||||
? `发现新版本 v${update.version},可在右上角下载`
|
||||
? `发现新版本 v${update.version},可在更新弹窗中下载`
|
||||
: '当前已是最新版本',
|
||||
);
|
||||
} catch {
|
||||
|
||||
@@ -30,41 +30,50 @@ body {
|
||||
.app-update-notice {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
top: calc(var(--window-chrome-height) + 12px);
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
max-width: min(520px, calc(100vw - 32px));
|
||||
padding: 12px 14px;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 14px;
|
||||
padding: 24px 22px 20px;
|
||||
border: 1px solid #efc9ae;
|
||||
border-radius: 12px;
|
||||
background: #fffaf5;
|
||||
box-shadow: 0 8px 24px rgb(100 49 26 / 16%);
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.app-update-notice > div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
justify-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.app-update-notice strong {
|
||||
color: #4a220f;
|
||||
font-size: 13px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.app-update-notice span,
|
||||
.app-update-notice p {
|
||||
margin: 0;
|
||||
color: #8d6a58;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.app-update-notice p {
|
||||
max-width: 320px;
|
||||
max-height: 120px;
|
||||
width: 100%;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.app-update-notice .app-update-download {
|
||||
align-self: stretch;
|
||||
padding: 9px 14px;
|
||||
}
|
||||
.app-update-notice button {
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 12px;
|
||||
@@ -81,6 +90,9 @@ body {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.app-update-notice .app-update-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -9588,6 +9600,88 @@ iframe.preview-frame {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.game-publish-progress-overlay {
|
||||
position: fixed;
|
||||
z-index: 500;
|
||||
inset: 0;
|
||||
background: rgb(35 24 19 / 62%);
|
||||
backdrop-filter: blur(3px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog {
|
||||
display: grid;
|
||||
width: min(440px, calc(100vw - 40px));
|
||||
outline: none;
|
||||
justify-items: center;
|
||||
gap: 12px;
|
||||
padding: 28px;
|
||||
border: 1px solid #e4c8ba;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 24px 64px rgb(62 37 27 / 28%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog h2,
|
||||
.game-publish-progress-dialog p,
|
||||
.game-publish-progress-dialog small {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog p,
|
||||
.game-publish-progress-dialog small {
|
||||
color: #8f7367;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog p[role='alert'] {
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
color: #a33f2a;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.game-publish-progress-icon {
|
||||
display: grid;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 16px;
|
||||
background: #f6dfd0;
|
||||
color: #c7653d;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.game-publish-progress-icon.is-failed {
|
||||
background: #f8d8d0;
|
||||
color: #b43c25;
|
||||
}
|
||||
|
||||
.game-publish-progress-icon .is-spinning {
|
||||
animation: app-update-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog button {
|
||||
min-width: 96px;
|
||||
padding: 8px 16px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
background: #c7653d;
|
||||
box-shadow: 0 0 0 0 rgb(199 101 61 / 0%);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.game-publish-progress-dialog button:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgb(199 101 61 / 28%);
|
||||
}
|
||||
|
||||
.game-approval-backdrop {
|
||||
position: fixed;
|
||||
top: var(--window-chrome-height);
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
/**
|
||||
* 客户端「发布」入口的可见反馈。
|
||||
*
|
||||
* DirectProject 项目不发工作台状态行,发布动作的提示必须回到项目对话;
|
||||
* 权限要求确认时也要在聊天里给出确认/取消,否则表现就是「点了没反应」。
|
||||
* 发布动作使用独立的全屏进度弹窗,不回到旧的聊天确认卡片;
|
||||
* 失败必须留在弹窗内可见,成功后自动切换到发布资料面板。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { beforeEach, describe, it, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
@@ -20,6 +22,12 @@ import {
|
||||
waitFor,
|
||||
within,
|
||||
} from './appSurface/harness';
|
||||
import { repoPath } from './repoPath';
|
||||
import {
|
||||
declaration,
|
||||
parseStyleSheet,
|
||||
resolveDeclarations,
|
||||
} from './styleCascade';
|
||||
|
||||
const readGamePublishAvailabilityMock = vi.hoisted(() =>
|
||||
vi.fn(async () => true),
|
||||
@@ -39,18 +47,41 @@ vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
|
||||
const PROJECT_PATH = '/tmp/game-publish-feedback-project';
|
||||
const PROJECT_ID = 'game-publish-feedback-project';
|
||||
|
||||
function withPrototypeStatus(
|
||||
manifest: GameCreationAppManifest,
|
||||
status: 'pending' | 'completed',
|
||||
): GameCreationAppManifest {
|
||||
return {
|
||||
...manifest,
|
||||
tasks: manifest.tasks.map((task) =>
|
||||
task.id === 'code-prototype' ? { ...task, status } : task,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function createFixtureManifest(): GameCreationAppManifest {
|
||||
return createGameCreationAppManifest(PROJECT_ID, '发布反馈项目');
|
||||
return withPrototypeStatus(
|
||||
createGameCreationAppManifest(PROJECT_ID, '发布反馈项目'),
|
||||
'completed',
|
||||
);
|
||||
}
|
||||
|
||||
function createPendingPrototypeManifest(): GameCreationAppManifest {
|
||||
return withPrototypeStatus(
|
||||
createGameCreationAppManifest(PROJECT_ID, '原型未完成项目'),
|
||||
'pending',
|
||||
);
|
||||
}
|
||||
|
||||
function installTauri(
|
||||
options: {
|
||||
exportPackage?: () => unknown;
|
||||
manifest?: GameCreationAppManifest;
|
||||
policy?: ReturnType<typeof emptyProjectPolicy>;
|
||||
readPolicy?: () => unknown;
|
||||
} = {},
|
||||
) {
|
||||
const manifest = createFixtureManifest();
|
||||
const manifest = options.manifest ?? createFixtureManifest();
|
||||
const chatHarness = createProjectChatRuntimeHarness({
|
||||
projectPath: PROJECT_PATH,
|
||||
});
|
||||
@@ -82,12 +113,11 @@ function installTauri(
|
||||
return { invoke };
|
||||
}
|
||||
|
||||
function renderPublishProject() {
|
||||
function renderPublishProject(
|
||||
manifest: GameCreationAppManifest = createFixtureManifest(),
|
||||
) {
|
||||
return render(
|
||||
<App
|
||||
initialProjectManifest={createFixtureManifest()}
|
||||
initialProjectPath={PROJECT_PATH}
|
||||
/>,
|
||||
<App initialProjectManifest={manifest} initialProjectPath={PROJECT_PATH} />,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,76 +155,115 @@ function createConfirmPolicy() {
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('客户端发布入口的可见反馈', () => {
|
||||
it('导出成功时先回即时反馈,再回结果并打开发布面板', async () => {
|
||||
installTauri({ exportPackage: createExportPackageResult });
|
||||
renderPublishProject();
|
||||
it('首个可运行原型未完成时直接阻止发布,不触发用户项目构建', async () => {
|
||||
const exportPackage = vi.fn(createExportPackageResult);
|
||||
const manifest = createPendingPrototypeManifest();
|
||||
installTauri({ exportPackage, manifest });
|
||||
|
||||
renderPublishProject(manifest);
|
||||
|
||||
const surface = await clickPublish();
|
||||
await waitFor(() => {
|
||||
expect(surface.textContent ?? '').toContain('正在检查发布权限…');
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'正在构建并打包试玩包,请稍候…',
|
||||
);
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'已构建并打包试玩包:exports/game.zip',
|
||||
'首个可运行原型尚未完成,暂不能发布',
|
||||
);
|
||||
});
|
||||
expect(surface.textContent ?? '').not.toContain('正在检查发布权限…');
|
||||
expect(exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('发布时显示全屏进度遮罩,成功后收起进度并打开发布面板', async () => {
|
||||
const deferred =
|
||||
createDeferred<ReturnType<typeof createExportPackageResult>>();
|
||||
const exportPackage = vi.fn(() => deferred.promise);
|
||||
installTauri({ exportPackage });
|
||||
renderPublishProject();
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '发布到游戏广场' }),
|
||||
);
|
||||
|
||||
const progressDialog = await screen.findByRole('dialog', {
|
||||
name: '发布进度',
|
||||
});
|
||||
expect(progressDialog.textContent ?? '').toContain(
|
||||
'正在构建并打包试玩包,请稍候…',
|
||||
);
|
||||
expect(progressDialog.textContent ?? '').toContain(
|
||||
'发布完成前请保持客户端开启,页面暂时不可操作。',
|
||||
);
|
||||
expect(
|
||||
progressDialog.closest('.game-publish-progress-overlay'),
|
||||
).not.toBeNull();
|
||||
expect(exportPackage).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText('导出试玩包需要确认,确认后继续。')).toBeNull();
|
||||
|
||||
deferred.resolve(createExportPackageResult());
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '发布进度' })).toBeNull();
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '发布到游戏广场' }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('导出失败时把可读错误写回项目对话', async () => {
|
||||
it('发布失败时在进度弹窗里显示错误并允许关闭', async () => {
|
||||
installTauri({
|
||||
exportPackage: () => {
|
||||
throw new Error('导出试玩包前需要先生成 exports/README.md');
|
||||
throw new Error('构建可玩版本失败:缺少入口');
|
||||
},
|
||||
});
|
||||
renderPublishProject();
|
||||
|
||||
const surface = await clickPublish();
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '发布到游戏广场' }),
|
||||
);
|
||||
|
||||
const failureDialog = await screen.findByRole('dialog', {
|
||||
name: '发布失败',
|
||||
});
|
||||
expect(failureDialog.textContent ?? '').toContain(
|
||||
'构建可玩版本失败:缺少入口',
|
||||
);
|
||||
fireEvent.click(
|
||||
within(failureDialog).getByRole('button', { name: '关闭' }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'导出试玩包前需要先生成 exports/README.md',
|
||||
);
|
||||
expect(screen.queryByRole('dialog', { name: '发布失败' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('策略要求确认时在 DirectProject 里展示确认卡片,确认后继续导出', async () => {
|
||||
const exportPackage = vi.fn(createExportPackageResult);
|
||||
installTauri({ exportPackage, policy: createConfirmPolicy() });
|
||||
renderPublishProject();
|
||||
|
||||
const publish = await screen.findByRole('button', {
|
||||
name: '发布到游戏广场',
|
||||
});
|
||||
fireEvent.click(publish);
|
||||
|
||||
const commandLabel = await screen.findByText('project.export_package');
|
||||
const card = commandLabel.closest('.pending-command');
|
||||
expect(card).not.toBeNull();
|
||||
expect(exportPackage).not.toHaveBeenCalled();
|
||||
expect(
|
||||
within(card as HTMLElement).getByText(
|
||||
'导出试玩包并打开「发布到游戏广场」面板。',
|
||||
it('发布进度遮罩固定覆盖整个工作区并压暗背景', () => {
|
||||
const rules = parseStyleSheet(
|
||||
readFileSync(
|
||||
repoPath('apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
within(card as HTMLElement).getByRole('button', { name: '确认' }),
|
||||
);
|
||||
await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1));
|
||||
const surface = await screen.findByLabelText('陶泥儿项目对话');
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'正在构建并打包试玩包,请稍候…',
|
||||
);
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'已构建并打包试玩包:exports/game.zip',
|
||||
const overlay = resolveDeclarations(
|
||||
rules,
|
||||
['.game-publish-progress-overlay'],
|
||||
1440,
|
||||
);
|
||||
expect(declaration(overlay, 'position')).toBe('fixed');
|
||||
expect(declaration(overlay, 'inset')).toBe('0');
|
||||
expect(declaration(overlay, 'z-index')).toBe('500');
|
||||
expect(declaration(overlay, 'pointer-events')).toBe('auto');
|
||||
expect(declaration(overlay, 'background')).toBe('rgb(35 24 19 / 62%)');
|
||||
});
|
||||
|
||||
it('取消权限确认时把取消结果写回项目对话', async () => {
|
||||
it('策略要求确认时不再回到聊天确认卡片,直接进入进度弹窗', async () => {
|
||||
const exportPackage = vi.fn(createExportPackageResult);
|
||||
installTauri({ exportPackage, policy: createConfirmPolicy() });
|
||||
renderPublishProject();
|
||||
@@ -202,71 +271,9 @@ describe('客户端发布入口的可见反馈', () => {
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '发布到游戏广场' }),
|
||||
);
|
||||
const commandLabel = await screen.findByText('project.export_package');
|
||||
const card = commandLabel.closest('.pending-command');
|
||||
expect(card).not.toBeNull();
|
||||
fireEvent.click(
|
||||
within(card as HTMLElement).getByRole('button', { name: '取消' }),
|
||||
);
|
||||
|
||||
const surface = await screen.findByLabelText('陶泥儿项目对话');
|
||||
await waitFor(() => {
|
||||
expect(surface.textContent ?? '').toContain('已取消导出本地试玩包');
|
||||
});
|
||||
expect(exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('确认时策略已改为拒绝,也要把拒绝原因写回项目对话', async () => {
|
||||
const exportPackage = vi.fn(createExportPackageResult);
|
||||
let denyOnNextPolicyRead = false;
|
||||
installTauri({
|
||||
exportPackage,
|
||||
readPolicy: () => {
|
||||
if (!denyOnNextPolicyRead) return createConfirmPolicy();
|
||||
return {
|
||||
path: '.agent/policy.json',
|
||||
policy: {
|
||||
deniedCommands: ['project.export_package'],
|
||||
confirmCommands: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
renderPublishProject();
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '发布到游戏广场' }),
|
||||
);
|
||||
const commandLabel = await screen.findByText('project.export_package');
|
||||
const card = commandLabel.closest('.pending-command');
|
||||
expect(card).not.toBeNull();
|
||||
denyOnNextPolicyRead = true;
|
||||
fireEvent.click(
|
||||
within(card as HTMLElement).getByRole('button', { name: '确认' }),
|
||||
);
|
||||
|
||||
const surface = await screen.findByLabelText('陶泥儿项目对话');
|
||||
await waitFor(() => {
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'项目权限策略拒绝执行:project.export_package',
|
||||
);
|
||||
});
|
||||
expect(exportPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('权限查询失败时也在项目对话里回显错误', async () => {
|
||||
installTauri({
|
||||
readPolicy: () => {
|
||||
throw new Error('策略文件损坏');
|
||||
},
|
||||
});
|
||||
renderPublishProject();
|
||||
|
||||
const surface = await clickPublish();
|
||||
await waitFor(() => {
|
||||
expect(surface.textContent ?? '').toContain(
|
||||
'发布前权限检查失败:策略文件损坏',
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(exportPackage).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByText('project.export_package')).toBeNull();
|
||||
expect(screen.queryByText('导出试玩包需要确认,确认后继续。')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user