437 lines
11 KiB
TypeScript
437 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import http from 'node:http';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { httpRequest } from '../server-node/src/testHttp.ts';
|
|
|
|
const scriptPath = fileURLToPath(import.meta.url);
|
|
const repoRoot = path.resolve(path.dirname(scriptPath), '..');
|
|
const bundledNodePath = path.join(
|
|
repoRoot,
|
|
'.tools',
|
|
'node-v22.22.2-win-x64',
|
|
process.platform === 'win32' ? 'node.exe' : 'bin/node',
|
|
);
|
|
const runtimeNodePath = fs.existsSync(bundledNodePath)
|
|
? bundledNodePath
|
|
: process.execPath;
|
|
const serverBuildPath = path.join(repoRoot, 'server-node', 'dist', 'server.js');
|
|
const webBuildPath = path.join(repoRoot, 'dist', 'index.html');
|
|
const publicRoot = path.join(repoRoot, 'public');
|
|
const proxyPort = 18080;
|
|
const nodePort = 18081;
|
|
const proxyBaseUrl = `http://127.0.0.1:${proxyPort}`;
|
|
const nodeBaseUrl = `http://127.0.0.1:${nodePort}`;
|
|
|
|
type ManagedChild = {
|
|
name: string;
|
|
process: ChildProcess;
|
|
};
|
|
|
|
function assertBuildArtifacts() {
|
|
if (!fs.existsSync(serverBuildPath)) {
|
|
throw new Error(
|
|
'server-node/dist/server.js 不存在,请先运行 npm run server-node:build',
|
|
);
|
|
}
|
|
|
|
if (!fs.existsSync(webBuildPath)) {
|
|
throw new Error('dist/index.html 不存在,请先运行 npm run build');
|
|
}
|
|
}
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
async function waitForReady(
|
|
label: string,
|
|
url: string,
|
|
validate: (bodyText: string, status: number) => void,
|
|
timeoutMs = 20000,
|
|
) {
|
|
const startedAt = Date.now();
|
|
let lastError: unknown = null;
|
|
|
|
while (Date.now() - startedAt < timeoutMs) {
|
|
try {
|
|
const response = await httpRequest(url);
|
|
const bodyText = await response.text();
|
|
validate(bodyText, response.status);
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
await sleep(250);
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
`[smoke:proxy] ${label} 未在 ${timeoutMs}ms 内就绪: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
|
);
|
|
}
|
|
|
|
function spawnManagedChild(
|
|
name: string,
|
|
command: string,
|
|
args: string[],
|
|
env: NodeJS.ProcessEnv,
|
|
): ManagedChild {
|
|
const child = spawn(command, args, {
|
|
cwd: repoRoot,
|
|
env,
|
|
stdio: 'inherit',
|
|
shell: false,
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
console.error(`[smoke:proxy] ${name} 启动失败`, error);
|
|
});
|
|
|
|
return {
|
|
name,
|
|
process: child,
|
|
};
|
|
}
|
|
|
|
async function stopChild(child: ManagedChild | null) {
|
|
if (!child || child.process.exitCode !== null) {
|
|
return;
|
|
}
|
|
|
|
child.process.kill('SIGTERM');
|
|
|
|
await Promise.race([
|
|
new Promise<void>((resolve) => {
|
|
child.process.once('exit', () => resolve());
|
|
}),
|
|
sleep(2000),
|
|
]);
|
|
|
|
if (child.process.exitCode === null) {
|
|
child.process.kill('SIGKILL');
|
|
await new Promise<void>((resolve) => {
|
|
child.process.once('exit', () => resolve());
|
|
});
|
|
}
|
|
}
|
|
|
|
function contentTypeFor(filePath: string) {
|
|
if (filePath.endsWith('.html')) {
|
|
return 'text/html; charset=utf-8';
|
|
}
|
|
if (filePath.endsWith('.js')) {
|
|
return 'text/javascript; charset=utf-8';
|
|
}
|
|
if (filePath.endsWith('.css')) {
|
|
return 'text/css; charset=utf-8';
|
|
}
|
|
if (filePath.endsWith('.json')) {
|
|
return 'application/json; charset=utf-8';
|
|
}
|
|
if (filePath.endsWith('.png')) {
|
|
return 'image/png';
|
|
}
|
|
if (filePath.endsWith('.jpg') || filePath.endsWith('.jpeg')) {
|
|
return 'image/jpeg';
|
|
}
|
|
if (filePath.endsWith('.webp')) {
|
|
return 'image/webp';
|
|
}
|
|
if (filePath.endsWith('.svg')) {
|
|
return 'image/svg+xml; charset=utf-8';
|
|
}
|
|
|
|
return 'application/octet-stream';
|
|
}
|
|
|
|
function resolveStaticFile(urlPath: string) {
|
|
const cleanPath = decodeURIComponent(urlPath.split('?')[0] || '/');
|
|
const normalizedPath = cleanPath === '/' ? '/index.html' : cleanPath;
|
|
const trimmedRelativePath = normalizedPath.replace(/^\/+/u, '');
|
|
const distRoot = path.resolve(repoRoot, 'dist');
|
|
const publicCandidatePath = path.resolve(publicRoot, trimmedRelativePath);
|
|
const distCandidatePath = path.resolve(distRoot, trimmedRelativePath);
|
|
|
|
if (
|
|
publicCandidatePath.startsWith(publicRoot) &&
|
|
fs.existsSync(publicCandidatePath) &&
|
|
fs.statSync(publicCandidatePath).isFile()
|
|
) {
|
|
return publicCandidatePath;
|
|
}
|
|
|
|
if (
|
|
distCandidatePath.startsWith(distRoot) &&
|
|
fs.existsSync(distCandidatePath) &&
|
|
fs.statSync(distCandidatePath).isFile()
|
|
) {
|
|
return distCandidatePath;
|
|
}
|
|
|
|
return webBuildPath;
|
|
}
|
|
|
|
async function startSameOriginProxy() {
|
|
const server = http.createServer((request, response) => {
|
|
const requestUrl = request.url || '/';
|
|
|
|
if (requestUrl === '/healthz') {
|
|
response.writeHead(200, {
|
|
'Content-Type': 'text/plain; charset=utf-8',
|
|
});
|
|
response.end('ok');
|
|
return;
|
|
}
|
|
|
|
if (requestUrl.startsWith('/api/')) {
|
|
const upstream = http.request(
|
|
{
|
|
hostname: '127.0.0.1',
|
|
port: nodePort,
|
|
path: requestUrl,
|
|
method: request.method,
|
|
headers: {
|
|
...request.headers,
|
|
host: `127.0.0.1:${nodePort}`,
|
|
},
|
|
},
|
|
(upstreamResponse) => {
|
|
response.writeHead(
|
|
upstreamResponse.statusCode ?? 502,
|
|
upstreamResponse.headers,
|
|
);
|
|
upstreamResponse.pipe(response);
|
|
},
|
|
);
|
|
|
|
upstream.on('error', (error) => {
|
|
response.writeHead(502, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
});
|
|
response.end(
|
|
JSON.stringify({
|
|
error: {
|
|
message:
|
|
error instanceof Error ? error.message : 'proxy upstream failed',
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
request.pipe(upstream);
|
|
return;
|
|
}
|
|
|
|
const filePath = resolveStaticFile(requestUrl);
|
|
response.writeHead(200, {
|
|
'Content-Type': contentTypeFor(filePath),
|
|
});
|
|
fs.createReadStream(filePath).pipe(response);
|
|
});
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(proxyPort, '127.0.0.1', () => resolve());
|
|
});
|
|
|
|
return server;
|
|
}
|
|
|
|
async function stopProxyServer(server: http.Server | null) {
|
|
if (!server) {
|
|
return;
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.close((error) => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function authEntry(baseUrl: string) {
|
|
const requestId = 'proxy-smoke-auth-entry';
|
|
const username = `proxy_${Date.now().toString(36)}`;
|
|
const response = await httpRequest(`${baseUrl}/api/auth/entry`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Request-Id': requestId,
|
|
},
|
|
body: JSON.stringify({
|
|
username,
|
|
password: 'proxy-secret-123',
|
|
}),
|
|
});
|
|
const payload = (await response.json()) as {
|
|
token: string;
|
|
user: {
|
|
id: string;
|
|
username: string;
|
|
};
|
|
};
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get('x-request-id'), requestId);
|
|
assert.equal(payload.user.username, username);
|
|
assert.ok(payload.token);
|
|
|
|
return payload;
|
|
}
|
|
|
|
async function main() {
|
|
assertBuildArtifacts();
|
|
|
|
let serverChild: ManagedChild | null = null;
|
|
let proxyServer: http.Server | null = null;
|
|
|
|
try {
|
|
console.log('[smoke:proxy] starting built node server');
|
|
serverChild = spawnManagedChild(
|
|
'server-node',
|
|
runtimeNodePath,
|
|
[serverBuildPath],
|
|
{
|
|
...process.env,
|
|
PROJECT_ROOT: repoRoot,
|
|
NODE_ENV: 'test',
|
|
NODE_SERVER_ADDR: `:${nodePort}`,
|
|
DATABASE_URL: 'pg-mem://genarrative-proxy-smoke',
|
|
LOG_LEVEL: 'silent',
|
|
JWT_SECRET: 'proxy-smoke-secret',
|
|
JWT_ISSUER: 'genarrative-proxy-smoke',
|
|
LLM_API_KEY: '',
|
|
DASHSCOPE_API_KEY: '',
|
|
},
|
|
);
|
|
|
|
await waitForReady(
|
|
'node server',
|
|
`${nodeBaseUrl}/healthz`,
|
|
(bodyText, status) => {
|
|
assert.equal(status, 200);
|
|
const payload = JSON.parse(bodyText) as {
|
|
ok: boolean;
|
|
service: string;
|
|
};
|
|
assert.equal(payload.ok, true);
|
|
assert.equal(payload.service, 'genarrative-node-server');
|
|
},
|
|
);
|
|
console.log('[smoke:proxy] node server ready');
|
|
|
|
console.log('[smoke:proxy] starting same-origin reverse proxy harness');
|
|
proxyServer = await startSameOriginProxy();
|
|
|
|
await waitForReady(
|
|
'reverse proxy',
|
|
`${proxyBaseUrl}/healthz`,
|
|
(bodyText, status) => {
|
|
assert.equal(status, 200);
|
|
assert.equal(bodyText.trim(), 'ok');
|
|
},
|
|
);
|
|
console.log('[smoke:proxy] reverse proxy ready');
|
|
|
|
const homeResponse = await httpRequest(`${proxyBaseUrl}/`);
|
|
const homeHtml = await homeResponse.text();
|
|
assert.equal(homeResponse.status, 200);
|
|
assert.match(homeHtml, /<div id="root"><\/div>/u);
|
|
console.log('[smoke:proxy] static web entry ok');
|
|
|
|
const entry = await authEntry(proxyBaseUrl);
|
|
console.log('[smoke:proxy] proxied auth entry ok');
|
|
|
|
const meResponse = await httpRequest(`${proxyBaseUrl}/api/auth/me`, {
|
|
headers: {
|
|
Authorization: `Bearer ${entry.token}`,
|
|
},
|
|
});
|
|
const mePayload = (await meResponse.json()) as {
|
|
user: {
|
|
username: string;
|
|
};
|
|
};
|
|
assert.equal(meResponse.status, 200);
|
|
assert.equal(mePayload.user.username, entry.user.username);
|
|
console.log('[smoke:proxy] proxied auth me ok');
|
|
|
|
const saveResponse = await httpRequest(
|
|
`${proxyBaseUrl}/api/runtime/save/snapshot`,
|
|
{
|
|
method: 'PUT',
|
|
headers: {
|
|
Authorization: `Bearer ${entry.token}`,
|
|
'Content-Type': 'application/json',
|
|
'X-Genarrative-Response-Envelope': 'v1',
|
|
},
|
|
body: JSON.stringify({
|
|
gameState: {
|
|
worldType: 'WUXIA',
|
|
chapter: 2,
|
|
},
|
|
bottomTab: 'adventure',
|
|
currentStory: {
|
|
text: 'proxy smoke story',
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
const savePayload = (await saveResponse.json()) as {
|
|
ok: true;
|
|
data: {
|
|
gameState: {
|
|
chapter: number;
|
|
};
|
|
};
|
|
meta: {
|
|
requestId: string;
|
|
operation: string;
|
|
};
|
|
};
|
|
assert.equal(saveResponse.status, 200);
|
|
assert.equal(savePayload.ok, true);
|
|
assert.equal(savePayload.data.gameState.chapter, 2);
|
|
assert.equal(savePayload.meta.operation, 'PUT /api/runtime/save/snapshot');
|
|
assert.ok(savePayload.meta.requestId);
|
|
console.log('[smoke:proxy] proxied runtime save ok');
|
|
|
|
const getResponse = await httpRequest(
|
|
`${proxyBaseUrl}/api/runtime/save/snapshot`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${entry.token}`,
|
|
},
|
|
},
|
|
);
|
|
const getPayload = (await getResponse.json()) as {
|
|
gameState: {
|
|
chapter: number;
|
|
};
|
|
bottomTab: string;
|
|
};
|
|
assert.equal(getResponse.status, 200);
|
|
assert.equal(getPayload.gameState.chapter, 2);
|
|
assert.equal(getPayload.bottomTab, 'adventure');
|
|
console.log('[smoke:proxy] proxied runtime snapshot read ok');
|
|
|
|
console.log('[smoke:proxy] all checks passed');
|
|
} finally {
|
|
await stopProxyServer(proxyServer);
|
|
await stopChild(serverChild);
|
|
}
|
|
}
|
|
|
|
void main().catch((error) => {
|
|
console.error('[smoke:proxy] failed');
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|