Merge branch 'master' into feat/creator-hub
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-05 19:51:18 +08:00
41 changed files with 5146 additions and 566 deletions
@@ -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);