Close scratch iteration 008 and enforce full surface audits
This commit is contained in:
@@ -435,6 +435,54 @@ async function verifySurfaceActions(context, surface) {
|
||||
return results;
|
||||
}
|
||||
|
||||
function collectSurfaceIssues(surface, record) {
|
||||
const issues = [];
|
||||
|
||||
if (!record.headingMatched) {
|
||||
issues.push(`heading-mismatch:${surface.key}:${record.headingText || '<empty>'}`);
|
||||
}
|
||||
|
||||
for (const problemText of record.problemTexts) {
|
||||
issues.push(`problem-text:${surface.key}:${problemText}`);
|
||||
}
|
||||
|
||||
for (const errorText of record.consoleErrors) {
|
||||
issues.push(`console:${surface.key}:${errorText}`);
|
||||
}
|
||||
|
||||
for (const errorText of record.pageErrors) {
|
||||
issues.push(`pageerror:${surface.key}:${errorText}`);
|
||||
}
|
||||
|
||||
for (const failure of record.requestFailures) {
|
||||
issues.push(`requestfailed:${surface.key}:${failure.method} ${failure.url} ${failure.error}`);
|
||||
}
|
||||
|
||||
for (const failure of record.responseErrors) {
|
||||
issues.push(`response:${surface.key}:${failure.status} ${failure.method} ${failure.url}`);
|
||||
}
|
||||
|
||||
if (surface.searchQuery) {
|
||||
if (!record.search?.available) {
|
||||
issues.push(`search-unavailable:${surface.key}`);
|
||||
} else if (
|
||||
!record.search.resultsVisible
|
||||
&& record.search.suggestionCount === 0
|
||||
&& (record.search.resultsText || '').length === 0
|
||||
) {
|
||||
issues.push(`search-empty:${surface.key}:${surface.searchQuery}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const actionResult of record.actions) {
|
||||
if (!actionResult.ok) {
|
||||
issues.push(`action-failed:${surface.key}:${actionResult.key}:${actionResult.reason ?? actionResult.finalUrl ?? 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(outputDirectory, { recursive: true });
|
||||
const authReport = await authenticateFrontdoor({
|
||||
@@ -452,17 +500,27 @@ async function main() {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
baseUrl,
|
||||
surfaces: [],
|
||||
issues: [],
|
||||
};
|
||||
|
||||
for (const surface of surfaceConfigs) {
|
||||
const surfaceReport = await inspectSurface(context, surface);
|
||||
surfaceReport.actions = await verifySurfaceActions(context, surface);
|
||||
surfaceReport.issues = collectSurfaceIssues(surface, surfaceReport);
|
||||
surfaceReport.ok = surfaceReport.issues.length === 0;
|
||||
report.surfaces.push(surfaceReport);
|
||||
report.issues.push(...surfaceReport.issues);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
report.failedSurfaceCount = report.surfaces.filter((surface) => !surface.ok).length;
|
||||
report.runtimeIssueCount = report.issues.length;
|
||||
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
|
||||
if (report.failedSurfaceCount > 0 || report.runtimeIssueCount > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
||||
|
||||
@@ -12,6 +12,11 @@ const outputDir = path.join(webRoot, 'output', 'playwright');
|
||||
const resultPath = path.join(outputDir, 'live-full-core-audit.json');
|
||||
|
||||
const suites = [
|
||||
{
|
||||
name: 'route-surface-ownership-check',
|
||||
script: 'live-route-surface-ownership-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-route-surface-ownership-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'frontdoor-canonical-route-sweep',
|
||||
script: 'live-frontdoor-canonical-route-sweep.mjs',
|
||||
@@ -47,6 +52,11 @@ const suites = [
|
||||
script: 'live-user-reported-admin-trust-check.mjs',
|
||||
reportPath: path.join(outputDir, 'live-user-reported-admin-trust-check.json'),
|
||||
},
|
||||
{
|
||||
name: 'changed-surfaces',
|
||||
script: 'live-frontdoor-changed-surfaces.mjs',
|
||||
reportPath: path.join(outputDir, 'live-frontdoor-changed-surfaces.json'),
|
||||
},
|
||||
{
|
||||
name: 'jobs-queues-action-sweep',
|
||||
script: 'live-jobs-queues-action-sweep.mjs',
|
||||
@@ -122,6 +132,7 @@ const failureCountKeys = new Set([
|
||||
'failedCount',
|
||||
'failureCount',
|
||||
'errorCount',
|
||||
'failedSurfaceCount',
|
||||
'runtimeIssueCount',
|
||||
'issueCount',
|
||||
'unexpectedErrorCount',
|
||||
@@ -204,6 +215,10 @@ async function readReport(reportPath) {
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSummary(summary) {
|
||||
await writeFile(resultPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function runSuite({ name, script }) {
|
||||
return new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
@@ -236,6 +251,7 @@ async function main() {
|
||||
retriedSuiteCount: 0,
|
||||
stabilizedAfterRetryCount: 0,
|
||||
};
|
||||
await persistSummary(summary);
|
||||
|
||||
for (const suite of suites) {
|
||||
process.stdout.write(`[live-full-core-audit] START ${suite.name}\n`);
|
||||
@@ -292,6 +308,7 @@ async function main() {
|
||||
};
|
||||
|
||||
summary.suites.push(result);
|
||||
await persistSummary(summary);
|
||||
process.stdout.write(
|
||||
`[live-full-core-audit] DONE ${suite.name} ok=${ok} exitCode=${execution.exitCode ?? 'null'} ` +
|
||||
`signals=${failureSignals.length} durationMs=${execution.durationMs}` +
|
||||
@@ -313,7 +330,7 @@ async function main() {
|
||||
stabilizedAfterRetry: suite.stabilizedAfterRetry,
|
||||
}));
|
||||
|
||||
await writeFile(resultPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
|
||||
await persistSummary(summary);
|
||||
|
||||
if (summary.failedSuiteCount > 0) {
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -648,7 +648,7 @@ export class CreatePromotionComponent implements OnInit {
|
||||
case 3:
|
||||
return this.releaseId().trim().length > 0 && this.targetEnvironmentId().length > 0;
|
||||
case 4:
|
||||
return this.preview() !== null;
|
||||
return this.preview() !== null && !this.loadingPreview();
|
||||
case 5:
|
||||
return this.justification().trim().length >= 10;
|
||||
default:
|
||||
@@ -660,7 +660,8 @@ export class CreatePromotionComponent implements OnInit {
|
||||
return (
|
||||
this.releaseId().trim().length > 0 &&
|
||||
this.targetEnvironmentId().length > 0 &&
|
||||
this.justification().trim().length >= 10
|
||||
this.justification().trim().length >= 10 &&
|
||||
!this.loadingPreview()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -684,12 +685,12 @@ export class CreatePromotionComponent implements OnInit {
|
||||
this.environments.set(items);
|
||||
this.loadingEnvironments.set(false);
|
||||
if (items.length > 0) {
|
||||
this.activeStep.set(2);
|
||||
this.promoteActiveStep(2);
|
||||
}
|
||||
|
||||
if (preferredTargetEnvironmentId && items.some((item) => item.id === preferredTargetEnvironmentId)) {
|
||||
this.targetEnvironmentId.set(preferredTargetEnvironmentId);
|
||||
this.activeStep.set(4);
|
||||
this.promoteActiveStep(4);
|
||||
this.loadPreview();
|
||||
}
|
||||
});
|
||||
@@ -704,7 +705,10 @@ export class CreatePromotionComponent implements OnInit {
|
||||
}
|
||||
|
||||
loadPreview(): void {
|
||||
if (!this.releaseId().trim() || !this.targetEnvironmentId()) {
|
||||
const releaseId = this.releaseId().trim();
|
||||
const targetEnvironmentId = this.targetEnvironmentId();
|
||||
|
||||
if (!releaseId || !targetEnvironmentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -712,7 +716,7 @@ export class CreatePromotionComponent implements OnInit {
|
||||
this.error.set(null);
|
||||
|
||||
this.api
|
||||
.getPromotionPreview(this.releaseId().trim(), this.targetEnvironmentId())
|
||||
.getPromotionPreview(releaseId, targetEnvironmentId)
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.error.set('Failed to load gate preview.');
|
||||
@@ -720,11 +724,17 @@ export class CreatePromotionComponent implements OnInit {
|
||||
})
|
||||
)
|
||||
.subscribe((preview) => {
|
||||
this.preview.set(preview);
|
||||
this.loadingPreview.set(false);
|
||||
if (preview) {
|
||||
this.activeStep.set(4);
|
||||
if (!preview) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.releaseId().trim() !== releaseId || this.targetEnvironmentId() !== targetEnvironmentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.preview.set(preview);
|
||||
this.promoteActiveStep(4);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -793,4 +803,8 @@ export class CreatePromotionComponent implements OnInit {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private promoteActiveStep(step: Step): void {
|
||||
this.activeStep.update((current) => (current < step ? step : current));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Route, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { of, Subject } from 'rxjs';
|
||||
|
||||
import { routes } from '../../app/app.routes';
|
||||
import { APPROVAL_API } from '../../app/core/api/approval.client';
|
||||
@@ -267,4 +267,155 @@ describe('CreatePromotionComponent release-context handoff', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks launch progression while gate preview refresh is still in flight', async () => {
|
||||
const previewRefresh = new Subject<{
|
||||
releaseId: string;
|
||||
releaseName: string;
|
||||
sourceEnvironment: string;
|
||||
targetEnvironment: string;
|
||||
gateResults: [];
|
||||
allGatesPassed: boolean;
|
||||
requiredApprovers: number;
|
||||
estimatedDeployTime: number;
|
||||
warnings: string[];
|
||||
}>();
|
||||
const approvalApi = {
|
||||
getAvailableEnvironments: jasmine.createSpy('getAvailableEnvironments').and.returnValue(of([])),
|
||||
getPromotionPreview: jasmine.createSpy('getPromotionPreview').and.returnValue(previewRefresh.asObservable()),
|
||||
submitPromotionRequest: jasmine.createSpy('submitPromotionRequest').and.returnValue(of(null)),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CreatePromotionComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ provide: APPROVAL_API, useValue: approvalApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(CreatePromotionComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.releaseId.set('rel-123');
|
||||
component.targetEnvironmentId.set('env-production');
|
||||
component.preview.set({
|
||||
releaseId: 'rel-123',
|
||||
releaseName: 'API Gateway',
|
||||
sourceEnvironment: 'stage',
|
||||
targetEnvironment: 'production',
|
||||
gateResults: [],
|
||||
allGatesPassed: true,
|
||||
requiredApprovers: 2,
|
||||
estimatedDeployTime: 120,
|
||||
warnings: [],
|
||||
});
|
||||
component.activeStep.set(4);
|
||||
|
||||
component.loadPreview();
|
||||
|
||||
expect(component.loadingPreview()).toBeTrue();
|
||||
expect(component.canAdvance(4)).toBeFalse();
|
||||
|
||||
component.nextStep();
|
||||
|
||||
expect(component.activeStep()).toBe(4);
|
||||
|
||||
previewRefresh.next({
|
||||
releaseId: 'rel-123',
|
||||
releaseName: 'API Gateway',
|
||||
sourceEnvironment: 'stage',
|
||||
targetEnvironment: 'production',
|
||||
gateResults: [],
|
||||
allGatesPassed: true,
|
||||
requiredApprovers: 2,
|
||||
estimatedDeployTime: 120,
|
||||
warnings: [],
|
||||
});
|
||||
previewRefresh.complete();
|
||||
|
||||
expect(component.loadingPreview()).toBeFalse();
|
||||
expect(component.canAdvance(4)).toBeTrue();
|
||||
expect(component.activeStep()).toBe(4);
|
||||
});
|
||||
|
||||
it('keeps the launch step visible when a late gate preview refresh resolves', async () => {
|
||||
const previewRefresh = new Subject<{
|
||||
releaseId: string;
|
||||
releaseName: string;
|
||||
sourceEnvironment: string;
|
||||
targetEnvironment: string;
|
||||
gateResults: [];
|
||||
allGatesPassed: boolean;
|
||||
requiredApprovers: number;
|
||||
estimatedDeployTime: number;
|
||||
warnings: string[];
|
||||
}>();
|
||||
const approvalApi = {
|
||||
getAvailableEnvironments: jasmine.createSpy('getAvailableEnvironments').and.returnValue(of([])),
|
||||
getPromotionPreview: jasmine.createSpy('getPromotionPreview').and.returnValue(previewRefresh.asObservable()),
|
||||
submitPromotionRequest: jasmine.createSpy('submitPromotionRequest').and.returnValue(of(null)),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CreatePromotionComponent],
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
{ provide: APPROVAL_API, useValue: approvalApi },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
const fixture = TestBed.createComponent(CreatePromotionComponent);
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component.releaseId.set('rel-123');
|
||||
component.targetEnvironmentId.set('env-production');
|
||||
component.preview.set({
|
||||
releaseId: 'rel-123',
|
||||
releaseName: 'API Gateway',
|
||||
sourceEnvironment: 'stage',
|
||||
targetEnvironment: 'production',
|
||||
gateResults: [],
|
||||
allGatesPassed: true,
|
||||
requiredApprovers: 2,
|
||||
estimatedDeployTime: 120,
|
||||
warnings: [],
|
||||
});
|
||||
component.justification.set('Release approval path validated end to end.');
|
||||
component.activeStep.set(6);
|
||||
|
||||
component.loadPreview();
|
||||
|
||||
previewRefresh.next({
|
||||
releaseId: 'rel-123',
|
||||
releaseName: 'API Gateway',
|
||||
sourceEnvironment: 'stage',
|
||||
targetEnvironment: 'production',
|
||||
gateResults: [],
|
||||
allGatesPassed: true,
|
||||
requiredApprovers: 2,
|
||||
estimatedDeployTime: 120,
|
||||
warnings: [],
|
||||
});
|
||||
previewRefresh.complete();
|
||||
|
||||
expect(component.activeStep()).toBe(6);
|
||||
expect(component.canSubmit()).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -259,6 +259,14 @@ test('legacy promotions create alias lands on the canonical wizard and submits a
|
||||
await page.locator('#target-env').selectOption('env-production');
|
||||
await expect(page.getByRole('heading', { name: 'Gate Preview' })).toBeVisible();
|
||||
await expect(page.getByText('All gates passed')).toBeVisible();
|
||||
await Promise.all([
|
||||
page.waitForResponse((response) =>
|
||||
response.request().method() === 'GET' &&
|
||||
response.url().includes('/api/v1/release-orchestrator/releases/rel-001/promotion-preview'),
|
||||
),
|
||||
page.getByRole('button', { name: 'Refresh Gate Preview' }).click(),
|
||||
]);
|
||||
await expect(page.getByText('All gates passed')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Next ->' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Approval Context' })).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user