This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -1,14 +1,26 @@
import net from 'node:net';
import path from 'node:path';
import {spawn} from 'node:child_process';
import {existsSync, readFileSync} from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const repoRoot = fileURLToPath(new URL('../', import.meta.url));
const serverRoot = fileURLToPath(new URL('../server-node/', import.meta.url));
const viteCliPath = fileURLToPath(new URL('./vite-cli.mjs', import.meta.url));
const serverTsxCliPath = fileURLToPath(
new URL('../server-node/node_modules/tsx/dist/cli.mjs', import.meta.url),
);
const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url));
const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url));
const bundledNodePath = fileURLToPath(
new URL('../.tools/node-v22.22.2-win-x64/node.exe', import.meta.url),
);
const bundledNpmCliPath = fileURLToPath(
new URL('../.tools/node-v22.22.2-win-x64/node_modules/npm/bin/npm-cli.js', import.meta.url),
);
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const DEFAULT_DEV_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:5432/genarrative';
const DEV_MEMORY_DATABASE_URL = 'pg-mem://genarrative-dev';
function parseEnvContents(contents) {
return contents
@@ -47,6 +59,44 @@ function readEnvFile(filePath) {
return parseEnvContents(readFileSync(filePath, 'utf8'));
}
function resolveDatabaseProbeTarget(databaseUrl) {
const trimmed = databaseUrl.trim();
if (!trimmed || !/^postgres(?:ql)?:\/\//u.test(trimmed)) {
return null;
}
try {
const url = new URL(trimmed);
return {
host: url.hostname === '0.0.0.0' ? '127.0.0.1' : url.hostname,
port: Number(url.port || 5432),
};
} catch {
return null;
}
}
function checkTcpReachable(target, timeoutMs = 1500) {
return new Promise((resolve) => {
const socket = net.createConnection(target);
let settled = false;
const finish = (result) => {
if (settled) {
return;
}
settled = true;
socket.destroy();
resolve(result);
};
socket.setTimeout(timeoutMs);
socket.once('connect', () => finish(true));
socket.once('timeout', () => finish(false));
socket.once('error', () => finish(false));
});
}
function resolveServerTarget(serverAddr) {
const trimmed = serverAddr.trim();
@@ -77,23 +127,104 @@ function resolveServerTarget(serverAddr) {
return `http://${trimmed}`;
}
function redactDatabaseUrl(databaseUrl) {
const trimmed = `${databaseUrl || ''}`.trim();
if (!trimmed) {
return '[missing]';
}
if (trimmed.startsWith('pg-mem://')) {
return trimmed;
}
try {
const url = new URL(trimmed);
const databaseName = url.pathname.replace(/^\/+/u, '') || 'postgres';
const portSuffix = url.port ? `:${url.port}` : '';
return `${url.protocol}//${url.hostname}${portSuffix}/${databaseName}`;
} catch {
return '[configured]';
}
}
function resolvePathEnvKey(envMap) {
return Object.keys(envMap).find((key) => key.toLowerCase() === 'path') || 'PATH';
}
function prependEnvPath(envMap, nextEntry) {
const pathKey = resolvePathEnvKey(envMap);
const currentValue = envMap[pathKey] || '';
const normalizedEntry = path.resolve(nextEntry);
const segments = currentValue
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
.filter((entry) => {
try {
return path.resolve(entry) !== normalizedEntry;
} catch {
return entry !== nextEntry;
}
});
envMap[pathKey] = [nextEntry, ...segments].join(path.delimiter);
}
const exampleEnv = readEnvFile(envExamplePath);
const localEnv = readEnvFile(envLocalPath);
const mergedEnv = {
...readEnvFile(envExamplePath),
...readEnvFile(envLocalPath),
...exampleEnv,
...localEnv,
...process.env,
};
const runtimeNodePath = existsSync(bundledNodePath)
? bundledNodePath
: process.execPath;
const runtimeNpmCliPath = existsSync(bundledNpmCliPath)
? bundledNpmCliPath
: '';
const runtimeNodeDir = path.dirname(runtimeNodePath);
mergedEnv.PROJECT_ROOT = mergedEnv.PROJECT_ROOT || repoRoot;
mergedEnv.NODE_SERVER_ADDR = mergedEnv.NODE_SERVER_ADDR || ':8081';
mergedEnv.NODE_SERVER_TARGET =
mergedEnv.NODE_SERVER_TARGET || resolveServerTarget(mergedEnv.NODE_SERVER_ADDR);
mergedEnv.SQLITE_PATH =
mergedEnv.SQLITE_PATH || path.join(repoRoot, 'server-node', 'data', 'genarrative.sqlite');
mergedEnv.DATABASE_URL =
mergedEnv.DATABASE_URL || DEFAULT_DEV_DATABASE_URL;
prependEnvPath(mergedEnv, runtimeNodeDir);
mergedEnv.npm_config_scripts_prepend_node_path = 'true';
const exampleDatabaseUrl = `${exampleEnv.DATABASE_URL || ''}`.trim();
const localDatabaseUrl = `${localEnv.DATABASE_URL || ''}`.trim();
const processDatabaseUrl = `${process.env.DATABASE_URL || ''}`.trim();
const hasExplicitDatabaseUrl =
Boolean(processDatabaseUrl) ||
(Boolean(localDatabaseUrl) && localDatabaseUrl !== exampleDatabaseUrl);
if (!hasExplicitDatabaseUrl) {
const databaseProbeTarget = resolveDatabaseProbeTarget(mergedEnv.DATABASE_URL);
if (databaseProbeTarget) {
const isReachable = await checkTcpReachable(databaseProbeTarget);
if (!isReachable) {
console.warn(
`[dev:node] PostgreSQL unavailable at ${databaseProbeTarget.host}:${databaseProbeTarget.port}; falling back to ${DEV_MEMORY_DATABASE_URL} for local dev.`,
);
console.warn(
'[dev:node] Current session will use in-memory persistence only. Set DATABASE_URL in .env.local to restore PostgreSQL-backed runtime data.',
);
mergedEnv.DATABASE_URL = DEV_MEMORY_DATABASE_URL;
}
}
}
console.log(`[dev:node] PROJECT_ROOT=${mergedEnv.PROJECT_ROOT}`);
console.log(`[dev:node] NODE_SERVER_ADDR=${mergedEnv.NODE_SERVER_ADDR}`);
console.log(`[dev:node] NODE_SERVER_TARGET=${mergedEnv.NODE_SERVER_TARGET}`);
console.log(`[dev:node] SQLITE_PATH=${mergedEnv.SQLITE_PATH}`);
console.log(`[dev:node] DATABASE_URL=${redactDatabaseUrl(mergedEnv.DATABASE_URL)}`);
console.log(`[dev:node] NODE_RUNTIME=${runtimeNodePath}`);
const children = new Set();
let shuttingDown = false;
@@ -167,15 +298,27 @@ function registerChild(name, child, siblingProvider) {
});
}
const serverProcess = spawn(npmCommand, ['run', 'dev'], {
cwd: serverRoot,
env: mergedEnv,
shell: process.platform === 'win32',
stdio: 'inherit',
});
const serverProcess = existsSync(serverTsxCliPath)
? spawn(runtimeNodePath, [serverTsxCliPath, 'watch', 'src/server.ts'], {
cwd: serverRoot,
env: mergedEnv,
stdio: 'inherit',
})
: runtimeNpmCliPath
? spawn(runtimeNodePath, [runtimeNpmCliPath, 'run', 'dev'], {
cwd: serverRoot,
env: mergedEnv,
stdio: 'inherit',
})
: spawn(npmCommand, ['run', 'dev'], {
cwd: serverRoot,
env: mergedEnv,
shell: process.platform === 'win32',
stdio: 'inherit',
});
const viteProcess = spawn(
process.execPath,
runtimeNodePath,
[viteCliPath, '--port=3000', '--host=0.0.0.0'],
{
cwd: repoRoot,

View File

@@ -0,0 +1,11 @@
# 旧 Vite 本地 API 插件
`scripts/dev-server/**` 已不再是当前开发入口。
当前编辑器与资产接口已经迁移到:
- `server-node/src/modules/editor/**`
- `server-node/src/modules/assets/**`
- `src/editor/shared/editorApiClient.ts`
这些文件仅保留为迁移参考,不要在这里继续新增 `/api/*` 编辑器写盘或资产生成接口。

View File

@@ -0,0 +1,414 @@
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 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';
}
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 candidatePath = path.resolve(repoRoot, 'dist', trimmedRelativePath);
const distRoot = path.resolve(repoRoot, 'dist');
if (
candidatePath.startsWith(distRoot) &&
fs.existsSync(candidatePath) &&
fs.statSync(candidatePath).isFile()
) {
return candidatePath;
}
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);
});

View File

@@ -0,0 +1,287 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import type { AddressInfo } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { createApp } from '../server-node/src/app.ts';
import type { AppConfig } from '../server-node/src/config.ts';
import { createAppContext } from '../server-node/src/server.ts';
import { httpRequest, type TestRequestInit } from '../server-node/src/testHttp.ts';
function createSmokeConfig(): AppConfig {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-server-node-smoke-'),
);
return {
nodeEnv: 'test',
projectRoot: tempRoot,
publicDir: path.join(tempRoot, 'public'),
logsDir: path.join(tempRoot, 'logs'),
dataDir: path.join(tempRoot, 'data'),
rawEnv: {},
databaseUrl: 'pg-mem://genarrative-smoke',
serverAddr: ':0',
logLevel: 'silent',
editorApiEnabled: true,
assetsApiEnabled: true,
jwtSecret: 'test-secret',
jwtExpiresIn: '7d',
jwtIssuer: 'genarrative-server-node-smoke',
llm: {
baseUrl: 'https://example.invalid',
apiKey: '',
model: 'test-model',
},
dashScope: {
baseUrl: 'https://example.invalid',
apiKey: '',
imageModel: 'test-image-model',
requestTimeoutMs: 1000,
},
};
}
async function withSmokeServer<T>(
run: (options: { baseUrl: string }) => Promise<T>,
) {
const context = await createAppContext(createSmokeConfig());
const app = createApp(context);
const server = await new Promise<import('node:http').Server>((resolve) => {
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
});
try {
const address = server.address() as AddressInfo;
return await run({
baseUrl: `http://127.0.0.1:${address.port}`,
});
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
await context.db.close();
}
}
function withBearer(token: string, init: TestRequestInit = {}) {
return {
...init,
headers: {
...(init.headers ?? {}),
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
} satisfies TestRequestInit;
}
async function authEntry(baseUrl: string) {
const username = `smoke_${Date.now().toString(36)}`;
const response = await httpRequest(`${baseUrl}/api/auth/entry`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password: 'smoke-secret-123',
}),
});
const payload = (await response.json()) as {
token: string;
user: {
id: string;
username: string;
};
};
assert.equal(response.status, 200);
assert.ok(payload.token);
assert.equal(payload.user.username, username);
return payload;
}
async function main() {
console.log('[server-node:smoke] booting ephemeral Express server');
await withSmokeServer(async ({ baseUrl }) => {
const healthzRequestId = 'smoke-healthz-request';
const healthzResponse = await httpRequest(`${baseUrl}/healthz`, {
headers: {
'X-Request-Id': healthzRequestId,
},
});
const healthzPayload = (await healthzResponse.json()) as {
ok: boolean;
service: string;
};
assert.equal(healthzResponse.status, 200);
assert.equal(healthzResponse.headers.get('x-request-id'), healthzRequestId);
assert.equal(healthzPayload.ok, true);
assert.equal(healthzPayload.service, 'genarrative-node-server');
console.log('[server-node:smoke] healthz ok');
const entry = await authEntry(baseUrl);
console.log('[server-node:smoke] auth entry ok');
const meResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
headers: {
Authorization: `Bearer ${entry.token}`,
},
});
const mePayload = (await meResponse.json()) as {
user: {
id: string;
username: string;
};
};
assert.equal(meResponse.status, 200);
assert.equal(mePayload.user.username, entry.user.username);
console.log('[server-node:smoke] auth me ok');
const putSnapshotResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
withBearer(entry.token, {
method: 'PUT',
body: JSON.stringify({
gameState: {
worldType: 'WUXIA',
chapter: 1,
},
bottomTab: 'adventure',
currentStory: {
text: 'smoke story',
},
}),
}),
);
const putSnapshotPayload = (await putSnapshotResponse.json()) as {
version: number;
bottomTab: string;
gameState: {
chapter: number;
};
};
assert.equal(putSnapshotResponse.status, 200);
assert.equal(putSnapshotPayload.version, 2);
assert.equal(putSnapshotPayload.bottomTab, 'adventure');
assert.equal(putSnapshotPayload.gameState.chapter, 1);
const getSnapshotResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
{
headers: {
Authorization: `Bearer ${entry.token}`,
},
},
);
const getSnapshotPayload = (await getSnapshotResponse.json()) as {
bottomTab: string;
gameState: {
chapter: number;
};
};
assert.equal(getSnapshotResponse.status, 200);
assert.equal(getSnapshotPayload.bottomTab, 'adventure');
assert.equal(getSnapshotPayload.gameState.chapter, 1);
console.log('[server-node:smoke] runtime snapshot roundtrip ok');
const putSettingsResponse = await httpRequest(
`${baseUrl}/api/runtime/settings`,
withBearer(entry.token, {
method: 'PUT',
body: JSON.stringify({
musicVolume: 0.3,
}),
}),
);
const putSettingsPayload = (await putSettingsResponse.json()) as {
musicVolume: number;
};
assert.equal(putSettingsResponse.status, 200);
assert.equal(putSettingsPayload.musicVolume, 0.3);
const getSettingsResponse = await httpRequest(`${baseUrl}/api/runtime/settings`, {
headers: {
Authorization: `Bearer ${entry.token}`,
},
});
const getSettingsPayload = (await getSettingsResponse.json()) as {
musicVolume: number;
};
assert.equal(getSettingsResponse.status, 200);
assert.equal(getSettingsPayload.musicVolume, 0.3);
console.log('[server-node:smoke] runtime settings roundtrip ok');
const deleteSnapshotResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
withBearer(entry.token, {
method: 'DELETE',
}),
);
const deleteSnapshotPayload = (await deleteSnapshotResponse.json()) as {
ok: boolean;
};
assert.equal(deleteSnapshotResponse.status, 200);
assert.equal(deleteSnapshotPayload.ok, true);
const emptySnapshotResponse = await httpRequest(
`${baseUrl}/api/runtime/save/snapshot`,
{
headers: {
Authorization: `Bearer ${entry.token}`,
},
},
);
const emptySnapshotPayload = await emptySnapshotResponse.json();
assert.equal(emptySnapshotResponse.status, 200);
assert.equal(emptySnapshotPayload, null);
console.log('[server-node:smoke] runtime snapshot delete ok');
const logoutResponse = await httpRequest(
`${baseUrl}/api/auth/logout`,
withBearer(entry.token, {
method: 'POST',
}),
);
const logoutPayload = (await logoutResponse.json()) as {
ok: boolean;
};
assert.equal(logoutResponse.status, 200);
assert.equal(logoutPayload.ok, true);
const expiredTokenResponse = await httpRequest(`${baseUrl}/api/auth/me`, {
headers: {
Authorization: `Bearer ${entry.token}`,
},
});
assert.equal(expiredTokenResponse.status, 401);
console.log('[server-node:smoke] logout invalidation ok');
});
console.log('[server-node:smoke] all checks passed');
}
void main().catch((error) => {
console.error('[server-node:smoke] failed');
console.error(error);
process.exit(1);
});