Skip to content

Commit 7a53838

Browse files
gustavoliraclaude
andcommitted
feat: swap to instrumented __coverage image in dedicated nightly coverage runs
A nightly run can only collect browser coverage if RHDH deploys the instrumented __coverage plugin image. Today the image swap only happens in PR mode (when a PR OCI URL is present); nightly runs deploy the plain released image, so window.__coverage__ is never created. Add the swap to the nightly OCI-direct resolution branch (plugins not in default.packages.yaml): for a frontend plugin, replace the image tag with its __coverage variant. Gating is deliberately conservative so the FUNCTIONAL nightly cannot break: - E2E_NIGHTLY_COVERAGE (explicit opt-in), NOT the ambient E2E_COLLECT_COVERAGE. run-e2e.sh defaults E2E_COLLECT_COVERAGE=true, so gating on it would make every functional nightly deploy __coverage images — and since those are built non-fatally by the overlay release publish, a missing one would break the deployment. Requiring the explicit opt-in keeps the functional nightly's resolution byte-identical to today (the env check short-circuits before any fs access). - coverage-anchors/ presence (the same rolled-out signal the overlay release publish uses to build the __coverage variant), so a plugin without one is never pointed at a tag that does not exist. - frontend-plugin role only. The {{inherit}} branch (DPDY plugins) is untouched — those resolve to the RHDH catalog image, which we cannot instrument. Adds tests for: opted-in swap, no swap in the functional nightly even with E2E_COLLECT_COVERAGE on, no swap when not rolled out, no swap for non-frontend roles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 685727f commit 7a53838

2 files changed

Lines changed: 151 additions & 2 deletions

File tree

src/utils/plugin-metadata.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,24 @@ async function resolvePluginPackages(
475475
prOciUrls = await getOCIUrlsForPR(workspacePath, prNumber);
476476
}
477477

478+
// A dedicated coverage run swaps rolled-out frontend plugins to their
479+
// instrumented __coverage image so the browser exposes window.__coverage__.
480+
//
481+
// This is gated on E2E_NIGHTLY_COVERAGE (an explicit opt-in), NOT on the
482+
// ambient E2E_COLLECT_COVERAGE: the functional nightly runs with coverage
483+
// collection on by default but deploys RELEASED images, and the __coverage
484+
// variant is built non-fatally by the overlay release publish — so swapping
485+
// there could point at a tag that doesn't exist and break the deployment.
486+
// Requiring the explicit opt-in keeps the functional nightly's resolution
487+
// identical to today; only a coverage-dedicated run (which ensures the
488+
// images exist) sets the flag. The coverage-anchors/ check further restricts
489+
// the swap to rolled-out workspaces.
490+
const coverageSwap =
491+
process.env.E2E_NIGHTLY_COVERAGE === "true" &&
492+
fs.existsSync(
493+
path.join(path.resolve(metadataPath, ".."), "coverage-anchors"),
494+
);
495+
478496
return plugins.map((plugin) => {
479497
const pkg = plugin.package;
480498
const pluginName = extractPluginName(pkg);
@@ -514,9 +532,15 @@ async function resolvePluginPackages(
514532
}
515533

516534
// OCI: use metadata's dynamicArtifact directly (not in default.packages.yaml, or not nightly).
535+
// For a rolled-out frontend plugin in a coverage run, swap to the
536+
// instrumented __coverage image so the nightly can collect coverage.
517537
if (metadata.packagePath.startsWith("oci://")) {
518-
console.log(`[PluginMetadata] ${pkg}${metadata.packagePath}`);
519-
return { ...plugin, package: metadata.packagePath };
538+
const resolved =
539+
coverageSwap && metadata.role === "frontend-plugin"
540+
? metadata.packagePath.replace(/(:[^!]+)/, "$1__coverage")
541+
: metadata.packagePath;
542+
console.log(`[PluginMetadata] ${pkg}${resolved}`);
543+
return { ...plugin, package: resolved };
520544
}
521545

522546
// Wrapper (local path): metadata is the source of truth.

src/utils/tests/plugin-metadata.nightly.test.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
import { describe, it, beforeEach, afterEach } from "node:test";
66
import assert from "node:assert";
77
import fs from "fs-extra";
8+
import path from "path";
9+
import os from "os";
10+
import yaml from "js-yaml";
811
import {
912
isNightlyJob,
1013
processPluginsForDeployment,
@@ -951,3 +954,125 @@ describe("getDpdyRegistry", () => {
951954
);
952955
});
953956
});
957+
958+
// ── Nightly coverage image swap ──────────────────────────────────────────────
959+
960+
describe("processPluginsForDeployment — nightly coverage swap", () => {
961+
const env = withCleanEnv();
962+
beforeEach(() => {
963+
env.save();
964+
delete process.env.GIT_PR_NUMBER;
965+
process.env.E2E_NIGHTLY_MODE = "true";
966+
});
967+
afterEach(() => env.restore());
968+
969+
const OCI_REF =
970+
"oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-theme:bs_1.49.4__0.14.5!red-hat-developer-hub-backstage-plugin-theme";
971+
972+
// Builds a workspace layout (<ws>/metadata + optional <ws>/coverage-anchors)
973+
// and returns the metadata dir to pass as metadataPath. The resolver derives
974+
// the workspace root as the parent of the metadata dir.
975+
async function createCoverageWorkspace(opts: {
976+
rolledOut: boolean;
977+
role?: string;
978+
}): Promise<string> {
979+
const ws = await fs.mkdtemp(path.join(os.tmpdir(), "cov-ws-"));
980+
const metadataDir = path.join(ws, "metadata");
981+
await fs.ensureDir(metadataDir);
982+
if (opts.rolledOut) {
983+
await fs.ensureDir(path.join(ws, "coverage-anchors"));
984+
}
985+
await fs.writeFile(
986+
path.join(metadataDir, "theme.yaml"),
987+
yaml.dump({
988+
apiVersion: "extensions.backstage.io/v1alpha1",
989+
kind: "Package",
990+
metadata: { name: "theme" },
991+
spec: {
992+
packageName: "@red-hat-developer-hub/backstage-plugin-theme",
993+
dynamicArtifact: OCI_REF,
994+
...(opts.role ? { backstage: { role: opts.role } } : {}),
995+
},
996+
}),
997+
);
998+
return metadataDir;
999+
}
1000+
1001+
const config: DynamicPluginsConfig = {
1002+
plugins: [{ package: OCI_REF, disabled: false }],
1003+
};
1004+
1005+
async function resolveWith(metadataDir: string): Promise<string> {
1006+
const result = await processPluginsForDeployment(
1007+
config,
1008+
metadataDir,
1009+
new Set(), // empty DPDY → OCI-direct branch, not {{inherit}}
1010+
);
1011+
return result.plugins![0].package;
1012+
}
1013+
1014+
it("swaps to the __coverage image for a rolled-out frontend plugin when opted in", async () => {
1015+
process.env.E2E_NIGHTLY_COVERAGE = "true";
1016+
const metadataDir = await createCoverageWorkspace({
1017+
rolledOut: true,
1018+
role: "frontend-plugin",
1019+
});
1020+
try {
1021+
const pkg = await resolveWith(metadataDir);
1022+
assert.strictEqual(
1023+
pkg,
1024+
"oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-theme:bs_1.49.4__0.14.5__coverage!red-hat-developer-hub-backstage-plugin-theme",
1025+
"tag must get the __coverage suffix, the !path must be preserved",
1026+
);
1027+
} finally {
1028+
await fs.remove(path.resolve(metadataDir, ".."));
1029+
}
1030+
});
1031+
1032+
it("does NOT swap in the functional nightly (E2E_NIGHTLY_COVERAGE unset), even with E2E_COLLECT_COVERAGE on", async () => {
1033+
// The functional nightly runs with E2E_COLLECT_COVERAGE=true by default but
1034+
// must keep deploying the released image — swapping there could point at a
1035+
// __coverage tag that doesn't exist and break the deployment.
1036+
process.env.E2E_COLLECT_COVERAGE = "true";
1037+
delete process.env.E2E_NIGHTLY_COVERAGE;
1038+
const metadataDir = await createCoverageWorkspace({
1039+
rolledOut: true,
1040+
role: "frontend-plugin",
1041+
});
1042+
try {
1043+
assert.strictEqual(
1044+
await resolveWith(metadataDir),
1045+
OCI_REF,
1046+
"functional nightly resolution must be unchanged (no swap)",
1047+
);
1048+
} finally {
1049+
await fs.remove(path.resolve(metadataDir, ".."));
1050+
}
1051+
});
1052+
1053+
it("does not swap when the workspace has no coverage-anchors (not rolled out)", async () => {
1054+
process.env.E2E_NIGHTLY_COVERAGE = "true";
1055+
const metadataDir = await createCoverageWorkspace({
1056+
rolledOut: false,
1057+
role: "frontend-plugin",
1058+
});
1059+
try {
1060+
assert.strictEqual(await resolveWith(metadataDir), OCI_REF);
1061+
} finally {
1062+
await fs.remove(path.resolve(metadataDir, ".."));
1063+
}
1064+
});
1065+
1066+
it("does not swap a non-frontend plugin even when rolled out and opted in", async () => {
1067+
process.env.E2E_NIGHTLY_COVERAGE = "true";
1068+
const metadataDir = await createCoverageWorkspace({
1069+
rolledOut: true,
1070+
role: "backend-plugin",
1071+
});
1072+
try {
1073+
assert.strictEqual(await resolveWith(metadataDir), OCI_REF);
1074+
} finally {
1075+
await fs.remove(path.resolve(metadataDir, ".."));
1076+
}
1077+
});
1078+
});

0 commit comments

Comments
 (0)