Repair hotfix route and action flows

This commit is contained in:
master
2026-03-10 18:06:14 +02:00
parent bb8327087d
commit f401a7182c
8 changed files with 312 additions and 6 deletions

View File

@@ -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.

View File

@@ -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'],

View File

@@ -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);
});

View File

@@ -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: `
<section class="hotfixes">
@@ -38,7 +41,15 @@ interface HotfixRow {
<td>{{ row.targetEnv }}</td>
<td>{{ row.urgency }}</td>
<td>{{ row.gates }}</td>
<td><button type="button">Review</button></td>
<td>
<a
class="action-link"
[routerLink]="['/releases/hotfixes', row.hotfixId]"
queryParamsHandling="preserve"
>
Review
</a>
</td>
</tr>
}
</tbody>
@@ -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',

View File

@@ -13,6 +13,10 @@ function redirectRunTab(runId: string, tab: string, queryParams: Record<string,
}
function preserveReleasesRedirect(template: string) {
return preserveReleasesRedirectWithQuery(template);
}
function preserveReleasesRedirectWithQuery(template: string, fixedQueryParams: Record<string, string> = {}) {
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',

View File

@@ -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&regions=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',

View File

@@ -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');
});
});

View File

@@ -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',
]);
});
});