#!/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 [ '', `${title}`, '', ``, '
', 'Split service smoke page ', 'x'.repeat(600), '
', ].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); });