Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d67ebb0c77 |
@@ -164,7 +164,7 @@ test('buildAutoChatSkillPrefix routes an uploaded xlsx only when Excel Analyst i
|
||||
|
||||
test('manifest enhanced search route is opt-in and uses the MindSearch skill prompt', () => {
|
||||
const routes = [{ skillName: 'search-enhanced', promptKey: 'search-enhanced', keywords: ['最新资料'], priority: 30 }];
|
||||
assert.match(buildAutoChatSkillPrefix('请搜索最新资料:Goose MCP', ['search-enhanced'], { skillRouterV2: true, manifestRoutes: routes }), /tkmind-search/);
|
||||
assert.match(buildAutoChatSkillPrefix('请搜索最新资料:Goose MCP', ['search-enhanced'], { skillRouterV2: true, manifestRoutes: routes }), /tkmind_search/);
|
||||
assert.equal(buildAutoChatSkillPrefix('请搜索最新资料:Goose MCP', [], { skillRouterV2: true, manifestRoutes: routes }), '');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import path from 'node:path';
|
||||
|
||||
import mysql from 'mysql2/promise';
|
||||
@@ -13,6 +14,37 @@ import { assertNonProductionTarget } from './safety.mjs';
|
||||
const { Client: PgClient } = pg;
|
||||
const SAFE_DB_NAME = /^[a-z][a-z0-9_]{5,62}$/;
|
||||
|
||||
const ISOLATED_GATE_REMOTE_ENV_KEYS = [
|
||||
'MINDSPACE_REMOTE_BASE_URL',
|
||||
'MINDSPACE_REMOTE_AUTH_TOKEN',
|
||||
'MINDSPACE_MCP_BASE_URL',
|
||||
'MINDSPACE_MCP_TOKEN_SECRET',
|
||||
];
|
||||
|
||||
/**
|
||||
* Release gate stacks must not inherit split-service MindSpace MCP routing from
|
||||
* the developer .env. Scoped MCP tokens would target the standalone 8082 service
|
||||
* and resolve workspace paths against the host H5 root instead of the isolated
|
||||
* gate sandbox, causing sandbox-fs write_file/publish_page ENOENT failures.
|
||||
*/
|
||||
export function sanitizeIsolatedGatePortalEnv(
|
||||
env,
|
||||
{ port, runtimeProfile = 'local' } = {},
|
||||
) {
|
||||
const sanitized = { ...env };
|
||||
for (const key of ISOLATED_GATE_REMOTE_ENV_KEYS) {
|
||||
delete sanitized[key];
|
||||
}
|
||||
sanitized.MEMIND_RUNTIME_PROFILE = runtimeProfile;
|
||||
sanitized.MINDSPACE_SERVER_ADAPTER = 'local';
|
||||
if (port != null) {
|
||||
const portalBase = `http://127.0.0.1:${port}`;
|
||||
sanitized.H5_PORTAL_BASE_URL = portalBase;
|
||||
sanitized.MINDSPACE_AGENT_API_BASE_URL = `${portalBase}/api`;
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function assertLoopbackHost(host, label) {
|
||||
const normalized = String(host ?? '').toLowerCase();
|
||||
if (!['localhost', '127.0.0.1', '::1', '/tmp'].includes(normalized)) {
|
||||
@@ -173,14 +205,44 @@ export async function selectBackendLlmProvider({
|
||||
}
|
||||
}
|
||||
|
||||
export function assertGatePortAvailable(port, host = '127.0.0.1') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', (error) => {
|
||||
if (error?.code === 'EADDRINUSE') {
|
||||
reject(new Error(
|
||||
`Release gate port ${host}:${port} is already in use; stop the stale local gate process before retrying`,
|
||||
));
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
server.once('listening', () => {
|
||||
server.close((closeError) => {
|
||||
if (closeError) reject(closeError);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
server.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPortal(baseUrl, child, timeoutMs = 60_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) throw new Error(`isolated Portal exited with code ${child.exitCode}`);
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/auth/status`, { signal: AbortSignal.timeout(1_000) });
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
if (response.ok) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`isolated Portal exited with code ${child.exitCode} after health check`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('isolated Portal exited')) {
|
||||
throw error;
|
||||
}
|
||||
// Startup can take several seconds while the isolated schema is initialized.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
@@ -188,6 +250,42 @@ async function waitForPortal(baseUrl, child, timeoutMs = 60_000) {
|
||||
throw new Error(`isolated Portal did not become ready: ${baseUrl}`);
|
||||
}
|
||||
|
||||
export async function grantGateUserSkillsByUsername({
|
||||
targetUrl,
|
||||
username,
|
||||
skillNames,
|
||||
}) {
|
||||
assertNonProductionTarget(targetUrl, 'isolated gate database');
|
||||
const normalizedUsername = String(username ?? '').trim();
|
||||
const skills = [...new Set(
|
||||
(Array.isArray(skillNames) ? skillNames : [])
|
||||
.map((name) => String(name ?? '').trim())
|
||||
.filter(Boolean),
|
||||
)];
|
||||
if (!normalizedUsername || skills.length === 0) return { userId: null, granted: [] };
|
||||
const target = await mysql.createConnection(targetUrl);
|
||||
try {
|
||||
const [rows] = await target.query(
|
||||
'SELECT id FROM h5_users WHERE username = ? LIMIT 1',
|
||||
[normalizedUsername],
|
||||
);
|
||||
const userId = rows[0]?.id ?? null;
|
||||
if (!userId) throw new Error(`Release gate user not found for skill grant: ${normalizedUsername}`);
|
||||
const now = Date.now();
|
||||
for (const skillName of skills) {
|
||||
await target.execute(
|
||||
`INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at)
|
||||
VALUES ('user', ?, ?, 1, ?)
|
||||
ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`,
|
||||
[userId, skillName, now],
|
||||
);
|
||||
}
|
||||
return { userId, granted: skills };
|
||||
} finally {
|
||||
await target.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHttpHealth(baseUrl, child, label, timeoutMs = 30_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
@@ -377,6 +475,7 @@ export async function createLocalGateStack({
|
||||
async function startPortal() {
|
||||
if (!childEnv || !baseUrl) throw new Error('isolated Portal environment is not initialized');
|
||||
if (child && child.exitCode === null) throw new Error('isolated Portal is already running');
|
||||
await assertGatePortAvailable(port);
|
||||
deepseekProxyLogFd = fs.openSync(deepseekProxyLogPath, 'a');
|
||||
deepseekProxyChild = spawn(
|
||||
process.execPath,
|
||||
@@ -408,10 +507,10 @@ export async function createLocalGateStack({
|
||||
try {
|
||||
mysqlUrl = await createMysqlDatabase(baseEnv.DATABASE_URL, mysqlDatabase);
|
||||
pgUrl = await createPgDatabase(baseEnv.MINDSPACE_USERDATA_PG_URL, pgDatabase);
|
||||
childEnv = sanitizeIsolatedGatePortalEnv(baseEnv, { port, runtimeProfile });
|
||||
childEnv = {
|
||||
...baseEnv,
|
||||
...childEnv,
|
||||
NODE_ENV: nodeEnv,
|
||||
...(runtimeProfile ? { MEMIND_RUNTIME_PROFILE: runtimeProfile } : {}),
|
||||
H5_HOST: '127.0.0.1',
|
||||
H5_PORT: String(port),
|
||||
H5_PUBLIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
assertGatePortAvailable,
|
||||
buildContainerPgConnectionString,
|
||||
buildPgConnectionString,
|
||||
makeIsolatedDatabaseName,
|
||||
sanitizeIsolatedGatePortalEnv,
|
||||
} from './local-stack.mjs';
|
||||
|
||||
test('isolated database names are bounded and identifier-safe', () => {
|
||||
@@ -41,3 +44,40 @@ test('container PostgreSQL DSN keeps credentials and host while isolating databa
|
||||
test('isolated database names reject empty or unsafe prefixes', () => {
|
||||
assert.throws(() => makeIsolatedDatabaseName('abc', '../bad'), /Unsafe/);
|
||||
});
|
||||
|
||||
test('sanitizeIsolatedGatePortalEnv drops split-service MCP routing', () => {
|
||||
const sanitized = sanitizeIsolatedGatePortalEnv({
|
||||
MINDSPACE_SERVER_ADAPTER: 'remote',
|
||||
MINDSPACE_REMOTE_BASE_URL: 'http://127.0.0.1:8082',
|
||||
MINDSPACE_REMOTE_AUTH_TOKEN: 'local-dev-secret',
|
||||
MINDSPACE_MCP_BASE_URL: 'http://127.0.0.1:8082',
|
||||
MINDSPACE_MCP_TOKEN_SECRET: 'local-dev-secret',
|
||||
MINDSPACE_AGENT_API_BASE_URL: 'http://127.0.0.1:8081/api',
|
||||
}, { port: 19087, runtimeProfile: 'local' });
|
||||
|
||||
assert.equal(sanitized.MINDSPACE_SERVER_ADAPTER, 'local');
|
||||
assert.equal(sanitized.MEMIND_RUNTIME_PROFILE, 'local');
|
||||
assert.equal(sanitized.H5_PORTAL_BASE_URL, 'http://127.0.0.1:19087');
|
||||
assert.equal(sanitized.MINDSPACE_AGENT_API_BASE_URL, 'http://127.0.0.1:19087/api');
|
||||
assert.equal(sanitized.MINDSPACE_REMOTE_BASE_URL, undefined);
|
||||
assert.equal(sanitized.MINDSPACE_MCP_BASE_URL, undefined);
|
||||
assert.equal(sanitized.MINDSPACE_MCP_TOKEN_SECRET, undefined);
|
||||
});
|
||||
|
||||
test('assertGatePortAvailable rejects occupied loopback ports', async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === 'object');
|
||||
await assert.rejects(
|
||||
() => assertGatePortAvailable(address.port),
|
||||
/already in use/,
|
||||
);
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await assertGatePortAvailable(address.port);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
||||
|
||||
import {
|
||||
createLocalGateStack,
|
||||
grantGateUserSkillsByUsername,
|
||||
selectBackendLlmProvider,
|
||||
seedSelectedProviderKeys,
|
||||
} from '../release-gate/local-stack.mjs';
|
||||
@@ -16,8 +17,12 @@ const scenarioIds = [
|
||||
|
||||
async function runScenario(scenarioId, port) {
|
||||
const childTimeoutMs = Math.max(
|
||||
30_000,
|
||||
Number(process.env.RELEASE_GATE_SCENARIO_PROCESS_TIMEOUT_MS ?? 360_000) || 360_000,
|
||||
60_000,
|
||||
Number(process.env.RELEASE_GATE_SCENARIO_PROCESS_TIMEOUT_MS ?? 900_000) || 900_000,
|
||||
);
|
||||
const stepTimeoutMs = Math.max(
|
||||
60_000,
|
||||
Number(process.env.RELEASE_GATE_SCENARIO_TIMEOUT_MS ?? 900_000) || 900_000,
|
||||
);
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
@@ -28,9 +33,7 @@ async function runScenario(scenarioId, port) {
|
||||
env: {
|
||||
...process.env,
|
||||
JOHN_PASSWORD: '888888',
|
||||
RELEASE_GATE_SCENARIO_TIMEOUT_MS: String(
|
||||
Number(process.env.RELEASE_GATE_SCENARIO_TIMEOUT_MS ?? 300_000) || 300_000,
|
||||
),
|
||||
RELEASE_GATE_SCENARIO_TIMEOUT_MS: String(stepTimeoutMs),
|
||||
},
|
||||
stdio: 'inherit',
|
||||
},
|
||||
@@ -77,10 +80,18 @@ async function register(username) {
|
||||
|
||||
try {
|
||||
await register('john2');
|
||||
const results = await Promise.all(scenarioIds.map(async (scenarioId) => ({
|
||||
scenarioId,
|
||||
code: await runScenario(scenarioId, new URL(stack.baseUrl).port),
|
||||
})));
|
||||
await grantGateUserSkillsByUsername({
|
||||
targetUrl: stack.mysqlUrl,
|
||||
username: 'john2',
|
||||
skillNames: ['static-page-publish'],
|
||||
});
|
||||
const results = [];
|
||||
for (const scenarioId of scenarioIds) {
|
||||
results.push({
|
||||
scenarioId,
|
||||
code: await runScenario(scenarioId, new URL(stack.baseUrl).port),
|
||||
});
|
||||
}
|
||||
for (const result of results) {
|
||||
console.log(`${result.code === 0 ? 'PASS' : 'FAIL'} ${result.scenarioId}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Direct goosed page smoke on TKMIND_API_TARGET (default https://127.0.0.1:18006).
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Agent, fetch } from 'undici';
|
||||
|
||||
import { buildChatSkillPrompt } from '../chat-skills.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(root, '.env'));
|
||||
|
||||
const secret = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
|
||||
const base = process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006';
|
||||
const workingDir = path.resolve(
|
||||
process.argv[2] ?? path.join(root, '.release-gate/local/goosed-direct-page'),
|
||||
);
|
||||
const targetHtml = process.argv[3] ?? 'public/suzhou-goosed-direct.html';
|
||||
const provider = process.argv[4] ?? process.env.GOOSED_PAGE_TEST_PROVIDER ?? 'custom_tkmind_relay_deepseek';
|
||||
const model = process.argv[5] ?? process.env.GOOSED_PAGE_TEST_MODEL ?? 'deepseek-chat';
|
||||
const timeoutMs = Number(process.env.GOOSED_PAGE_TEST_TIMEOUT_MS ?? 600_000);
|
||||
|
||||
const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
||||
|
||||
async function apiFetch(pathname, init = {}) {
|
||||
const headers = {
|
||||
...(init.headers ?? {}),
|
||||
'X-Secret-Key': secret,
|
||||
};
|
||||
if (init.body && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
return fetch(`${base}${pathname}`, {
|
||||
...init,
|
||||
headers,
|
||||
dispatcher,
|
||||
});
|
||||
}
|
||||
|
||||
async function apiJson(pathname, body) {
|
||||
const response = await apiFetch(pathname, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`${pathname} ${response.status}: ${text.slice(0, 800)}`);
|
||||
if (!text.trim()) return {};
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function messageVisibleText(message) {
|
||||
return (message?.content ?? [])
|
||||
.filter((item) => item?.type === 'text')
|
||||
.map((item) => String(item.text ?? ''))
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function executeSessionReply(sessionId, requestId, prompt) {
|
||||
const eventsResponse = await apiFetch(`/sessions/${sessionId}/events`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
});
|
||||
if (!eventsResponse.ok || !eventsResponse.body) {
|
||||
const text = await eventsResponse.text().catch(() => '');
|
||||
throw new Error(text || '无法建立 goosed 事件流');
|
||||
}
|
||||
|
||||
const replyResponse = await apiFetch(`/sessions/${sessionId}/reply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
request_id: requestId,
|
||||
user_message: {
|
||||
role: 'user',
|
||||
created: Date.now(),
|
||||
content: [{ type: 'text', text: prompt }],
|
||||
metadata: { userVisible: true, agentVisible: true, displayText: prompt },
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!replyResponse.ok) {
|
||||
const text = await replyResponse.text().catch(() => '');
|
||||
throw new Error(text || 'reply 失败');
|
||||
}
|
||||
replyResponse.body?.cancel?.();
|
||||
|
||||
const reader = Readable.fromWeb(eventsResponse.body);
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let messages = [];
|
||||
let finishSeen = false;
|
||||
let errorText = '';
|
||||
|
||||
const pushMessage = (list, message) => {
|
||||
const index = list.findIndex((item) => item.id === message.id);
|
||||
if (index >= 0) {
|
||||
const next = [...list];
|
||||
next[index] = message;
|
||||
return next;
|
||||
}
|
||||
return [...list, message];
|
||||
};
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for await (const chunk of reader) {
|
||||
if (Date.now() > deadline) throw new Error(`goosed 事件流超时 ${timeoutMs}ms`);
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const frames = buffer.split('\n\n');
|
||||
buffer = frames.pop() ?? '';
|
||||
for (const frame of frames) {
|
||||
let data = '';
|
||||
for (const line of frame.split('\n')) {
|
||||
if (line.startsWith('data:')) data += line.slice(5).trim();
|
||||
}
|
||||
if (!data) continue;
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const routingId = event.chat_request_id ?? event.request_id;
|
||||
if (routingId && routingId !== requestId) continue;
|
||||
|
||||
if (event.type === 'Message' && event.message?.metadata?.userVisible !== false) {
|
||||
messages = pushMessage(messages, event.message);
|
||||
} else if (event.type === 'UpdateConversation') {
|
||||
messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible !== false);
|
||||
} else if (event.type === 'Error') {
|
||||
errorText = String(event.error ?? event.message ?? 'goose 执行失败');
|
||||
throw new Error(errorText);
|
||||
} else if (event.type === 'Finish') {
|
||||
finishSeen = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (finishSeen) break;
|
||||
}
|
||||
|
||||
const assistantTexts = messages
|
||||
.filter((item) => item.role === 'assistant')
|
||||
.map((item) => messageVisibleText(item))
|
||||
.filter(Boolean);
|
||||
return {
|
||||
finishSeen,
|
||||
combined: assistantTexts.join('\n\n').trim(),
|
||||
toolCalls: messages.flatMap((item) => (item.content ?? [])
|
||||
.filter((part) => part?.type === 'toolRequest' || part?.type === 'toolResponse')
|
||||
.map((part) => ({
|
||||
role: item.role,
|
||||
type: part.type,
|
||||
name: part.toolCall?.value?.name ?? part.toolResponse?.value?.name ?? part.name ?? null,
|
||||
}))),
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
fs.mkdirSync(path.join(workingDir, 'public'), { recursive: true });
|
||||
console.log(`goosed base: ${base}`);
|
||||
console.log(`working_dir: ${workingDir}`);
|
||||
console.log(`target: ${targetHtml}`);
|
||||
console.log(`provider: ${provider} / ${model}`);
|
||||
|
||||
const start = await apiJson('/agent/start', { working_dir: workingDir });
|
||||
const sessionId = start.id;
|
||||
console.log(`session: ${sessionId}`);
|
||||
|
||||
await apiJson('/agent/update_provider', {
|
||||
session_id: sessionId,
|
||||
provider,
|
||||
model,
|
||||
});
|
||||
|
||||
const skillPrefix = buildChatSkillPrompt('generate-page', 'static-page-publish');
|
||||
const userText = `${skillPrefix}请帮我做一个全新的苏州一日游攻略页面,保存为 ${targetHtml},不要修改或复用已有页面,做完直接给我链接。`;
|
||||
const requestId = crypto.randomUUID();
|
||||
|
||||
const reply = await executeSessionReply(sessionId, requestId, userText);
|
||||
const htmlPath = path.join(workingDir, targetHtml);
|
||||
const htmlExists = fs.existsSync(htmlPath);
|
||||
const htmlSize = htmlExists ? fs.statSync(htmlPath).size : 0;
|
||||
|
||||
console.log('\n=== result ===');
|
||||
console.log(`finish_seen: ${reply.finishSeen}`);
|
||||
console.log(`html_exists: ${htmlExists}`);
|
||||
console.log(`html_bytes: ${htmlSize}`);
|
||||
console.log(`tool_calls: ${reply.toolCalls.length}`);
|
||||
for (const call of reply.toolCalls.slice(-12)) {
|
||||
console.log(` - ${call.role} ${call.type} ${call.name ?? ''}`);
|
||||
}
|
||||
if (reply.combined) {
|
||||
console.log('\n--- assistant excerpt ---');
|
||||
console.log(reply.combined.slice(-1500));
|
||||
}
|
||||
|
||||
if (!htmlExists || htmlSize <= 0) {
|
||||
console.error('\nFAIL: goosed did not materialize target HTML in working_dir');
|
||||
process.exit(1);
|
||||
}
|
||||
const html = fs.readFileSync(htmlPath, 'utf8');
|
||||
if (!/苏州/u.test(html)) {
|
||||
console.error('\nFAIL: generated HTML missing expected keyword 苏州');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nPASS: goosed wrote deliverable HTML on 18006');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user