mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* chore(deps): upgrade express 4.22.1 -> 5.2.1 and @types/express
Breaking changes addressed:
- Renamed all bare wildcard route segments from * to *splat across
src/server.ts, src/static-resource-routes.ts, src/project-routes.ts,
src/import-export-routes.ts, and all three test stubs that define
app.get/options/delete routes using /raw/* or /raw/* patterns
- Updated wildcard param access from (req.params as any)[0] / req.params[0]
to Array.isArray(req.params.splat) ? req.params.splat.join('/') : String(...)
to handle the Express 5 / path-to-regexp v8 change where wildcard params
are now string[] instead of string
- Updated app.get('*') SPA fallback to app.get('/*splat') in server.ts
- Annotated five connector route handlers with Request<{ connectorId: string }>
so the typed param resolves as string, not string | string[], fixing the
10 TS2345 / TS2322 errors that surfaced when @types/express moved to 5.0.6
- Fixed two app.listen() beforeAll callbacks in origin-validation.test.ts to
accept and propagate the optional Error argument Express 5 now passes to
the listen callback, resolving TS2769 overload mismatch
* chore(nix): refresh daemonHash for rebased lockfile
* fix(daemon): await res.sendFile() in async route handlers for Express 5 compatibility
Express 5 res.sendFile() returns a Promise. Without await, async route
handlers return before the response is sent, causing Express to call
next() and fall through to a 404. Add await to all res.sendFile() calls
in async handlers in static-resource-routes.ts and server.ts.
* fix(daemon): use readFile+send for spritesheet route instead of sendFile
Express 5 res.sendFile() returns undefined (not a Promise). ENOENT errors
call next() asynchronously after the route handler's try/catch has returned,
causing unhandled 404 responses. Replacing with fs.promises.readFile + res.send
keeps the error path fully within the handler's try/catch.
---------
Co-authored-by: Patrick A <259201958+eefynet@users.noreply.github.com>
133 lines
4.7 KiB
TypeScript
133 lines
4.7 KiB
TypeScript
import http from 'node:http';
|
|
import type { AddressInfo } from 'node:net';
|
|
import express from 'express';
|
|
import type { Express } from 'express';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { getFile } from '../src/mcp.js';
|
|
|
|
const PROJECT_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
|
|
|
interface Harness {
|
|
server: http.Server;
|
|
baseUrl: string;
|
|
}
|
|
|
|
interface TextContent {
|
|
type: string;
|
|
text: string;
|
|
}
|
|
|
|
function makeDaemonApp(text: string, contentType = 'text/plain'): Express {
|
|
const app = express();
|
|
app.get('/api/projects/:id/raw/*splat', (_req, res) => {
|
|
res.set({ 'content-type': contentType }).send(text);
|
|
});
|
|
return app;
|
|
}
|
|
|
|
function startServer(app: Express): Promise<Harness> {
|
|
return new Promise((resolve) => {
|
|
const tmp = http.createServer();
|
|
tmp.listen(0, '127.0.0.1', () => {
|
|
const { port } = tmp.address() as AddressInfo;
|
|
tmp.close(() => {
|
|
const server = app.listen(port, '127.0.0.1', () =>
|
|
resolve({ server, baseUrl: `http://127.0.0.1:${port}` }),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
const FIVE_HUNDRED_LINES = Array.from({ length: 500 }, (_, i) => `line ${i + 1}`).join('\n');
|
|
|
|
function contentTexts(content: TextContent[]): string[] {
|
|
return content.map((c) => c.text);
|
|
}
|
|
|
|
function lastText(parts: string[]): string {
|
|
const text = parts.at(-1);
|
|
if (text == null) throw new Error('expected MCP text content');
|
|
return text;
|
|
}
|
|
|
|
describe('getFile offset/limit slicing', () => {
|
|
let server: http.Server;
|
|
let baseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
const r = await startServer(makeDaemonApp(FIVE_HUNDRED_LINES, 'text/plain'));
|
|
server = r.server;
|
|
baseUrl = r.baseUrl;
|
|
});
|
|
|
|
afterAll(() => new Promise((resolve) => server.close(resolve)));
|
|
|
|
it('default args return the full file when totalLines <= 2000 and add no window marker', async () => {
|
|
const r = await getFile(baseUrl, PROJECT_ID, 'file.txt', null, null);
|
|
const textParts = contentTexts(r.content);
|
|
expect(textParts.some((t) => t.startsWith('[od:file-window'))).toBe(false);
|
|
const body = lastText(textParts);
|
|
expect(body.split('\n').length).toBe(500);
|
|
expect(body.split('\n')[0]).toBe('line 1');
|
|
expect(body.split('\n')[499]).toBe('line 500');
|
|
});
|
|
|
|
it('limit caps the slice and stamps a truncation marker with totalLines', async () => {
|
|
const r = await getFile(baseUrl, PROJECT_ID, 'file.txt', null, null, 0, 100);
|
|
const textParts = contentTexts(r.content);
|
|
const marker = textParts.find((t) => t.startsWith('[od:file-window'));
|
|
expect(marker).toBeDefined();
|
|
expect(marker).toContain('offset=0');
|
|
expect(marker).toContain('returnedLines=100');
|
|
expect(marker).toContain('totalLines=500');
|
|
expect(marker).toContain('offset=100');
|
|
const body = lastText(textParts);
|
|
expect(body.split('\n').length).toBe(100);
|
|
expect(body.split('\n')[0]).toBe('line 1');
|
|
expect(body.split('\n')[99]).toBe('line 100');
|
|
});
|
|
|
|
it('offset returns a mid-file slice and the marker reflects start', async () => {
|
|
const r = await getFile(baseUrl, PROJECT_ID, 'file.txt', null, null, 200, 50);
|
|
const textParts = contentTexts(r.content);
|
|
const marker = textParts.find((t) => t.startsWith('[od:file-window'));
|
|
expect(marker).toContain('offset=200');
|
|
expect(marker).toContain('returnedLines=50');
|
|
const body = lastText(textParts);
|
|
expect(body.split('\n')[0]).toBe('line 201');
|
|
expect(body.split('\n')[49]).toBe('line 250');
|
|
});
|
|
|
|
it('offset past EOF returns empty slice but still stamps the marker (no truncation note)', async () => {
|
|
const r = await getFile(baseUrl, PROJECT_ID, 'file.txt', null, null, 1000, 50);
|
|
const textParts = contentTexts(r.content);
|
|
const marker = textParts.find((t) => t.startsWith('[od:file-window'));
|
|
expect(marker).toContain('offset=500');
|
|
expect(marker).toContain('returnedLines=0');
|
|
expect(marker).toContain('totalLines=500');
|
|
expect(marker).not.toContain('call get_file again');
|
|
const body = lastText(textParts);
|
|
expect(body).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('getFile binary rejection unchanged', () => {
|
|
let server: http.Server;
|
|
let baseUrl: string;
|
|
|
|
beforeAll(async () => {
|
|
const r = await startServer(makeDaemonApp('binary-bytes', 'image/png'));
|
|
server = r.server;
|
|
baseUrl = r.baseUrl;
|
|
});
|
|
|
|
afterAll(() => new Promise((resolve) => server.close(resolve)));
|
|
|
|
it('returns an error result for binary mimes regardless of offset/limit', async () => {
|
|
const r = await getFile(baseUrl, PROJECT_ID, 'logo.png', null, null, 0, 100);
|
|
expect('isError' in r && r.isError).toBe(true);
|
|
const text = contentTexts(r.content).join('\n');
|
|
expect(text).toMatch(/binary content is not yet supported/);
|
|
});
|
|
});
|