补齐 External v1 场景生成路由的本地真实栈受理 smoke
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m49s
Project CI / Backend tests (push) Successful in 5m9s
Project CI / Native shell tests (push) Successful in 6m14s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m6s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 9m12s
Project CI / Frontend tests (push) Successful in 2m42s
Project CI / AI game creator shell web tests (push) Successful in 1m41s
Project CI / Repository checks (push) Successful in 2m16s

- 新增 scripts/check-external-v1-scene-generation-smoke.mjs:dev 账号现建外部 API Key,校验非法 Key 401、缺幂等键/空 sceneContent/custom 缺 customStyle 各自 400、合法请求 202 且返回 operationId、同键重放同一 operationId、同键换请求体 409
- 轮询生成状态得到 completed 与 assetObjectId,并用 external read-url 换签名地址下载成功(200 image/png),证明 Provider 出图在本机可用
- 里程碑最后一条验收改为已勾选,并把先前「Provider 出图未验证」的环境结论更新为已实测
This commit is contained in:
kdletters
2026-09-28 19:52:16 +08:00
parent ad2ab4cd60
commit eb7c00658a
3 changed files with 258 additions and 2 deletions
@@ -0,0 +1,254 @@
// External v1 场景生成路由的本地真实栈受理 smoke。
//
// 用法:
// E2E_API_BASE 默认 http://127.0.0.1:4198
// E2E_EXTERNAL_API_KEY 可选;不给时脚本在 dev 环境注册一个账号并通过
// /api/profile/api-keys 现建一个 Key(dev 密码登录会自动注册)。
//
// 覆盖:非法 Key 401 → 缺幂等键 400 → 空 sceneContent 400 → custom 缺 customStyle 400 →
// 合法请求 202 且返回 operationId → 同键重放返回同一 operationId → 同键换请求体 409 →
// 按 operationId 查一次生成状态(Provider 出图按环境可用性如实记录,不作为受理判据)。
const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:4198';
const ENVELOPE = { 'x-genarrative-response-envelope': 'v1' };
const DEV_PASSWORD = 'GenE2e123!';
let failures = 0;
function check(name, ok, detail = '') {
if (!ok) failures += 1;
console.log(
`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` :: ${detail}` : ''}`,
);
}
async function api(pathname, options = {}) {
const { method = 'GET', token, body, headers = {}, externalKey } = options;
const finalHeaders = { ...ENVELOPE, ...headers };
if (token) finalHeaders.Authorization = `Bearer ${token}`;
if (externalKey) finalHeaders.Authorization = `Bearer ${externalKey}`;
let finalBody;
if (body !== undefined) {
finalHeaders['Content-Type'] = 'application/json';
finalBody = JSON.stringify(body);
}
const response = await fetch(`${API}${pathname}`, {
method,
headers: finalHeaders,
body: finalBody,
});
const text = await response.text();
let json = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return {
status: response.status,
text,
json,
data: json?.data,
error: json?.error,
};
}
async function resolveExternalKey() {
const provided = (process.env.E2E_EXTERNAL_API_KEY ?? '').trim();
if (provided) return provided;
const phone = `133${String(Date.now()).slice(-8)}`;
const registered = await api('/api/auth/entry', {
method: 'POST',
body: { purePhoneNumber: phone, password: DEV_PASSWORD },
});
const token = registered.data?.token;
check(
'dev 账号注册/登录成功',
registered.status === 200 && Boolean(token),
`status=${registered.status}`,
);
if (!token) process.exit(1);
const created = await api('/api/profile/api-keys', {
method: 'POST',
token,
body: { name: 'E2E 场景路由 smoke' },
});
const apiKey = created.data?.apiKey;
check(
'创建外部 API Key 成功',
created.status === 200 && Boolean(apiKey),
`status=${created.status}`,
);
if (!apiKey) process.exit(1);
return apiKey;
}
const SCENE_PATH = '/api/external/v1/editor/scenes/generations';
async function main() {
const apiKey = await resolveExternalKey();
const unauthorized = await api(SCENE_PATH, {
method: 'POST',
externalKey: 'tnr_sk_invalid_probe',
headers: { 'Idempotency-Key': `scene-smoke-unauthorized-${Date.now()}` },
body: { sceneContent: '夕阳下的海边小镇', stylePreset: 'flat' },
});
check(
'非法 API Key 被拒(401)',
unauthorized.status === 401,
`status=${unauthorized.status}`,
);
const stamp = Date.now();
const missingKey = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
body: { sceneContent: '夕阳下的海边小镇', stylePreset: 'flat' },
});
check(
'缺 Idempotency-Key 被拒(400)',
missingKey.status === 400,
`status=${missingKey.status}`,
);
const blankScene = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': `scene-smoke-blank-${stamp}` },
body: { sceneContent: ' ', stylePreset: 'flat' },
});
check(
'空 sceneContent 被拒(400)',
blankScene.status === 400,
`status=${blankScene.status}`,
);
const customWithoutStyle = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': `scene-smoke-custom-${stamp}` },
body: { sceneContent: '夕阳下的海边小镇', stylePreset: 'custom' },
});
check(
'custom 风格缺 customStyle 被拒(400)',
customWithoutStyle.status === 400,
`status=${customWithoutStyle.status}`,
);
const idempotencyKey = `scene-smoke-${stamp}`;
const body = {
sceneContent: '夕阳下的海边小镇,远处有灯塔',
stylePreset: 'flat',
aspectRatio: '16:9',
imageSize: '1K',
generationInputs: { source: 'ai-game-creator-client' },
};
const accepted = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': idempotencyKey },
body,
});
const operationId = accepted.data?.operationId ?? accepted.data?.operation_id;
check(
'合法场景请求被受理(202 + operationId)',
accepted.status === 202 && Boolean(operationId),
`status=${accepted.status} operationId=${operationId ?? ''} msg=${accepted.error?.message ?? ''}`,
);
if (!operationId) process.exit(1);
const replayed = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': idempotencyKey },
body,
});
check(
'同键同请求重放返回同一 operationId',
replayed.status === 202 &&
(replayed.data?.operationId ?? replayed.data?.operation_id) ===
operationId,
`status=${replayed.status} operationId=${replayed.data?.operationId ?? ''}`,
);
const conflicting = await api(SCENE_PATH, {
method: 'POST',
externalKey: apiKey,
headers: { 'Idempotency-Key': idempotencyKey },
body: { ...body, sceneContent: '同一个幂等键换了画面内容' },
});
check(
'同键换请求体被拒(409)',
conflicting.status === 409,
`status=${conflicting.status} code=${conflicting.error?.code ?? ''}`,
);
let generation = null;
for (let attempt = 0; attempt < 12; attempt += 1) {
generation = await api(`/api/external/v1/generations/${operationId}`, {
externalKey: apiKey,
});
const status = generation.data?.status ?? '';
if (
status === 'completed' ||
status === 'succeeded' ||
status === 'failed' ||
status === 'cancelled'
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 5_000));
}
const status = generation?.data?.status ?? 'unknown';
check(
'按 operationId 能查到生成状态',
generation?.status === 200 && status !== 'unknown',
`status=${generation?.status} generationStatus=${status}`,
);
const result = generation?.data?.result ?? {};
const assetObjectId = result.assetObjectId ?? result.asset?.assetObjectId;
const objectKey = result.asset?.objectKey ?? result.objectKey ?? '';
const providerProducedImage =
status === 'completed' && Boolean(assetObjectId);
check(
'Provider 出图成功并返回素材引用(completed + assetObjectId)',
providerProducedImage,
`generationStatus=${status} assetObjectId=${assetObjectId ?? ''}`,
);
if (providerProducedImage && objectKey) {
const readUrl = await api(
`/api/external/v1/assets/read-url?objectKey=${encodeURIComponent(objectKey)}`,
{ externalKey: apiKey },
);
const signedUrl = readUrl.data?.read?.signedUrl ?? readUrl.data?.signedUrl;
const imageResponse = signedUrl ? await fetch(signedUrl) : null;
const contentType = imageResponse?.headers.get('content-type') ?? '';
check(
'出图素材能通过 external read-url 换签名地址下载',
readUrl.status === 200 &&
Boolean(signedUrl) &&
imageResponse?.status === 200 &&
contentType.startsWith('image/'),
`readUrl=${readUrl.status} objectKey=${objectKey} download=${imageResponse?.status ?? 'n/a'} type=${contentType}`,
);
}
console.log(
`\nProvider 出图:${
providerProducedImage
? `已验证(assetObjectId=${assetObjectId}${objectKey ? ` objectKey=${objectKey}` : ''})`
: `未验证(当前 generationStatus=${status},本机图像 Provider 不可用时属环境限制)`
}`,
);
console.log(`生成状态原始响应:${generation?.text?.slice(0, 400) ?? ''}`);
console.log(`\n${failures === 0 ? '全部通过' : `${failures} 项失败`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((error) => {
console.error(
`[check-external-v1-scene-generation-smoke] 运行失败:${error}`,
);
process.exit(1);
});