合并 origin/master 到 feat/adapt-xhs-skill:发布入口迁入导出产物面板并吸收发布绑定、内部版本与上传进度
Project CI / Backend tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m18s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m28s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m20s
Project CI / Repository checks (pull_request) Failing after 33s
Project CI / Frontend tests (pull_request) Successful in 3m7s
Project CI / AI game creator shell web tests (pull_request) Failing after 2m55s
Project CI / Native shell tests (pull_request) Successful in 8m39s

- 陶泥儿 tab 发布面板去掉前端灰度:删除 readGamePublishAvailability 调用、发布权限占位与对应测试 mock,与 master 删除原生灰度命令保持一致,只在项目清单就绪前显示「正在读取项目清单」
- App.tsx 保留本分支导出产物面板架构,发布入口不再挂 Direct 聊天头;useTaonierTab 收敛 allowed/availabilityPending 状态并同步注释
- 发布表单 hook 合并本分支表单状态与 master 的发布绑定回读、AGC 工程内部版本派生、线上最近提交与上传进度阶段
- GameDistributionPublishFormView 接入发布状态面板、只读「项目版本」标签、发布阶段步骤与「更新游戏」等文案
- 合并发布表单与发布反馈测试:恢复封面/试玩包断言,补 readGameDistributionPublication mock
- agc-skills/manifest.json 取本分支新增的 platform-abstract、vite-export-xhs-minitool 与 master 的三处 sha256,version 提升到 2026-08-26.66
- decision-log.md 保留双方新增条目;技术方案文档补回双方新增章节;玩法链路文档取 master 重写后的发行合同
This commit is contained in:
2026-10-06 20:01:16 +08:00
478 changed files with 49995 additions and 21633 deletions
+162 -35
View File
@@ -4,6 +4,13 @@ import http from 'node:http';
const port = Number(process.argv[2] ?? 8099);
/** 与后端 `MEMBERSHIP_UNLIMITED_CONCURRENCY` 一致的假数据哨兵。
*
* 后端定义:`server-rs/crates/module-runtime/src/membership/limits.rs`
* (`pub const MEMBERSHIP_UNLIMITED_CONCURRENCY: u32 = 128`;`>=` 该值视为不设上限)。
* 改后端常量时请同步这里,否则本地预览的「不限并发」会与线上不一致。 */
const UNLIMITED_CONCURRENCY_THRESHOLD = 128;
const admin = {
subject: 'admin:local',
username: 'owner',
@@ -447,20 +454,45 @@ const userDetail = {
createdAt: iso(60 * 24 * 120),
lastLoginAt: iso(30),
wallet: {
balance: 1860,
permanentPoints: 1200,
userId: 'u_1001',
totalBalance: 1860,
spendableBalance: 1660,
dailyFreePoints: 0,
monthlyPoints: 0,
permanentPoints: 1200,
heldPoints: 200,
refundDebtPoints: 0,
walletFrozen: false,
manualFrozen: false,
refundDebtFrozen: false,
},
membership: {
plan: 'plus',
status: 'active',
cycleKind: 'monthly',
cycleIndex: 1,
cycleCount: 1,
cycleGrantedPoints: 600,
cycleRemainingPoints: 320,
startedAtMicros: 1_790_640_000_000_000,
expiresAtMicros: 1_793_232_000_000_000,
cycleResetsAtMicros: 1_793_232_000_000_000,
updatedAtMicros: 1_790_640_000_000_000,
},
rechargeOrders: [],
restrictions: {},
tags: ['vip', '内测'],
};
function route(pathname, method, url) {
function parseJsonBody(requestBody) {
try {
return JSON.parse(requestBody);
} catch {
return null;
}
}
function route(pathname, method, url, requestBody = '') {
if (pathname === '/admin/api/login' && method === 'POST') {
return { token: 'local-fake-token', admin };
}
@@ -617,7 +649,7 @@ function route(pathname, method, url) {
totalBalance: 1_860,
spendableBalance: 1_660,
dailyFreePoints: 0,
membershipLimitedPoints: 0,
monthlyPoints: 0,
permanentPoints: 1_200,
heldPoints: 200,
refundDebtPoints: 0,
@@ -723,28 +755,68 @@ function route(pathname, method, url) {
scannedCount: orders.length,
};
}
if (pathname === '/admin/api/profile/recharge-products') {
const product = (
productId,
if (pathname === '/admin/api/profile/membership-plans') {
const plan = (
planId,
title,
priceCents,
pointsAmount,
bonusPoints,
rank,
monthPriceCents,
yearPriceCents,
periodPoints,
modelAccess,
concurrentJobLimit,
enabled,
) => ({
plan: planId,
title,
rank,
monthPriceCents,
yearPriceCents,
periodPoints,
modelAccess,
concurrentJobLimit,
// 与后端目录单一真相源 `PROFILE_MEMBERSHIP_RECOMMENDED_PLAN` 对齐:运营推荐 Plus。
recommended: planId === 'plus',
unlimitedConcurrency:
concurrentJobLimit >= UNLIMITED_CONCURRENCY_THRESHOLD,
enabled,
});
const plans = [
plan('normal', '非会员', 0, 0, 0, 0, 'basic', 1, false),
plan('starter', '入门会员', 1, 3_900, 39_000, 400, 'basic', 1, true),
plan('plus', '轻享会员', 2, 9_900, 99_000, 1_150, 'full', 3, true),
plan('pro', '专业会员', 3, 29_900, 299_000, 3_650, 'full', 5, true),
plan('max', '旗舰会员', 4, 69_900, 699_000, 8_650, 'full', 128, true),
];
// 本地预览按请求体回显被保存的档位:把提交字段覆盖到对应档位行,避免保存非 starter 档后表单被回填成 starter。
if (method === 'POST') {
const submitted = parseJsonBody(requestBody);
const target =
plans.find((entry) => entry.plan === submitted?.plan) ?? plans[1];
const merged = { ...target, ...(submitted ?? {}) };
return {
...merged,
unlimitedConcurrency:
Number(merged.concurrentJobLimit) >= UNLIMITED_CONCURRENCY_THRESHOLD,
};
}
return {
entries: plans,
unlimitedConcurrencyThreshold: UNLIMITED_CONCURRENCY_THRESHOLD,
};
}
if (pathname === '/admin/api/profile/recharge-products') {
const product = (productId, title, priceCents, pointsAmount) => ({
productId,
title,
priceCents,
kind: 'points',
pointsAmount,
bonusPoints,
// 已退役列:真实后端固定写 0,假数据也保持一致。
bonusPoints: 0,
durationDays: 0,
badgeLabel: bonusPoints ? `赠送 ${bonusPoints}` : '',
badgeLabel: '',
description: `${pointsAmount} 泥点`,
tier: 'none',
membershipPeriodPoints: 0,
membershipPeriodDays: 0,
membershipQueueLimit: 0,
membershipDiscountBps: 0,
enabled: true,
sortOrder: 1,
createdBy: 'u_admin',
@@ -754,8 +826,8 @@ function route(pathname, method, url) {
});
return {
entries: [
product('prod_points_600', '600 泥点', 3_000, 600, 0),
product('prod_points_1200', '1200 泥点 + 赠送 100', 6_000, 1_200, 100),
product('prod_points_600', '600 泥点', 3_000, 600),
product('prod_points_1200', '1200 泥点', 6_000, 1_200),
],
};
}
@@ -999,18 +1071,21 @@ function route(pathname, method, url) {
alias: '标准模型',
modelId: 'gpt-5.6-sol',
enabled: true,
access: 'full',
},
{
id: 'model_terra',
alias: '快速模型',
modelId: 'gpt-5.6-terra',
enabled: true,
access: 'basic',
},
{
id: 'model_legacy',
alias: '旧版模型',
modelId: 'gpt-5.4',
enabled: false,
access: 'basic',
},
],
};
@@ -1027,6 +1102,7 @@ function route(pathname, method, url) {
publicationRevision: versions[0]?.versionNumber ?? 0,
createdAt: iso(60 * 24 * 20),
updatedAt: iso(60 * 3),
deletedAt: null,
versions,
});
const version = (versionId, versionNumber, status, bytes) => ({
@@ -1041,17 +1117,56 @@ function route(pathname, method, url) {
reviewReason: null,
entryUrl: `https://games.example.com/${versionId}/index.html`,
});
const allGames = [
game('game_1001', '陶泥小世界', '阿陶', 'published', 345, [
version('ver_1001_7', 7, 'published', 18_400_000),
version('ver_1001_6', 6, 'published', 17_900_000),
]),
game('game_1002', '纸片拼图计划', '小满', 'suspended', 96, [
version('ver_1002_3', 3, 'suspended', 5_120_004),
]),
game('game_1003', '三消工坊', '老周', 'unpublished', 12, []),
{
...game('game_1004', '已删除的试作', '阿陶', 'unpublished', 4, []),
status: 'deleted',
deletedAt: iso(60 * 2),
},
];
const limit = Math.min(
Math.max(Number(url.searchParams.get('limit') ?? 50) || 50, 1),
200,
);
const keyword = (url.searchParams.get('keyword') ?? '')
.trim()
.toLowerCase();
const owner = (url.searchParams.get('owner') ?? '').trim();
const status = (url.searchParams.get('status') ?? '').trim();
const offset = Number(url.searchParams.get('cursor') ?? 0) || 0;
// 状态/关键词/作者过滤与游标语义跟服务端保持一致:默认排除已删除作品。
const filtered = allGames.filter((entry) => {
if (status === 'deleted') {
if (entry.status !== 'deleted') return false;
} else if (status) {
if (entry.status !== status || entry.deletedAt) return false;
} else if (entry.deletedAt) {
return false;
}
if (owner && entry.author.id !== owner) return false;
if (
keyword &&
!entry.title.toLowerCase().includes(keyword) &&
!entry.gameId.toLowerCase().includes(keyword) &&
!entry.author.id.toLowerCase().includes(keyword)
) {
return false;
}
return true;
});
const page = filtered.slice(offset, offset + limit);
const nextOffset = offset + page.length;
return {
games: [
game('game_1001', '陶泥小世界', '阿陶', 'published', 345, [
version('ver_1001_7', 7, 'published', 18_400_000),
version('ver_1001_6', 6, 'published', 17_900_000),
]),
game('game_1002', '纸片拼图计划', '小满', 'suspended', 96, [
version('ver_1002_3', 3, 'suspended', 5_120_004),
]),
game('game_1003', '三消工坊', '老周', 'unpublished', 12, []),
],
games: page,
nextCursor: nextOffset < filtered.length ? String(nextOffset) : null,
};
}
if (pathname === '/admin/api/editor-assets') {
@@ -1104,13 +1219,25 @@ function route(pathname, method, url) {
const server = http.createServer((request, response) => {
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`);
const payload = route(url.pathname, request.method ?? 'GET', url);
const body = JSON.stringify({ data: payload });
response.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'x-genarrative-response-envelope': 'v1',
const chunks = [];
request.on('data', (chunk) => {
chunks.push(chunk);
});
request.on('end', () => {
const requestBody = Buffer.concat(chunks).toString('utf8');
const payload = route(
url.pathname,
request.method ?? 'GET',
url,
requestBody,
);
const body = JSON.stringify({ data: payload });
response.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'x-genarrative-response-envelope': 'v1',
});
response.end(body);
});
response.end(body);
});
server.listen(port, '127.0.0.1', () => {
+1 -1
View File
@@ -75,7 +75,7 @@ set -euo pipefail
rustc -vV > /opt/genarrative-ci/rust-cache/rustc.txt
export GITHUB_ENV=/tmp/rust-cache.env CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=4 CI=true
# sccache 对 CARGO_*(除 jobserver/jobs 等特例)参与 hash,必须与 workflow 对齐。
export CARGO_HTTP_MULTIPLEXING=false CARGO_NET_RETRY=10 CARGO_TERM_COLOR=always
export CARGO_HTTP_MULTIPLEXING=false CARGO_NET_RETRY=10 CARGO_TERM_COLOR=always CARGO_TERM_QUIET=true
bash /tmp/ci-rust-cache.sh prepare
set -a
source "${GITHUB_ENV}"
+22 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// 检查 game-distribution 的 Rust DTO 与手写 TS DTO 是否一致。
// 检查游戏分发与创作者公开查询的 Rust DTO 与手写 TS DTO 是否一致。
//
// 为什么需要它:`packages/shared/src/contracts/gameDistribution.ts` 是手写的,没有生成绑定兜底。
// Rust 侧加字段而 TS 侧忘改时,只有跑起来才会发现。这里用一张显式映射表逐字段/逐变体比对:
@@ -16,11 +16,19 @@ const RUST_FILE = 'server-rs/crates/shared-contracts/src/game_distribution.rs';
const TS_FILE = 'packages/shared/src/contracts/gameDistribution.ts';
const ADMIN_RUST_FILE = 'server-rs/crates/shared-contracts/src/admin.rs';
const ADMIN_TS_FILE = 'apps/admin-web/src/api/adminApiTypes.ts';
const CREATOR_RUST_FILE = 'server-rs/crates/shared-contracts/src/creator.rs';
const CREATOR_TS_FILE = 'packages/shared/src/contracts/creator.ts';
const API_MODULE_FILE =
'server-rs/crates/api-server/src/modules/game_distribution.rs';
// [Rust 类型名, TS 类型名]
const PAIRS = [
['CreatorUser', 'CreatorUser'],
['CreatorRelationship', 'CreatorRelationship'],
['CreatorProfile', 'CreatorProfile'],
['CreatorConnection', 'CreatorConnection'],
['CreatorConnections', 'CreatorConnections'],
['CreatorRelationshipResponse', 'CreatorRelationshipResponse'],
[
'GameDistributionPublishMetadataSuggestionRequest',
'GameDistributionPublishMetadataSuggestionRequest',
@@ -49,6 +57,10 @@ const PAIRS = [
'GameDistributionCreateVersionRequest',
'GameDistributionCreateVersionRequest',
],
[
'GameDistributionUpdateGameMetadataRequest',
'GameDistributionUpdateGameMetadataRequest',
],
['GameDistributionPrivateVersion', 'GameDistributionPrivateVersion'],
['AdminGameReviewGamesQuery', 'AdminGameReviewGamesQuery'],
['AdminGameReviewGame', 'AdminGameReviewGame'],
@@ -120,7 +132,7 @@ function renamedMember(value, kind, rename) {
function rustDefinitions(source) {
const result = new Map();
const pattern =
/\n(?<attrs>(?:#\[[^\n]*\]\n)*)pub (?<kind>struct|enum) (?<name>GameDistribution\w+|AdminGameReview\w*)(?<body>[\s\S]*?)\n\}/g;
/\n(?<attrs>(?:#\[[^\n]*\]\n)*)pub (?<kind>struct|enum) (?<name>GameDistribution\w+|AdminGameReview\w*|Creator\w+)(?<body>[\s\S]*?)\n\}/g;
let match;
while ((match = pattern.exec(source))) {
const { attrs, kind, name, body } = match.groups;
@@ -141,7 +153,7 @@ function rustDefinitions(source) {
function tsDefinitions(source) {
const result = new Map();
const objectPattern =
/export (?:type|interface) (GameDistribution\w+|AdminGameReview\w*)(?: =)? \{([\s\S]*?)\n\};?/g;
/export (?:type|interface) (GameDistribution\w+|AdminGameReview\w*|Creator\w+)(?: =)? \{([\s\S]*?)\n\};?/g;
let match;
while ((match = objectPattern.exec(source))) {
const members = [];
@@ -155,7 +167,7 @@ function tsDefinitions(source) {
result.set(match[1], { kind: 'struct', members, required });
}
const unionPattern =
/export type (GameDistribution\w+|AdminGameReview\w*) =\s*([^;]+);/g;
/export type (GameDistribution\w+|AdminGameReview\w*|Creator\w+) =\s*([^;]+);/g;
while ((match = unionPattern.exec(source))) {
if (result.has(match[1])) continue;
result.set(match[1], {
@@ -320,12 +332,16 @@ function insertedKeys(body) {
const rust = rustDefinitions(
fs.readFileSync(RUST_FILE, 'utf8') +
'\n' +
fs.readFileSync(ADMIN_RUST_FILE, 'utf8'),
fs.readFileSync(ADMIN_RUST_FILE, 'utf8') +
'\n' +
fs.readFileSync(CREATOR_RUST_FILE, 'utf8'),
);
const ts = tsDefinitions(
fs.readFileSync(TS_FILE, 'utf8') +
'\n' +
fs.readFileSync(ADMIN_TS_FILE, 'utf8'),
fs.readFileSync(ADMIN_TS_FILE, 'utf8') +
'\n' +
fs.readFileSync(CREATOR_TS_FILE, 'utf8'),
);
const failures = [];
const mappedRust = new Set(PAIRS.map(([rustName]) => rustName));
@@ -18,6 +18,7 @@
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-ops-rollback-e2e
import { createHash, randomBytes } from 'node:crypto';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import JSZip from 'jszip';
@@ -99,6 +100,37 @@ async function release(pathname) {
return { status: response.status, body, headers: response.headers };
}
/// 原样请求(不自动解压):压缩与条件请求必须看真实线字节,不能让 fetch 帮忙解码。
function releaseRaw(pathname, { acceptEncoding, ifNoneMatch } = {}) {
return new Promise((resolve, reject) => {
const target = new URL(`${API}${pathname}`);
const headers = {};
if (acceptEncoding) headers['accept-encoding'] = acceptEncoding;
if (ifNoneMatch) headers['if-none-match'] = ifNoneMatch;
const request = http.request(
{
hostname: target.hostname,
port: target.port,
path: `${target.pathname}${target.search}`,
headers,
},
(response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () =>
resolve({
status: response.statusCode,
headers: response.headers,
body: Buffer.concat(chunks),
}),
);
},
);
request.on('error', reject);
request.end();
});
}
async function adminToken() {
const login = await api('/admin/api/login', {
method: 'POST',
@@ -397,6 +429,47 @@ async function main() {
);
assertReleaseHeaders('v1 CSS', baselineCss, { html: false });
// 传输与条件请求契约:引擎包动辄 1 MiB 以上,文本资源必须按 Accept-Encoding 压缩,
// 并在 60 秒缓存窗口之后靠 ETag 重验证(304)而不是重下整包。
const releasePath = `/api/game-distribution/releases/${gameId}/index.html`;
const compressedHtml = await releaseRaw(releasePath, {
acceptEncoding: 'gzip',
});
check(
'发行文本资源按 Accept-Encoding 压缩并声明 Vary',
compressedHtml.status === 200 &&
compressedHtml.headers['content-encoding'] === 'gzip' &&
String(compressedHtml.headers.vary ?? '').includes('accept-encoding') &&
compressedHtml.body.length < Buffer.byteLength(baselineRelease.body),
`encoding=${compressedHtml.headers['content-encoding'] ?? ''} vary=${compressedHtml.headers.vary ?? ''} bytes=${compressedHtml.body.length}/${Buffer.byteLength(baselineRelease.body)}`,
);
const releaseEtag = baselineRelease.headers.get('etag') ?? '';
check(
'发行资源下发强 ETag',
/^"[0-9a-f]{32}"$/u.test(releaseEtag),
`etag=${releaseEtag}`,
);
const revalidated = await releaseRaw(releasePath, {
ifNoneMatch: releaseEtag,
});
check(
'If-None-Match 命中时返回 304 且不带正文',
revalidated.status === 304 &&
revalidated.body.length === 0 &&
revalidated.headers['cache-control'] === RELEASE_CACHE_CONTROL &&
revalidated.headers.etag === releaseEtag,
`status=${revalidated.status} body=${revalidated.body.length} cache-control=${revalidated.headers['cache-control'] ?? ''}`,
);
const identityHtml = await releaseRaw(releasePath, {
acceptEncoding: 'identity',
});
check(
'客户端不接受 gzip 时不下发压缩体',
identityHtml.status === 200 &&
identityHtml.headers['content-encoding'] === undefined,
`encoding=${identityHtml.headers['content-encoding'] ?? ''}`,
);
const pendingIds = {};
for (const marker of ['V2-OK', 'V3-OK']) {
const built = await buildPackage(marker);
@@ -7,8 +7,8 @@
// E2E_CHROMIUM_EXECUTABLE=<ms-playwright 里的 chrome.exe,可省略>
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-sandbox-e2e
//
// 覆盖:ES module 与同包资源能在 opaque sandbox 下载入;外站 fetch / WebSocket 被挡;
// localStorage / document.cookie / 父文档 DOM 都拿不到;顶层跳转与弹窗被挡;
// 覆盖:ES module 与同包资源能在 opaque sandbox 下载入;运行期 storage 兼容层可用;
// 外站 fetch / WebSocket 被挡;document.cookie / 父文档 DOM 都拿不到;顶层跳转和弹窗被挡;
// 敏感权限(定位)被拒;同时核对发行网关的 CSP / nosniff / CORS / Cookie 403 策略。
import { createRequire } from 'node:module';
import path from 'node:path';
@@ -140,14 +140,14 @@ const PROBE_APP = `import { marker } from './helper.js';
const results = { moduleLoaded: marker === 'helper-ok' };
// 探针自身也可能被浏览器直接抛错挡下(例如 opaque origin 读 cookie),
// 所以每一步单独兜底,最后无论如何都把结果 postMessage 给父页面。
try {
// opaque origin 的原生 storage 不可用,但发行网关会在游戏脚本前注入运行期兼容层。
try {
window.localStorage.getItem('probe');
results.storageBlocked = false;
window.localStorage.setItem('probe', 'ok');
results.storageAvailable = window.localStorage.getItem('probe') === 'ok';
window.localStorage.removeItem('probe');
} catch {
results.storageBlocked = true;
results.storageAvailable = false;
}
try {
@@ -479,8 +479,8 @@ document.body.appendChild(frame);
results.moduleLoaded === true,
);
check(
'localStorage 在 opaque sandbox 下不可用',
results.storageBlocked === true,
'opaque sandbox 下运行期 storage 兼容层可用',
results.storageAvailable === true,
);
check(
'document.cookie 在 opaque sandbox 下为空',
+27 -2
View File
@@ -2642,7 +2642,6 @@ function assertAiGameCreatorShellUserDevBoundary() {
'function resolveEmbeddedPreviewUrl(',
"url.protocol !== 'http:' || url.hostname !== '127.0.0.1'",
'sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"',
'src={embeddedUrl}',
]) {
if (
!sourceIncludesSnippet(aiGameCreatorLocalGamePreviewFrameSource, snippet)
@@ -2652,6 +2651,29 @@ function assertAiGameCreatorShellUserDevBoundary() {
);
}
}
// 预览框的 `src` 允许经一个短变量中转(刷新时要在地址上叠加 `__agc_reload` 这类非业务参数),
// 但这个变量必须由被校验过的 `embeddedUrl` 派生:只换名字、丢掉派生关系仍按漂移处理。
const iframeUrlDerivation = aiGameCreatorLocalGamePreviewFrameSource.match(
/const\s+iframeUrl\s*=\s*useMemo\(\s*\(\s*\)\s*=>\s*\{[\s\S]*?\}\s*,\s*\[/,
);
if (
!sourceIncludesSnippet(
aiGameCreatorLocalGamePreviewFrameSource,
'src={embeddedUrl}',
) &&
!(
sourceIncludesSnippet(
aiGameCreatorLocalGamePreviewFrameSource,
'src={iframeUrl ?? undefined}',
) &&
iframeUrlDerivation !== null &&
iframeUrlDerivation[0].includes('embeddedUrl')
)
) {
throw new Error(
'AI game creator embedded preview boundary drifted: iframe src must derive from the validated embeddedUrl',
);
}
for (const snippet of [
"await invoke<LocalPreviewStatus>(\n 'activate_local_game_preview'",
'已载入客户端运行视图',
@@ -5081,7 +5103,7 @@ for (const step of steps) {
continue;
}
console.log(`[check:native-shells] ${step.label}`);
// 成功不打印进度标签;只有在失败时才知道是哪个 step,避免每个 step 一行刷屏。
const result = spawnSync(step.command, step.args, {
cwd: process.cwd(),
stdio: 'inherit',
@@ -5102,6 +5124,9 @@ for (const step of steps) {
}
if ((result.status ?? 0) !== 0) {
console.error(
`[check:native-shells] ${step.label} failed (exit=${result.status})`,
);
process.exit(result.status ?? 1);
}
}
@@ -7725,6 +7725,28 @@ for (const [file, content] of [
}
}
for (const [snippet, reason] of [
[
'-o ServerAliveInterval=20 -o ServerAliveCountMax=6',
'Mac AGC Checkout 的 Git SSH 必须启用保活,避免 WebSocket idle timeout 断开 agent。',
],
[
'echo "[agc-macos] git fetch 仍在进行',
'Mac AGC Checkout 的 git fetch 必须持续输出心跳,避免长时间无 stdout 被误判为 agent 失联。',
],
[
'AGC_GIT_FETCH_TIMEOUT_SECONDS',
'Mac AGC Checkout 的 git fetch 必须有可配置的超时和失败诊断。',
],
]) {
if (!agcMacosPipelineContent.includes(snippet)) {
failed = true;
console.error(
`[check:production-ops] jenkins/Jenkinsfile.ai-game-creator-shell-macos-build ${reason}`,
);
}
}
// 更新签名私钥只允许经 Jenkins 凭据绑定注入构建进程:既不得把凭据本身写进 Jenkinsfile,
// 也不得回显到日志或进归档(对应「AGC 更新发布管线渠道化」第 5 条的可静态复核部分)。
for (const [file, content] of [
@@ -70,7 +70,6 @@ const retiredOriginalSpacetimeSources = new Set([
'runtime/admin_work_visibility.rs',
'runtime/browse_history.rs',
'runtime/creation_entry_config.rs',
'runtime/profile.rs',
'runtime/settings.rs',
'runtime/snapshots.rs',
]);
+6 -6
View File
@@ -54,39 +54,39 @@ const forbiddenSnippets = [
'ai_result_reference 快照组装应使用 by_ai_result_reference_task_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs',
snippet:
'.profile_save_archive()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)',
reason: 'profile_save_archive 已有 by_profile_save_archive_user_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs',
snippet:
'.profile_played_world()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)',
reason: 'profile_played_world 已有 by_profile_played_world_user_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs',
snippet:
'.profile_wallet_ledger()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)',
reason: 'profile_wallet_ledger 已有 by_profile_wallet_ledger_user_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs',
snippet:
'.profile_referral_relation()\n .iter()\n .filter(|row| row.inviter_user_id == user_id)',
reason:
'profile_referral_relation 已有 by_profile_referral_inviter_user_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs',
snippet:
'.profile_recharge_order()\n .iter()\n .filter(|row| row.user_id == user_id)',
reason:
'profile_recharge_order 已有 by_profile_recharge_order_user_id 索引',
},
{
file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs',
file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs',
snippet: '.tracking_daily_stat()\n .iter()\n .filter(|row| {',
reason:
'tracking_daily_stat 已有 by_tracking_daily_stat_scope_day / event_day 索引,analytics 查询不应整表过滤',
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""创作者关系真实 API / SpacetimeDB 验证,仅允许显式指定的隔离本地库。
先用仓库 dev 启动器启动全新 creator-hub-validation 库和 API,启用开发密码注册。
运行:python3 scripts/test-creator-runtime.py --database creator-hub-validation
测试凭据只保存在内存或隔离 data dir,不输出令牌,不触发外部媒体服务。
"""
import argparse
import concurrent.futures
import hashlib
import json
import os
from pathlib import Path
import time
import urllib.error
import urllib.parse
import urllib.request
def request(url, method="GET", body=None, token=None, expected=200):
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = "Bearer " + token
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, method=method, data=data, headers=headers)
try:
response = urllib.request.urlopen(req, timeout=30)
except urllib.error.HTTPError as error:
response = error
assert response.status == expected, f"{method} {urllib.parse.urlsplit(url).path}: {response.status} != {expected}"
raw = response.read()
return (json.loads(raw) if raw else None), response.headers
def option(raw):
return raw[1] if raw[0] == 0 else None
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--database", required=True)
parser.add_argument("--dev-stack", default=".app/dev-stack.json")
args = parser.parse_args()
state = json.loads(Path(args.dev_stack).read_text())
assert args.database == state["database"] == "creator-hub-validation", "只允许专用隔离库"
api = state["services"]["api-server"]["url"]
server = state["services"]["spacetime"]["url"]
for url in [api, server]:
parsed = urllib.parse.urlsplit(url)
assert parsed.scheme == "http" and parsed.hostname in ["127.0.0.1", "localhost", "::1"]
data_dir = Path(state["spacetimeDataDir"])
service = json.loads((data_dir / "dev-api-identities/local-node.json").read_text())
operator_path = data_dir / "creator-test-operator.json"
if operator_path.exists():
operator = json.loads(operator_path.read_text())
else:
operator, _ = request(server + "/v1/identity", "POST", {})
fd = os.open(operator_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as file:
json.dump(operator, file)
scope = hashlib.sha256((server + "\n" + args.database).encode()).hexdigest()
secret = json.loads((data_dir / "dev-runtime-service-bootstrap-secrets" / (scope + ".json")).read_text())["secret"]
result, _ = request(server + "/v1/database/" + args.database + "/call/authorize_database_migration_operator", "POST", [{"bootstrap_secret": secret, "operator_identity_hex": operator["identity"], "note": "创作者隔离测试夹具"}], operator["token"])
assert result[0], "测试迁移身份授权失败"
def call(name, value, token=operator["token"]):
return request(server + "/v1/database/" + args.database + "/call/" + name, "POST", [value], token)[0]
def export(table):
result = call("export_database_migration_to_file", {"include_tables": [table]})
assert result[0], option(result[-1])
return json.loads(option(result[2]))["tables"][0]["rows"]
def fixtures(tables):
result = call("import_database_migration_incremental_from_file", {"migration_json": json.dumps({"schema_version": 1, "exported_at_micros": int(time.time() * 1e6), "tables": [{"name": k, "rows": v} for k, v in tables.items()]}), "include_tables": list(tables), "replace_existing": False, "dry_run": False})
assert result[0], option(result[-1])
def login(number):
value, _ = request(api + "/api/auth/entry", "POST", {"purePhoneNumber": str(13900006000 + number), "password": "Creator-test-2026"})
return value["user"]["id"], value["token"]
def relation(viewer, target):
return request(api + "/api/creators/" + target + "/relationship", token=viewer[1])[0]["relationship"]
def write(viewer, target, method="PUT", remove=False, expected=200):
path = "/api/creators/me/followers/" + target if remove else "/api/creators/" + target + "/follow"
return request(api + path, method, token=viewer[1], expected=expected)[0]
def profile(user):
value, _ = request(api + "/api/creators/" + user)
assert set(value["user"]) == {"id", "publicUserCode", "displayName", "avatarUrl"}
return value
request(api + "/healthz")
a, b, c = [login(n) for n in [1, 2, 3]]
# 重跑时先清理测试账号的相互出边;不清表或删除已有业务数据。
for actor in [a, b, c]:
for target in [a, b, c]:
if actor != target:
write(actor, target[0], "DELETE")
assert relation(a, a[0]) == {"isSelf": True, "isFollowing": False, "isFollowedBy": False}
for kind in ["following", "followers"]:
empty, _ = request(api + f"/api/creators/{c[0]}/{kind}")
assert empty == {"items": [], "nextCursor": None, "total": 0}
write(a, b[0])
first = next(row for row in export("user_follow") if row["follower_user_id"] == a[0] and row["followee_user_id"] == b[0])
write(a, b[0])
second = next(row for row in export("user_follow") if row["relationship_id"] == first["relationship_id"])
assert first == second, "重复关注不能改时间"
write(b, a[0])
assert relation(a, b[0]) == {"isSelf": False, "isFollowing": True, "isFollowedBy": True}
write(a, b[0], "DELETE", remove=True)
write(a, b[0], "DELETE", remove=True)
assert relation(a, b[0]) == {"isSelf": False, "isFollowing": True, "isFollowedBy": False}
write(b, a[0]) # 移除不是拉黑。
write(a, b[0], "DELETE")
assert not relation(a, b[0])["isFollowing"]
write(a, b[0])
third = next(row for row in export("user_follow") if row["relationship_id"] == first["relationship_id"])
assert third["created_at"] != first["created_at"]
write(a, a[0], expected=400)
write(a, a[0], "DELETE", expected=400)
write(a, a[0], "DELETE", remove=True, expected=400)
write(a, "missing-creator", expected=404)
request(api + "/api/creators/" + b[0] + "/follow", "PUT", expected=401)
request(api + "/api/creators/" + b[0] + "/followers", token="invalid", expected=401)
request(api + "/api/creators/" + a[0] + "/followers/" + b[0], "DELETE", token=c[1], expected=404)
write(c, b[0], "DELETE", remove=True)
assert relation(a, b[0])["isFollowedBy"], "第三方移除不能改变他人入边"
stranger, _ = request(server + "/v1/identity", "POST", {})
denied = call("follow_creator_and_return", {"actor_id": a[0], "target_id": b[0]}, stranger["token"])
assert option(denied[0]) is None and option(denied[1]) == "forbidden"
denied = call("list_creator_connections_and_return", {"owner_id": a[0], "viewer_id": [0, a[0]], "kind": "following", "limit": 20, "cursor": None}, stranger["token"])
assert option(denied[0]) is None and option(denied[1]) == "forbidden"
print("PASS 双向关系、幂等、重新关注、移除粉丝、自操作、认证和越权", flush=True)
peers = [login(n) for n in range(10, 65)]
stamp = {"__timestamp_micros_since_unix_epoch__": 1700000000000000}
rows = []
for peer, _ in peers:
for follower, followee in [(a[0], peer), (peer, a[0])]:
rows.append({"relationship_id": f"{len(follower.encode())}:{follower}{followee}", "follower_user_id": follower, "followee_user_id": followee, "created_at": stamp})
# 不存在的对端必须同时从计数和列表排除。
rows.extend([{"relationship_id": f"{len(x.encode())}:{x}{y}", "follower_user_id": x, "followee_user_id": y, "created_at": stamp} for x, y in [(a[0], "missing-peer"), ("missing-peer", a[0])]])
fixtures({"user_follow": rows})
for kind, count_key in [("following", "followingCount"), ("followers", "followerCount")]:
for viewer in [None, a[1], b[1], c[1]]:
seen, cursor, total = [], None, None
while True:
path = f"/api/creators/{a[0]}/{kind}?limit=20"
if cursor:
path += "&cursor=" + urllib.parse.quote(cursor)
result, headers = request(api + path, token=viewer)
total = result["total"]
assert headers["Cache-Control"] == "private, no-store"
for row in result["items"]:
assert (row["relationship"] is not None) == (viewer == a[1])
assert set(row["user"]) == {"id", "publicUserCode", "displayName", "avatarUrl"}
seen.append(row["user"]["id"])
cursor = result["nextCursor"]
if not cursor:
break
request(api + f"/api/creators/{b[0]}/{kind}?cursor=" + cursor, expected=400)
other = "followers" if kind == "following" else "following"
request(api + f"/api/creators/{a[0]}/{other}?cursor=" + cursor, expected=400)
assert len(seen) == len(set(seen)) == total == profile(a[0])[count_key]
assert total == 56 and "missing-peer" not in seen
before = export("user_follow")
a = login(1) # 触发认证快照刷新和持久化。
assert export("user_follow") == before
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(lambda _: write(c, b[0]), range(8)))
assert len([row for row in export("user_follow") if row["follower_user_id"] == c[0] and row["followee_user_id"] == b[0]]) == 1
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(lambda n: write(c, b[0], "PUT" if n % 2 else "DELETE"), range(8)))
db_has = any(row["follower_user_id"] == c[0] and row["followee_user_id"] == b[0] for row in export("user_follow"))
assert relation(c, b[0])["isFollowing"] == db_has
# 丢弃成功回执,模拟调用方不知道写入结果;正式读取决定下一动作。
write(c, b[0])
assert relation(c, b[0])["isFollowing"]
write(c, b[0], "DELETE")
assert not relation(c, b[0])["isFollowing"]
print("PASS 同时间多页、公开只读、失效用户过滤、认证快照保留、并发及未知结果回读", flush=True)
none = {"none": []}
some = lambda value: {"some": value}
games, versions = [], []
# 其它作者的更新作品占满旧广场前 48 项,目标作者仍应得到自己的 48 项。
for prefix, owner, count in [("target", a[0], 55), ("other", b[0], 55), ("hidden", a[0], 6)]:
for n in range(count):
game_id = f"creator-web-fixture-{prefix}-{n:03}"
version_id = game_id + "-v1"
created = {"__timestamp_micros_since_unix_epoch__": 1800000000000000 + (1000 if prefix == "other" else 0) + n}
game = dict(game_id=game_id, owner_user_id=owner, title=game_id, summary="测试", description="测试", category="adventure", tags_json="[]", cover_asset_id=none, author_name=none, author_avatar_url=none, device_support_desktop=True, device_support_mobile=True, device_support_touch=True, input_modes_json='["touch"]', orientation="any", publication_revision=1, active_version_id=some(version_id), visibility="published", play_count=0, created_at=created, updated_at=created, local_project_id=none, cover_object_key=none, screenshots_json=none, deleted_at=none)
version = dict(version_id=version_id, game_id=game_id, owner_user_id=owner, version_number=1, package_sha256="0" * 64, package_bytes=1, package_file_count=1, package_entry_path="index.html", status="published", review_reason=none, created_at=created, updated_at=created, local_project_id=none, package_object_key=none, package_manifest_json=none, entry_url=some("/games/game_" + hashlib.sha256(game_id.encode()).hexdigest()[:32] + "/"), reviewed_by_user_id=none, uploaded_at=none, submitted_at=none, reviewed_at=none, published_at=some(created), revoked_at=none, last_error_code=none, last_error_message=none, metadata_json=none)
if prefix == "hidden":
if n == 0: game["visibility"] = "unpublished"
if n == 1: game["visibility"] = "suspended"
if n == 2: game["deleted_at"] = some(created)
if n == 3: game["active_version_id"] = none
if n == 4: version["status"] = "pending_review"
if n == 5: version["game_id"] = "wrong-game"
games.append(game)
versions.append(version)
fixtures({"game_distribution_game": games, "game_distribution_version": versions})
target, _ = request(api + "/api/game-distribution/games?authorId=" + a[0])
assert len(target["games"]) == 48 and target["nextCursor"] is None
assert all(game["author"]["id"] == a[0] and game["id"].startswith("creator-web-fixture-target-") for game in target["games"])
assert [game["id"] for game in target["games"]] == [f"creator-web-fixture-target-{n:03}" for n in range(54, 6, -1)]
gallery, _ = request(api + "/api/game-distribution/games")
assert len(gallery["games"]) == 48 and gallery["nextCursor"] is None
assert all(game["author"]["id"] == b[0] for game in gallery["games"])
request(api + "/api/game-distribution/games?authorId=", expected=400)
empty, _ = request(api + "/api/game-distribution/games?authorId=" + c[0])
assert empty["games"] == []
print("PASS 作者过滤先于 48 项截取、公开版本隔离、旧广场上限与空作者校验", flush=True)
print("PASS 创作者后端隔离运行时验收", flush=True)
if __name__ == "__main__":
main()
+258
View File
@@ -0,0 +1,258 @@
/** 隔离创作者数据库的真实浏览器验收。先运行 test-creator-runtime.py,再启动同库 dev:web。
* CREATOR_PLAYWRIGHT_DIR 指向临时安装的 playwright;浏览器、截图与登录信息均不入库。
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const state = JSON.parse(fs.readFileSync('.app/dev-stack.json', 'utf8'));
assert.equal(state.database, 'creator-hub-validation');
const web = state.services.web.url;
const api = state.services['api-server'].url;
for (const url of [web, api]) assert.equal(new URL(url).hostname, '127.0.0.1');
const runtimeRequire = process.env.CREATOR_PLAYWRIGHT_DIR
? createRequire(
path.resolve(process.env.CREATOR_PLAYWRIGHT_DIR, 'package.json'),
)
: require;
const { chromium } = runtimeRequire('playwright');
async function json(endpoint, token, method = 'GET', body) {
const response = await fetch(api + endpoint, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
assert.ok(response.ok, `${method} ${endpoint}: ${response.status}`);
const result = await response.json();
return result.data ?? result;
}
async function login(n) {
return json('/api/auth/entry', null, 'POST', {
purePhoneNumber: String(13900006000 + n),
password: 'Creator-test-2026',
});
}
async function waitRows(page, count) {
await page.waitForFunction(
(n) => document.querySelectorAll('.creator-list > li').length === n,
count,
);
}
(async () => {
const [a, b] = await Promise.all([login(1), login(2)]);
const browser = await chromium.launch({ headless: true });
const errors = [];
try {
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
});
await context.addInitScript(
(token) =>
localStorage.setItem('genarrative.auth.access-token.v1', token),
a.token,
);
const page = await context.newPage();
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(web + '/creators');
await page.locator('.game-card').first().waitFor();
assert.equal(await page.locator('.game-card').count(), 48);
assert.deepEqual(
await page
.getByRole('navigation', { name: '平台导航', exact: true })
.getByRole('button')
.allTextContents(),
['创作', '项目', '游戏', '创作者主页', '我的'],
);
assert.equal(
await page
.locator('.creator-profile')
.getByRole('button', { name: '取消关注' })
.count(),
0,
);
await page.screenshot({ path: '/tmp/creator-home-desktop.png' });
await page.locator('.game-card').first().click();
await page.locator('.game-detail-page').waitFor();
await page.locator('.game-detail-page .creator-user-link').click();
await page.locator('.creator-counts').waitFor();
assert.equal(new URL(page.url()).searchParams.get('id'), a.user.id);
await page
.getByRole('navigation', { name: '平台导航', exact: true })
.getByRole('button', { name: '创作者主页' })
.click();
assert.equal(new URL(page.url()).search, ''); // 同舞台切回本人,URL 改变也会重绘。
await page.locator('.creator-counts a').first().click();
await waitRows(page, 20);
await page.getByRole('button', { name: '加载更多' }).click();
await waitRows(page, 40);
await page.locator('.platform-tab-panel').evaluate((el) => {
el.scrollTop = 650;
});
await page.waitForTimeout(100);
await page.locator('.creator-list .creator-user-link').nth(7).click();
await page.locator('.creator-counts').waitFor();
await page.goBack();
await waitRows(page, 40);
assert.ok(
await page
.locator('.platform-tab-panel')
.evaluate((el) => el.scrollTop > 200),
);
const first = page.locator('.creator-list > li').first();
const target = new URL(
await first.locator('a').getAttribute('href'),
web,
).searchParams.get('id');
await first.getByRole('button', { name: '取消关注' }).click();
await first.getByRole('button', { name: '重新关注' }).waitFor();
await first.getByRole('button', { name: '重新关注' }).click();
await first.getByRole('button', { name: '取消关注' }).waitFor();
await first.getByRole('button', { name: '取消关注' }).click();
await first.getByRole('button', { name: '重新关注' }).waitFor();
await page.getByRole('button', { name: '刷新列表' }).click();
await page.waitForFunction(
(id) =>
!document.querySelector(`.creator-list a[href="/creators?id=${id}"]`),
target,
);
await json(`/api/creators/${target}/follow`, a.token, 'PUT');
// 同一个列表舞台切换到粉丝,验证双向独立和移除确认。
await page
.getByRole('navigation', { name: '关系列表' })
.getByRole('link', { name: '粉丝', exact: true })
.click();
await page.getByRole('heading', { name: /我的粉丝/ }).waitFor();
await waitRows(page, 20);
const fan = page.locator('.creator-list > li').first();
const fanId = new URL(
await fan.locator('a').getAttribute('href'),
web,
).searchParams.get('id');
await fan.getByRole('button', { name: '取消关注' }).click();
await fan.getByRole('button', { name: '回关' }).waitFor();
await fan.getByRole('button', { name: '回关' }).click();
await fan.getByRole('button', { name: '取消关注' }).waitFor();
await fan.getByRole('button', { name: '移除粉丝' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: '取消', exact: true }).click();
await fan.getByRole('button', { name: '移除粉丝' }).click();
await dialog.getByRole('button', { name: '移除粉丝', exact: true }).click();
await page.waitForFunction(
(id) =>
!document.querySelector(`.creator-list a[href="/creators?id=${id}"]`),
fanId,
);
const relation = await json(`/api/creators/${fanId}/relationship`, a.token);
assert.equal(relation.relationship.isFollowing, true);
assert.equal(relation.relationship.isFollowedBy, false);
console.log(
'PASS desktop: navigation, author/game links, pagination/back/scroll, unfollow retention, follow-back and remove',
);
// 上一步可能移除了 B 的入边,重建只读列表验收所需的双方关系。
await json(`/api/creators/${a.user.id}/follow`, b.token, 'PUT');
await json(`/api/creators/${b.user.id}/follow`, a.token, 'PUT');
// 他人两类列表只有主页链接,不读取访问者与列表用户的关系。
for (const tab of ['following', 'followers']) {
const requests = [];
const record = (request) => {
if (request.url().includes('/relationship'))
requests.push(request.url());
};
page.on('request', record);
await page.goto(`${web}/creators/connections?id=${b.user.id}&tab=${tab}`);
await page.locator('.creator-list > li').first().waitFor();
assert.equal(await page.locator('.creator-list button').count(), 0);
assert.equal(requests.length, 0);
page.off('request', record);
await page.locator('.creator-list a').first().click();
await page.locator('.creator-counts').waitFor();
}
await page.goto(`${web}/creators?id=${b.user.id}`);
await page.locator('.creator-relation button').first().waitFor();
const follow = page.locator('.creator-relation button').first();
await page.waitForFunction(
() => !document.querySelector('.creator-relation button')?.disabled,
);
const oldLabel = await follow.innerText();
await follow.click();
await page.waitForFunction(
(label) =>
document.querySelector('.creator-relation button')?.textContent !==
label &&
!document.querySelector('.creator-relation button')?.disabled,
oldLabel,
);
await follow.click();
console.log(
'PASS public lists: both read-only, no relationship requests, all users link to creator pages',
);
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(web + '/creators');
await page.locator('.creator-profile').waitFor();
const welcome = page.getByRole('button', { name: '好', exact: true });
if (await welcome.isVisible()) await welcome.click();
assert.deepEqual(
await page
.getByRole('navigation', { name: '移动平台导航' })
.getByRole('button')
.allTextContents(),
['游戏', '创作者主页', '我的'],
);
assert.ok(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
);
await page.screenshot({ path: '/tmp/creator-home-mobile.png' });
await page.locator('.creator-counts a').last().click();
await page.locator('.creator-list > li').first().waitFor();
assert.ok(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
);
await page.screenshot({ path: '/tmp/creator-followers-mobile.png' });
await page.locator('.creator-list a').first().focus();
await page.keyboard.press('Enter');
await page.locator('.creator-counts').waitFor();
const guest = await browser.newContext({
viewport: { width: 375, height: 812 },
});
const guestPage = await guest.newPage();
await guestPage.goto(`${web}/creators?id=${b.user.id}`);
await guestPage.locator('.creator-profile').waitFor();
const guestWelcome = guestPage.getByRole('button', {
name: '好',
exact: true,
});
if (await guestWelcome.isVisible()) await guestWelcome.click();
for (const tab of ['following', 'followers']) {
await guestPage.goto(
`${web}/creators/connections?id=${b.user.id}&tab=${tab}`,
);
await guestPage.locator('.creator-list > li').first().waitFor();
if (await guestWelcome.isVisible()) await guestWelcome.click();
assert.equal(await guestPage.locator('.creator-list button').count(), 0);
await guestPage
.getByRole('button', { name: '返回', exact: true })
.click();
await guestPage.locator('.creator-profile').waitFor();
assert.equal(new URL(guestPage.url()).searchParams.get('id'), b.user.id);
}
await guestPage.getByRole('button', { name: '关注', exact: true }).click();
await guestPage.getByRole('dialog').waitFor();
assert.equal(new URL(guestPage.url()).searchParams.get('id'), b.user.id);
assert.deepEqual(errors, []);
console.log(
'PASS mobile 375px: three navigation entries, no horizontal overflow, keyboard links, guest login without follow',
);
} finally {
await browser.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});