moving
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
@@ -94,15 +94,32 @@ function spawnInCwd(
|
||||
});
|
||||
}
|
||||
|
||||
async function occupyPort(): Promise<{ server: net.Server; port: number }> {
|
||||
const server = net.createServer();
|
||||
async function occupyPort(): Promise<{
|
||||
server: http.Server;
|
||||
port: number;
|
||||
requestCount: () => number;
|
||||
bearerRequestCount: () => number;
|
||||
}> {
|
||||
let requests = 0;
|
||||
let bearerRequests = 0;
|
||||
const server = http.createServer((request, response) => {
|
||||
requests += 1;
|
||||
if (request.headers.authorization?.startsWith('Bearer ')) bearerRequests += 1;
|
||||
response.writeHead(503, { 'content-type': 'application/json' });
|
||||
response.end('{}');
|
||||
});
|
||||
const port = await new Promise<number>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
resolve(typeof addr === 'object' && addr ? addr.port : 0);
|
||||
});
|
||||
});
|
||||
return { server, port };
|
||||
return {
|
||||
server,
|
||||
port,
|
||||
requestCount: () => requests,
|
||||
bearerRequestCount: () => bearerRequests,
|
||||
};
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
@@ -128,6 +145,36 @@ async function waitFor(
|
||||
throw new Error(`Timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function waitForReadyFile(
|
||||
child: ChildProcessWithoutNullStreams,
|
||||
readyFile: string,
|
||||
output: () => { stdout: string; stderr: string },
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await waitFor(
|
||||
() => fs.existsSync(readyFile) || child.exitCode !== null || child.signalCode !== null,
|
||||
timeoutMs,
|
||||
);
|
||||
} catch (error) {
|
||||
const { stdout, stderr } = output();
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)} waiting for ${readyFile}`
|
||||
+ `\nexit=${String(child.exitCode)} signal=${String(child.signalCode)}`
|
||||
+ `\nstdout:\n${stdout}\nstderr:\n${stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(readyFile)) {
|
||||
const { stdout, stderr } = output();
|
||||
throw new Error(
|
||||
`Launcher exited before writing ${readyFile}`
|
||||
+ `\nexit=${String(child.exitCode)} signal=${String(child.signalCode)}`
|
||||
+ `\nstdout:\n${stdout}\nstderr:\n${stderr}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHealth(url: string, timeoutMs: number): Promise<Record<string, unknown>> {
|
||||
let lastError = '';
|
||||
await waitFor(async () => {
|
||||
@@ -332,7 +379,7 @@ describe('Waggle CLI Launcher', () => {
|
||||
|
||||
it('reports occupied ports with a working --port recovery command', async () => {
|
||||
const home = makeHome();
|
||||
const { server: blocker, port } = await occupyPort();
|
||||
const { server: blocker, port, requestCount, bearerRequestCount } = await occupyPort();
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle-ai/waggle'], home);
|
||||
expect(build.status).toBe(0);
|
||||
@@ -353,6 +400,8 @@ describe('Waggle CLI Launcher', () => {
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(`Port ${port} is already in use`);
|
||||
expect(result.stderr).toContain(`npx waggle --port ${port + 1}`);
|
||||
expect(bearerRequestCount()).toBe(0);
|
||||
expect(requestCount()).toBe(0);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
@@ -381,6 +430,7 @@ describe('Waggle CLI Launcher', () => {
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.join(extractDir, 'package', 'package.json'), 'utf8'),
|
||||
);
|
||||
const distFiles = fs.readdirSync(path.join(extractDir, 'package', 'dist'));
|
||||
const result = await run(
|
||||
process.execPath,
|
||||
[path.join(extractDir, 'package', 'dist', 'cli.js'), '--help'],
|
||||
@@ -388,6 +438,15 @@ describe('Waggle CLI Launcher', () => {
|
||||
);
|
||||
|
||||
expect(pkg.bin.waggle).toBe('./dist/cli.js');
|
||||
expect({
|
||||
transformersDependency: pkg.dependencies?.['@huggingface/transformers'],
|
||||
bundledNativeBindings: distFiles.filter((file) => /^onnxruntime_binding-.*\.node$/.test(file)),
|
||||
bundledTransformersRuntime: distFiles.filter((file) => /^transformers\.node-.*\.js$/.test(file)),
|
||||
}).toEqual({
|
||||
transformersDependency: '3.8.1',
|
||||
bundledNativeBindings: [],
|
||||
bundledTransformersRuntime: [],
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain('Usage:');
|
||||
expect(result.stdout).not.toContain('[waggle:service]');
|
||||
@@ -396,11 +455,11 @@ describe('Waggle CLI Launcher', () => {
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
it('installs the packed launcher and runs npx help plus startup recovery', async () => {
|
||||
const home = makeHome();
|
||||
const { server: blocker, port } = await occupyPort();
|
||||
const { server: blocker, port, requestCount, bearerRequestCount } = await occupyPort();
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle-ai/waggle'], home);
|
||||
expect(build.status).toBe(0);
|
||||
@@ -434,6 +493,19 @@ describe('Waggle CLI Launcher', () => {
|
||||
);
|
||||
expect(install.status).toBe(0);
|
||||
|
||||
const nativeEmbeddingRuntime = await runInCwd(
|
||||
process.execPath,
|
||||
[
|
||||
'--input-type=module',
|
||||
'--eval',
|
||||
"const runtime = await import('@huggingface/transformers'); if (typeof runtime.pipeline !== 'function') throw new Error('Transformers pipeline API is unavailable');",
|
||||
],
|
||||
projectDir,
|
||||
home,
|
||||
);
|
||||
expect(nativeEmbeddingRuntime.status).toBe(0);
|
||||
expect(nativeEmbeddingRuntime.stderr).toBe('');
|
||||
|
||||
const help = await runInCwd(bin('npx'), ['waggle', '--help'], projectDir, home);
|
||||
expect(help.status).toBe(0);
|
||||
expect(help.stdout).toContain('Usage:');
|
||||
@@ -453,14 +525,22 @@ describe('Waggle CLI Launcher', () => {
|
||||
expect(startup.stderr).toContain(`Port ${port} is already in use`);
|
||||
expect(startup.stderr).toContain(`npx waggle --port ${port + 1}`);
|
||||
expect(fs.existsSync(path.join(home, '.waggle'))).toBe(false);
|
||||
expect(bearerRequestCount()).toBe(0);
|
||||
expect(requestCount()).toBe(0);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
}, 240_000);
|
||||
|
||||
it('starts the installed launcher long enough to serve health', async () => {
|
||||
it('starts the installed launcher, isolates concurrent histories, and reloads them after restart', async () => {
|
||||
const home = makeHome();
|
||||
const {
|
||||
server: blocker,
|
||||
port: preferredPort,
|
||||
requestCount,
|
||||
bearerRequestCount,
|
||||
} = await occupyPort();
|
||||
let child: ChildProcessWithoutNullStreams | undefined;
|
||||
try {
|
||||
const build = await run(bin('npm'), ['run', 'build', '--workspace', '@waggle-ai/waggle'], home);
|
||||
@@ -495,33 +575,215 @@ describe('Waggle CLI Launcher', () => {
|
||||
);
|
||||
expect(install.status).toBe(0);
|
||||
|
||||
const port = await freePort();
|
||||
const dataDir = path.join(home, 'data');
|
||||
const readyFile = path.join(home, 'desktop-ready.json');
|
||||
const restartReadyFile = path.join(home, 'desktop-ready-restart.json');
|
||||
const desktopBootstrapToken = 'launcher-desktop-bootstrap-token-1234567890';
|
||||
const unreachableOllamaPort = await freePort();
|
||||
const unreachableVllmPort = await freePort();
|
||||
const providerFreeEnv = {
|
||||
EMBEDDING_PROVIDER: 'mock',
|
||||
ANTHROPIC_API_KEY: '',
|
||||
OPENAI_API_KEY: '',
|
||||
GEMINI_API_KEY: '',
|
||||
GOOGLE_API_KEY: '',
|
||||
XAI_API_KEY: '',
|
||||
DEEPSEEK_API_KEY: '',
|
||||
MISTRAL_API_KEY: '',
|
||||
DASHSCOPE_API_KEY: '',
|
||||
MINIMAX_API_KEY: '',
|
||||
ZHIPU_API_KEY: '',
|
||||
MOONSHOT_API_KEY: '',
|
||||
PERPLEXITY_API_KEY: '',
|
||||
OPENROUTER_API_KEY: '',
|
||||
OLLAMA_HOST: `http://127.0.0.1:${unreachableOllamaPort}`,
|
||||
VLLM_HOST: `http://127.0.0.1:${unreachableVllmPort}`,
|
||||
};
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child = spawnInCwd(
|
||||
bin('npx'),
|
||||
['waggle', '--port', String(port), '--skip-litellm', '--no-open'],
|
||||
['waggle', '--port', String(preferredPort), '--skip-litellm', '--no-open'],
|
||||
projectDir,
|
||||
home,
|
||||
{ WAGGLE_DATA_DIR: dataDir },
|
||||
{
|
||||
WAGGLE_DATA_DIR: dataDir,
|
||||
WAGGLE_DESKTOP_PORT_FALLBACK: '1',
|
||||
WAGGLE_INSTANCE_ID: 'launcher-fallback-health',
|
||||
WAGGLE_DESKTOP_BOOTSTRAP_TOKEN: desktopBootstrapToken,
|
||||
WAGGLE_READY_FILE: readyFile,
|
||||
...providerFreeEnv,
|
||||
},
|
||||
);
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
||||
|
||||
const health = await waitForHealth(`http://127.0.0.1:${port}/health`, 30_000);
|
||||
await waitForReadyFile(child, readyFile, () => ({ stdout, stderr }), 90_000);
|
||||
const ready = JSON.parse(fs.readFileSync(readyFile, 'utf8')) as {
|
||||
preferredPort: number;
|
||||
port: number;
|
||||
};
|
||||
expect(ready.preferredPort).toBe(preferredPort);
|
||||
expect(ready.port).not.toBe(preferredPort);
|
||||
const baseUrl = `http://127.0.0.1:${ready.port}`;
|
||||
const health = await waitForHealth(`${baseUrl}/health`, 30_000);
|
||||
await waitFor(() => stdout.includes('Press Ctrl+C to stop'), 10_000);
|
||||
|
||||
expect(health.database).toMatchObject({ healthy: true });
|
||||
expect(stdout).toContain(`Server: http://localhost:${port}`);
|
||||
expect(stdout).toContain(`Server: http://localhost:${ready.port}`);
|
||||
expect(stdout).toContain('Browser: not opened (--no-open)');
|
||||
expect(stdout).toContain(`Open manually: http://localhost:${port}`);
|
||||
expect(stdout).toContain(`Open manually: http://localhost:${ready.port}`);
|
||||
expect(stderr).not.toContain('Failed to start Waggle');
|
||||
expect(fs.existsSync(path.join(dataDir, 'personal.mind'))).toBe(true);
|
||||
expect(bearerRequestCount()).toBe(0);
|
||||
expect(requestCount()).toBe(0);
|
||||
|
||||
const tokenResponse = await fetch(`${baseUrl}/api/auth/session-token`, {
|
||||
headers: { 'x-waggle-desktop-bootstrap': desktopBootstrapToken },
|
||||
});
|
||||
expect(tokenResponse.status).toBe(200);
|
||||
const token = (await tokenResponse.json() as { token?: string }).token;
|
||||
if (!token) throw new Error('Installed launcher returned no session token');
|
||||
const headers = {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
const workspaceResponse = await fetch(`${baseUrl}/api/workspaces`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ name: 'Packed launcher concurrent history', group: 'Test' }),
|
||||
});
|
||||
expect(workspaceResponse.status).toBe(201);
|
||||
const workspaceId = (await workspaceResponse.json() as { id?: string }).id;
|
||||
if (!workspaceId) throw new Error('Installed launcher returned no workspace id');
|
||||
|
||||
const sessionA = 'packed-concurrent-a';
|
||||
const sessionB = 'packed-concurrent-b';
|
||||
const markerA = 'PACKED_CONCURRENT_SESSION_A';
|
||||
const markerB = 'PACKED_CONCURRENT_SESSION_B';
|
||||
const [chatA, chatB] = await Promise.all([
|
||||
fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ message: markerA, workspace: workspaceId, session: sessionA }),
|
||||
}),
|
||||
fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ message: markerB, workspace: workspaceId, session: sessionB }),
|
||||
}),
|
||||
]);
|
||||
const [chatBodyA, chatBodyB] = await Promise.all([chatA.text(), chatB.text()]);
|
||||
for (const [response, body] of [[chatA, chatBodyA], [chatB, chatBodyB]] as const) {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('content-type')).toMatch(/^text\/event-stream/);
|
||||
expect(body.match(/^event:\s*done\s*$/gm)).toHaveLength(1);
|
||||
expect(body.match(/^event:\s*error\s*$/gm) ?? []).toHaveLength(0);
|
||||
expect(body).toContain('No AI model is ready');
|
||||
}
|
||||
|
||||
for (const sessionId of [sessionA, sessionB]) {
|
||||
expect(fs.existsSync(path.join(
|
||||
dataDir,
|
||||
'workspaces',
|
||||
workspaceId,
|
||||
'sessions',
|
||||
`${sessionId}.jsonl`,
|
||||
))).toBe(true);
|
||||
}
|
||||
|
||||
await stopProcess(child);
|
||||
child = undefined;
|
||||
|
||||
let restartStdout = '';
|
||||
let restartStderr = '';
|
||||
child = spawnInCwd(
|
||||
bin('npx'),
|
||||
['waggle', '--port', String(preferredPort), '--skip-litellm', '--no-open'],
|
||||
projectDir,
|
||||
home,
|
||||
{
|
||||
WAGGLE_DATA_DIR: dataDir,
|
||||
WAGGLE_DESKTOP_PORT_FALLBACK: '1',
|
||||
WAGGLE_INSTANCE_ID: 'launcher-fallback-history-restart',
|
||||
WAGGLE_DESKTOP_BOOTSTRAP_TOKEN: desktopBootstrapToken,
|
||||
WAGGLE_READY_FILE: restartReadyFile,
|
||||
...providerFreeEnv,
|
||||
},
|
||||
);
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk) => { restartStdout += chunk; });
|
||||
child.stderr.on('data', (chunk) => { restartStderr += chunk; });
|
||||
|
||||
await waitForReadyFile(
|
||||
child,
|
||||
restartReadyFile,
|
||||
() => ({ stdout: restartStdout, stderr: restartStderr }),
|
||||
90_000,
|
||||
);
|
||||
const restartReady = JSON.parse(fs.readFileSync(restartReadyFile, 'utf8')) as {
|
||||
preferredPort: number;
|
||||
port: number;
|
||||
};
|
||||
expect(restartReady.preferredPort).toBe(preferredPort);
|
||||
expect(restartReady.port).not.toBe(preferredPort);
|
||||
const restartBaseUrl = `http://127.0.0.1:${restartReady.port}`;
|
||||
const restartHealth = await waitForHealth(`${restartBaseUrl}/health`, 30_000);
|
||||
await waitFor(() => restartStdout.includes('Press Ctrl+C to stop'), 10_000);
|
||||
expect(restartHealth.database).toMatchObject({ healthy: true });
|
||||
expect(restartStderr).not.toContain('Failed to start Waggle');
|
||||
|
||||
const restartTokenResponse = await fetch(`${restartBaseUrl}/api/auth/session-token`, {
|
||||
headers: { 'x-waggle-desktop-bootstrap': desktopBootstrapToken },
|
||||
});
|
||||
expect(restartTokenResponse.status).toBe(200);
|
||||
const restartToken = (await restartTokenResponse.json() as { token?: string }).token;
|
||||
if (!restartToken) throw new Error('Restarted launcher returned no session token');
|
||||
const restartHeaders = { authorization: `Bearer ${restartToken}` };
|
||||
const [historyResponseA, historyResponseB] = await Promise.all([
|
||||
fetch(
|
||||
`${restartBaseUrl}/api/history?workspace=${encodeURIComponent(workspaceId)}&session=${encodeURIComponent(sessionA)}`,
|
||||
{ headers: restartHeaders },
|
||||
),
|
||||
fetch(
|
||||
`${restartBaseUrl}/api/history?workspace=${encodeURIComponent(workspaceId)}&session=${encodeURIComponent(sessionB)}`,
|
||||
{ headers: restartHeaders },
|
||||
),
|
||||
]);
|
||||
expect(historyResponseA.status).toBe(200);
|
||||
expect(historyResponseB.status).toBe(200);
|
||||
const [historyA, historyB] = await Promise.all([
|
||||
historyResponseA.json(),
|
||||
historyResponseB.json(),
|
||||
]) as Array<{
|
||||
sessionId: string;
|
||||
count: number;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
}>;
|
||||
|
||||
expect(historyA.sessionId).toBe(sessionA);
|
||||
expect(historyB.sessionId).toBe(sessionB);
|
||||
expect(historyA.count).toBe(2);
|
||||
expect(historyB.count).toBe(2);
|
||||
expect(historyA.messages).toEqual([
|
||||
expect.objectContaining({ role: 'user', content: markerA }),
|
||||
expect.objectContaining({ role: 'assistant', content: expect.stringContaining('No AI model is ready') }),
|
||||
]);
|
||||
expect(historyB.messages).toEqual([
|
||||
expect.objectContaining({ role: 'user', content: markerB }),
|
||||
expect.objectContaining({ role: 'assistant', content: expect.stringContaining('No AI model is ready') }),
|
||||
]);
|
||||
expect(historyA.messages.some(({ content }) => content.includes(markerB))).toBe(false);
|
||||
expect(historyB.messages.some(({ content }) => content.includes(markerA))).toBe(false);
|
||||
expect(bearerRequestCount()).toBe(0);
|
||||
expect(requestCount()).toBe(0);
|
||||
} finally {
|
||||
if (child) await stopProcess(child);
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
||||
// Windows can release SQLite WAL/SHM handles a few milliseconds after
|
||||
// taskkill reports success; retry the isolated temp-home cleanup.
|
||||
fs.rmSync(home, {
|
||||
@@ -531,6 +793,6 @@ describe('Waggle CLI Launcher', () => {
|
||||
retryDelay: 100,
|
||||
});
|
||||
}
|
||||
}, 180_000);
|
||||
}, 360_000);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user