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:
Gergo Magyar 2026-08-03 17:10:06 +00:00
parent d1a5479f5f
commit a4245119c5

View file

@ -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.',
});
});
});