Files
Genarrative/scripts/check-game-distribution-web-publish-recovery-e2e.mjs
T
kdletters bbe416ca60
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 记录「点一次会建资源的入口需要同步守卫或稳定幂等键」
2026-09-28 20:37:48 +08:00

363 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 游戏分发网页发布「操作恢复」检查:双击提交只落一份、发行包上传中断后草稿仍在、
// 关窗重开后能继续同一版本、换账号不会看到前一个账号的私有草稿。
//
// 需要:完整本地 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);
});