mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
fix(serve): serve web UI at root path instead of 404 (#1048)
* fix(serve): serve web UI at root path instead of 404 gitnexus serve returned Cannot GET / because no route handler existed for the root path. Now serves the built gitnexus-web dist at / with SPA fallback for client-side routing. Falls back to a helpful landing page with API links when the web UI hasn't been built yet. Also updates the build script to build and copy gitnexus-web into gitnexus/web/ for the published npm package. * fix(serve): address Copilot review feedback - Use regex SPA fallback that excludes /api paths (avoids serving index.html for unknown API routes) - Add rel="noopener noreferrer" to external link (reverse-tabnabbing) - Move build "done" log after web UI step * fix(build): use npm run build for web UI, add npm install guard The build script ran `npx tsc -b && npx vite build` in gitnexus-web/, but CI only installs node_modules for gitnexus/ — not gitnexus-web/. npx then resolved the wrong `tsc` package (a trojan on npm), causing all CI jobs to fail. Fix: add an npm install guard when node_modules is missing, and use `npm run build` (which runs the local typescript) instead of npx. * feat(serve): styled fallback page, asset 404s, build script safety - Add landingPageHtml() with gitnexus-web design tokens (void bg, surface cards, accent color, terminal-style build command block). - Add resolveWebDistDir() helper with non-ENOENT error logging. - Register express.static with Cache-Control headers (no-cache HTML, immutable assets) and SPA fallback route. - Replace wildcard SPA fallback with regex that excludes /api/* AND asset-like file extensions (.js, .css, .ico, .woff2, .map, etc.). - Add ordering comment warning about SPA fallback route placement. scripts/build.js: - Change npm install to npm ci. - Add timeout: 120_000 to all execSync calls. Test coverage: - 26 new unit tests for design tokens, terminal block, external links, SPA regex acceptance/exclusion, cache headers, and fs.access edge cases. Closes #1048 (review feedback) * fix: format, lint, and add GITNEXUS_WEB_DIST env var - Remove unused fsType import from web-ui-serving.test.ts (lint error) - Run prettier on fallback-page-screenshot.html and test file - Add GITNEXUS_WEB_DIST env var as primary override in resolveWebDistDir - Add tests for env var: prefer when set, fallback when dir missing * fix: use cross-platform path matching in env var tests Path.includes('/env/dist') fails on Windows where path.join produces backslashed paths. Normalize via path.sep replacement before matching. * fix(serve): address PR #1048 review findings - Add uncaughtException/unhandledRejection crash guards to HTTP serve path - Export SPA_FALLBACK_REGEX so tests use the production constant (no drift) - Export staticCacheControlSetHeaders so tests verify the real production function - Add real Express dispatch tests for API 404 and asset 404 isolation - Delete committed debug artifact fallback-page-screenshot.html
This commit is contained in:
parent
c4999b02b0
commit
94a4365e6d
4 changed files with 467 additions and 3 deletions
|
|
@ -35,7 +35,8 @@
|
|||
"hooks",
|
||||
"scripts",
|
||||
"skills",
|
||||
"vendor"
|
||||
"vendor",
|
||||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node scripts/build.js",
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@ const SHARED_DEST = path.join(DIST, '_shared');
|
|||
|
||||
// ── 1. Build gitnexus-shared ───────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus-shared…');
|
||||
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit' });
|
||||
execSync('npx tsc', { cwd: SHARED_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// ── 2. Build gitnexus ──────────────────────────────────────────────
|
||||
console.log('[build] compiling gitnexus…');
|
||||
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit' });
|
||||
execSync('npx tsc', { cwd: ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// ── 3. Copy shared dist ────────────────────────────────────────────
|
||||
console.log('[build] copying shared module into dist/_shared…');
|
||||
|
|
@ -70,4 +70,24 @@ walk(DIST, ['.js', '.d.ts'], rewriteFile);
|
|||
const cliEntry = path.join(DIST, 'cli', 'index.js');
|
||||
if (fs.existsSync(cliEntry)) fs.chmodSync(cliEntry, 0o755);
|
||||
|
||||
// ── 6. Build & copy web UI ──────────────────────────────────────────
|
||||
const WEB_ROOT = path.resolve(ROOT, '..', 'gitnexus-web');
|
||||
const WEB_DEST = path.join(DIST, '..', 'web');
|
||||
|
||||
if (fs.existsSync(path.join(WEB_ROOT, 'package.json'))) {
|
||||
console.log('[build] building gitnexus-web…');
|
||||
if (!fs.existsSync(path.join(WEB_ROOT, 'node_modules'))) {
|
||||
console.log('[build] installing gitnexus-web dependencies…');
|
||||
execSync('npm ci', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
}
|
||||
execSync('npm run build', { cwd: WEB_ROOT, stdio: 'inherit', timeout: 120_000 });
|
||||
|
||||
// Copy dist → gitnexus/web/ (shipped in the npm package)
|
||||
fs.rmSync(WEB_DEST, { recursive: true, force: true });
|
||||
fs.cpSync(path.join(WEB_ROOT, 'dist'), WEB_DEST, { recursive: true });
|
||||
console.log('[build] copied web UI → gitnexus/web/');
|
||||
} else {
|
||||
console.log('[build] skipping web UI (gitnexus-web not found)');
|
||||
}
|
||||
|
||||
console.log(`[build] done — rewrote ${rewritten} files.`);
|
||||
|
|
|
|||
|
|
@ -127,6 +127,105 @@ export const isIgnorableGraphQueryError = (err: unknown): boolean => {
|
|||
);
|
||||
};
|
||||
|
||||
export const SPA_FALLBACK_REGEX = /^(?!\/api(?:\/|$))(?!.*\.\w{1,10}$).*/;
|
||||
|
||||
export const resolveWebDistDir = async (
|
||||
primaryDir: string,
|
||||
fallbackDir: string,
|
||||
): Promise<string | null> => {
|
||||
const envDir = process.env.GITNEXUS_WEB_DIST;
|
||||
const dirs = envDir ? [envDir, primaryDir, fallbackDir] : [primaryDir, fallbackDir];
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
await fs.access(path.join(dir, 'index.html'));
|
||||
return dir;
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') {
|
||||
console.warn(`[serve] could not access web UI dir ${dir}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const landingPageHtml = (): string => `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>GitNexus</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:Outfit,system-ui,-apple-system,sans-serif;background:#06060a;color:#e4e4ed;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1.5rem}
|
||||
.card{background:#101018;border:1px solid #2a2a3a;border-radius:0.75rem;padding:2rem;max-width:480px;width:100%}
|
||||
.logo{font-size:1.5rem;font-weight:700;color:#e4e4ed;letter-spacing:-0.02em;margin-bottom:0.25rem}
|
||||
.subtitle{font-size:0.875rem;color:#8888a0;margin-bottom:1.5rem}
|
||||
.section-title{font-size:0.75rem;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;color:#5a5a70;margin-bottom:0.75rem}
|
||||
.endpoint{margin:0.25rem 0;font-size:0.875rem}
|
||||
.endpoint a{color:#7c3aed;text-decoration:none}
|
||||
.endpoint a:hover{text-decoration:underline}
|
||||
.endpoint code{background:#16161f;padding:0.15em 0.4em;border-radius:0.25rem;font-size:0.8rem;color:#8888a0}
|
||||
.divider{height:1px;background:#1e1e2a;margin:1.25rem 0}
|
||||
.terminal{background:#0a0a10;border:1px solid #1e1e2a;border-radius:0.5rem;padding:0.75rem 1rem;font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:0.8rem;color:#8888a0;margin-bottom:1rem;overflow-x:auto}
|
||||
.terminal .prompt{color:#7c3aed;user-select:none}
|
||||
.terminal .cmd{color:#e4e4ed}
|
||||
.link-row{display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;margin-top:0.5rem}
|
||||
.link-row svg{flex-shrink:0}
|
||||
a.ext{color:#7c3aed;text-decoration:none;display:inline-flex;align-items:center;gap:0.25rem}
|
||||
a.ext:hover{text-decoration:underline}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">GitNexus</div>
|
||||
<div class="subtitle">API server is running</div>
|
||||
<div class="section-title">Endpoints</div>
|
||||
<p class="endpoint"><a href="/api/info">/api/info</a> <span style="color:#5a5a70">— Server version & context</span></p>
|
||||
<p class="endpoint"><a href="/api/repos">/api/repos</a> <span style="color:#5a5a70">— Indexed repositories</span></p>
|
||||
<p class="endpoint"><code>/api/heartbeat</code> <span style="color:#5a5a70">— SSE heartbeat</span></p>
|
||||
<p class="endpoint"><code>/api/graph</code> <code>/api/query</code> <code>/api/search</code> <span style="color:#5a5a70">— Data</span></p>
|
||||
<p class="endpoint"><code>/api/mcp</code> <span style="color:#5a5a70">— MCP over StreamableHTTP</span></p>
|
||||
<div class="divider"></div>
|
||||
<div class="section-title">Web UI not found</div>
|
||||
<div class="terminal"><span class="prompt">$ </span><span class="cmd">cd gitnexus-web && npm run build</span></div>
|
||||
<div class="link-row">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#7c3aed" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
<a class="ext" href="https://gitnexus.vercel.app" target="_blank" rel="noopener noreferrer">gitnexus.vercel.app</a>
|
||||
<span style="color:#5a5a70">— connects to this server</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export const staticCacheControlSetHeaders = (res: express.Response, filePath: string): void => {
|
||||
if (filePath.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
} else {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
}
|
||||
};
|
||||
|
||||
export const registerWebUI = (app: express.Express, staticDir: string | null): void => {
|
||||
if (staticDir) {
|
||||
app.use(
|
||||
express.static(staticDir, {
|
||||
setHeaders: staticCacheControlSetHeaders,
|
||||
}),
|
||||
);
|
||||
// ⚠ This must remain the LAST route before the global error handler.
|
||||
// The regex excludes /api paths AND paths with file extensions (.js, .css, etc.)
|
||||
// so missing assets get real 404s instead of the SPA HTML.
|
||||
// Adding routes below this will be unreachable for non-API, non-asset paths.
|
||||
app.get(SPA_FALLBACK_REGEX, (_req, res) => {
|
||||
res.sendFile(path.join(staticDir, 'index.html'));
|
||||
});
|
||||
} else {
|
||||
app.get('/', (_req, res) => {
|
||||
res.type('html').send(landingPageHtml());
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const ensureStreamIsWritable = (res: express.Response, signal?: AbortSignal): void => {
|
||||
if (signal?.aborted || res.destroyed || res.writableEnded) {
|
||||
throw new ClientDisconnectedError();
|
||||
|
|
@ -1557,6 +1656,17 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
res.json({ id: job.id, status: 'failed', error: 'Cancelled by user' });
|
||||
});
|
||||
|
||||
// ── Web UI (served at root) ───────────────────────────────────────
|
||||
|
||||
// Resolve the gitnexus-web dist directory relative to this file's location.
|
||||
// In the published package: <pkg>/dist/server/api.js → <pkg>/web/
|
||||
// In dev (tsx): gitnexus/src/server/api.ts → gitnexus-web/dist/
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webDistDir = path.resolve(__dirname, '..', '..', 'web');
|
||||
const devWebDistDir = path.resolve(__dirname, '..', '..', '..', 'gitnexus-web', 'dist');
|
||||
const staticDir = await resolveWebDistDir(webDistDir, devWebDistDir);
|
||||
registerWebUI(app, staticDir);
|
||||
|
||||
// Global error handler — catch anything the route handlers miss
|
||||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
|
|
@ -1586,5 +1696,18 @@ export const createServer = async (port: number, host: string = '127.0.0.1') =>
|
|||
};
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
|
||||
// Catch-all crash guards (mirrors startMCPServer in mcp/server.ts)
|
||||
let shuttingDown = false;
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('GitNexus uncaughtException:', err?.stack || err);
|
||||
if (!shuttingDown) {
|
||||
shuttingDown = true;
|
||||
shutdown().catch(() => {});
|
||||
}
|
||||
});
|
||||
process.on('unhandledRejection', (reason: any) => {
|
||||
console.error('GitNexus unhandledRejection:', reason?.stack || reason);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
|||
320
gitnexus/test/unit/web-ui-serving.test.ts
Normal file
320
gitnexus/test/unit/web-ui-serving.test.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import express from 'express';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { accessMock } = vi.hoisted(() => ({
|
||||
accessMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: { access: accessMock },
|
||||
access: accessMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
registerWebUI,
|
||||
resolveWebDistDir,
|
||||
landingPageHtml,
|
||||
SPA_FALLBACK_REGEX,
|
||||
staticCacheControlSetHeaders,
|
||||
} from '../../src/server/api.js';
|
||||
|
||||
type MockRoute = { method: string; path: string | RegExp; handler: Function[] };
|
||||
type MockApp = {
|
||||
use: ReturnType<typeof vi.fn>;
|
||||
get: ReturnType<typeof vi.fn>;
|
||||
_routes: MockRoute[];
|
||||
};
|
||||
|
||||
const createMockApp = (): MockApp => {
|
||||
const _routes: MockRoute[] = [];
|
||||
return {
|
||||
use: vi.fn(),
|
||||
get: vi.fn((p: string | RegExp, ...h: Function[]) =>
|
||||
_routes.push({ method: 'get', path: p, handler: h }),
|
||||
),
|
||||
_routes,
|
||||
};
|
||||
};
|
||||
|
||||
const invokeHandler = async (app: MockApp, method: string, reqPath: string) => {
|
||||
for (const route of app._routes) {
|
||||
if (route.method !== method) continue;
|
||||
if (route.path instanceof RegExp) {
|
||||
if (!route.path.test(reqPath)) continue;
|
||||
} else {
|
||||
if (route.path !== reqPath) continue;
|
||||
}
|
||||
const res: any = {
|
||||
sendFile: vi.fn(),
|
||||
type: vi.fn().mockReturnThis(),
|
||||
send: vi.fn().mockReturnThis(),
|
||||
setHeader: vi.fn(),
|
||||
};
|
||||
await route.handler[0]({ path: reqPath } as any, res, vi.fn());
|
||||
return res;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('landingPageHtml', () => {
|
||||
const html = landingPageHtml();
|
||||
|
||||
it('contains void background colour from gitnexus-web design tokens', () => {
|
||||
expect(html).toContain('#06060a');
|
||||
});
|
||||
|
||||
it('contains surface card colour from gitnexus-web design tokens', () => {
|
||||
expect(html).toContain('#101018');
|
||||
});
|
||||
|
||||
it('contains accent colour from gitnexus-web design tokens', () => {
|
||||
expect(html).toContain('#7c3aed');
|
||||
});
|
||||
|
||||
it('uses Outfit font with system-ui fallback', () => {
|
||||
expect(html).toContain('Outfit');
|
||||
expect(html).toContain('system-ui');
|
||||
});
|
||||
|
||||
it('contains the build command in a terminal-style block', () => {
|
||||
expect(html).toContain('cd gitnexus-web');
|
||||
expect(html).toContain('npm run build');
|
||||
});
|
||||
|
||||
it('contains the Vercel link with safe external attributes', () => {
|
||||
expect(html).toContain('https://gitnexus.vercel.app');
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
});
|
||||
|
||||
it('contains the Web UI not found message', () => {
|
||||
expect(html).toContain('Web UI not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SPA fallback regex', () => {
|
||||
it('allows root path', () => {
|
||||
expect(SPA_FALLBACK_REGEX.test('/')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows SPA routes', () => {
|
||||
expect(SPA_FALLBACK_REGEX.test('/processes')).toBe(true);
|
||||
expect(SPA_FALLBACK_REGEX.test('/settings')).toBe(true);
|
||||
expect(SPA_FALLBACK_REGEX.test('/clusters')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes /api paths', () => {
|
||||
expect(SPA_FALLBACK_REGEX.test('/api')).toBe(false);
|
||||
expect(SPA_FALLBACK_REGEX.test('/api/')).toBe(false);
|
||||
expect(SPA_FALLBACK_REGEX.test('/api/info')).toBe(false);
|
||||
expect(SPA_FALLBACK_REGEX.test('/api/does-not-exist')).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes asset-like paths with file extensions', () => {
|
||||
expect(SPA_FALLBACK_REGEX.test('/assets/missing.js')).toBe(false);
|
||||
expect(SPA_FALLBACK_REGEX.test('/assets/missing.css')).toBe(false);
|
||||
expect(SPA_FALLBACK_REGEX.test('/favicon.ico')).toBe(false);
|
||||
expect(SPA_FALLBACK_REGEX.test('/assets/font.woff2')).toBe(false);
|
||||
expect(SPA_FALLBACK_REGEX.test('/static/app.map')).toBe(false);
|
||||
expect(SPA_FALLBACK_REGEX.test('/images/logo.png')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows SPA routes with dots not at the end', () => {
|
||||
expect(SPA_FALLBACK_REGEX.test('/v1.0/api')).toBe(true);
|
||||
expect(SPA_FALLBACK_REGEX.test('/docs/v2.0/guide')).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes paths ending in dot-plus-extension regardless of content before it', () => {
|
||||
expect(SPA_FALLBACK_REGEX.test('/user@example.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerWebUI', () => {
|
||||
it('registers express.static and SPA fallback when staticDir provided', () => {
|
||||
const app = createMockApp();
|
||||
registerWebUI(app as any, '/some/dir');
|
||||
expect(app.use).toHaveBeenCalledTimes(1);
|
||||
expect(app.get).toHaveBeenCalledTimes(1);
|
||||
const [regex] = app.get.mock.calls[0] as [RegExp, ...Function[]];
|
||||
expect(regex.source).toBe(SPA_FALLBACK_REGEX.source);
|
||||
});
|
||||
|
||||
it('registers landing page route when staticDir is null', () => {
|
||||
const app = createMockApp();
|
||||
registerWebUI(app as any, null);
|
||||
expect(app.use).not.toHaveBeenCalled();
|
||||
expect(app.get).toHaveBeenCalledTimes(1);
|
||||
const [path] = app.get.mock.calls[0] as [string, ...Function[]];
|
||||
expect(path).toBe('/');
|
||||
});
|
||||
|
||||
it('landing page handler returns styled HTML', async () => {
|
||||
const app = createMockApp();
|
||||
registerWebUI(app as any, null);
|
||||
const res = await invokeHandler(app, 'get', '/');
|
||||
expect(res.type).toHaveBeenCalledWith('html');
|
||||
expect(res.send).toHaveBeenCalledWith(expect.stringContaining('Web UI not found'));
|
||||
expect(res.send).toHaveBeenCalledWith(expect.stringContaining('#06060a'));
|
||||
expect(res.send).toHaveBeenCalledWith(expect.stringContaining('#7c3aed'));
|
||||
});
|
||||
|
||||
it('Cache-Control setHeaders sets no-cache for HTML, immutable for assets', () => {
|
||||
const captureHeaders = (filePath: string) => {
|
||||
const headers: Record<string, string> = {};
|
||||
const res = {
|
||||
setHeader: (k: string, v: string) => {
|
||||
headers[k] = v;
|
||||
},
|
||||
};
|
||||
staticCacheControlSetHeaders(res as express.Response, filePath);
|
||||
return headers;
|
||||
};
|
||||
expect(captureHeaders('index.html')).toEqual({ 'Cache-Control': 'no-cache' });
|
||||
expect(captureHeaders('app.js')).toEqual({
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
});
|
||||
expect(captureHeaders('style.css')).toEqual({
|
||||
'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveWebDistDir', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns primary dir when index.html exists', async () => {
|
||||
accessMock.mockImplementation(async (p: string) => {
|
||||
if (p.includes('primary')) return undefined;
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
});
|
||||
const result = await resolveWebDistDir('/primary', '/fallback');
|
||||
expect(result).toBe('/primary');
|
||||
});
|
||||
|
||||
it('returns fallback dir when primary missing', async () => {
|
||||
accessMock.mockImplementation(async (p: string) => {
|
||||
if (p.includes('fallback')) return undefined;
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
});
|
||||
const result = await resolveWebDistDir('/primary', '/fallback');
|
||||
expect(result).toBe('/fallback');
|
||||
});
|
||||
|
||||
it('returns null when both dirs missing', async () => {
|
||||
accessMock.mockImplementation(async () => {
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
});
|
||||
const result = await resolveWebDistDir('/primary', '/fallback');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('warns on non-ENOENT errors but continues', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
accessMock.mockImplementation(async (p: string) => {
|
||||
if (p.includes('primary'))
|
||||
throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
if (p.includes('fallback')) return undefined;
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
});
|
||||
const result = await resolveWebDistDir('/primary', '/fallback');
|
||||
expect(result).toBe('/fallback');
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('could not access web UI dir /primary'),
|
||||
'permission denied',
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('prefers GITNEXUS_WEB_DIST env var when set', async () => {
|
||||
const original = process.env.GITNEXUS_WEB_DIST;
|
||||
process.env.GITNEXUS_WEB_DIST = '/env/dist';
|
||||
try {
|
||||
accessMock.mockImplementation(async (p: string) => {
|
||||
const normalized = p.split(path.sep).join('/');
|
||||
if (normalized.includes('/env/dist')) return undefined;
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
});
|
||||
const result = await resolveWebDistDir('/primary', '/fallback');
|
||||
expect(result).toBe('/env/dist');
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.GITNEXUS_WEB_DIST;
|
||||
} else {
|
||||
process.env.GITNEXUS_WEB_DIST = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to primary when GITNEXUS_WEB_DIST dir missing', async () => {
|
||||
const original = process.env.GITNEXUS_WEB_DIST;
|
||||
process.env.GITNEXUS_WEB_DIST = '/env/dist';
|
||||
try {
|
||||
accessMock.mockImplementation(async (p: string) => {
|
||||
const normalized = p.split(path.sep).join('/');
|
||||
if (normalized.includes('/env/dist'))
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
if (normalized.includes('/primary')) return undefined;
|
||||
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
|
||||
});
|
||||
const result = await resolveWebDistDir('/primary', '/fallback');
|
||||
expect(result).toBe('/primary');
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.GITNEXUS_WEB_DIST;
|
||||
} else {
|
||||
process.env.GITNEXUS_WEB_DIST = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real Express dispatch — API and asset isolation', () => {
|
||||
const makeRequest = (app: express.Express, method: string, url: string): Promise<number> => {
|
||||
return new Promise((resolve) => {
|
||||
const server = app.listen(0, () => {
|
||||
const opts = {
|
||||
hostname: 'localhost',
|
||||
port: (server.address() as any).port,
|
||||
method,
|
||||
path: url,
|
||||
};
|
||||
const req = http.request(opts, (res) => {
|
||||
server.close();
|
||||
resolve(res.statusCode ?? 0);
|
||||
});
|
||||
req.on('error', () => {
|
||||
server.close();
|
||||
resolve(0);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
it('GET /api/does-not-exist returns 404, not SPA HTML', async () => {
|
||||
const app = express();
|
||||
app.get('/api/info', (_req, res) => res.json({ ok: true }));
|
||||
registerWebUI(app, '/nonexistent');
|
||||
const status = await makeRequest(app, 'GET', '/api/does-not-exist');
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /assets/missing.js returns 404, not SPA HTML', async () => {
|
||||
const app = express();
|
||||
app.get('/api/info', (_req, res) => res.json({ ok: true }));
|
||||
registerWebUI(app, '/nonexistent');
|
||||
const status = await makeRequest(app, 'GET', '/assets/missing.js');
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
|
||||
it('GET / returns the landing page when no web build exists', async () => {
|
||||
const app = express();
|
||||
registerWebUI(app, null);
|
||||
const status = await makeRequest(app, 'GET', '/');
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue