mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
test(group): cover the lazy syncGroup import that no test reached (#2802 review)
9ea9676dc turned `GroupService.groupSync`'s `syncGroup` into
`await import('./sync.js')` — this branch's one changed control-flow line in
production code — and nothing exercised it. Every existing test stopped short:
`service.test.ts` returns at the empty-name guard; `group-service-not-found.test.ts`
mocks `loadGroupConfig` to reject and never invokes its `syncGroupMock`;
`group-sync.test.ts` imports `syncGroup` directly, bypassing `GroupService`; and
the startup guard asserts only the negative, that `sync.js` is absent at startup.
`tsc` catches a path typo, but nothing verified the import resolves and hands off
correctly — while every production `group_sync` call goes through that line.
No production change was needed; the reviewed design was sound. This is the
missing coverage.
The happy-path test mocks nothing: it points `GITNEXUS_HOME` at a pool temp dir,
seeds a real `group.yaml`, and calls `groupSync`, so `loadGroupConfig` resolves,
`groupDir` is found, and execution falls through into the REAL `syncGroup`. What
makes a real sync reachable with no indexed repo: an empty registry puts both
members in `missingRepos`, but one declared manifest link still yields
synthetic-UID contracts. It asserts the returned counts AND reads back the
`contracts.json` that real `syncGroup` wrote into `groupDir` via the production
`readContractRegistry`, which pins the option handoff too.
Two further tests use `vi.doMock` to re-evaluate the service against a `sync.js`
whose load throws: one asserts the call rejects with the load failure in its
`cause` chain — so the caller gets a catchable rejection, not a floating
unhandled one — and one asserts both pre-import guards still answer with
`sync.js` unloadable, which is also a structural pin that the module has no
STATIC import of it (a static one would throw at re-import, before any call).
Mutation proofs: pointing the specifier at `./sync-nope.js` turns 2 of 3 red
("Cannot find module .../sync-nope.js ... at GroupService.groupSync
service.ts:349"); aliasing a real-but-wrong export turns 1 red. Restored, all 3
green, and `service.ts` verified byte-identical to HEAD.
Out of scope, stated rather than glossed: the final `isError: true` MCP envelope
is produced above `GroupService` and needs a full `LocalBackend`; the rejection
test is the in-scope half of that claim.
Refs #2802
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d1a5479f5f
commit
a4245119c5
1 changed files with 205 additions and 0 deletions
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* `GroupService.groupSync` reaches `syncGroup` through a DYNAMIC
|
||||
* `await import('./sync.js')`. A static import would drag the six contract
|
||||
* extractors — and, through them, the native tree-sitter binding — onto MCP
|
||||
* server startup, which never syncs; `groupSync` is the module's only consumer.
|
||||
*
|
||||
* That import is the one control-flow line the change added, and EVERY
|
||||
* production `group_sync` call runs it. Mocking `./sync.js` out would prove
|
||||
* nothing about it: the claims worth pinning are that the specifier still
|
||||
* RESOLVES and that the destructured `syncGroup` is the real function. So the
|
||||
* happy path here mocks nothing and drives the real module — mutating the
|
||||
* specifier (`'./sync-nope.js'`) or the destructured name turns it RED.
|
||||
*
|
||||
* Reaching a real `syncGroup` with no indexed repo is what the group.yaml below
|
||||
* is for: `GITNEXUS_HOME` points at an empty temp home, so the registry is
|
||||
* empty and every member repo lands in `missingRepos`, while a declared
|
||||
* manifest link still yields synthetic-UID contracts (the same shape
|
||||
* `manifest-synthetic-impact.test.ts` covers downstream). Every detector is off,
|
||||
* so nothing opens a repo graph.
|
||||
*
|
||||
* The negative direction is covered too: `sync.js` is replaced with a module
|
||||
* whose load THROWS, pinning that the failure surfaces as a rejected
|
||||
* `groupSync()` the MCP dispatch layer can convert into a scoped tool error,
|
||||
* and that the two guards ahead of the import still answer without ever
|
||||
* resolving it.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createTempDirPool } from '../../helpers/temp-dir-pool.js';
|
||||
import { makeGroupToolPort } from '../../unit/group/fixtures.js';
|
||||
import { GroupService } from '../../../src/core/group/service.js';
|
||||
import { readContractRegistry } from '../../../src/core/group/storage.js';
|
||||
|
||||
const tempDirs = createTempDirPool('gn-group-lazy-sync-');
|
||||
|
||||
const GROUP_NAME = 'lazy-sync';
|
||||
const CONTRACT_ID = 'custom::rotateSigningKey';
|
||||
const LOAD_FAILURE = 'simulated ./sync.js load failure';
|
||||
|
||||
/**
|
||||
* A group whose two members are absent from the registry (so `syncGroup`
|
||||
* reports them missing instead of opening a graph) but which declares one
|
||||
* manifest link, the one input a full `syncGroup` turns into contracts without
|
||||
* an indexed repo. `app/frontend` is the link's `from` with `role: consumer`,
|
||||
* so `app/backend` is the provider.
|
||||
*/
|
||||
async function seedGroup(home: string): Promise<string> {
|
||||
const groupDir = path.join(home, 'groups', GROUP_NAME);
|
||||
await fsp.mkdir(groupDir, { recursive: true });
|
||||
await fsp.writeFile(
|
||||
path.join(groupDir, 'group.yaml'),
|
||||
`version: 1
|
||||
name: ${GROUP_NAME}
|
||||
description: ""
|
||||
repos:
|
||||
app/backend: lazy-sync-backend
|
||||
app/frontend: lazy-sync-frontend
|
||||
links:
|
||||
- from: app/frontend
|
||||
to: app/backend
|
||||
type: custom
|
||||
contract: rotateSigningKey
|
||||
role: consumer
|
||||
packages: {}
|
||||
detect:
|
||||
http: false
|
||||
grpc: false
|
||||
thrift: false
|
||||
topics: false
|
||||
shared_libs: false
|
||||
embedding_fallback: false
|
||||
includes: false
|
||||
workspace_deps: false
|
||||
matching:
|
||||
bm25_threshold: 0.7
|
||||
embedding_threshold: 0.65
|
||||
max_candidates_per_step: 3
|
||||
`,
|
||||
'utf8',
|
||||
);
|
||||
return groupDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every message in an error's `cause` chain. The module runner reports a failed
|
||||
* module load through its own error with the original attached as `cause`, and
|
||||
* exactly where it puts it is a runner detail — flattening the chain keeps the
|
||||
* assertion about the failure that happened, not about how vitest wraps it.
|
||||
*/
|
||||
function errorChainText(err: unknown): string {
|
||||
const messages: string[] = [];
|
||||
const seen = new Set<unknown>();
|
||||
let current: unknown = err;
|
||||
while (current instanceof Error && !seen.has(current)) {
|
||||
seen.add(current);
|
||||
messages.push(current.message);
|
||||
current = current.cause;
|
||||
}
|
||||
return messages.join(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
* A `GroupService` from a freshly re-evaluated module graph in which
|
||||
* `./sync.js` cannot be loaded at all. The re-import is what makes this a
|
||||
* statement about the LAZY import: a static one would have thrown here, at
|
||||
* `service.js` load, rather than at the `groupSync` call below.
|
||||
*/
|
||||
async function serviceWithUnloadableSync(home: string): Promise<GroupService> {
|
||||
vi.resetModules();
|
||||
vi.doMock('../../../src/core/group/sync.js', () => {
|
||||
throw new Error(LOAD_FAILURE);
|
||||
});
|
||||
const { GroupService: FreshGroupService } = await import('../../../src/core/group/service.js');
|
||||
return new FreshGroupService(makeGroupToolPort(home));
|
||||
}
|
||||
|
||||
describe('GroupService.groupSync — lazy ./sync.js import', () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock('../../../src/core/group/sync.js');
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('resolves the real ./sync.js and returns the real syncGroup result', async () => {
|
||||
const home = tempDirs.dir();
|
||||
vi.stubEnv('GITNEXUS_HOME', home);
|
||||
const groupDir = await seedGroup(home);
|
||||
|
||||
const result = await new GroupService(makeGroupToolPort(home)).groupSync({
|
||||
name: GROUP_NAME,
|
||||
});
|
||||
|
||||
// Only the REAL syncGroup produces this: two synthetic manifest contracts
|
||||
// (provider + consumer) and their cross-link, with both members reported
|
||||
// missing because the temp registry is empty.
|
||||
expect(result).toMatchObject({
|
||||
contracts: 2,
|
||||
crossLinks: 1,
|
||||
missingRepos: ['app/backend', 'app/frontend'],
|
||||
});
|
||||
|
||||
// `groupDir` reached syncGroup's options too: the registry it wrote there
|
||||
// carries the contracts the returned counts summarize.
|
||||
await expect(readContractRegistry(groupDir)).resolves.toMatchObject({
|
||||
version: 1,
|
||||
missingRepos: ['app/backend', 'app/frontend'],
|
||||
contracts: [
|
||||
{
|
||||
contractId: CONTRACT_ID,
|
||||
role: 'provider',
|
||||
repo: 'app/backend',
|
||||
symbolUid: `manifest::app/backend::${CONTRACT_ID}`,
|
||||
meta: { source: 'manifest' },
|
||||
},
|
||||
{
|
||||
contractId: CONTRACT_ID,
|
||||
role: 'consumer',
|
||||
repo: 'app/frontend',
|
||||
symbolUid: `manifest::app/frontend::${CONTRACT_ID}`,
|
||||
meta: { source: 'manifest' },
|
||||
},
|
||||
],
|
||||
crossLinks: [
|
||||
{
|
||||
contractId: CONTRACT_ID,
|
||||
matchType: 'manifest',
|
||||
from: { repo: 'app/frontend' },
|
||||
to: { repo: 'app/backend' },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a ./sync.js load failure as a rejected groupSync call', async () => {
|
||||
const home = tempDirs.dir();
|
||||
vi.stubEnv('GITNEXUS_HOME', home);
|
||||
await seedGroup(home);
|
||||
const service = await serviceWithUnloadableSync(home);
|
||||
|
||||
// Awaited inside `groupSync`, so the caller (LocalBackend → MCP dispatch)
|
||||
// gets a catchable rejection rather than a floating unhandled one. A
|
||||
// `groupSync` that instead RESOLVED — swallowing the failed import into a
|
||||
// fake success — reports the sentinel and fails this assertion.
|
||||
const outcome = await service.groupSync({ name: GROUP_NAME }).then(
|
||||
() => 'resolved: the failed ./sync.js import did not propagate',
|
||||
(err: unknown) => errorChainText(err),
|
||||
);
|
||||
expect(outcome).toContain(LOAD_FAILURE);
|
||||
});
|
||||
|
||||
it('answers both pre-import guards without resolving ./sync.js', async () => {
|
||||
const home = tempDirs.dir();
|
||||
vi.stubEnv('GITNEXUS_HOME', home);
|
||||
const service = await serviceWithUnloadableSync(home);
|
||||
|
||||
await expect(service.groupSync({ name: ' ' })).resolves.toEqual({
|
||||
error: 'name is required',
|
||||
});
|
||||
await expect(service.groupSync({ name: 'never-configured' })).resolves.toEqual({
|
||||
error: 'Group "never-configured" not found. Run group_list to see configured groups.',
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue