test(scenario): cover multi-image page generation end to end
Memind CI / Test, build, and release guards (push) Failing after 14m27s
Memind CI / Test, build, and release guards (push) Failing after 14m27s
The read_image poisoning fix had no end-to-end guard because scenarios could only send text. Add an upload_images step that renders and uploads visually distinct images, let chat steps attach them the same way the WeChat channel does, and assert the generated page actually embeds every upload. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import {
|
||||
buildAssetImageUrl,
|
||||
createAgentRun,
|
||||
createReporter,
|
||||
listScenarios,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
logoutViaApi,
|
||||
resolvePortalBase,
|
||||
snapshotPublicHtml,
|
||||
uploadImageAssets,
|
||||
verifyPageAccess,
|
||||
verifySurveyDelivery,
|
||||
verifyChildrenHobbyDietSurvey,
|
||||
@@ -95,6 +97,7 @@ async function runScenario(scenario, port) {
|
||||
let assistantCombinedLength = 0;
|
||||
let publishKey = null;
|
||||
let htmlBefore = [];
|
||||
let uploadedAssetIds = [];
|
||||
|
||||
for (const step of scenario.steps ?? []) {
|
||||
const label = step.label ?? step.action;
|
||||
@@ -119,6 +122,20 @@ async function runScenario(scenario, port) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.action === 'upload_images') {
|
||||
if (!auth) {
|
||||
throw new Error('upload_images 步骤前必须先 login');
|
||||
}
|
||||
const assets = await uploadImageAssets(
|
||||
baseUrl,
|
||||
auth.cookie,
|
||||
{ images: step.images ?? [], categoryCode: step.categoryCode ?? 'oa' },
|
||||
reporter,
|
||||
);
|
||||
uploadedAssetIds = assets.map((asset) => asset.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.action === 'chat') {
|
||||
if (!auth) {
|
||||
throw new Error('chat 步骤前必须先 login');
|
||||
@@ -133,6 +150,9 @@ async function runScenario(scenario, port) {
|
||||
sessionId,
|
||||
selectedChatSkill: step.selectedChatSkill ?? null,
|
||||
selectedAssetIds: step.selectedAssetIds ?? null,
|
||||
imageUrls: step.useUploadedAssets
|
||||
? uploadedAssetIds.map(buildAssetImageUrl)
|
||||
: null,
|
||||
});
|
||||
reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`);
|
||||
|
||||
@@ -191,6 +211,7 @@ async function runScenario(scenario, port) {
|
||||
replyText: reply.combined,
|
||||
htmlBefore,
|
||||
expect: step.expect.page,
|
||||
uploadedAssetIds,
|
||||
reporter,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,11 +121,16 @@ export async function logoutViaApi(baseUrl, cookie, reporter) {
|
||||
return body;
|
||||
}
|
||||
|
||||
function buildUserMessage(text, { selectedChatSkill = null } = {}) {
|
||||
function buildUserMessage(text, { selectedChatSkill = null, imageUrls = null } = {}) {
|
||||
const metadata = { userVisible: true, displayText: text };
|
||||
if (selectedChatSkill) {
|
||||
metadata.memindRun = { selectedChatSkill };
|
||||
}
|
||||
// Same shape the WeChat channel attaches for recent uploads, so scenarios can
|
||||
// exercise the vision turn without the browser upload widget.
|
||||
if (Array.isArray(imageUrls) && imageUrls.length > 0) {
|
||||
metadata.imageUrls = imageUrls;
|
||||
}
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
@@ -134,16 +139,21 @@ function buildUserMessage(text, { selectedChatSkill = null } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAssetImageUrl(assetId) {
|
||||
return `/api/mindspace/v1/assets/${assetId}/download?inline=1`;
|
||||
}
|
||||
|
||||
export async function createAgentRun(baseUrl, cookie, {
|
||||
message,
|
||||
sessionId = null,
|
||||
selectedChatSkill = null,
|
||||
selectedAssetIds = null,
|
||||
imageUrls = null,
|
||||
}) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const body = {
|
||||
request_id: requestId,
|
||||
user_message: buildUserMessage(message, { selectedChatSkill }),
|
||||
user_message: buildUserMessage(message, { selectedChatSkill, imageUrls }),
|
||||
};
|
||||
if (sessionId) body.session_id = sessionId;
|
||||
if (Array.isArray(selectedAssetIds) && selectedAssetIds.length > 0) {
|
||||
@@ -296,6 +306,100 @@ export async function waitForAssistantGrowth(baseUrl, cookie, sessionId, {
|
||||
return null;
|
||||
}
|
||||
|
||||
const UPLOADABLE_CATEGORY_CODES = ['oa', 'public'];
|
||||
|
||||
async function resolveUploadCategoryId(baseUrl, cookie, preferredCode) {
|
||||
const response = await fetch(`${baseUrl}/api/mindspace/v1/space`, {
|
||||
headers: { Cookie: cookie },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取 MindSpace 空间失败 ${response.status}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
const categories = payload?.data?.categories ?? payload?.categories ?? [];
|
||||
const uploadable = categories.filter((item) => UPLOADABLE_CATEGORY_CODES.includes(item?.code));
|
||||
const picked = uploadable.find((item) => item.code === preferredCode) ?? uploadable[0];
|
||||
if (!picked?.id) {
|
||||
throw new Error(`MindSpace 没有可上传的分类(需要 ${UPLOADABLE_CATEGORY_CODES.join(' / ')})`);
|
||||
}
|
||||
return picked.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a deterministic, visually distinct JPEG so a vision model can tell the
|
||||
* uploads apart. Shapes and colors only — no text, because the rasterizer has no
|
||||
* guaranteed CJK font.
|
||||
*/
|
||||
async function renderScenarioImage({ background = '#1e3a8a', shape = 'circle', accent = '#fbbf24' }) {
|
||||
const { default: sharp } = await import('sharp');
|
||||
const shapes = {
|
||||
circle: '<circle cx="400" cy="300" r="180" fill="ACCENT"/>',
|
||||
square: '<rect x="220" y="120" width="360" height="360" fill="ACCENT"/>',
|
||||
triangle: '<polygon points="400,110 600,480 200,480" fill="ACCENT"/>',
|
||||
diamond: '<polygon points="400,90 620,300 400,510 180,300" fill="ACCENT"/>',
|
||||
};
|
||||
const figure = (shapes[shape] ?? shapes.circle).replace('ACCENT', accent);
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600">`
|
||||
+ `<rect width="800" height="600" fill="${background}"/>${figure}</svg>`;
|
||||
return sharp(Buffer.from(svg)).jpeg({ quality: 90 }).toBuffer();
|
||||
}
|
||||
|
||||
export async function uploadImageAssets(
|
||||
baseUrl,
|
||||
cookie,
|
||||
{ images = [], categoryCode = 'oa' } = {},
|
||||
reporter,
|
||||
) {
|
||||
const categoryId = await resolveUploadCategoryId(baseUrl, cookie, categoryCode);
|
||||
const assets = [];
|
||||
|
||||
for (const [index, spec] of images.entries()) {
|
||||
const buffer = await renderScenarioImage(spec ?? {});
|
||||
const filename = spec?.filename ?? `scenario-image-${index + 1}.jpg`;
|
||||
|
||||
const createResponse = await fetch(`${baseUrl}/api/mindspace/v1/uploads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({
|
||||
category_id: categoryId,
|
||||
filename,
|
||||
size_bytes: buffer.length,
|
||||
declared_mime_type: 'image/jpeg',
|
||||
}),
|
||||
});
|
||||
if (!createResponse.ok) {
|
||||
throw new Error(`创建上传失败 ${createResponse.status}: ${await createResponse.text()}`);
|
||||
}
|
||||
const upload = (await createResponse.json())?.data;
|
||||
const uploadUrl = new URL(upload.uploadUrl, baseUrl).href;
|
||||
|
||||
const putResponse = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/octet-stream', Cookie: cookie },
|
||||
body: buffer,
|
||||
});
|
||||
if (!putResponse.ok) {
|
||||
throw new Error(`上传内容失败 ${putResponse.status}: ${await putResponse.text()}`);
|
||||
}
|
||||
|
||||
const completeResponse = await fetch(
|
||||
`${baseUrl}/api/mindspace/v1/uploads/${encodeURIComponent(upload.id)}/complete`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
if (!completeResponse.ok) {
|
||||
throw new Error(`完成上传失败 ${completeResponse.status}: ${await completeResponse.text()}`);
|
||||
}
|
||||
assets.push((await completeResponse.json())?.data);
|
||||
}
|
||||
|
||||
reporter?.pass('上传图片', `${assets.length} 张 → ${assets.map((a) => a.id).join(', ')}`);
|
||||
return assets;
|
||||
}
|
||||
|
||||
export async function verifyPageAccess({
|
||||
baseUrl,
|
||||
cookie,
|
||||
@@ -303,6 +407,7 @@ export async function verifyPageAccess({
|
||||
replyText,
|
||||
htmlBefore = [],
|
||||
expect = {},
|
||||
uploadedAssetIds = [],
|
||||
reporter,
|
||||
}) {
|
||||
const keywords = expect.keywords ?? [];
|
||||
@@ -354,6 +459,19 @@ export async function verifyPageAccess({
|
||||
reporter.fail('页面元数据', '缺少 mindspace-cover');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expect.requireUploadedImages) {
|
||||
const embedded = uploadedAssetIds.filter((assetId) => assetId && html.includes(assetId));
|
||||
const required = expect.minUploadedImages ?? uploadedAssetIds.length;
|
||||
if (embedded.length < required) {
|
||||
reporter.fail(
|
||||
'页面嵌入上传图片',
|
||||
`命中 ${embedded.length}/${uploadedAssetIds.length} 张,要求至少 ${required} 张`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
reporter.pass('页面嵌入上传图片', `${embedded.length}/${uploadedAssetIds.length} 张`);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user