补齐游戏分发发布恢复链路并修掉双击提交建两份游戏
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
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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user