mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user