test(游戏共创): e2e 跟进改动说明/授权继承新语义并补贡献归集断言
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / Repository checks (pull_request) Failing after 37s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m27s
Project CI / Frontend tests (pull_request) Successful in 3m41s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m25s
Project CI / Native shell tests (pull_request) Successful in 9m12s

三个发布 helper 补衍生作品必填 changeSummary(theme 脚本那 6 行是另一 session 已写好的,随本次一并提交);lineage 新增衍生作品 PUT 提升被拒 409 FORK_AUTHORIZATION_INHERITED 且档位不变、入口禁用态与未登录文案断言;新增贡献归集段(不变式 + 三链路传递 + 非作者 403 + 匿名 401)
This commit is contained in:
2026-10-06 18:36:10 +08:00
parent 970dc8e65c
commit fa9ef9037f
3 changed files with 360 additions and 15 deletions
+22 -2
View File
@@ -329,6 +329,7 @@ async function publishToPublic({
stamp,
tag,
label,
changeSummary,
}) {
const zip = await buildZip(`visual-${tag}-${stamp}`);
const created = await api(`/api/game-distribution/games/${gameId}/versions`, {
@@ -341,6 +342,8 @@ async function publishToPublic({
packageFileCount: zip.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
// 衍生作品必填「本次核心改动说明」;根节点不传(传了也会被忽略)。
...(changeSummary ? { changeSummary } : {}),
},
});
const versionId = created.data?.versionId;
@@ -451,6 +454,12 @@ async function createPublishedNode({
stamp,
tag: `visual-${label}`,
label,
// 衍生节点(有父)必填核心改动说明:截图里的「每一代差异」用它渲染。
...(parent
? {
changeSummary: `视觉夹具:节点 ${label} 相对 ${parent.label} 的核心改动说明`,
}
: {}),
});
const detail = await publicDetail(gameId);
check(
@@ -458,7 +467,18 @@ async function createPublishedNode({
detail.data?.status === 'published',
`status=${detail.data?.status ?? '∅'}`,
);
if (promoteTo) {
// 新语义:衍生作品在创建时**继承**父作品档位(创建后忽略请求里的档位、母版才允许 PUT 提升)。
if (parent) {
const childView = await ownerGame(owner.token, gameId);
const parentView = await ownerGame(parent.token, parent.gameId);
const childTier = childView?.forkAuthorization ?? null;
const parentTier = parentView?.forkAuthorization ?? null;
check(
`造树:衍生节点 ${label} 继承父档位(${childTier} === ${parentTier})`,
childTier !== null && childTier === parentTier,
`child=${childTier} parent=${parentTier}`,
);
} else if (promoteTo) {
const promoted = await promoteForkAuthorization({
token: owner.token,
gameId,
@@ -466,7 +486,7 @@ async function createPublishedNode({
key: `visual-promote-${label}-${stamp}`,
});
check(
`造树:节点 ${label} 授权=${promoteTo}`,
`造树:母版节点 ${label} 授权提升=${promoteTo}`,
promoted.status === 200,
brief(promoted),
);
+332 -13
View File
@@ -80,8 +80,9 @@ const LINEAGE_ANCHOR_LABEL = process.env.E2E_LINEAGE_LABEL_ANCHOR ?? '当前作
const LINEAGE_PARENT_UNAVAILABLE =
process.env.E2E_LINEAGE_LABEL_PARENT_UNAVAILABLE ?? '原作品已不可用';
// 详情页「改造这个作品」入口(392cb3bb0):标签与文案取自 GameDetailPage.tsx:430-490。
const REMIX_ENTRY_LABEL = process.env.E2E_REMIX_ENTRY_LABEL ?? 'Fork 这个作品';
const REMIX_LOGIN_LABEL = process.env.E2E_REMIX_LOGIN_LABEL ?? '登录后可 Fork';
const REMIX_ENTRY_LABEL = process.env.E2E_REMIX_ENTRY_LABEL ?? '一键 Fork 共创';
const REMIX_LOGIN_LABEL =
process.env.E2E_REMIX_LOGIN_LABEL ?? '登录后一键 Fork';
const REMIX_STEPS_TEXT =
process.env.E2E_REMIX_STEPS_TEXT ?? '从平台作品开始创作';
const REMIX_NOTE_TEXT = process.env.E2E_REMIX_NOTE_TEXT ?? '整个工程';
@@ -351,6 +352,9 @@ async function promoteForkAuthorization({
}
/// 完整发布链路:建版本 → 传包 → 送审 → 管理员通过(照抄 media-e2e.mjs:431-573 的顺序与请求体)。
///
/// `changeSummary` 只在**衍生作品**上传(§3.4:衍生作品发布必填「本次核心改动说明」,
/// 母版传了也会被忽略)——调用方按 `fork` 是否存在决定要不要带。
async function publishToPublic({
token,
admin,
@@ -361,6 +365,7 @@ async function publishToPublic({
stamp,
tag,
label,
changeSummary,
}) {
const created = await api(`/api/game-distribution/games/${gameId}/versions`, {
method: 'POST',
@@ -372,6 +377,7 @@ async function publishToPublic({
packageFileCount: zip.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
...(changeSummary ? { changeSummary } : {}),
},
});
const versionId = created.data?.versionId;
@@ -784,7 +790,11 @@ async function runBrowserStep({
const bump = (key) => () => {
counters[key] += 1;
};
document.addEventListener('pointerdown', bump('pointerdown'), true);
document.addEventListener(
'pointerdown',
bump('pointerdown'),
true,
);
document.addEventListener('mousedown', bump('mousedown'), true);
document.addEventListener('click', bump('click'), true);
window.__lineageMouseProbe = counters;
@@ -857,17 +867,76 @@ async function runBrowserStep({
timeout: 120_000,
});
await page.waitForTimeout(1500);
check(
'浏览器视觉:授权 forbidden 的作品详情页没有「Fork 这个作品」入口',
// 新前端:forbidden 作品的入口**照常渲染**,但为禁用态(aria-disabled + --blocked),
// 点它只给一句说明、不展开获取面板。这里先点一次再把可观测量喂给断言。
const blockedEntry = page
.locator('.co-creation-fork-primary--blocked')
.first();
if ((await blockedEntry.count()) > 0) {
// aria-disabled 的元素会被 Playwright 的可操作性检查拦下(element is not enabled);
// 这里测的正是应用自己的禁用分支,因此用 force 绕过检查、把点击真的发出去。
await blockedEntry.click({ force: true }).catch(() => {});
await page.waitForTimeout(400);
}
const blockedTitle =
(await page
.getByRole('button', { name: REMIX_ENTRY_LABEL, exact: true })
.count()) === 0 &&
(await page
.getByRole('button', { name: REMIX_LOGIN_LABEL, exact: true })
.count()) === 0,
`forbidden=${forbiddenGameId}`,
.locator('.co-creation-fork-primary--blocked')
.first()
.getAttribute('title')
.catch(() => null)) ?? '';
const blockedPanelCount = await page
.locator('#game-fork-remix-panel')
.count();
check(
'浏览器视觉:授权 forbidden 的作品入口为禁用态(aria-disabled + --blocked + 原因 title),点它不展开面板',
(await page
.locator('.co-creation-fork-primary[aria-disabled="true"]')
.count()) >= 1 &&
(await page.locator('.co-creation-fork-primary--blocked').count()) >=
1 &&
blockedTitle.includes('无法 Fork 这部作品') &&
blockedPanelCount === 0,
`forbidden=${forbiddenGameId} title=${blockedTitle.slice(0, 40)} panel=${blockedPanelCount}`,
);
// 未登录访客:入口文案是另一套(登录后一键 Fork),点击走登录门禁而不是展开面板。
const anonymousContext = await browser.newContext({
viewport: { width: 1280, height: 900 },
});
try {
const anonymousPage = await anonymousContext.newPage();
await anonymousPage.goto(`${WEB}/games/detail?id=${parentId}`, {
waitUntil: 'commit',
timeout: 120_000,
});
const anonymousEntry = anonymousPage.getByRole('button', {
name: REMIX_LOGIN_LABEL,
exact: true,
});
const anonymousEntryVisible = await visible(anonymousEntry, 60_000);
check(
'浏览器视觉:未登录访客看到的是「登录后一键 Fork」入口',
anonymousEntryVisible,
`label=${REMIX_LOGIN_LABEL}`,
);
if (anonymousEntryVisible) {
await anonymousEntry.first().click();
await anonymousPage.waitForTimeout(800);
check(
'浏览器视觉:未登录点击该入口走登录门禁(不展开获取面板)',
(await anonymousPage
.getByRole('tab', { name: '密码登录' })
.count()) >= 1 &&
(await anonymousPage
.locator('#game-fork-remix-panel')
.count()) === 0,
`loginTab=${await anonymousPage.getByRole('tab', { name: '密码登录' }).count()} panel=${await anonymousPage.locator('#game-fork-remix-panel').count()}`,
);
}
} finally {
await anonymousContext.close().catch(() => {});
}
// 父作品软删除后:树仍在,但父节点内容不得再出现。
const deleted = await deleteParent();
check(
@@ -1074,6 +1143,38 @@ async function main() {
if (!childId) process.exit(1);
const childZip = await buildZip(`child-${suffix}`);
const childChangeSummary = 'E2E:把跳台改成三段,并重画全部背景与角色立绘';
// 衍生作品必填「本次核心改动说明」(§3.4):缺失与超短都必须失败关闭(400 + 稳定码),
// 且都不写库——先各试一次(用不同的 Idempotency-Key,失败不消耗版本号),
// 再带合法说明走完整发布链路,两头都钉住。
for (const [tag, summary, expectedCode] of [
['missing', undefined, 'FORK_CHANGE_SUMMARY_REQUIRED'],
['too-short', '改了', 'FORK_CHANGE_SUMMARY_INVALID'],
]) {
const rejected = await api(
`/api/game-distribution/games/${childId}/versions`,
{
method: 'POST',
token: forker.token,
headers: { 'Idempotency-Key': `lineage-child-summary-${tag}-${stamp}` },
body: {
packageSha256: childZip.sha256,
packageBytes: childZip.bytes.length,
packageFileCount: childZip.fileCount,
packageEntryPath: 'index.html',
gameMetadata: childMetadata,
...(summary === undefined ? {} : { changeSummary: summary }),
},
},
);
check(
`衍生作品版本:核心改动说明${tag === 'missing' ? '缺失' : '超短'} → 400 ${expectedCode}`,
rejected.status === 400 && rejected.error?.code === expectedCode,
brief(rejected),
);
}
await publishToPublic({
token: forker.token,
admin,
@@ -1084,7 +1185,21 @@ async function main() {
stamp,
tag: 'child',
label: '子作品',
changeSummary: childChangeSummary,
});
const childPublicVersion = (await publicDetail(childId)).data?.currentVersion;
check(
'衍生作品当前版本公开下发 changeSummary',
childPublicVersion?.changeSummary === childChangeSummary,
`changeSummary=${String(childPublicVersion?.changeSummary ?? '∅').slice(0, 40)}`,
);
const parentDetailVersion = (await publicDetail(parentId)).data
?.currentVersion;
check(
'母版当前版本不发 changeSummary(null,不是省略键)',
parentDetailVersion?.changeSummary === null,
`changeSummary=${JSON.stringify(parentDetailVersion?.changeSummary ?? '∅')}`,
);
// C 段:真实树形状(锚点 = 子作品)
const tree = await lineageOf(childId);
@@ -1233,6 +1348,12 @@ async function main() {
tag: `rich-${label}`,
label: `H) 节点 ${label}`,
quiet: true,
// 衍生节点(有父)必填核心改动说明;根节点不传(传了也会被忽略)。
...(parent
? {
changeSummary: `H) 节点 ${label} 相对 ${parent.label} 的核心改动说明`,
}
: {}),
});
const detail = await publicDetail(gameId);
check(
@@ -1240,7 +1361,38 @@ async function main() {
published.versionId !== null && detail.data?.status === 'published',
`versionId=${published.versionId ?? '∅'} status=${detail.data?.status ?? '∅'} cover=${coverAssetId}`,
);
if (promoteTo) {
// 新语义:**衍生作品在创建时继承父作品档位**(创建时快照),创建后该请求里的档位字段一律被忽略;
// 母版(根)才允许用 PUT 提升。这里按「根 → 提升 / 衍生 → 断言继承」分流。
if (parent) {
const childView = await ownerGame(owner.token, gameId);
const parentView = await ownerGame(parent.owner.token, parent.gameId);
const childTier = childView.game?.forkAuthorization ?? null;
const parentTier = parentView.game?.forkAuthorization ?? null;
check(
`H) 衍生节点 ${label} 创建时继承父作品档位(child=${childTier} === parent=${parentTier})`,
childTier !== null && childTier === parentTier,
`child=${childTier} parent=${parentTier}`,
);
if (promoteTo && promoteTo !== childTier) {
// 新语义(实测确认):衍生作品创建后**禁止**再用 PUT 提升——稳定错误码 FORK_AUTHORIZATION_INHERITED,
// 且档位必须保持不变(失败关闭)。这里按实测口径钉死。
const probe = await promoteForkAuthorization({
token: owner.token,
gameId,
key: `lineage-rich-probe-promote-${label}-${stamp}`,
to: promoteTo,
});
const after = await ownerGame(owner.token, gameId);
const tierAfter = after.game?.forkAuthorization ?? null;
check(
`H) 衍生节点 ${label} 的 PUT 提升被拒(409 FORK_AUTHORIZATION_INHERITED)且档位不变`,
probe.status === 409 &&
(probe.error?.code ?? '') === 'FORK_AUTHORIZATION_INHERITED' &&
tierAfter === childTier,
`status=${probe.status} code=${probe.error?.code ?? '∅'} tier=${childTier}→${tierAfter}`,
);
}
} else if (promoteTo) {
const promoted = await promoteForkAuthorization({
token: owner.token,
gameId,
@@ -1248,7 +1400,7 @@ async function main() {
to: promoteTo,
});
check(
`H) 节点 ${label} 共创授权提升为 ${promoteTo}(供后续继续 Fork)`,
`H) 母版节点 ${label} 共创授权提升为 ${promoteTo}(母版仍可提升)`,
promoted.status === 200 &&
(promoted.data?.game?.forkAuthorization ?? null) === promoteTo,
brief(promoted),
@@ -1423,6 +1575,173 @@ async function main() {
`[lineage-e2e] 富树 lineage URL=${WEB}/games/lineage?id=${richRootId}`,
);
}
// ---------- I 段:贡献归集 /api/game-distribution/games/{game_id}/contribution(作者视角) ----------
// 契约(读实现 + DTO 注释):Bearer;data 直接是
// { gameId, own{playCount}, inherited{playCount}, total{playCount},
// byGeneration[{generation,gameCount,own,inherited,total}],
// directChildren[{gameId,generation,own,inherited,total}], nodeCount, truncated, truncatedReason }
// 不变式:total = own + inherited;inherited = Σ byGeneration.total = Σ directChildren.total;
// nodeCount = Σ byGeneration.gameCount(**不含根自身**);非作者按「不存在」处理。
if (RICH_TREE) {
const totals = (node) => node?.playCount ?? null;
// 先记几笔游玩,让不变式里的数字不是全 0(计数端点是公开路由;失败只记 NOTE,不作为本票的断言)
const playResults = [];
for (const [label, node, times] of [
['R', richNodes.R, 1],
['E', richNodes.E, 2],
['F1', richNodes.F1, 1],
]) {
for (let index = 0; index < times; index += 1) {
const response = await api(
`/api/game-distribution/games/${node.gameId}/plays`,
{
method: 'POST',
token: author.token,
body: { clientId: `lineage-e2e-${label}-${index}-${stamp}` },
},
);
playResults.push(`${label}:${response.status}`);
}
}
note(`游玩计数探针(让贡献不变式非零):${playResults.join(' ')}`);
const contributionResponse = await api(
`/api/game-distribution/games/${richNodes.R.gameId}/contribution`,
{ token: author.token },
);
const contribution =
contributionResponse.data?.contribution ??
contributionResponse.data ??
null;
check(
'I) 作者可读贡献归集(200)',
contributionResponse.status === 200 && contribution !== null,
brief(contributionResponse),
);
const own = totals(contribution?.own);
const inherited = totals(contribution?.inherited);
const total = totals(contribution?.total);
check(
'I) 不变式:total = own + inherited',
own !== null && inherited !== null && total === own + inherited,
`own=${own} inherited=${inherited} total=${total}`,
);
const children = contribution?.directChildren ?? [];
const sumChildren = children.reduce(
(sum, child) => sum + (totals(child.total) ?? 0),
0,
);
check(
'I) 不变式:inherited = Σ directChildren.total',
inherited === sumChildren && children.length === 3,
`inherited=${inherited} Σchildren=${sumChildren} children=${children
.map((child) => `${child.gameId.slice(-6)}:${totals(child.total)}`)
.join(',')}`,
);
const generations = contribution?.byGeneration ?? [];
const sumGenerationTotals = generations.reduce(
(sum, entry) => sum + (totals(entry.total) ?? 0),
0,
);
const sumGenerationCounts = generations.reduce(
(sum, entry) => sum + (entry.gameCount ?? 0),
0,
);
check(
'I) 不变式:inherited = Σ byGeneration.total',
inherited === sumGenerationTotals,
`inherited=${inherited} Σgen=${sumGenerationTotals} gen=${JSON.stringify(
generations.map((entry) => [
entry.generation,
entry.gameCount,
totals(entry.total),
]),
)}`,
);
check(
'I) 不变式:nodeCount = Σ byGeneration.gameCount = 5(不含根自身)',
contribution?.nodeCount === sumGenerationCounts &&
contribution?.nodeCount === 5,
`nodeCount=${contribution?.nodeCount} ΣgameCount=${sumGenerationCounts}`,
);
check(
'I) 小树不截断(truncated=false / truncatedReason=null)',
contribution?.truncated === false &&
(contribution?.truncatedReason ?? null) === null,
`truncated=${contribution?.truncated} reason=${contribution?.truncatedReason ?? 'null'}`,
);
// 三层链路的传递性:R.inherited = B1.total + C1.total + F1.total;B1.total = B1.own + D.total
const b1ContributionResponse = await api(
`/api/game-distribution/games/${richNodes.B1.gameId}/contribution`,
{ token: author.token },
);
const b1Contribution =
b1ContributionResponse.data?.contribution ??
b1ContributionResponse.data ??
null;
const dOfB1 = (b1Contribution?.directChildren ?? []).find(
(child) => child.gameId === richNodes.D.gameId,
);
const findChild = (id) => children.find((child) => child.gameId === id);
check(
'I) 链路传递:B1.total = B1.own + D.total',
totals(b1Contribution?.total) ===
(totals(b1Contribution?.own) ?? NaN) + (totals(dOfB1?.total) ?? NaN),
`B1.own=${totals(b1Contribution?.own)} D.total=${totals(dOfB1?.total)} B1.total=${totals(b1Contribution?.total)}`,
);
check(
'I) 链路传递:R.inherited = B1.total + C1.total + F1.total',
inherited ===
['B1', 'C1', 'F1'].reduce(
(sum, key) =>
sum + (totals(findChild(richNodes[key].gameId)?.total) ?? NaN),
0,
),
`R.inherited=${inherited} Σ=${['B1', 'C1', 'F1']
.map(
(key) =>
`${key}:${totals(findChild(richNodes[key].gameId)?.total)}`,
)
.join(',')}`,
);
// E 属作者乙(forker)——贡献归集是**作者视角**接口,非作者会 403,所以用它的 owner token 查。
const eContributionResponse = await api(
`/api/game-distribution/games/${richNodes.E.gameId}/contribution`,
{ token: forker.token },
);
const eContribution =
eContributionResponse.data?.contribution ??
eContributionResponse.data ??
null;
check(
'I) 最深一层 E:无子代(nodeCount=0、inherited=0、total=own)',
eContribution?.nodeCount === 0 &&
totals(eContribution?.inherited) === 0 &&
totals(eContribution?.total) === totals(eContribution?.own),
`nodeCount=${eContribution?.nodeCount} own=${totals(eContribution?.own)} inherited=${totals(eContribution?.inherited)} total=${totals(eContribution?.total)}`,
);
const foreignContribution = await api(
`/api/game-distribution/games/${richNodes.R.gameId}/contribution`,
{ token: forker.token },
);
check(
'I) 非作者读贡献归集被拒(403 FORBIDDEN「游戏 owner 不匹配」,不泄露计数)',
foreignContribution.status === 403 &&
(foreignContribution.error?.code ?? '') === 'FORBIDDEN' &&
!foreignContribution.text.includes('playCount'),
brief(foreignContribution),
);
const anonymousContribution = await api(
`/api/game-distribution/games/${richNodes.R.gameId}/contribution`,
);
check(
'I) 匿名读贡献归集 → 401',
anonymousContribution.status === 401,
brief(anonymousContribution),
);
}
}
// G 段:Fork 取件通道(M2a)
@@ -249,6 +249,8 @@ async function buildReleaseZip(marker) {
}
/// 完整发布链路(照抄 lineage 脚本 :320-407):建版本 → 传发行包 → 送审 → 管理员通过。
///
/// `changeSummary` 只在**衍生作品**上传:它是衍生作品发布新版本的必填项(母版忽略)。
async function publishToPublic({
token,
admin,
@@ -259,6 +261,7 @@ async function publishToPublic({
tag,
label,
quiet = false,
changeSummary,
}) {
const zip = await buildReleaseZip(`theme-${tag}-${stamp}`);
const created = await api(`/api/game-distribution/games/${gameId}/versions`, {
@@ -271,6 +274,7 @@ async function publishToPublic({
packageFileCount: zip.fileCount,
packageEntryPath: 'index.html',
gameMetadata: metadata,
...(changeSummary ? { changeSummary } : {}),
},
});
const versionId = created.data?.versionId;
@@ -561,6 +565,8 @@ async function main() {
tag: 'child',
label: '衍生作品 C',
quiet: true,
// 衍生作品必填「本次核心改动说明」,母版忽略该字段。
changeSummary: 'E2E:主题用例里的衍生作品核心改动说明',
});
check(
'衍生作品 C 已公开可读',