游戏分发 E2E 抽出共享 multipart 发布 helper

- 新增 scripts/game-distribution-e2e-helpers.mjs:COVER_PNG、imagePart、coverImagePart、publishFormData、resolveApiRequest
- 11 个 E2E 脚本删除各自的重复实现,改为 import;api() 的 body 分支收敛到 resolveApiRequest
- 各脚本的封面文件名前缀、envelope 与鉴权头合并顺序、返回形状保持不变
This commit is contained in:
2026-10-07 11:42:45 +08:00
parent ab2af60d30
commit 1f239a96ce
12 changed files with 269 additions and 524 deletions
+14 -57
View File
@@ -15,6 +15,12 @@ import { readFile } from 'node:fs/promises';
import JSZip from 'jszip';
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:12401';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
@@ -26,10 +32,6 @@ if (!ADMIN_USER || !ADMIN_PASSWORD) {
);
process.exit(2);
}
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
let failures = 0;
function check(name, ok, detail = '') {
@@ -71,20 +73,16 @@ async function api(path, options = {}) {
} = 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 request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${path}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -106,47 +104,6 @@ function stamp() {
return `${Date.now()}${Math.floor(Math.random() * 1000)}`;
}
/**
* 发布用图片 part。
*
* 新合同不再走素材库直传登记:图片字节随 `cover` / `screenshot` part 提交,由服务端直写
* 项目快照桶并派生 objectKey。这里只在本机准备字节、文件名与 content-type。
*/
function imagePart(kind, id, bytes = COVER_PNG) {
return {
bytes,
fileName: `${kind}-${id}.png`,
contentType: 'image/png',
};
}
/**
* 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 按顺序重复的 `screenshot` 二进制 part。
*
* `metadata.screenshots` 里的 `null` 槽位按出现顺序消费 `screenshots` 数组里的 part;string
* 槽位沿用该 objectKey。调用方负责保证 null 槽位数量与 part 数量一致(不一致服务端会 400)。
*/
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;
}
function gameMetadata(overrides = {}) {
return {
title: `分发媒体验证 ${stamp().slice(-6)}`,
@@ -23,10 +23,12 @@ import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const ADMIN_USER = (process.env.E2E_ADMIN_USER ?? '').trim();
@@ -71,20 +73,16 @@ async function api(pathname, options = {}) {
} = 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 request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -172,36 +170,8 @@ async function setPublishGate(admin, enabled, rolloutPercent) {
return response;
}
/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */
function coverImagePart(id) {
return {
bytes: COVER_PNG,
fileName: `ops-${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 coverImagePart = (id) =>
imagePart('cover', id, { fileName: `ops-${id}.png` });
function gameMetadata(title, coverObjectKey) {
return {
@@ -11,10 +11,12 @@
// 的 my-games)→ 作者撤回自己的版本成功。整条链路只在本机 dev 数据库落行;唯一的存储写入
// 是「建游戏必须提供封面」逼出的一个 67 字节 PNG(新合同由服务端把 cover part 直写项目
// 快照桶),发行包分片与确认全部停在越权拒绝之前,不会产生任何对象。
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
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();
@@ -49,20 +51,16 @@ async function api(path, options = {}) {
} = 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 request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${path}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -95,41 +93,8 @@ function gameMetadata(title) {
};
}
/**
* 发布用封面图片 part。
*
* 新合同不再走素材库直传登记:封面字节随创建请求的 `cover` part 提交,由服务端直写项目
* 快照桶。这里只在本机准备字节、文件名与 content-type。
*/
function coverImagePart(id) {
return {
bytes: COVER_PNG,
fileName: `owner-isolation-${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 coverImagePart = (id) =>
imagePart('cover', id, { fileName: `owner-isolation-${id}.png` });
async function register(prefix) {
const response = await api('/api/auth/entry', {
@@ -9,10 +9,12 @@ import { createHash, randomBytes } from 'node:crypto';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const API_PORT = Number(new URL(API).port || '80');
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
@@ -45,20 +47,16 @@ async function api(pathname, options = {}) {
} = 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 request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -90,36 +88,8 @@ function apiServerWorkingSetMb() {
}
}
/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */
function coverImagePart(id) {
return {
bytes: COVER_PNG,
fileName: `limits-${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 coverImagePart = (id) =>
imagePart('cover', id, { fileName: `limits-${id}.png` });
async function createGameWithVersion(
author,
@@ -51,6 +51,12 @@ import { fileURLToPath } from 'node:url';
import JSZip from 'jszip';
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const DEV_STACK_PATH = resolve(REPO_ROOT, '.app/dev-stack.json');
@@ -88,12 +94,6 @@ const REQUIRE_PLATFORM_ORIGIN =
(process.env.E2E_REQUIRE_PLATFORM_ORIGIN ?? '').trim() === '1';
const GAME_DISTRIBUTION_PURCHASE_SOURCE_TYPE = 'game_purchase';
/** 1x1 透明 PNG:仅用于满足发布必填封面,内容不影响本用例断言。 */
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
let failures = 0;
let passes = 0;
let warnings = 0;
@@ -177,20 +177,16 @@ async function api(path, options = {}) {
} = 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 request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${path}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -223,36 +219,8 @@ async function registerAccount(prefix) {
return { status: entry.status, token: entry.data?.token ?? '', phone };
}
/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */
function coverImagePart(id) {
return {
bytes: COVER_PNG,
fileName: `cover-${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 coverImagePart = (id) =>
imagePart('cover', id, { fileName: `cover-${id}.png` });
/**
* 生成最小可玩发行包:入口页里带唯一标记,用来证明播放会话读到的是同一份包内容。
+16 -46
View File
@@ -15,10 +15,12 @@ import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
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();
@@ -51,20 +53,16 @@ async function api(pathname, options = {}) {
} = 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 request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -95,36 +93,8 @@ function gameMetadata(title) {
};
}
/** 发布用封面图片 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 coverImagePart = (id) =>
imagePart('cover', id, { fileName: `sandbox-${id}.png` });
const PROBE_HELPER = "export const marker = 'helper-ok';\n";
@@ -21,10 +21,12 @@ import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
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();
@@ -65,20 +67,16 @@ async function api(pathname, options = {}) {
} = 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 request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -148,36 +146,8 @@ function gameMetadata(title) {
};
}
/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */
function coverImagePart(id) {
return {
bytes: COVER_PNG,
fileName: `upload-resume-${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 coverImagePart = (id) =>
imagePart('cover', id, { fileName: `upload-resume-${id}.png` });
// 真实 ZIP(> 8 MiB,随机内容不可压缩),根目录含 index.html,条目数与声明的 packageFileCount 一致。
async function buildLargeFixtureZip() {
@@ -21,10 +21,12 @@ import { createHash } from 'node:crypto';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
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();
@@ -59,20 +61,16 @@ async function api(path, options = {}) {
} = 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 request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${path}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -112,36 +110,8 @@ function gameMetadata(title) {
};
}
/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */
function coverImagePart(id) {
return {
bytes: COVER_PNG,
fileName: `upload-safety-${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 coverImagePart = (id) =>
imagePart('cover', id, { fileName: `upload-safety-${id}.png` });
async function main() {
const adminLogin = await api('/admin/api/login', {
@@ -15,10 +15,12 @@ import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const API_PORT = Number(new URL(API).port || '80');
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
@@ -59,20 +61,16 @@ async function api(pathname, options = {}) {
} = 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 request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -104,36 +102,8 @@ async function adminToken() {
return token;
}
/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */
function coverImagePart(id) {
return {
bytes: COVER_PNG,
fileName: `restart-${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 coverImagePart = (id) =>
imagePart('cover', id, { fileName: `restart-${id}.png` });
function gameMetadata(title, coverObjectKey) {
return {
@@ -10,10 +10,12 @@ import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const WEB = process.env.E2E_WEB_BASE ?? 'http://127.0.0.1:3000';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
@@ -45,20 +47,16 @@ async function api(pathname, options = {}) {
} = 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 request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -77,36 +75,8 @@ async function loadPlaywright() {
return requireFromDir('playwright');
}
/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */
function coverImagePart(id) {
return {
bytes: COVER_PNG,
fileName: `a11y-${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 coverImagePart = (id) =>
imagePart('cover', id, { fileName: `a11y-${id}.png` });
async function loginThroughWebUi(page, phone) {
await page.goto(`${WEB}/games`, { waitUntil: 'commit', timeout: 120_000 });
+16 -46
View File
@@ -12,10 +12,12 @@ import path from 'node:path';
import JSZip from 'jszip';
const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
import {
imagePart,
publishFormData,
resolveApiRequest,
} from './game-distribution-e2e-helpers.mjs';
const WEB = process.env.E2E_WEB_BASE ?? 'http://127.0.0.1:3000';
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:8082';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
@@ -79,20 +81,16 @@ async function api(pathname, options = {}) {
} = 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 request = resolveApiRequest({
formData,
binary,
body,
headers: finalHeaders,
});
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
headers: request.headers,
body: request.body,
});
const text = await response.text();
let json = null;
@@ -110,36 +108,8 @@ async function api(pathname, options = {}) {
};
}
/** 发布用封面图片 part:字节随 `cover` part 提交,服务端直写项目快照桶。 */
function coverImagePart(id) {
return {
bytes: COVER_PNG,
fileName: `web-e2e-${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 coverImagePart = (id) =>
imagePart('cover', id, { fileName: `web-e2e-${id}.png` });
async function publishFixtureGame(stamp, title) {
const adminLogin = await api('/admin/api/login', {
+95
View File
@@ -0,0 +1,95 @@
// 游戏分发 E2E 脚本共用的 multipart 发布 helper。
//
// 这些脚本都直连 api-server,发布口是同一套契约:`metadata` JSON 文本 part + 可选 `cover`
// 二进制 part + 按顺序重复的 `screenshot` 二进制 part;各自的 `api()` 也只在 body 形态上分支。
// 这里收敛这段稳定契约,脚本只保留自己的路径、鉴权头与返回形状差异。
/** 1x1 透明 PNG:仅用于满足发布必填的封面/截图 part,内容不参与断言。 */
export const COVER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
/**
* 发布用图片 part。
*
* 默认文件名 `<kind>-<id>.png`、`image/png`;脚本可通过 `options.fileName` 保留自己的前缀,
* 或用 `options.bytes` / `options.contentType` 覆盖字节与类型。
*/
export function imagePart(kind, id, options = {}) {
const {
bytes = COVER_PNG,
fileName = `${kind}-${id}.png`,
contentType = 'image/png',
} = options;
return { bytes, fileName, contentType };
}
/**
* 封面 part 便捷封装:等价于 `imagePart('cover', id, options)`。
*
* 脚本可用 `options.fileName` 给出完整文件名,或用 `options.namePrefix` 生成
* `<prefix>-<id>.png`。
*/
export function coverImagePart(id, options = {}) {
const { namePrefix, fileName, ...rest } = options;
const resolvedFileName =
fileName ?? (namePrefix ? `${namePrefix}-${id}.png` : `cover-${id}.png`);
return imagePart('cover', id, { ...rest, fileName: resolvedFileName });
}
/**
* 组装发布请求体:`metadata` 文本 part + 可选 `cover` / 按顺序重复的 `screenshot` 二进制 part。
*
* `metadata.screenshots` 里的 `null` 槽位按出现顺序消费 `screenshots` 数组里的 part;string
* 槽位沿用该 objectKey。调用方负责保证 null 槽位数量与 part 数量一致(不一致服务端会 400)。
*/
export 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;
}
/**
* 推导 `api()` 的最终请求头与请求体。
*
* - `formData`:multipart/form-data,不手动设 `Content-Type`,交给 fetch 生成 boundary。
* - `binary`:直接作为 body(例如分片 PUT 的字节)。
* - `body !== undefined`:按 JSON 序列化,并补 `Content-Type: application/json`。
*
* 调用方先自行合并 `{ ...ENVELOPE, ...headers }` 与 `Authorization`,再把结果作为 `headers`
* 传进来;返回值里的 `headers` 是补完 `Content-Type` 后的副本。
*/
export function resolveApiRequest({
formData,
binary,
body,
headers = {},
} = {}) {
const finalHeaders = { ...headers };
let finalBody;
if (formData) {
finalBody = formData;
} else if (binary) {
finalBody = binary;
} else if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
return { headers: finalHeaders, body: finalBody };
}