1f243f4edf
- 新增 scripts/check-game-distribution-fork-authorization-e2e.mjs(789 行): 真实 HTTP + 浏览器端到端验收作者侧共创授权链路 —— 从 .app/dev-stack.json 读栈地址; 开 game-distribution:publish 灰度;注册两个真实作者;上传封面并创建作品; 断言三态提升阶梯(forbidden→nonCommercial→full)、降级 409、过期 CAS 409、 未知档位 400、非作者 403、未带 Bearer 401、同 key 同 body 重放 replayed=true、 以及越权/非法请求无副作用;浏览器侧用 Playwright 在 /games/mine 断言入口与三态 编辑器(当前档位不可点、更高档位可点)并真跑一次网页提升,截图留档。 - package.json:新增 npm script check:game-distribution-fork-authorization-e2e, 命名与既有 check:game-distribution-*-e2e 保持一致。 - 实测:本地 dev 栈 + Chrome(E2E_PLAYWRIGHT_DIR 临时 playwright)跑 48 项断言 48 PASS / 0 FAIL;npm run check:encoding 通过。
790 lines
29 KiB
JavaScript
790 lines
29 KiB
JavaScript
// 游戏分发「作品级共创授权(Fork authorization)」链路真实行为验收。
|
||
// 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server [+ web])+ 管理员账号。
|
||
//
|
||
// 用法(Windows + bash 均可;只依赖 Node 内置模块 + 仓库已有依赖):
|
||
// E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \
|
||
// node scripts/check-game-distribution-fork-authorization-e2e.mjs
|
||
// SKIP_BROWSER=1 只跑 HTTP 契约部分(跳过 Playwright 视觉步骤)
|
||
// E2E_API_BASE / E2E_WEB_BASE 覆盖地址;默认从 CWD 的 .app/dev-stack.json 读取(不写死端口)
|
||
// E2E_PLAYWRIGHT_DIR 指向临时装了 playwright 的目录(仓库 devDependencies 不含 playwright)
|
||
// E2E_CHROMIUM_EXECUTABLE 指定 Chromium 可执行文件(默认用 Playwright 自带浏览器)
|
||
// E2E_FORK_ENTRY_LABEL / E2E_FORK_LABEL_FORBIDDEN / E2E_FORK_LABEL_NON_COMMERCIAL / E2E_FORK_LABEL_FULL
|
||
// 覆盖前端文案(前端尚未落地,默认值见下方常量)
|
||
//
|
||
// 参考的仓库既有脚本与实现(本脚本按同一风格写成,未猜测既有契约):
|
||
// [1] admin 登录 / 缺凭据退出码 2 / 灰度开关:scripts/check-game-distribution-web-e2e.mjs:22-31,150-156,176-187
|
||
// [2] 作者注册(/api/auth/entry,dev 自动注册):scripts/check-game-distribution-web-e2e.mjs:24,161-166
|
||
// [3] 从 .app/dev-stack.json 取地址:scripts/check-game-distribution-ratings-e2e.mjs:15-27
|
||
// [4] 封面直传 + 确认("发布游戏必须提供封面"):scripts/check-game-distribution-owner-isolation.mjs:97-150
|
||
// [5] 建游戏 payload 与响应取值:scripts/check-game-distribution-owner-isolation.mjs:53-64,201-212
|
||
// [6] my-games 明细形状(data.game):server-rs/crates/api-server/src/modules/game_distribution.rs:1151-1200,2991-3016
|
||
// [7] Playwright 装载 / 启动 / 网页登录:scripts/check-game-distribution-web-publish-e2e.mjs:51-57,74-90,148-159
|
||
//
|
||
// 契约假设(⚠ 只读核查确认 master 尚未落地,脚本按目标契约断言):
|
||
// A. PUT /api/game-distribution/games/{game_id}/fork-authorization 在 master 不存在:
|
||
// 全仓库 grep `fork-authorization|forkAuthorization|fork_authorization` 无匹配;
|
||
// 受保护路由全量枚举见 modules/game_distribution.rs:296-355。
|
||
// B. game 载荷当前没有 forkAuthorization 字段:modules/game_distribution.rs:2952-2977。
|
||
// C. 因此步骤 5/6 在 master 上会 404 / 字段缺失并 FAIL —— 设计预期(功能落地后再跑)。
|
||
// D. 前端 /games/mine 的「共创授权」入口与三态文案同样未落地,故做成可覆盖常量。
|
||
// E. 响应包装:本脚本同时接受 `data.game` 与 `data` 两种形态,并同时接受
|
||
// `data.replayed` 与 `data.game.replayed`(既有写接口把 `replayed` 放在 data 顶层,
|
||
// 例:modules/game_distribution.rs:682-700 的评价管理响应)。
|
||
|
||
import { readFileSync } from 'node:fs';
|
||
import { mkdtemp } from 'node:fs/promises';
|
||
import { createRequire } from 'node:module';
|
||
import { tmpdir } from 'node:os';
|
||
import path from 'node:path';
|
||
|
||
const COVER_PNG = Buffer.from(
|
||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||
'base64',
|
||
);
|
||
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 GATE_KEY = 'game-distribution:publish';
|
||
const SKIP_BROWSER = (process.env.SKIP_BROWSER ?? '').trim() === '1';
|
||
|
||
// 三态档位的线上取值(契约值,不随文案变化)。
|
||
const FORK_TIERS = ['forbidden', 'nonCommercial', 'full'];
|
||
// 前端文案(对齐 packages/shared/src/contracts/gameDistribution.ts:130-148),可用环境变量覆盖。
|
||
const FORK_ENTRY_LABEL = process.env.E2E_FORK_ENTRY_LABEL ?? '共创授权';
|
||
const FORK_TIER_LABELS = {
|
||
forbidden: process.env.E2E_FORK_LABEL_FORBIDDEN ?? '禁止共创',
|
||
nonCommercial: process.env.E2E_FORK_LABEL_NON_COMMERCIAL ?? '允许非商用共创',
|
||
full: process.env.E2E_FORK_LABEL_FULL ?? '允许全开放共创',
|
||
};
|
||
const FORK_TIER_SHORT_LABELS = {
|
||
forbidden: process.env.E2E_FORK_SHORT_FORBIDDEN ?? '禁止',
|
||
nonCommercial: process.env.E2E_FORK_SHORT_NON_COMMERCIAL ?? '非商用',
|
||
full: process.env.E2E_FORK_SHORT_FULL ?? '全开放',
|
||
};
|
||
const FORK_FULL_BADGE = process.env.E2E_FORK_FULL_BADGE ?? '共创授权已达上限';
|
||
|
||
if (!ADMIN_USER || !ADMIN_PASSWORD) {
|
||
console.error(
|
||
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' +
|
||
'game-distribution:publish 写入口并验收共创授权;本地栈可先以 GENARRATIVE_ADMIN_USERNAME / ' +
|
||
'GENARRATIVE_ADMIN_PASSWORD 启动 api-server。',
|
||
);
|
||
process.exit(2);
|
||
}
|
||
|
||
// 地址一律从 CWD 的 dev 栈状态文件读取,不写死端口(对齐 ratings-e2e.mjs:15-27)。
|
||
const devStack = JSON.parse(
|
||
readFileSync(path.resolve(process.cwd(), '.app/dev-stack.json'), 'utf8'),
|
||
);
|
||
const API = (
|
||
process.env.E2E_API_BASE ??
|
||
devStack.services?.['api-server']?.url ??
|
||
''
|
||
).replace(/\/+$/u, '');
|
||
const WEB = (
|
||
process.env.E2E_WEB_BASE ??
|
||
devStack.services?.web?.url ??
|
||
'http://127.0.0.1:3000'
|
||
).replace(/\/+$/u, '');
|
||
if (!API) {
|
||
console.error(
|
||
'无法从 .app/dev-stack.json 解析 api-server 地址:请先 `npm run dev` 启动本地栈,' +
|
||
'或用 E2E_API_BASE 显式指定。',
|
||
);
|
||
process.exit(2);
|
||
}
|
||
|
||
let checks = 0;
|
||
let failures = 0;
|
||
let skipped = 0;
|
||
function check(name, ok, detail = '') {
|
||
checks += 1;
|
||
if (!ok) failures += 1;
|
||
console.log(
|
||
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
|
||
);
|
||
}
|
||
function skip(name, detail = '') {
|
||
skipped += 1;
|
||
console.log(`SKIP ${name}${detail ? ` :: ${detail}` : ''}`);
|
||
}
|
||
|
||
// ---------- HTTP helpers(对齐 owner-isolation.mjs:36-76) ----------
|
||
|
||
async function api(pathname, options = {}) {
|
||
const { method = 'GET', token, body, headers = {} } = options;
|
||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||
let finalBody;
|
||
if (body !== undefined) {
|
||
finalHeaders['Content-Type'] = 'application/json';
|
||
finalBody = JSON.stringify(body);
|
||
}
|
||
const response = await fetch(`${API}${pathname}`, {
|
||
method,
|
||
headers: finalHeaders,
|
||
body: finalBody,
|
||
signal: AbortSignal.timeout(30_000),
|
||
});
|
||
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,
|
||
error: json?.error,
|
||
};
|
||
}
|
||
|
||
/// 负面用例的可读诊断:状态 + 错误码 + 响应文本片段(失败时能直接看出是哪个层拒的)。
|
||
function brief(body) {
|
||
const code = body?.error?.code ?? body?.json?.error?.code ?? '';
|
||
return `status=${body?.status} code=${code} text=${String(body?.text ?? '').slice(0, 200)}`;
|
||
}
|
||
|
||
/// 兼容 `data.game` 与扁平 `data` 两种响应形态,并抽出 `replayed`。
|
||
function forkState(body) {
|
||
const data = body?.data ?? null;
|
||
const game = data?.game ?? data ?? null;
|
||
const replayed =
|
||
data?.replayed ?? data?.game?.replayed ?? game?.replayed ?? null;
|
||
return { game, replayed, forkAuthorization: game?.forkAuthorization ?? null };
|
||
}
|
||
|
||
/// 断言某个响应里的 game 载荷带有合法的 forkAuthorization,并等于期望档位。
|
||
function checkForkTier(name, body, expected, { expectReplayed } = {}) {
|
||
const { game, replayed, forkAuthorization } = forkState(body);
|
||
const shapeOk =
|
||
game !== null &&
|
||
typeof game === 'object' &&
|
||
Object.hasOwn(game, 'forkAuthorization');
|
||
const allowed = FORK_TIERS.includes(forkAuthorization);
|
||
const detail =
|
||
`status=${body?.status} forkAuthorization=${JSON.stringify(forkAuthorization)} ` +
|
||
`replayed=${JSON.stringify(replayed)} shapeOk=${shapeOk} allowed=${allowed}`;
|
||
check(
|
||
`${name}(契约形状:game.forkAuthorization 存在且取值合法)`,
|
||
shapeOk && allowed,
|
||
detail,
|
||
);
|
||
check(
|
||
`${name}(档位 === ${expected})`,
|
||
forkAuthorization === expected,
|
||
detail,
|
||
);
|
||
if (expectReplayed !== undefined) {
|
||
check(
|
||
`${name}(replayed === ${expectReplayed})`,
|
||
replayed === expectReplayed,
|
||
detail,
|
||
);
|
||
}
|
||
return { game, replayed, forkAuthorization };
|
||
}
|
||
|
||
async function register(prefix) {
|
||
const phone = `${prefix}${String(Date.now()).slice(-8)}`;
|
||
const response = await api('/api/auth/entry', {
|
||
method: 'POST',
|
||
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
|
||
});
|
||
return { phone, response, token: response.data?.token };
|
||
}
|
||
|
||
function gameMetadata(title) {
|
||
return {
|
||
title,
|
||
summary: '共创授权验收临时游戏',
|
||
description: '',
|
||
category: '休闲',
|
||
tags: ['e2e'],
|
||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||
inputModes: ['keyboard', 'mouse'],
|
||
orientation: 'landscape',
|
||
};
|
||
}
|
||
|
||
// 建游戏必须有封面(modules/game_distribution.rs:2806-2814),走现役直传 + 确认链路
|
||
// (对齐 owner-isolation.mjs:97-150;只往 dev bucket 写一个 67 字节 PNG)。
|
||
async function uploadCover(token, id) {
|
||
const fileName = `fork-authorization-${id}.png`;
|
||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||
method: 'POST',
|
||
token,
|
||
body: {
|
||
legacyPrefix: 'generated-character-drafts',
|
||
pathSegments: ['game-distribution', 'fork-authorization', String(id)],
|
||
fileName,
|
||
contentType: 'image/png',
|
||
access: 'private',
|
||
maxSizeBytes: COVER_PNG.length,
|
||
metadata: { asset_kind: 'game_distribution_cover' },
|
||
},
|
||
});
|
||
if (ticket.status !== 200) {
|
||
throw new Error(
|
||
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
|
||
);
|
||
}
|
||
const upload = ticket.data.upload;
|
||
const form = new FormData();
|
||
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
|
||
if (value !== null && value !== undefined) form.append(key, String(value));
|
||
}
|
||
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
|
||
const put = await fetch(upload.host, { method: 'POST', body: form });
|
||
if (!put.ok) {
|
||
throw new Error(`直传对象存储失败 ${put.status}`);
|
||
}
|
||
const confirm = await api('/api/assets/objects/confirm', {
|
||
method: 'POST',
|
||
token,
|
||
body: {
|
||
bucket: upload.bucket,
|
||
objectKey: upload.objectKey,
|
||
contentType: 'image/png',
|
||
contentLength: COVER_PNG.length,
|
||
assetKind: 'game_distribution_cover',
|
||
accessPolicy: 'private',
|
||
entityId: 'game-distribution-fork-authorization',
|
||
},
|
||
});
|
||
if (confirm.status !== 200) {
|
||
throw new Error(
|
||
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
|
||
);
|
||
}
|
||
return confirm.data.assetObject.assetObjectId;
|
||
}
|
||
|
||
// ---------- Playwright helpers(对齐 web-publish-e2e.mjs:51-57,74-90,148-159) ----------
|
||
|
||
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 });
|
||
}
|
||
|
||
/// 三态选项的 DOM 契约尚未落地:按常见可交互语义逐个尝试定位。
|
||
async function findTierOption(page, label) {
|
||
const candidates = [
|
||
page.getByRole('radio', { name: label }),
|
||
page.getByRole('menuitemradio', { name: label }),
|
||
page.getByRole('button', { name: label }),
|
||
page.locator('[role="option"]').filter({ hasText: label }),
|
||
page.locator('label').filter({ hasText: label }),
|
||
page.getByText(label, { exact: false }),
|
||
];
|
||
for (const locator of candidates) {
|
||
const first = locator.first();
|
||
const count = await first.count().catch(() => 0);
|
||
if (count > 0 && (await first.isVisible().catch(() => false))) {
|
||
return first;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// `isDisabled()` 覆盖 button/input/select/[aria-disabled];取不到时退回 aria-disabled 属性。
|
||
async function isTierDisabled(locator) {
|
||
const disabled = await locator.isDisabled().catch(() => null);
|
||
if (disabled !== null) return { disabled, source: 'isDisabled' };
|
||
const aria = await locator.getAttribute('aria-disabled').catch(() => null);
|
||
if (aria !== null)
|
||
return { disabled: aria === 'true', source: 'aria-disabled' };
|
||
return { disabled: null, source: 'unknown' };
|
||
}
|
||
|
||
// ---------- 浏览器视觉步骤 ----------
|
||
|
||
async function runBrowserStep({ phone, title, draftTitle }) {
|
||
if (SKIP_BROWSER) {
|
||
skip('浏览器视觉:/games/mine 共创授权入口与三态', 'SKIP_BROWSER=1');
|
||
return;
|
||
}
|
||
|
||
let chromium;
|
||
try {
|
||
({ chromium } = await loadPlaywright());
|
||
} catch (error) {
|
||
check(
|
||
'浏览器视觉:Playwright 可用',
|
||
false,
|
||
`仓库 devDependencies 不含 playwright(package.json:255-282),请先执行 ` +
|
||
`\`npm install --prefix %TEMP%\\genarrative-pw --no-save --no-package-lock playwright\` ` +
|
||
`并设置 E2E_PLAYWRIGHT_DIR=%TEMP%\\genarrative-pw(写法见 web-publish-e2e.mjs:4-5);` +
|
||
`或设置 SKIP_BROWSER=1 只跑 HTTP 部分。原始错误:${error?.message ?? error}`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
|
||
const browser = await chromium.launch({
|
||
headless: true,
|
||
...(executablePath ? { executablePath } : {}),
|
||
});
|
||
const screenshotDir = await mkdtemp(
|
||
path.join(tmpdir(), 'genarrative-fork-e2e-'),
|
||
);
|
||
try {
|
||
const page = await browser.newPage({
|
||
viewport: { width: 1280, height: 900 },
|
||
});
|
||
await loginThroughWebUi(page, phone);
|
||
check('浏览器视觉:作者在网页里用密码登录成功', true, `phone=${phone}`);
|
||
|
||
await page.goto(`${WEB}/games/mine`, {
|
||
waitUntil: 'commit',
|
||
timeout: 120_000,
|
||
});
|
||
// A. HTTP 步骤已把作品提升到 full:卡片只读展示档位,不给入口。
|
||
const fullCard = page.getByText(title, { exact: false }).first();
|
||
const fullCardVisible = await fullCard
|
||
.waitFor({ state: 'visible', timeout: 60_000 })
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
check(
|
||
'浏览器视觉:/games/mine 出现 full 档作品卡片',
|
||
fullCardVisible,
|
||
`title=${title}`,
|
||
);
|
||
check(
|
||
'浏览器视觉:full 档卡片显示档位文案',
|
||
await page
|
||
.getByText(`共创:${FORK_TIER_LABELS.full}`)
|
||
.first()
|
||
.isVisible()
|
||
.catch(() => false),
|
||
`label=共创:${FORK_TIER_LABELS.full}`,
|
||
);
|
||
const fullCardElement = page
|
||
.locator('article.my-game-card')
|
||
.filter({ hasText: title })
|
||
.first();
|
||
check(
|
||
'浏览器视觉:full 档卡片显示只读上限且不提供入口',
|
||
(await fullCardElement
|
||
.getByText(FORK_FULL_BADGE)
|
||
.first()
|
||
.isVisible()
|
||
.catch(() => false)) &&
|
||
(await fullCardElement
|
||
.getByRole('button', { name: FORK_ENTRY_LABEL, exact: true })
|
||
.count()) === 0,
|
||
`badge=${FORK_FULL_BADGE} card=article.my-game-card`,
|
||
);
|
||
|
||
// B. 全新草稿作品(forbidden):卡片有「共创授权」入口,点开后三态可选、当前档位不可点。
|
||
const draftCard = page.getByText(draftTitle, { exact: false }).first();
|
||
const draftCardVisible = await draftCard
|
||
.waitFor({ state: 'visible', timeout: 60_000 })
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
check(
|
||
'浏览器视觉:/games/mine 出现草稿作品卡片',
|
||
draftCardVisible,
|
||
`title=${draftTitle}`,
|
||
);
|
||
check(
|
||
'浏览器视觉:草稿卡片档位文案为禁止共创',
|
||
await page
|
||
.getByText(`共创:${FORK_TIER_LABELS.forbidden}`)
|
||
.first()
|
||
.isVisible()
|
||
.catch(() => false),
|
||
`label=共创:${FORK_TIER_LABELS.forbidden}`,
|
||
);
|
||
|
||
const entry = page
|
||
.getByRole('button', { name: FORK_ENTRY_LABEL, exact: true })
|
||
.first();
|
||
const entryVisible = await entry
|
||
.waitFor({ state: 'visible', timeout: 30_000 })
|
||
.then(() => true)
|
||
.catch(() => false);
|
||
check(
|
||
'浏览器视觉:草稿卡片上可见「共创授权」入口',
|
||
entryVisible,
|
||
`label=${FORK_ENTRY_LABEL}`,
|
||
);
|
||
|
||
if (entryVisible) {
|
||
await entry.click();
|
||
await page.waitForTimeout(600);
|
||
const options = {};
|
||
for (const tier of FORK_TIERS) {
|
||
options[tier] = await findTierOption(
|
||
page,
|
||
FORK_TIER_SHORT_LABELS[tier],
|
||
);
|
||
check(
|
||
`浏览器视觉:三态选项可见(${tier} = ${FORK_TIER_SHORT_LABELS[tier]})`,
|
||
options[tier] !== null,
|
||
`label=${FORK_TIER_SHORT_LABELS[tier]}`,
|
||
);
|
||
}
|
||
// 当前档位 forbidden、无更低档位:当前档位必须不可点,更高档位必须可点。
|
||
for (const tier of FORK_TIERS) {
|
||
const locator = options[tier];
|
||
if (!locator) {
|
||
check(
|
||
`浏览器视觉:${tier} 档位禁用态`,
|
||
false,
|
||
`未定位到 ${FORK_TIER_SHORT_LABELS[tier]} 选项元素,无法判定禁用态`,
|
||
);
|
||
continue;
|
||
}
|
||
const { disabled, source } = await isTierDisabled(locator);
|
||
const shouldBeDisabled = tier === 'forbidden';
|
||
check(
|
||
`浏览器视觉:${tier} 档位${shouldBeDisabled ? '不可点(当前档位)' : '可点(更高档位)'}`,
|
||
disabled === shouldBeDisabled,
|
||
disabled === null
|
||
? `label=${FORK_TIER_SHORT_LABELS[tier]} 无法判定禁用态(尝试过 isDisabled 与 aria-disabled)`
|
||
: `label=${FORK_TIER_SHORT_LABELS[tier]} source=${source} disabled=${disabled}`,
|
||
);
|
||
}
|
||
|
||
const panelShot = path.join(
|
||
screenshotDir,
|
||
'fork-authorization-panel.png',
|
||
);
|
||
await page.screenshot({ path: panelShot, fullPage: true });
|
||
check('浏览器视觉:三态面板已截图', true, `截图路径=${panelShot}`);
|
||
|
||
// C. 走一次真实提升(禁止 → 非商用):确认面板 + 卡片文案回读。
|
||
const promote = options.nonCommercial;
|
||
if (promote) {
|
||
await promote.click();
|
||
await page.waitForTimeout(300);
|
||
check(
|
||
'浏览器视觉:点击非商用后出现确认提升提示',
|
||
await page
|
||
.getByText(
|
||
new RegExp(`确认提升为「${FORK_TIER_LABELS.nonCommercial}」`),
|
||
)
|
||
.first()
|
||
.isVisible()
|
||
.catch(() => false),
|
||
`期望包含 确认提升为「${FORK_TIER_LABELS.nonCommercial}」`,
|
||
);
|
||
const confirmButton = page
|
||
.getByRole('button', { name: '确认提升', exact: true })
|
||
.first();
|
||
const confirmVisible = await confirmButton
|
||
.isVisible()
|
||
.catch(() => false);
|
||
check(
|
||
'浏览器视觉:确认提升按钮可见',
|
||
confirmVisible,
|
||
'button=确认提升',
|
||
);
|
||
if (confirmVisible) {
|
||
await confirmButton.click();
|
||
check(
|
||
'浏览器视觉:网页提升到非商用后卡片文案更新',
|
||
await page
|
||
.getByText(`共创:${FORK_TIER_LABELS.nonCommercial}`)
|
||
.first()
|
||
.waitFor({ state: 'visible', timeout: 30_000 })
|
||
.then(() => true)
|
||
.catch(() => false),
|
||
`label=共创:${FORK_TIER_LABELS.nonCommercial}`,
|
||
);
|
||
}
|
||
} else {
|
||
check('浏览器视觉:确认提升按钮可见', false, '未定位到非商用选项');
|
||
}
|
||
|
||
const afterShot = path.join(
|
||
screenshotDir,
|
||
'fork-authorization-after-promote.png',
|
||
);
|
||
await page.screenshot({ path: afterShot, fullPage: true });
|
||
check('浏览器视觉:提升后已截图', true, `截图路径=${afterShot}`);
|
||
}
|
||
|
||
const screenshotPath = path.join(
|
||
screenshotDir,
|
||
'fork-authorization-panel.png',
|
||
);
|
||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||
check('浏览器视觉:已截图保存', true, `截图路径=${screenshotPath}`);
|
||
} finally {
|
||
await browser.close().catch(() => {});
|
||
console.log(`[fork-e2e] 截图目录:${screenshotDir}`);
|
||
}
|
||
}
|
||
|
||
// ---------- 主流程 ----------
|
||
|
||
async function main() {
|
||
console.log(
|
||
`[fork-e2e] api-server=${API} web=${WEB} database=${devStack.database}`,
|
||
);
|
||
|
||
// 1) 管理员登录(对齐 web-e2e.mjs:150-156)
|
||
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);
|
||
|
||
// 2) 开灰度(对齐 web-e2e.mjs:176-187)
|
||
const gate = await api('/admin/api/feature-gates', {
|
||
method: 'PUT',
|
||
token: admin,
|
||
body: {
|
||
gateKey: GATE_KEY,
|
||
enabled: true,
|
||
rolloutPercent: 100,
|
||
allowUserIds: [],
|
||
allowUserTags: [],
|
||
denyUserIds: [],
|
||
description: 'E2E 共创授权链路',
|
||
},
|
||
});
|
||
check('发布灰度已开启', gate.status === 200, `status=${gate.status}`);
|
||
|
||
// 3) 注册两个作者(对齐 web-e2e.mjs:161-166)
|
||
const stamp = Date.now();
|
||
const author = await register('132');
|
||
const other = await register('133');
|
||
check(
|
||
'作者注册拿到 token',
|
||
author.response.status === 200 && Boolean(author.token),
|
||
`status=${author.response.status} phone=${author.phone}`,
|
||
);
|
||
check(
|
||
'第二个账号注册拿到 token',
|
||
other.response.status === 200 && Boolean(other.token),
|
||
`status=${other.response.status} phone=${other.phone}`,
|
||
);
|
||
if (!author.token || !other.token) process.exit(1);
|
||
|
||
// 4) 创建作品(建游戏必须有封面:modules/game_distribution.rs:2806-2814)
|
||
const title = `共创授权 ${String(stamp).slice(-6)}`;
|
||
const coverAssetId = await uploadCover(author.token, stamp);
|
||
const created = await api('/api/game-distribution/games', {
|
||
method: 'POST',
|
||
token: author.token,
|
||
headers: { 'Idempotency-Key': `fork-e2e-game-${stamp}` },
|
||
body: { ...gameMetadata(title), coverAssetId },
|
||
});
|
||
const gameId = created.data?.id;
|
||
check(
|
||
'作者创建作品成功并取到 game.id',
|
||
created.status === 200 && Boolean(gameId),
|
||
`status=${created.status} id=${gameId ?? ''} msg=${created.error?.message ?? ''}`,
|
||
);
|
||
if (!gameId) process.exit(1);
|
||
|
||
const forkPath = `/api/game-distribution/games/${gameId}/fork-authorization`;
|
||
const forkBody = (expected, next) => ({
|
||
expectedForkAuthorization: expected,
|
||
forkAuthorization: next,
|
||
});
|
||
// 幂等键:除"重放"用例复用同一把键外,每一步都是独立键。
|
||
const keyNonCommercial = `fork-e2e-nc-${stamp}`;
|
||
const keyFull = `fork-e2e-full-${stamp}`;
|
||
const bodyNonCommercial = forkBody('forbidden', 'nonCommercial');
|
||
const bodyFull = forkBody('nonCommercial', 'full');
|
||
|
||
// 5a) 初始档位必须是 forbidden(默认值)
|
||
const initial = await api(`/api/game-distribution/my-games/${gameId}`, {
|
||
token: author.token,
|
||
});
|
||
const initialGame = initial.data?.game ?? null;
|
||
check(
|
||
'初始 GET my-games/{game_id} 的 game 载荷存在',
|
||
initial.status === 200 && initialGame !== null,
|
||
`status=${initial.status}`,
|
||
);
|
||
checkForkTier('初始档位', initial, 'forbidden');
|
||
|
||
// 5b) forbidden → nonCommercial
|
||
const promoted = await api(forkPath, {
|
||
method: 'PUT',
|
||
token: author.token,
|
||
headers: { 'Idempotency-Key': keyNonCommercial },
|
||
body: bodyNonCommercial,
|
||
});
|
||
check(
|
||
'提升到 nonCommercial 返回 200',
|
||
promoted.status === 200,
|
||
`status=${promoted.status} msg=${promoted.error?.message ?? ''}`,
|
||
);
|
||
checkForkTier('nonCommercial', promoted, 'nonCommercial', {
|
||
expectReplayed: false,
|
||
});
|
||
|
||
// 5c) nonCommercial → full
|
||
const upgraded = await api(forkPath, {
|
||
method: 'PUT',
|
||
token: author.token,
|
||
headers: { 'Idempotency-Key': keyFull },
|
||
body: bodyFull,
|
||
});
|
||
check(
|
||
'提升到 full 返回 200',
|
||
upgraded.status === 200,
|
||
`status=${upgraded.status} msg=${upgraded.error?.message ?? ''}`,
|
||
);
|
||
checkForkTier('full', upgraded, 'full', { expectReplayed: false });
|
||
|
||
// 5d) 降级必须被拒(full → forbidden)→ 409
|
||
const downgrade = await api(forkPath, {
|
||
method: 'PUT',
|
||
token: author.token,
|
||
headers: { 'Idempotency-Key': `fork-e2e-downgrade-${stamp}` },
|
||
body: forkBody('full', 'forbidden'),
|
||
});
|
||
check(
|
||
'降级 full → forbidden 被拒(409)',
|
||
downgrade.status === 409,
|
||
brief(downgrade),
|
||
);
|
||
|
||
// 5e) 过期 CAS 必须被拒(当前已是 full,却声明 expected=forbidden)→ 409
|
||
const staleCas = await api(forkPath, {
|
||
method: 'PUT',
|
||
token: author.token,
|
||
headers: { 'Idempotency-Key': `fork-e2e-stale-${stamp}` },
|
||
body: forkBody('forbidden', 'full'),
|
||
});
|
||
check(
|
||
'过期 CAS(expected=forbidden,实际 full)被拒(409)',
|
||
staleCas.status === 409,
|
||
brief(staleCas),
|
||
);
|
||
|
||
// 5f) 未知档位必须被拒(allowed 不是三态之一)→ 400
|
||
const unknownTier = await api(forkPath, {
|
||
method: 'PUT',
|
||
token: author.token,
|
||
headers: { 'Idempotency-Key': `fork-e2e-unknown-${stamp}` },
|
||
body: forkBody('full', 'allowed'),
|
||
});
|
||
check(
|
||
'未知档位 allowed 被拒(400)',
|
||
unknownTier.status === 400,
|
||
brief(unknownTier),
|
||
);
|
||
|
||
// 5g) 幂等重放:复用 nonCommercial → full 的同一把键与同一 body → 200 且 replayed=true
|
||
const replay = await api(forkPath, {
|
||
method: 'PUT',
|
||
token: author.token,
|
||
headers: { 'Idempotency-Key': keyFull },
|
||
body: bodyFull,
|
||
});
|
||
check(
|
||
'幂等重放返回 200',
|
||
replay.status === 200,
|
||
`status=${replay.status} msg=${replay.error?.message ?? ''}`,
|
||
);
|
||
checkForkTier('幂等重放', replay, 'full', { expectReplayed: true });
|
||
|
||
// 5h) 非作者(第二个账号)必须被拒 → 403
|
||
const foreign = await api(forkPath, {
|
||
method: 'PUT',
|
||
token: other.token,
|
||
headers: { 'Idempotency-Key': `fork-e2e-foreign-${stamp}` },
|
||
body: forkBody('full', 'forbidden'),
|
||
});
|
||
check('非作者发起变更被拒(403)', foreign.status === 403, brief(foreign));
|
||
|
||
// 5i) 未带 Bearer 必须被拒 → 401
|
||
const anonymous = await api(forkPath, {
|
||
method: 'PUT',
|
||
headers: { 'Idempotency-Key': `fork-e2e-anon-${stamp}` },
|
||
body: forkBody('full', 'forbidden'),
|
||
});
|
||
check(
|
||
'未带 Authorization 被拒(401)',
|
||
anonymous.status === 401,
|
||
brief(anonymous),
|
||
);
|
||
|
||
// 6) 回读确认最终档位仍是 full(越权与非法请求都不得有副作用)
|
||
const finalRead = await api(`/api/game-distribution/my-games/${gameId}`, {
|
||
token: author.token,
|
||
});
|
||
check(
|
||
'最终 GET my-games/{game_id} 仍为 200',
|
||
finalRead.status === 200,
|
||
`status=${finalRead.status}`,
|
||
);
|
||
checkForkTier('最终档位(非法请求无副作用)', finalRead, 'full');
|
||
|
||
// 7) 浏览器视觉:需要一张 full 档作品(只读展示)与一张全新草稿作品(三态编辑器)。
|
||
const draftTitle = `共创授权草稿 ${String(stamp).slice(-6)}`;
|
||
const draftCoverAssetId = await uploadCover(author.token, `${stamp}-draft`);
|
||
const draftCreated = await api('/api/game-distribution/games', {
|
||
method: 'POST',
|
||
token: author.token,
|
||
headers: { 'Idempotency-Key': `fork-e2e-draft-${stamp}` },
|
||
body: { ...gameMetadata(draftTitle), coverAssetId: draftCoverAssetId },
|
||
});
|
||
check(
|
||
'草稿作品创建成功(浏览器三态用例)',
|
||
draftCreated.status === 200 && Boolean(draftCreated.data?.id),
|
||
`status=${draftCreated.status} id=${draftCreated.data?.id ?? ''}`,
|
||
);
|
||
|
||
await runBrowserStep({ phone: author.phone, title, draftTitle });
|
||
|
||
console.log(
|
||
`[fork-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`,
|
||
);
|
||
if (failures > 0) {
|
||
process.exitCode = 1;
|
||
}
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(`[fork-e2e] 未捕获异常:${error?.stack ?? error}`);
|
||
process.exitCode = 1;
|
||
});
|