Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions __tests__/arkts-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,13 @@ beforeAll(async () => {

describe('ArkTS attribute-chain resolution precision', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
// Windows refuses to delete a file that still has an open handle, so the
// database has to be closed before the temp tree goes. POSIX unlinks an
// open file happily, which is why this only ever fails on Windows.
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
Expand Down Expand Up @@ -65,7 +71,7 @@ describe('ArkTS attribute-chain resolution precision', () => {
'}\n'
);

const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const fns = cg.getNodesByKind('function');
Expand Down Expand Up @@ -99,7 +105,13 @@ describe('ArkTS attribute-chain resolution precision', () => {

describe('ArkTS ohpm workspace import resolution', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
// Windows refuses to delete a file that still has an open handle, so the
// database has to be closed before the temp tree goes. POSIX unlinks an
// open file happily, which is why this only ever fails on Windows.
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
Expand Down Expand Up @@ -146,7 +158,7 @@ describe('ArkTS ohpm workspace import resolution', () => {
'}\n'
);

const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const classes = cg.getNodesByKind('class');
Expand All @@ -168,7 +180,13 @@ describe('ArkTS ohpm workspace import resolution', () => {

describe('ArkUI state → build() re-render bridge (assignment-gated)', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
// Windows refuses to delete a file that still has an open handle, so the
// database has to be closed before the temp tree goes. POSIX unlinks an
// open file happily, which is why this only ever fails on Windows.
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
Expand Down Expand Up @@ -201,7 +219,7 @@ describe('ArkUI state → build() re-render bridge (assignment-gated)', () => {
'}\n'
);

const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const methods = cg.getNodesByKind('method');
Expand Down Expand Up @@ -229,7 +247,13 @@ describe('ArkUI state → build() re-render bridge (assignment-gated)', () => {

describe('ArkUI @ohos.events.emitter bridge', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
// Windows refuses to delete a file that still has an open handle, so the
// database has to be closed before the temp tree goes. POSIX unlinks an
// open file happily, which is why this only ever fails on Windows.
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
Expand Down Expand Up @@ -266,7 +290,7 @@ describe('ArkUI @ohos.events.emitter bridge', () => {
'}\n'
);

const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const methods = cg.getNodesByKind('method');
Expand Down Expand Up @@ -299,7 +323,7 @@ describe('ArkUI @ohos.events.emitter bridge', () => {
'}\n'
);

const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const fns = cg.getNodesByKind('function');
Expand All @@ -314,7 +338,13 @@ describe('ArkUI @ohos.events.emitter bridge', () => {

describe('ArkUI router bridge (pushUrl literal → @Entry struct)', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
// Windows refuses to delete a file that still has an open handle, so the
// database has to be closed before the temp tree goes. POSIX unlinks an
// open file happily, which is why this only ever fails on Windows.
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
Expand Down Expand Up @@ -343,7 +373,7 @@ describe('ArkUI router bridge (pushUrl literal → @Entry struct)', () => {
'}\n'
);

const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const methods = cg.getNodesByKind('method');
Expand All @@ -362,7 +392,13 @@ describe('ArkUI router bridge (pushUrl literal → @Entry struct)', () => {

describe('ohpm main entry (custom barrel + .ts consumer)', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
// Windows refuses to delete a file that still has an open handle, so the
// database has to be closed before the temp tree goes. POSIX unlinks an
// open file happily, which is why this only ever fails on Windows.
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
Expand Down Expand Up @@ -408,7 +444,7 @@ describe('ohpm main entry (custom barrel + .ts consumer)', () => {
'}\n'
);

const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const classes = cg.getNodesByKind('class');
Expand Down
22 changes: 17 additions & 5 deletions __tests__/cli-ui-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* observable fact rather than a promise.
*/

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { execFileSync, spawn, type ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as http from 'http';
Expand Down Expand Up @@ -221,11 +221,23 @@ describe('codegraph ui — serving', () => {

const markerFile = (): string => path.join(markerDir, 'opened.txt');

/** The opener is async (detached); give it a moment before concluding. */
/**
* The opener is async (detached); give it a moment before concluding.
*
* Waits for CONTENT, not merely for the file to appear. Shell redirection
* creates the target before the command writes into it — `cmd.exe` opens the
* `>` target as it parses the line, ahead of `echo` — so an existence check
* can return a file that is real but still empty. Callers that expect no
* launch still get null: a file that never gains content times out the same
* as one that never appears.
*/
async function waitForMarker(timeoutMs: number): Promise<string | null> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (fs.existsSync(markerFile())) return fs.readFileSync(markerFile(), 'utf-8');
if (fs.existsSync(markerFile())) {
const body = fs.readFileSync(markerFile(), 'utf-8');
if (body.trim() !== '') return body;
}
if (Date.now() > deadline) return null;
await new Promise((r) => setTimeout(r, 50));
}
Expand Down Expand Up @@ -278,11 +290,11 @@ describe('codegraph ui — serving', () => {
}, 60_000);

it('CODEGRAPH_BROWSER=none suppresses the launch like --no-open', async () => {
fs.rmSync(markerFile(), { force: true });
const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: 'none' });
try {
expect((await get(viewer.port, '/')).status).toBe(200);
expect(await waitForMarker(1_000)).toBeNull();
await vi.waitFor(() => expect(viewer.output()).toContain('Open that URL in a browser'));
expect(viewer.output()).not.toContain('Opening your browser');
} finally {
await stopViewer(viewer.child);
}
Expand Down
16 changes: 10 additions & 6 deletions __tests__/frameworks-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,13 @@ describe('Java end-to-end — field-injected bean trace (issue #389)', () => {

describe('JVM FQN imports — end-to-end', () => {
let tmpDir: string | undefined;
let cg: CodeGraph | undefined;
afterEach(() => {
// Windows refuses to delete a file that still has an open handle, so the
// database has to be closed before the temp tree goes. Closing here rather
// than at the end of each test also survives a mid-test assertion failure.
cg?.close();
cg = undefined;
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
tmpDir = undefined;
});
Expand All @@ -749,7 +755,7 @@ describe('JVM FQN imports — end-to-end', () => {
'package com.example.app\n\nimport com.example.Bar\n\nclass App {\n fun run() { Bar().greet() }\n}\n'
);

const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const bar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::Bar');
Expand All @@ -765,8 +771,6 @@ describe('JVM FQN imports — end-to-end', () => {
.getIncomingEdges(bar!.id)
.find((e) => e.kind === 'imports');
expect(reachesBar, 'an imports edge should resolve to Bar via FQN').toBeDefined();

cg.close();
});

it('resolves a Kotlin top-level function import', async () => {
Expand All @@ -780,7 +784,7 @@ describe('JVM FQN imports — end-to-end', () => {
'package com.example.app\n\nimport com.example.util\n\nfun main() { util() }\n'
);

const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const util = cg.getNodesByKind('function').find((n) => n.qualifiedName === 'com.example::util');
Expand All @@ -801,7 +805,7 @@ describe('JVM FQN imports — end-to-end', () => {
'package com.example.app\n\nimport com.example.JavaBar\n\nfun main() { JavaBar().greet() }\n'
);

const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const javaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example::JavaBar');
Expand Down Expand Up @@ -833,7 +837,7 @@ describe('JVM FQN imports — end-to-end', () => {
'package app\n\nimport com.example.beta.Bar\n\nfun b() { Bar().who() }\n'
);

const cg = CodeGraph.initSync(tmpDir);
cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const alphaBar = cg.getNodesByKind('class').find((n) => n.qualifiedName === 'com.example.alpha::Bar');
Expand Down
80 changes: 70 additions & 10 deletions __tests__/mcp-daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { rmTempDir } from './rm-temp';
import { getDaemonSocketPath } from '../src/mcp/daemon-paths';
import { CodeGraphPackageVersion } from '../src/mcp/version';

Expand Down Expand Up @@ -162,6 +163,61 @@ function countListeningLines(root: string): number {
return readDaemonLog(root).split('\n').filter((l) => l.includes('[CodeGraph daemon] Listening on')).length;
}

/**
* Every daemon that reached "Listening", by pid, from the log it writes itself.
*
* The lockfile names one daemon; the log names all of them. Under the
* concurrent-launcher race a second candidate can bind before the first
* candidate's lock is visible to it, and only one of the two ends up in the
* lockfile — so reaping by lockfile alone leaves a live daemon holding the
* database until its idle timeout, long past any teardown.
*/
function listeningPids(root: string): number[] {
const pids = new Set<number>();
for (const m of readDaemonLog(root).matchAll(/Listening on .*?\(pid (\d+)/g)) {
pids.add(Number(m[1]));
}
return [...pids];
}

/**
* Kill every daemon this root ever started, including one that has not started
* yet when the reap begins.
*
* Daemons are detached: there is no handle to close and no exit to await, so
* they have to be found by pid and killed. A single pass is not enough. A
* candidate that is mid-spawn when teardown runs binds a moment later and only
* then writes its "Listening" line, so a one-shot read of the lockfile and log
* cannot see it — and a daemon missed here holds the database open for its full
* idle timeout, which is far longer than any removal is willing to retry.
*
* So this keeps looking until the root has been quiet for several consecutive
* passes with nothing alive, which covers the spawn window rather than assuming
* it has closed. Guards our own pid: the version-mismatch test plants
* `pid: process.pid` in the lockfile, and we must never SIGKILL the worker.
*
* Returns whether the root actually went quiet. The two exits mean opposite
* things — quiet reached is a clean reap, `maxPasses` burned is a daemon that
* kept respawning or refused SIGKILL — and a caller that cannot tell them apart
* reads the second as the first, so the reap can report success on the exact
* run where it did nothing.
*/
async function reapDaemons(root: string, quietPasses = 6, maxPasses = 200): Promise<boolean> {
let quiet = 0;
for (let pass = 0; pass < maxPasses && quiet < quietPasses; pass++) {
const lockPid = readLockPid(root);
const pids = [...new Set([...(lockPid ? [lockPid] : []), ...listeningPids(root)])]
.filter((pid) => pid !== process.pid);
const alive = pids.filter(isAlive);
for (const pid of alive) {
try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
}
quiet = alive.length === 0 ? quiet + 1 : 0;
await new Promise((r) => setTimeout(r, 25));
}
return quiet >= quietPasses;
}

function killTree(...procs: ChildProcessWithoutNullStreams[]): void {
for (const p of procs) {
if (!p.killed) { try { p.kill('SIGKILL'); } catch { /* gone */ } }
Expand All @@ -185,18 +241,22 @@ describe('Shared MCP daemon (issue #411)', () => {
});

afterEach(async () => {
// Registered before the kill, so no exit can land between the two.
const exits = servers.map((s) =>
s.child.exitCode === null && s.child.signalCode === null
? new Promise<void>((resolve) => s.child.once('exit', () => resolve()))
: Promise.resolve()
);
killTree(...servers.map((s) => s.child));
// The daemon is detached (not a tracked child) — reap it explicitly via the
// pid it recorded, so a test can't leak a background daemon. Guard against
// our own pid: the version-mismatch test plants `pid: process.pid` in the
// lockfile, and we must never SIGKILL the vitest worker.
const daemonPid = readLockPid(realRoot);
if (daemonPid && daemonPid !== process.pid && isAlive(daemonPid)) {
try { process.kill(daemonPid, 'SIGKILL'); } catch { /* race */ }
}
await new Promise((r) => setTimeout(r, 50));
await Promise.all(exits);
const reaped = await reapDaemons(realRoot);
servers.length = 0;
fs.rmSync(tempDir, { recursive: true, force: true });
await rmTempDir(tempDir);
// Asserted after the removal so a failed reap still cleans up what it can,
// and surfaces as itself rather than as the EPERM it would cause next.
if (!reaped) {
throw new Error(`reapDaemons exhausted its pass budget on ${realRoot} — a daemon is still alive`);
}
});

it('two invocations share ONE detached daemon; both attach as proxies', async () => {
Expand Down
16 changes: 12 additions & 4 deletions __tests__/mcp-initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { rmTempDir } from './rm-temp';

const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');

Expand Down Expand Up @@ -107,12 +108,19 @@ describe('MCP initialize handshake (issue #172)', () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-init-'));
});

afterEach(() => {
if (child && !child.killed) {
child.kill('SIGKILL');
afterEach(async () => {
// kill() only asks; the child still owns its cwd and the project database
// when it returns, and Windows will not delete either out from under a live
// process. POSIX removes them regardless, which is why CI never sees this.
if (child) {
if (child.exitCode === null && child.signalCode === null) {
const exited = new Promise<void>((resolve) => child!.once('exit', () => resolve()));
child.kill('SIGKILL');
await exited;
}
child = null;
}
fs.rmSync(tempDir, { recursive: true, force: true });
await rmTempDir(tempDir);
});

it('responds to initialize quickly when no .codegraph exists in cwd', async () => {
Expand Down
Loading