Close first-time user operator journey remediation

Promotions: replace empty-state stub with operator landing surface
showing pipeline stages, prerequisites, and onboarding guidance.

Operations: unify naming across sidebar, breadcrumb, title, and H1
from "Platform Ops" to "Operations".

Playwright: add promotions and operations landing journey checks to
the retained first-time-user remediation and aggregate audit suites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-15 14:33:27 +02:00
parent e884b4bddd
commit 2da76588d4
7 changed files with 614 additions and 11 deletions

View File

@@ -277,6 +277,43 @@ async function main() {
};
}));
// ── P0 surface checks: promotions and operations ───────────────────
results.push(await runCheck(page, 'promotions-landing-surface', '/releases/promotions', async () => {
const heading = await headingText(page);
const text = await bodyText(page);
const links = await hrefs(page);
const hasCreateAction = links.some((href) => href.includes('create'));
const hasMeaningfulContent =
text.includes('Bundle-version anchored')
|| text.includes('No promotions yet')
|| text.includes('No promotions match')
|| text.includes('Promotion')
&& text.includes('Env Path');
return {
ok: heading === 'Promotions' && hasCreateAction && hasMeaningfulContent,
snapshot: await capture(page, 'promotions-landing-surface', {
hasCreateAction,
hasMeaningfulContent,
}),
};
}));
results.push(await runCheck(page, 'operations-overview-surface', '/ops/operations', async () => {
const heading = await headingText(page);
const text = await bodyText(page);
const hasBlocking = text.includes('Blocking');
const hasPendingActions = text.includes('Pending Operator Actions');
const hasTestId = await page.locator('[data-testid="operations-overview"]').count().then((c) => c > 0).catch(() => false);
return {
ok: heading === 'Operations' && hasBlocking && hasPendingActions && hasTestId,
snapshot: await capture(page, 'operations-overview-surface', {
hasBlocking,
hasPendingActions,
hasTestId,
}),
};
}));
const failedChecks = results.filter((result) => !result.ok);
const runtimeIssueCount =
runtime.consoleErrors.length

View File

@@ -67,6 +67,11 @@ const suites = [
script: 'live-first-time-user-reporting-truthfulness-check.mjs',
reportPath: path.join(outputDir, 'live-first-time-user-reporting-truthfulness-check.json'),
},
{
name: 'promotions-operations-landing-check',
script: 'live-promotions-operations-landing-check.mjs',
reportPath: path.join(outputDir, 'live-promotions-operations-landing-check.json'),
},
{
name: 'changed-surfaces',
script: 'live-frontdoor-changed-surfaces.mjs',

View File

@@ -0,0 +1,411 @@
#!/usr/bin/env node
/**
* Playwright journey: Promotions landing and Operations overview
* Sprint: SPRINT_20260315_006 / FTU-OPS-002 + FTU-OPS-006
*
* Verifies that:
* 1. /releases/promotions renders a real landing surface (not blank)
* 2. The promotions empty state provides operator guidance
* 3. The "Create Promotion" action is reachable
* 4. /ops/operations renders the Operations overview with child workflow links
* 5. Operations overview has blocking cards, pending actions, and setup boundary
* 6. Naming consistency: H1, title, and breadcrumb all say "Operations"
*/
import { mkdir, writeFile } from 'node:fs/promises';
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 outputDir = path.join(webRoot, 'output', 'playwright');
const outputPath = path.join(outputDir, 'live-promotions-operations-landing-check.json');
const authStatePath = path.join(outputDir, 'live-promotions-operations-landing-check.state.json');
const authReportPath = path.join(outputDir, 'live-promotions-operations-landing-check.auth.json');
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
const scopeQuery = 'tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d';
function buildUrl(route) {
const url = new URL(route, baseUrl);
const scopedParams = new URLSearchParams(scopeQuery);
for (const [key, value] of scopedParams.entries()) {
url.searchParams.set(key, value);
}
return url.toString();
}
function cleanText(value) {
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
}
function isStaticAsset(url) {
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url);
}
function isNavigationAbort(errorText = '') {
return /aborted|net::err_abort/i.test(errorText);
}
function createRuntime() {
return {
consoleErrors: [],
pageErrors: [],
requestFailures: [],
responseErrors: [],
};
}
function attachRuntime(page, runtime) {
page.on('console', (message) => {
if (message.type() === 'error') {
runtime.consoleErrors.push({ page: page.url(), text: message.text() });
}
});
page.on('pageerror', (error) => {
if (page.url() === 'about:blank' && String(error).includes('sessionStorage')) {
return;
}
runtime.pageErrors.push({
page: page.url(),
text: error instanceof Error ? error.message : String(error),
});
});
page.on('requestfailed', (request) => {
const errorText = request.failure()?.errorText ?? 'unknown';
if (isStaticAsset(request.url()) || isNavigationAbort(errorText)) {
return;
}
runtime.requestFailures.push({
page: page.url(),
method: request.method(),
url: request.url(),
error: errorText,
});
});
page.on('response', (response) => {
if (isStaticAsset(response.url())) {
return;
}
if (response.status() >= 400) {
runtime.responseErrors.push({
page: page.url(),
method: response.request().method(),
status: response.status(),
url: response.url(),
});
}
});
}
async function settle(page, ms = 1250) {
await page.waitForLoadState('domcontentloaded', { timeout: 20_000 }).catch(() => {});
await page.waitForTimeout(ms);
}
async function headingText(page) {
const headings = page.locator('h1, main h1');
const count = await headings.count().catch(() => 0);
for (let index = 0; index < Math.min(count, 6); index += 1) {
const text = cleanText(await headings.nth(index).innerText().catch(() => ''));
if (text) {
return text;
}
}
return '';
}
async function bodyText(page) {
return cleanText(await page.locator('body').innerText().catch(() => ''));
}
async function hrefs(page, selector = 'a') {
return page.locator(selector).evaluateAll((nodes) =>
nodes
.map((node) => node.getAttribute('href'))
.filter((value) => typeof value === 'string'),
).catch(() => []);
}
async function main() {
await mkdir(outputDir, { recursive: true });
const authReport = await authenticateFrontdoor({
statePath: authStatePath,
reportPath: authReportPath,
});
const browser = await chromium.launch({
headless: true,
args: ['--ignore-certificate-errors', '--disable-dev-shm-usage'],
});
const context = await createAuthenticatedContext(browser, authReport, {
statePath: authStatePath,
});
const runtime = createRuntime();
context.on('page', (pg) => attachRuntime(pg, runtime));
const page = await context.newPage();
attachRuntime(page, runtime);
const results = [];
const failures = [];
try {
// ── Check 1: Promotions landing renders real content ──────────────
await page.goto(buildUrl('/releases/promotions'), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page);
const promotionsHeading = await headingText(page);
const promotionsBody = await bodyText(page);
const promotionsLinks = await hrefs(page);
const promotionsTitle = await page.title().catch(() => '');
const hasPromotionsH1 = promotionsHeading === 'Promotions';
const hasCreateAction = promotionsLinks.some(
(href) => href.includes('create') || href.includes('/releases/promotions/create'),
);
// Either the list table is visible OR the empty state guidance is present
const hasPromotionsTable = await page.locator('table[aria-label="Promotions"]').count().then((c) => c > 0).catch(() => false);
const hasEmptyGuidance =
promotionsBody.includes('No promotions yet') ||
promotionsBody.includes('No promotions match') ||
promotionsBody.includes('Bundle-version anchored');
const hasMeaningfulContent = hasPromotionsTable || hasEmptyGuidance;
results.push({
key: 'promotions-landing',
route: '/releases/promotions',
ok: hasPromotionsH1 && hasCreateAction && hasMeaningfulContent,
snapshot: {
heading: promotionsHeading,
title: promotionsTitle,
hasCreateAction,
hasPromotionsTable,
hasEmptyGuidance,
hasMeaningfulContent,
},
});
if (!hasPromotionsH1) {
failures.push('Promotions landing did not render H1 "Promotions".');
}
if (!hasCreateAction) {
failures.push('Promotions landing has no reachable "Create Promotion" action.');
}
if (!hasMeaningfulContent) {
failures.push('Promotions landing rendered blank -- no table and no empty state guidance.');
}
// ── Check 2: Promotions empty state has operator guidance ─────────
// If empty state is present, verify it has pipeline stages and prerequisite links
if (!hasPromotionsTable && promotionsBody.includes('No promotions yet')) {
const hasPrerequisites = promotionsBody.includes('Prerequisites') || promotionsBody.includes('prerequisite');
const hasPipelineStages =
promotionsBody.includes('Select Bundle Version') &&
promotionsBody.includes('Gate Evaluation') &&
promotionsBody.includes('Approval');
const hasVersionsLink = promotionsLinks.some((href) => href.includes('/releases/versions'));
results.push({
key: 'promotions-empty-state-guidance',
route: '/releases/promotions',
ok: hasPrerequisites && hasPipelineStages && hasVersionsLink,
snapshot: {
hasPrerequisites,
hasPipelineStages,
hasVersionsLink,
},
});
if (!hasPipelineStages) {
failures.push('Promotions empty state is missing pipeline stage guidance.');
}
if (!hasVersionsLink) {
failures.push('Promotions empty state is missing link to Release Versions.');
}
} else {
results.push({
key: 'promotions-empty-state-guidance',
route: '/releases/promotions',
ok: true,
snapshot: { skipped: 'Promotions list has data, empty state not rendered.' },
});
}
// ── Check 3: Operations overview renders real content ─────────────
await page.goto(buildUrl('/ops/operations'), {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
await settle(page);
const opsHeading = await headingText(page);
const opsBody = await bodyText(page);
const opsLinks = await hrefs(page);
const opsTitle = await page.title().catch(() => '');
const hasOpsH1 = opsHeading === 'Operations';
const hasBlockingSection = opsBody.includes('Blocking');
const hasPendingActions = opsBody.includes('Pending Operator Actions');
const hasSetupBoundary = opsBody.includes('Setup Boundary');
const hasChildWorkflowLinks =
opsLinks.some((href) => href.includes('/ops/operations/doctor')) &&
opsLinks.some((href) => href.includes('/evidence/audit-log'));
const hasOverviewTestId = await page.locator('[data-testid="operations-overview"]').count().then((c) => c > 0).catch(() => false);
results.push({
key: 'operations-overview',
route: '/ops/operations',
ok: hasOpsH1 && hasBlockingSection && hasPendingActions && hasSetupBoundary && hasOverviewTestId,
snapshot: {
heading: opsHeading,
title: opsTitle,
hasBlockingSection,
hasPendingActions,
hasSetupBoundary,
hasChildWorkflowLinks,
hasOverviewTestId,
},
});
if (!hasOpsH1) {
failures.push(`Operations overview H1 is "${opsHeading}" instead of "Operations".`);
}
if (!hasBlockingSection) {
failures.push('Operations overview missing Blocking section.');
}
if (!hasPendingActions) {
failures.push('Operations overview missing Pending Operator Actions.');
}
if (!hasSetupBoundary) {
failures.push('Operations overview missing Setup Boundary section.');
}
// ── Check 4: Operations title/breadcrumb naming consistency ──────
const titleMatchesOps = opsTitle.includes('Operations');
results.push({
key: 'operations-naming-consistency',
route: '/ops/operations',
ok: titleMatchesOps && hasOpsH1,
snapshot: {
title: opsTitle,
heading: opsHeading,
titleMatchesOps,
},
});
if (!titleMatchesOps) {
failures.push(`Operations document title is "${opsTitle}", expected it to include "Operations".`);
}
// ── Check 5: Operations quick nav has key child routes ───────────
const quickNavLinks = await page
.locator('[data-testid^="operations-nav-"]')
.evaluateAll((nodes) =>
nodes.map((node) => ({
testId: node.getAttribute('data-testid'),
text: (node.textContent || '').trim(),
})),
)
.catch(() => []);
const hasDataIntegrityNav = quickNavLinks.some((item) => item.text.includes('Data Integrity'));
const hasJobsQueuesNav = quickNavLinks.some((item) => item.text.includes('Jobs'));
const hasHealthSloNav = quickNavLinks.some((item) => item.text.includes('Health'));
const hasFeedsNav = quickNavLinks.some((item) => item.text.includes('Feeds'));
results.push({
key: 'operations-quick-nav',
route: '/ops/operations',
ok: hasDataIntegrityNav && hasJobsQueuesNav && hasHealthSloNav,
snapshot: {
quickNavLinks,
hasDataIntegrityNav,
hasJobsQueuesNav,
hasHealthSloNav,
hasFeedsNav,
},
});
if (!hasDataIntegrityNav || !hasJobsQueuesNav || !hasHealthSloNav) {
failures.push('Operations quick nav is missing key child routes (Data Integrity, Jobs, Health).');
}
// ── Check 6: Operations blocking cards link to real pages ────────
const blockingCards = await page
.locator('[data-testid^="operations-blocking-"]')
.evaluateAll((nodes) =>
nodes.map((node) => ({
testId: node.getAttribute('data-testid'),
text: (node.textContent || '').trim(),
href: node.getAttribute('href') || node.closest('a')?.getAttribute('href') || '',
})),
)
.catch(() => []);
results.push({
key: 'operations-blocking-cards',
route: '/ops/operations',
ok: blockingCards.length > 0,
snapshot: {
blockingCardCount: blockingCards.length,
blockingCards,
},
});
if (blockingCards.length === 0) {
failures.push('Operations overview has no blocking cards rendered.');
}
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
await browser.close().catch(() => {});
}
const runtimeIssueCount =
runtime.consoleErrors.length +
runtime.pageErrors.length +
runtime.requestFailures.length +
runtime.responseErrors.length;
const summary = {
generatedAtUtc: new Date().toISOString(),
baseUrl,
failedCheckCount: failures.length,
runtimeIssueCount,
results,
failures,
runtime,
};
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
if (failures.length > 0) {
process.exitCode = 1;
}
}
main().catch(async (error) => {
const summary = {
generatedAtUtc: new Date().toISOString(),
fatalError: error instanceof Error ? error.message : String(error),
};
await mkdir(outputDir, { recursive: true });
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
console.error(error);
process.exitCode = 1;
});

View File

@@ -2,7 +2,7 @@
<header class="ops-overview__header">
<div class="ops-overview__title-group">
<p class="ops-overview__eyebrow">Ops / Operations</p>
<h1>Platform Ops</h1>
<h1>Operations</h1>
<p class="ops-overview__subtitle">
Consolidated operator shell for blocking platform issues, execution control, diagnostics,
and airgap workflows. Topology and agent ownership remain under Setup.

View File

@@ -111,8 +111,51 @@ interface PromotionRow {
}
@if (!loading() && !error()) {
@if (filteredPromotions().length === 0) {
<div class="state-block">No promotions match the current filters.</div>
@if (promotions().length === 0) {
<section class="promotions-list__empty" aria-label="No promotions yet">
<div class="empty-hero">
<h2>No promotions yet</h2>
<p>
Promotions move a release version from one environment to another through
a policy-gated approval pipeline. Each promotion captures bundle identity,
gate evaluation, security snapshot, and decision evidence.
</p>
</div>
<div class="empty-pipeline" aria-label="Promotion pipeline stages">
<div class="pipeline-stage">
<strong>1. Select Bundle Version</strong>
<span>Choose the release version and target environment for promotion.</span>
</div>
<div class="pipeline-arrow" aria-hidden="true">-></div>
<div class="pipeline-stage">
<strong>2. Gate Evaluation</strong>
<span>Policy gates run automatically to verify security, compliance, and data health.</span>
</div>
<div class="pipeline-arrow" aria-hidden="true">-></div>
<div class="pipeline-stage">
<strong>3. Approval & Launch</strong>
<span>Approved promotions execute with full evidence capture and audit trail.</span>
</div>
</div>
<div class="empty-actions">
<a class="btn-primary" routerLink="create">Create First Promotion</a>
<a class="link-sm" routerLink="/releases/versions">View Release Versions</a>
<a class="link-sm" routerLink="/releases/approvals">View Approval Queue</a>
</div>
<div class="empty-context">
<h3>Prerequisites</h3>
<ul>
<li>At least one <a routerLink="/releases/versions">release version</a> must exist.</li>
<li><a routerLink="/releases/environments">Environments</a> and promotion paths must be configured in topology.</li>
<li><a routerLink="/ops/policy">Policy gates</a> control which promotions pass automatically and which require manual approval.</li>
</ul>
</div>
</section>
} @else if (filteredPromotions().length === 0) {
<div class="state-block">No promotions match the current filters. Try adjusting the status or environment filter above.</div>
} @else {
<table class="promotions-list__table" aria-label="Promotions">
<thead>
@@ -399,10 +442,114 @@ interface PromotionRow {
clip: rect(0, 0, 0, 0);
}
.promotions-list__empty {
display: grid;
gap: 1.25rem;
}
.empty-hero {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
background: var(--color-surface-alt, #f9fafb);
padding: 1.25rem;
}
.empty-hero h2 {
margin: 0 0 0.5rem;
font-size: 1.15rem;
font-weight: 600;
}
.empty-hero p {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
line-height: 1.5;
max-width: 65ch;
}
.empty-pipeline {
display: grid;
grid-template-columns: 1fr auto 1fr auto 1fr;
gap: 0.5rem;
align-items: center;
}
.pipeline-stage {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
padding: 0.75rem;
display: grid;
gap: 0.25rem;
}
.pipeline-stage strong {
font-size: 0.82rem;
}
.pipeline-stage span {
font-size: 0.75rem;
color: var(--color-text-secondary, #666);
line-height: 1.4;
}
.pipeline-arrow {
font-size: 1.1rem;
color: var(--color-text-secondary, #999);
text-align: center;
}
.empty-actions {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}
.empty-context {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
padding: 0.9rem 1rem;
}
.empty-context h3 {
margin: 0 0 0.5rem;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #666);
}
.empty-context ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.35rem;
}
.empty-context li {
font-size: 0.82rem;
line-height: 1.45;
color: var(--color-text-secondary, #666);
}
.empty-context a {
color: var(--color-brand-primary, #4f46e5);
text-decoration: none;
}
@media (max-width: 900px) {
.promotions-list__filters {
grid-template-columns: 1fr;
}
.empty-pipeline {
grid-template-columns: 1fr;
}
.pipeline-arrow {
transform: rotate(90deg);
}
}
`,
],

View File

@@ -5,8 +5,8 @@ export const OPERATIONS_ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
title: 'Platform Ops',
data: { breadcrumb: 'Ops' },
title: 'Operations',
data: { breadcrumb: 'Operations' },
loadComponent: () =>
import('../features/platform/ops/platform-ops-overview-page.component').then(
(m) => m.PlatformOpsOverviewPageComponent,