补齐游戏分发发布恢复链路并修掉双击提交建两份游戏
Project CI / AI game creator shell Rust crates (push) Successful in 1m37s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m7s
Project CI / Backend tests (push) Successful in 4m12s
Project CI / Frontend tests (push) Successful in 2m9s
Project CI / Native shell tests (push) Successful in 6m20s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m28s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m31s
Project CI / AI game creator shell web tests (push) Successful in 1m40s
Project CI / Repository checks (push) Successful in 2m25s

- 新增 scripts/check-game-distribution-web-publish-recovery-e2e.mjs:上传中断留草稿、关窗/登录失效后重登续传沿用原 versionId、双击只落一份、换账号不显示他人草稿,共 10 项 PASS
- GamePublishPage 加同步 submitInFlightRef 在途守卫与按次稳定的 Idempotency-Key(失败重试复用、成功后清空),双击不再创建第二份游戏
- 游戏分发里程碑阶段 C 第 5 条改为已勾选
- pitfalls 记录「点一次会建资源的入口需要同步守卫或稳定幂等键」
This commit is contained in:
kdletters
2026-09-28 20:37:48 +08:00
parent bbbd91db88
commit bbe416ca60
5 changed files with 401 additions and 7 deletions
@@ -0,0 +1,362 @@
// 游戏分发网页发布「操作恢复」检查:双击提交只落一份、发行包上传中断后草稿仍在、
// 关窗重开后能继续同一版本、换账号不会看到前一个账号的私有草稿。
//
// 需要:完整本地 dev 栈(`npm run dev`)+ 管理员账号 + playwright。
// npm install --prefix %TEMP%\genarrative-pw --no-save --no-package-lock playwright
// E2E_PLAYWRIGHT_DIR=%TEMP%\genarrative-pw
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-web-publish-recovery-e2e
import { mkdtemp, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const WEB = process.env.E2E_WEB_BASE ?? 'http://127.0.0.1:3000';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? '';
const DEV_PASSWORD = 'GenE2e123!';
const DRAFT_KEY = 'genarrative.game-distribution.publish-draft.v1';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要打开发布灰度并核对发布草稿。',
);
process.exit(2);
}
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body } = options;
const headers = { ...ENVELOPE };
if (token) headers.Authorization = `Bearer ${token}`;
let finalBody;
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const response = await fetch(`${API}${pathname}`, {
method,
headers,
body: finalBody,
});
const text = await response.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return { status: response.status, text, json, data: json?.data };
}
async function loadPlaywright() {
const dir = (process.env.E2E_PLAYWRIGHT_DIR ?? '').trim();
if (!dir) return import('playwright');
const requireFromDir = createRequire(path.join(dir, 'noop.js'));
return requireFromDir('playwright');
}
async function loginThroughWebUi(page, phone) {
await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 });
await page.waitForSelector('.platform-account-entry', { timeout: 120_000 });
await page.locator('.platform-account-entry').first().click();
await page.getByRole('tab', { name: '密码登录' }).first().click();
await page.waitForTimeout(600);
await page
.getByLabel('同意法律协议')
.first()
.check({ force: true })
.catch(() => {});
await page
.locator('input[placeholder="13800000000"]:visible')
.first()
.fill(phone);
await page
.locator('input[placeholder="输入密码"]:visible')
.first()
.fill(DEV_PASSWORD);
await page.getByRole('button', { name: '登录', exact: true }).first().click();
await page
.locator('.platform-account-entry')
.first()
.filter({ hasText: phone.slice(-4) })
.waitFor({ state: 'visible', timeout: 30_000 });
}
async function fillPublishForm(page, title, coverPath, zipPath) {
await page.goto(`${WEB}/games/publish`, {
waitUntil: 'commit',
timeout: 120_000,
});
await page
.getByLabel('游戏名称')
.waitFor({ state: 'visible', timeout: 60_000 });
await page.getByLabel('游戏名称').fill(title);
await page.getByLabel('一句话简介').fill('发布恢复链路 E2E');
await page.getByLabel('分类').selectOption('休闲');
await page.getByLabel('游戏封面(必需)').setInputFiles(coverPath);
await page
.locator('img[alt="游戏封面预览"]')
.waitFor({ state: 'visible', timeout: 60_000 });
await page.getByLabel('发行包 ZIP').setInputFiles(zipPath);
await page.waitForTimeout(400);
}
async function main() {
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
check(
'管理员登录成功',
adminLogin.status === 200 && Boolean(admin),
`status=${adminLogin.status}`,
);
if (!admin) process.exit(1);
await api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 100,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 发布恢复链路',
},
});
const stamp = Date.now();
const phone = `139${String(stamp).slice(-8)}`;
const title = `发布恢复 ${String(stamp).slice(-6)}`;
const workDir = await mkdtemp(
path.join(tmpdir(), 'genarrative-publish-recovery-'),
);
const coverPath = path.join(workDir, 'cover.png');
const zipPath = path.join(workDir, 'package.zip');
await writeFile(coverPath, COVER_PNG);
const zip = new JSZip();
zip.file(
'index.html',
'<!doctype html><html><head><meta charset="utf-8"><title>recovery</title>' +
'<script src="assets/app.js"></script></head><body><h1>RECOVERY-OK</h1></body></html>',
);
zip.file('assets/app.js', 'document.documentElement.dataset.e2e="recovery";');
await writeFile(
zipPath,
Buffer.from(await zip.generateAsync({ type: 'uint8array' })),
);
const { chromium } = await loadPlaywright();
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
let draftSnapshot = '';
try {
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
});
const page = await context.newPage();
await loginThroughWebUi(page, phone);
check('作者网页登录成功', true, `phone=${phone}`);
let abortUploads = true;
let interrupted = false;
await page.route(
'**/api/game-distribution/versions/*/package*',
async (route) => {
if (abortUploads) {
interrupted = true;
await route.abort();
return;
}
await route.continue();
},
);
await fillPublishForm(page, title, coverPath, zipPath);
const submitButton = page.getByRole('button', { name: '提交审核' }).first();
await submitButton.click();
// 双击:第二次点击发生在提交中,按钮应已禁用,不产生第二份游戏/版本。
await submitButton.click({ force: true, timeout: 3_000 }).catch(() => {});
await page.waitForTimeout(4_000);
check('发行包上传失败时给出错误提示', interrupted, '已拦截一次上传请求');
const authorLogin = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
});
const author = authorLogin.data?.token;
const mineAfterFailure = await api('/api/game-distribution/my-games', {
token: author,
});
const gamesAfterFailure = (mineAfterFailure.data?.games ?? []).filter(
(item) => item.title === title,
);
check(
'双击提交只落一份游戏与一个版本',
gamesAfterFailure.length === 1 &&
gamesAfterFailure[0].versions?.length === 1,
`games=${gamesAfterFailure.length} versions=${gamesAfterFailure[0]?.versions?.length ?? 'n/a'}`,
);
// 后面续传阶段放行上传。
abortUploads = false;
draftSnapshot = await page.evaluate(
(key) => localStorage.getItem(key) ?? '',
DRAFT_KEY,
);
const draft = draftSnapshot ? JSON.parse(draftSnapshot) : null;
check(
'上传中断后本地保留恢复草稿',
Boolean(draft?.versionId) && draft.gameId === gamesAfterFailure[0]?.id,
`versionId=${draft?.versionId ?? ''}`,
);
// 关窗 + 登录失效:清掉 access token 与 refresh cookie 后重登同一账号,
// 草稿仍在 localStorage 里,应该还能接着原版本上传。
await page.close();
const clearing = await context.newPage();
await clearing.goto(`${WEB}/games`, {
waitUntil: 'commit',
timeout: 120_000,
});
await clearing.evaluate(() => {
try {
localStorage.removeItem('genarrative.auth.access-token.v1');
} catch {
// 存储不可用时下面按登出后的恢复提示判定。
}
});
await context.clearCookies();
await clearing.close();
const reopened = await context.newPage();
await reopened.goto(`${WEB}/games/publish`, {
waitUntil: 'commit',
timeout: 120_000,
});
const loginPrompt = await reopened
.getByText('登录后才能发布游戏')
.first()
.waitFor({ state: 'visible', timeout: 30_000 })
.then(() => true)
.catch(() => false);
check('登录失效后发布页要求重新登录', loginPrompt);
await loginThroughWebUi(reopened, phone);
await reopened.goto(`${WEB}/games/publish`, {
waitUntil: 'commit',
timeout: 120_000,
});
await reopened
.getByText(/上次发布未完成(版本 v1)/u)
.first()
.waitFor({ state: 'visible', timeout: 60_000 });
check('重新登录后仍能看到继续上传的恢复提示', true);
await reopened.getByRole('button', { name: '继续上传' }).first().click();
await reopened.getByLabel('发行包 ZIP').setInputFiles(zipPath);
await reopened.waitForTimeout(400);
await reopened
.getByRole('button', { name: '继续上传并送审' })
.first()
.click();
await reopened
.getByText('已提交审核,审核通过后会自动出现在游戏广场。')
.first()
.waitFor({ state: 'visible', timeout: 60_000 });
const mineAfterResume = await api('/api/game-distribution/my-games', {
token: author,
});
const resumedGame = (mineAfterResume.data?.games ?? []).find(
(item) => item.title === title,
);
check(
'继续上传沿用原版本并进入待审',
resumedGame?.versions?.length === 1 &&
resumedGame?.versions?.[0]?.versionId === draft?.versionId &&
resumedGame?.latestVersion?.status === 'pending_review',
`versions=${resumedGame?.versions?.length ?? 'n/a'} status=${resumedGame?.latestVersion?.status ?? ''}`,
);
const draftAfterResume = await reopened.evaluate(
(key) => localStorage.getItem(key) ?? '',
DRAFT_KEY,
);
check('恢复成功后清理本地草稿', draftAfterResume === '');
await reopened.close();
await context.close();
// 换账号:把前一个账号的草稿留在同一浏览器里,新账号不应看到恢复提示。
const otherPhone = `137${String(stamp + 7).slice(-8)}`;
const otherContext = await browser.newContext({
viewport: { width: 1280, height: 900 },
});
await otherContext.addInitScript(
({ key, value }) => {
try {
localStorage.setItem(key, value);
} catch {
// 存储不可用时这条用例不成立,下面按「没有恢复提示」判定。
}
},
{ key: DRAFT_KEY, value: draftSnapshot },
);
const otherPage = await otherContext.newPage();
await loginThroughWebUi(otherPage, otherPhone);
await otherPage.goto(`${WEB}/games/publish`, {
waitUntil: 'commit',
timeout: 120_000,
});
await otherPage
.getByLabel('游戏名称')
.waitFor({ state: 'visible', timeout: 60_000 });
await otherPage.waitForTimeout(1_500);
const leakedDraft = await otherPage.getByText(/上次发布未完成/u).count();
check(
'换账号后不会显示上一个账号的私有草稿',
leakedDraft === 0,
`matches=${leakedDraft}`,
);
await otherContext.close();
} finally {
await browser.close();
await api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled: false,
rolloutPercent: 0,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 发布恢复链路(收尾关闭)',
},
});
}
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(
`[check-game-distribution-web-publish-recovery-e2e] 运行失败:${error}`,
);
process.exit(1);
});