316 lines
11 KiB
JavaScript
316 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
import assert from 'node:assert/strict';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs';
|
|
import net from 'node:net';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createMindSpaceRemoteServerAdapter } from '../mindspace-remote-server-adapter.mjs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const root = path.resolve(__dirname, '..');
|
|
const runtimeRoot = path.join(root, '.runtime', 'mindspace-service');
|
|
const localToken = 'local-split-service-smoke-token';
|
|
|
|
function loadDotenvFile(targetEnv, filepath) {
|
|
if (!fs.existsSync(filepath) || !fs.statSync(filepath).isFile()) return;
|
|
const content = fs.readFileSync(filepath, 'utf8');
|
|
for (const rawLine of content.split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith('#') || !line.includes('=')) continue;
|
|
const [rawKey, ...rawValueParts] = line.split('=');
|
|
const key = rawKey.trim();
|
|
if (!key || key in targetEnv) continue;
|
|
const rawValue = rawValueParts.join('=').trim();
|
|
const unwrapped =
|
|
(rawValue.startsWith('"') && rawValue.endsWith('"')) ||
|
|
(rawValue.startsWith("'") && rawValue.endsWith("'"))
|
|
? rawValue.slice(1, -1)
|
|
: rawValue;
|
|
targetEnv[key] = unwrapped;
|
|
}
|
|
}
|
|
|
|
function runBuild() {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
['scripts/build-mindspace-service-runtime.mjs', '--skip-node-modules'],
|
|
{
|
|
cwd: root,
|
|
stdio: 'inherit',
|
|
env: process.env,
|
|
},
|
|
);
|
|
if (result.status !== 0) {
|
|
throw new Error(`MindSpace runtime build failed with status ${result.status}`);
|
|
}
|
|
}
|
|
|
|
async function freePort() {
|
|
const server = net.createServer();
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
const port = server.address().port;
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
return port;
|
|
}
|
|
|
|
async function waitForHealth(endpoint, childLog, timeoutMs = 20_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastError = null;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(`${endpoint}/health`);
|
|
if (response.ok) return response.json();
|
|
lastError = new Error(`health returned ${response.status}`);
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
if (childLog.exited) break;
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
const detail = childLog.lines.slice(-30).join('\n');
|
|
throw new Error(
|
|
`MindSpace split-service smoke could not reach /health: ${lastError?.message ?? 'unknown'}${
|
|
detail ? `\n--- service log ---\n${detail}` : ''
|
|
}`,
|
|
);
|
|
}
|
|
|
|
async function stopChild(child) {
|
|
if (!child || child.exitCode != null) return;
|
|
const waitForExit = () =>
|
|
new Promise((resolve) => child.once('exit', resolve));
|
|
child.kill('SIGTERM');
|
|
await Promise.race([
|
|
waitForExit(),
|
|
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
|
]);
|
|
if (child.exitCode != null) return;
|
|
child.kill('SIGKILL');
|
|
await Promise.race([
|
|
waitForExit(),
|
|
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
|
]);
|
|
}
|
|
|
|
function pageHtml({ title = 'Split Smoke', cover = 'hero.png' } = {}) {
|
|
return [
|
|
'<!doctype html><html><head>',
|
|
`<title>${title}</title>`,
|
|
'<meta name="description" content="Split service smoke page">',
|
|
`<meta name="mindspace-cover" content='{"tag":"Smoke","accent":"#3366cc","accent2":"#112233","subtitle":"Split service"${cover ? `,"cover":"${cover}"` : ''}}'>`,
|
|
'</head><body><main>',
|
|
'Split service smoke page ',
|
|
'x'.repeat(600),
|
|
'</main></body></html>',
|
|
].join('');
|
|
}
|
|
|
|
async function resolveSmokeUserId() {
|
|
loadDotenvFile(process.env, path.join(root, '.env'));
|
|
loadDotenvFile(process.env, path.join(root, '.env.local'));
|
|
const db = await import('../db.mjs');
|
|
if (!db.isDatabaseConfigured()) {
|
|
throw new Error('split-service smoke requires DATABASE_URL or MYSQL_* configuration');
|
|
}
|
|
const pool = db.createDbPool();
|
|
try {
|
|
const supplied = String(
|
|
process.env.MINDSPACE_SPLIT_SMOKE_USER_ID ??
|
|
process.env.MINDSPACE_LOOPBACK_USER_ID ??
|
|
'',
|
|
).trim();
|
|
if (supplied) return { userId: supplied, pool };
|
|
const [rows] = await pool.query(
|
|
`SELECT id FROM h5_users WHERE status = 'active' ORDER BY created_at DESC LIMIT 1`,
|
|
);
|
|
const userId = String(rows?.[0]?.id ?? '').trim();
|
|
if (!userId) throw new Error('split-service smoke could not find an active local H5 user');
|
|
return { userId, pool };
|
|
} catch (error) {
|
|
await pool.end().catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function cleanupSmokeRows(pool, { userId, sessionId }) {
|
|
const packageId = `cp_${sessionId}`;
|
|
const now = Date.now();
|
|
await pool.query(`DELETE FROM h5_conversation_artifacts WHERE package_id = ?`, [packageId]).catch(() => {});
|
|
await pool.query(
|
|
`DELETE FROM h5_conversation_packages WHERE id = ? OR (user_id = ? AND session_id = ?)`,
|
|
[packageId, userId, sessionId],
|
|
).catch(() => {});
|
|
await pool.query(
|
|
`UPDATE h5_assets
|
|
SET status = 'deleted', deleted_at = ?, updated_at = ?
|
|
WHERE user_id = ? AND source_session_id = ?`,
|
|
[now, now, userId, sessionId],
|
|
).catch(() => {});
|
|
}
|
|
|
|
async function main() {
|
|
const skipBuild = process.argv.includes('--skip-build');
|
|
if (!skipBuild) runBuild();
|
|
|
|
const { userId, pool } = await resolveSmokeUserId();
|
|
const sessionId = `split-service-smoke-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
|
const packageId = `cp_${sessionId}`;
|
|
const runtimeDataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mindspace-split-service-'));
|
|
fs.mkdirSync(path.join(runtimeDataRoot, 'MindSpace', userId), { recursive: true });
|
|
const port = Number(process.env.MINDSPACE_SPLIT_SMOKE_PORT ?? '') || await freePort();
|
|
const endpoint = `http://127.0.0.1:${port}`;
|
|
const childLog = { lines: [], exited: false };
|
|
const child = spawn(process.execPath, ['server.mjs'], {
|
|
cwd: runtimeRoot,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: {
|
|
...process.env,
|
|
MINDSPACE_MEMIND_ROOT: path.join(runtimeRoot, 'memind-source'),
|
|
MINDSPACE_SERVICE_H5_ROOT: runtimeDataRoot,
|
|
MINDSPACE_STORAGE_ROOT: path.join(runtimeDataRoot, 'data', 'mindspace'),
|
|
H5_USERS_ROOT: path.join(runtimeDataRoot, 'users'),
|
|
MINDSPACE_REMOTE_AUTH_TOKEN: localToken,
|
|
MINDSPACE_SERVICE_PORT: String(port),
|
|
MINDSPACE_SERVICE_AGENT_WORKER_ENABLED: 'false',
|
|
MINDSPACE_SERVICE_WORKSPACE_MAINTENANCE: 'false',
|
|
},
|
|
});
|
|
const capture = (chunk) => {
|
|
for (const line of String(chunk).split(/\r?\n/).filter(Boolean)) {
|
|
childLog.lines.push(line);
|
|
if (childLog.lines.length > 200) childLog.lines.shift();
|
|
}
|
|
};
|
|
child.stdout.on('data', capture);
|
|
child.stderr.on('data', capture);
|
|
child.once('exit', () => {
|
|
childLog.exited = true;
|
|
});
|
|
|
|
try {
|
|
const health = await waitForHealth(endpoint, childLog);
|
|
assert.equal(health.ok, true);
|
|
assert.equal(health.service, 'mindspace-service');
|
|
|
|
const contractResponse = await fetch(`${endpoint}/mindspace/v1/contract`);
|
|
assert.equal(contractResponse.ok, true);
|
|
const contract = await contractResponse.json();
|
|
assert.ok(contract.bindings.publicFinishService.includes('prepareWechatHtmlDelivery'));
|
|
assert.ok(contract.bindings.publicFinishService.includes('ensureWechatFreshPageThumbnails'));
|
|
assert.ok(contract.bindings.workspaceToolService.includes('writeFile'));
|
|
|
|
const remote = createMindSpaceRemoteServerAdapter({
|
|
endpoint,
|
|
authToken: localToken,
|
|
timeoutMs: 20_000,
|
|
});
|
|
await remote.assertReady();
|
|
|
|
const workspaceRef = `mindspace://users/${userId}/workspace`;
|
|
const html = pageHtml();
|
|
const write = await remote.workspaceToolService.writeFile({
|
|
workspaceRef,
|
|
sessionId,
|
|
packageId,
|
|
path: 'public/split-service-smoke.html',
|
|
content: html,
|
|
messageId: 'message-workspace-write',
|
|
});
|
|
assert.equal(write.relativePath, 'public/split-service-smoke.html');
|
|
|
|
const read = await remote.workspaceToolService.readFile({
|
|
workspaceRef,
|
|
sessionId,
|
|
packageId,
|
|
path: 'public/split-service-smoke.html',
|
|
});
|
|
assert.match(read.content, /Split service smoke page/);
|
|
|
|
const delivery = await remote.publicFinishService.prepareWechatHtmlDelivery({
|
|
userId,
|
|
sessionId,
|
|
reply: {
|
|
text: '页面完成:https://wrong.example/MindSpace/user/public/split-service-smoke.html',
|
|
messages: [
|
|
{
|
|
id: 'message-wechat-write',
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'toolRequest',
|
|
toolCall: {
|
|
value: {
|
|
name: 'write_file',
|
|
arguments: {
|
|
path: 'public/split-service-smoke.html',
|
|
content: html,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
intent: {
|
|
agentText: '生成 public/split-service-smoke.html',
|
|
},
|
|
requestStartedAt: Date.now() - 1_000,
|
|
allowRecentArtifacts: false,
|
|
});
|
|
assert.equal(delivery.confirmedArtifacts.length, 1);
|
|
assert.equal(delivery.confirmedArtifacts[0].relativePath, 'public/split-service-smoke.html');
|
|
assert.equal(Object.hasOwn(delivery.confirmedArtifacts[0], 'localPath'), false);
|
|
|
|
const thumbnail = await remote.publicFinishService.ensureWechatFreshPageThumbnails({
|
|
userId,
|
|
sessionId,
|
|
artifacts: delivery.confirmedArtifacts,
|
|
images: [
|
|
{
|
|
jobId: 'split-service-smoke-image',
|
|
purpose: 'hero',
|
|
mimeType: 'image/png',
|
|
htmlSrc: 'hero.png',
|
|
},
|
|
],
|
|
});
|
|
assert.equal(thumbnail.ok, true);
|
|
assert.deepEqual(thumbnail.matchRelativePaths, ['public/split-service-smoke.html']);
|
|
assert.ok(thumbnail.thumbnailRelativePaths.includes('public/split-service-smoke.thumbnail.svg'));
|
|
assert.equal(JSON.stringify(thumbnail).includes(runtimeDataRoot), false);
|
|
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
endpoint,
|
|
userId,
|
|
sessionId,
|
|
checks: [
|
|
'health',
|
|
'contract',
|
|
'workspace-write-read',
|
|
'wechat-html-delivery',
|
|
'wechat-fresh-thumbnail',
|
|
],
|
|
}, null, 2));
|
|
} finally {
|
|
await cleanupSmokeRows(pool, { userId, sessionId }).catch(() => {});
|
|
await pool.end().catch(() => {});
|
|
await stopChild(child);
|
|
fs.rmSync(runtimeDataRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
process.exit(1);
|
|
});
|