chore(ci): fail CI when gateway events go unhandled by the mobile apps (#100206)

* ci: guard gateway protocol event coverage for iOS/Android clients (#100198)

Adds scripts/check-protocol-event-coverage.mjs, which derives the
server->client event catalog from GATEWAY_EVENTS in
src/gateway/server-methods-list.ts, extracts the events each mobile app
handles from Swift/Kotlin dispatch sites, and fails on gateway events no
client handles unless allowlisted with a reason in
scripts/protocol-event-coverage.allowlist.json. Wired as
pnpm check:protocol-coverage in the CI guards shard.

* fix(ci): scope Kotlin event extraction to handle*Event dispatch functions (#100198)

Bare event == "..." literals in predicate helpers outside the dispatch
path (gatewayEventInvalidatesNodesDevices in NodeRuntime.kt, which has no
production caller) counted as Android coverage, silently masking that
node.pair.requested/resolved have no live handler. Kotlin extraction now
only reads when(event) labels and event comparisons inside fun
handle*Event(...) bodies; node.pair.* moved to the Android allowlist with
a truthful reason. Swift extraction stays tree-wide because consumption
there always reads .event off a received EventFrame.

* fix(android): remove dead node pairing event helper

* fix(ci): preserve protocol allowlist parse errors

* test(ci): align tooling import plan
This commit is contained in:
Peter Steinberger
2026-07-05 03:06:52 -07:00
committed by GitHub
parent 29857e25aa
commit 5af24d16bf
8 changed files with 668 additions and 10 deletions
+1
View File
@@ -1206,6 +1206,7 @@ jobs:
case "$TASK" in
guards)
pnpm check:no-conflict-markers
pnpm check:protocol-coverage
pnpm tool-display:check
pnpm check:host-env-policy:swift
pnpm dup:check:coverage
@@ -3714,8 +3714,6 @@ internal fun parseGatewayNodeApprovalState(raw: String?): GatewayNodeApprovalSta
else -> GatewayNodeApprovalState.Loading
}
internal fun gatewayEventInvalidatesNodesDevices(event: String): Boolean = event == "node.pair.requested" || event == "node.pair.resolved"
internal fun nodeConnectFailureNeedsApprovalRefresh(error: GatewaySession.ErrorShape): Boolean = error.details?.code == "PAIRING_REQUIRED"
internal fun currentNodeCapabilityApproval(
@@ -21,14 +21,6 @@ class GatewayNodeApprovalStateTest {
assertEquals(GatewayNodeApprovalState.Loading, parseGatewayNodeApprovalState("future-state"))
}
@Test
fun nodePairingEventsInvalidateNodeDeviceState() {
assertTrue(gatewayEventInvalidatesNodesDevices("node.pair.requested"))
assertTrue(gatewayEventInvalidatesNodesDevices("node.pair.resolved"))
assertFalse(gatewayEventInvalidatesNodesDevices("device.pair.resolved"))
assertFalse(gatewayEventInvalidatesNodesDevices("exec.approval.resolved"))
}
@Test
fun nodePairingFailuresRefreshNodeDeviceState() {
assertTrue(
+1
View File
@@ -1542,6 +1542,7 @@
"check:no-conflict-markers": "node scripts/check-no-conflict-markers.mjs",
"check:no-runtime-action-load-config": "node scripts/check-no-runtime-action-load-config.mjs",
"check:opengrep-rule-metadata": "node security/opengrep/check-rule-metadata.mjs",
"check:protocol-coverage": "node scripts/check-protocol-event-coverage.mjs",
"check:runtime-sidecar-loaders": "node --import tsx scripts/check-runtime-sidecar-loaders.mjs",
"check:static-import-sccs": "pnpm check:madge-import-cycles",
"check:temp-path-guardrails": "node --import tsx scripts/check-temp-path-guardrails.ts",
+433
View File
@@ -0,0 +1,433 @@
#!/usr/bin/env node
// Guards gateway -> mobile-client event coverage drift.
//
// Source of truth for server->client event names is GATEWAY_EVENTS in
// src/gateway/server-methods-list.ts (the catalog advertised to clients in
// hello-ok `features.events`). packages/gateway-protocol only types the event
// frame envelope (`event: NonEmptyString`), so the gateway catalog is the most
// canonical single list of wire event names.
//
// Client "handled" sets are extracted with deliberately simple parsing over
// the mobile app sources: Swift `switch <x>.event { case "..." }` blocks plus
// `.event == "..."` comparisons, and Kotlin `when (event) { "..." -> }` blocks
// plus `event == "..."` comparisons scoped to `fun handle*Event(...)` bodies
// so predicate helpers outside the dispatch path do not count as coverage. Events a client intentionally does not
// consume live in scripts/protocol-event-coverage.allowlist.json with a
// one-line reason. New gateway events that no client handles (and are not
// allowlisted) fail the check.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const GATEWAY_EVENTS_FILE = "src/gateway/server-methods-list.ts";
const GATEWAY_EVENT_CONSTANTS_FILE = "src/gateway/events.ts";
const ALLOWLIST_FILE = "scripts/protocol-event-coverage.allowlist.json";
// Scan roots per client. The sentinel files are the primary event dispatch
// surfaces; if one moves, the check must fail loudly instead of silently
// passing with an empty handled set.
const IOS_SCAN_ROOTS = ["apps/ios/Sources", "apps/shared/OpenClawKit/Sources"];
const IOS_SENTINEL_FILE = "apps/ios/Sources/Chat/IOSGatewayChatTransport.swift";
const ANDROID_SCAN_ROOT = "apps/android/app/src/main/java/ai/openclaw/app";
const ANDROID_SENTINEL_FILES = [
"apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt",
"apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt",
];
// Minimum plausible catalog size; a partial parse below this means the
// GATEWAY_EVENTS array changed shape and the extractor needs updating.
const MIN_EXPECTED_GATEWAY_EVENTS = 10;
const GATEWAY_EVENTS_BLOCK_RE = /export const GATEWAY_EVENTS = \[([\s\S]*?)\];/u;
const SWIFT_EVENT_SWITCH_RE = /\bswitch\s+\w+(?:\.\w+)*\.event\s*\{/u;
const SWIFT_CASE_LABEL_RE = /^\s*case\s+(.+?):/u;
const KOTLIN_EVENT_WHEN_RE = /\bwhen\s*\(\s*event\s*\)\s*\{/u;
// Kotlin gateway handlers follow the `handle*Event` naming convention
// (handleEvent, handleGatewayEvent, handleExecApprovalGatewayEvent, ...).
// Handlers named differently surface as loud "unhandled" failures, which is
// the safe direction for a coverage gate.
const KOTLIN_HANDLER_FUN_RE = /\bfun\s+handle\w*Event\s*\(/u;
const KOTLIN_CASE_LABEL_RE = /^\s*((?:"[^"]+"\s*,\s*)*"[^"]+")\s*->/u;
const SWIFT_EVENT_COMPARISON_RE = /\.event\s*==\s*"([^"]+)"/gu;
const KOTLIN_EVENT_COMPARISON_RE = /\bevent\s*==\s*"([^"]+)"/gu;
const STRING_LITERAL_RE = /"([^"]+)"/gu;
function isMainModule() {
return process.argv[1] ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) : false;
}
/**
* Extracts the gateway event name list from server-methods-list.ts source.
* Bare identifiers in the array (e.g. GATEWAY_EVENT_UPDATE_AVAILABLE) are
* resolved against constantsSource (src/gateway/events.ts).
*/
export function extractGatewayEventNames(listSource, constantsSource) {
const block = GATEWAY_EVENTS_BLOCK_RE.exec(listSource);
if (!block) {
throw new Error(
`Could not find the GATEWAY_EVENTS array in ${GATEWAY_EVENTS_FILE}. ` +
"If the catalog moved, update scripts/check-protocol-event-coverage.mjs.",
);
}
const body = block[1].replace(/\/\*[\s\S]*?\*\//gu, "").replace(/\/\/[^\n]*/gu, "");
const names = [];
for (const token of body.matchAll(/"([^"]+)"|([A-Za-z_$][\w$]*)/gu)) {
if (token[1]) {
names.push(token[1]);
continue;
}
const identifier = token[2];
const constant = new RegExp(`export const ${identifier} = "([^"]+)"`, "u").exec(
constantsSource,
);
if (!constant) {
throw new Error(
`Could not resolve GATEWAY_EVENTS identifier "${identifier}" from ${GATEWAY_EVENT_CONSTANTS_FILE}.`,
);
}
names.push(constant[1]);
}
if (names.length < MIN_EXPECTED_GATEWAY_EVENTS) {
throw new Error(
`Extracted only ${names.length} gateway events from ${GATEWAY_EVENTS_FILE}; ` +
"the array shape likely changed. Update the extractor.",
);
}
return names;
}
// Neutralizes string literals and line comments so brace counting cannot be
// confused by braces inside strings or comments.
function sanitizeLineForBraces(line) {
return line
.replace(/\\"/gu, "")
.replace(/"[^"]*"/gu, '""')
.replace(/\/\/.*$/u, "");
}
// Collects case-label lines at depth 1 of blocks whose header matches
// headerRe, feeding each into extractLabels. Line-based on purpose: this is a
// drift check, not a parser; sources keep one case label per line.
function collectBlockCaseLabels(source, headerRe, extractLabels) {
const names = [];
const lines = source.split("\n");
for (let i = 0; i < lines.length; i += 1) {
if (!headerRe.test(lines[i])) {
continue;
}
let depth = 0;
for (let j = i; j < lines.length; j += 1) {
const line = lines[j];
if (depth === 1) {
extractLabels(line, names);
}
const braceSource =
j === i
? sanitizeLineForBraces(line.slice(line.indexOf("{")))
: sanitizeLineForBraces(line);
for (const char of braceSource) {
if (char === "{") {
depth += 1;
} else if (char === "}") {
depth -= 1;
}
}
if (j > i && depth <= 0) {
break;
}
}
}
return names;
}
function pushStringLiterals(segment, names) {
for (const literal of segment.matchAll(STRING_LITERAL_RE)) {
names.push(literal[1]);
}
}
/**
* Extracts event names a Swift source handles: string-literal case labels of
* `switch <x>.event` blocks plus `.event == "..."` comparisons. Case labels
* built from constants are invisible to this extractor and need an allowlist
* entry explaining that.
*/
export function extractSwiftHandledEvents(source) {
const names = collectBlockCaseLabels(source, SWIFT_EVENT_SWITCH_RE, (line, sink) => {
const label = SWIFT_CASE_LABEL_RE.exec(line);
if (label) {
pushStringLiterals(label[1], sink);
}
});
for (const comparison of source.matchAll(SWIFT_EVENT_COMPARISON_RE)) {
names.push(comparison[1]);
}
return new Set(names);
}
// Collects the bodies of Kotlin gateway event handler functions
// (`fun handle*Event(...)`). Signatures may span multiple lines, so scan
// forward from the declaration to the first `{` before brace counting.
function extractKotlinHandlerBodies(source) {
const bodies = [];
const lines = source.split("\n");
for (let i = 0; i < lines.length; i += 1) {
if (!KOTLIN_HANDLER_FUN_RE.test(lines[i])) {
continue;
}
let depth = 0;
let opened = false;
const body = [];
for (let j = i; j < lines.length; j += 1) {
const sanitized = sanitizeLineForBraces(lines[j]);
if (opened) {
body.push(lines[j]);
}
for (const char of sanitized) {
if (char === "{") {
depth += 1;
opened = true;
} else if (char === "}") {
depth -= 1;
}
}
if (opened && depth <= 0) {
break;
}
}
if (opened) {
bodies.push(body.join("\n"));
}
}
return bodies;
}
/**
* Extracts event names a Kotlin source handles: string-literal case labels of
* `when (event)` blocks plus `event == "..."` comparisons, both scoped to
* `fun handle*Event(...)` bodies. Scoping matters: bare `event == "..."`
* literals also appear in predicate helpers that are not called from the
* dispatch path (e.g. gatewayEventInvalidatesNodesDevices in NodeRuntime.kt),
* and counting those would silently mark events as covered. Swift extraction
* stays tree-wide because Swift consumption always reads `.event` off a
* received EventFrame, which does not have that false-positive shape.
*/
export function extractKotlinHandledEvents(source) {
const names = [];
for (const body of extractKotlinHandlerBodies(source)) {
names.push(
...collectBlockCaseLabels(body, KOTLIN_EVENT_WHEN_RE, (line, sink) => {
const label = KOTLIN_CASE_LABEL_RE.exec(line);
if (label) {
pushStringLiterals(label[1], sink);
}
}),
);
for (const comparison of body.matchAll(KOTLIN_EVENT_COMPARISON_RE)) {
names.push(comparison[1]);
}
}
return new Set(names);
}
/**
* Compares a client's handled events against the gateway catalog and its
* allowlist. Returns human-readable error strings. Client-only names (e.g. the
* client-synthesized "seqGap" pseudo-event) are intentionally ignored; this
* check only guards the server->client direction.
*/
export function compareEventCoverage(params) {
const { client, serverEvents, handledEvents, allowlist } = params;
const errors = [];
const serverSet = new Set(serverEvents);
for (const event of serverEvents) {
if (handledEvents.has(event) || event in allowlist) {
continue;
}
errors.push(
`[${client}] gateway event "${event}" has no handler and no allowlist entry. ` +
`Handle it in the ${client} app or add it to ${ALLOWLIST_FILE} with a reason.`,
);
}
for (const [event, reason] of Object.entries(allowlist)) {
if (typeof reason !== "string" || reason.trim() === "") {
errors.push(`[${client}] allowlist entry "${event}" needs a non-empty reason string.`);
}
if (!serverSet.has(event)) {
errors.push(
`[${client}] allowlist entry "${event}" is not a gateway event anymore; remove it from ${ALLOWLIST_FILE}.`,
);
} else if (handledEvents.has(event)) {
errors.push(
`[${client}] allowlist entry "${event}" is now handled; remove it from ${ALLOWLIST_FILE}.`,
);
}
}
return errors;
}
function listFilesRecursive(rootDir, extension, fsImpl) {
const files = [];
const queue = [rootDir];
while (queue.length > 0) {
const current = queue.pop();
let entries;
try {
entries = fsImpl.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
// Test sources match the same event literals but are not product
// handlers; including them would mask real coverage gaps.
if (entry.name === "Tests" || entry.name === ".build" || entry.name === "build") {
continue;
}
queue.push(fullPath);
continue;
}
if (entry.isFile() && entry.name.endsWith(extension)) {
files.push(fullPath);
}
}
}
return files.toSorted((left, right) => left.localeCompare(right));
}
function readRequiredFile(rootDir, relativePath, fsImpl) {
const fullPath = path.resolve(rootDir, relativePath);
try {
return fsImpl.readFileSync(fullPath, "utf8");
} catch {
throw new Error(
`Required file ${relativePath} is missing. If it moved, update scripts/check-protocol-event-coverage.mjs.`,
);
}
}
function loadAllowlist(rootDir, fsImpl) {
const raw = readRequiredFile(rootDir, ALLOWLIST_FILE, fsImpl);
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`${ALLOWLIST_FILE} is not valid JSON: ${String(error)}`, { cause: error });
}
for (const client of ["ios", "android"]) {
if (typeof parsed[client] !== "object" || parsed[client] === null) {
throw new Error(`${ALLOWLIST_FILE} must contain an object entry for "${client}".`);
}
}
return parsed;
}
function collectClientHandledEvents(params) {
const { rootDir, roots, extension, extract, sentinels, fsImpl } = params;
const handled = new Set();
for (const root of roots) {
const rootPath = path.resolve(rootDir, root);
if (!fsImpl.existsSync(rootPath)) {
throw new Error(
`Scan root ${root} is missing. If it moved, update scripts/check-protocol-event-coverage.mjs.`,
);
}
for (const filePath of listFilesRecursive(rootPath, extension, fsImpl)) {
for (const event of extract(fsImpl.readFileSync(filePath, "utf8"))) {
handled.add(event);
}
}
}
for (const sentinel of sentinels) {
const source = readRequiredFile(rootDir, sentinel, fsImpl);
if (extract(source).size === 0) {
throw new Error(
`Sentinel dispatch file ${sentinel} no longer matches any event names; ` +
"its event handling likely moved or changed shape. Update scripts/check-protocol-event-coverage.mjs.",
);
}
}
return handled;
}
/**
* Runs the full coverage check against a repo checkout and returns error
* strings plus a summary for logging.
*/
export function collectProtocolEventCoverageErrors(params = {}) {
const rootDir = params.rootDir ?? process.cwd();
const fsImpl = params.fs ?? fs;
const serverEvents = extractGatewayEventNames(
readRequiredFile(rootDir, GATEWAY_EVENTS_FILE, fsImpl),
readRequiredFile(rootDir, GATEWAY_EVENT_CONSTANTS_FILE, fsImpl),
);
const allowlist = loadAllowlist(rootDir, fsImpl);
const clients = [
{
client: "ios",
handledEvents: collectClientHandledEvents({
rootDir,
roots: IOS_SCAN_ROOTS,
extension: ".swift",
extract: extractSwiftHandledEvents,
sentinels: [IOS_SENTINEL_FILE],
fsImpl,
}),
},
{
client: "android",
handledEvents: collectClientHandledEvents({
rootDir,
roots: [ANDROID_SCAN_ROOT],
extension: ".kt",
extract: extractKotlinHandledEvents,
sentinels: ANDROID_SENTINEL_FILES,
fsImpl,
}),
},
];
const errors = [];
const summaryParts = [];
for (const { client, handledEvents } of clients) {
errors.push(
...compareEventCoverage({
client,
serverEvents,
handledEvents,
allowlist: allowlist[client],
}),
);
const handledCount = serverEvents.filter((event) => handledEvents.has(event)).length;
summaryParts.push(
`${client} handles ${handledCount}, allowlists ${Object.keys(allowlist[client]).length}`,
);
}
return {
errors,
summary: `${serverEvents.length} gateway events; ${summaryParts.join("; ")}.`,
};
}
if (isMainModule()) {
let result;
try {
result = collectProtocolEventCoverageErrors();
} catch (error) {
console.error(
`Protocol event coverage check failed: ${error instanceof Error ? error.message : String(error)}`,
);
process.exit(1);
}
if (result.errors.length > 0) {
console.error("Protocol event coverage check failed:");
for (const error of result.errors) {
console.error(` - ${error}`);
}
process.exit(1);
}
console.log(`Protocol event coverage OK: ${result.summary}`);
}
@@ -0,0 +1,43 @@
{
"$comment": "Gateway events each mobile client intentionally does not handle yet, with a one-line reason. Consumed by scripts/check-protocol-event-coverage.mjs (pnpm check:protocol-coverage). Adding a gateway event without a client handler requires either handling it or adding an entry here; the check also fails when an entry goes stale (event removed or now handled).",
"ios": {
"session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.",
"session.tool": "Session tool stream is not rendered by the iOS chat surface yet.",
"sessions.changed": "iOS refreshes session lists via explicit sessions.* requests instead of this push event.",
"presence": "Presence roster is a control-UI (web/desktop) surface; iOS does not render it.",
"shutdown": "iOS relies on socket close plus reconnect/backoff instead of the shutdown notice.",
"heartbeat": "iOS liveness uses tick and WebSocket-level ping; heartbeat is unused.",
"cron": "Cron run activity is not surfaced in the iOS app.",
"node.pair.requested": "Node pairing state is fetched on demand; no push consumer on iOS yet.",
"node.pair.resolved": "Node pairing state is fetched on demand; no push consumer on iOS yet.",
"device.pair.requested": "Device pairing flows poll via device.pair.* methods on iOS.",
"device.pair.resolved": "Device pairing flows poll via device.pair.* methods on iOS.",
"voicewake.routing.changed": "iOS only consumes voicewake.changed trigger updates; routing changes are not surfaced.",
"exec.approval.requested": "Handled in NodeAppModel via ExecApprovalNotificationBridge constants; the literal-only extractor cannot see constant case labels.",
"exec.approval.resolved": "Handled in NodeAppModel via ExecApprovalNotificationBridge constants; the literal-only extractor cannot see constant case labels.",
"plugin.approval.requested": "Plugin approval prompts are not implemented on iOS.",
"plugin.approval.resolved": "Plugin approval prompts are not implemented on iOS.",
"terminal.data": "Embedded terminal is a web/desktop surface; iOS has no terminal client.",
"terminal.exit": "Embedded terminal is a web/desktop surface; iOS has no terminal client.",
"update.available": "Gateway self-update notices do not apply to iOS; app updates ship via the App Store."
},
"android": {
"session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.",
"session.tool": "Session tool stream is not rendered by the Android chat surface yet.",
"presence": "Presence roster is a control-UI (web/desktop) surface; Android does not render it.",
"talk.mode": "Android toggles talk mode locally; gateway talk.mode sync is not consumed.",
"shutdown": "Android relies on socket close plus reconnect/backoff instead of the shutdown notice.",
"heartbeat": "Android liveness uses tick and WebSocket-level ping; heartbeat is unused.",
"cron": "Cron run activity is not surfaced in the Android app.",
"node.pair.requested": "Android's bounded operator session lacks operator.pairing; onboarding refreshes node approval with explicit node.list requests.",
"node.pair.resolved": "Android's bounded operator session lacks operator.pairing; onboarding refreshes node approval with explicit node.list requests.",
"device.pair.requested": "Device pairing flows poll via device.pair.* methods on Android.",
"device.pair.resolved": "Device pairing flows poll via device.pair.* methods on Android.",
"voicewake.changed": "Android reads voicewake state on demand via voicewake.get; no push consumer yet.",
"voicewake.routing.changed": "Android reads voicewake state on demand via voicewake.get; no push consumer yet.",
"plugin.approval.requested": "Plugin approval prompts are not implemented on Android.",
"plugin.approval.resolved": "Plugin approval prompts are not implemented on Android.",
"terminal.data": "Embedded terminal is a web/desktop surface; Android has no terminal client.",
"terminal.exit": "Embedded terminal is a web/desktop surface; Android has no terminal client."
}
}
+1
View File
@@ -892,6 +892,7 @@ describe("test-projects args", () => {
includePatterns: [
"src/scripts/docs-link-audit.test.ts",
"src/scripts/sync-plugin-versions.test.ts",
"test/e2e/qa-lab/runtime/gateway-mcp-real-transports.test.ts",
"test/helpers/temp-dir.test.ts",
"test/scripts/android-pin-version.test.ts",
"test/scripts/bench-cli-startup.test.ts",
@@ -0,0 +1,189 @@
// Check Protocol Event Coverage tests cover gateway event extraction and drift comparison.
import { describe, expect, it } from "vitest";
import {
compareEventCoverage,
extractGatewayEventNames,
extractKotlinHandledEvents,
extractSwiftHandledEvents,
} from "../../scripts/check-protocol-event-coverage.mjs";
const GATEWAY_LIST_FIXTURE = `
export const GATEWAY_EVENTS = [
"connect.challenge",
"chat",
// comment noise
"session.message",
"tick",
"health",
"terminal.data",
"terminal.exit",
"presence",
"cron",
"shutdown",
GATEWAY_EVENT_UPDATE_AVAILABLE,
];
`;
const GATEWAY_CONSTANTS_FIXTURE = `
export const GATEWAY_EVENT_UPDATE_AVAILABLE = "update.available" as const;
`;
describe("extractGatewayEventNames", () => {
it("collects literals and resolves identifiers", () => {
const names = extractGatewayEventNames(GATEWAY_LIST_FIXTURE, GATEWAY_CONSTANTS_FIXTURE);
expect(names).toContain("connect.challenge");
expect(names).toContain("update.available");
expect(names).toHaveLength(11);
});
it("fails loudly when the array is missing", () => {
expect(() => extractGatewayEventNames("export const OTHER = [];", "")).toThrow(
/GATEWAY_EVENTS/,
);
});
it("fails loudly on unresolved identifiers", () => {
expect(() => extractGatewayEventNames(GATEWAY_LIST_FIXTURE, "")).toThrow(
/GATEWAY_EVENT_UPDATE_AVAILABLE/,
);
});
});
describe("extractSwiftHandledEvents", () => {
it("collects switch case literals and comparisons, skipping nested and non-event code", () => {
const source = `
static func mapEventFrame(_ evt: EventFrame) -> Event? {
switch evt.event {
case "tick":
return .tick
case "chat", "session.message":
guard let payload = evt.payload else { return nil }
switch payload.kind {
case "nested.ignored":
return nil
default:
return .chat
}
case SomeBridge.requestedKind:
return .approval
default:
return nil
}
}
func other(_ status: String) {
switch status {
case "ok", "completed":
break
default:
break
}
}
if evt.event == "connect.challenge" { return }
`;
const handled = extractSwiftHandledEvents(source);
expect([...handled].toSorted()).toEqual([
"chat",
"connect.challenge",
"session.message",
"tick",
]);
});
});
describe("extractKotlinHandledEvents", () => {
it("collects when-block case literals and comparisons inside handler functions only", () => {
const source = `
fun handleGatewayEvent(event: String, payloadJson: String?) {
when (event) {
"tick" -> {
scope.launch { pollHealth() }
}
"chat" -> {
when (parseKind(payloadJson)) {
"nested.ignored" -> return
else -> handleChat(payloadJson)
}
}
"sessions.changed", "session.message" -> refresh()
}
}
private fun handleEvent(
frame: JsonObject,
) {
val event = frame["event"].asStringOrNull() ?: return
if (event == "connect.challenge") { return }
val other = keyEvent == "not.a.gateway.event"
}
`;
const handled = extractKotlinHandledEvents(source);
expect([...handled].toSorted()).toEqual([
"chat",
"connect.challenge",
"session.message",
"sessions.changed",
"tick",
]);
});
it("ignores event literals outside handler function bodies", () => {
// Regression guard: predicate helpers that are not called from the
// dispatch path must not count as coverage (false negative for the gate).
const source = `
internal fun gatewayEventInvalidatesNodesDevices(event: String): Boolean = event == "node.pair.requested" || event == "node.pair.resolved"
fun topLevelNotAHandler(event: String) {
if (event == "presence") { render() }
when (event) {
"cron" -> refresh()
}
}
fun handleGatewayEvent(event: String) {
if (event == "tick") { touch() }
}
`;
const handled = extractKotlinHandledEvents(source);
expect([...handled].toSorted()).toEqual(["tick"]);
});
});
describe("compareEventCoverage", () => {
const serverEvents = ["tick", "chat", "presence", "cron"];
it("passes when every event is handled or allowlisted", () => {
const errors = compareEventCoverage({
client: "ios",
serverEvents,
handledEvents: new Set(["tick", "chat", "client.only.synthetic"]),
allowlist: { presence: "not rendered", cron: "not surfaced" },
});
expect(errors).toEqual([]);
});
it("reports unhandled events missing from the allowlist", () => {
const errors = compareEventCoverage({
client: "android",
serverEvents,
handledEvents: new Set(["tick", "chat"]),
allowlist: { presence: "not rendered" },
});
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('[android] gateway event "cron"');
});
it("reports stale allowlist entries", () => {
const errors = compareEventCoverage({
client: "ios",
serverEvents,
handledEvents: new Set(["tick", "chat", "presence"]),
allowlist: {
presence: "now handled, should be removed",
"gone.event": "no longer a gateway event",
cron: "",
},
});
expect(errors.some((error) => error.includes('"presence" is now handled'))).toBe(true);
expect(errors.some((error) => error.includes('"gone.event" is not a gateway event'))).toBe(
true,
);
expect(errors.some((error) => error.includes('"cron" needs a non-empty reason'))).toBe(true);
});
});