合并远端 master 到美术包合同修复分支
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m20s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m48s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m16s
Project CI / Backend tests (pull_request) Successful in 7m25s
Project CI / Frontend tests (pull_request) Successful in 3m10s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m28s
Project CI / Repository checks (pull_request) Successful in 6m39s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m20s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m48s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m16s
Project CI / Backend tests (pull_request) Successful in 7m25s
Project CI / Frontend tests (pull_request) Successful in 3m10s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m28s
Project CI / Repository checks (pull_request) Successful in 6m39s
Project CI / Native shell tests (pull_request) Has been cancelled
同步 origin/master 的最新代码、测试与文档变更 保留图集按实际产物返回的提示词并移除已退役发布说明 合并技能包清单并递增版本,保留双方项目经验记录
This commit is contained in:
@@ -31,14 +31,6 @@ const PAIRS = [
|
||||
['CreatorConnection', 'CreatorConnection'],
|
||||
['CreatorConnections', 'CreatorConnections'],
|
||||
['CreatorRelationshipResponse', 'CreatorRelationshipResponse'],
|
||||
[
|
||||
'GameDistributionPublishMetadataSuggestionRequest',
|
||||
'GameDistributionPublishMetadataSuggestionRequest',
|
||||
],
|
||||
[
|
||||
'GameDistributionPublishMetadataSuggestion',
|
||||
'GameDistributionPublishMetadataSuggestion',
|
||||
],
|
||||
['GameDistributionVersionStatus', 'GameDistributionVersionStatus'],
|
||||
['GameDistributionVisibility', 'GameDistributionGameVisibility'],
|
||||
['GameDistributionInputMode', 'GameDistributionInputMode'],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
@@ -61,20 +63,26 @@ function section(title) {
|
||||
}
|
||||
|
||||
async function api(pathname, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {}, binary } = options;
|
||||
const {
|
||||
method = 'GET',
|
||||
token,
|
||||
body,
|
||||
formData,
|
||||
headers = {},
|
||||
binary,
|
||||
} = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
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;
|
||||
@@ -162,57 +170,18 @@ async function setPublishGate(admin, enabled, rolloutPercent) {
|
||||
return response;
|
||||
}
|
||||
|
||||
async function uploadCover(token, id) {
|
||||
const fileName = `ops-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'ops-rollback', String(id)],
|
||||
fileName,
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: COVER_PNG.length,
|
||||
metadata: { asset_kind: 'game_distribution_cover' },
|
||||
},
|
||||
});
|
||||
if (ticket.status !== 200) {
|
||||
throw new Error(`创建直传凭证失败 ${ticket.status}`);
|
||||
}
|
||||
const upload = ticket.data.upload;
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
|
||||
if (value !== null && value !== undefined) form.append(key, String(value));
|
||||
}
|
||||
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
|
||||
const put = await fetch(upload.host, { method: 'POST', body: form });
|
||||
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
|
||||
const confirm = await api('/api/assets/objects/confirm', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
bucket: upload.bucket,
|
||||
objectKey: upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: COVER_PNG.length,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-ops-rollback',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`);
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
const coverImagePart = (id) =>
|
||||
imagePart('cover', id, { fileName: `ops-${id}.png` });
|
||||
|
||||
function gameMetadata(title, coverAssetId) {
|
||||
function gameMetadata(title, coverObjectKey) {
|
||||
return {
|
||||
title,
|
||||
summary: '发布开关回滚窗口 E2E',
|
||||
description: '',
|
||||
category: '休闲',
|
||||
tags: ['ops'],
|
||||
coverAssetId,
|
||||
coverObjectKey,
|
||||
screenshots: [],
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
inputModes: ['keyboard', 'mouse'],
|
||||
orientation: 'landscape',
|
||||
@@ -242,13 +211,13 @@ async function createVersion(token, gameId, metadata, bytes, fileCount, key) {
|
||||
method: 'POST',
|
||||
token,
|
||||
headers: { 'Idempotency-Key': key },
|
||||
body: {
|
||||
formData: publishFormData({
|
||||
packageSha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
packageBytes: bytes.length,
|
||||
packageFileCount: fileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -369,25 +338,30 @@ async function main() {
|
||||
.map((pair) => pair.split('=').slice(1).join('='))
|
||||
.find((value) => value && value.length > 20);
|
||||
|
||||
const coverAssetId = await uploadCover(author, stamp);
|
||||
const cover = coverImagePart(stamp);
|
||||
const title = `发布回滚 ${String(stamp).slice(-6)}`;
|
||||
const metadata = gameMetadata(title, coverAssetId);
|
||||
const metadata = gameMetadata(title, null);
|
||||
const created = await api('/api/game-distribution/games', {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': key('ops-game') },
|
||||
body: metadata,
|
||||
formData: publishFormData(metadata, { cover }),
|
||||
});
|
||||
const gameId = created.data?.id;
|
||||
check('创建游戏成功', created.status === 200 && Boolean(gameId));
|
||||
if (!gameId) process.exit(1);
|
||||
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
|
||||
const resolvedMetadata = {
|
||||
...metadata,
|
||||
coverObjectKey: created.data.coverObjectKey,
|
||||
};
|
||||
|
||||
section('基线:v1 公开、v2/v3 待审、v4 只有版本记录');
|
||||
const v1 = await buildPackage('V1-OK');
|
||||
const v1Version = await createVersion(
|
||||
author,
|
||||
gameId,
|
||||
metadata,
|
||||
resolvedMetadata,
|
||||
v1.bytes,
|
||||
v1.fileCount,
|
||||
key('ops-v1'),
|
||||
@@ -476,7 +450,7 @@ async function main() {
|
||||
const version = await createVersion(
|
||||
author,
|
||||
gameId,
|
||||
metadata,
|
||||
resolvedMetadata,
|
||||
built.bytes,
|
||||
built.fileCount,
|
||||
key(`ops-${marker}`),
|
||||
@@ -507,7 +481,7 @@ async function main() {
|
||||
const v4Version = await createVersion(
|
||||
author,
|
||||
gameId,
|
||||
metadata,
|
||||
resolvedMetadata,
|
||||
v4Built.bytes,
|
||||
v4Built.fileCount,
|
||||
key('ops-v4'),
|
||||
@@ -530,7 +504,12 @@ async function main() {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': key('ops-blocked-game') },
|
||||
body: metadata,
|
||||
// 「新图」语义:带二进制 cover 时 metadata 的 coverObjectKey 必须是 null,别同时给
|
||||
// 一个可复用 objectKey,否则 payload 含义含糊,网关检查顺序变化时结论会漂。
|
||||
formData: publishFormData(
|
||||
{ ...resolvedMetadata, coverObjectKey: null },
|
||||
{ cover },
|
||||
),
|
||||
}),
|
||||
],
|
||||
[
|
||||
@@ -539,15 +518,15 @@ async function main() {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': key('ops-blocked-version') },
|
||||
body: {
|
||||
formData: publishFormData({
|
||||
packageSha256: createHash('sha256')
|
||||
.update(v4Built.bytes)
|
||||
.digest('hex'),
|
||||
packageBytes: v4Built.bytes.length,
|
||||
packageFileCount: v4Built.fileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
gameMetadata: resolvedMetadata,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
[
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
// 确认分包、送审、撤回全部按「不可见」处理 → 未认证读私有版本 401 → 越权写没有副作用
|
||||
// (作者侧已收字节仍为 0)→ 请求体里伪造 owner 不生效(游戏仍归请求者、不出现在被冒名账号
|
||||
// 的 my-games)→ 作者撤回自己的版本成功。整条链路只在本机 dev 数据库落行;唯一的存储写入
|
||||
// 是「建游戏必须提供封面」逼出的一个 67 字节 PNG(dev bucket,走现役直传 + 确认链路),
|
||||
// 发行包分片与确认全部停在越权拒绝之前,不会产生任何对象。
|
||||
const COVER_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
// 是「建游戏必须提供封面」逼出的一个 67 字节 PNG(新合同由服务端把 cover part 直写项目
|
||||
// 快照桶),发行包分片与确认全部停在越权拒绝之前,不会产生任何对象。
|
||||
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();
|
||||
@@ -39,20 +41,26 @@ function check(name, ok, detail = '') {
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {}, binary } = options;
|
||||
const {
|
||||
method = 'GET',
|
||||
token,
|
||||
body,
|
||||
formData,
|
||||
headers = {},
|
||||
binary,
|
||||
} = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
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;
|
||||
@@ -85,56 +93,8 @@ function gameMetadata(title) {
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadCover(token, id) {
|
||||
const fileName = `owner-isolation-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'owner-isolation', String(id)],
|
||||
fileName,
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: COVER_PNG.length,
|
||||
metadata: { asset_kind: 'game_distribution_cover' },
|
||||
},
|
||||
});
|
||||
if (ticket.status !== 200) {
|
||||
throw new Error(
|
||||
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
const upload = ticket.data.upload;
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
|
||||
if (value !== null && value !== undefined) form.append(key, String(value));
|
||||
}
|
||||
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
|
||||
const put = await fetch(upload.host, { method: 'POST', body: form });
|
||||
if (!put.ok) {
|
||||
throw new Error(`直传对象存储失败 ${put.status}`);
|
||||
}
|
||||
const confirm = await api('/api/assets/objects/confirm', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
bucket: upload.bucket,
|
||||
objectKey: upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: COVER_PNG.length,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-owner-isolation',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) {
|
||||
throw new Error(
|
||||
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
const coverImagePart = (id) =>
|
||||
imagePart('cover', id, { fileName: `owner-isolation-${id}.png` });
|
||||
|
||||
async function register(prefix) {
|
||||
const response = await api('/api/auth/entry', {
|
||||
@@ -198,12 +158,15 @@ async function main() {
|
||||
|
||||
const stamp = Date.now();
|
||||
const title = `越权隔离 ${String(stamp).slice(-6)}`;
|
||||
const coverAssetId = await uploadCover(author.token, stamp);
|
||||
const cover = coverImagePart(stamp);
|
||||
const created = await api('/api/game-distribution/games', {
|
||||
method: 'POST',
|
||||
token: author.token,
|
||||
headers: { 'Idempotency-Key': `iso-game-${stamp}` },
|
||||
body: { ...gameMetadata(title), coverAssetId },
|
||||
formData: publishFormData(
|
||||
{ ...gameMetadata(title), coverObjectKey: null, screenshots: [] },
|
||||
{ cover },
|
||||
),
|
||||
});
|
||||
const gameId = created.data?.id;
|
||||
check(
|
||||
@@ -219,13 +182,18 @@ async function main() {
|
||||
method: 'POST',
|
||||
token: author.token,
|
||||
headers: { 'Idempotency-Key': `iso-version-${stamp}` },
|
||||
body: {
|
||||
formData: publishFormData({
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageBytes: 2048,
|
||||
packageFileCount: 2,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: { ...gameMetadata(title), coverAssetId },
|
||||
},
|
||||
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
|
||||
gameMetadata: {
|
||||
...gameMetadata(title),
|
||||
coverObjectKey: created.data.coverObjectKey,
|
||||
screenshots: [],
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const versionId = versionResponse.data?.versionId;
|
||||
@@ -391,20 +359,21 @@ async function main() {
|
||||
`status=${authorState.status} receivedBytes=${authorState.data?.receivedBytes}`,
|
||||
);
|
||||
|
||||
const intruderCoverAssetId = await uploadCover(
|
||||
intruder.token,
|
||||
`${stamp}-intruder`,
|
||||
);
|
||||
const intruderCover = coverImagePart(`${stamp}-intruder`);
|
||||
const forged = await api('/api/game-distribution/games', {
|
||||
method: 'POST',
|
||||
token: intruder.token,
|
||||
headers: { 'Idempotency-Key': `iso-forged-${stamp}` },
|
||||
body: {
|
||||
...gameMetadata(`伪造 owner ${String(stamp).slice(-6)}`),
|
||||
coverAssetId: intruderCoverAssetId,
|
||||
ownerUserId: 'user_forged',
|
||||
owner_user_id: 'user_forged',
|
||||
},
|
||||
formData: publishFormData(
|
||||
{
|
||||
...gameMetadata(`伪造 owner ${String(stamp).slice(-6)}`),
|
||||
coverObjectKey: null,
|
||||
screenshots: [],
|
||||
ownerUserId: 'user_forged',
|
||||
owner_user_id: 'user_forged',
|
||||
},
|
||||
{ cover: intruderCover },
|
||||
),
|
||||
});
|
||||
const forgedGameId = forged.data?.id;
|
||||
check(
|
||||
|
||||
@@ -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' };
|
||||
@@ -35,20 +37,26 @@ function check(name, ok, detail = '') {
|
||||
}
|
||||
|
||||
async function api(pathname, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {}, binary } = options;
|
||||
const {
|
||||
method = 'GET',
|
||||
token,
|
||||
body,
|
||||
formData,
|
||||
headers = {},
|
||||
binary,
|
||||
} = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
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;
|
||||
@@ -80,51 +88,12 @@ function apiServerWorkingSetMb() {
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadCover(token, id) {
|
||||
const fileName = `limits-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'limits', String(id)],
|
||||
fileName,
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: COVER_PNG.length,
|
||||
metadata: { asset_kind: 'game_distribution_cover' },
|
||||
},
|
||||
});
|
||||
if (ticket.status !== 200)
|
||||
throw new Error(`创建直传凭证失败 ${ticket.status}`);
|
||||
const upload = ticket.data.upload;
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
|
||||
if (value !== null && value !== undefined) form.append(key, String(value));
|
||||
}
|
||||
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
|
||||
const put = await fetch(upload.host, { method: 'POST', body: form });
|
||||
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
|
||||
const confirm = await api('/api/assets/objects/confirm', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
bucket: upload.bucket,
|
||||
objectKey: upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: COVER_PNG.length,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-limits',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`);
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
const coverImagePart = (id) =>
|
||||
imagePart('cover', id, { fileName: `limits-${id}.png` });
|
||||
|
||||
async function createGameWithVersion(
|
||||
author,
|
||||
coverAssetId,
|
||||
cover,
|
||||
title,
|
||||
suffix,
|
||||
declaration,
|
||||
@@ -135,7 +104,8 @@ async function createGameWithVersion(
|
||||
description: '',
|
||||
category: '休闲',
|
||||
tags: ['limits'],
|
||||
coverAssetId,
|
||||
coverObjectKey: null,
|
||||
screenshots: [],
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
inputModes: ['keyboard', 'mouse'],
|
||||
orientation: 'landscape',
|
||||
@@ -144,7 +114,7 @@ async function createGameWithVersion(
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `limits-game-${suffix}` },
|
||||
body: metadata,
|
||||
formData: publishFormData(metadata, { cover }),
|
||||
});
|
||||
const gameId = created.data?.id;
|
||||
if (created.status !== 200 || !gameId) {
|
||||
@@ -156,13 +126,17 @@ async function createGameWithVersion(
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `limits-version-${suffix}` },
|
||||
body: {
|
||||
formData: publishFormData({
|
||||
packageSha256: declaration.packageSha256 ?? 'a'.repeat(64),
|
||||
packageBytes: declaration.packageBytes,
|
||||
packageFileCount: declaration.packageFileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
|
||||
gameMetadata: {
|
||||
...metadata,
|
||||
coverObjectKey: created.data.coverObjectKey,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return { gameId, metadata, version };
|
||||
}
|
||||
@@ -232,12 +206,12 @@ async function main() {
|
||||
`status=${register.status}`,
|
||||
);
|
||||
if (!author) process.exit(1);
|
||||
const coverAssetId = await uploadCover(author, stamp);
|
||||
const cover = coverImagePart(stamp);
|
||||
|
||||
// A. 声明超过 200 MiB:创建版本阶段就该失败关闭,不必真的传包。
|
||||
const declaredOver = await createGameWithVersion(
|
||||
author,
|
||||
coverAssetId,
|
||||
cover,
|
||||
`边界 声明超限 ${String(stamp).slice(-5)}`,
|
||||
`${stamp}-declared`,
|
||||
{ packageBytes: 200 * MIB + 1, packageFileCount: 1 },
|
||||
@@ -262,7 +236,7 @@ async function main() {
|
||||
);
|
||||
const manyCase = await createGameWithVersion(
|
||||
author,
|
||||
coverAssetId,
|
||||
cover,
|
||||
`边界 文件数 ${String(stamp).slice(-5)}`,
|
||||
`${stamp}-many`,
|
||||
{
|
||||
@@ -301,7 +275,7 @@ async function main() {
|
||||
);
|
||||
const bigFileCase = await createGameWithVersion(
|
||||
author,
|
||||
coverAssetId,
|
||||
cover,
|
||||
`边界 单文件 ${String(stamp).slice(-5)}`,
|
||||
`${stamp}-bigfile`,
|
||||
{
|
||||
@@ -352,7 +326,7 @@ async function main() {
|
||||
);
|
||||
const expandedCase = await createGameWithVersion(
|
||||
author,
|
||||
coverAssetId,
|
||||
cover,
|
||||
`边界 展开量 ${String(stamp).slice(-5)}`,
|
||||
`${stamp}-expanded`,
|
||||
{
|
||||
@@ -389,7 +363,7 @@ async function main() {
|
||||
);
|
||||
const ratioCase = await createGameWithVersion(
|
||||
author,
|
||||
coverAssetId,
|
||||
cover,
|
||||
`边界 压缩比 ${String(stamp).slice(-5)}`,
|
||||
`${stamp}-ratio`,
|
||||
{
|
||||
@@ -442,7 +416,7 @@ async function main() {
|
||||
);
|
||||
const bigCase = await createGameWithVersion(
|
||||
author,
|
||||
coverAssetId,
|
||||
cover,
|
||||
`边界 200MiB 档 ${String(stamp).slice(-5)}`,
|
||||
`${stamp}-big`,
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -167,20 +167,26 @@ async function resolveWebBase() {
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {}, binary } = options;
|
||||
const {
|
||||
method = 'GET',
|
||||
token,
|
||||
body,
|
||||
formData,
|
||||
headers = {},
|
||||
binary,
|
||||
} = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
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;
|
||||
@@ -213,56 +219,8 @@ async function registerAccount(prefix) {
|
||||
return { status: entry.status, token: entry.data?.token ?? '', phone };
|
||||
}
|
||||
|
||||
async function uploadCover(token, id) {
|
||||
const fileName = `cover-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'cover', id],
|
||||
fileName,
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: COVER_PNG.length,
|
||||
metadata: { asset_kind: 'game_distribution_cover' },
|
||||
},
|
||||
});
|
||||
if (ticket.status !== 200) {
|
||||
throw new Error(
|
||||
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
const upload = ticket.data.upload;
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
|
||||
if (value !== null && value !== undefined) form.append(key, String(value));
|
||||
}
|
||||
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
|
||||
const put = await fetch(upload.host, { method: 'POST', body: form });
|
||||
if (!put.ok) {
|
||||
throw new Error(`直传对象存储失败 ${put.status}`);
|
||||
}
|
||||
const confirm = await api('/api/assets/objects/confirm', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
bucket: upload.bucket,
|
||||
objectKey: upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: COVER_PNG.length,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-cover',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) {
|
||||
throw new Error(
|
||||
`confirm 失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
const coverImagePart = (id) =>
|
||||
imagePart('cover', id, { fileName: `cover-${id}.png` });
|
||||
|
||||
/**
|
||||
* 生成最小可玩发行包:入口页里带唯一标记,用来证明播放会话读到的是同一份包内容。
|
||||
@@ -287,7 +245,7 @@ async function buildPackage(marker) {
|
||||
};
|
||||
}
|
||||
|
||||
function gameMetadata(title, coverAssetId) {
|
||||
function gameMetadata(title, coverObjectKey) {
|
||||
return {
|
||||
title,
|
||||
summary: '买断制泥点付费真实链路验证',
|
||||
@@ -297,7 +255,7 @@ function gameMetadata(title, coverAssetId) {
|
||||
deviceSupport: { desktop: true, mobile: true, touch: true },
|
||||
inputModes: ['keyboard', 'mouse', 'touch'],
|
||||
orientation: 'responsive',
|
||||
coverAssetId,
|
||||
coverObjectKey,
|
||||
screenshots: [],
|
||||
};
|
||||
}
|
||||
@@ -309,24 +267,24 @@ async function createVersionAndSubmit({
|
||||
price,
|
||||
marker,
|
||||
title,
|
||||
coverAssetId,
|
||||
coverObjectKey,
|
||||
expectedPublicationRevision,
|
||||
}) {
|
||||
const id = stamp();
|
||||
const metadata = gameMetadata(title, coverAssetId);
|
||||
const metadata = gameMetadata(title, coverObjectKey);
|
||||
const pkg = await buildPackage(marker);
|
||||
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
|
||||
method: 'POST',
|
||||
token: authorToken,
|
||||
headers: { 'Idempotency-Key': `e2e-purchase-version-${id}` },
|
||||
body: {
|
||||
formData: publishFormData({
|
||||
priceMudPoints: price,
|
||||
packageSha256: pkg.sha256,
|
||||
packageBytes: pkg.bytes.length,
|
||||
packageFileCount: pkg.fileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (version.status !== 200 || !version.data?.versionId) {
|
||||
throw new Error(
|
||||
@@ -374,18 +332,18 @@ async function createVersionAndSubmit({
|
||||
async function publishGame({
|
||||
authorToken,
|
||||
adminToken,
|
||||
coverAssetId,
|
||||
cover,
|
||||
price,
|
||||
marker,
|
||||
title,
|
||||
}) {
|
||||
const id = stamp();
|
||||
const metadata = gameMetadata(title, coverAssetId);
|
||||
const metadata = gameMetadata(title, null);
|
||||
const created = await api('/api/game-distribution/games', {
|
||||
method: 'POST',
|
||||
token: authorToken,
|
||||
headers: { 'Idempotency-Key': `e2e-purchase-game-${id}` },
|
||||
body: metadata,
|
||||
formData: publishFormData(metadata, { cover }),
|
||||
});
|
||||
if (created.status !== 200 || !created.data?.id) {
|
||||
throw new Error(
|
||||
@@ -393,6 +351,8 @@ async function publishGame({
|
||||
);
|
||||
}
|
||||
const gameId = created.data.id;
|
||||
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
|
||||
const coverObjectKey = created.data.coverObjectKey;
|
||||
|
||||
const { versionId, pkg } = await createVersionAndSubmit({
|
||||
authorToken,
|
||||
@@ -400,7 +360,7 @@ async function publishGame({
|
||||
price,
|
||||
marker,
|
||||
title,
|
||||
coverAssetId,
|
||||
coverObjectKey,
|
||||
expectedPublicationRevision: created.data.publicationRevision ?? 0,
|
||||
});
|
||||
|
||||
@@ -425,7 +385,7 @@ async function publishGame({
|
||||
`审核通过失败 ${approved.status} ${approved.text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
return { gameId, versionId, pkg };
|
||||
return { gameId, versionId, pkg, coverObjectKey };
|
||||
}
|
||||
|
||||
async function walletBalance(token) {
|
||||
@@ -750,11 +710,11 @@ async function main() {
|
||||
`status=${gateOpen.status}`,
|
||||
);
|
||||
|
||||
const coverAssetId = await uploadCover(author.token, stamp());
|
||||
const cover = coverImagePart(stamp());
|
||||
check(
|
||||
'作者封面素材直传并 confirm',
|
||||
Boolean(coverAssetId),
|
||||
String(coverAssetId).slice(0, 24),
|
||||
'作者封面图片 part 准备完成',
|
||||
cover.bytes.length > 0 && cover.contentType === 'image/png',
|
||||
`${cover.fileName} bytes=${cover.bytes.length}`,
|
||||
);
|
||||
|
||||
// 2. 付费游戏:发布 → 审核通过
|
||||
@@ -762,7 +722,7 @@ async function main() {
|
||||
const paid = await publishGame({
|
||||
authorToken: author.token,
|
||||
adminToken,
|
||||
coverAssetId,
|
||||
cover,
|
||||
price: PRICE_MUD_POINTS,
|
||||
marker: paidMarker,
|
||||
title: `买断制支付验证 ${stamp().slice(-6)}`,
|
||||
@@ -1091,7 +1051,7 @@ async function main() {
|
||||
const free = await publishGame({
|
||||
authorToken: author.token,
|
||||
adminToken,
|
||||
coverAssetId,
|
||||
cover,
|
||||
price: 0,
|
||||
marker: freeMarker,
|
||||
title: `免费游戏回归 ${stamp().slice(-6)}`,
|
||||
@@ -1151,7 +1111,7 @@ async function main() {
|
||||
const pricey = await publishGame({
|
||||
authorToken: author.token,
|
||||
adminToken,
|
||||
coverAssetId,
|
||||
cover,
|
||||
price: 1_000_000,
|
||||
marker: priceyMarker,
|
||||
title: `余额不足验证 ${stamp().slice(-6)}`,
|
||||
@@ -1413,7 +1373,8 @@ async function main() {
|
||||
price: PRICE_MUD_POINTS,
|
||||
marker: pendingMarker,
|
||||
title: `待审付费版本 ${stamp().slice(-6)}`,
|
||||
coverAssetId,
|
||||
// 沿用该付费游戏当前封面 objectKey。
|
||||
coverObjectKey: paid.coverObjectKey,
|
||||
expectedPublicationRevision:
|
||||
paidPublicBeforePending.data?.publicationRevision ?? 0,
|
||||
});
|
||||
@@ -1490,24 +1451,32 @@ async function main() {
|
||||
// 定价边界用例用独立作者账号:作者自有列表按条数上限聚合返回版本,作品多的账号
|
||||
// 会让目标游戏的版本查不到(既有口径),独立账号才能用公开接口可靠数版本。
|
||||
const boundsAuthor = await registerAccount('132');
|
||||
const boundsCoverAssetId = await uploadCover(boundsAuthor.token, stamp());
|
||||
const boundsCover = coverImagePart(stamp());
|
||||
check(
|
||||
'定价边界用例的准备作者与封面就绪',
|
||||
boundsAuthor.status === 200 && Boolean(boundsCoverAssetId),
|
||||
`status=${boundsAuthor.status} cover=${Boolean(boundsCoverAssetId)}`,
|
||||
boundsAuthor.status === 200 && boundsCover.bytes.length > 0,
|
||||
`status=${boundsAuthor.status} coverBytes=${boundsCover.bytes.length}`,
|
||||
);
|
||||
const boundsGameCreated = await api('/api/game-distribution/games', {
|
||||
method: 'POST',
|
||||
token: boundsAuthor.token,
|
||||
headers: { 'Idempotency-Key': `e2e-bounds-game-${stamp()}` },
|
||||
body: gameMetadata(`定价边界验证 ${stamp().slice(-6)}`, boundsCoverAssetId),
|
||||
formData: publishFormData(
|
||||
gameMetadata(`定价边界验证 ${stamp().slice(-6)}`, null),
|
||||
{ cover: boundsCover },
|
||||
),
|
||||
});
|
||||
const boundsGameId = boundsGameCreated.data?.id;
|
||||
// 建版沿用创建时服务端派生的封面 objectKey。
|
||||
const boundsCoverObjectKey = boundsGameCreated.data?.coverObjectKey;
|
||||
check(
|
||||
'定价边界用例的准备游戏创建成功',
|
||||
boundsGameCreated.status === 200 && Boolean(boundsGameId),
|
||||
`status=${boundsGameCreated.status} gameId=${String(boundsGameId)}`,
|
||||
);
|
||||
// 建游失败时下面的 /my-games/undefined 与 /games/undefined/versions 会级联报错,
|
||||
// 掩盖真正的根因;这里与其它脚本一致,先记录失败再退出。
|
||||
if (!boundsGameId) process.exit(1);
|
||||
// 单游戏作者视图把自有条目挂在 `data.game` 下(`versions` 在条目里,不在顶层)。
|
||||
const countBoundsVersions = async () => {
|
||||
const ownerEntry = await api(
|
||||
@@ -1522,7 +1491,7 @@ async function main() {
|
||||
method: 'POST',
|
||||
token: boundsAuthor.token,
|
||||
headers: { 'Idempotency-Key': `e2e-bounds-version-${suffix}-${stamp()}` },
|
||||
body: {
|
||||
formData: publishFormData({
|
||||
priceMudPoints: price,
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageBytes: 1024,
|
||||
@@ -1530,9 +1499,9 @@ async function main() {
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: gameMetadata(
|
||||
`定价边界验证 ${suffix}`,
|
||||
boundsCoverAssetId,
|
||||
boundsCoverObjectKey,
|
||||
),
|
||||
},
|
||||
}),
|
||||
});
|
||||
const tooHigh = await declareVersion(1_000_001, 'toohigh');
|
||||
check(
|
||||
|
||||
@@ -120,7 +120,8 @@ async function createGame(owner, title) {
|
||||
description: null,
|
||||
category: '休闲',
|
||||
tags_json: '["e2e"]',
|
||||
cover_asset_id: null,
|
||||
cover_object_key: null,
|
||||
screenshots_json: null,
|
||||
author_name: [0, owner.user.displayName],
|
||||
author_avatar_url: null,
|
||||
device_support_desktop: true,
|
||||
@@ -139,7 +140,6 @@ async function publish(gameId, owner, revision) {
|
||||
title: '网站评价验收',
|
||||
summary: '本地评价 fixture,发行资源不上传',
|
||||
category: '休闲',
|
||||
coverAssetId: 'reviews-e2e-cover',
|
||||
coverObjectKey: 'reviews-e2e/cover.png',
|
||||
tags: ['e2e'],
|
||||
deviceSupport: { desktop: true, mobile: true, touch: true },
|
||||
|
||||
@@ -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();
|
||||
@@ -41,20 +43,26 @@ function check(name, ok, detail = '') {
|
||||
}
|
||||
|
||||
async function api(pathname, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {}, binary } = options;
|
||||
const {
|
||||
method = 'GET',
|
||||
token,
|
||||
body,
|
||||
formData,
|
||||
headers = {},
|
||||
binary,
|
||||
} = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
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;
|
||||
@@ -85,54 +93,8 @@ function gameMetadata(title) {
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadCover(token, id) {
|
||||
const fileName = `sandbox-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'sandbox', String(id)],
|
||||
fileName,
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: COVER_PNG.length,
|
||||
metadata: { asset_kind: 'game_distribution_cover' },
|
||||
},
|
||||
});
|
||||
if (ticket.status !== 200) {
|
||||
throw new Error(
|
||||
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
const upload = ticket.data.upload;
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
|
||||
if (value !== null && value !== undefined) form.append(key, String(value));
|
||||
}
|
||||
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
|
||||
const put = await fetch(upload.host, { method: 'POST', body: form });
|
||||
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
|
||||
const confirm = await api('/api/assets/objects/confirm', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
bucket: upload.bucket,
|
||||
objectKey: upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: COVER_PNG.length,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-sandbox',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) {
|
||||
throw new Error(
|
||||
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
const coverImagePart = (id) =>
|
||||
imagePart('cover', id, { fileName: `sandbox-${id}.png` });
|
||||
|
||||
const PROBE_HELPER = "export const marker = 'helper-ok';\n";
|
||||
|
||||
@@ -333,15 +295,19 @@ async function main() {
|
||||
.createHash('sha256')
|
||||
.update(packageBytes)
|
||||
.digest('hex');
|
||||
const coverAssetId = await uploadCover(author, stamp);
|
||||
const cover = coverImagePart(stamp);
|
||||
const title = `发行沙箱 ${String(stamp).slice(-6)}`;
|
||||
const metadata = { ...gameMetadata(title), coverAssetId };
|
||||
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}` },
|
||||
body: metadata,
|
||||
formData: publishFormData(metadata, { cover }),
|
||||
});
|
||||
const gameId = created.data?.id;
|
||||
check(
|
||||
@@ -357,13 +323,17 @@ async function main() {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `sandbox-version-${stamp}` },
|
||||
body: {
|
||||
formData: publishFormData({
|
||||
packageSha256,
|
||||
packageBytes: packageBytes.length,
|
||||
packageFileCount: 3,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
// 建版沿用创建时服务端派生的封面 objectKey。
|
||||
gameMetadata: {
|
||||
...metadata,
|
||||
coverObjectKey: created.data.coverObjectKey,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const versionId = versionResponse.data?.versionId;
|
||||
|
||||
@@ -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();
|
||||
@@ -55,20 +57,26 @@ function check(name, ok, detail = '') {
|
||||
}
|
||||
|
||||
async function api(pathname, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {}, binary } = options;
|
||||
const {
|
||||
method = 'GET',
|
||||
token,
|
||||
body,
|
||||
formData,
|
||||
headers = {},
|
||||
binary,
|
||||
} = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
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;
|
||||
@@ -138,56 +146,8 @@ function gameMetadata(title) {
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadCover(token, id) {
|
||||
const fileName = `upload-resume-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'upload-resume', String(id)],
|
||||
fileName,
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: COVER_PNG.length,
|
||||
metadata: { asset_kind: 'game_distribution_cover' },
|
||||
},
|
||||
});
|
||||
if (ticket.status !== 200) {
|
||||
throw new Error(
|
||||
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
const upload = ticket.data.upload;
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
|
||||
if (value !== null && value !== undefined) form.append(key, String(value));
|
||||
}
|
||||
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
|
||||
const put = await fetch(upload.host, { method: 'POST', body: form });
|
||||
if (!put.ok) {
|
||||
throw new Error(`直传对象存储失败 ${put.status}`);
|
||||
}
|
||||
const confirm = await api('/api/assets/objects/confirm', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
bucket: upload.bucket,
|
||||
objectKey: upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: COVER_PNG.length,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-upload-resume',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) {
|
||||
throw new Error(
|
||||
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
const coverImagePart = (id) =>
|
||||
imagePart('cover', id, { fileName: `upload-resume-${id}.png` });
|
||||
|
||||
// 真实 ZIP(> 8 MiB,随机内容不可压缩),根目录含 index.html,条目数与声明的 packageFileCount 一致。
|
||||
async function buildLargeFixtureZip() {
|
||||
@@ -246,14 +206,18 @@ async function runPrepare(paths) {
|
||||
);
|
||||
await writeFile(paths.zip, zipBytes);
|
||||
|
||||
const coverAssetId = await uploadCover(author, stamp);
|
||||
const cover = coverImagePart(stamp);
|
||||
const title = `分片续传 ${String(stamp).slice(-6)}`;
|
||||
const metadata = { ...gameMetadata(title), coverAssetId };
|
||||
const metadata = {
|
||||
...gameMetadata(title),
|
||||
coverObjectKey: null,
|
||||
screenshots: [],
|
||||
};
|
||||
const created = await api('/api/game-distribution/games', {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `resume-game-${stamp}` },
|
||||
body: metadata,
|
||||
formData: publishFormData(metadata, { cover }),
|
||||
});
|
||||
const gameId = created.data?.id;
|
||||
check(
|
||||
@@ -269,13 +233,17 @@ async function runPrepare(paths) {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `resume-version-${stamp}` },
|
||||
body: {
|
||||
formData: publishFormData({
|
||||
packageSha256: zipSha256,
|
||||
packageBytes: zipBytes.length,
|
||||
packageFileCount: built.fileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
// 建版沿用创建时服务端派生的封面 objectKey。
|
||||
gameMetadata: {
|
||||
...metadata,
|
||||
coverObjectKey: created.data.coverObjectKey,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const versionId = versionResponse.data?.versionId;
|
||||
|
||||
@@ -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();
|
||||
@@ -49,20 +51,26 @@ function check(name, ok, detail = '') {
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {}, binary } = options;
|
||||
const {
|
||||
method = 'GET',
|
||||
token,
|
||||
body,
|
||||
formData,
|
||||
headers = {},
|
||||
binary,
|
||||
} = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
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;
|
||||
@@ -102,56 +110,8 @@ function gameMetadata(title) {
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadCover(token, id) {
|
||||
const fileName = `upload-safety-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'upload-safety', String(id)],
|
||||
fileName,
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: COVER_PNG.length,
|
||||
metadata: { asset_kind: 'game_distribution_cover' },
|
||||
},
|
||||
});
|
||||
if (ticket.status !== 200) {
|
||||
throw new Error(
|
||||
`创建直传凭证失败 ${ticket.status} ${ticket.text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
const upload = ticket.data.upload;
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
|
||||
if (value !== null && value !== undefined) form.append(key, String(value));
|
||||
}
|
||||
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
|
||||
const put = await fetch(upload.host, { method: 'POST', body: form });
|
||||
if (!put.ok) {
|
||||
throw new Error(`直传对象存储失败 ${put.status}`);
|
||||
}
|
||||
const confirm = await api('/api/assets/objects/confirm', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
bucket: upload.bucket,
|
||||
objectKey: upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: COVER_PNG.length,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-upload-safety',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) {
|
||||
throw new Error(
|
||||
`确认素材失败 ${confirm.status} ${confirm.text.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
const coverImagePart = (id) =>
|
||||
imagePart('cover', id, { fileName: `upload-safety-${id}.png` });
|
||||
|
||||
async function main() {
|
||||
const adminLogin = await api('/admin/api/login', {
|
||||
@@ -205,9 +165,13 @@ async function main() {
|
||||
|
||||
const zipBytes = await buildFixtureZip();
|
||||
const zipSha256 = createHash('sha256').update(zipBytes).digest('hex');
|
||||
const coverAssetId = await uploadCover(author, stamp);
|
||||
const cover = coverImagePart(stamp);
|
||||
const title = `并发上传 ${String(stamp).slice(-6)}`;
|
||||
const metadata = { ...gameMetadata(title), coverAssetId };
|
||||
const metadata = {
|
||||
...gameMetadata(title),
|
||||
coverObjectKey: null,
|
||||
screenshots: [],
|
||||
};
|
||||
|
||||
// 1. 幂等重放:同 key 同请求返回同一条记录,同 key 换请求体冲突。
|
||||
const gameKey = `safety-game-${stamp}`;
|
||||
@@ -215,7 +179,7 @@ async function main() {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': gameKey },
|
||||
body: metadata,
|
||||
formData: publishFormData(metadata, { cover }),
|
||||
});
|
||||
const gameId = created.data?.id;
|
||||
check(
|
||||
@@ -229,7 +193,7 @@ async function main() {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': gameKey },
|
||||
body: metadata,
|
||||
formData: publishFormData(metadata, { cover }),
|
||||
});
|
||||
check(
|
||||
'同 key 同请求重放建游戏返回同一 game',
|
||||
@@ -241,7 +205,7 @@ async function main() {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': gameKey },
|
||||
body: { ...metadata, title: `${title} 改` },
|
||||
formData: publishFormData({ ...metadata, title: `${title} 改` }, { cover }),
|
||||
});
|
||||
check(
|
||||
'同 key 换请求体建游戏被拒(不产生第二份)',
|
||||
@@ -255,7 +219,11 @@ async function main() {
|
||||
packageBytes: zipBytes.length,
|
||||
packageFileCount: 2,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
// 建版沿用创建时服务端派生的封面 objectKey。
|
||||
gameMetadata: {
|
||||
...metadata,
|
||||
coverObjectKey: created.data.coverObjectKey,
|
||||
},
|
||||
};
|
||||
const versionResponse = await api(
|
||||
`/api/game-distribution/games/${gameId}/versions`,
|
||||
@@ -263,7 +231,7 @@ async function main() {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': versionKey },
|
||||
body: versionBody,
|
||||
formData: publishFormData(versionBody),
|
||||
},
|
||||
);
|
||||
const versionId = versionResponse.data?.versionId;
|
||||
@@ -283,7 +251,7 @@ async function main() {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': versionKey },
|
||||
body: versionBody,
|
||||
formData: publishFormData(versionBody),
|
||||
},
|
||||
);
|
||||
check(
|
||||
@@ -440,7 +408,7 @@ async function main() {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `safety-version-recovery-${stamp}` },
|
||||
body: versionBody,
|
||||
formData: publishFormData(versionBody),
|
||||
},
|
||||
);
|
||||
const recoveryVersionId = recoveryVersion.data?.versionId;
|
||||
|
||||
@@ -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' };
|
||||
@@ -49,20 +51,26 @@ function check(name, ok, detail = '') {
|
||||
}
|
||||
|
||||
async function api(pathname, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {}, binary } = options;
|
||||
const {
|
||||
method = 'GET',
|
||||
token,
|
||||
body,
|
||||
formData,
|
||||
headers = {},
|
||||
binary,
|
||||
} = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
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;
|
||||
@@ -94,56 +102,18 @@ async function adminToken() {
|
||||
return token;
|
||||
}
|
||||
|
||||
async function uploadCover(token, id) {
|
||||
const fileName = `restart-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'restart', String(id)],
|
||||
fileName,
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: COVER_PNG.length,
|
||||
metadata: { asset_kind: 'game_distribution_cover' },
|
||||
},
|
||||
});
|
||||
if (ticket.status !== 200)
|
||||
throw new Error(`创建直传凭证失败 ${ticket.status}`);
|
||||
const upload = ticket.data.upload;
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
|
||||
if (value !== null && value !== undefined) form.append(key, String(value));
|
||||
}
|
||||
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
|
||||
const put = await fetch(upload.host, { method: 'POST', body: form });
|
||||
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
|
||||
const confirm = await api('/api/assets/objects/confirm', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
bucket: upload.bucket,
|
||||
objectKey: upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: COVER_PNG.length,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-restart',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`);
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
const coverImagePart = (id) =>
|
||||
imagePart('cover', id, { fileName: `restart-${id}.png` });
|
||||
|
||||
function gameMetadata(title, coverAssetId) {
|
||||
function gameMetadata(title, coverObjectKey) {
|
||||
return {
|
||||
title,
|
||||
summary: '校验重启恢复 E2E',
|
||||
description: '',
|
||||
category: '休闲',
|
||||
tags: ['restart'],
|
||||
coverAssetId,
|
||||
coverObjectKey,
|
||||
screenshots: [],
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
inputModes: ['keyboard', 'mouse'],
|
||||
orientation: 'landscape',
|
||||
@@ -162,13 +132,13 @@ async function createVersion(
|
||||
method: 'POST',
|
||||
token,
|
||||
headers: { 'Idempotency-Key': `restart-version-${suffix}` },
|
||||
body: {
|
||||
formData: publishFormData({
|
||||
packageSha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
packageBytes: bytes.length,
|
||||
packageFileCount: fileCount,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -224,25 +194,33 @@ async function runPrepare() {
|
||||
const author = register.data?.token;
|
||||
check('作者注册拿到 token', register.status === 200 && Boolean(author));
|
||||
if (!author) process.exit(1);
|
||||
const coverAssetId = await uploadCover(author, stamp);
|
||||
const cover = coverImagePart(stamp);
|
||||
const title = `校验重启 ${String(stamp).slice(-6)}`;
|
||||
const metadata = gameMetadata(title, coverAssetId);
|
||||
const metadata = gameMetadata(title, null);
|
||||
|
||||
// 先发布 v1(公开版本),用来验证后续失败/重试不会误删公开内容。
|
||||
const created = await api('/api/game-distribution/games', {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `restart-game-${stamp}` },
|
||||
body: metadata,
|
||||
formData: publishFormData(metadata, { cover }),
|
||||
});
|
||||
const gameId = created.data?.id;
|
||||
check('创建游戏成功', created.status === 200 && Boolean(gameId));
|
||||
// `check` 只记失败不中断:创建失败时 created.data 为空,继续解引用会抛未捕获 TypeError
|
||||
// 把脚本直接打崩,拿不到后面成组的 check 结论。
|
||||
if (!gameId) process.exit(1);
|
||||
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
|
||||
const resolvedMetadata = {
|
||||
...metadata,
|
||||
coverObjectKey: created.data.coverObjectKey,
|
||||
};
|
||||
const v1Package = await buildPackage(1, 'V1-OK');
|
||||
const v1Bytes = v1Package.bytes;
|
||||
const v1 = await createVersion(
|
||||
author,
|
||||
gameId,
|
||||
metadata,
|
||||
resolvedMetadata,
|
||||
v1Bytes,
|
||||
`${stamp}-v1`,
|
||||
v1Package.fileCount,
|
||||
@@ -279,7 +257,7 @@ async function runPrepare() {
|
||||
const v2 = await createVersion(
|
||||
author,
|
||||
gameId,
|
||||
metadata,
|
||||
resolvedMetadata,
|
||||
v2Bytes,
|
||||
`${stamp}-v2`,
|
||||
v2Package.fileCount,
|
||||
@@ -330,7 +308,7 @@ async function runPrepare() {
|
||||
password: DEV_PASSWORD,
|
||||
gameId,
|
||||
title,
|
||||
coverAssetId,
|
||||
coverObjectKey: resolvedMetadata.coverObjectKey,
|
||||
versionId,
|
||||
v1VersionId: v1.data?.versionId,
|
||||
v2Bytes: v2Bytes.length,
|
||||
@@ -430,20 +408,31 @@ async function runResume() {
|
||||
Buffer.from('PK\x03\x04broken', 'binary'),
|
||||
randomBytes(1024),
|
||||
]);
|
||||
const metadata = gameMetadata(state.title, state.coverAssetId);
|
||||
// 兼容旧版脚本写下的 STATE_FILE:当时只存 coverAssetId(旧素材对象 ID,不是对象存储 key)。
|
||||
// 建版沿用封面要求等于游戏当前的 cover_object_key,所以恢复时先查回真实 key,绝不能拿
|
||||
// coverAssetId 冒充;v1 在写 STATE_FILE 前已审核通过,公开详情能返回当前封面。
|
||||
const current = state.coverObjectKey
|
||||
? null
|
||||
: await api(`/api/game-distribution/games/${state.gameId}`, {
|
||||
token: author,
|
||||
});
|
||||
const metadata = gameMetadata(
|
||||
state.title,
|
||||
state.coverObjectKey ?? current?.data?.coverObjectKey,
|
||||
);
|
||||
const v3 = await api(
|
||||
`/api/game-distribution/games/${state.gameId}/versions`,
|
||||
{
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `restart-version-v3-${state.stamp}` },
|
||||
body: {
|
||||
formData: publishFormData({
|
||||
packageSha256: createHash('sha256').update(brokenBytes).digest('hex'),
|
||||
packageBytes: brokenBytes.length,
|
||||
packageFileCount: 1,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const v3Id = v3.data?.versionId;
|
||||
|
||||
@@ -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' };
|
||||
@@ -35,20 +37,26 @@ function check(name, ok, detail = '') {
|
||||
}
|
||||
|
||||
async function api(pathname, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {}, binary } = options;
|
||||
const {
|
||||
method = 'GET',
|
||||
token,
|
||||
body,
|
||||
formData,
|
||||
headers = {},
|
||||
binary,
|
||||
} = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
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;
|
||||
@@ -67,47 +75,8 @@ async function loadPlaywright() {
|
||||
return requireFromDir('playwright');
|
||||
}
|
||||
|
||||
async function uploadCover(token, id) {
|
||||
const fileName = `a11y-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'a11y', String(id)],
|
||||
fileName,
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: COVER_PNG.length,
|
||||
metadata: { asset_kind: 'game_distribution_cover' },
|
||||
},
|
||||
});
|
||||
if (ticket.status !== 200)
|
||||
throw new Error(`创建直传凭证失败 ${ticket.status}`);
|
||||
const upload = ticket.data.upload;
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
|
||||
if (value !== null && value !== undefined) form.append(key, String(value));
|
||||
}
|
||||
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
|
||||
const put = await fetch(upload.host, { method: 'POST', body: form });
|
||||
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
|
||||
const confirm = await api('/api/assets/objects/confirm', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
bucket: upload.bucket,
|
||||
objectKey: upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: COVER_PNG.length,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-a11y',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) throw new Error(`确认素材失败 ${confirm.status}`);
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
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 });
|
||||
@@ -192,14 +161,15 @@ async function publishFixtureGame(adminToken, stamp, title) {
|
||||
.createHash('sha256')
|
||||
.update(bytes)
|
||||
.digest('hex');
|
||||
const coverAssetId = await uploadCover(author, stamp);
|
||||
const cover = coverImagePart(stamp);
|
||||
const metadata = {
|
||||
title,
|
||||
summary: '可达性 E2E 临时游戏',
|
||||
description: '',
|
||||
category: '休闲',
|
||||
tags: ['e2e'],
|
||||
coverAssetId,
|
||||
coverObjectKey: null,
|
||||
screenshots: [],
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
inputModes: ['keyboard', 'mouse'],
|
||||
orientation: 'landscape',
|
||||
@@ -208,20 +178,27 @@ async function publishFixtureGame(adminToken, stamp, title) {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `a11y-game-${stamp}` },
|
||||
body: metadata,
|
||||
formData: publishFormData(metadata, { cover }),
|
||||
});
|
||||
const gameId = created.data?.id;
|
||||
// 创建失败时 created.data 为空,下面解引用 coverObjectKey 会抛未捕获 TypeError 把脚本打崩,
|
||||
// 拿不到成组的可达性 check 结论;先退出,保留一条正常的失败退出码。
|
||||
if (!gameId) process.exit(1);
|
||||
const version = await api(`/api/game-distribution/games/${gameId}/versions`, {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `a11y-version-${stamp}` },
|
||||
body: {
|
||||
formData: publishFormData({
|
||||
packageSha256: sha256,
|
||||
packageBytes: bytes.length,
|
||||
packageFileCount: 2,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
// 建版沿用创建时服务端派生的封面 objectKey。
|
||||
gameMetadata: {
|
||||
...metadata,
|
||||
coverObjectKey: created.data.coverObjectKey,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const versionId = version.data?.versionId;
|
||||
const uploaded = await api(
|
||||
|
||||
@@ -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' };
|
||||
@@ -69,20 +71,26 @@ async function warmUpWebRoutesInBrowser(browser, urls) {
|
||||
}
|
||||
|
||||
async function api(pathname, options = {}) {
|
||||
const { method = 'GET', token, body, headers = {}, binary } = options;
|
||||
const {
|
||||
method = 'GET',
|
||||
token,
|
||||
body,
|
||||
formData,
|
||||
headers = {},
|
||||
binary,
|
||||
} = options;
|
||||
const finalHeaders = { ...ENVELOPE, ...headers };
|
||||
if (token) finalHeaders.Authorization = `Bearer ${token}`;
|
||||
let finalBody;
|
||||
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;
|
||||
@@ -100,50 +108,8 @@ async function api(pathname, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadCover(token, id) {
|
||||
const fileName = `web-e2e-${id}.png`;
|
||||
const ticket = await api('/api/assets/direct-upload-tickets', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
legacyPrefix: 'generated-character-drafts',
|
||||
pathSegments: ['game-distribution', 'web-e2e', String(id)],
|
||||
fileName,
|
||||
contentType: 'image/png',
|
||||
access: 'private',
|
||||
maxSizeBytes: COVER_PNG.length,
|
||||
metadata: { asset_kind: 'game_distribution_cover' },
|
||||
},
|
||||
});
|
||||
if (ticket.status !== 200) {
|
||||
throw new Error(`创建直传凭证失败 ${ticket.status}`);
|
||||
}
|
||||
const upload = ticket.data.upload;
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(upload.formFields ?? {})) {
|
||||
if (value !== null && value !== undefined) form.append(key, String(value));
|
||||
}
|
||||
form.append('file', new Blob([COVER_PNG], { type: 'image/png' }), fileName);
|
||||
const put = await fetch(upload.host, { method: 'POST', body: form });
|
||||
if (!put.ok) throw new Error(`直传对象存储失败 ${put.status}`);
|
||||
const confirm = await api('/api/assets/objects/confirm', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: {
|
||||
bucket: upload.bucket,
|
||||
objectKey: upload.objectKey,
|
||||
contentType: 'image/png',
|
||||
contentLength: COVER_PNG.length,
|
||||
assetKind: 'game_distribution_cover',
|
||||
accessPolicy: 'private',
|
||||
entityId: 'game-distribution-web-e2e',
|
||||
},
|
||||
});
|
||||
if (confirm.status !== 200) {
|
||||
throw new Error(`确认素材失败 ${confirm.status}`);
|
||||
}
|
||||
return confirm.data.assetObject.assetObjectId;
|
||||
}
|
||||
const coverImagePart = (id) =>
|
||||
imagePart('cover', id, { fileName: `web-e2e-${id}.png` });
|
||||
|
||||
async function publishFixtureGame(stamp, title) {
|
||||
const adminLogin = await api('/admin/api/login', {
|
||||
@@ -202,14 +168,15 @@ async function publishFixtureGame(stamp, title) {
|
||||
.update(packageBytes)
|
||||
.digest('hex');
|
||||
|
||||
const coverAssetId = await uploadCover(author, stamp);
|
||||
const cover = coverImagePart(stamp);
|
||||
const metadata = {
|
||||
title,
|
||||
summary: '网页链路 E2E 临时游戏',
|
||||
description: '',
|
||||
category: '休闲',
|
||||
tags: ['e2e'],
|
||||
coverAssetId,
|
||||
coverObjectKey: null,
|
||||
screenshots: [],
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
inputModes: ['keyboard', 'mouse'],
|
||||
orientation: 'landscape',
|
||||
@@ -219,7 +186,7 @@ async function publishFixtureGame(stamp, title) {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `web-e2e-game-${stamp}` },
|
||||
body: metadata,
|
||||
formData: publishFormData(metadata, { cover }),
|
||||
});
|
||||
const gameId = created.data?.id;
|
||||
if (created.status !== 200 || !gameId) {
|
||||
@@ -233,13 +200,17 @@ async function publishFixtureGame(stamp, title) {
|
||||
method: 'POST',
|
||||
token: author,
|
||||
headers: { 'Idempotency-Key': `web-e2e-version-${stamp}` },
|
||||
body: {
|
||||
formData: publishFormData({
|
||||
packageSha256,
|
||||
packageBytes: packageBytes.length,
|
||||
packageFileCount: 2,
|
||||
packageEntryPath: 'index.html',
|
||||
gameMetadata: metadata,
|
||||
},
|
||||
// 建版沿用创建时服务端派生的封面 objectKey(必须属于该游戏当前媒体)。
|
||||
gameMetadata: {
|
||||
...metadata,
|
||||
coverObjectKey: created.data.coverObjectKey,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const versionId = versionResponse.data?.versionId;
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
* 生成文件没重新跑」以外的情形,也很容易被本地未提交改动掩盖;比较生成前后快照更直接——
|
||||
* 只要重新生成后的内容与现有内容不同,就说明仓库里的绑定是陈旧的。
|
||||
*
|
||||
* 覆盖两套 crate、四个目录:
|
||||
* 覆盖两套 crate、五个目录:
|
||||
* - `shared-contracts` → `packages/shared/src/contracts/generated`;
|
||||
* - AGC shell → `chat/generated` + `services/generated` + `features/ui-editor/types`。
|
||||
* - AGC shell → `chat/generated` + `services/generated` + `features/ui-editor/types` +
|
||||
* `export/generated`。
|
||||
*
|
||||
* 生成文件一律以 ts-rs 原始输出提交,不接受 prettier / eslint 等工具二次改写:这些目录
|
||||
* 同时登记在 `.prettierignore` 与 `.eslintrc.cjs` 的 `ignorePatterns`,所以这里直接逐字节
|
||||
@@ -30,6 +31,8 @@ const AGC_CHAT_DIR =
|
||||
const AGC_SERVICES_DIR = 'apps/ai-game-creator-shell/src/services/generated';
|
||||
const AGC_UI_EDITOR_DIR =
|
||||
'apps/ai-game-creator-shell/src/features/ui-editor/types';
|
||||
const AGC_EXPORT_DIR =
|
||||
'apps/ai-game-creator-shell/src/view/project-development/export/generated';
|
||||
|
||||
/** 一套绑定的生成入口、覆盖目录与"怎么重新生成"的提示。 */
|
||||
const targets = [
|
||||
@@ -51,7 +54,7 @@ const targets = [
|
||||
},
|
||||
{
|
||||
label: 'ai-game-creator-shell',
|
||||
dirs: [AGC_CHAT_DIR, AGC_SERVICES_DIR, AGC_UI_EDITOR_DIR],
|
||||
dirs: [AGC_CHAT_DIR, AGC_SERVICES_DIR, AGC_UI_EDITOR_DIR, AGC_EXPORT_DIR],
|
||||
cargoArgs: [
|
||||
'test',
|
||||
'--locked',
|
||||
@@ -70,7 +73,7 @@ function snapshot(dir) {
|
||||
// "0 个文件"静默通过——`export_to` 路径被改名或生成文件被整体删除时,校验形同虚设。
|
||||
throw new Error(`生成绑定目录不存在,无法校验:${dir}`);
|
||||
}
|
||||
// 四个生成目录都是平铺的(ts-rs 的 `export_to` 直接落文件,不再嵌套子目录);
|
||||
// 五个生成目录都是平铺的(ts-rs 的 `export_to` 直接落文件,不再嵌套子目录);
|
||||
// 不用 `recursive`,避免依赖 Node 18.17 / 20.1 才有的选项(旧版本会静默退化成只读顶层)。
|
||||
const entries = fs.readdirSync(absolute);
|
||||
return new Map(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// 游戏分发 E2E 脚本共用的 multipart 发布 helper。
|
||||
//
|
||||
// 这些脚本都直连 api-server,发布口是同一套契约:`metadata` JSON 文本 part + 可选 `cover`
|
||||
// 二进制 part + 按顺序重复的 `screenshot` 二进制 part;各自的 `api()` 也只在 body 形态上分支。
|
||||
// 这里收敛这段稳定契约,脚本只保留自己的路径、鉴权头与返回形状差异。
|
||||
|
||||
/** 1x1 透明 PNG:仅用于满足发布必填的封面/截图 part,内容不参与断言。 */
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装发布请求体:`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 };
|
||||
}
|
||||
@@ -428,12 +428,19 @@ function assertFixtureRepository(cwd, env) {
|
||||
);
|
||||
}
|
||||
|
||||
const fixtureTempRoot = join(homedir(), 'data', 'tmp');
|
||||
|
||||
function isolatedGitEnvironment() {
|
||||
// Git hooks export repository/index paths that override cwd, including in
|
||||
// linked worktrees. Fixture Git and lint-staged must never inherit them.
|
||||
return Object.fromEntries(
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([name]) => !/^GIT_/iu.test(name)),
|
||||
);
|
||||
// 夹具建在 ~/data/tmp 下,而 home 自身也可能是 Git 仓库(部分 CI 容器即如此)。
|
||||
// 用 ceiling 阻止 git 向上发现父仓库,否则尚未 `git init` 的夹具会被
|
||||
// assertFixtureRepository 误判成“命令落到外部仓库”。
|
||||
env.GIT_CEILING_DIRECTORIES = fixtureTempRoot;
|
||||
return env;
|
||||
}
|
||||
|
||||
// 守卫用例把本文件跑在毒化环境里;子进程必须真的继承 GIT_DIR,否则断言会空转。
|
||||
@@ -472,7 +479,6 @@ function assertHookClearsGitEnvironment(hookName, contents, command) {
|
||||
const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
||||
|
||||
function createTempDirectory(prefix) {
|
||||
const tempRoot = join(homedir(), 'data', 'tmp');
|
||||
mkdirSync(tempRoot, { recursive: true });
|
||||
return mkdtempSync(join(tempRoot, prefix));
|
||||
mkdirSync(fixtureTempRoot, { recursive: true });
|
||||
return mkdtempSync(join(fixtureTempRoot, prefix));
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ def main():
|
||||
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)
|
||||
game = dict(game_id=game_id, owner_user_id=owner, title=game_id, summary="测试", description="测试", category="adventure", tags_json="[]", 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, price_mud_points=0)
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user