mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-30 01:51:20 +00:00
Merge branch 'main' into fix/fts-non-fatal-in-analyze
This commit is contained in:
commit
6ccbde5aaa
2 changed files with 268 additions and 217 deletions
|
|
@ -164,6 +164,137 @@ function makeMiniRepoCopy(basename: string, prefix: string): string {
|
|||
return repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects libuv-emitted bind-restriction errors (EACCES / EPERM /
|
||||
* EADDRNOTAVAIL on `listen` or `bind`) so the host-flag tests can
|
||||
* tolerate CI/sandbox environments that forbid loopback binding.
|
||||
*
|
||||
* Match policy: every alternative MUST carry a `listen ` or `bind `
|
||||
* prefix. Bare "permission denied" / "operation not permitted"
|
||||
* substrings in stderr (e.g. from a Node fs EACCES during module
|
||||
* loading) MUST NOT match — those represent real test failures that
|
||||
* should not be silently swallowed.
|
||||
*/
|
||||
function isEvalServerBindRestriction(stderr: string): boolean {
|
||||
return /(?:listen|bind) (?:EPERM|EACCES|EADDRNOTAVAIL|operation not permitted|permission denied)/i.test(
|
||||
stderr,
|
||||
);
|
||||
}
|
||||
|
||||
// Subprocess timeout for eval-server READY signal. Must be < the
|
||||
// outer vitest test budget (35s) so the reject branch fires before
|
||||
// vitest gives up on the test.
|
||||
const EVAL_SERVER_READY_TIMEOUT_MS = 30000;
|
||||
|
||||
/**
|
||||
* Drives the spawn → settle → timer → stderr → close lifecycle shared by
|
||||
* the `eval-server --host` integration tests. Each test passes test-specific
|
||||
* spawn args, a timeout message, and an `onStdout` callback that owns the
|
||||
* READY-signal parsing and any post-READY probing.
|
||||
*
|
||||
* The helper owns: spawn wiring, child.once('error'), the setTimeout
|
||||
* timer, stderr accumulation + 'unknown option' fast-reject, and the close
|
||||
* handler's priority chain (unknown-option → bind-restriction → unexpected-exit
|
||||
* reject). Tests that need to short-circuit before `await` may inspect
|
||||
* `isSettled()`; `settle()` itself is idempotent so calling it after the
|
||||
* promise has already resolved is a safe no-op.
|
||||
*/
|
||||
function runEvalServerHostFlagTest(
|
||||
spawnArgs: string[],
|
||||
opts: {
|
||||
timeoutMsg: string;
|
||||
onStdout: (params: {
|
||||
stdoutBuffer: string;
|
||||
isSettled: () => boolean;
|
||||
settle: (fn: () => void) => void;
|
||||
resolve: () => void;
|
||||
reject: (err: Error) => void;
|
||||
}) => void | Promise<void>;
|
||||
},
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
['--import', tsxImportUrl, cliEntry, 'eval-server', ...spawnArgs],
|
||||
{
|
||||
cwd: MINI_REPO,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: cliEnv(),
|
||||
},
|
||||
);
|
||||
|
||||
let stdoutBuffer = '';
|
||||
let stderrBuffer = '';
|
||||
let settled = false;
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.kill('SIGTERM');
|
||||
fn();
|
||||
};
|
||||
|
||||
child.once('error', (err) => {
|
||||
settle(() => reject(new Error(`Failed to spawn eval-server: ${err.message}`)));
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
settle(() => reject(new Error(opts.timeoutMsg)));
|
||||
}, EVAL_SERVER_READY_TIMEOUT_MS);
|
||||
|
||||
// The `settled` flag is set synchronously by settle(); onStdout callbacks
|
||||
// that await can rely on isSettled() reflecting any close-handler
|
||||
// settlement that occurred during the await. Do not make settle() async.
|
||||
child.stdout.on('data', async (chunk: Buffer) => {
|
||||
stdoutBuffer += chunk.toString();
|
||||
if (settled) return;
|
||||
try {
|
||||
await opts.onStdout({
|
||||
stdoutBuffer,
|
||||
isSettled: () => settled,
|
||||
settle,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
} catch (err) {
|
||||
settle(() => reject(err instanceof Error ? err : new Error(String(err))));
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrBuffer += chunk.toString();
|
||||
if (stderrBuffer.includes('unknown option') || stderrBuffer.includes('error: unknown')) {
|
||||
settle(() => reject(new Error(`eval-server rejected --host flag:\n${stderrBuffer}`)));
|
||||
}
|
||||
});
|
||||
|
||||
child.once('close', (code) => {
|
||||
if (settled) return;
|
||||
if (stderrBuffer.includes('unknown option') || stderrBuffer.includes('error: unknown')) {
|
||||
settle(() => reject(new Error(`eval-server rejected --host flag:\n${stderrBuffer}`)));
|
||||
return;
|
||||
}
|
||||
if (isEvalServerBindRestriction(stderrBuffer)) {
|
||||
settle(() => {
|
||||
console.warn(
|
||||
`[test tolerated] eval-server could not bind in this environment; --host wiring not verified.\nstderr: ${stderrBuffer.trim()}`,
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
return;
|
||||
}
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`eval-server exited unexpectedly (code=${code}) before READY signal.\nstderr: ${stderrBuffer.trim() || '<empty>'}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('CLI end-to-end', () => {
|
||||
it('status command exits cleanly', () => {
|
||||
const result = runCli('status', MINI_REPO);
|
||||
|
|
@ -1263,43 +1394,12 @@ describe('CLI end-to-end', () => {
|
|||
|
||||
describe('eval-server --host flag', () => {
|
||||
it('emits READY signal containing the bound host 127.0.0.1', () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'--import',
|
||||
tsxImportUrl,
|
||||
cliEntry,
|
||||
'eval-server',
|
||||
'--port',
|
||||
'0',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--idle-timeout',
|
||||
'3',
|
||||
],
|
||||
{
|
||||
cwd: MINI_REPO,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: cliEnv(),
|
||||
},
|
||||
);
|
||||
|
||||
let stdoutBuffer = '';
|
||||
let stderrBuffer = '';
|
||||
let settled = false;
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.kill('SIGTERM');
|
||||
fn();
|
||||
};
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdoutBuffer += chunk.toString();
|
||||
if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:')) {
|
||||
return runEvalServerHostFlagTest(
|
||||
['--port', '0', '--host', '127.0.0.1', '--idle-timeout', '3'],
|
||||
{
|
||||
timeoutMsg: 'eval-server did not emit READY signal within 30s',
|
||||
onStdout({ stdoutBuffer, settle, resolve, reject }) {
|
||||
if (!stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:')) return;
|
||||
if (stdoutBuffer.includes('GITNEXUS_EVAL_SERVER_READY:127.0.0.1:')) {
|
||||
settle(resolve);
|
||||
} else {
|
||||
|
|
@ -1311,199 +1411,108 @@ describe('CLI end-to-end', () => {
|
|||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrBuffer += chunk.toString();
|
||||
if (stderrBuffer.includes('unknown option') || stderrBuffer.includes('error: unknown')) {
|
||||
settle(() => reject(new Error(`eval-server rejected --host flag:\n${stderrBuffer}`)));
|
||||
}
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
settle(() => reject(new Error('eval-server did not emit READY signal within 30s')));
|
||||
}, 30000);
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}, 35000);
|
||||
|
||||
it('binds to 0.0.0.0 and serves /health on 127.0.0.1 (cross-container use case)', () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'--import',
|
||||
tsxImportUrl,
|
||||
cliEntry,
|
||||
'eval-server',
|
||||
'--port',
|
||||
'0',
|
||||
'--host',
|
||||
'0.0.0.0',
|
||||
'--idle-timeout',
|
||||
'3',
|
||||
],
|
||||
{
|
||||
cwd: MINI_REPO,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: cliEnv(),
|
||||
},
|
||||
);
|
||||
return runEvalServerHostFlagTest(
|
||||
['--port', '0', '--host', '0.0.0.0', '--idle-timeout', '3'],
|
||||
{
|
||||
timeoutMsg: 'eval-server --host 0.0.0.0 did not emit READY signal within 30s',
|
||||
async onStdout({ stdoutBuffer, isSettled, settle, resolve, reject }) {
|
||||
const readyLine = stdoutBuffer
|
||||
.split('\n')
|
||||
.find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:0.0.0.0:'));
|
||||
if (!readyLine || isSettled()) return;
|
||||
|
||||
let stdoutBuffer = '';
|
||||
let settled = false;
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.kill('SIGTERM');
|
||||
fn();
|
||||
};
|
||||
|
||||
child.stdout.on('data', async (chunk: Buffer) => {
|
||||
stdoutBuffer += chunk.toString();
|
||||
const readyLine = stdoutBuffer
|
||||
.split('\n')
|
||||
.find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:0.0.0.0:'));
|
||||
if (!readyLine || settled) return;
|
||||
|
||||
// Parse the actual OS-assigned port from the READY signal
|
||||
const boundPort = readyLine.split(':').pop()?.trim();
|
||||
if (!boundPort || isNaN(Number(boundPort))) {
|
||||
settle(() => reject(new Error(`Could not parse port from READY signal: ${readyLine}`)));
|
||||
return;
|
||||
}
|
||||
|
||||
// A server bound to 0.0.0.0 must be reachable on 127.0.0.1 from the same host
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${boundPort}/health`);
|
||||
if (res.status === 200) {
|
||||
settle(resolve);
|
||||
} else {
|
||||
settle(() => reject(new Error(`/health returned ${res.status}, expected 200`)));
|
||||
// Parse the actual OS-assigned port from the READY signal
|
||||
const boundPort = readyLine.split(':').pop()?.trim();
|
||||
if (!boundPort || isNaN(Number(boundPort))) {
|
||||
settle(() =>
|
||||
reject(new Error(`Could not parse port from READY signal: ${readyLine}`)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`eval-server bound to 0.0.0.0 but /health unreachable on 127.0.0.1:${boundPort}: ${err}`,
|
||||
|
||||
// A server bound to 0.0.0.0 must be reachable on 127.0.0.1 from the same host
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${boundPort}/health`);
|
||||
if (res.status === 200) {
|
||||
settle(resolve);
|
||||
} else {
|
||||
settle(() => reject(new Error(`/health returned ${res.status}, expected 200`)));
|
||||
}
|
||||
} catch (err) {
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`eval-server bound to 0.0.0.0 but /health unreachable on 127.0.0.1:${boundPort}: ${err}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
if (text.includes('unknown option') || text.includes('error: unknown')) {
|
||||
settle(() => reject(new Error(`eval-server rejected --host flag:\n${text}`)));
|
||||
}
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
settle(() =>
|
||||
reject(new Error('eval-server --host 0.0.0.0 did not emit READY signal within 30s')),
|
||||
);
|
||||
}, 30000);
|
||||
});
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}, 35000);
|
||||
|
||||
it('emits READY signal with bound IP (not literal "localhost") when --host localhost is used', () => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'--import',
|
||||
tsxImportUrl,
|
||||
cliEntry,
|
||||
'eval-server',
|
||||
'--port',
|
||||
'0',
|
||||
'--host',
|
||||
'localhost',
|
||||
'--idle-timeout',
|
||||
'3',
|
||||
],
|
||||
{
|
||||
cwd: MINI_REPO,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: cliEnv(),
|
||||
},
|
||||
);
|
||||
return runEvalServerHostFlagTest(
|
||||
['--port', '0', '--host', 'localhost', '--idle-timeout', '3'],
|
||||
{
|
||||
timeoutMsg: 'eval-server --host localhost did not emit READY signal within 30s',
|
||||
async onStdout({ stdoutBuffer, isSettled, settle, resolve, reject }) {
|
||||
const readyLine = stdoutBuffer
|
||||
.split('\n')
|
||||
.find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:'));
|
||||
if (!readyLine || isSettled()) return;
|
||||
|
||||
let stdoutBuffer = '';
|
||||
let settled = false;
|
||||
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
child.kill('SIGTERM');
|
||||
fn();
|
||||
};
|
||||
|
||||
child.stdout.on('data', async (chunk: Buffer) => {
|
||||
stdoutBuffer += chunk.toString();
|
||||
const readyLine = stdoutBuffer
|
||||
.split('\n')
|
||||
.find((l) => l.startsWith('GITNEXUS_EVAL_SERVER_READY:'));
|
||||
if (!readyLine || settled) return;
|
||||
|
||||
// The signal must contain a real bound IP, not the literal input string
|
||||
if (readyLine.includes(':localhost:')) {
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`READY signal contained literal "localhost" instead of a bound IP:\n${readyLine}`,
|
||||
// The signal must contain a real bound IP, not the literal input string
|
||||
if (readyLine.includes(':localhost:')) {
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`READY signal contained literal "localhost" instead of a bound IP:\n${readyLine}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse host and port: everything after the prefix up to the last colon
|
||||
const withoutPrefix = readyLine.slice('GITNEXUS_EVAL_SERVER_READY:'.length);
|
||||
const lastColon = withoutPrefix.lastIndexOf(':');
|
||||
const signalHost = withoutPrefix.slice(0, lastColon); // "127.0.0.1" or "[::1]"
|
||||
const boundPort = withoutPrefix.slice(lastColon + 1).trim();
|
||||
if (!boundPort || isNaN(Number(boundPort))) {
|
||||
settle(() => reject(new Error(`Could not parse port from READY signal: ${readyLine}`)));
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe /health at the bound address to confirm the server is reachable
|
||||
try {
|
||||
const res = await fetch(`http://${signalHost}:${boundPort}/health`);
|
||||
if (res.status === 200) {
|
||||
settle(resolve);
|
||||
} else {
|
||||
settle(() => reject(new Error(`/health returned ${res.status}, expected 200`)));
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`eval-server bound to localhost but /health unreachable at ${signalHost}:${boundPort}: ${err}`,
|
||||
|
||||
// Parse host and port: everything after the prefix up to the last colon
|
||||
const withoutPrefix = readyLine.slice('GITNEXUS_EVAL_SERVER_READY:'.length);
|
||||
const lastColon = withoutPrefix.lastIndexOf(':');
|
||||
const signalHost = withoutPrefix.slice(0, lastColon); // "127.0.0.1" or "[::1]"
|
||||
const boundPort = withoutPrefix.slice(lastColon + 1).trim();
|
||||
if (!boundPort || isNaN(Number(boundPort))) {
|
||||
settle(() =>
|
||||
reject(new Error(`Could not parse port from READY signal: ${readyLine}`)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe /health at the bound address to confirm the server is reachable
|
||||
try {
|
||||
const res = await fetch(`http://${signalHost}:${boundPort}/health`);
|
||||
if (res.status === 200) {
|
||||
settle(resolve);
|
||||
} else {
|
||||
settle(() => reject(new Error(`/health returned ${res.status}, expected 200`)));
|
||||
}
|
||||
} catch (err) {
|
||||
settle(() =>
|
||||
reject(
|
||||
new Error(
|
||||
`eval-server bound to localhost but /health unreachable at ${signalHost}:${boundPort}: ${err}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
if (text.includes('unknown option') || text.includes('error: unknown')) {
|
||||
settle(() => reject(new Error(`eval-server rejected --host flag:\n${text}`)));
|
||||
}
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
settle(() =>
|
||||
reject(new Error('eval-server --host localhost did not emit READY signal within 30s')),
|
||||
);
|
||||
}, 30000);
|
||||
});
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}, 35000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
42
gitnexus/test/unit/eval-server-bind-restriction.test.ts
Normal file
42
gitnexus/test/unit/eval-server-bind-restriction.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* Inline copy of the production regex predicate. The production
|
||||
* function lives inside `cli-e2e.test.ts` as a test-internal helper;
|
||||
* if it later moves to a shared util this test should import that
|
||||
* util directly. For now we duplicate the regex so a precedence
|
||||
* regression in cli-e2e.test.ts fails this unit test as well as the
|
||||
* integration tests.
|
||||
*/
|
||||
function isEvalServerBindRestriction(stderr: string): boolean {
|
||||
return /(?:listen|bind) (?:EPERM|EACCES|EADDRNOTAVAIL|operation not permitted|permission denied)/i.test(
|
||||
stderr,
|
||||
);
|
||||
}
|
||||
|
||||
describe('isEvalServerBindRestriction', () => {
|
||||
describe('matches genuine libuv bind-restriction errors', () => {
|
||||
it.each([
|
||||
['Error: listen EACCES: permission denied 127.0.0.1:80'],
|
||||
['Error: listen EPERM: operation not permitted 0.0.0.0:80'],
|
||||
['Error: listen EADDRNOTAVAIL: address not available 192.168.1.99:0'],
|
||||
['something then bind EACCES: permission denied'],
|
||||
['LISTEN EACCES: permission denied'],
|
||||
])('matches %p', (stderr) => {
|
||||
expect(isEvalServerBindRestriction(stderr)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('does not match unrelated stderr', () => {
|
||||
it.each([
|
||||
["Error: EACCES: permission denied, open '/etc/shadow'"],
|
||||
['Error: ENOENT: no such file or directory'],
|
||||
['sandbox blocked: operation not permitted'],
|
||||
['GITNEXUS_EVAL_SERVER_READY:127.0.0.1:5173'],
|
||||
[''],
|
||||
['unknown option --host'],
|
||||
])('does not match %p', (stderr) => {
|
||||
expect(isEvalServerBindRestriction(stderr)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue