From f401a7182ced310c8bf139832002b0fb0afdc686 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 10 Mar 2026 18:06:14 +0200 Subject: [PATCH] Repair hotfix route and action flows --- ...0_031_FE_hotfix_route_and_action_repair.md | 45 +++++ .../live-frontdoor-canonical-route-sweep.mjs | 1 + .../scripts/live-hotfix-action-check.mjs | 184 ++++++++++++++++++ .../hotfixes/hotfixes-queue.component.ts | 21 +- .../src/app/routes/releases.routes.ts | 13 +- .../routes/route-surface-ownership.spec.ts | 20 ++ .../hotfixes-queue.component.spec.ts | 24 +++ .../release-control-routes.spec.ts | 10 +- 8 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 docs/implplan/SPRINT_20260310_031_FE_hotfix_route_and_action_repair.md create mode 100644 src/Web/StellaOps.Web/scripts/live-hotfix-action-check.mjs create mode 100644 src/Web/StellaOps.Web/src/tests/release-control/hotfixes-queue.component.spec.ts diff --git a/docs/implplan/SPRINT_20260310_031_FE_hotfix_route_and_action_repair.md b/docs/implplan/SPRINT_20260310_031_FE_hotfix_route_and_action_repair.md new file mode 100644 index 000000000..dfc7bb72f --- /dev/null +++ b/docs/implplan/SPRINT_20260310_031_FE_hotfix_route_and_action_repair.md @@ -0,0 +1,45 @@ +# Sprint 20260310_031 - Hotfix Route And Action Repair + +## Topic & Scope +- Remove dead hotfix actions from the Releases surface and converge hotfix creation on the shipped canonical release creation workflow. +- Repair the hotfix queue so `Review` opens the existing detail surface instead of doing nothing. +- Working directory: `src/Web/StellaOps.Web/src/app/routes`. +- Expected evidence: focused Angular route/component tests, live Playwright hotfix action sweep, rebuilt web bundle synced into the local compose stack. + +## Dependencies & Concurrency +- Depends on the current local Stella Ops stack staying reachable at `https://stella-ops.local`. +- Safe parallelism: bounded to Releases route wiring, hotfix queue UI, and supporting Playwright harnesses. + +## Documentation Prerequisites +- `docs/qa/feature-checks/FLOW.md` +- `docs/modules/platform/architecture-overview.md` + +## Delivery Tracker + +### TASK-01 - Repair hotfix create and review actions +Status: DONE +Dependency: none +Owners: QA, 3rd line support, Product Manager, Architect, Developer +Task description: +- The current hotfix queue and `/releases/hotfixes/new` route expose active controls that do not perform any user-visible action. This violates the zero-tolerance QA bar for live routes and actionability. +- Diagnose the broken interactions, confirm the canonical shipped workflow, and repair the hotfix route contract and queue actions without reviving duplicate placeholder UI. + +Completion criteria: +- [x] `/releases/hotfixes/new` lands on the canonical release creation workflow with `type=hotfix` and `hotfixLane=true` while preserving scope query params. +- [x] The hotfix queue `Review` action opens `/releases/hotfixes/:hotfixId` and preserves current scope. +- [x] Focused route/component tests cover the redirect and queue link behavior. +- [x] A live Playwright hotfix action sweep passes with zero failed actions and zero runtime issues. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-10 | Sprint created for live hotfix route and action repair after Playwright found inert `Review` and `Submit For Review` controls. | QA | +| 2026-03-10 | Root cause confirmed: `/releases/hotfixes/new` was a dead placeholder form and the queue `Review` action was an inert button. Redirected hotfix creation to the canonical release creation workflow, rewired `Review` to the existing detail route, rebuilt/synced the web bundle, and passed focused Angular coverage plus live Playwright hotfix and canonical route sweeps (`111/111`). | QA / Developer | + +## Decisions & Risks +- Decision: keep `/releases/versions/new` as the canonical hotfix creation workflow and make `/releases/hotfixes/new` a compatibility redirect instead of extending the dead placeholder `HotfixCreatePageComponent`. +- Decision: use the existing hotfix detail page for queue review instead of inventing a new modal or secondary workflow. +- Decision: update the canonical route sweep contract so `/releases/hotfixes/new` is accepted as a compatibility redirect to `/releases/versions/new`; the dedicated hotfix action sweep remains responsible for asserting `type=hotfix` and `hotfixLane=true`. + +## Next Checkpoints +- Move to the next deep action sweep under Releases after this scoped commit. diff --git a/src/Web/StellaOps.Web/scripts/live-frontdoor-canonical-route-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-frontdoor-canonical-route-sweep.mjs index 254b7e3a4..56f2b20d6 100644 --- a/src/Web/StellaOps.Web/scripts/live-frontdoor-canonical-route-sweep.mjs +++ b/src/Web/StellaOps.Web/scripts/live-frontdoor-canonical-route-sweep.mjs @@ -186,6 +186,7 @@ const strictRouteExpectations = { const allowedFinalPaths = { '/releases': ['/releases/deployments'], '/releases/promotion-queue': ['/releases/promotions'], + '/releases/hotfixes/new': ['/releases/versions/new'], '/ops/policy': ['/ops/policy/overview'], '/ops/policy/audit': ['/ops/policy/audit/policy'], '/ops/platform-setup/trust-signing': ['/setup/trust-signing'], diff --git a/src/Web/StellaOps.Web/scripts/live-hotfix-action-check.mjs b/src/Web/StellaOps.Web/scripts/live-hotfix-action-check.mjs new file mode 100644 index 000000000..c7996c943 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-hotfix-action-check.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const webRoot = path.resolve(__dirname, '..'); +const outputDirectory = path.join(webRoot, 'output', 'playwright'); + +const BASE_URL = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local'; +const STATE_PATH = path.join(outputDirectory, 'live-frontdoor-auth-state.json'); +const REPORT_PATH = path.join(outputDirectory, 'live-frontdoor-auth-report.json'); +const RESULT_PATH = path.join(outputDirectory, 'live-hotfix-action-check.json'); +const EXPECTED_SCOPE = { + tenant: 'demo-prod', + regions: 'us-east', + environments: 'stage', + timeWindow: '7d', +}; +const HOTFIX_LIST_URL = new URL('/releases/hotfixes', BASE_URL); +const HOTFIX_CREATE_URL = new URL('/releases/hotfixes/new', BASE_URL); + +for (const [key, value] of Object.entries(EXPECTED_SCOPE)) { + HOTFIX_LIST_URL.searchParams.set(key, value); + HOTFIX_CREATE_URL.searchParams.set(key, value); +} + +function collectScopeIssues(url, expectedScope, label) { + const issues = []; + const parsed = new URL(url); + + for (const [key, expectedValue] of Object.entries(expectedScope)) { + const actualValue = parsed.searchParams.get(key); + if (actualValue !== expectedValue) { + issues.push(`${label} expected ${key}=${expectedValue} but got ${actualValue ?? '(missing)'}`); + } + } + + return issues; +} + +function shouldIgnoreRequestFailure(request) { + const url = request.url(); + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return true; + } + + const error = request.failure()?.errorText ?? ''; + return error.includes('net::ERR_ABORTED'); +} + +async function main() { + mkdirSync(outputDirectory, { recursive: true }); + + await authenticateFrontdoor({ + baseUrl: BASE_URL, + statePath: STATE_PATH, + reportPath: REPORT_PATH, + headless: true, + }); + + const authReport = JSON.parse(readFileSync(REPORT_PATH, 'utf8')); + const browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'] }); + + try { + const context = await createAuthenticatedContext(browser, authReport, { + statePath: STATE_PATH, + contextOptions: { + acceptDownloads: false, + }, + }); + const page = await context.newPage(); + const runtimeIssues = []; + const failedActions = []; + + page.on('console', (message) => { + if (message.type() === 'error') { + runtimeIssues.push(`console:${message.text()}`); + } + }); + + page.on('pageerror', (error) => { + runtimeIssues.push(`pageerror:${error.message}`); + }); + + page.on('response', (response) => { + const url = response.url(); + if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) { + return; + } + + if (response.status() >= 400) { + runtimeIssues.push(`response:${response.status()}:${response.request().method()}:${url}`); + } + }); + + page.on('requestfailed', (request) => { + if (shouldIgnoreRequestFailure(request)) { + return; + } + + runtimeIssues.push(`requestfailed:${request.method()}:${request.url()}:${request.failure()?.errorText ?? 'unknown'}`); + }); + + const result = { + checkedAtUtc: new Date().toISOString(), + hotfixListUrl: '', + hotfixListHeading: '', + hotfixReviewUrl: '', + hotfixReviewHeading: '', + hotfixCreateUrl: '', + hotfixCreateHeading: '', + failedActionCount: 0, + failedActions, + runtimeIssueCount: 0, + runtimeIssues, + scopeIssues: [], + }; + + await page.goto(HOTFIX_LIST_URL.toString(), { waitUntil: 'networkidle', timeout: 30_000 }); + result.hotfixListUrl = page.url(); + result.hotfixListHeading = (await page.locator('h1').first().textContent().catch(() => '')) ?? ''; + + await Promise.all([ + page.waitForURL(/\/releases\/hotfixes\/platform-bundle-1-3-1-hotfix1/, { timeout: 10_000 }), + page.getByRole('link', { name: 'Review' }).click(), + ]); + await page.waitForLoadState('networkidle'); + result.hotfixReviewUrl = page.url(); + result.hotfixReviewHeading = (await page.locator('h1').first().textContent().catch(() => '')) ?? ''; + result.scopeIssues.push(...collectScopeIssues(result.hotfixReviewUrl, EXPECTED_SCOPE, 'hotfixReviewUrl')); + + if (!new URL(result.hotfixReviewUrl).pathname.endsWith('/releases/hotfixes/platform-bundle-1-3-1-hotfix1')) { + failedActions.push(`Review expected /releases/hotfixes/platform-bundle-1-3-1-hotfix1 but landed on ${result.hotfixReviewUrl}`); + } + + if (!result.hotfixReviewHeading.includes('platform-bundle-1-3-1-hotfix1')) { + failedActions.push(`Review expected hotfix detail heading but found "${result.hotfixReviewHeading}"`); + } + + await page.goto(HOTFIX_CREATE_URL.toString(), { waitUntil: 'networkidle', timeout: 30_000 }); + result.hotfixCreateUrl = page.url(); + result.hotfixCreateHeading = (await page.locator('h1').first().textContent().catch(() => '')) ?? ''; + result.scopeIssues.push(...collectScopeIssues(result.hotfixCreateUrl, EXPECTED_SCOPE, 'hotfixCreateUrl')); + + const createUrl = new URL(result.hotfixCreateUrl); + if (createUrl.pathname !== '/releases/versions/new') { + failedActions.push(`Create Hotfix expected /releases/versions/new but landed on ${result.hotfixCreateUrl}`); + } + + if (createUrl.searchParams.get('type') !== 'hotfix' || createUrl.searchParams.get('hotfixLane') !== 'true') { + failedActions.push(`Create Hotfix expected type=hotfix and hotfixLane=true but landed on ${result.hotfixCreateUrl}`); + } + + if (result.hotfixCreateHeading !== 'Create Release Version') { + failedActions.push(`Create Hotfix expected "Create Release Version" heading but found "${result.hotfixCreateHeading}"`); + } + + result.failedActionCount = failedActions.length; + result.runtimeIssueCount = runtimeIssues.length + result.scopeIssues.length; + result.runtimeIssues.push(...result.scopeIssues); + + writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, 'utf8'); + await context.close(); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + + if (result.failedActionCount > 0 || result.runtimeIssueCount > 0) { + throw new Error(`hotfix action check failed: failedActionCount=${result.failedActionCount} runtimeIssueCount=${result.runtimeIssueCount}`); + } + } finally { + await browser.close(); + } +} + +main().catch((error) => { + process.stderr.write(`[live-hotfix-action-check] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts b/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts index 1f202c98e..7e70700e3 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts @@ -1,6 +1,8 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { RouterLink } from '@angular/router'; interface HotfixRow { + hotfixId: string; bundle: string; targetEnv: string; urgency: string; @@ -10,6 +12,7 @@ interface HotfixRow { @Component({ selector: 'app-hotfixes-queue', standalone: true, + imports: [RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -38,7 +41,15 @@ interface HotfixRow { {{ row.targetEnv }} {{ row.urgency }} {{ row.gates }} - + + + Review + + } @@ -90,13 +101,18 @@ interface HotfixRow { color: var(--color-text-secondary); } - td button { + .action-link { + display: inline-flex; + align-items: center; + justify-content: center; border: 1px solid var(--color-border-primary); background: var(--color-surface-primary); border-radius: var(--radius-md); padding: 0.25rem 0.45rem; font-size: 0.74rem; cursor: pointer; + color: inherit; + text-decoration: none; } `, ], @@ -104,6 +120,7 @@ interface HotfixRow { export class HotfixesQueueComponent { readonly hotfixes: HotfixRow[] = [ { + hotfixId: 'platform-bundle-1-3-1-hotfix1', bundle: 'platform-bundle@1.3.1-hotfix1', targetEnv: 'prod-eu', urgency: 'Critical', diff --git a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts index ba109e02d..4f27e93b5 100644 --- a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts @@ -13,6 +13,10 @@ function redirectRunTab(runId: string, tab: string, queryParams: Record = {}) { return ({ params, queryParams, @@ -30,7 +34,7 @@ function preserveReleasesRedirect(template: string) { } const target = router.parseUrl(targetPath); - target.queryParams = { ...queryParams }; + target.queryParams = { ...queryParams, ...fixedQueryParams }; target.fragment = fragment ?? null; return target; }; @@ -170,8 +174,11 @@ export const RELEASES_ROUTES: Routes = [ path: 'hotfixes/new', title: 'Create Hotfix', data: { breadcrumb: 'Create Hotfix' }, - loadComponent: () => - import('../features/releases/hotfix-create-page.component').then((m) => m.HotfixCreatePageComponent), + pathMatch: 'full', + redirectTo: preserveReleasesRedirectWithQuery('/releases/versions/new', { + type: 'hotfix', + hotfixLane: 'true', + }), }, { path: 'hotfixes/:hotfixId', diff --git a/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts b/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts index 2e50f8f7d..1fc008e6e 100644 --- a/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts +++ b/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts @@ -91,6 +91,26 @@ describe('Route surface ownership', () => { expect(typeof environmentDetailRoute?.loadComponent).toBe('function'); }); + it('redirects hotfix creation aliases into the canonical release creation workflow', () => { + const hotfixCreateRoute = RELEASES_ROUTES.find((route) => route.path === 'hotfixes/new'); + const redirect = hotfixCreateRoute?.redirectTo; + + if (typeof redirect !== 'function') { + throw new Error('hotfixes/new must expose a redirect function.'); + } + + expect( + invokeRedirect(redirect, { + params: {}, + queryParams: { + tenant: 'demo-prod', + regions: 'us-east', + environments: 'stage', + }, + }), + ).toBe('/releases/versions/new?tenant=demo-prod®ions=us-east&environments=stage&type=hotfix&hotfixLane=true'); + }); + it('maps legacy release environment shortcuts to the canonical Releases inventory', () => { const releaseOrchestratorRoute = LEGACY_REDIRECT_ROUTE_TEMPLATES.find( (route) => route.path === 'release-orchestrator/environments', diff --git a/src/Web/StellaOps.Web/src/tests/release-control/hotfixes-queue.component.spec.ts b/src/Web/StellaOps.Web/src/tests/release-control/hotfixes-queue.component.spec.ts new file mode 100644 index 000000000..8798c0216 --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/release-control/hotfixes-queue.component.spec.ts @@ -0,0 +1,24 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +import { HotfixesQueueComponent } from '../../app/features/release-control/hotfixes/hotfixes-queue.component'; + +describe('HotfixesQueueComponent', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HotfixesQueueComponent], + providers: [provideRouter([])], + }).compileComponents(); + }); + + it('renders Review as a detail link for the queued hotfix', () => { + const fixture = TestBed.createComponent(HotfixesQueueComponent); + fixture.detectChanges(); + + const reviewLink = fixture.nativeElement.querySelector('.action-link') as HTMLAnchorElement | null; + + expect(reviewLink).not.toBeNull(); + expect(reviewLink?.getAttribute('href')).toBe('/releases/hotfixes/platform-bundle-1-3-1-hotfix1'); + expect(reviewLink?.textContent?.trim()).toBe('Review'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/tests/release-control/release-control-routes.spec.ts b/src/Web/StellaOps.Web/src/tests/release-control/release-control-routes.spec.ts index 589203625..be4bbe2be 100644 --- a/src/Web/StellaOps.Web/src/tests/release-control/release-control-routes.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/release-control/release-control-routes.spec.ts @@ -48,7 +48,15 @@ describe('RELEASES_ROUTES (pre-alpha)', () => { it('uses redirects only for canonical run-shell entry points', () => { const redirectPaths = RELEASES_ROUTES.filter((route) => route.redirectTo).map((route) => route.path); - expect(redirectPaths).toEqual(['', 'runs/:runId', 'runs/:runId/:tab']); + expect(redirectPaths).toEqual([ + '', + 'runs/:runId', + 'runs/:runId/:tab', + 'promotion-queue', + 'promotion-queue/create', + 'promotion-queue/:promotionId', + 'hotfixes/new', + ]); }); });