mirror of
https://github.com/kemalai/FreeCrawl-SEO-Tool.git
synced 2026-10-18 00:39:38 +02:00
265 lines
8.4 KiB
TypeScript
265 lines
8.4 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
startMcpBridge,
|
|
stopMcpBridge,
|
|
type McpBridgeDeps,
|
|
} from '../apps/desktop/src/main/mcp-bridge.js';
|
|
|
|
/**
|
|
* Exercises the localhost bridge's HTTP layer end-to-end (auth, header-based
|
|
* session routing, typed error → status mapping, capabilities, session
|
|
* lifecycle dispatch) against fake deps — no Electron, no real crawler.
|
|
*/
|
|
|
|
let dir: string;
|
|
let port: number;
|
|
let token: string;
|
|
const calls: Array<[string, ...unknown[]]> = [];
|
|
const sessions = new Map<string, { label: string; doc: string | null }>();
|
|
let seq = 0;
|
|
|
|
const deps: McpBridgeDeps = {
|
|
userDataDir: '', // set in beforeAll
|
|
getCapabilities: () => ({
|
|
bridgeVersion: 2,
|
|
appVersion: '0.10.0',
|
|
features: ['crawl-lease', 'crawl-queue', 'sessions'],
|
|
limits: { maxHeadlessSessions: 3, maxQueueDepth: 4, sessionIdleTtlMs: 1_800_000 },
|
|
}),
|
|
startCrawl: async (input, ctx) => {
|
|
calls.push(['start', ctx.sessionId, ctx.clientId, input.onBusy ?? null]);
|
|
if (input.onBusy === 'reject')
|
|
return { error: 'crawl-in-progress', message: 'busy', details: { sessionId: ctx.sessionId } };
|
|
return {
|
|
ok: true,
|
|
crawlId: 'cr_x',
|
|
sessionId: ctx.sessionId,
|
|
config: { startUrl: input.startUrl ?? '' } as never,
|
|
queued: false,
|
|
waitedMs: 0,
|
|
};
|
|
},
|
|
stopCrawl: (ctx, opts) => {
|
|
calls.push(['stop', ctx.sessionId, opts.force]);
|
|
return { ok: true };
|
|
},
|
|
pauseCrawl: () => ({ ok: true }),
|
|
resumeCrawl: () => ({ ok: true }),
|
|
clearCrawl: async (ctx, opts) => {
|
|
if (ctx.sessionId === 'primary' && !opts.force)
|
|
return { error: 'project-locked', message: 'unsaved work', details: {} };
|
|
return { ok: true };
|
|
},
|
|
hasAction: (name) => name === 'export-csv',
|
|
runAction: async (name, _input, ctx) => {
|
|
calls.push(['action', name, ctx.sessionId]);
|
|
return { session: ctx.sessionId };
|
|
},
|
|
getCrawlProgress: (ctx) => ({
|
|
progress: null,
|
|
crawlId: null,
|
|
sessionId: ctx.sessionId,
|
|
ownerClientId: null,
|
|
ownedByCaller: false,
|
|
}),
|
|
getProjectInfo: (ctx) => ({ projectPath: `/p/${ctx.sessionId}`, urlsCrawled: 0 }),
|
|
listSessions: () => [
|
|
{
|
|
sessionId: 'primary',
|
|
label: 'Primary',
|
|
kind: 'primary',
|
|
documentPath: null,
|
|
dbPath: null,
|
|
urlsCrawled: 0,
|
|
crawl: null,
|
|
ownerClientId: null,
|
|
createdAt: 0,
|
|
lastUsedAt: 0,
|
|
},
|
|
...[...sessions.entries()].map(([id, s]) => ({
|
|
sessionId: id,
|
|
label: s.label,
|
|
kind: 'headless' as const,
|
|
documentPath: s.doc,
|
|
dbPath: `/db/${id}`,
|
|
urlsCrawled: 0,
|
|
crawl: null,
|
|
ownerClientId: null,
|
|
createdAt: 0,
|
|
lastUsedAt: 0,
|
|
})),
|
|
],
|
|
createSession: async (input) => {
|
|
const id = `sess_${++seq}`;
|
|
sessions.set(id, { label: input.label ?? `agent-${seq}`, doc: input.projectPath ?? null });
|
|
return {
|
|
sessionId: id,
|
|
dbPath: `/db/${id}`,
|
|
documentPath: input.projectPath ?? null,
|
|
created: true,
|
|
label: input.label ?? `agent-${seq}`,
|
|
};
|
|
},
|
|
closeSession: async (input, ctx) => {
|
|
if (!sessions.has(ctx.sessionId))
|
|
return { error: 'session-not-found', message: 'gone', details: {} };
|
|
sessions.delete(ctx.sessionId);
|
|
return input.save ? { ok: true, savedTo: input.savePath ?? '/s.seoproject' } : { ok: true };
|
|
},
|
|
saveSession: async (input, ctx) => {
|
|
if (!sessions.has(ctx.sessionId))
|
|
return { error: 'session-not-found', message: 'gone', details: {} };
|
|
return { filePath: input.projectPath ?? '/d.seoproject', bytesWritten: 1234 };
|
|
},
|
|
log: () => {},
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
dir = mkdtempSync(join(tmpdir(), 'fc-bridge-'));
|
|
deps.userDataDir = dir;
|
|
const started = await startMcpBridge(deps);
|
|
if (!started) throw new Error('bridge failed to start');
|
|
port = started.port;
|
|
token = started.token;
|
|
const disc = JSON.parse(readFileSync(join(dir, 'mcp-bridge.json'), 'utf8'));
|
|
expect(disc.version).toBe(2);
|
|
});
|
|
|
|
afterAll(() => {
|
|
stopMcpBridge();
|
|
try {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
});
|
|
|
|
const base = () => `http://127.0.0.1:${port}`;
|
|
async function req(
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
headers: Record<string, string> = {},
|
|
) {
|
|
const r = await fetch(base() + path, {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
...headers,
|
|
},
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
});
|
|
let json: unknown = null;
|
|
try {
|
|
json = await r.json();
|
|
} catch {
|
|
/* no body */
|
|
}
|
|
return { status: r.status, body: json as Record<string, unknown> };
|
|
}
|
|
|
|
describe('MCP bridge routing', () => {
|
|
it('rejects a bad token with 401', async () => {
|
|
const r = await fetch(base() + '/v1/capabilities', {
|
|
headers: { Authorization: 'Bearer wrong' },
|
|
});
|
|
expect(r.status).toBe(401);
|
|
});
|
|
|
|
it('serves capabilities with the sessions feature', async () => {
|
|
const r = await req('GET', '/v1/capabilities');
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.bridgeVersion).toBe(2);
|
|
expect((r.body.features as string[]).includes('sessions')).toBe(true);
|
|
});
|
|
|
|
it('routes a headerless request to the primary session with a null client', async () => {
|
|
const r = await req('POST', '/v1/crawl/start', { startUrl: 'x' });
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.sessionId).toBe('primary');
|
|
expect(calls.at(-1)).toEqual(['start', 'primary', null, null]);
|
|
});
|
|
|
|
it('routes by session + client headers', async () => {
|
|
const r = await req(
|
|
'POST',
|
|
'/v1/crawl/start',
|
|
{ startUrl: 'y' },
|
|
{ 'x-freecrawl-session': 'sess_9', 'x-freecrawl-client': 'clientA' },
|
|
);
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.sessionId).toBe('sess_9');
|
|
expect(calls.at(-1)).toEqual(['start', 'sess_9', 'clientA', null]);
|
|
});
|
|
|
|
it('maps crawl-in-progress to 409', async () => {
|
|
const r = await req('POST', '/v1/crawl/start', { onBusy: 'reject' });
|
|
expect(r.status).toBe(409);
|
|
expect(r.body.error).toBe('crawl-in-progress');
|
|
});
|
|
|
|
it('guards clear on primary with unsaved work (project-locked 409), overridable by force', async () => {
|
|
const locked = await req('POST', '/v1/crawl/clear', {});
|
|
expect(locked.status).toBe(409);
|
|
expect(locked.body.error).toBe('project-locked');
|
|
const forced = await req('POST', '/v1/crawl/clear', { force: true });
|
|
expect(forced.status).toBe(200);
|
|
expect(forced.body.ok).toBe(true);
|
|
});
|
|
|
|
it('propagates the force flag on stop', async () => {
|
|
await req('POST', '/v1/crawl/stop', { force: true }, { 'x-freecrawl-session': 'primary' });
|
|
expect(calls.at(-1)).toEqual(['stop', 'primary', true]);
|
|
});
|
|
|
|
it('routes actions to the request session; unknown actions 400', async () => {
|
|
const ok = await req('POST', '/v1/action/export-csv', {}, { 'x-freecrawl-session': 'sess_3' });
|
|
expect(ok.status).toBe(200);
|
|
expect(ok.body.session).toBe('sess_3');
|
|
const bad = await req('POST', '/v1/action/nope', {});
|
|
expect(bad.status).toBe(400);
|
|
expect(bad.body.error).toBe('bad-request');
|
|
});
|
|
|
|
it('runs the session create → list → save → close lifecycle', async () => {
|
|
const created = await req('POST', '/v1/session/create', { mode: 'scratch', label: 't' });
|
|
expect(created.status).toBe(200);
|
|
const id = created.body.sessionId as string;
|
|
expect(id.startsWith('sess_')).toBe(true);
|
|
|
|
const list = await req('GET', '/v1/session/list');
|
|
expect((list.body.sessions as Array<{ sessionId: string }>).some((s) => s.sessionId === id)).toBe(
|
|
true,
|
|
);
|
|
|
|
const saved = await req(
|
|
'POST',
|
|
'/v1/session/save',
|
|
{ projectPath: '/x.seoproject' },
|
|
{ 'x-freecrawl-session': id },
|
|
);
|
|
expect(saved.body.bytesWritten).toBe(1234);
|
|
|
|
const closed = await req(
|
|
'POST',
|
|
'/v1/session/close',
|
|
{ save: true, savePath: '/y.seoproject' },
|
|
{ 'x-freecrawl-session': id },
|
|
);
|
|
expect(closed.body.savedTo).toBe('/y.seoproject');
|
|
|
|
const closedAgain = await req('POST', '/v1/session/close', {}, { 'x-freecrawl-session': id });
|
|
expect(closedAgain.status).toBe(404);
|
|
expect(closedAgain.body.error).toBe('session-not-found');
|
|
});
|
|
|
|
it('returns 400 for an unknown route', async () => {
|
|
const r = await req('GET', '/v1/nope');
|
|
expect(r.status).toBe(400);
|
|
});
|
|
});
|