feat(gateway): accept WebSocket request trace context (#113189)

* feat(gateway): accept WebSocket request trace context

* test(gateway): prove WebSocket trace isolation

* test(gateway): simplify traced response match

* ci: allow iOS screenshot validation to finish

* fix(gateway): keep traced request failures scoped

* test(ci): align iOS screenshot timeout contract

* test(ui): reset config route location

* ci: scope iOS screenshots to native changes

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Yue Fei
2026-07-28 10:15:05 -07:00
committed by GitHub
parent f43064d48b
commit 1b4a465ea1
13 changed files with 526 additions and 24 deletions
+3 -3
View File
@@ -3171,7 +3171,7 @@ jobs:
needs: [preflight]
if: needs.preflight.outputs.run_ios_build == 'true'
runs-on: ${{ (github.event_name == 'workflow_dispatch' || github.run_attempt > 1) && 'macos-26' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 75
timeout-minutes: 150
env:
HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }}
steps:
@@ -3316,11 +3316,11 @@ jobs:
retention-days: 14
- name: Capture iOS release screenshots
if: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && env.HISTORICAL_TARGET != 'true' }}
if: ${{ (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && needs.preflight.outputs.run_macos == 'true')) && env.HISTORICAL_TARGET != 'true' }}
run: pnpm ios:screenshots
- name: Upload iOS release screenshot evidence
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && env.HISTORICAL_TARGET != 'true' }}
if: ${{ always() && (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && needs.preflight.outputs.run_macos == 'true')) && env.HISTORICAL_TARGET != 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ios-release-screenshots-${{ needs.preflight.outputs.checkout_revision }}
@@ -23,6 +23,7 @@ data class GatewayRequestFrame(
val id: String,
val method: String,
val params: JsonElement? = null,
val traceparent: String? = null,
)
@Serializable
@@ -1179,17 +1179,20 @@ public struct RequestFrame: Codable, Sendable {
public let id: String
public let method: String
public let params: AnyCodable?
public let traceparent: String?
public init(
type: String,
id: String,
method: String,
params: AnyCodable? = nil)
params: AnyCodable? = nil,
traceparent: String? = nil)
{
self.type = type
self.id = id
self.method = method
self.params = params
self.traceparent = traceparent
}
private enum CodingKeys: String, CodingKey {
@@ -1197,6 +1200,7 @@ public struct RequestFrame: Codable, Sendable {
case id
case method
case params
case traceparent
}
}
+1
View File
@@ -3851,6 +3851,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Signals exported
- H2: Configuration reference
- H3: Environment variables
- H2: Continue an upstream WebSocket trace
- H2: Privacy and content capture
- H2: Sampling and flushing
- H3: Model-call observation units
+33
View File
@@ -114,6 +114,39 @@ stdout, or `both` for both.
| `OTEL_SEMCONV_STABILITY_OPT_IN` | Set to `gen_ai_latest_experimental` to emit the latest GenAI inference span shape: `{gen_ai.operation.name} {gen_ai.request.model}` span names, `CLIENT` span kind, and `gen_ai.provider.name` instead of the legacy `gen_ai.system`. GenAI metrics always use bounded, low-cardinality attributes regardless. |
| `OPENCLAW_OTEL_PRELOADED` | Set to `1` when another preload or host process already registered the global OpenTelemetry SDK. The plugin then skips its own NodeSDK lifecycle but still wires diagnostic listeners and honors `traces`/`metrics`/`logs`. |
## Continue an upstream WebSocket trace
An authenticated Gateway WebSocket client can attach a W3C `traceparent` to
each request frame:
```json
{
"type": "req",
"id": "eval-item-42",
"method": "agent",
"params": {},
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
```
The Gateway creates a child request context that preserves the upstream trace
ID and sampling flags. Agent, harness, model-call, and provider spans created
inside the request remain on that trace. This allows a local experiment runner
to create one Langfuse/OpenTelemetry trace per dataset item and correlate the
corresponding OpenClaw execution.
Trace context is request-scoped, not connection-scoped. On a long-lived
WebSocket, generate or inject the appropriate `traceparent` independently for
every RPC. Concurrent requests remain isolated even when their work
interleaves.
The field is accepted only after the existing Gateway authentication handshake
and does not affect authentication or method authorization. A `traceparent` on
the initial `connect` frame is ignored. Missing or syntactically malformed
values within the 128-character field limit silently fall back to a fresh
request trace; longer values make the request frame invalid. `tracestate` and
`baggage` are not accepted by the Gateway WebSocket protocol.
## Privacy and content capture
Raw model/tool content is **not** exported by default. Spans carry bounded
+10 -1
View File
@@ -45,10 +45,19 @@ that supervise the Gateway as a child process, see
Frame shapes:
- Request: `{type:"req", id, method, params}`
- Request: `{type:"req", id, method, params, traceparent?}`
- Response: `{type:"res", id, ok, payload|error}`
- Event: `{type:"event", event, payload, seq?, stateVersion?}`
After authentication, a client may include a W3C `traceparent` string on each
request frame. The Gateway continues a valid value as a child trace context for
that request. Missing or syntactically malformed values within the
128-character field limit keep the default fresh request trace and do not fail
the RPC; longer values make the request frame invalid. The initial `connect`
request never establishes trace context for later frames. Use a separate
`traceparent` for each logical request on a long-lived connection; do not treat
the WebSocket itself as one trace.
Response errors use `{ code, message, details?, retryable?, retryAfterMs? }`.
Clients should branch on `code` and `details.code`; `message` remains human-readable
and can change except where a compatibility note says otherwise. Method-level
@@ -89,6 +89,24 @@ describe("protocol export registries", () => {
});
describe("lazy protocol validators", () => {
it("accepts bounded request-frame trace context metadata", () => {
const request = {
type: "req",
id: "request-1",
method: "status.summary",
params: {},
};
expect(protocol.validateRequestFrame(request)).toBe(true);
expect(
protocol.validateRequestFrame({
...request,
traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
}),
).toBe(true);
expect(protocol.validateRequestFrame({ ...request, traceparent: "x".repeat(129) })).toBe(false);
});
it("validates through exported lazy validators", () => {
expect(validateCommandsListParams({})).toBe(true);
expect(validateCommandsListParams({ includeArgs: true })).toBe(true);
@@ -158,6 +158,7 @@ export const RequestFrameSchema = closedObject({
id: NonEmptyString,
method: NonEmptyString,
params: Type.Optional(Type.Unknown()),
traceparent: Type.Optional(Type.String({ maxLength: 128 })),
});
/** Server response frame envelope paired with a prior request id. */
@@ -0,0 +1,358 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import {
createDiagnosticTraceContext,
getActiveDiagnosticTraceContext,
runWithDiagnosticTraceContext,
type DiagnosticTraceContext,
} from "../../../infra/diagnostic-trace-context.js";
import { createEmptyPluginRegistry } from "../../../plugins/registry-empty.js";
import {
connectOk,
getFreePort,
installGatewayTestHooks,
onceMessage,
startGatewayServer,
trackConnectChallengeNonce,
} from "../../test-helpers.js";
import {
resetTestPluginRegistry,
setTestPluginRegistry,
} from "../../test-helpers.plugin-registry.js";
import type { GatewayWsClient } from "../ws-types.js";
import { createGatewayAuthenticatedRequestDispatcher } from "./authenticated-request-dispatch.js";
import type { GatewayWsMessageHandlerParams } from "./message-handler-types.js";
const TRACEPARENTS = {
first: "00-11111111111111111111111111111111-1111111111111111-01",
second: "00-22222222222222222222222222222222-2222222222222222-00",
} as const;
installGatewayTestHooks({ scope: "suite" });
function createClient(): GatewayWsClient {
return {
socket: {} as WebSocket,
connect: {
minProtocol: 1,
maxProtocol: 1,
client: {
id: "gateway-client",
version: "dev",
platform: "test",
mode: "backend",
},
role: "operator",
scopes: ["operator.admin"],
},
connId: "conn-trace-test",
usesSharedGatewayAuth: false,
};
}
function createDispatcher(
handler: NonNullable<GatewayWsMessageHandlerParams["extraHandlers"][string]>,
) {
const send = vi.fn();
const logGateway = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const dispatcher = createGatewayAuthenticatedRequestDispatcher({
handler: {
connId: "conn-trace-test",
extraHandlers: { "test.trace": handler },
buildRequestContext: () => ({}) as never,
send,
close: vi.fn(),
isClosed: () => false,
setCloseCause: vi.fn(),
logGateway,
} as unknown as GatewayWsMessageHandlerParams,
isWebchatConnect: () => false,
});
return { dispatcher, logGateway, send };
}
async function dispatchInFreshMessageScope(
dispatcher: ReturnType<typeof createDispatcher>["dispatcher"],
client: GatewayWsClient,
id: string,
traceparent?: string,
): Promise<void> {
await runWithDiagnosticTraceContext(createDiagnosticTraceContext(), () =>
dispatcher.dispatch(
{
type: "req",
id,
method: "test.trace",
params: {},
...(traceparent ? { traceparent } : {}),
},
client,
),
);
}
async function openAuthenticatedTraceSocket(params: {
port: number;
token: string;
connectTraceparent: string;
}): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${params.port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve, reject) => {
const onOpen = () => {
ws.off("error", onError);
resolve();
};
const onError = (error: Error) => {
ws.off("open", onOpen);
reject(error);
};
ws.once("open", onOpen);
ws.once("error", onError);
});
try {
await connectOk(ws, {
token: params.token,
traceparent: params.connectTraceparent,
});
return ws;
} catch (error) {
ws.terminate();
throw error;
}
}
async function sendTraceRequest(
ws: WebSocket,
id: string,
traceparent?: string,
): Promise<{ ok: boolean }> {
const response = onceMessage<{ type: "res"; id: string; ok: boolean }>(
ws,
(value) => value.type === "res" && value.id === id,
);
ws.send(
JSON.stringify({
type: "req",
id,
method: "test.trace",
params: {},
...(traceparent ? { traceparent } : {}),
}),
);
return await response;
}
describe("authenticated WebSocket request trace dispatch", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("continues a valid upstream trace as a child context", async () => {
let observed: DiagnosticTraceContext | undefined;
const { dispatcher } = createDispatcher(() => {
observed = getActiveDiagnosticTraceContext();
});
await dispatchInFreshMessageScope(dispatcher, createClient(), "first", TRACEPARENTS.first);
await vi.waitFor(() => {
expect(observed).toBeDefined();
});
expect(observed).toMatchObject({
traceId: "11111111111111111111111111111111",
parentSpanId: "1111111111111111",
traceFlags: "01",
});
expect(observed?.spanId).not.toBe("1111111111111111");
});
it("keeps handler failure logging and responses inside the request trace", async () => {
let loggedContext: DiagnosticTraceContext | undefined;
let responseContext: DiagnosticTraceContext | undefined;
const { dispatcher, logGateway, send } = createDispatcher(async () => {
throw new Error("expected trace failure");
});
logGateway.error.mockImplementation(() => {
loggedContext = getActiveDiagnosticTraceContext();
});
send.mockImplementation(() => {
responseContext = getActiveDiagnosticTraceContext();
});
await dispatchInFreshMessageScope(dispatcher, createClient(), "failure", TRACEPARENTS.first);
await vi.waitFor(() => {
expect(logGateway.error).toHaveBeenCalled();
expect(send).toHaveBeenCalled();
});
expect(loggedContext).toMatchObject({
traceId: "11111111111111111111111111111111",
parentSpanId: "1111111111111111",
traceFlags: "01",
});
expect(responseContext).toEqual(loggedContext);
});
it("retains fresh roots for missing and malformed traceparent values", async () => {
const observed = new Map<string, DiagnosticTraceContext | undefined>();
const { dispatcher } = createDispatcher(({ req }) => {
observed.set(req.id, getActiveDiagnosticTraceContext());
});
const client = createClient();
await dispatchInFreshMessageScope(dispatcher, client, "missing");
await vi.waitFor(() => {
expect(observed.has("missing")).toBe(true);
});
await dispatchInFreshMessageScope(
dispatcher,
client,
"malformed",
"00-11111111111111111111111111111111-1111111111111111-zz",
);
await vi.waitFor(() => {
expect(observed.has("malformed")).toBe(true);
});
const missing = observed.get("missing");
const malformed = observed.get("malformed");
expect(missing).toBeDefined();
expect(malformed).toBeDefined();
expect(missing?.traceId).not.toBe("11111111111111111111111111111111");
expect(malformed?.traceId).not.toBe("11111111111111111111111111111111");
expect(missing?.traceId).not.toBe(malformed?.traceId);
});
it("isolates concurrent request contexts on one connection", async () => {
let releaseRequests: (() => void) | undefined;
const requestBarrier = new Promise<void>((resolve) => {
releaseRequests = resolve;
});
const observed = new Map<
string,
{ before: DiagnosticTraceContext | undefined; after?: DiagnosticTraceContext }
>();
const { dispatcher } = createDispatcher(async ({ req }) => {
const observation: {
before: DiagnosticTraceContext | undefined;
after?: DiagnosticTraceContext;
} = { before: getActiveDiagnosticTraceContext() };
observed.set(req.id, observation);
await requestBarrier;
observation.after = getActiveDiagnosticTraceContext();
});
const client = createClient();
await Promise.all([
dispatchInFreshMessageScope(dispatcher, client, "first", TRACEPARENTS.first),
dispatchInFreshMessageScope(dispatcher, client, "second", TRACEPARENTS.second),
]);
await vi.waitFor(() => {
expect(observed.size).toBe(2);
});
releaseRequests?.();
await vi.waitFor(() => {
expect([...observed.values()].every((entry) => entry.after)).toBe(true);
});
expect(observed.get("first")?.before?.traceId).toBe("11111111111111111111111111111111");
expect(observed.get("second")?.before?.traceId).toBe("22222222222222222222222222222222");
expect(observed.get("first")?.after).toEqual(observed.get("first")?.before);
expect(observed.get("second")?.after).toEqual(observed.get("second")?.before);
});
it("preserves request isolation through a real authenticated WebSocket session", async () => {
const observed = new Map<
string,
{ before: DiagnosticTraceContext | undefined; after?: DiagnosticTraceContext }
>();
let requestBarrier: Promise<void> | undefined;
let releaseRequests: (() => void) | undefined;
const registry = createEmptyPluginRegistry();
registry.gatewayHandlers["test.trace"] = async ({ req, respond }) => {
const observation: {
before: DiagnosticTraceContext | undefined;
after?: DiagnosticTraceContext;
} = { before: getActiveDiagnosticTraceContext() };
observed.set(req.id, observation);
await requestBarrier;
observation.after = getActiveDiagnosticTraceContext();
respond(true, { traced: true });
};
setTestPluginRegistry(registry);
const token = "gateway-request-trace-test-token";
const port = await getFreePort();
const server = await startGatewayServer(port, {
auth: { mode: "token", token },
bind: "loopback",
controlUiEnabled: false,
});
let ws: WebSocket | undefined;
try {
ws = await openAuthenticatedTraceSocket({
port,
token,
connectTraceparent: TRACEPARENTS.first,
});
await expect(sendTraceRequest(ws, "untraced-after-connect")).resolves.toMatchObject({
ok: true,
});
await expect(
sendTraceRequest(
ws,
"malformed",
"00-11111111111111111111111111111111-1111111111111111-zz",
),
).resolves.toMatchObject({ ok: true });
const afterConnect = observed.get("untraced-after-connect")?.before;
const malformed = observed.get("malformed")?.before;
expect(afterConnect).toBeDefined();
expect(malformed).toBeDefined();
expect(afterConnect?.traceId).not.toBe("11111111111111111111111111111111");
expect(malformed?.traceId).not.toBe("11111111111111111111111111111111");
expect(malformed?.traceId).not.toBe(afterConnect?.traceId);
requestBarrier = new Promise<void>((resolve) => {
releaseRequests = resolve;
});
const first = sendTraceRequest(ws, "concurrent-first", TRACEPARENTS.first);
const second = sendTraceRequest(ws, "concurrent-second", TRACEPARENTS.second);
await vi.waitFor(() => {
expect(observed.has("concurrent-first")).toBe(true);
expect(observed.has("concurrent-second")).toBe(true);
});
releaseRequests?.();
await expect(Promise.all([first, second])).resolves.toMatchObject([
{ ok: true },
{ ok: true },
]);
const firstObservation = observed.get("concurrent-first");
const secondObservation = observed.get("concurrent-second");
expect(firstObservation?.before).toMatchObject({
traceId: "11111111111111111111111111111111",
parentSpanId: "1111111111111111",
traceFlags: "01",
});
expect(secondObservation?.before).toMatchObject({
traceId: "22222222222222222222222222222222",
parentSpanId: "2222222222222222",
traceFlags: "00",
});
expect(firstObservation?.after).toEqual(firstObservation?.before);
expect(secondObservation?.after).toEqual(secondObservation?.before);
} finally {
ws?.terminate();
await server.close();
resetTestPluginRegistry();
}
});
});
@@ -5,6 +5,11 @@ import {
formatValidationErrors,
validateRequestFrame,
} from "../../../../packages/gateway-protocol/src/index.js";
import {
createChildDiagnosticTraceContext,
parseDiagnosticTraceparent,
runWithDiagnosticTraceContext,
} from "../../../infra/diagnostic-trace-context.js";
import { formatForLog, logWs } from "../../ws-log.js";
import type { GatewayWsClient } from "../ws-types.js";
import type { GatewayWsMessageHandlerParams } from "./message-handler-types.js";
@@ -137,21 +142,31 @@ export function createGatewayAuthenticatedRequestDispatcher(params: {
});
};
const requestDispatch = (async () => {
const { handleGatewayRequest } = await import("../../server-methods.js");
await handleGatewayRequest({
req,
respond,
client,
isWebchatConnect: params.isWebchatConnect,
extraHandlers,
methodRegistry: getMethodRegistry?.(),
context: buildRequestContext(),
});
})().catch((err: unknown) => {
logGateway.error(`request handler failed: ${formatForLog(err)}`);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
});
const executeRequest = async () => {
try {
const { handleGatewayRequest } = await import("../../server-methods.js");
await handleGatewayRequest({
req,
respond,
client,
isWebchatConnect: params.isWebchatConnect,
extraHandlers,
methodRegistry: getMethodRegistry?.(),
context: buildRequestContext(),
});
} catch (err) {
// Failure diagnostics and responses belong to the same request trace as the handler.
logGateway.error(`request handler failed: ${formatForLog(err)}`);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
};
const upstreamTrace = parseDiagnosticTraceparent(req.traceparent);
const requestDispatch = upstreamTrace
? runWithDiagnosticTraceContext(
createChildDiagnosticTraceContext(upstreamTrace),
executeRequest,
)
: executeRequest();
if (DEVICE_CREDENTIAL_INVALIDATING_METHODS.has(req.method)) {
const barrier = requestDispatch.finally(() => {
if (deviceCredentialMutationBarrier === barrier) {
@@ -10,6 +10,10 @@ import {
resetDiagnosticEventsForTest,
type DiagnosticSecurityEvent,
} from "../../../infra/diagnostic-events.js";
import {
getActiveDiagnosticTraceContext,
type DiagnosticTraceContext,
} from "../../../infra/diagnostic-trace-context.js";
import { setAvatar } from "../../../state/user-profiles.js";
import { withOpenClawTestState } from "../../../test-utils/openclaw-test-state.js";
import { mintAgentRuntimeIdentityToken } from "../../agent-runtime-identity-token.js";
@@ -282,23 +286,30 @@ function attachGatewayHarness(options: {
logWsControl,
send,
socketSend,
sendRequest: (id: string, method: string, params: Record<string, unknown> = {}) => {
sendRequest: (
id: string,
method: string,
params: Record<string, unknown> = {},
traceparent?: string,
) => {
sendMessage(
JSON.stringify({
type: "req",
id,
method,
params,
...(traceparent ? { traceparent } : {}),
}),
);
},
sendConnect: (id: string, params: Record<string, unknown>) => {
sendConnect: (id: string, params: Record<string, unknown>, traceparent?: string) => {
sendMessage(
JSON.stringify({
type: "req",
id,
method: "connect",
params,
...(traceparent ? { traceparent } : {}),
}),
);
},
@@ -308,6 +319,54 @@ function attachGatewayHarness(options: {
};
}
describe("WebSocket request trace context", () => {
const upstreamTraceId = "4bf92f3577b34da6a3ce929d0e0e4736";
const upstreamSpanId = "00f067aa0ba902b7";
const upstreamTraceparent = `00-${upstreamTraceId}-${upstreamSpanId}-01`;
beforeEach(() => {
vi.clearAllMocks();
});
it("does not carry connect-frame trace context into later requests", async () => {
let observed: DiagnosticTraceContext | undefined;
vi.mocked(handleGatewayRequest).mockImplementation(async () => {
observed = getActiveDiagnosticTraceContext();
});
const harness = attachGatewayHarness({
connId: "conn-connect-trace",
connectNonce: "nonce-connect-trace",
});
harness.sendConnect(
"connect-1",
{
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "gateway-client",
version: "dev",
platform: "test",
mode: "backend",
},
role: "operator",
caps: [],
},
upstreamTraceparent,
);
await waitForFast(() => {
expect(harness.client).not.toBeNull();
});
harness.sendRequest("untraced-1", "status.summary");
await waitForFast(() => {
expect(observed).toBeDefined();
});
expect(observed?.traceId).not.toBe(upstreamTraceId);
});
});
function connectTrustedProxyUser(connId: string) {
loadConfigMock.mockImplementationOnce(() => ({
gateway: {
+2
View File
@@ -1004,6 +1004,7 @@ type ConnectReqOptions = {
prePairDevice?: boolean;
browserOrigin?: string;
timeoutMs?: number;
traceparent?: string;
};
function shouldPrePairTestDevice(params: {
@@ -1193,6 +1194,7 @@ export async function connectReq(
type: "req",
id,
method: "connect",
...(opts?.traceparent ? { traceparent: opts.traceparent } : {}),
params: {
minProtocol: opts?.minProtocol ?? PROTOCOL_VERSION,
maxProtocol: opts?.maxProtocol ?? PROTOCOL_VERSION,
@@ -310,16 +310,17 @@ describe("iOS Fastlane release upload gates", () => {
);
});
it("runs the exact screenshot lane during manual and full release CI", () => {
it("runs the exact screenshot lane during native Apple, manual, and full release CI", () => {
const workflow = readFileSync(ciWorkflowPath, "utf8");
const iosJobStart = workflow.indexOf("\n ios-build:\n");
const iosJobEnd = workflow.indexOf("\n android:\n", iosJobStart);
const iosJob = workflow.slice(iosJobStart, iosJobEnd);
expect(iosJob).toContain("timeout-minutes: 75");
expect(iosJob).toContain("timeout-minutes: 150");
expect(iosJob).toContain("Capture iOS release screenshots");
expect(iosJob).toContain("github.event_name == 'workflow_dispatch'");
expect(iosJob).toContain("github.event_name == 'pull_request'");
expect(iosJob).toContain("needs.preflight.outputs.run_macos == 'true'");
expect(iosJob).toContain("run: pnpm ios:screenshots");
expect(iosJob).toContain("Upload iOS release screenshot evidence");
expect(iosJob).toContain("apps/ios/build/SnapshotTestResults/*.xcresult");