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 记录「点一次会建资源的入口需要同步守卫或稳定幂等键」
363 lines
13 KiB
JavaScript
363 lines
13 KiB
JavaScript
// 游戏分发网页发布「操作恢复」检查:双击提交只落一份、发行包上传中断后草稿仍在、
|
||
// 关窗重开后能继续同一版本、换账号不会看到前一个账号的私有草稿。
|
||
//
|
||
// 需要:完整本地 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);
|
||
});
|