Skip to content

Commit 7b794e4

Browse files
committed
test: guard daemon routing metadata drift
1 parent 98f23fe commit 7b794e4

11 files changed

Lines changed: 181 additions & 0 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ Single-context repo. Read `CONTEXT.md` for domain language and testing/architect
7676
- CLI/client/runtime output projection: `src/commands/cli-output.ts`, `src/commands/client-output.ts`, `src/commands/runtime-output.ts`
7777
- Do not reintroduce CLI-shaped command adapters or schemas as a second source of truth. CLI, Node.js, and MCP should project from command contracts.
7878
- Keep `src/daemon/request-router.ts` as request orchestration: auth, diagnostics scope, request admission, locking, handler chain, and fallback dispatch.
79+
- New daemon handler-family commands must update the relevant `DAEMON_COMMAND_GROUPS.*Handler` entry and the handler module's exported `*_COMMAND_HANDLERS` coverage table; `src/daemon/__tests__/request-handler-catalog.test.ts` guards drift and overlap.
7980
- Put request policies in focused request modules:
8081
- tenant/lease/selector/lock admission: `src/daemon/request-admission.ts`
8182
- artifact/error finalization: `src/daemon/request-finalization.ts`
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { listMcpExposedCommandNames } from '../../command-catalog.ts';
4+
import { listCommandMetadataNames, listMcpCommandMetadata } from '../command-metadata.ts';
5+
import { listExecutableCommandNames } from '../command-surface.ts';
6+
7+
test('MCP exposed command names have metadata and executable command definitions', () => {
8+
const mcpExposedNames = listMcpExposedCommandNames().sort();
9+
const mcpMetadataNames = listMcpCommandMetadata()
10+
.map((definition) => definition.name)
11+
.sort();
12+
const metadataNames = new Set<string>(listCommandMetadataNames());
13+
const executableNames = new Set<string>(listExecutableCommandNames());
14+
15+
assert.deepEqual(mcpMetadataNames, mcpExposedNames);
16+
17+
for (const name of mcpExposedNames) {
18+
assert.ok(metadataNames.has(name), `${name} must have command metadata`);
19+
assert.ok(executableNames.has(name), `${name} must have an executable command definition`);
20+
}
21+
});

src/commands/command-metadata.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ export function listMcpCommandMetadata(): AnyCommandMetadata[] {
2929
});
3030
}
3131

32+
export function listCommandMetadataNames(): CommandName[] {
33+
return [...commandMetadataMap.keys()].sort();
34+
}
35+
3236
export function isCommandName(name: string): name is CommandName {
3337
return commandMetadataMap.has(name as CommandName);
3438
}

src/commands/command-surface.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export async function runCommand(
3535
return await getCommandDefinition(name).invoke(client, input);
3636
}
3737

38+
export function listExecutableCommandNames(): CommandName[] {
39+
return [...commandMap.keys()].sort();
40+
}
41+
3842
function getCommandDefinition(name: CommandName): AnyExecutableCommand {
3943
return commandMap.get(name)!;
4044
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { DAEMON_COMMAND_GROUPS, INTERNAL_COMMANDS } from '../../command-catalog.ts';
4+
import { FIND_COMMAND_HANDLERS } from '../handlers/find.ts';
5+
import { INTERACTION_COMMAND_HANDLERS } from '../handlers/interaction.ts';
6+
import { handleLeaseCommands, LEASE_COMMAND_HANDLERS } from '../handlers/lease.ts';
7+
import { REACT_NATIVE_COMMAND_HANDLERS } from '../handlers/react-native.ts';
8+
import { RECORD_TRACE_COMMAND_HANDLERS } from '../handlers/record-trace.ts';
9+
import { SESSION_COMMAND_HANDLERS } from '../handlers/session.ts';
10+
import { SNAPSHOT_COMMAND_HANDLERS } from '../handlers/snapshot.ts';
11+
import { LeaseRegistry } from '../lease-registry.ts';
12+
13+
const handlerFamilies = [
14+
{
15+
name: 'leaseHandler',
16+
commands: DAEMON_COMMAND_GROUPS.leaseHandler,
17+
handlers: LEASE_COMMAND_HANDLERS,
18+
},
19+
{
20+
name: 'sessionHandler',
21+
commands: DAEMON_COMMAND_GROUPS.sessionHandler,
22+
handlers: SESSION_COMMAND_HANDLERS,
23+
},
24+
{
25+
name: 'snapshot',
26+
commands: DAEMON_COMMAND_GROUPS.snapshot,
27+
handlers: SNAPSHOT_COMMAND_HANDLERS,
28+
},
29+
{
30+
name: 'reactNativeHandler',
31+
commands: DAEMON_COMMAND_GROUPS.reactNativeHandler,
32+
handlers: REACT_NATIVE_COMMAND_HANDLERS,
33+
},
34+
{
35+
name: 'recordTraceHandler',
36+
commands: DAEMON_COMMAND_GROUPS.recordTraceHandler,
37+
handlers: RECORD_TRACE_COMMAND_HANDLERS,
38+
},
39+
{
40+
name: 'findHandler',
41+
commands: DAEMON_COMMAND_GROUPS.findHandler,
42+
handlers: FIND_COMMAND_HANDLERS,
43+
},
44+
{
45+
name: 'interactionHandler',
46+
commands: DAEMON_COMMAND_GROUPS.interactionHandler,
47+
handlers: INTERACTION_COMMAND_HANDLERS,
48+
},
49+
] as const;
50+
51+
test('daemon handler routing groups match handler coverage', () => {
52+
for (const { name, commands, handlers } of handlerFamilies) {
53+
assert.deepEqual(
54+
Object.keys(handlers).sort(),
55+
[...commands].sort(),
56+
`${name} catalog must match its handler module`,
57+
);
58+
}
59+
});
60+
61+
test('lease handler coverage table points at executable commands', async () => {
62+
const leaseRegistry = new LeaseRegistry();
63+
const allocated = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-a' });
64+
65+
for (const command of Object.keys(LEASE_COMMAND_HANDLERS)) {
66+
const response = await handleLeaseCommands({
67+
req: {
68+
command,
69+
token: 'test-token',
70+
session: 'catalog-test',
71+
flags: {
72+
tenant: 'tenant-a',
73+
runId: 'run-a',
74+
...(command === INTERNAL_COMMANDS.leaseAllocate
75+
? {}
76+
: { leaseId: allocated.leaseId }),
77+
},
78+
positionals: [],
79+
},
80+
leaseRegistry,
81+
});
82+
83+
assert.notEqual(response, null, `${command} should be handled by lease handler`);
84+
}
85+
});
86+
87+
test('daemon handler routing groups are disjoint', () => {
88+
const ownerByCommand = new Map<string, string>();
89+
for (const { name, commands } of handlerFamilies) {
90+
for (const command of commands) {
91+
const previousOwner = ownerByCommand.get(command);
92+
assert.equal(
93+
previousOwner,
94+
undefined,
95+
`${command} is routed by both ${previousOwner} and ${name}`,
96+
);
97+
ownerByCommand.set(command, name);
98+
}
99+
}
100+
});

src/daemon/handlers/find.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,14 @@ import { setSessionSnapshot } from '../session-snapshot.ts';
1717
import { errorResponse } from './response.ts';
1818
import { getActiveAndroidSnapshotFreshness } from '../android-snapshot-freshness.ts';
1919
import { dispatchFindReadOnlyViaRuntime } from '../selector-runtime.ts';
20+
import { PUBLIC_COMMANDS } from '../../command-catalog.ts';
2021

2122
export { parseFindArgs } from '../../utils/finders.ts';
2223

24+
export const FIND_COMMAND_HANDLERS = {
25+
[PUBLIC_COMMANDS.find]: true,
26+
} as const satisfies Record<string, true>;
27+
2328
type FindContext = {
2429
req: DaemonRequest;
2530
sessionName: string;

src/daemon/handlers/interaction.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ import {
1616
recoverAndroidBlockingSystemDialog,
1717
} from '../android-system-dialog.ts';
1818

19+
export const INTERACTION_COMMAND_HANDLERS = {
20+
[PUBLIC_COMMANDS.click]: true,
21+
[PUBLIC_COMMANDS.fill]: true,
22+
[PUBLIC_COMMANDS.get]: true,
23+
[PUBLIC_COMMANDS.is]: true,
24+
[PUBLIC_COMMANDS.longPress]: true,
25+
[PUBLIC_COMMANDS.press]: true,
26+
[PUBLIC_COMMANDS.type]: true,
27+
} as const satisfies Record<string, true>;
28+
1929
export async function handleInteractionCommands(
2030
params: InteractionHandlerParams,
2131
): Promise<DaemonResponse | null> {

src/daemon/handlers/lease.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import type { DaemonRequest, DaemonResponse } from '../types.ts';
22
import type { LeaseRegistry } from '../lease-registry.ts';
33
import { resolveLeaseScope } from '../lease-context.ts';
4+
import { INTERNAL_COMMANDS } from '../../command-catalog.ts';
45

56
type LeaseHandlerArgs = {
67
req: DaemonRequest;
78
leaseRegistry: LeaseRegistry;
89
};
910

11+
export const LEASE_COMMAND_HANDLERS = {
12+
[INTERNAL_COMMANDS.leaseAllocate]: true,
13+
[INTERNAL_COMMANDS.leaseHeartbeat]: true,
14+
[INTERNAL_COMMANDS.leaseRelease]: true,
15+
} as const satisfies Record<string, true>;
16+
1017
export async function handleLeaseCommands(args: LeaseHandlerArgs): Promise<DaemonResponse | null> {
1118
const { req, leaseRegistry } = args;
1219
const leaseScope = resolveLeaseScope(req);

src/daemon/handlers/react-native.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import { captureSnapshotForSession } from './interaction-snapshot.ts';
1515
import { finalizeTouchInteraction, type InteractionHandlerParams } from './interaction-common.ts';
1616
import { readSnapshotNodesReferenceFrame } from './interaction-touch-reference-frame.ts';
1717

18+
export const REACT_NATIVE_COMMAND_HANDLERS = {
19+
[PUBLIC_COMMANDS.reactNative]: true,
20+
} as const satisfies Record<string, true>;
21+
1822
export async function handleReactNativeCommands(
1923
params: InteractionHandlerParams,
2024
): Promise<DaemonResponse | null> {

src/daemon/handlers/record-trace.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import type { DaemonRequest, DaemonResponse } from '../types.ts';
55
import { SessionStore } from '../session-store.ts';
66
import { handleRecordCommand } from './record-trace-recording.ts';
77
import { errorResponse } from './response.ts';
8+
import { PUBLIC_COMMANDS } from '../../command-catalog.ts';
9+
10+
export const RECORD_TRACE_COMMAND_HANDLERS = {
11+
[PUBLIC_COMMANDS.record]: true,
12+
[PUBLIC_COMMANDS.trace]: true,
13+
} as const satisfies Record<string, true>;
814

915
export async function handleRecordTraceCommands(params: {
1016
req: DaemonRequest;

0 commit comments

Comments
 (0)