From c32cacc1a50e56cc4cadb946a8f05294f0921e97 Mon Sep 17 00:00:00 2001 From: Subhash Khileri Date: Fri, 6 Mar 2026 11:56:17 +0530 Subject: [PATCH 1/4] test.runOnce implementation --- docs/CLAUDE.md | 7 +- docs/api/playwright/base-config.md | 2 +- docs/api/playwright/test-fixtures.md | 38 +++++- docs/changelog.md | 12 +- .../core-concepts/playwright-fixtures.md | 113 ++++++++++++++++-- docs/guide/deployment/rhdh-deployment.md | 34 +++--- docs/overlay/test-structure/spec-files.md | 80 ++++++++----- package.json | 10 +- src/playwright/base-config.ts | 1 + src/playwright/fixtures/test.ts | 67 +++++++++-- src/playwright/teardown-namespaces.ts | 45 +++++++ src/playwright/teardown-reporter.ts | 49 ++++++++ yarn.lock | 38 +++++- 13 files changed, 415 insertions(+), 81 deletions(-) create mode 100644 src/playwright/teardown-namespaces.ts create mode 100644 src/playwright/teardown-reporter.ts diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 647f4b4..1fe230f 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -46,7 +46,9 @@ docs/ import { test, expect } from "@red-hat-developer-hub/e2e-test-utils/test"; test.describe("Feature", () => { - test.beforeAll(async ({ rhdh }) => { /* deploy */ }); + test.beforeAll(async ({ rhdh }) => { + await test.runOnce("my-plugin-deploy", async () => { /* deploy */ }); + }); test.beforeEach(async ({ loginHelper }) => { /* login */ }); test("should...", async ({ uiHelper }) => { /* test */ }); }); @@ -121,6 +123,8 @@ When documenting, reference these source files: | Fixtures | `src/playwright/fixtures/test.ts` | | Base Config | `src/playwright/base-config.ts` | | Global Setup | `src/playwright/global-setup.ts` | +| Teardown Reporter | `src/playwright/teardown-reporter.ts` | +| Teardown Namespaces | `src/playwright/teardown-namespaces.ts` | ## Common Tasks @@ -209,5 +213,6 @@ Base URL is configured as `/rhdh-e2e-test-utils/` in `config.ts`. | Helpers | `@red-hat-developer-hub/e2e-test-utils/helpers` | UIhelper, LoginHelper, etc. | | Page objects | `@red-hat-developer-hub/e2e-test-utils/pages` | CatalogPage, HomePage, etc. | | Utilities | `@red-hat-developer-hub/e2e-test-utils/utils` | KubernetesClientHelper, etc. | +| Teardown | `@red-hat-developer-hub/e2e-test-utils/teardown` | Custom namespace teardown registration | | ESLint | `@red-hat-developer-hub/e2e-test-utils/eslint` | ESLint config | | TypeScript | `@red-hat-developer-hub/e2e-test-utils/tsconfig` | TSConfig base | diff --git a/docs/api/playwright/base-config.md b/docs/api/playwright/base-config.md index e45e3c8..cbcf90b 100644 --- a/docs/api/playwright/base-config.md +++ b/docs/api/playwright/base-config.md @@ -59,7 +59,7 @@ Raw base configuration object. Use for advanced customization. retries: Number(process.env.PLAYWRIGHT_RETRIES ?? 0), workers: process.env.PLAYWRIGHT_WORKERS || "50%", outputDir: "node_modules/.cache/e2e-test-results", - reporter: [["list"], ["html"], ["json"]], + reporter: [["list"], ["html"], ["json"], ["teardown-reporter"]], use: { viewport: { width: 1920, height: 1080 }, video: { mode: "retain-on-failure", size: { width: 1280, height: 720 } }, diff --git a/docs/api/playwright/test-fixtures.md b/docs/api/playwright/test-fixtures.md index f1bac62..936a578 100644 --- a/docs/api/playwright/test-fixtures.md +++ b/docs/api/playwright/test-fixtures.md @@ -16,12 +16,14 @@ import { test, expect } from "@red-hat-developer-hub/e2e-test-utils/test"; **Type:** `RHDHDeployment` -Shared RHDH deployment across all tests in a worker. +Shared RHDH deployment across all tests in a worker. Wrap expensive setup in `test.runOnce` to avoid re-deploying when workers restart after test failures. ```typescript test.beforeAll(async ({ rhdh }) => { - await rhdh.configure({ auth: "keycloak" }); - await rhdh.deploy(); + await test.runOnce("my-plugin-deploy", async () => { + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); + }); }); test("access rhdh", async ({ rhdh }) => { @@ -80,6 +82,30 @@ test("using baseURL", async ({ page, baseURL }) => { }); ``` +## `test.runOnce` + +```typescript +test.runOnce(key: string, fn: () => Promise | void): Promise +``` + +Executes `fn` exactly once per test run, even across worker restarts. Returns `true` if executed, `false` if skipped. Useful for expensive or persistent operations (deployments, database seeding, service provisioning) that should not repeat after a worker restart. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `key` | `string` | Unique identifier for this operation | +| `fn` | `() => Promise \| void` | Function to execute once | + +```typescript +test.beforeAll(async ({ rhdh }) => { + await test.runOnce("my-deploy", async () => { + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); + }); +}); +``` + +See [Playwright Fixtures — `test.runOnce`](/guide/core-concepts/playwright-fixtures#test-runonce-—-execute-a-function-once-per-test-run) for detailed usage and examples. + ## Exported Types ```typescript @@ -95,8 +121,10 @@ import { test, expect } from "@red-hat-developer-hub/e2e-test-utils/test"; test.describe("My Tests", () => { test.beforeAll(async ({ rhdh }) => { - await rhdh.configure({ auth: "keycloak" }); - await rhdh.deploy(); + await test.runOnce("my-plugin-deploy", async () => { + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); + }); }); test.beforeEach(async ({ page, loginHelper }) => { diff --git a/docs/changelog.md b/docs/changelog.md index 6669147..7a08b54 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,7 +2,17 @@ All notable changes to this project will be documented in this file. -## [1.1.13] - Current +## [1.1.14] - Current + +### Added +- **`test.runOnce(key, fn)`**: Execute a function exactly once per test run, even across worker restarts. Prevents re-running expensive operations (deployments, service provisioning, data seeding) when Playwright creates new workers after test failures. +- **Teardown reporter**: Built-in Playwright reporter that automatically deletes Kubernetes namespaces after all tests complete. Active only in CI (`process.env.CI`). +- **`registerTeardownNamespace(projectName, namespace)`**: Register custom namespaces for automatic cleanup. Import from `@red-hat-developer-hub/e2e-test-utils/teardown`. + +### Changed +- Namespace cleanup moved from worker fixture to teardown reporter to prevent premature deletion on test failures. + +## [1.1.13] ### Added - Support for GitHub authentication provider diff --git a/docs/guide/core-concepts/playwright-fixtures.md b/docs/guide/core-concepts/playwright-fixtures.md index 11e36be..f35a5c7 100644 --- a/docs/guide/core-concepts/playwright-fixtures.md +++ b/docs/guide/core-concepts/playwright-fixtures.md @@ -154,16 +154,103 @@ export default defineConfig({ }); ``` -## Auto-Cleanup +## `test.runOnce` — Execute a Function Once Per Test Run -In CI environments (when `CI` environment variable is set): +Playwright's `beforeAll` runs once **per worker**, not once per test run. When a test fails, Playwright kills the worker and creates a new one for remaining tests — causing `beforeAll` to run again. For operations that are expensive or produce persistent side effects, this leads to unnecessary re-execution. -- Namespaces are automatically deleted after tests complete -- Prevents resource accumulation on shared clusters +`test.runOnce` ensures a function executes **exactly once per test run**, even across worker restarts: -For local development: -- Namespaces are preserved for debugging -- Manual cleanup may be required +```typescript +test.beforeAll(async ({ rhdh }) => { + await test.runOnce("my-plugin-deploy", async () => { + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); + }); +}); +``` + +### How It Works + +- Uses file-based flags scoped to the Playwright runner process +- When a worker restarts after a test failure, `runOnce` detects the flag and skips +- Any state created by the function (deployments, databases, services) stays alive +- Flags reset automatically between test runs + +### When to Use + +Use `test.runOnce` when your `beforeAll` performs an operation that: +- Is **expensive** (deployments, database seeding, service provisioning) +- Creates **persistent state** that survives beyond the worker process (Kubernetes resources, external services, test data) +- Should **not repeat** once successfully completed + +Common examples: +- RHDH deployment (`rhdh.deploy()`) +- External service deployment (customization providers, mock APIs) +- Database seeding or migration +- Any setup script that takes significant time + +### Key: Unique Identifier + +The `key` parameter must be unique across all `runOnce` calls in your test run. Use a descriptive name: + +```typescript +// Deploy RHDH +await test.runOnce("tech-radar-deploy", async () => { + await rhdh.deploy(); +}); + +// Deploy an external service +await test.runOnce("tech-radar-data-provider", async () => { + await $`bash ${setupScript} ${namespace}`; +}); + +// Seed test data +await test.runOnce("catalog-seed-data", async () => { + await apiHelper.importEntity("https://example.com/catalog-info.yaml"); +}); +``` + +## Namespace Cleanup (Teardown) + +In CI environments (`CI` environment variable is set), namespaces are automatically deleted after all tests complete. This is handled by a built-in **teardown reporter** that: + +1. Runs in the main Playwright process (survives worker restarts) +2. Waits for **all tests** in a project to finish +3. Deletes the namespace matching the project name + +### Default Behavior + +No configuration needed. The namespace is derived from your project name: + +```typescript +// playwright.config.ts +projects: [ + { name: "tech-radar" }, // Namespace "tech-radar" deleted after all tests + { name: "catalog" }, // Namespace "catalog" deleted after all tests +] +``` + +### Custom Namespaces + +If you deploy to a namespace that differs from the project name, register it for cleanup: + +```typescript +import { registerTeardownNamespace } from "@red-hat-developer-hub/e2e-test-utils/teardown"; + +test.beforeAll(async ({ rhdh }) => { + await test.runOnce("custom-deploy", async () => { + await rhdh.configure({ namespace: "my-custom-ns", auth: "keycloak" }); + await rhdh.deploy(); + registerTeardownNamespace("my-project", "my-custom-ns"); + }); +}); +``` + +Multiple namespaces per project are supported — all registered namespaces are deleted after that project's tests complete. + +### Local Development + +Namespaces are **not** deleted locally (only in CI). This preserves deployments for debugging. ## Best Practices for Projects and Spec Files @@ -213,12 +300,14 @@ import { test, expect } from "@red-hat-developer-hub/e2e-test-utils/test"; test.describe("My Plugin Tests", () => { test.beforeAll(async ({ rhdh }) => { - await rhdh.configure({ - auth: "keycloak", - appConfig: "tests/config/app-config.yaml", - dynamicPlugins: "tests/config/plugins.yaml", + await test.runOnce("my-plugin-deploy", async () => { + await rhdh.configure({ + auth: "keycloak", + appConfig: "tests/config/app-config.yaml", + dynamicPlugins: "tests/config/plugins.yaml", + }); + await rhdh.deploy(); }); - await rhdh.deploy(); }); test.beforeEach(async ({ page, loginHelper }) => { diff --git a/docs/guide/deployment/rhdh-deployment.md b/docs/guide/deployment/rhdh-deployment.md index 7aacb15..f28491c 100644 --- a/docs/guide/deployment/rhdh-deployment.md +++ b/docs/guide/deployment/rhdh-deployment.md @@ -33,8 +33,10 @@ import { test } from "@red-hat-developer-hub/e2e-test-utils/test"; test.beforeAll(async ({ rhdh }) => { // rhdh is already instantiated with namespace from project name - await rhdh.configure({ auth: "keycloak" }); - await rhdh.deploy(); + await test.runOnce("my-plugin-deploy", async () => { + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); + }); }); test("example", async ({ rhdh }) => { @@ -176,7 +178,7 @@ await deployment.teardown(); ``` ::: warning -This permanently deletes all resources in the namespace. In CI, this happens automatically. +You typically don't need to call this manually. In CI, the built-in teardown reporter automatically deletes namespaces after all tests complete. See [Namespace Cleanup](/guide/core-concepts/playwright-fixtures#namespace-cleanup-teardown). ::: ## Properties @@ -259,21 +261,23 @@ import { test } from "@red-hat-developer-hub/e2e-test-utils/test"; import { $ } from "@red-hat-developer-hub/e2e-test-utils/utils"; test.beforeAll(async ({ rhdh }) => { - const namespace = rhdh.deploymentConfig.namespace; + await test.runOnce("my-plugin-deploy", async () => { + const namespace = rhdh.deploymentConfig.namespace; - // Configure RHDH - await rhdh.configure({ auth: "keycloak" }); + // Configure RHDH + await rhdh.configure({ auth: "keycloak" }); - // Run custom setup before deployment - await $`bash scripts/setup.sh ${namespace}`; + // Run custom setup before deployment + await $`bash scripts/setup.sh ${namespace}`; - // Set runtime environment variables - process.env.MY_CUSTOM_URL = await rhdh.k8sClient.getRouteLocation( - namespace, - "my-service" - ); + // Set runtime environment variables + process.env.MY_CUSTOM_URL = await rhdh.k8sClient.getRouteLocation( + namespace, + "my-service" + ); - // Deploy RHDH (uses env vars set above) - await rhdh.deploy(); + // Deploy RHDH (uses env vars set above) + await rhdh.deploy(); + }); }); ``` diff --git a/docs/overlay/test-structure/spec-files.md b/docs/overlay/test-structure/spec-files.md index 79fe1f0..bdeed71 100644 --- a/docs/overlay/test-structure/spec-files.md +++ b/docs/overlay/test-structure/spec-files.md @@ -26,10 +26,12 @@ A typical spec file follows this structure: import { test, expect, Page } from "@red-hat-developer-hub/e2e-test-utils/test"; test.describe("Test ", () => { - // Setup: Deploy RHDH once per worker + // Setup: Deploy RHDH once per test run test.beforeAll(async ({ rhdh }) => { - await rhdh.configure({ auth: "keycloak" }); - await rhdh.deploy(); + await test.runOnce("my-plugin-deploy", async () => { + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); + }); }); // Login before each test @@ -44,6 +46,14 @@ test.describe("Test ", () => { }); ``` +::: info Why `test.runOnce`? +When a test fails, Playwright kills the worker and creates a new one for remaining tests. Without `runOnce`, `beforeAll` would re-execute expensive setup (deployments, service provisioning, data seeding) from scratch. `runOnce` ensures the operation happens only once; remaining tests reuse the existing state. See [Playwright Fixtures — `test.runOnce`](/guide/core-concepts/playwright-fixtures#test-runonce-—-execute-a-function-once-per-test-run) for details. +::: + +::: info Automatic Cleanup +In CI, namespaces are automatically deleted after all tests complete via the built-in teardown reporter. No manual cleanup code is needed. See [Namespace Cleanup](/guide/core-concepts/playwright-fixtures#namespace-cleanup-teardown) for details. +::: + ## Imports Import test utilities from `@red-hat-developer-hub/e2e-test-utils`: @@ -84,8 +94,10 @@ For plugins that only need configuration (no external services): ```typescript test.beforeAll(async ({ rhdh }) => { - await rhdh.configure({ auth: "keycloak" }); - await rhdh.deploy(); + await test.runOnce("my-plugin-deploy", async () => { + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); + }); }); ``` @@ -111,24 +123,26 @@ const setupScript = path.join( ); test.beforeAll(async ({ rhdh }) => { - const project = rhdh.deploymentConfig.namespace; + await test.runOnce("tech-radar-deploy", async () => { + const project = rhdh.deploymentConfig.namespace; - // 1. Configure RHDH first - await rhdh.configure({ auth: "keycloak" }); + // 1. Configure RHDH first + await rhdh.configure({ auth: "keycloak" }); - // 2. Deploy external service - await $`bash ${setupScript} ${project}`; + // 2. Deploy external service + await $`bash ${setupScript} ${project}`; - // 3. Get service URL and set as env var - process.env.TECH_RADAR_DATA_URL = ( - await rhdh.k8sClient.getRouteLocation( - project, - "test-backstage-customization-provider", - ) - ).replace("http://", ""); + // 3. Get service URL and set as env var + process.env.TECH_RADAR_DATA_URL = ( + await rhdh.k8sClient.getRouteLocation( + project, + "test-backstage-customization-provider", + ) + ).replace("http://", ""); - // 4. Deploy RHDH (will use TECH_RADAR_DATA_URL from rhdh-secrets.yaml) - await rhdh.deploy(); + // 4. Deploy RHDH (will use TECH_RADAR_DATA_URL from rhdh-secrets.yaml) + await rhdh.deploy(); + }); }); ``` @@ -140,8 +154,10 @@ For simpler tests without Keycloak: ```typescript test.beforeAll(async ({ rhdh }) => { - await rhdh.configure({ auth: "guest" }); - await rhdh.deploy(); + await test.runOnce("my-plugin-guest-deploy", async () => { + await rhdh.configure({ auth: "guest" }); + await rhdh.deploy(); + }); }); test.beforeEach(async ({ loginHelper }) => { @@ -249,16 +265,18 @@ const setupScript = path.join( test.describe("Test tech-radar plugin", () => { test.beforeAll(async ({ rhdh }) => { - const project = rhdh.deploymentConfig.namespace; - await rhdh.configure({ auth: "keycloak" }); - await $`bash ${setupScript} ${project}`; - process.env.TECH_RADAR_DATA_URL = ( - await rhdh.k8sClient.getRouteLocation( - project, - "test-backstage-customization-provider", - ) - ).replace("http://", ""); - await rhdh.deploy(); + await test.runOnce("tech-radar-deploy", async () => { + const project = rhdh.deploymentConfig.namespace; + await rhdh.configure({ auth: "keycloak" }); + await $`bash ${setupScript} ${project}`; + process.env.TECH_RADAR_DATA_URL = ( + await rhdh.k8sClient.getRouteLocation( + project, + "test-backstage-customization-provider", + ) + ).replace("http://", ""); + await rhdh.deploy(); + }); }); test.beforeEach(async ({ loginHelper }) => { diff --git a/package.json b/package.json index f781748..7dacfdd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@red-hat-developer-hub/e2e-test-utils", - "version": "1.1.13", + "version": "1.1.14", "description": "Test utilities for RHDH E2E tests", "license": "Apache-2.0", "repository": { @@ -44,6 +44,10 @@ "./keycloak": { "types": "./dist/deployment/keycloak/index.d.ts", "default": "./dist/deployment/keycloak/index.js" + }, + "./teardown": { + "types": "./dist/playwright/teardown-namespaces.d.ts", + "default": "./dist/playwright/teardown-namespaces.js" } }, "publishConfig": { @@ -84,7 +88,8 @@ "@types/js-yaml": "^4.0.9", "@types/lodash.clonedeepwith": "^4.5.9", "@types/lodash.mergewith": "^4.6.9", - "@types/node": "^24.10.1" + "@types/node": "^24.10.1", + "@types/proper-lockfile": "^4.1.4" }, "dependencies": { "@axe-core/playwright": "^4.11.0", @@ -100,6 +105,7 @@ "lodash.mergewith": "^4.6.2", "otplib": "12.0.1", "prettier": "^3.7.4", + "proper-lockfile": "^4.1.2", "typescript": "^5.9.3", "typescript-eslint": "^8.48.1", "zx": "^8.8.5" diff --git a/src/playwright/base-config.ts b/src/playwright/base-config.ts index 566ce16..398ed13 100644 --- a/src/playwright/base-config.ts +++ b/src/playwright/base-config.ts @@ -19,6 +19,7 @@ export const baseConfig: PlaywrightTestConfig = { ["list"], ["html", { outputFolder: "playwright-report", open: "on-failure" }], ["json", { outputFile: "playwright-report/results.json" }], + [resolve(import.meta.dirname, "../playwright/teardown-reporter.js")], ], use: { ignoreHTTPSErrors: true, diff --git a/src/playwright/fixtures/test.ts b/src/playwright/fixtures/test.ts index f7c783d..8165ba2 100644 --- a/src/playwright/fixtures/test.ts +++ b/src/playwright/fixtures/test.ts @@ -1,6 +1,52 @@ import { RHDHDeployment } from "../../deployment/rhdh/index.js"; import { test as base } from "@playwright/test"; import { LoginHelper, UIhelper } from "../helpers/index.js"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import lockfile from "proper-lockfile"; + +// Each test run gets its own flag directory (ppid = Playwright runner PID) +const flagDir = path.join(os.tmpdir(), `playwright-once-${process.ppid}`); + +/** + * Executes a function only once per test run, even across multiple workers. + * Automatically resets between test runs (each run uses a unique flag directory). + * Safe for fullyParallel: true (uses proper-lockfile for cross-process coordination). + * + * @param key - Unique identifier for this setup operation + * @param fn - Function to execute once + * @returns true if executed, false if skipped (already ran) + */ +async function runOnce( + key: string, + fn: () => Promise | void, +): Promise { + const flagFile = path.join(flagDir, `${key}.done`); + const lockTarget = path.join(flagDir, key); + + fs.mkdirSync(flagDir, { recursive: true }); + + // already executed, skip without locking + if (fs.existsSync(flagFile)) return false; + + // Ensure lock target file exists + fs.writeFileSync(lockTarget, "", { flag: "a" }); + const release = await lockfile.lock(lockTarget, { + retries: { retries: 30, minTimeout: 200 }, + stale: 300_000, + }); + + try { + // Double-check after acquiring lock + if (fs.existsSync(flagFile)) return false; + await fn(); + fs.writeFileSync(flagFile, ""); + return true; + } finally { + await release(); + } +} type RHDHDeploymentTestFixtures = { rhdh: RHDHDeployment; @@ -12,9 +58,7 @@ type RHDHDeploymentWorkerFixtures = { rhdhDeploymentWorker: RHDHDeployment; }; -export * from "@playwright/test"; - -export const test = base.extend< +const baseTest = base.extend< RHDHDeploymentTestFixtures, RHDHDeploymentWorkerFixtures >({ @@ -27,15 +71,8 @@ export const test = base.extend< const rhdhDeployment = new RHDHDeployment(workerInfo.project.name); - try { - await rhdhDeployment.configure(); - await use(rhdhDeployment); - } finally { - if (process.env.CI) { - console.log(`Deleting namespace ${workerInfo.project.name}`); - await rhdhDeployment.teardown(); - } - } + await rhdhDeployment.configure(); + await use(rhdhDeployment); }, { scope: "worker", auto: true }, ], @@ -65,3 +102,9 @@ export const test = base.extend< { scope: "test" }, ] as const, }); + +export const test = Object.assign(baseTest, { + runOnce, +}); + +export * from "@playwright/test"; diff --git a/src/playwright/teardown-namespaces.ts b/src/playwright/teardown-namespaces.ts new file mode 100644 index 0000000..f6dea54 --- /dev/null +++ b/src/playwright/teardown-namespaces.ts @@ -0,0 +1,45 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; + +// Workers use process.ppid (Playwright runner PID) +// Reporter (main process) uses process.pid +// Both resolve to the same directory. +const TEARDOWN_DIR = path.join( + os.tmpdir(), + `playwright-teardown-${process.ppid || process.pid}`, +); +const TEARDOWN_FILE = path.join(TEARDOWN_DIR, "namespaces.json"); + +type TeardownRegistry = Record; + +function read(): TeardownRegistry { + if (!fs.existsSync(TEARDOWN_FILE)) return {}; + return JSON.parse(fs.readFileSync(TEARDOWN_FILE, "utf-8")) as TeardownRegistry; +} + +/** + * Registers a namespace for teardown after all tests in a project complete. + * Used by consumers who deploy to custom namespaces (not matching the project name). + */ +export function registerTeardownNamespace( + projectName: string, + namespace: string, +): void { + fs.mkdirSync(TEARDOWN_DIR, { recursive: true }); + const registry = read(); + const namespaces = registry[projectName] ?? []; + if (!namespaces.includes(namespace)) { + namespaces.push(namespace); + registry[projectName] = namespaces; + fs.writeFileSync(TEARDOWN_FILE, JSON.stringify(registry)); + } +} + +/** + * Returns all custom namespaces registered for teardown for a project. + * Used by the teardown reporter. + */ +export function getTeardownNamespaces(projectName: string): string[] { + return read()[projectName] ?? []; +} diff --git a/src/playwright/teardown-reporter.ts b/src/playwright/teardown-reporter.ts new file mode 100644 index 0000000..a863bea --- /dev/null +++ b/src/playwright/teardown-reporter.ts @@ -0,0 +1,49 @@ +import type { Reporter, Suite } from "@playwright/test/reporter"; +import { KubernetesClientHelper } from "../utils/kubernetes-client.js"; +import { getTeardownNamespaces } from "./teardown-namespaces.js"; + +/** + * Playwright reporter that deletes namespaces after all tests complete. + * Runs in the main process, so it survives worker restarts. + * Only active when process.env.CI is set. + * + * By default, deletes the namespace matching the project name. + * For custom namespaces, consumers can register them via registerTeardownNamespace(). + */ +export default class TeardownReporter implements Reporter { + private _projects = new Set(); + + onBegin(_config: unknown, suite: Suite): void { + for (const test of suite.allTests()) { + const name = test.parent.project()?.name; + if (name) this._projects.add(name); + } + } + + async onEnd(): Promise { + if (!process.env.CI) return; + + let k8sClient: KubernetesClientHelper; + try { + k8sClient = new KubernetesClientHelper(); + } catch (error) { + console.error(`[TeardownReporter] Cannot connect to cluster, skipping teardown:`, error); + return; + } + + for (const projectName of this._projects) { + const customNamespaces = getTeardownNamespaces(projectName); + const namespaces = customNamespaces.length > 0 ? customNamespaces : [projectName]; + + for (const ns of namespaces) { + console.log(`[TeardownReporter] Deleting namespace "${ns}" (project: ${projectName})`); + try { + await k8sClient.deleteNamespace(ns); + console.log(`[TeardownReporter] Namespace "${ns}" deleted successfully`); + } catch (error) { + console.error(`[TeardownReporter] Failed to delete namespace "${ns}":`, error); + } + } + } + } +} diff --git a/yarn.lock b/yarn.lock index ec23cc0..76feb5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -337,6 +337,7 @@ __metadata: "@types/lodash.clonedeepwith": ^4.5.9 "@types/lodash.mergewith": ^4.6.9 "@types/node": ^24.10.1 + "@types/proper-lockfile": ^4.1.4 eslint: ^9.39.1 eslint-plugin-check-file: ^3.3.1 eslint-plugin-playwright: ^2.4.0 @@ -346,6 +347,7 @@ __metadata: lodash.mergewith: ^4.6.2 otplib: 12.0.1 prettier: ^3.7.4 + proper-lockfile: ^4.1.2 typescript: ^5.9.3 typescript-eslint: ^8.48.1 zx: ^8.8.5 @@ -447,6 +449,22 @@ __metadata: languageName: node linkType: hard +"@types/proper-lockfile@npm:^4.1.4": + version: 4.1.4 + resolution: "@types/proper-lockfile@npm:4.1.4" + dependencies: + "@types/retry": "*" + checksum: b0d1b8e84a563b2c5f869f7ff7542b1d83dec03d1c9d980847cbb189865f44b4a854673cdde59767e41bcb8c31932e613ac43822d358a6f8eede6b79ccfceb1d + languageName: node + linkType: hard + +"@types/retry@npm:*": + version: 0.12.5 + resolution: "@types/retry@npm:0.12.5" + checksum: 3fb6bf91835ca0eb2987567d6977585235a7567f8aeb38b34a8bb7bbee57ac050ed6f04b9998cda29701b8c893f5dfe315869bc54ac17e536c9235637fe351a2 + languageName: node + linkType: hard + "@types/stream-buffers@npm:^3.0.3": version: 3.0.8 resolution: "@types/stream-buffers@npm:3.0.8" @@ -1409,7 +1427,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6": +"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: ac85f94da92d8eb6b7f5a8b20ce65e43d66761c55ce85ac96df6865308390da45a8d3f0296dd3a663de65d30ba497bd46c696cc1e248c72b13d6d567138a4fc7 @@ -2119,6 +2137,17 @@ __metadata: languageName: node linkType: hard +"proper-lockfile@npm:^4.1.2": + version: 4.1.2 + resolution: "proper-lockfile@npm:4.1.2" + dependencies: + graceful-fs: ^4.2.4 + retry: ^0.12.0 + signal-exit: ^3.0.2 + checksum: 00078ee6a61c216a56a6140c7d2a98c6c733b3678503002dc073ab8beca5d50ca271de4c85fca13b9b8ee2ff546c36674d1850509b84a04a5d0363bcb8638939 + languageName: node + linkType: hard + "pump@npm:^3.0.0": version: 3.0.3 resolution: "pump@npm:3.0.3" @@ -2205,6 +2234,13 @@ __metadata: languageName: node linkType: hard +"signal-exit@npm:^3.0.2": + version: 3.0.7 + resolution: "signal-exit@npm:3.0.7" + checksum: a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" From 1dd2034289e8dcde8cfe298cd580d3f0eaa017a7 Mon Sep 17 00:00:00 2001 From: Subhash Khileri Date: Fri, 6 Mar 2026 12:54:49 +0530 Subject: [PATCH 2/4] test.runOnce implementation withing deploy --- docs/CLAUDE.md | 3 +- docs/api/playwright/test-fixtures.md | 28 ++-- docs/changelog.md | 3 +- .../core-concepts/playwright-fixtures.md | 134 +++++++++++++----- docs/guide/deployment/rhdh-deployment.md | 13 +- docs/overlay/test-structure/spec-files.md | 36 +++-- src/deployment/rhdh/deployment.ts | 39 +++-- src/playwright/fixtures/test.ts | 47 +----- src/playwright/run-once.ts | 46 ++++++ src/playwright/teardown-namespaces.ts | 4 +- src/playwright/teardown-reporter.ts | 21 ++- 11 files changed, 229 insertions(+), 145 deletions(-) create mode 100644 src/playwright/run-once.ts diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 1fe230f..3a341cf 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -47,7 +47,8 @@ docs/ test.describe("Feature", () => { test.beforeAll(async ({ rhdh }) => { - await test.runOnce("my-plugin-deploy", async () => { /* deploy */ }); + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); }); test.beforeEach(async ({ loginHelper }) => { /* login */ }); test("should...", async ({ uiHelper }) => { /* test */ }); diff --git a/docs/api/playwright/test-fixtures.md b/docs/api/playwright/test-fixtures.md index 936a578..605d2bf 100644 --- a/docs/api/playwright/test-fixtures.md +++ b/docs/api/playwright/test-fixtures.md @@ -16,14 +16,12 @@ import { test, expect } from "@red-hat-developer-hub/e2e-test-utils/test"; **Type:** `RHDHDeployment` -Shared RHDH deployment across all tests in a worker. Wrap expensive setup in `test.runOnce` to avoid re-deploying when workers restart after test failures. +Shared RHDH deployment across all tests in a worker. `deploy()` automatically skips if the deployment already succeeded, even after worker restarts. ```typescript test.beforeAll(async ({ rhdh }) => { - await test.runOnce("my-plugin-deploy", async () => { - await rhdh.configure({ auth: "keycloak" }); - await rhdh.deploy(); - }); + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); }); test("access rhdh", async ({ rhdh }) => { @@ -88,7 +86,11 @@ test("using baseURL", async ({ page, baseURL }) => { test.runOnce(key: string, fn: () => Promise | void): Promise ``` -Executes `fn` exactly once per test run, even across worker restarts. Returns `true` if executed, `false` if skipped. Useful for expensive or persistent operations (deployments, database seeding, service provisioning) that should not repeat after a worker restart. +Executes `fn` exactly once per test run, even across worker restarts. Returns `true` if executed, `false` if skipped. + +::: tip +`rhdh.deploy()` already uses `runOnce` internally, so you don't need to wrap simple deployments. Use `test.runOnce` when you have **additional expensive operations** (external services, scripts, data seeding) alongside `deploy()`. +::: | Parameter | Type | Description | |-----------|------|-------------| @@ -96,15 +98,17 @@ Executes `fn` exactly once per test run, even across worker restarts. Returns `t | `fn` | `() => Promise \| void` | Function to execute once | ```typescript +// Wrap pre-deploy setup that shouldn't repeat test.beforeAll(async ({ rhdh }) => { - await test.runOnce("my-deploy", async () => { + await test.runOnce("full-setup", async () => { + await $`bash deploy-external-service.sh`; await rhdh.configure({ auth: "keycloak" }); - await rhdh.deploy(); + await rhdh.deploy(); // safe to nest, has its own internal protection }); }); ``` -See [Playwright Fixtures — `test.runOnce`](/guide/core-concepts/playwright-fixtures#test-runonce-—-execute-a-function-once-per-test-run) for detailed usage and examples. +See [Deployment Protection](/guide/core-concepts/playwright-fixtures#deployment-protection-built-in) and [`test.runOnce`](/guide/core-concepts/playwright-fixtures#test-runonce-—-run-any-expensive-operation-once) for details. ## Exported Types @@ -121,10 +125,8 @@ import { test, expect } from "@red-hat-developer-hub/e2e-test-utils/test"; test.describe("My Tests", () => { test.beforeAll(async ({ rhdh }) => { - await test.runOnce("my-plugin-deploy", async () => { - await rhdh.configure({ auth: "keycloak" }); - await rhdh.deploy(); - }); + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); }); test.beforeEach(async ({ page, loginHelper }) => { diff --git a/docs/changelog.md b/docs/changelog.md index 7a08b54..1970fe7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,7 +5,8 @@ All notable changes to this project will be documented in this file. ## [1.1.14] - Current ### Added -- **`test.runOnce(key, fn)`**: Execute a function exactly once per test run, even across worker restarts. Prevents re-running expensive operations (deployments, service provisioning, data seeding) when Playwright creates new workers after test failures. +- **`deploy()` built-in protection**: `rhdh.deploy()` now automatically skips if the deployment already succeeded in the current test run. No code changes needed — existing `beforeAll` patterns work as before, but deployments are no longer repeated when Playwright restarts workers after test failures. +- **`test.runOnce(key, fn)`**: Execute any function exactly once per test run, even across worker restarts. Use for expensive pre-deploy operations (external services, setup scripts, data seeding) that `deploy()` alone doesn't cover. Safe to nest with `deploy()`'s built-in protection. - **Teardown reporter**: Built-in Playwright reporter that automatically deletes Kubernetes namespaces after all tests complete. Active only in CI (`process.env.CI`). - **`registerTeardownNamespace(projectName, namespace)`**: Register custom namespaces for automatic cleanup. Import from `@red-hat-developer-hub/e2e-test-utils/teardown`. diff --git a/docs/guide/core-concepts/playwright-fixtures.md b/docs/guide/core-concepts/playwright-fixtures.md index f35a5c7..eba44c1 100644 --- a/docs/guide/core-concepts/playwright-fixtures.md +++ b/docs/guide/core-concepts/playwright-fixtures.md @@ -154,17 +154,36 @@ export default defineConfig({ }); ``` -## `test.runOnce` — Execute a Function Once Per Test Run +## Deployment Protection (Built-in) -Playwright's `beforeAll` runs once **per worker**, not once per test run. When a test fails, Playwright kills the worker and creates a new one for remaining tests — causing `beforeAll` to run again. For operations that are expensive or produce persistent side effects, this leads to unnecessary re-execution. +`rhdh.deploy()` is automatically protected against redundant re-execution. When a test fails and Playwright restarts the worker, `deploy()` detects that the deployment already succeeded and skips — no re-deployment, no wasted time. -`test.runOnce` ensures a function executes **exactly once per test run**, even across worker restarts: +This works out of the box. A simple `beforeAll` is all you need: ```typescript test.beforeAll(async ({ rhdh }) => { - await test.runOnce("my-plugin-deploy", async () => { + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); // runs once, skips on worker restart +}); +``` + +::: tip Why is this needed? +Playwright's `beforeAll` runs once **per worker**, not once per test run. When a test fails, Playwright kills the worker and creates a new one for remaining tests — causing `beforeAll` to run again. Without protection, this would re-deploy RHDH from scratch every time a test fails. +::: + +## `test.runOnce` — Run Any Expensive Operation Once + +While `rhdh.deploy()` has built-in protection, you may have **other expensive operations** in your `beforeAll` that also shouldn't repeat on worker restart — deploying external services, seeding databases, running setup scripts, etc. + +`test.runOnce` ensures any function executes **exactly once per test run**, even across worker restarts: + +```typescript +test.beforeAll(async ({ rhdh }) => { + await test.runOnce("tech-radar-setup", async () => { await rhdh.configure({ auth: "keycloak" }); - await rhdh.deploy(); + await $`bash ${setupScript} ${namespace}`; // expensive external service + process.env.DATA_URL = await rhdh.k8sClient.getRouteLocation(namespace, "my-service"); + await rhdh.deploy(); // also protected internally, nesting is safe }); }); ``` @@ -173,40 +192,81 @@ test.beforeAll(async ({ rhdh }) => { - Uses file-based flags scoped to the Playwright runner process - When a worker restarts after a test failure, `runOnce` detects the flag and skips -- Any state created by the function (deployments, databases, services) stays alive +- Any state created by the function (deployments, services, data) stays alive - Flags reset automatically between test runs ### When to Use -Use `test.runOnce` when your `beforeAll` performs an operation that: -- Is **expensive** (deployments, database seeding, service provisioning) -- Creates **persistent state** that survives beyond the worker process (Kubernetes resources, external services, test data) -- Should **not repeat** once successfully completed - -Common examples: -- RHDH deployment (`rhdh.deploy()`) -- External service deployment (customization providers, mock APIs) -- Database seeding or migration -- Any setup script that takes significant time +| Scenario | What to use | +|----------|------------| +| Just `configure()` + `deploy()` | Nothing extra — `deploy()` is already protected | +| Pre-deploy setup (external services, scripts, env vars) + `deploy()` | Wrap the entire block in `test.runOnce` | +| Multiple independent expensive operations | Use separate `test.runOnce` calls with different keys | -### Key: Unique Identifier +### Examples -The `key` parameter must be unique across all `runOnce` calls in your test run. Use a descriptive name: +**Simple deployment — no `test.runOnce` needed:** ```typescript -// Deploy RHDH -await test.runOnce("tech-radar-deploy", async () => { +test.beforeAll(async ({ rhdh }) => { + await rhdh.configure({ auth: "keycloak" }); await rhdh.deploy(); }); +``` + +**Pre-deploy setup — wrap in `test.runOnce`:** + +```typescript +test.beforeAll(async ({ rhdh }) => { + await test.runOnce("tech-radar-full-setup", async () => { + await rhdh.configure({ auth: "keycloak" }); + await $`bash deploy-external-service.sh ${rhdh.deploymentConfig.namespace}`; + process.env.DATA_URL = await rhdh.k8sClient.getRouteLocation( + rhdh.deploymentConfig.namespace, "data-provider" + ); + await rhdh.deploy(); + }); +}); +``` + +**Multiple independent operations with separate keys:** + +```typescript +test.describe("Feature A", () => { + test.beforeAll(async ({ rhdh }) => { + await test.runOnce("seed-catalog-data", async () => { + await apiHelper.importEntity("https://example.com/catalog-info.yaml"); + }); + }); +}); -// Deploy an external service -await test.runOnce("tech-radar-data-provider", async () => { - await $`bash ${setupScript} ${namespace}`; +test.describe("Feature B", () => { + test.beforeAll(async () => { + await test.runOnce("deploy-mock-api", async () => { + await $`bash deploy-mock.sh`; + }); + }); }); +``` + +### Key: Unique Identifier + +The `key` parameter must be unique across all `runOnce` calls in your test run. Use a descriptive name that reflects the operation: + +```typescript +await test.runOnce("tech-radar-deploy", async () => { ... }); +await test.runOnce("tech-radar-data-provider", async () => { ... }); +await test.runOnce("catalog-seed-data", async () => { ... }); +``` + +### Nesting -// Seed test data -await test.runOnce("catalog-seed-data", async () => { - await apiHelper.importEntity("https://example.com/catalog-info.yaml"); +`test.runOnce` can be safely nested. Since `rhdh.deploy()` uses `runOnce` internally, wrapping it in an outer `test.runOnce` is harmless — the outer call skips everything on worker restart, and the inner one never runs: + +```typescript +await test.runOnce("full-setup", async () => { + await $`bash setup.sh`; // protected by outer runOnce + await rhdh.deploy(); // has its own internal runOnce (harmless) }); ``` @@ -215,7 +275,7 @@ await test.runOnce("catalog-seed-data", async () => { In CI environments (`CI` environment variable is set), namespaces are automatically deleted after all tests complete. This is handled by a built-in **teardown reporter** that: 1. Runs in the main Playwright process (survives worker restarts) -2. Waits for **all tests** in a project to finish +2. Waits for **all tests** to finish 3. Deletes the namespace matching the project name ### Default Behavior @@ -238,11 +298,9 @@ If you deploy to a namespace that differs from the project name, register it for import { registerTeardownNamespace } from "@red-hat-developer-hub/e2e-test-utils/teardown"; test.beforeAll(async ({ rhdh }) => { - await test.runOnce("custom-deploy", async () => { - await rhdh.configure({ namespace: "my-custom-ns", auth: "keycloak" }); - await rhdh.deploy(); - registerTeardownNamespace("my-project", "my-custom-ns"); - }); + await rhdh.configure({ namespace: "my-custom-ns", auth: "keycloak" }); + await rhdh.deploy(); + registerTeardownNamespace("my-project", "my-custom-ns"); }); ``` @@ -300,14 +358,12 @@ import { test, expect } from "@red-hat-developer-hub/e2e-test-utils/test"; test.describe("My Plugin Tests", () => { test.beforeAll(async ({ rhdh }) => { - await test.runOnce("my-plugin-deploy", async () => { - await rhdh.configure({ - auth: "keycloak", - appConfig: "tests/config/app-config.yaml", - dynamicPlugins: "tests/config/plugins.yaml", - }); - await rhdh.deploy(); + await rhdh.configure({ + auth: "keycloak", + appConfig: "tests/config/app-config.yaml", + dynamicPlugins: "tests/config/plugins.yaml", }); + await rhdh.deploy(); }); test.beforeEach(async ({ page, loginHelper }) => { diff --git a/docs/guide/deployment/rhdh-deployment.md b/docs/guide/deployment/rhdh-deployment.md index f28491c..1c742b8 100644 --- a/docs/guide/deployment/rhdh-deployment.md +++ b/docs/guide/deployment/rhdh-deployment.md @@ -33,10 +33,8 @@ import { test } from "@red-hat-developer-hub/e2e-test-utils/test"; test.beforeAll(async ({ rhdh }) => { // rhdh is already instantiated with namespace from project name - await test.runOnce("my-plugin-deploy", async () => { - await rhdh.configure({ auth: "keycloak" }); - await rhdh.deploy(); - }); + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); // automatically skips if already deployed }); test("example", async ({ rhdh }) => { @@ -112,6 +110,8 @@ test.setTimeout(900_000); await rhdh.deploy({ timeout: null }); ``` +`deploy()` automatically skips if the deployment already succeeded in the current test run (e.g., after a worker restart due to test failure). This prevents expensive re-deployments. + This method: 1. Merges configuration files (common → auth → project) 2. [Injects plugin metadata](/guide/configuration/config-files#plugin-metadata-injection) into dynamic plugins config @@ -261,7 +261,8 @@ import { test } from "@red-hat-developer-hub/e2e-test-utils/test"; import { $ } from "@red-hat-developer-hub/e2e-test-utils/utils"; test.beforeAll(async ({ rhdh }) => { - await test.runOnce("my-plugin-deploy", async () => { + // Wrap in test.runOnce because the setup script is also expensive + await test.runOnce("my-plugin-setup", async () => { const namespace = rhdh.deploymentConfig.namespace; // Configure RHDH @@ -276,7 +277,7 @@ test.beforeAll(async ({ rhdh }) => { "my-service" ); - // Deploy RHDH (uses env vars set above) + // Deploy RHDH (has built-in protection, safe to nest inside runOnce) await rhdh.deploy(); }); }); diff --git a/docs/overlay/test-structure/spec-files.md b/docs/overlay/test-structure/spec-files.md index bdeed71..b53301e 100644 --- a/docs/overlay/test-structure/spec-files.md +++ b/docs/overlay/test-structure/spec-files.md @@ -26,12 +26,10 @@ A typical spec file follows this structure: import { test, expect, Page } from "@red-hat-developer-hub/e2e-test-utils/test"; test.describe("Test ", () => { - // Setup: Deploy RHDH once per test run + // Setup: Deploy RHDH test.beforeAll(async ({ rhdh }) => { - await test.runOnce("my-plugin-deploy", async () => { - await rhdh.configure({ auth: "keycloak" }); - await rhdh.deploy(); - }); + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); // automatically skips if already deployed }); // Login before each test @@ -46,8 +44,8 @@ test.describe("Test ", () => { }); ``` -::: info Why `test.runOnce`? -When a test fails, Playwright kills the worker and creates a new one for remaining tests. Without `runOnce`, `beforeAll` would re-execute expensive setup (deployments, service provisioning, data seeding) from scratch. `runOnce` ensures the operation happens only once; remaining tests reuse the existing state. See [Playwright Fixtures — `test.runOnce`](/guide/core-concepts/playwright-fixtures#test-runonce-—-execute-a-function-once-per-test-run) for details. +::: tip Automatic deployment protection +`rhdh.deploy()` automatically skips if the deployment already succeeded — even after Playwright restarts the worker due to a test failure. No extra wrapping needed for simple setups. For pre-deploy setup (external services, scripts), wrap the entire block in `test.runOnce`. See [Deployment Protection](/guide/core-concepts/playwright-fixtures#deployment-protection-built-in) for details. ::: ::: info Automatic Cleanup @@ -94,14 +92,12 @@ For plugins that only need configuration (no external services): ```typescript test.beforeAll(async ({ rhdh }) => { - await test.runOnce("my-plugin-deploy", async () => { - await rhdh.configure({ auth: "keycloak" }); - await rhdh.deploy(); - }); + await rhdh.configure({ auth: "keycloak" }); + await rhdh.deploy(); }); ``` -This is the simplest setup. RHDH is configured and deployed directly. Plugin configuration comes from `tests/config/app-config-rhdh.yaml`. +This is the simplest setup. RHDH is configured and deployed directly. `deploy()` automatically skips if already deployed (e.g., after a worker restart). Plugin configuration comes from `tests/config/app-config-rhdh.yaml`. ### Scenario 2: With Pre-requisites (External Services) @@ -113,6 +109,8 @@ Some plugins require external services to be running before RHDH starts. For exa 3. Get service URL and set as environment variable 4. Deploy RHDH (uses the environment variable in its configuration) +Since this setup includes an external service deployment (step 2) that is expensive and shouldn't repeat, wrap the entire block in `test.runOnce`: + ```typescript import { $ } from "@red-hat-developer-hub/e2e-test-utils/utils"; import path from "path"; @@ -123,7 +121,7 @@ const setupScript = path.join( ); test.beforeAll(async ({ rhdh }) => { - await test.runOnce("tech-radar-deploy", async () => { + await test.runOnce("tech-radar-setup", async () => { const project = rhdh.deploymentConfig.namespace; // 1. Configure RHDH first @@ -140,7 +138,7 @@ test.beforeAll(async ({ rhdh }) => { ) ).replace("http://", ""); - // 4. Deploy RHDH (will use TECH_RADAR_DATA_URL from rhdh-secrets.yaml) + // 4. Deploy RHDH (has its own built-in protection, nesting is safe) await rhdh.deploy(); }); }); @@ -154,10 +152,8 @@ For simpler tests without Keycloak: ```typescript test.beforeAll(async ({ rhdh }) => { - await test.runOnce("my-plugin-guest-deploy", async () => { - await rhdh.configure({ auth: "guest" }); - await rhdh.deploy(); - }); + await rhdh.configure({ auth: "guest" }); + await rhdh.deploy(); }); test.beforeEach(async ({ loginHelper }) => { @@ -265,7 +261,7 @@ const setupScript = path.join( test.describe("Test tech-radar plugin", () => { test.beforeAll(async ({ rhdh }) => { - await test.runOnce("tech-radar-deploy", async () => { + await test.runOnce("tech-radar-setup", async () => { const project = rhdh.deploymentConfig.namespace; await rhdh.configure({ auth: "keycloak" }); await $`bash ${setupScript} ${project}`; @@ -275,7 +271,7 @@ test.describe("Test tech-radar plugin", () => { "test-backstage-customization-provider", ) ).replace("http://", ""); - await rhdh.deploy(); + await rhdh.deploy(); // built-in protection, safe to nest inside runOnce }); }); diff --git a/src/deployment/rhdh/deployment.ts b/src/deployment/rhdh/deployment.ts index 480340f..311f6bf 100644 --- a/src/deployment/rhdh/deployment.ts +++ b/src/deployment/rhdh/deployment.ts @@ -8,6 +8,7 @@ import { generateDynamicPluginsConfigFromMetadata, } from "../../utils/plugin-metadata.js"; import { envsubst } from "../../utils/common.js"; +import { runOnce } from "../../playwright/run-once.js"; import cloneDeepWith from "lodash.clonedeepwith"; import fs from "fs-extra"; import { @@ -38,28 +39,40 @@ export class RHDHDeployment { } async deploy(options?: { timeout?: number | null }): Promise { - this._log("Starting RHDH deployment..."); // Default 600s, custom number to override, null to skip and let consumer control the timeout const timeout = options?.timeout === undefined ? 600_000 : options.timeout; if (timeout !== null) { test.setTimeout(timeout); } - await this.k8sClient.createNamespaceIfNotExists( - this.deploymentConfig.namespace, - ); + const executed = await runOnce( + `deploy-${this.deploymentConfig.namespace}`, + async () => { + this._log("Starting RHDH deployment..."); - await this._applyAppConfig(); - await this._applySecrets(); + await this.k8sClient.createNamespaceIfNotExists( + this.deploymentConfig.namespace, + ); - if (this.deploymentConfig.method === "helm") { - await this._deployWithHelm(this.deploymentConfig.valueFile); - await this.scaleDownAndRestart(); // Restart as helm does not monitor config changes - } else { - await this._applyDynamicPlugins(); - await this._deployWithOperator(this.deploymentConfig.subscription); + await this._applyAppConfig(); + await this._applySecrets(); + + if (this.deploymentConfig.method === "helm") { + await this._deployWithHelm(this.deploymentConfig.valueFile); + await this.scaleDownAndRestart(); // Restart as helm does not monitor config changes + } else { + await this._applyDynamicPlugins(); + await this._deployWithOperator(this.deploymentConfig.subscription); + } + await this.waitUntilReady(); + }, + ); + + if (!executed) { + this._log( + `Deployment already completed for namespace "${this.deploymentConfig.namespace}", skipping`, + ); } - await this.waitUntilReady(); } private async _applyAppConfig(): Promise { diff --git a/src/playwright/fixtures/test.ts b/src/playwright/fixtures/test.ts index 8165ba2..15c8762 100644 --- a/src/playwright/fixtures/test.ts +++ b/src/playwright/fixtures/test.ts @@ -1,52 +1,7 @@ import { RHDHDeployment } from "../../deployment/rhdh/index.js"; import { test as base } from "@playwright/test"; import { LoginHelper, UIhelper } from "../helpers/index.js"; -import fs from "fs"; -import path from "path"; -import os from "os"; -import lockfile from "proper-lockfile"; - -// Each test run gets its own flag directory (ppid = Playwright runner PID) -const flagDir = path.join(os.tmpdir(), `playwright-once-${process.ppid}`); - -/** - * Executes a function only once per test run, even across multiple workers. - * Automatically resets between test runs (each run uses a unique flag directory). - * Safe for fullyParallel: true (uses proper-lockfile for cross-process coordination). - * - * @param key - Unique identifier for this setup operation - * @param fn - Function to execute once - * @returns true if executed, false if skipped (already ran) - */ -async function runOnce( - key: string, - fn: () => Promise | void, -): Promise { - const flagFile = path.join(flagDir, `${key}.done`); - const lockTarget = path.join(flagDir, key); - - fs.mkdirSync(flagDir, { recursive: true }); - - // already executed, skip without locking - if (fs.existsSync(flagFile)) return false; - - // Ensure lock target file exists - fs.writeFileSync(lockTarget, "", { flag: "a" }); - const release = await lockfile.lock(lockTarget, { - retries: { retries: 30, minTimeout: 200 }, - stale: 300_000, - }); - - try { - // Double-check after acquiring lock - if (fs.existsSync(flagFile)) return false; - await fn(); - fs.writeFileSync(flagFile, ""); - return true; - } finally { - await release(); - } -} +import { runOnce } from "../run-once.js"; type RHDHDeploymentTestFixtures = { rhdh: RHDHDeployment; diff --git a/src/playwright/run-once.ts b/src/playwright/run-once.ts new file mode 100644 index 0000000..7cb4d61 --- /dev/null +++ b/src/playwright/run-once.ts @@ -0,0 +1,46 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; +import lockfile from "proper-lockfile"; + +// Each test run gets its own flag directory (ppid = Playwright runner PID) +const flagDir = path.join(os.tmpdir(), `playwright-once-${process.ppid}`); + +/** + * Executes a function only once per test run, even across multiple workers. + * Automatically resets between test runs (each run uses a unique flag directory). + * Safe for fullyParallel: true (uses proper-lockfile for cross-process coordination). + * + * @param key - Unique identifier for this setup operation + * @param fn - Function to execute once + * @returns true if executed, false if skipped (already ran) + */ +export async function runOnce( + key: string, + fn: () => Promise | void, +): Promise { + const flagFile = path.join(flagDir, `${key}.done`); + const lockTarget = path.join(flagDir, key); + + fs.mkdirSync(flagDir, { recursive: true }); + + // already executed, skip without locking + if (fs.existsSync(flagFile)) return false; + + // Ensure lock target file exists + fs.writeFileSync(lockTarget, "", { flag: "a" }); + const release = await lockfile.lock(lockTarget, { + retries: { retries: 30, minTimeout: 200 }, + stale: 300_000, + }); + + try { + // Double-check after acquiring lock + if (fs.existsSync(flagFile)) return false; + await fn(); + fs.writeFileSync(flagFile, ""); + return true; + } finally { + await release(); + } +} diff --git a/src/playwright/teardown-namespaces.ts b/src/playwright/teardown-namespaces.ts index f6dea54..d82f92c 100644 --- a/src/playwright/teardown-namespaces.ts +++ b/src/playwright/teardown-namespaces.ts @@ -15,7 +15,9 @@ type TeardownRegistry = Record; function read(): TeardownRegistry { if (!fs.existsSync(TEARDOWN_FILE)) return {}; - return JSON.parse(fs.readFileSync(TEARDOWN_FILE, "utf-8")) as TeardownRegistry; + return JSON.parse( + fs.readFileSync(TEARDOWN_FILE, "utf-8"), + ) as TeardownRegistry; } /** diff --git a/src/playwright/teardown-reporter.ts b/src/playwright/teardown-reporter.ts index a863bea..6fa2233 100644 --- a/src/playwright/teardown-reporter.ts +++ b/src/playwright/teardown-reporter.ts @@ -27,21 +27,32 @@ export default class TeardownReporter implements Reporter { try { k8sClient = new KubernetesClientHelper(); } catch (error) { - console.error(`[TeardownReporter] Cannot connect to cluster, skipping teardown:`, error); + console.error( + `[TeardownReporter] Cannot connect to cluster, skipping teardown:`, + error, + ); return; } for (const projectName of this._projects) { const customNamespaces = getTeardownNamespaces(projectName); - const namespaces = customNamespaces.length > 0 ? customNamespaces : [projectName]; + const namespaces = + customNamespaces.length > 0 ? customNamespaces : [projectName]; for (const ns of namespaces) { - console.log(`[TeardownReporter] Deleting namespace "${ns}" (project: ${projectName})`); + console.log( + `[TeardownReporter] Deleting namespace "${ns}" (project: ${projectName})`, + ); try { await k8sClient.deleteNamespace(ns); - console.log(`[TeardownReporter] Namespace "${ns}" deleted successfully`); + console.log( + `[TeardownReporter] Namespace "${ns}" deleted successfully`, + ); } catch (error) { - console.error(`[TeardownReporter] Failed to delete namespace "${ns}":`, error); + console.error( + `[TeardownReporter] Failed to delete namespace "${ns}":`, + error, + ); } } } From b16673ce2c78a0aa1726fc3fc8c656f9f322c89f Mon Sep 17 00:00:00 2001 From: Subhash Khileri Date: Fri, 6 Mar 2026 15:44:11 +0530 Subject: [PATCH 3/4] suppress the rhdh configure logs --- src/deployment/rhdh/deployment.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/deployment/rhdh/deployment.ts b/src/deployment/rhdh/deployment.ts index 311f6bf..1d66f5c 100644 --- a/src/deployment/rhdh/deployment.ts +++ b/src/deployment/rhdh/deployment.ts @@ -31,11 +31,6 @@ export class RHDHDeployment { constructor(namespace: string) { this.deploymentConfig = this._buildDeploymentConfig({ namespace }); this.rhdhUrl = this._buildBaseUrl(); - this._log( - `RHDH deployment initialized (namespace: ${this.deploymentConfig.namespace})`, - ); - this._log("RHDH Base URL: " + this.rhdhUrl); - console.table(this.deploymentConfig); } async deploy(options?: { timeout?: number | null }): Promise { @@ -49,6 +44,8 @@ export class RHDHDeployment { `deploy-${this.deploymentConfig.namespace}`, async () => { this._log("Starting RHDH deployment..."); + this._log("RHDH Base URL: " + this.rhdhUrl); + console.table(this.deploymentConfig); await this.k8sClient.createNamespaceIfNotExists( this.deploymentConfig.namespace, @@ -426,10 +423,6 @@ export class RHDHDeployment { if (deploymentOptions) { this.deploymentConfig = this._buildDeploymentConfig(deploymentOptions); this.rhdhUrl = this._buildBaseUrl(); - this._log( - `RHDH deployment initialized (namespace: ${this.deploymentConfig.namespace})`, - ); - console.table(this.deploymentConfig); } await this.k8sClient.createNamespaceIfNotExists( this.deploymentConfig.namespace, From 35335352df8265cd8f16a40eb34e761e74954689 Mon Sep 17 00:00:00 2001 From: Subhash Khileri Date: Fri, 6 Mar 2026 16:13:33 +0530 Subject: [PATCH 4/4] suppress the fixture logs --- src/playwright/fixtures/test.ts | 4 ---- src/playwright/teardown-reporter.ts | 12 +----------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/playwright/fixtures/test.ts b/src/playwright/fixtures/test.ts index 15c8762..dadc9fc 100644 --- a/src/playwright/fixtures/test.ts +++ b/src/playwright/fixtures/test.ts @@ -20,10 +20,6 @@ const baseTest = base.extend< rhdhDeploymentWorker: [ // eslint-disable-next-line no-empty-pattern async ({}, use, workerInfo) => { - console.log( - `Deploying rhdh for plugin ${workerInfo.project.name} in namespace ${workerInfo.project.name}`, - ); - const rhdhDeployment = new RHDHDeployment(workerInfo.project.name); await rhdhDeployment.configure(); diff --git a/src/playwright/teardown-reporter.ts b/src/playwright/teardown-reporter.ts index 6fa2233..709c726 100644 --- a/src/playwright/teardown-reporter.ts +++ b/src/playwright/teardown-reporter.ts @@ -43,17 +43,7 @@ export default class TeardownReporter implements Reporter { console.log( `[TeardownReporter] Deleting namespace "${ns}" (project: ${projectName})`, ); - try { - await k8sClient.deleteNamespace(ns); - console.log( - `[TeardownReporter] Namespace "${ns}" deleted successfully`, - ); - } catch (error) { - console.error( - `[TeardownReporter] Failed to delete namespace "${ns}":`, - error, - ); - } + await k8sClient.deleteNamespace(ns); } } }