diff --git a/scripts/check-game-distribution-media-e2e.mjs b/scripts/check-game-distribution-media-e2e.mjs index 85c4486d2..545e1aa6c 100644 --- a/scripts/check-game-distribution-media-e2e.mjs +++ b/scripts/check-game-distribution-media-e2e.mjs @@ -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)}`, diff --git a/scripts/check-game-distribution-ops-rollback-e2e.mjs b/scripts/check-game-distribution-ops-rollback-e2e.mjs index a4cce4571..78cf19e00 100644 --- a/scripts/check-game-distribution-ops-rollback-e2e.mjs +++ b/scripts/check-game-distribution-ops-rollback-e2e.mjs @@ -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 { diff --git a/scripts/check-game-distribution-owner-isolation.mjs b/scripts/check-game-distribution-owner-isolation.mjs index cf7594c41..a7f026883 100644 --- a/scripts/check-game-distribution-owner-isolation.mjs +++ b/scripts/check-game-distribution-owner-isolation.mjs @@ -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', { diff --git a/scripts/check-game-distribution-package-limits-e2e.mjs b/scripts/check-game-distribution-package-limits-e2e.mjs index 5c5c7a5f1..c6a02dd94 100644 --- a/scripts/check-game-distribution-package-limits-e2e.mjs +++ b/scripts/check-game-distribution-package-limits-e2e.mjs @@ -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, diff --git a/scripts/check-game-distribution-purchase-e2e.mjs b/scripts/check-game-distribution-purchase-e2e.mjs index 20c4ceef3..bb4fff4f6 100644 --- a/scripts/check-game-distribution-purchase-e2e.mjs +++ b/scripts/check-game-distribution-purchase-e2e.mjs @@ -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` }); /** * 生成最小可玩发行包:入口页里带唯一标记,用来证明播放会话读到的是同一份包内容。 diff --git a/scripts/check-game-distribution-sandbox-e2e.mjs b/scripts/check-game-distribution-sandbox-e2e.mjs index f03c1be3b..863add0b9 100644 --- a/scripts/check-game-distribution-sandbox-e2e.mjs +++ b/scripts/check-game-distribution-sandbox-e2e.mjs @@ -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"; diff --git a/scripts/check-game-distribution-upload-resume.mjs b/scripts/check-game-distribution-upload-resume.mjs index a1b0b5365..ad649338a 100644 --- a/scripts/check-game-distribution-upload-resume.mjs +++ b/scripts/check-game-distribution-upload-resume.mjs @@ -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() { diff --git a/scripts/check-game-distribution-upload-safety.mjs b/scripts/check-game-distribution-upload-safety.mjs index afaa08486..c1145a0e9 100644 --- a/scripts/check-game-distribution-upload-safety.mjs +++ b/scripts/check-game-distribution-upload-safety.mjs @@ -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', { diff --git a/scripts/check-game-distribution-validation-restart-e2e.mjs b/scripts/check-game-distribution-validation-restart-e2e.mjs index 260e3df03..e2761b6fa 100644 --- a/scripts/check-game-distribution-validation-restart-e2e.mjs +++ b/scripts/check-game-distribution-validation-restart-e2e.mjs @@ -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 { diff --git a/scripts/check-game-distribution-web-a11y-e2e.mjs b/scripts/check-game-distribution-web-a11y-e2e.mjs index 48ddad423..b8bbf59ef 100644 --- a/scripts/check-game-distribution-web-a11y-e2e.mjs +++ b/scripts/check-game-distribution-web-a11y-e2e.mjs @@ -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 }); diff --git a/scripts/check-game-distribution-web-e2e.mjs b/scripts/check-game-distribution-web-e2e.mjs index 727b33b1c..26ec9a05d 100644 --- a/scripts/check-game-distribution-web-e2e.mjs +++ b/scripts/check-game-distribution-web-e2e.mjs @@ -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', { diff --git a/scripts/game-distribution-e2e-helpers.mjs b/scripts/game-distribution-e2e-helpers.mjs new file mode 100644 index 000000000..8dc3a89f5 --- /dev/null +++ b/scripts/game-distribution-e2e-helpers.mjs @@ -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。 + * + * 默认文件名 `-.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` 生成 + * `-.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 }; +}