Files
Genarrative/scripts/check-game-distribution-sandbox-e2e.mjs
T
k88936 eea37f80f1 游戏分发真实链路脚本迁移到 multipart 媒体直传
- 12 个 check-game-distribution-* 脚本改用 multipart:metadata + cover/screenshot 二进制 part
- 删除 assetId/assetObjectId 断言与素材库直传前置,公开读改走 media/read-url 与 read-bytes
- media-e2e 负例重写:缺封面、超 6 张、非本作品 objectKey、槽位与 part 数量不匹配
- ratings-e2e 直连 reducer 字段改 cover_object_key/screenshots_json,test-creator-runtime 同步
- .gitattributes 为 export/generated 生成目录豁免行尾空白检查
2026-10-07 00:50:19 +08:00

521 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 游戏发行沙箱的真实浏览器检查:把「探针包」发布到本地发行网关,再用 Chromium 以
// `sandbox="allow-scripts"` 的 iframe 打开它,断言不透明来源下能载入什么、被挡掉了什么。
//
// 需要:本地 dev 栈(SpacetimeDB + api-server)+ 管理员账号 + playwright。
// npm install --prefix %TEMP%\genarrative-pw --no-save --no-package-lock playwright
// E2E_PLAYWRIGHT_DIR=%TEMP%\genarrative-pw
// E2E_CHROMIUM_EXECUTABLE=<ms-playwright 里的 chrome.exe,可省略>
// E2E_ADMIN_USER=... E2E_ADMIN_PASSWORD=... npm run check:game-distribution-sandbox-e2e
//
// 覆盖: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';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
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!';
if (!ADMIN_USER || !ADMIN_PASSWORD) {
console.error(
'缺少 E2E_ADMIN_USER / E2E_ADMIN_PASSWORD:本脚本要按发布灰度口径打开写入口并审核探针包。',
);
process.exit(2);
}
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(pathname, options = {}) {
const {
method = 'GET',
token,
body,
formData,
headers = {},
binary,
} = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
let finalBody;
if (formData) {
// multipart/form-data:不手动设 Content-Type,交给 fetch 生成 boundary。
finalBody = formData;
} else 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,
});
const text = await response.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return {
status: response.status,
json,
text,
data: json?.data,
error: json?.error,
};
}
function gameMetadata(title) {
return {
title,
summary: '发行沙箱探针',
description: '',
category: '休闲',
tags: ['e2e'],
deviceSupport: { desktop: true, mobile: false, touch: false },
inputModes: ['keyboard', 'mouse'],
orientation: 'landscape',
};
}
/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */
function coverImagePart(id) {
return {
bytes: COVER_PNG,
fileName: `sandbox-${id}.png`,
contentType: 'image/png',
};
}
/** 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 重复 `screenshot` 二进制 part。 */
function publishFormData(metadata, { cover, screenshots = [] } = {}) {
const form = new FormData();
form.append('metadata', JSON.stringify(metadata));
if (cover) {
form.append(
'cover',
new Blob([cover.bytes], { type: cover.contentType }),
cover.fileName,
);
}
for (const screenshot of screenshots) {
if (!screenshot) continue;
form.append(
'screenshot',
new Blob([screenshot.bytes], { type: screenshot.contentType }),
screenshot.fileName,
);
}
return form;
}
const PROBE_HELPER = "export const marker = 'helper-ok';\n";
const PROBE_APP = `import { marker } from './helper.js';
const results = { moduleLoaded: marker === 'helper-ok' };
try {
// opaque origin 的原生 storage 不可用,但发行网关会在游戏脚本前注入运行期兼容层。
try {
window.localStorage.setItem('probe', 'ok');
results.storageAvailable = window.localStorage.getItem('probe') === 'ok';
window.localStorage.removeItem('probe');
} catch {
results.storageAvailable = false;
}
try {
results.cookieHidden = document.cookie === '';
} catch {
results.cookieHidden = true;
}
try {
void window.parent.document.body;
results.parentDomBlocked = false;
} catch {
results.parentDomBlocked = true;
}
results.crossOriginFetchBlocked = await fetch('http://127.0.0.1:4199/readyz')
.then(() => false)
.catch(() => true);
results.crossOriginWebSocketBlocked = await new Promise((resolve) => {
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
try {
const socket = new WebSocket('ws://127.0.0.1:4199/probe');
const timer = setTimeout(() => {
try {
socket.close();
} catch {}
finish(false);
}, 1500);
socket.onopen = () => {
clearTimeout(timer);
socket.close();
finish(false);
};
socket.onerror = () => {
clearTimeout(timer);
finish(true);
};
} catch {
finish(true);
}
});
// CSP 挡 Worker 时不一定同步抛错,会以 worker 的 error 事件报出来。
results.workerBlocked = await new Promise((resolve) => {
let settled = false;
const finish = (value) => {
if (settled) return;
settled = true;
resolve(value);
};
try {
const worker = new Worker(
URL.createObjectURL(
new Blob(['self.postMessage(1)'], { type: 'text/javascript' }),
),
);
const timer = setTimeout(() => {
worker.terminate();
finish(false);
}, 1500);
worker.onerror = () => {
clearTimeout(timer);
worker.terminate();
finish(true);
};
worker.onmessage = () => {
clearTimeout(timer);
worker.terminate();
finish(false);
};
} catch {
finish(true);
}
});
try {
results.popupBlocked = window.open('https://example.com') === null;
} catch {
results.popupBlocked = true;
}
try {
window.top.location.href = 'about:blank#probe-top';
results.topNavigationBlocked = false;
} catch {
results.topNavigationBlocked = true;
}
let permissionState = 'unknown';
try {
const status = await navigator.permissions.query({ name: 'geolocation' });
permissionState = status.state;
} catch {
permissionState = 'blocked';
}
results.sensitivePermissionDenied =
permissionState === 'denied' || permissionState === 'blocked';
} catch (error) {
results.probeError = String(error);
}
parent.postMessage({ type: 'probe-results', results }, '*');
`;
async function buildProbePackage() {
const zip = new JSZip();
zip.file(
'index.html',
'<!doctype html><html><head><meta charset="utf-8"><title>sandbox probe</title>' +
'<script type="module" src="assets/app.js"></script></head><body><h1>sandbox probe</h1></body></html>',
);
zip.file('assets/app.js', PROBE_APP);
zip.file('assets/helper.js', PROBE_HELPER);
const bytes = await zip.generateAsync({ type: 'uint8array' });
return Buffer.from(bytes);
}
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 main() {
const adminLogin = await api('/admin/api/login', {
method: 'POST',
body: { username: ADMIN_USER, password: ADMIN_PASSWORD },
});
const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken;
check(
'管理员登录成功',
adminLogin.status === 200 && Boolean(admin),
`status=${adminLogin.status}`,
);
if (!admin) process.exit(1);
const stamp = Date.now();
const register = await api('/api/auth/entry', {
method: 'POST',
body: {
purePhoneNumber: `134${String(stamp).slice(-8)}`,
password: DEV_PASSWORD,
},
});
const author = register.data?.token;
check(
'作者注册拿到 token',
register.status === 200 && Boolean(author),
`status=${register.status}`,
);
if (!author) process.exit(1);
const setGate = (enabled, rolloutPercent) =>
api('/admin/api/feature-gates', {
method: 'PUT',
token: admin,
body: {
gateKey: 'game-distribution:publish',
enabled,
rolloutPercent,
allowUserIds: [],
allowUserTags: [],
denyUserIds: [],
description: 'E2E 发行沙箱探针',
},
});
const gateOpen = await setGate(true, 100);
check(
'发布灰度可开启并放量',
gateOpen.status === 200,
`status=${gateOpen.status}`,
);
const packageBytes = await buildProbePackage();
const packageSha256 = (await import('node:crypto'))
.createHash('sha256')
.update(packageBytes)
.digest('hex');
const cover = coverImagePart(stamp);
const title = `发行沙箱 ${String(stamp).slice(-6)}`;
const metadata = {
...gameMetadata(title),
coverObjectKey: null,
screenshots: [],
};
const created = await api('/api/game-distribution/games', {
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `sandbox-game-${stamp}` },
formData: publishFormData(metadata, { cover }),
});
const gameId = created.data?.id;
check(
'创建游戏成功',
created.status === 200 && Boolean(gameId),
`status=${created.status} msg=${created.error?.message ?? ''}`,
);
if (!gameId) process.exit(1);
const versionResponse = await api(
`/api/game-distribution/games/${gameId}/versions`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `sandbox-version-${stamp}` },
formData: publishFormData({
packageSha256,
packageBytes: packageBytes.length,
packageFileCount: 3,
packageEntryPath: 'index.html',
// 建版沿用创建时服务端派生的封面 objectKey。
gameMetadata: {
...metadata,
coverObjectKey: created.data.coverObjectKey,
},
}),
},
);
const versionId = versionResponse.data?.versionId;
const uploadPackage = await api(
`/api/game-distribution/versions/${versionId}/package`,
{
method: 'PUT',
token: author,
headers: {
'Idempotency-Key': `sandbox-upload-${stamp}`,
'Content-Type': 'application/zip',
},
binary: packageBytes,
},
);
check(
'探针包上传成功',
uploadPackage.status === 200 && uploadPackage.data?.status === 'uploaded',
`status=${uploadPackage.status}`,
);
const submitted = await api(
`/api/game-distribution/versions/${versionId}/submit`,
{
method: 'POST',
token: author,
headers: { 'Idempotency-Key': `sandbox-submit-${stamp}` },
body: { expectedPublicationRevision: 0 },
},
);
const approved = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
method: 'POST',
token: admin,
headers: { 'Idempotency-Key': `sandbox-approve-${stamp}` },
body: { decision: 'approve', expectedPublicationRevision: 0 },
},
);
check(
'探针包送审并通过审核',
submitted.status === 202 && approved.status === 200,
`submit=${submitted.status} approve=${approved.status}`,
);
const entryUrl = `${API}/api/game-distribution/releases/${gameId}/index.html`;
const entryResponse = await fetch(entryUrl);
const csp = entryResponse.headers.get('content-security-policy') ?? '';
check(
'发行文档带最小权限 CSP(connect-src self / worker-src none)',
entryResponse.status === 200 &&
csp.includes("connect-src 'self'") &&
csp.includes("worker-src 'none'"),
`status=${entryResponse.status} csp=${csp.slice(0, 60)}…`,
);
check(
'发行响应带 nosniff 与跨源 CORS',
entryResponse.headers.get('x-content-type-options') === 'nosniff' &&
entryResponse.headers.get('access-control-allow-origin') === '*',
`nosniff=${entryResponse.headers.get('x-content-type-options')} acao=${entryResponse.headers.get('access-control-allow-origin')}`,
);
const cookieRequest = await fetch(entryUrl, {
headers: { Cookie: 'genarrative_access_token=probe' },
});
check(
'带平台 Cookie 的发行请求 403',
cookieRequest.status === 403,
`status=${cookieRequest.status}`,
);
const unknownExtension = await fetch(
`${API}/api/game-distribution/releases/${gameId}/secrets.env`,
);
check(
'未知扩展名不通过发行网关下发',
unknownExtension.status === 404,
`status=${unknownExtension.status}`,
);
const { chromium } = await loadPlaywright();
const executablePath = (process.env.E2E_CHROMIUM_EXECUTABLE ?? '').trim();
const browser = await chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {}),
});
try {
const page = await browser.newPage();
page.on('console', (message) => {
console.log(` [page:${message.type()}] ${message.text()}`);
});
page.on('pageerror', (error) => {
console.log(` [pageerror] ${error}`);
});
const harness = `<!doctype html><html><head><meta charset="utf-8"><title>harness</title></head><body>
<script>
window.__probeResults = null;
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'probe-results') window.__probeResults = event.data.results;
});
const frame = document.createElement('iframe');
frame.setAttribute('sandbox', 'allow-scripts');
frame.setAttribute('allow', 'fullscreen');
frame.src = ${JSON.stringify(entryUrl)};
document.body.appendChild(frame);
</script></body></html>`;
await page.setContent(harness);
await page.waitForFunction(() => window.__probeResults !== null, null, {
timeout: 30_000,
});
const results = await page.evaluate(() => window.__probeResults);
console.log('探针结果:', JSON.stringify(results));
check(
'opaque sandbox 下 ES module 与同包资源能载入',
results.moduleLoaded === true,
);
check(
'opaque sandbox 下运行期 storage 兼容层可用',
results.storageAvailable === true,
);
check(
'document.cookie 在 opaque sandbox 下为空',
results.cookieHidden === true,
);
check('读不到父文档 DOM', results.parentDomBlocked === true);
check('跨源 fetch 被挡', results.crossOriginFetchBlocked === true);
check('跨源 WebSocket 被挡', results.crossOriginWebSocketBlocked === true);
check('Worker 被 CSP 挡下', results.workerBlocked === true);
check('弹窗被 sandbox 挡下', results.popupBlocked === true);
check('顶层跳转被挡', results.topNavigationBlocked === true);
check('敏感权限(定位)被拒', results.sensitivePermissionDenied === true);
const harnessUrl = page.url();
check(
'探针没有改变父页面地址',
harnessUrl === 'about:blank',
`url=${harnessUrl}`,
);
} finally {
await browser.close();
}
const gateClosed = await setGate(false, 0);
check(
'发布灰度恢复关闭',
gateClosed.status === 200,
`status=${gateClosed.status}`,
);
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(`[check:game-distribution-sandbox-e2e] 运行失败:${error}`);
process.exit(1);
});