Files
computer-playbook/roles/test-e2e-playwright/files/service-gating.js
kevinveenbirkenbachandClaude Opus 5 7d8e7e87ff test(mcp): prove a switched-off surface is actually gone
Every role-local MCP spec gates itself on the service flag and skips when the surface is off, so nothing ever checked that switching it off removes it. A surface still serving after the flag went false was indistinguishable from one that was never deployed: both read as a green skip. The gating helper had no inverse at all, so there was no way to assert what a disabled service must not do.

A shared assertion now probes the endpoint with both a GET and a POST while the flag is false and fails on any answer that shows something listening. It counts 401 and 403 as listening, which is the point: a refused credential still means the surface is there. The check is transport-agnostic because the roles differ in their enabled-state probes, over SSE, Streamable HTTP or plain JSON-RPC, while an endpoint that is gone answers none of them. Each role passes its endpoint URL as a thunk, since some resolve it only once a test is running.

The coverage lint requires the registration, and derives the role set from meta/mcp.yml rather than from a filename convention, which is how the tenth role surfaced: its spec is not named like the other nine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 00:40:15 +02:00

167 lines
5.5 KiB
JavaScript

/**
* Shared service-gating helper for role-local Playwright specs.
*
* Contract:
* isServiceEnabled("sso") -> boolean
* requireService("sso", testFn) -> wraps a test, calling test.skip() when
* the service is disabled
* isServiceDisabledReason("email") -> "EMAIL_SERVICE_ENABLED=false" style
* diagnostic string, or null if enabled
*
* Sources of truth:
* Per-service boolean env flags named <SERVICE>_SERVICE_ENABLED in
* UPPER_SNAKE_CASE. Values MUST be the literal strings "true" or "false".
* An absent variable is treated as "enabled" so local
* `make compose-playwright role=<role>` runs against a fully-featured
* deploy keep their old behaviour.
*
* Typo-safety:
* A service name that is not declared in the spec's env registry (i.e.
* no <SERVICE>_SERVICE_ENABLED variable was ever rendered for it) MUST
* be treated as a test authoring bug and hard-fail the caller. Otherwise
* `isServiceEnabled("oicd")` would silently disable tests without
* surfacing the typo.
*
* See:
* docs/contributing/artefact/files/role/playwright.specs.js.md
*/
const { test } = require("@playwright/test");
const SUFFIX = "_SERVICE_ENABLED";
function envKey(name) {
if (typeof name !== "string" || name.length === 0) {
throw new Error(`service-gating: service name must be a non-empty string, got ${String(name)}`);
}
return name.toUpperCase().replace(/[^A-Z0-9]+/g, "_") + SUFFIX;
}
function rawValue(name) {
return process.env[envKey(name)];
}
function parseBoolean(raw, key) {
if (raw === undefined) return undefined;
if (raw === "true") return true;
if (raw === "false") return false;
throw new Error(
`service-gating: ${key} must be "true" or "false" (strict), got ${JSON.stringify(raw)}`
);
}
/**
* The env registry is the set of `<SERVICE>_SERVICE_ENABLED` keys that were
* rendered by Ansible into the Playwright env. Any service outside this set
* is considered unknown and using it throws.
*/
function registeredServices() {
const registered = new Set();
for (const key of Object.keys(process.env)) {
if (key.endsWith(SUFFIX)) {
const serviceSlug = key.slice(0, -SUFFIX.length);
if (serviceSlug.length > 0) {
registered.add(serviceSlug.toLowerCase());
}
}
}
return registered;
}
function assertKnownService(name) {
const registered = registeredServices();
if (registered.size === 0) {
// No <SERVICE>_SERVICE_ENABLED at all in the env — assume an older
// staged .env without service-gate flags. Treat every service as
// enabled and let individual lookups fall through to the default.
return;
}
const slug = name.toLowerCase();
if (!registered.has(slug)) {
throw new Error(
`service-gating: Unknown service "${name}". ` +
`Declare ${envKey(name)} in the role's templates/playwright.env.j2 ` +
`(see docs/contributing/artefact/files/role/playwright.specs.js.md#service-gating).`
);
}
}
function isServiceEnabled(name) {
assertKnownService(name);
const key = envKey(name);
const parsed = parseBoolean(rawValue(name), key);
return parsed === undefined ? true : parsed;
}
function isServiceDisabledReason(name) {
if (isServiceEnabled(name)) return null;
return `${envKey(name)}=false`;
}
/**
* Skip the current test when the named service is disabled. Call this at
* the top of a test body, before Playwright has performed any action:
*
* test("foo", async ({ page }) => {
* skipUnlessServiceEnabled("sso");
* // real assertions below
* });
*
* This API exists in addition to `requireService` because Playwright's
* fixture system validates test function signatures at parse time and
* rejects wrapper functions whose first argument is not a destructuring
* pattern ("First argument must use the object destructuring pattern").
* Calling `skipUnlessServiceEnabled` from inside the body keeps the
* original arrow-with-destructuring signature Playwright insists on.
*/
function skipUnlessServiceEnabled(name) {
const reason = isServiceDisabledReason(name);
if (reason !== null) {
test.skip(true, reason);
}
}
/**
* Skip the current test unless the named service is disabled, so a spec can
* assert what a switched-off service must NOT do:
*
* test("the endpoint is gone while mcp is off", async ({ page }) => {
* skipUnlessServiceDisabled("mcp");
* // assert absence below
* });
*
* Without this the disabled state is never asserted at all: every gated spec
* skips itself when the flag is false, so a surface that kept serving after
* being switched off looks exactly like one that was never deployed.
*/
function skipUnlessServiceDisabled(name) {
if (isServiceEnabled(name)) {
test.skip(true, `${envKey(name)}=true`);
}
}
/**
* Legacy wrapper kept for compatibility with callers that were written
* against the wrap-style API. New code SHOULD use
* `skipUnlessServiceEnabled` at the top of the test body instead.
*/
function requireService(name, testFn) {
if (typeof testFn !== "function") {
throw new Error(
`service-gating: requireService("${name}", fn) requires a function as second argument`
);
}
return async function serviceGatedTest({ page, browser, context, request, browserName } = {}) {
skipUnlessServiceEnabled(name);
return testFn({ page, browser, context, request, browserName });
};
}
module.exports = {
isServiceEnabled,
isServiceDisabledReason,
requireService,
skipUnlessServiceDisabled,
skipUnlessServiceEnabled,
};