From 8bfb4024f6e79841d0aa9ea97a0a20f246cbf28b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 06:18:15 +0100 Subject: [PATCH] test: harden qa parity config cleanup --- .../src/browser/chrome.internal.test.ts | 2 +- .../qa-lab/src/suite-runtime-gateway.test.ts | 45 ++++++++- .../qa-lab/src/suite-runtime-gateway.ts | 93 +++++++++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/extensions/browser/src/browser/chrome.internal.test.ts b/extensions/browser/src/browser/chrome.internal.test.ts index 56b3ef83401..8abdc5f382b 100644 --- a/extensions/browser/src/browser/chrome.internal.test.ts +++ b/extensions/browser/src/browser/chrome.internal.test.ts @@ -524,7 +524,7 @@ describe("chrome.ts internal", () => { }); }, run: async (baseUrl) => { - await expect(isChromeCdpReady(baseUrl, 50, 10)).resolves.toBe(true); + await expect(isChromeCdpReady(baseUrl, 500, 100)).resolves.toBe(true); }, }); }); diff --git a/extensions/qa-lab/src/suite-runtime-gateway.test.ts b/extensions/qa-lab/src/suite-runtime-gateway.test.ts index b5fc820b925..c530d265217 100644 --- a/extensions/qa-lab/src/suite-runtime-gateway.test.ts +++ b/extensions/qa-lab/src/suite-runtime-gateway.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { getGatewayRetryAfterMs, isConfigHashConflict } from "./suite-runtime-gateway.js"; +import { + getGatewayRetryAfterMs, + isConfigHashConflict, + isConfigPatchNoopForSnapshot, +} from "./suite-runtime-gateway.js"; describe("qa suite gateway helpers", () => { it("reads retry-after from the primary gateway error before appended logs", () => { @@ -19,4 +23,43 @@ describe("qa suite gateway helpers", () => { expect(getGatewayRetryAfterMs(error)).toBe(null); expect(isConfigHashConflict(error)).toBe(true); }); + + it("detects cleanup config patches that would not change the snapshot", () => { + const config = { + tools: { + profile: "coding", + }, + agents: { + list: [{ id: "qa", model: { primary: "openai/gpt-5.4" } }], + }, + }; + + expect( + isConfigPatchNoopForSnapshot( + config, + JSON.stringify({ + tools: { + deny: null, + }, + }), + ), + ).toBe(true); + }); + + it("keeps changed merge patches eligible for the gateway", () => { + expect( + isConfigPatchNoopForSnapshot( + { + tools: { + deny: ["image_generate"], + }, + }, + JSON.stringify({ + tools: { + deny: null, + }, + }), + ), + ).toBe(false); + }); }); diff --git a/extensions/qa-lab/src/suite-runtime-gateway.ts b/extensions/qa-lab/src/suite-runtime-gateway.ts index e0a1241d242..e36c6c2d4b8 100644 --- a/extensions/qa-lab/src/suite-runtime-gateway.ts +++ b/extensions/qa-lab/src/suite-runtime-gateway.ts @@ -108,6 +108,89 @@ function getGatewayRetryAfterMs(error: unknown) { return null; } +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isObjectWithStringId(value: unknown): value is { id: string } & Record { + return isPlainObject(value) && typeof value.id === "string"; +} + +function applyQaMergePatch(target: unknown, patch: unknown): unknown { + if (Array.isArray(target) && Array.isArray(patch)) { + const merged = target.map((entry) => structuredClone(entry)); + const indexById = new Map(); + for (const [index, entry] of merged.entries()) { + if (isObjectWithStringId(entry)) { + indexById.set(entry.id, index); + } + } + for (const patchEntry of patch) { + if (!isObjectWithStringId(patchEntry)) { + merged.push(structuredClone(patchEntry)); + continue; + } + const existingIndex = indexById.get(patchEntry.id); + if (existingIndex === undefined) { + merged.push(structuredClone(patchEntry)); + indexById.set(patchEntry.id, merged.length - 1); + continue; + } + merged[existingIndex] = applyQaMergePatch(merged[existingIndex], patchEntry); + } + return merged; + } + if (!isPlainObject(patch)) { + return structuredClone(patch); + } + const base = isPlainObject(target) ? structuredClone(target) : {}; + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete base[key]; + continue; + } + base[key] = applyQaMergePatch(base[key], value); + } + return base; +} + +function areJsonValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false; + } + return left.every((entry, index) => areJsonValuesEqual(entry, right[index])); + } + if (isPlainObject(left) || isPlainObject(right)) { + if (!isPlainObject(left) || !isPlainObject(right)) { + return false; + } + const leftKeys = Object.keys(left).toSorted(); + const rightKeys = Object.keys(right).toSorted(); + if (!areJsonValuesEqual(leftKeys, rightKeys)) { + return false; + } + return leftKeys.every((key) => areJsonValuesEqual(left[key], right[key])); + } + return false; +} + +function isConfigPatchNoopForSnapshot(config: Record, raw: string): boolean { + let patch: unknown; + try { + patch = JSON.parse(raw); + } catch { + return false; + } + if (!isPlainObject(patch)) { + return false; + } + return areJsonValuesEqual(applyQaMergePatch(config, patch), config); +} + async function readConfigSnapshot(env: Pick) { const snapshot = (await env.gateway.call( "config.get", @@ -141,6 +224,15 @@ async function runConfigMutation(params: { let lastConflict: unknown = null; for (let attempt = 1; attempt <= 8; attempt += 1) { const snapshot = await readConfigSnapshot(params.env); + if ( + params.action === "config.patch" && + isConfigPatchNoopForSnapshot(snapshot.config, params.raw) + ) { + // QA scenarios do best-effort cleanup in finally blocks. Skipping + // client-known no-op patches keeps that cleanup from burning the + // control-plane write budget and making later capability checks flaky. + return { ok: true, noop: true }; + } try { const result = await params.env.gateway.call( params.action, @@ -235,6 +327,7 @@ export { fetchJson, formatGatewayPrimaryErrorText, getGatewayRetryAfterMs, + isConfigPatchNoopForSnapshot, isConfigHashConflict, isGatewayRestartRace, patchConfig,