#!/usr/bin/env node
/**
* john 账号水果主题 E2E(仅本地):工作区 HTML → 预览图 → 保存到空间 → 可点击链接
* 用法:node scripts/fruit-theme-john-e2e.mjs
* 环境:自动启动本地 server(MINDSPACE_E2E_PORT);勿对生产 URL 跑本脚本
*/
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadH5Environment } from './load-env.mjs';
import { createDbPool } from '../db.mjs';
import { createUserAuth } from '../user-auth.mjs';
import { buildPublicUrl } from '../user-publish.mjs';
import {
ensureWorkspaceHtmlThumbnail,
workspaceThumbnailRelativePath,
} from '../mindspace-workspace-thumbnails.mjs';
import { isModernFeedThumbnail } from '../mindspace-thumbnails.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
loadH5Environment(import.meta.dirname);
if (/go\.tkmind\.cn|goo\.tkmind\.cn|120\.26\.184\.105/i.test(process.env.BASE_URL ?? '')) {
throw new Error('本脚本仅用于本地验证,请勿设置生产 BASE_URL');
}
const portalPort = Number(process.env.MINDSPACE_E2E_PORT ?? 18140);
const BASE_URL = `http://127.0.0.1:${portalPort}`;
const HTML_NAME = 'fruit-theme-test.html';
const USERNAME = 'john';
const JOHN_PASSWORD = process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '981122tj';
const thumbRel = workspaceThumbnailRelativePath(HTML_NAME);
const thumbPngRel = thumbRel.replace(/\.svg$/i, '.png');
let publishDir = null;
let htmlPath = null;
let publicPageUrl = null;
let publicThumbUrl = null;
const fixtureHtml = `
夏日鲜果指南
夏日鲜果指南
鲜果美食主题测试页面。
`;
let createdFixture = false;
const portal = spawn(process.execPath, ['server.mjs'], {
cwd: root,
env: {
...process.env,
H5_PORT: String(portalPort),
H5_PUBLIC_BASE_URL: BASE_URL,
TKMIND_API_TARGET: 'http://127.0.0.1:9',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let portalLogs = '';
portal.stdout.on('data', (chunk) => {
portalLogs += chunk.toString();
});
portal.stderr.on('data', (chunk) => {
portalLogs += chunk.toString();
});
async function waitForPortal() {
for (let attempt = 0; attempt < 80; attempt += 1) {
if (portal.exitCode != null) throw new Error(`本地 server 提前退出\n${portalLogs}`);
try {
const response = await fetch(`${BASE_URL}/auth/status`);
if (response.ok) return;
} catch {
// still starting
}
await new Promise((resolve) => setTimeout(resolve, 150));
}
throw new Error(`本地 server 启动超时\n${portalLogs}`);
}
function cookieHeader(setCookie) {
if (!setCookie) return '';
const parts = Array.isArray(setCookie) ? setCookie : [setCookie];
return parts.map((c) => c.split(';')[0]).join('; ');
}
async function request(pathname, { method = 'GET', body, cookie } = {}) {
const headers = {};
if (body) headers['Content-Type'] = 'application/json';
if (cookie) headers.Cookie = cookie;
const response = await fetch(`${BASE_URL}${pathname}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const type = response.headers.get('content-type') ?? '';
const payload = type.includes('json') ? await response.json() : await response.text();
return { response, payload, setCookie: response.headers.getSetCookie?.() ?? response.headers.get('set-cookie') };
}
async function ensureJohnPassword() {
const pool = createDbPool();
const auth = createUserAuth(pool, { h5Root: root });
let login = await auth.login({ username: USERNAME, password: JOHN_PASSWORD, ip: '127.0.0.1' });
if (!login.ok) {
const [rows] = await pool.query(`SELECT id, email FROM h5_users WHERE username = ? LIMIT 1`, [
USERNAME,
]);
const user = rows[0];
if (!user) throw new Error('本地数据库无 john 用户,请先注册或导入数据');
const email = user.email ?? `${USERNAME}@local.test`;
if (!user.email) {
await pool.query(`UPDATE h5_users SET email = ? WHERE id = ?`, [email, user.id]);
}
const reset = await auth.resetPassword({ username: USERNAME, email, password: JOHN_PASSWORD });
assert.equal(reset.ok, true, reset.message ?? '重置 john 本地密码失败');
login = await auth.login({ username: USERNAME, password: JOHN_PASSWORD, ip: '127.0.0.1' });
}
assert.equal(login.ok, true, login.error ?? 'john 本地登录失败');
const { response, setCookie } = await request('/auth/login', {
method: 'POST',
body: { username: USERNAME, password: JOHN_PASSWORD },
});
assert.equal(response.status, 200, 'john HTTP 登录应成功');
await pool.end();
return { cookie: cookieHeader(setCookie), userId: login.user.id };
}
async function main() {
console.log('=== john 水果主题 E2E(本地) ===');
await waitForPortal();
console.log(`BASE_URL: ${BASE_URL}`);
const { cookie, userId } = await ensureJohnPassword();
console.log('✓ john 本地登录成功');
publishDir = path.join(root, 'MindSpace', userId, 'public');
htmlPath = path.join(publishDir, HTML_NAME);
publicPageUrl = buildPublicUrl(BASE_URL, userId, `public/${HTML_NAME}`);
publicThumbUrl = buildPublicUrl(BASE_URL, userId, `public/${thumbPngRel}`);
try {
await fs.access(htmlPath);
} catch {
await fs.mkdir(publishDir, { recursive: true });
await fs.writeFile(htmlPath, fixtureHtml, 'utf8');
createdFixture = true;
}
const html = await fs.readFile(htmlPath, 'utf8');
assert.match(html, /mindspace-cover/, 'HTML 应包含 mindspace-cover');
const svg = await ensureWorkspaceHtmlThumbnail(publishDir, HTML_NAME, html, {
title: '夏日鲜果指南',
subtitle: '水果主题测试',
});
assert.equal(isModernFeedThumbnail(svg), true, '工作区预览图应为新版 3:4');
assert.match(svg, /美食|鲜果|FRUIT/i, '预览图应体现水果/美食主题');
const hasPhoto = /preserveAspectRatio="xMidYMid slice"/.test(svg);
console.log(`✓ 工作区预览图已生成: ${thumbRel}(含主图: ${hasPhoto ? '是' : '否'})`);
const pageCheck = await request(`/MindSpace/${USERNAME}/${HTML_NAME}`);
assert.equal(pageCheck.response.status, 200, `公网 HTML 应可访问: ${publicPageUrl}`);
console.log(`✓ 公网 HTML 200: ${publicPageUrl}`);
const thumbCheck = await request(`/MindSpace/${userId}/public/${thumbPngRel}`);
assert.equal(thumbCheck.response.status, 200, `公网预览图应可访问: ${publicThumbUrl}`);
console.log(`✓ 公网预览图 200: ${publicThumbUrl}`);
const agentReplyLink = `[夏日鲜果指南 · 水果主题测试](${publicPageUrl})`;
assert.match(agentReplyLink, /^\[.+\]\(https?:\/\/.+\)$/);
console.log(`✓ Agent 应回复的可点击链接:\n ${agentReplyLink}`);
const { response: spaceRes, payload: spacePayload } = await request('/api/mindspace/v1/space', {
cookie,
});
assert.equal(spaceRes.status, 200, JSON.stringify(spacePayload));
const saveBody = {
title: '夏日鲜果指南 · 水果主题测试',
summary: 'TKMind 水果主题预览图 E2E',
content: html,
content_format: 'html',
page_type: 'html',
category_code: 'draft',
};
const created = await request('/api/mindspace/v1/pages', {
method: 'POST',
body: saveBody,
cookie,
});
assert.equal(created.response.status, 201, JSON.stringify(created.payload));
const pageId = created.payload?.data?.id ?? created.payload?.data?.page?.id;
assert.ok(pageId, '应创建页面记录');
await new Promise((r) => setTimeout(r, 800));
const thumbApi = await request(`/api/mindspace/v1/pages/${pageId}/thumbnail`, { cookie });
assert.equal(thumbApi.response.status, 200, '页面缩略图 API 应返回 200');
assert.match(String(thumbApi.payload), /width="540" height="720"/);
console.log(`✓ 我的空间缩略图 API 200: /api/mindspace/v1/pages/${pageId}/thumbnail`);
console.log('\n=== 全部通过 ===');
}
try {
await main();
} finally {
portal.kill('SIGTERM');
if (createdFixture && htmlPath && publishDir) {
await fs.rm(htmlPath, { force: true });
await fs.rm(path.join(publishDir, thumbRel), { force: true });
}
}