Repair hotfix route and action flows
This commit is contained in:
@@ -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.
|
||||
@@ -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'],
|
||||
|
||||
184
src/Web/StellaOps.Web/scripts/live-hotfix-action-check.mjs
Normal file
184
src/Web/StellaOps.Web/scripts/live-hotfix-action-check.mjs
Normal 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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user