// 游戏分发「共创主题」链路真实行为验收(公开读 + 后台写 + 作品详情 themes 增量)。 // // 需要完整本地 dev 栈(`npm run dev`:SpacetimeDB standalone + api-server)+ 管理员账号;不需要浏览器。 // // 用法: // E2E_ADMIN_USER=<管理员> E2E_ADMIN_PASSWORD=<密码> \ // node scripts/check-game-distribution-theme-e2e.mjs // E2E_API_BASE 可覆盖 api-server 地址(默认从 CWD 的 .app/dev-stack.json 读取,不写死端口) // // 契约来源(逐条读实现确认;**一律用符号检索定位,不写行号**——行号会随提交漂移): // [1] 公开主题路由(匿名 + no-store):`api-server/src/modules/game_distribution.rs` 里 // `public_games` 路由族的 `themes` / `themes/{theme_id}` 两条(`themes` 前缀可定位) // [2] 公开列表分页与响应形状:`list_public_themes` + `public_themes_payload` + `themes_page_limit` // [3] 公开详情:`get_public_theme` + `public_theme_detail_payload` // (未发布/不存在都回「主题不存在」→ 404;已发布空成员是 200 + 空 roots) // + `module-game-distribution/src/theme.rs` 的 `game_distribution_theme_public_visible` // [4] 可见性与 memberCount 判定:`game_distribution_theme_visible_members` // (未删 + 已公开 + 有当前公开版本)+ `game_distribution_theme_roots_truncated` // [5] 作品详情 `themes` 增量「先取根再反查」:`game_distribution_themes_for_game` // [6] 后台写路由(admin 鉴权):`admin_create_theme` / `admin_update_theme` / // `admin_set_theme_member` / `admin_delete_theme_member` / `admin_list_themes` / // `admin_list_theme_members`(成员名单不套公开可见性过滤) // [7] 错误码映射:`map_spacetime_error` 里的 `GAME_DISTRIBUTION_THEME_*` 分支前缀判定 // [8] 分页极限与游标:`game_distribution_theme_page_limit`(默认 20 / 上限 50 / 0 取默认 / 超界截断)、 // `parse_game_distribution_theme_cursor`、`page_admin_theme_members` // (成员名单缺省 20 / 上限 50,按 `sort_order` 升序 + `member_id` 兜底,游标 `{sort_order}:{memberId}`) // // 复用/照抄的 helper(注释标了出处;抽公共模块会改动其它已提交脚本,故照抄): // - 从 .app/dev-stack.json 读地址:scripts/check-game-distribution-lineage-e2e.mjs 的 // `const devStack = JSON.parse(...)` 那一段 // - check/note/api/brief/register/gameMetadata/uploadCover/createGame/ownerGame/publicDetail: // lineage 脚本同名 helper // - 完整发布链路(建版本 → 传发行包 → 送审 → 管理员通过):lineage 脚本的 `publishToPublic` // - 改编声明(fork 字段)+ 公开版本 id 取法:lineage 脚本里取 `versions.find(status==='published')` 那一段 import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import path from 'node:path'; import JSZip from 'jszip'; 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'; if (!ADMIN_USER || !ADMIN_PASSWORD) { console.error( '缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开 ' + 'game-distribution:publish 写入口并把作品推到公开(主题成员只呈现公开作品)。', ); process.exit(2); } 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, ''); 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; const skipped = 0; function check(name, ok, detail = '') { checks += 1; if (!ok) failures += 1; console.log( `${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`, ); } function note(message) { console.log(`NOTE ${message}`); } const COVER_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64', ); async function api(pathname, options = {}) { const { method = 'GET', token, body, headers = {}, binary } = options; const finalHeaders = { ...ENVELOPE, ...headers }; if (token) finalHeaders.Authorization = `Bearer ${token}`; let finalBody; if (binary) { finalBody = binary; } else 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(120_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, cacheControl: response.headers.get('cache-control') ?? '', }; } 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)}`; } function errorCode(body) { return body?.error?.code ?? body?.json?.error?.code ?? ''; } function errorText(body) { return String( body?.error?.details?.message ?? body?.error?.message ?? body?.json?.error?.message ?? '', ); } 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, coverAssetId }) { return { title, summary: '共创主题验收临时作品', description: '', category: '休闲', tags: ['e2e'], coverAssetId, deviceSupport: { desktop: true, mobile: false, touch: false }, inputModes: ['keyboard', 'mouse'], orientation: 'landscape', }; } async function uploadCover(token, id) { const fileName = `theme-${id}.png`; const ticket = await api('/api/assets/direct-upload-tickets', { method: 'POST', token, body: { legacyPrefix: 'generated-character-drafts', pathSegments: ['game-distribution', 'theme', 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-theme', }, }); if (confirm.status !== 200) { throw new Error( `确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`, ); } return confirm.data.assetObject.assetObjectId; } async function createGame({ token, metadata, idemKey, fork }) { return api('/api/game-distribution/games', { method: 'POST', token, headers: { 'Idempotency-Key': idemKey }, body: fork ? { ...metadata, fork } : metadata, }); } async function ownerGame(token, gameId) { const response = await api(`/api/game-distribution/my-games/${gameId}`, { token, }); return { response, game: response.data?.game ?? null }; } function publicDetail(gameId, token) { return api(`/api/game-distribution/games/${gameId}`, { token }); } async function buildReleaseZip(marker) { const zip = new JSZip(); zip.file( 'index.html', `${marker}

${marker}

`, ); const bytes = Buffer.from(await zip.generateAsync({ type: 'uint8array' })); return { bytes, fileCount: 1, sha256: createHash('sha256').update(bytes).digest('hex'), }; } /// 完整发布链路(照抄 lineage 脚本 :320-407):建版本 → 传发行包 → 送审 → 管理员通过。 async function publishToPublic({ token, admin, gameId, gameRevision, metadata, stamp, tag, label, quiet = false, }) { const zip = await buildReleaseZip(`theme-${tag}-${stamp}`); const created = await api(`/api/game-distribution/games/${gameId}/versions`, { method: 'POST', token, headers: { 'Idempotency-Key': `theme-version-${tag}-${stamp}` }, body: { packageSha256: zip.sha256, packageBytes: zip.bytes.length, packageFileCount: zip.fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, }, }); const versionId = created.data?.versionId; if (!quiet) { check( `${label} 版本创建成功`, created.status === 200 && Boolean(versionId), `status=${created.status} ${created.text.slice(0, 140)}`, ); } if (!versionId) return { versionId: null }; const upload = await api( `/api/game-distribution/versions/${versionId}/package`, { method: 'PUT', token, headers: { 'Idempotency-Key': `theme-upload-${tag}-${stamp}`, 'Content-Type': 'application/zip', }, binary: zip.bytes, }, ); if (!quiet) { check( `${label} 发行包上传成功`, upload.status === 200, `status=${upload.status}`, ); } const submitted = await api( `/api/game-distribution/versions/${versionId}/submit`, { method: 'POST', token, headers: { 'Idempotency-Key': `theme-submit-${tag}-${stamp}` }, body: { expectedPublicationRevision: gameRevision }, }, ); if (!quiet) { check( `${label} 送审成功(202)`, submitted.status === 202, `status=${submitted.status} ${submitted.text.slice(0, 140)}`, ); } const readback = await api(`/api/game-distribution/versions/${versionId}`, { token, }); const approved = await api( `/admin/api/game-distribution/versions/${versionId}/review`, { method: 'POST', token: admin, headers: { 'Idempotency-Key': `theme-approve-${tag}-${stamp}` }, body: { decision: 'approve', expectedPublicationRevision: readback.data?.version?.publicationRevision ?? gameRevision, }, }, ); if (!quiet) { check( `${label} 管理员审核通过并公开`, approved.status === 200, `status=${approved.status} ${approved.text.slice(0, 140)}`, ); } return { versionId }; } // ---------- 主题专用 helper ---------- function adminCreateTheme({ admin, body, key }) { return api('/admin/api/game-distribution/themes', { method: 'POST', token: admin, headers: key === undefined ? {} : { 'Idempotency-Key': key }, body, }); } function adminUpdateTheme({ admin, themeId, body, key }) { return api(`/admin/api/game-distribution/themes/${themeId}`, { method: 'PUT', token: admin, headers: key === undefined ? {} : { 'Idempotency-Key': key }, body, }); } function adminPutMember({ admin, themeId, rootGameId, sortOrder = 0 }) { return api( `/admin/api/game-distribution/themes/${themeId}/members/${rootGameId}`, { method: 'PUT', token: admin, body: { sortOrder }, }, ); } function adminDeleteMember({ admin, themeId, rootGameId }) { return api( `/admin/api/game-distribution/themes/${themeId}/members/${rootGameId}`, { method: 'DELETE', token: admin, }, ); } function adminListThemes({ admin, limit, status } = {}) { const query = new URLSearchParams(); if (limit !== undefined) query.set('limit', String(limit)); if (status !== undefined) query.set('status', status); const suffix = query.toString() ? `?${query.toString()}` : ''; return api(`/admin/api/game-distribution/themes${suffix}`, { token: admin }); } function publicThemes({ limit, cursor } = {}) { const query = new URLSearchParams(); if (limit !== undefined) query.set('limit', String(limit)); if (cursor !== undefined) query.set('cursor', cursor); const suffix = query.toString() ? `?${query.toString()}` : ''; return api(`/api/game-distribution/themes${suffix}`); } function publicThemeDetail(themeId) { return api(`/api/game-distribution/themes/${themeId}`); } function adminThemeMembers({ admin, themeId, limit, cursor } = {}) { const query = new URLSearchParams(); if (limit !== undefined) query.set('limit', String(limit)); if (cursor !== undefined) query.set('cursor', cursor); const suffix = query.toString() ? `?${query.toString()}` : ''; return api( `/admin/api/game-distribution/themes/${themeId}/members${suffix}`, { token: admin }, ); } function softDeleteGame({ token, gameId, revision, key }) { return api( `/api/game-distribution/my-games/${gameId}?expectedPublicationRevision=${revision}`, { method: 'DELETE', token, headers: { 'Idempotency-Key': key } }, ); } function themeIdsOf(body) { return (body?.data?.themes ?? []).map((theme) => theme.themeId); } // ---------- 主流程 ---------- async function main() { console.log(`[theme-e2e] api-server=${API} database=${devStack.database}`); 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); 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}`); const stamp = Date.now(); const suffix = String(stamp).slice(-6); const author = await register('132'); const stranger = await register('133'); check( '作者注册拿到 token', author.response.status === 200 && Boolean(author.token), `status=${author.response.status}`, ); check( '另一个普通账号(非 admin)注册拿到 token', stranger.response.status === 200 && Boolean(stranger.token), `status=${stranger.response.status}`, ); if (!author.token || !stranger.token) process.exit(1); // ---------- fixture:根作品 R(公开)+ 衍生作品 C(公开)+ 第二个根 R2 ---------- const rootMeta = gameMetadata({ title: `主题根作品 ${suffix}`, coverAssetId: await uploadCover(author.token, `${stamp}-root`), }); const rootCreated = await createGame({ token: author.token, metadata: rootMeta, idemKey: `theme-root-${stamp}`, }); const rootId = rootCreated.data?.id; check( '根作品 R 创建成功', rootCreated.status === 200 && Boolean(rootId), `status=${rootCreated.status} id=${rootId ?? ''}`, ); if (!rootId) process.exit(1); await publishToPublic({ token: author.token, admin, gameId: rootId, gameRevision: rootCreated.data?.publicationRevision ?? 0, metadata: rootMeta, stamp, tag: 'root', label: '根作品 R', quiet: true, }); check( '根作品 R 已公开可读', (await publicDetail(rootId)).data?.status === 'published', 'status=published', ); // 派生作品 C:需要 R 已公开 + 授权非禁止 + R 的当前公开版本 ID const promote = await api( `/api/game-distribution/games/${rootId}/fork-authorization`, { method: 'PUT', token: author.token, headers: { 'Idempotency-Key': `theme-promote-${stamp}` }, body: { expectedForkAuthorization: 'forbidden', forkAuthorization: 'nonCommercial', }, }, ); check( '根作品 R 共创授权提升为 nonCommercial(衍生作品前提)', promote.status === 200, brief(promote), ); const rootVersions = (await ownerGame(author.token, rootId)).game?.versions ?? []; const rootPublicVersionId = rootVersions.find((version) => version.status === 'published')?.versionId ?? ''; const childMeta = gameMetadata({ title: `主题衍生作品 ${suffix}`, coverAssetId: await uploadCover(author.token, `${stamp}-child`), }); const childCreated = await createGame({ token: author.token, metadata: childMeta, idemKey: `theme-child-${stamp}`, fork: { parentGameId: rootId, parentVersionId: rootPublicVersionId }, }); const childId = childCreated.data?.id; check( '衍生作品 C(带改编声明)创建成功', childCreated.status === 200 && Boolean(childId), `status=${childCreated.status} id=${childId ?? ''} parentVersion=${rootPublicVersionId}`, ); if (!childId) process.exit(1); await publishToPublic({ token: author.token, admin, gameId: childId, gameRevision: childCreated.data?.publicationRevision ?? 0, metadata: childMeta, stamp, tag: 'child', label: '衍生作品 C', quiet: true, }); check( '衍生作品 C 已公开可读', (await publicDetail(childId)).data?.status === 'published', 'status=published', ); const secondRootMeta = gameMetadata({ title: `主题第二个根 ${suffix}`, coverAssetId: await uploadCover(author.token, `${stamp}-root2`), }); const secondRootCreated = await createGame({ token: author.token, metadata: secondRootMeta, idemKey: `theme-root2-${stamp}`, }); const secondRootId = secondRootCreated.data?.id; if (!secondRootId) { check('第二个根作品 R2 创建成功', false, brief(secondRootCreated)); process.exit(1); } await publishToPublic({ token: author.token, admin, gameId: secondRootId, gameRevision: secondRootCreated.data?.publicationRevision ?? 0, metadata: secondRootMeta, stamp, tag: 'root2', label: '根作品 R2', quiet: true, }); // ---------- 1:建主题 → 加根成员 → 公开读 ---------- const themeT1 = await adminCreateTheme({ admin, key: `theme-t1-${stamp}`, body: { name: `主题 T1 ${suffix}`, summary: '主题 T1 简介', badge: 'T1', sortOrder: 1, status: 'published', }, }); const t1Id = themeT1.data?.theme?.themeId; check( '1) 后台创建已发布主题 T1 成功', themeT1.status === 200 && Boolean(t1Id) && themeT1.data?.theme?.status === 'published' && themeT1.data?.replayed === false, brief(themeT1), ); if (!t1Id) process.exit(1); const memberPut = await adminPutMember({ admin, themeId: t1Id, rootGameId: rootId, sortOrder: 5, }); check( '1) 加根成员 R 成功(成员写入不要求幂等键)', memberPut.status === 200 && memberPut.data?.themeId === t1Id && memberPut.data?.rootGameId === rootId && memberPut.data?.sortOrder === 5, brief(memberPut), ); const listWithT1 = await publicThemes({ limit: 50 }); check( '1) 公开列表只含已发布主题且含 T1、memberCount=1', listWithT1.status === 200 && themeIdsOf(listWithT1).includes(t1Id) && (listWithT1.data?.themes ?? []).every( (theme) => theme.status === undefined, ) && (listWithT1.data?.themes ?? []).find((theme) => theme.themeId === t1Id) ?.memberCount === 1, `themes=${themeIdsOf(listWithT1).length} t1MemberCount=${ (listWithT1.data?.themes ?? []).find((theme) => theme.themeId === t1Id) ?.memberCount ?? 'n/a' }`, ); const detailT1 = await publicThemeDetail(t1Id); const catalogEntry = ( await api('/api/game-distribution/games') ).data?.games?.find((game) => game.id === rootId); check( '1) 公开详情 roots 逐条是公开作品摘要且 memberCount 与可见成员数一致', detailT1.status === 200 && detailT1.data?.roots?.length === 1 && detailT1.data?.roots?.[0]?.id === rootId && detailT1.data?.memberCount === detailT1.data?.roots?.length && JSON.stringify(Object.keys(detailT1.data.roots[0]).sort()) === JSON.stringify(Object.keys(catalogEntry ?? {}).sort()), `roots=${detailT1.data?.roots?.length} memberCount=${detailT1.data?.memberCount} rootKeys=${Object.keys(detailT1.data?.roots?.[0] ?? {}).length} catalogKeys=${Object.keys(catalogEntry ?? {}).length}`, ); check( '1) 公开详情顶层键集合恰为契约的七个(含 rootsTruncated)', JSON.stringify(Object.keys(detailT1.data ?? {}).sort()) === JSON.stringify( [ 'badge', 'memberCount', 'name', 'roots', 'rootsTruncated', 'summary', 'themeId', ].sort(), ), `keys=${Object.keys(detailT1.data ?? {}).join(',')}`, ); check( '1) 小主题下 rootsTruncated 是布尔且为 false(memberCount === roots.length 只在这种小主题下成立)', typeof detailT1.data?.rootsTruncated === 'boolean' && detailT1.data?.rootsTruncated === false && detailT1.data?.memberCount === detailT1.data?.roots?.length, `rootsTruncated=${JSON.stringify(detailT1.data?.rootsTruncated)} memberCount=${detailT1.data?.memberCount} roots=${detailT1.data?.roots?.length}`, ); // ---------- 2:只允许根 ---------- const notRoot = await adminPutMember({ admin, themeId: t1Id, rootGameId: childId, }); check( '2) 第 1 代子作品当成员 → 409 THEME_MEMBER_NOT_ROOT', notRoot.status === 409 && errorCode(notRoot) === 'THEME_MEMBER_NOT_ROOT', brief(notRoot), ); const memberGameMissing = await adminPutMember({ admin, themeId: t1Id, rootGameId: `game_${'0'.repeat(32)}`, }); check( '2) 不存在的 gameId 当成员 → 404 THEME_MEMBER_GAME_NOT_FOUND', memberGameMissing.status === 404 && errorCode(memberGameMissing) === 'THEME_MEMBER_GAME_NOT_FOUND', brief(memberGameMissing), ); // ---------- 3:跨主题多归属 ---------- const themeT2 = await adminCreateTheme({ admin, key: `theme-t2-${stamp}`, body: { name: `主题 T2 ${suffix}`, summary: '主题 T2 简介', badge: 'T2', sortOrder: 2, status: 'published', }, }); const t2Id = themeT2.data?.theme?.themeId; check('3) 创建第二个已发布主题 T2 成功', Boolean(t2Id), brief(themeT2)); if (!t2Id) process.exit(1); await adminPutMember({ admin, themeId: t2Id, rootGameId: rootId }); const detailT2 = await publicThemeDetail(t2Id); check( '3) 同一个根加入两个主题 → 两条主题都能读到它', detailT2.data?.roots?.some((root) => root.id === rootId) && detailT1.data?.roots?.some((root) => root.id === rootId), `t1Roots=${detailT1.data?.roots?.length} t2Roots=${detailT2.data?.roots?.length}`, ); // ---------- 4:作品详情 themes 口径(含衍生作品) ---------- const rootDetailAnonymous = await publicDetail(rootId); const rootDetailAuthed = await publicDetail(rootId, author.token); const childDetailAnonymous = await publicDetail(childId); const rootThemes = rootDetailAnonymous.data?.themes ?? []; const childThemes = childDetailAnonymous.data?.themes ?? []; check( '4) 根作品详情的 themes 数组长度为 2(匿名与登录都发)', Array.isArray(rootDetailAnonymous.data?.themes) && Array.isArray(rootDetailAuthed.data?.themes) && rootThemes.length === 2 && rootThemes .map((theme) => theme.themeId) .sort() .join(',') === [t1Id, t2Id].sort().join(','), `anonymous=${JSON.stringify(rootThemes.map((theme) => theme.themeId))} authed=${JSON.stringify((rootDetailAuthed.data?.themes ?? []).map((theme) => theme.themeId))}`, ); check( '4) 衍生作品(第 1 代)也返回其根所属主题(口径:先取根再反查)', childThemes.length === 2 && childThemes .map((theme) => theme.themeId) .sort() .join(',') === [t1Id, t2Id].sort().join(','), `childThemes=${JSON.stringify(childThemes.map((theme) => theme.themeId))}`, ); check( '4) themes 条目的形状是 {themeId,name,badge}', rootThemes.length > 0 && JSON.stringify(Object.keys(rootThemes[0]).sort()) === JSON.stringify(['badge', 'name', 'themeId']), `keys=${Object.keys(rootThemes[0] ?? {}).join(',')}`, ); check( '4) 没有所属主题的作品返回空数组(不是缺键)', (await publicDetail(secondRootId)).data?.themes?.length === 0, `secondRootThemes=${JSON.stringify((await publicDetail(secondRootId)).data?.themes ?? null)}`, ); // ---------- 5:可见性 ---------- const draftTheme = await adminCreateTheme({ admin, key: `theme-draft-${stamp}`, body: { name: `主题草稿 ${suffix}`, summary: '草稿主题', badge: 'D', sortOrder: 9, status: 'draft', }, }); const draftId = draftTheme.data?.theme?.themeId; check('5) 创建草稿主题成功', Boolean(draftId), brief(draftTheme)); await adminPutMember({ admin, themeId: draftId, rootGameId: rootId }); const draftPublic = await publicThemeDetail(draftId); check( '5) 草稿主题公开详情 → 404(不返回空壳)', draftPublic.status === 404 && errorText(draftPublic).includes('主题不存在'), brief(draftPublic), ); const adminListWithDraft = await adminListThemes({ admin, status: 'draft' }); check( '5) 后台列表按 draft 过滤仍能看到该草稿主题(公开侧看不见、后台可见)', adminListWithDraft.status === 200 && themeIdsOf({ data: adminListWithDraft.data }).includes(draftId), `draftThemes=${themeIdsOf({ data: adminListWithDraft.data }).length}`, ); const archived = await adminUpdateTheme({ admin, themeId: t1Id, key: `theme-archive-${stamp}`, body: { name: `主题 T1 ${suffix}`, summary: '主题 T1 简介', badge: 'T1', sortOrder: 1, status: 'archived', }, }); check( '5) 主题改为 archived 成功', archived.status === 200 && archived.data?.theme?.status === 'archived', brief(archived), ); const archivedPublic = await publicThemeDetail(t1Id); check( '5) archived 主题公开详情 → 404;且其所属作品的 themes 也不含它', archivedPublic.status === 404 && !((await publicDetail(rootId)).data?.themes ?? []).some( (theme) => theme.themeId === t1Id, ), brief(archivedPublic), ); const republished = await adminUpdateTheme({ admin, themeId: t1Id, key: `theme-republish-${stamp}`, body: { name: `主题 T1 ${suffix}`, summary: '主题 T1 简介', badge: 'T1', sortOrder: 1, status: 'published', }, }); check( '5) 主题改回 published 后公开详情恢复 200', republished.status === 200 && (await publicThemeDetail(t1Id)).status === 200, brief(republished), ); // 已发布但成员全不可见 → 200 + 空 roots;成员下架 → 消失,重新公开 → 自动回来 const emptyTheme = await adminCreateTheme({ admin, key: `theme-empty-${stamp}`, body: { name: `主题空成员 ${suffix}`, summary: '成员不可见的主题', badge: 'E', sortOrder: 3, status: 'published', }, }); const emptyThemeId = emptyTheme.data?.theme?.themeId; await adminPutMember({ admin, themeId: emptyThemeId, rootGameId: secondRootId, }); const beforeUnpublish = await publicThemeDetail(emptyThemeId); check( '5) 成员均可见时 memberCount=1', beforeUnpublish.data?.memberCount === 1 && beforeUnpublish.data?.roots?.[0]?.id === secondRootId, `memberCount=${beforeUnpublish.data?.memberCount}`, ); const secondRootNow = await ownerGame(author.token, secondRootId); const unpublishSecondRoot = await api( `/api/game-distribution/games/${secondRootId}/unpublish`, { method: 'POST', token: author.token, headers: { 'Idempotency-Key': `theme-unpublish-root2-${stamp}` }, body: { expectedPublicationRevision: secondRootNow.game?.publicationRevision ?? 0, }, }, ); check( '5) 成员作品 R2 下架成功', unpublishSecondRoot.status === 200, brief(unpublishSecondRoot), ); const emptyAfterUnpublish = await publicThemeDetail(emptyThemeId); check( '5) 成员下架后:已发布主题仍是 200 + 空 roots + memberCount=0(不返回空壳 404)', emptyAfterUnpublish.status === 200 && emptyAfterUnpublish.data?.roots?.length === 0 && emptyAfterUnpublish.data?.memberCount === 0, `status=${emptyAfterUnpublish.status} roots=${emptyAfterUnpublish.data?.roots?.length} memberCount=${emptyAfterUnpublish.data?.memberCount}`, ); await publishToPublic({ token: author.token, admin, gameId: secondRootId, gameRevision: (await ownerGame(author.token, secondRootId)).game?.publicationRevision ?? 0, metadata: secondRootMeta, stamp, tag: 'root2-v2', label: '成员 R2 重新公开', quiet: true, }); const restored = await publicThemeDetail(emptyThemeId); check( '5) 成员重新公开后该根自动回到 roots(行未删除,只读侧过滤)且仍只有一行', restored.status === 200 && restored.data?.roots?.length === 1 && restored.data?.roots?.[0]?.id === secondRootId && restored.data?.memberCount === 1, `roots=${restored.data?.roots?.length} memberCount=${restored.data?.memberCount}`, ); // ---------- 6:分页 ---------- const paginationThemeIds = []; for (let index = 0; index < 51; index += 1) { const created = await adminCreateTheme({ admin, key: `theme-pag-${index}-${stamp}`, body: { name: `主题分页 ${suffix}-${index}`, summary: '分页用例', badge: 'P', sortOrder: index, status: 'published', }, }); if (created.status !== 200) { check( '6) 分页用例主题创建成功', false, `index=${index} ${brief(created)}`, ); } paginationThemeIds.push(created.data?.theme?.themeId); } check( '6) 51 个已发布主题创建完成(用于默认页大小 / 上限截断 / 游标翻页)', paginationThemeIds.every(Boolean), `created=${paginationThemeIds.filter(Boolean).length}`, ); const defaultPage = await publicThemes(); check( '6) 不给 limit → 默认 20 条且给出 nextCursor', defaultPage.status === 200 && defaultPage.data?.themes?.length === 20 && typeof defaultPage.data?.nextCursor === 'string' && defaultPage.data.nextCursor.length > 0, `size=${defaultPage.data?.themes?.length} nextCursor=${JSON.stringify(defaultPage.data?.nextCursor ?? null).slice(0, 40)}`, ); const hugeLimit = await publicThemes({ limit: 999 }); check( '6) limit=999 → 截断到上限 50(既不是错误,也不是全量)', hugeLimit.status === 200 && hugeLimit.data?.themes?.length === 50, `size=${hugeLimit.data?.themes?.length}`, ); const collectedIds = []; let cursor = undefined; let pages = 0; let lastCursor = 'unset'; for (;;) { const page = await publicThemes({ limit: 20, ...(cursor ? { cursor } : {}), }); if (page.status !== 200) { check('6) 游标翻页每一页都 200', false, brief(page)); break; } pages += 1; collectedIds.push(...themeIdsOf(page)); lastCursor = page.data?.nextCursor ?? null; if (!lastCursor) break; cursor = lastCursor; if (pages > 20) { check('6) 游标翻页在合理页数内结束', false, `pages=${pages}`); break; } } const uniqueIds = new Set(collectedIds); check( '6) 游标翻页不重复(所有 themeId 唯一)', uniqueIds.size === collectedIds.length, `pages=${pages} total=${collectedIds.length} unique=${uniqueIds.size}`, ); check( '6) 游标翻页不漏(本轮的 51 个分页主题 + T1/T2/空成员主题都能翻到)', paginationThemeIds.every((id) => uniqueIds.has(id)) && uniqueIds.has(t1Id) && uniqueIds.has(t2Id) && uniqueIds.has(emptyThemeId), `missing=${[...paginationThemeIds, t1Id, t2Id, emptyThemeId].filter((id) => id && !uniqueIds.has(id)).length}`, ); check( '6) 末页 nextCursor 为 null', lastCursor === null, `lastCursor=${JSON.stringify(lastCursor)}`, ); const badCursor = await publicThemes({ cursor: 'abc' }); check( '6) ?cursor=abc → 400(状态码符合契约)', badCursor.status === 400, brief(badCursor), ); check( '6) ?cursor=abc → 稳定错误码 THEME_INVALID_CURSOR(此前红断言,本轮应转绿)', errorCode(badCursor) === 'THEME_INVALID_CURSOR', brief(badCursor), ); note( '该断言是上一轮标红的产品缺口回归网:模块侧现在把常量前缀拼进游标错误文案' + '(module-game-distribution/src/theme.rs 的 `GAME_DISTRIBUTION_THEME_INVALID_CURSOR_CODE` + 「: 主题列表游标格式无效」),' + 'api-server 的 `map_spacetime_error` 前缀分支因此可达,客户端拿到的 error.code 从 BAD_REQUEST 变成 THEME_INVALID_CURSOR(状态码仍是 400)。', ); // ---------- 7:后台权限 ---------- const anonymousAdminCalls = [ ['GET 列表', () => adminListThemes({})], [ 'POST 创建', () => api('/admin/api/game-distribution/themes', { method: 'POST', headers: { 'Idempotency-Key': `theme-anon-${stamp}` }, body: { name: `匿名主题 ${suffix}`, status: 'draft' }, }), ], [ 'PUT 更新', () => api(`/admin/api/game-distribution/themes/${t1Id}`, { method: 'PUT', headers: { 'Idempotency-Key': `theme-anon-upd-${stamp}` }, body: { name: `匿名改 ${suffix}`, summary: '', badge: '', sortOrder: 0, status: 'draft', }, }), ], [ 'PUT 成员', () => api(`/admin/api/game-distribution/themes/${t1Id}/members/${rootId}`, { method: 'PUT', body: { sortOrder: 0 }, }), ], [ 'DELETE 成员', () => api(`/admin/api/game-distribution/themes/${t1Id}/members/${rootId}`, { method: 'DELETE', }), ], ]; for (const [label, call] of anonymousAdminCalls) { const response = await call(); check( `7) 未登录后台 ${label} → 401`, response.status === 401, brief(response), ); } const strangerAdmin = await api('/admin/api/game-distribution/themes', { token: stranger.token, }); check( '7) 普通作者 token 访问后台主题列表 → 403', strangerAdmin.status === 403, brief(strangerAdmin), ); const memberPutAgain = await adminPutMember({ admin, themeId: t1Id, rootGameId: rootId, sortOrder: 7, }); const detailAfterRepeat = await publicThemeDetail(t1Id); check( '7) 重复 PUT 同一成员只留一行(roots 仍 1 条)且 sortOrder 被更新', memberPutAgain.status === 200 && memberPutAgain.data?.sortOrder === 7 && memberPutAgain.data?.createdAt === memberPut.data?.createdAt && detailAfterRepeat.data?.roots?.length === 1 && detailAfterRepeat.data?.memberCount === 1, `createdAt(第一次)=${memberPut.data?.createdAt} createdAt(复传)=${memberPutAgain.data?.createdAt} roots=${detailAfterRepeat.data?.roots?.length}`, ); const deleteExisting = await adminDeleteMember({ admin, themeId: t2Id, rootGameId: rootId, }); const deleteExistingAgain = await adminDeleteMember({ admin, themeId: t2Id, rootGameId: rootId, }); const deleteNeverAdded = await adminDeleteMember({ admin, themeId: t2Id, rootGameId: secondRootId, }); check( '7) DELETE 成员幂等:已删再删 data 相同;删不存在的成员同样 200 且同形状', deleteExisting.status === 200 && JSON.stringify(deleteExisting.data) === JSON.stringify(deleteExistingAgain.data) && deleteNeverAdded.status === 200 && JSON.stringify(Object.keys(deleteNeverAdded.data ?? {}).sort()) === JSON.stringify(['rootGameId', 'themeId']), `first=${JSON.stringify(deleteExisting.data)} again=${JSON.stringify(deleteExistingAgain.data)} never=${JSON.stringify(deleteNeverAdded.data)}`, ); note( 'DELETE 的「逐字节相同」指 data 载荷:平台信封里的 meta.requestId 每个请求都不同(既有协议),' + '因此比较的是 data 而非整段响应文本。', ); // ---------- 8:后台幂等 ---------- const idempotencyKey = `theme-idem-${stamp}`; const idemBody = { name: `主题幂等 ${suffix}`, summary: '同键重放', badge: 'I', sortOrder: 4, status: 'published', }; const idemFirst = await adminCreateTheme({ admin, key: idempotencyKey, body: idemBody, }); const idemReplay = await adminCreateTheme({ admin, key: idempotencyKey, body: idemBody, }); const idemThemeId = idemFirst.data?.theme?.themeId; const adminAll = await adminListThemes({ admin, limit: 200 }); const sameNameCount = (adminAll.data?.themes ?? []).filter( (theme) => theme.name === idemBody.name, ).length; check( '8) 同键重放 → replayed=true 且 themeId 相同', idemFirst.status === 200 && idemReplay.status === 200 && idemFirst.data?.replayed === false && idemReplay.data?.replayed === true && idemReplay.data?.theme?.themeId === idemThemeId, `first=${idemFirst.data?.replayed} replay=${idemReplay.data?.replayed}`, ); check( '8) 同键重放不产生第二个主题(同名主题在后台列表中只有 1 条)', sameNameCount === 1, `sameName=${sameNameCount}`, ); const idemConflict = await adminCreateTheme({ admin, key: idempotencyKey, body: { ...idemBody, name: `主题幂等改名 ${suffix}` }, }); check( '8) 同键换请求 → 409 THEME_IDEMPOTENCY_CONFLICT', idemConflict.status === 409 && errorCode(idemConflict) === 'THEME_IDEMPOTENCY_CONFLICT', brief(idemConflict), ); const missingKey = await api('/admin/api/game-distribution/themes', { method: 'POST', token: admin, body: { name: `缺键主题 ${suffix}`, status: 'draft' }, }); check( '8) 创建主题缺 Idempotency-Key → 400', missingKey.status === 400 && errorCode(missingKey) === 'BAD_REQUEST', brief(missingKey), ); // ---------- 9:公开路由匿名可读 + no-store ---------- for (const [label, pathname] of [ ['主题列表', '/api/game-distribution/themes'], ['主题详情', `/api/game-distribution/themes/${t1Id}`], ['作品详情', `/api/game-distribution/games/${rootId}`], ]) { const response = await api(pathname); check( `9) 匿名 ${label} 200 且带 Cache-Control: no-store`, response.status === 200 && response.cacheControl.includes('no-store'), `status=${response.status} cache-control=${response.cacheControl || '∅'}`, ); } // ---------- 10:后台成员名单(含不可见成员 / 草稿主题 / 分页 / 错误码) ---------- // 契约:`admin_list_theme_members`(不套公开可见性过滤、draft/archived 也能读)、 // `totalMembers` 是成员行总数(不受分页影响)、`visibility ∈ missing|deleted|published|unpublished|suspended`、 // `visible` 复用公开侧同一份判定、分页沿用主题列表的 20/50 与游标解析。 const bulkTheme = await adminCreateTheme({ admin, key: `theme-bulk-${stamp}`, body: { name: `主题批量成员 ${suffix}`, summary: '成员行数 > 50、可见成员为 0', badge: 'B', sortOrder: 8, status: 'published', }, }); const bulkThemeId = bulkTheme.data?.theme?.themeId; check('10) 批量成员主题创建成功', Boolean(bulkThemeId), brief(bulkTheme)); // 用「未公开草稿作品」当成员:可见成员恒为 0,但成员行数 > 50,用来验证 // memberCount 只数可见成员、后台名单 totalMembers 数行且分页可翻。 let reusableCoverAssetId = await uploadCover(author.token, `${stamp}-bulk`); const bulkRootIds = []; let reuseNoted = false; for (let index = 0; index < 51; index += 1) { const title = `批量成员根作品 ${suffix}-${index}`; let created = await createGame({ token: author.token, metadata: gameMetadata({ title, coverAssetId: reusableCoverAssetId }), idemKey: `theme-bulk-root-${index}-${stamp}`, }); if (created.status !== 200) { if (!reuseNoted) { note( `封面 assetId 不能跨作品复用时改为逐件上传(首次失败:${brief(created)})`, ); reuseNoted = true; } reusableCoverAssetId = await uploadCover( author.token, `${stamp}-bulk-${index}`, ); created = await createGame({ token: author.token, metadata: gameMetadata({ title, coverAssetId: reusableCoverAssetId }), idemKey: `theme-bulk-root-retry-${index}-${stamp}`, }); } const id = created.data?.id; if (!id) { check( '10) 批量根作品创建成功', false, `index=${index} ${brief(created)}`, ); break; } bulkRootIds.push(id); const memberResponse = await adminPutMember({ admin, themeId: bulkThemeId, rootGameId: id, sortOrder: index, }); if (memberResponse.status !== 200) { check( '10) 批量成员写入成功', false, `index=${index} ${brief(memberResponse)}`, ); break; } } check( '10) 51 个未公开根作品加入同一主题(成员行数 > 50)', bulkRootIds.length === 51, `rows=${bulkRootIds.length}`, ); const bulkPublicDetail = await publicThemeDetail(bulkThemeId); check( '10) memberCount 只数可见成员:51 行不可见成员 → memberCount=0、roots=[]、rootsTruncated=false', bulkPublicDetail.status === 200 && bulkPublicDetail.data?.memberCount === 0 && bulkPublicDetail.data?.roots?.length === 0 && bulkPublicDetail.data?.rootsTruncated === false, `memberCount=${bulkPublicDetail.data?.memberCount} roots=${bulkPublicDetail.data?.roots?.length} rootsTruncated=${JSON.stringify(bulkPublicDetail.data?.rootsTruncated)}`, ); note( '未覆盖(成本):>50 个**可见**成员导致 roots 真被截断、rootsTruncated=true 的真实数据用例——' + '需要 51 个走完整发布链路的公开作品。该分支由 module-game-distribution/src/theme.rs 里 ' + '`game_distribution_theme_roots_truncated` 的单测(51/50 → true)覆盖;本脚本只覆盖了' + '「行数 >50、可见 0」的对照面(rootsTruncated=false)。', ); // 软删除成员:行仍在 → visible=false 且 visibility=deleted const softDeletedRoot = await createGame({ token: author.token, metadata: gameMetadata({ title: `软删除成员 ${suffix}`, coverAssetId: await uploadCover(author.token, `${stamp}-soft`), }), idemKey: `theme-soft-root-${stamp}`, }); const softDeletedRootId = softDeletedRoot.data?.id; if (!softDeletedRootId) { check('10) 软删除 fixture 作品创建成功', false, brief(softDeletedRoot)); } const softDeleted = softDeletedRootId ? await softDeleteGame({ token: author.token, gameId: softDeletedRootId, revision: softDeletedRoot.data?.publicationRevision ?? 0, key: `theme-soft-delete-${stamp}`, }) : { status: 0, text: 'fixture 未创建' }; if (softDeletedRootId) { await adminPutMember({ admin, themeId: bulkThemeId, rootGameId: softDeletedRootId, sortOrder: 100, }); } check( '10) 软删除 fixture 作品就绪', softDeleted.status === 200, brief(softDeleted), ); const bulkMembers = await adminThemeMembers({ admin, themeId: bulkThemeId, limit: 999, }); const bulkRows = bulkMembers.data?.members ?? []; check( '10) 行数 >50 时 limit=999 截断到 50(不是错误),totalMembers 仍是行总数 52,且仍有下一页', bulkMembers.status === 200 && bulkRows.length === 50 && bulkMembers.data?.totalMembers === 52 && typeof bulkMembers.data?.nextCursor === 'string', `rows=${bulkRows.length} totalMembers=${bulkMembers.data?.totalMembers} nextCursor=${JSON.stringify(bulkMembers.data?.nextCursor ?? null).slice(0, 30)}`, ); check( '10) totalMembers 不受分页影响:limit=1 时 members 只有 1 行但 totalMembers 仍为 52', (await adminThemeMembers({ admin, themeId: bulkThemeId, limit: 1 })).data ?.totalMembers === 52 && (await adminThemeMembers({ admin, themeId: bulkThemeId, limit: 1 })).data ?.members?.length === 1, `totalMembers=${(await adminThemeMembers({ admin, themeId: bulkThemeId, limit: 1 })).data?.totalMembers}`, ); const unpublishedRow = bulkRows.find( (member) => member.rootGameId === bulkRootIds[0], ); check( '10) 未公开成员照实返回:visible=false 且 visibility=unpublished', unpublishedRow?.visible === false && unpublishedRow?.visibility === 'unpublished', `visible=${JSON.stringify(unpublishedRow?.visible)} visibility=${unpublishedRow?.visibility}`, ); check( '10) 成员行键集合恰为契约的六个(visible 与 visibility 都在)', bulkRows.length > 0 && JSON.stringify(Object.keys(bulkRows[0]).sort()) === JSON.stringify( [ 'createdAt', 'rootGameId', 'sortOrder', 'title', 'visibility', 'visible', ].sort(), ), `keys=${Object.keys(bulkRows[0] ?? {}).join(',')}`, ); check( '10) 成员行按 sortOrder 升序返回', bulkRows.every( (row, index) => index === 0 || bulkRows[index - 1].sortOrder <= row.sortOrder, ), `sortOrders=${bulkRows .slice(0, 5) .map((row) => row.sortOrder) .join(',')}…`, ); const memberPageIds = []; const memberRows = []; let memberCursor; let memberPages = 0; let lastMemberCursor = 'unset'; for (;;) { const page = await adminThemeMembers({ admin, themeId: bulkThemeId, limit: 20, ...(memberCursor ? { cursor: memberCursor } : {}), }); if (page.status !== 200) { check('10) 成员名单游标翻页每页 200', false, brief(page)); break; } memberPages += 1; const pageRows = page.data?.members ?? []; memberRows.push(...pageRows); memberPageIds.push(...pageRows.map((row) => row.rootGameId)); lastMemberCursor = page.data?.nextCursor ?? null; if (!lastMemberCursor) break; memberCursor = lastMemberCursor; if (memberPages > 10) { check( '10) 成员名单游标翻页在合理页数内结束', false, `pages=${memberPages}`, ); break; } } check( '10) 成员名单游标翻页不重不漏(52 行分 3 页,末页 nextCursor=null)', memberPages === 3 && memberPageIds.length === 52 && new Set(memberPageIds).size === 52 && lastMemberCursor === null && bulkRootIds.every((id) => memberPageIds.includes(id)), `pages=${memberPages} total=${memberPageIds.length} unique=${new Set(memberPageIds).size} lastCursor=${JSON.stringify(lastMemberCursor)}`, ); const softDeletedRow = memberRows.find( (member) => member.rootGameId === softDeletedRootId, ); check( '10) 已软删除成员照实返回:visible=false 且 visibility=deleted(行未删除,末页仍能翻到)', softDeletedRow?.visible === false && softDeletedRow?.visibility === 'deleted', `visible=${JSON.stringify(softDeletedRow?.visible)} visibility=${softDeletedRow?.visibility ?? '∅'}(该行 sortOrder=100,落最后一页)`, ); const badMemberCursor = await adminThemeMembers({ admin, themeId: bulkThemeId, cursor: 'abc', }); check( '10) 成员名单非法游标 → 400 THEME_INVALID_CURSOR(与主题列表同一套解析)', badMemberCursor.status === 400 && errorCode(badMemberCursor) === 'THEME_INVALID_CURSOR', brief(badMemberCursor), ); const missingThemeMembers = await adminThemeMembers({ admin, themeId: `theme_${'0'.repeat(32)}`, }); check( '10) 主题不存在 → 404', missingThemeMembers.status === 404, brief(missingThemeMembers), ); const draftThemeMembers = await adminThemeMembers({ admin, themeId: draftId, limit: 20, }); check( '10) draft 主题也能读到成员名单(该接口存在的理由:公开侧看不见、运营要能看)', draftThemeMembers.status === 200 && (draftThemeMembers.data?.members ?? []).some( (member) => member.rootGameId === rootId, ) && draftThemeMembers.data?.totalMembers === 1, `status=${draftThemeMembers.status} totalMembers=${draftThemeMembers.data?.totalMembers}`, ); const anonymousMembers = await api( `/admin/api/game-distribution/themes/${bulkThemeId}/members`, ); const strangerMembers = await api( `/admin/api/game-distribution/themes/${bulkThemeId}/members`, { token: stranger.token }, ); check( '10) 未登录读成员名单 → 401;普通作者 token → 403', anonymousMembers.status === 401 && strangerMembers.status === 403, `anonymous=${anonymousMembers.status} stranger=${strangerMembers.status}`, ); note( '未覆盖:`visibility=missing`(游戏行本身不存在)与「visibility=published 但无当前公开版本 → visible=false」' + '两个防御性分支——前者需要删游戏行、后者需要在 kept-published 状态下清空当前公开版本,现有 HTTP 面都构造不出来;' + '两者在 `game_distribution_theme_member_visibility` / `game_distribution_theme_member_visible` 的单测里覆盖。', ); note( '未覆盖:本脚本不做主题成员的可见性上限(详情 roots 受 50 条响应上限约束)与「同一个根重复加入两个主题后删除其一的读取一致性」之外的并发场景;' + '分页排序键(created_at 倒序 + themeId 兜底)只通过「不重不漏」间接验证,未构造同微秒创建的主题对。', ); console.log( `[theme-e2e] 共 ${checks} 项:PASS ${checks - failures},FAIL ${failures},SKIP ${skipped}`, ); if (failures > 0) { process.exitCode = 1; } } main().catch((error) => { console.error(`[theme-e2e] 未捕获异常:${error?.stack ?? error}`); process.exitCode = 1; });