Add call graph fixtures for various languages and scenarios
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Findings Ledger CI / build-test (push) Has been cancelled
Findings Ledger CI / migration-validation (push) Has been cancelled
Findings Ledger CI / generate-manifest (push) Has been cancelled
Lighthouse CI / Lighthouse Audit (push) Has been cancelled
Lighthouse CI / Axe Accessibility Audit (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Reachability Corpus Validation / validate-corpus (push) Has been cancelled
Reachability Corpus Validation / validate-ground-truths (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Reachability Corpus Validation / determinism-check (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

- Introduced `all-edge-reasons.json` to test edge resolution reasons in .NET.
- Added `all-visibility-levels.json` to validate method visibility levels in .NET.
- Created `dotnet-aspnetcore-minimal.json` for a minimal ASP.NET Core application.
- Included `go-gin-api.json` for a Go Gin API application structure.
- Added `java-spring-boot.json` for the Spring PetClinic application in Java.
- Introduced `legacy-no-schema.json` for legacy application structure without schema.
- Created `node-express-api.json` for an Express.js API application structure.
This commit is contained in:
master
2025-12-16 10:44:24 +02:00
parent 4391f35d8a
commit 5a480a3c2a
223 changed files with 19367 additions and 727 deletions

View File

@@ -9,7 +9,7 @@
| WEB-AIAI-31-003 | DONE (2025-12-12) | Telemetry headers + prompt hash support; documented guardrail surface for audit visibility. |
| WEB-CONSOLE-23-002 | DONE (2025-12-04) | console/status polling + run stream client/store/UI shipped; samples verified in `docs/api/console/samples/`. |
| WEB-CONSOLE-23-003 | DONE (2025-12-07) | Exports client/store/service + models shipped; targeted Karma specs green locally with CHROME_BIN override (`node ./node_modules/@angular/cli/bin/ng.js test --watch=false --browsers=ChromeHeadless --include console-export specs`). Backend manifest/limits v0.4 published; awaiting final Policy/DevOps sign-off but UI/client slice complete. |
| WEB-RISK-66-001 | BLOCKED (2025-12-03) | Same implementation landed; npm ci hangs so Angular tests cant run; waiting on stable install environment and gateway endpoints to validate. |
| WEB-RISK-66-001 | BLOCKED (2025-12-03) | Same implementation landed; npm ci hangs so Angular tests can’t run; waiting on stable install environment and gateway endpoints to validate. |
| WEB-EXC-25-001 | DONE (2025-12-12) | Exception contract + sample updated (`docs/api/console/exception-schema.md`); `ExceptionApiHttpClient` enforces scopes + trace/tenant headers with unit spec. |
| WEB-EXC-25-002 | DONE (2025-12-12) | Contract + samples in `docs/api/gateway/policy-exceptions.md`; client + unit spec in `src/Web/StellaOps.Web/src/app/core/api/policy-exceptions.client.ts`. |
| WEB-EXC-25-003 | DONE (2025-12-12) | Contract + samples in `docs/api/gateway/exception-events.md`; client + unit spec in `src/Web/StellaOps.Web/src/app/core/api/exception-events.client.ts`. |
@@ -48,3 +48,5 @@
| UI-VEX-0215-A11Y | DONE (2025-12-12) | Added dialog semantics + focus trap for `VexDecisionModalComponent` and Playwright Axe coverage in `tests/e2e/a11y-smoke.spec.ts`. |
| UI-TRIAGE-0215-FIXTURES | DONE (2025-12-12) | Made quickstart mock fixtures deterministic for triage surfaces (VEX decisions, audit bundles, vulnerabilities) to support offline-kit hashing and stable tests. |
| UI-TRIAGE-4601-001 | DONE (2025-12-15) | Keyboard shortcuts for triage workspace (SPRINT_4601_0001_0001_keyboard_shortcuts.md). |
| UI-TRIAGE-4602-001 | DONE (2025-12-15) | Finish triage decision drawer/evidence pills QA: component specs + Storybook stories (SPRINT_4602_0001_0001_decision_drawer_evidence_tab.md). |
| UI-TTFS-0340-001 | DONE (2025-12-15) | FirstSignalCard UI component + client/store/tests (SPRINT_0340_0001_0001_first_signal_card_ui.md). |

View File

@@ -90,6 +90,11 @@ import {
OrchestratorControlHttpClient,
MockOrchestratorControlClient,
} from './core/api/orchestrator-control.client';
import {
FIRST_SIGNAL_API,
FirstSignalHttpClient,
MockFirstSignalClient,
} from './core/api/first-signal.client';
import {
EXCEPTION_EVENTS_API,
EXCEPTION_EVENTS_API_BASE_URL,
@@ -361,6 +366,17 @@ export const appConfig: ApplicationConfig = {
mock: MockOrchestratorControlClient
) => (config.config.quickstartMode ? mock : http),
},
FirstSignalHttpClient,
MockFirstSignalClient,
{
provide: FIRST_SIGNAL_API,
deps: [AppConfigService, FirstSignalHttpClient, MockFirstSignalClient],
useFactory: (
config: AppConfigService,
http: FirstSignalHttpClient,
mock: MockFirstSignalClient
) => (config.config.quickstartMode ? mock : http),
},
{
provide: EXCEPTION_EVENTS_API_BASE_URL,
deps: [AppConfigService],

View File

@@ -0,0 +1,171 @@
import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http';
import { Inject, Injectable, InjectionToken } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { EVENT_SOURCE_FACTORY, type EventSourceFactory } from './console-status.client';
import { ORCHESTRATOR_API_BASE_URL } from './orchestrator.client';
import { FirstSignalResponse, type FirstSignalRunStreamPayload } from './first-signal.models';
import { generateTraceId } from './trace.util';
export interface FirstSignalApi {
getFirstSignal(
runId: string,
options?: { etag?: string; tenantId?: string; projectId?: string; traceId?: string }
): Observable<{ response: FirstSignalResponse | null; etag: string | null; cacheStatus: string }>;
streamFirstSignal(runId: string, options?: { tenantId?: string; traceId?: string }): Observable<FirstSignalRunStreamPayload>;
}
export const FIRST_SIGNAL_API = new InjectionToken<FirstSignalApi>('FIRST_SIGNAL_API');
@Injectable({ providedIn: 'root' })
export class FirstSignalHttpClient implements FirstSignalApi {
constructor(
private readonly http: HttpClient,
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(ORCHESTRATOR_API_BASE_URL) private readonly baseUrl: string,
@Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory
) {}
/**
* Fetch the first signal for a run.
* Supports conditional requests via If-None-Match.
*/
getFirstSignal(
runId: string,
options: { etag?: string; tenantId?: string; projectId?: string; traceId?: string } = {}
): Observable<{ response: FirstSignalResponse | null; etag: string | null; cacheStatus: string }> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
if (!this.tenantService.authorize('orchestrator', 'read', ['orch:read'], options.projectId, traceId)) {
return throwError(() => new Error('Unauthorized: missing orch:read scope'));
}
return this.http
.get<FirstSignalResponse>(`${this.baseUrl}/orchestrator/runs/${encodeURIComponent(runId)}/first-signal`, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.etag),
observe: 'response',
})
.pipe(
map((resp: HttpResponse<FirstSignalResponse>) => ({
response: resp.body ?? null,
etag: resp.headers.get('ETag'),
cacheStatus: resp.headers.get('Cache-Status') ?? 'unknown',
})),
catchError((err) => {
if (err?.status === 304) {
return of({ response: null, etag: options.etag ?? null, cacheStatus: 'not-modified' });
}
return throwError(() => err);
})
);
}
/**
* Subscribe to run stream `first_signal` events.
* NOTE: SSE requires tenant to be provided via query param (EventSource cannot set custom headers).
*/
streamFirstSignal(runId: string, options: { tenantId?: string; traceId?: string } = {}): Observable<FirstSignalRunStreamPayload> {
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
const params = new HttpParams().set('tenant', tenant).set('traceId', traceId);
const url = `${this.baseUrl}/orchestrator/stream/runs/${encodeURIComponent(runId)}?${params.toString()}`;
return new Observable<FirstSignalRunStreamPayload>((observer) => {
const source = this.eventSourceFactory(url);
const onFirstSignal = (event: MessageEvent) => {
try {
observer.next(JSON.parse(event.data) as FirstSignalRunStreamPayload);
} catch (e) {
observer.error(e);
}
};
source.addEventListener('first_signal', onFirstSignal as EventListener);
source.onerror = (err) => {
observer.error(err);
source.close();
};
return () => {
source.removeEventListener('first_signal', onFirstSignal as EventListener);
source.close();
};
});
}
private resolveTenant(tenantId?: string): string {
const tenant = (tenantId && tenantId.trim()) || this.authSession.getActiveTenantId();
if (!tenant) {
throw new Error('FirstSignalHttpClient requires an active tenant identifier.');
}
return tenant;
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {
let headers = new HttpHeaders({
'Content-Type': 'application/json',
'X-StellaOps-Tenant': tenantId,
'X-Stella-Trace-Id': traceId,
'X-Stella-Request-Id': traceId,
});
if (projectId) {
headers = headers.set('X-Stella-Project', projectId);
}
if (ifNoneMatch) {
headers = headers.set('If-None-Match', ifNoneMatch);
}
return headers;
}
}
@Injectable({ providedIn: 'root' })
export class MockFirstSignalClient implements FirstSignalApi {
private readonly fixedTimestamp = '2025-01-01T00:00:00Z';
getFirstSignal(
runId: string,
options: { etag?: string; tenantId?: string; projectId?: string; traceId?: string } = {}
): Observable<{ response: FirstSignalResponse | null; etag: string | null; cacheStatus: string }> {
void options;
const etag = '"first-signal-mock-v1"';
return of({
response: {
runId,
summaryEtag: etag,
firstSignal: {
type: 'queued',
stage: 'resolve',
step: 'initialize',
message: `Mock first signal for run ${runId}`,
at: this.fixedTimestamp,
artifact: { kind: 'run' },
},
},
etag,
cacheStatus: 'mock',
});
}
streamFirstSignal(runId: string, options: { tenantId?: string; traceId?: string } = {}): Observable<FirstSignalRunStreamPayload> {
void runId;
void options;
return new Observable<FirstSignalRunStreamPayload>(() => {
// Intentionally no-op; mock mode relies on HTTP polling/refresh for determinism.
return () => {};
});
}
}

View File

@@ -0,0 +1,41 @@
/**
* Orchestrator First Signal API response types.
* Mirrors `StellaOps.Orchestrator.WebService.Contracts.FirstSignalResponse`.
*/
export interface FirstSignalResponse {
runId: string;
firstSignal: FirstSignalDto | null;
summaryEtag: string;
}
export interface FirstSignalDto {
type: string;
stage?: string | null;
step?: string | null;
message: string;
at: string; // ISO-8601
artifact?: FirstSignalArtifactDto | null;
}
export interface FirstSignalArtifactDto {
kind: string;
range?: FirstSignalRangeDto | null;
}
export interface FirstSignalRangeDto {
start: number;
end: number;
}
/**
* Run SSE payload for `first_signal` events emitted on the run stream.
* Current server payload includes `{ runId, etag, signal }`; clients may ignore `signal` and refetch via ETag.
*/
export interface FirstSignalRunStreamPayload {
runId: string;
etag: string;
signal?: unknown;
}
export type FirstSignalLoadState = 'idle' | 'loading' | 'loaded' | 'unavailable' | 'error' | 'offline';

View File

@@ -0,0 +1,77 @@
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import type { FirstSignalApi } from './first-signal.client';
import { FIRST_SIGNAL_API } from './first-signal.client';
import { FirstSignalStore } from './first-signal.store';
describe('FirstSignalStore', () => {
let store: FirstSignalStore;
let api: jasmine.SpyObj<FirstSignalApi>;
beforeEach(() => {
api = jasmine.createSpyObj<FirstSignalApi>('FirstSignalApi', ['getFirstSignal', 'streamFirstSignal']);
TestBed.configureTestingModule({
providers: [FirstSignalStore, { provide: FIRST_SIGNAL_API, useValue: api }],
});
store = TestBed.inject(FirstSignalStore);
});
afterEach(() => {
store.disconnect();
});
it('stores response when loaded', () => {
api.getFirstSignal.and.returnValue(
of({
response: {
runId: 'run-1',
summaryEtag: '"etag-1"',
firstSignal: {
type: 'started',
message: 'hello',
at: '2025-01-01T00:00:00Z',
},
},
etag: '"etag-1"',
cacheStatus: 'hit',
})
);
store.load('run-1');
expect(store.state()).toBe('loaded');
expect(store.hasSignal()).toBeTrue();
expect(store.firstSignal()?.message).toBe('hello');
expect(store.etag()).toBe('"etag-1"');
});
it('falls back to polling when SSE errors', fakeAsync(() => {
api.streamFirstSignal.and.returnValue(throwError(() => new Error('boom')));
api.getFirstSignal.and.returnValue(
of({
response: {
runId: 'run-2',
summaryEtag: '"etag-2"',
firstSignal: null,
},
etag: '"etag-2"',
cacheStatus: 'hit',
})
);
store.connect('run-2', { pollIntervalMs: 1000 });
expect(store.realtimeMode()).toBe('polling');
tick(999);
expect(api.getFirstSignal).not.toHaveBeenCalled();
tick(1);
expect(api.getFirstSignal).toHaveBeenCalledTimes(1);
store.disconnect();
}));
});

View File

@@ -0,0 +1,126 @@
import { Injectable, Signal, computed, inject, signal } from '@angular/core';
import { finalize } from 'rxjs/operators';
import { Subscription, timer } from 'rxjs';
import { FIRST_SIGNAL_API, type FirstSignalApi } from './first-signal.client';
import { FirstSignalLoadState, type FirstSignalResponse } from './first-signal.models';
export type FirstSignalRealtimeMode = 'disconnected' | 'sse' | 'polling';
@Injectable({ providedIn: 'root' })
export class FirstSignalStore {
private readonly client = inject(FIRST_SIGNAL_API) as FirstSignalApi;
private readonly responseSignal = signal<FirstSignalResponse | null>(null);
private readonly etagSignal = signal<string | null>(null);
private readonly cacheStatusSignal = signal<string | null>(null);
private readonly stateSignal = signal<FirstSignalLoadState>('idle');
private readonly errorSignal = signal<string | null>(null);
private readonly realtimeModeSignal = signal<FirstSignalRealtimeMode>('disconnected');
private streamSubscription: Subscription | null = null;
private pollSubscription: Subscription | null = null;
readonly response: Signal<FirstSignalResponse | null> = this.responseSignal.asReadonly();
readonly etag: Signal<string | null> = this.etagSignal.asReadonly();
readonly cacheStatus: Signal<string | null> = this.cacheStatusSignal.asReadonly();
readonly state: Signal<FirstSignalLoadState> = this.stateSignal.asReadonly();
readonly error: Signal<string | null> = this.errorSignal.asReadonly();
readonly realtimeMode: Signal<FirstSignalRealtimeMode> = this.realtimeModeSignal.asReadonly();
readonly firstSignal = computed(() => this.responseSignal()?.firstSignal ?? null);
readonly hasSignal = computed(() => !!this.responseSignal()?.firstSignal);
prime(entry: { response: FirstSignalResponse; etag?: string | null }): void {
if (!entry?.response) return;
this.responseSignal.set(entry.response);
this.etagSignal.set(entry.etag ?? entry.response.summaryEtag ?? null);
this.cacheStatusSignal.set('prefetch');
this.stateSignal.set('loaded');
this.errorSignal.set(null);
}
load(runId: string, options: { tenantId?: string; projectId?: string } = {}): void {
this.stateSignal.set('loading');
this.errorSignal.set(null);
const priorEtag = this.etagSignal();
this.client
.getFirstSignal(runId, {
etag: priorEtag ?? undefined,
tenantId: options.tenantId,
projectId: options.projectId,
})
.pipe(finalize(() => this.stateSignal.set(this.stateSignal() === 'loading' ? 'idle' : this.stateSignal())))
.subscribe({
next: (result) => {
this.cacheStatusSignal.set(result.cacheStatus);
if (result.cacheStatus === 'not-modified') {
this.stateSignal.set('loaded');
return;
}
if (!result.response) {
this.stateSignal.set('unavailable');
return;
}
this.responseSignal.set(result.response);
this.etagSignal.set(result.etag ?? result.response.summaryEtag ?? null);
this.stateSignal.set('loaded');
},
error: (err: unknown) => {
this.stateSignal.set(navigator.onLine ? 'error' : 'offline');
this.errorSignal.set(this.normalizeError(err));
},
});
}
connect(runId: string, options: { tenantId?: string; projectId?: string; pollIntervalMs?: number } = {}): void {
this.disconnect();
this.realtimeModeSignal.set('sse');
this.streamSubscription = this.client.streamFirstSignal(runId, { tenantId: options.tenantId }).subscribe({
next: () => {
this.realtimeModeSignal.set('sse');
this.load(runId, options);
},
error: () => {
this.startPolling(runId, options);
},
});
}
disconnect(): void {
this.streamSubscription?.unsubscribe();
this.streamSubscription = null;
this.pollSubscription?.unsubscribe();
this.pollSubscription = null;
this.realtimeModeSignal.set('disconnected');
}
clear(): void {
this.disconnect();
this.responseSignal.set(null);
this.etagSignal.set(null);
this.cacheStatusSignal.set(null);
this.stateSignal.set('idle');
this.errorSignal.set(null);
}
private startPolling(runId: string, options: { tenantId?: string; projectId?: string; pollIntervalMs?: number }): void {
const pollIntervalMs = Math.max(1000, Math.floor(options.pollIntervalMs ?? 5000));
this.pollSubscription?.unsubscribe();
this.pollSubscription = timer(pollIntervalMs, pollIntervalMs).subscribe(() => this.load(runId, options));
this.realtimeModeSignal.set('polling');
}
private normalizeError(err: unknown): string {
if (err instanceof Error) return err.message;
return 'Unknown error fetching first signal';
}
}

View File

@@ -42,6 +42,7 @@
<input type="text" [value]="runId()" (input)="runId.set($any($event.target).value)" (change)="startRunStream()" />
</label>
</header>
<app-first-signal-card [runId]="runId()" [enableRealTime]="true" [pollIntervalMs]="5000" />
<div class="events">
<div class="event" *ngFor="let evt of runEvents()">
<div class="meta">

View File

@@ -55,6 +55,10 @@ header {
gap: 1rem;
}
.run-stream app-first-signal-card {
margin: 0.75rem 0 1rem;
}
.run-stream label {
display: flex;
align-items: center;

View File

@@ -3,11 +3,12 @@ import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, inject, signal }
import { ConsoleStatusService } from '../../core/console/console-status.service';
import { ConsoleStatusStore } from '../../core/console/console-status.store';
import { FirstSignalCardComponent } from '../runs/components/first-signal-card/first-signal-card.component';
@Component({
selector: 'app-console-status',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, FirstSignalCardComponent],
templateUrl: './console-status.component.html',
styleUrls: ['./console-status.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -0,0 +1,62 @@
<header class="first-signal-card__header">
<div class="first-signal-card__title">
<span class="first-signal-card__label">First signal</span>
<span class="badge" [class]="badgeClass()">{{ badgeText() }}</span>
</div>
<div class="first-signal-card__meta">
@if (realtimeMode() === 'sse') {
<span class="realtime-indicator realtime-indicator--live">Live</span>
} @else if (realtimeMode() === 'polling') {
<span class="realtime-indicator realtime-indicator--polling">Polling</span>
}
@if (stageText(); as stage) {
<span class="first-signal-card__stage">{{ stage }}</span>
}
<span class="first-signal-card__run-id">Run: {{ runId() }}</span>
</div>
</header>
@if (signal(); as sig) {
<div class="first-signal-card__body" aria-live="polite" aria-atomic="true">
<p class="first-signal-card__message">{{ sig.message }}</p>
@if (sig.artifact) {
<div class="first-signal-card__artifact">
<span class="first-signal-card__artifact-kind">{{ sig.artifact.kind }}</span>
@if (sig.artifact.range) {
<span class="first-signal-card__artifact-range">
Range {{ sig.artifact.range.start }}{{ sig.artifact.range.end }}
</span>
}
</div>
}
<time class="first-signal-card__timestamp" [attr.datetime]="sig.at" [title]="sig.at">
{{ sig.at | date: 'medium' }}
</time>
</div>
} @else if (response()) {
<div class="first-signal-card__empty" role="status">
<p>Waiting for first signal…</p>
</div>
} @else if (state() === 'loading' && showSkeleton()) {
<div class="first-signal-card__skeleton" aria-hidden="true">
<div class="skeleton-line skeleton-line--wide"></div>
<div class="skeleton-line skeleton-line--medium"></div>
<div class="skeleton-line skeleton-line--narrow"></div>
</div>
} @else if (state() === 'unavailable') {
<div class="first-signal-card__empty" role="status">
<p>Signal not available yet.</p>
</div>
} @else if (state() === 'offline') {
<div class="first-signal-card__error" role="alert">
<p>Offline. Last known signal may be stale.</p>
<button type="button" class="retry-button" (click)="retry()">Retry</button>
</div>
} @else if (state() === 'error') {
<div class="first-signal-card__error" role="alert">
<p>{{ error() ?? 'Failed to load signal.' }}</p>
<button type="button" class="retry-button" (click)="retry()">Try again</button>
</div>
}

View File

@@ -0,0 +1,233 @@
.first-signal-card {
display: block;
padding: 1rem;
border-radius: 12px;
border: 1px solid #e5e7eb;
background: #ffffff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.first-signal-card__header {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 0.75rem;
}
.first-signal-card__title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.first-signal-card__label {
font-weight: 700;
color: #0f172a;
}
.first-signal-card__meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
color: #64748b;
font-size: 0.875rem;
}
.realtime-indicator {
display: inline-flex;
align-items: center;
padding: 0.1rem 0.5rem;
border-radius: 9999px;
font-size: 0.725rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.realtime-indicator--live {
background: rgba(20, 184, 166, 0.14);
border: 1px solid rgba(20, 184, 166, 0.25);
color: #0f766e;
}
.realtime-indicator--polling {
background: rgba(245, 158, 11, 0.14);
border: 1px solid rgba(245, 158, 11, 0.25);
color: #b45309;
}
.first-signal-card__run-id {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
color: #475569;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.55rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.03em;
border: 1px solid transparent;
}
.badge--neutral {
background: #f1f5f9;
color: #334155;
border-color: #e2e8f0;
}
.badge--info {
background: rgba(59, 130, 246, 0.14);
color: #1d4ed8;
border-color: rgba(59, 130, 246, 0.25);
}
.badge--ok {
background: rgba(20, 184, 166, 0.14);
color: #0f766e;
border-color: rgba(20, 184, 166, 0.25);
}
.badge--warn {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
border-color: rgba(245, 158, 11, 0.25);
}
.badge--error {
background: rgba(239, 68, 68, 0.14);
color: #b91c1c;
border-color: rgba(239, 68, 68, 0.25);
}
.badge--unknown {
background: #f8fafc;
color: #64748b;
border-color: #e2e8f0;
}
.first-signal-card__body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.first-signal-card__message {
margin: 0;
font-size: 0.95rem;
line-height: 1.5;
color: #0f172a;
}
.first-signal-card__artifact {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.875rem;
color: #334155;
padding: 0.5rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #f8fafc;
}
.first-signal-card__artifact-kind {
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #475569;
}
.first-signal-card__artifact-range {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
overflow-wrap: anywhere;
}
.first-signal-card__timestamp {
font-size: 0.825rem;
color: #64748b;
}
.first-signal-card__empty {
padding: 0.75rem 0;
color: #64748b;
}
.first-signal-card__error {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.85rem;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.25);
background: rgba(239, 68, 68, 0.06);
color: #7f1d1d;
}
.retry-button {
align-self: flex-start;
border: 1px solid #fca5a5;
background: #ffffff;
color: #991b1b;
border-radius: 10px;
padding: 0.4rem 0.75rem;
font-weight: 700;
cursor: pointer;
transition:
transform var(--motion-duration-sm) var(--motion-ease-standard),
background-color var(--motion-duration-sm) var(--motion-ease-standard);
&:hover,
&:focus-visible {
background: #fee2e2;
transform: translateY(-1px);
}
}
.first-signal-card__skeleton {
display: grid;
gap: 0.5rem;
}
.skeleton-line {
height: 12px;
border-radius: 6px;
background: linear-gradient(90deg, #eef2ff, #f1f5f9, #eef2ff);
background-size: 200% 100%;
animation: shimmer 1.8s infinite;
}
.skeleton-line--wide {
width: 92%;
}
.skeleton-line--medium {
width: 72%;
}
.skeleton-line--narrow {
width: 48%;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton-line {
animation: none;
background: #e5e7eb;
}
.retry-button {
transition: none;
}
}

View File

@@ -0,0 +1,157 @@
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
OnDestroy,
signal,
} from '@angular/core';
import { FirstSignalStore } from '../../../../core/api/first-signal.store';
import { FirstSignalDto } from '../../../../core/api/first-signal.models';
import { FirstSignalPrefetchService } from '../../services/first-signal-prefetch.service';
@Component({
selector: 'app-first-signal-card',
standalone: true,
imports: [CommonModule],
templateUrl: './first-signal-card.component.html',
styleUrls: ['./first-signal-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'first-signal-card',
role: 'region',
'aria-label': 'First signal status',
'[attr.aria-busy]': "state() === 'loading'",
'[class.first-signal-card--loading]': "state() === 'loading'",
'[class.first-signal-card--error]': "state() === 'error'",
'[class.first-signal-card--offline]': "state() === 'offline'",
},
})
export class FirstSignalCardComponent implements OnDestroy {
private readonly store = inject(FirstSignalStore);
private readonly prefetch = inject(FirstSignalPrefetchService);
private lastLoadKey: string | null = null;
readonly runId = input.required<string>();
readonly tenantId = input<string | null>(null);
readonly projectId = input<string | null>(null);
readonly enableRealTime = input<boolean>(true);
readonly pollIntervalMs = input<number>(5000);
readonly skeletonDelayMs = input<number>(50);
private skeletonDelayHandle: ReturnType<typeof setTimeout> | null = null;
private readonly showSkeletonSignal = signal(false);
readonly state = this.store.state;
readonly error = this.store.error;
readonly response = this.store.response;
readonly signal = this.store.firstSignal;
readonly hasSignal = this.store.hasSignal;
readonly realtimeMode = this.store.realtimeMode;
readonly showSkeleton = this.showSkeletonSignal.asReadonly();
readonly badgeText = computed(() => this.formatBadgeText(this.signal()?.type));
readonly badgeClass = computed(() => this.formatBadgeClass(this.signal()?.type));
readonly stageText = computed(() => this.formatStageText(this.signal()));
constructor() {
effect(
() => {
const runId = this.runId();
const tenantId = this.tenantId() ?? undefined;
const projectId = this.projectId() ?? undefined;
const enableRealTime = this.enableRealTime();
const pollIntervalMs = this.pollIntervalMs();
const loadKey = `${runId}|${tenantId ?? ''}|${projectId ?? ''}|${enableRealTime ? '1' : '0'}|${pollIntervalMs}`;
if (this.lastLoadKey === loadKey) {
return;
}
this.lastLoadKey = loadKey;
this.store.clear();
const prefetched = this.prefetch.get(runId);
if (prefetched?.response) {
this.store.prime({ response: prefetched.response, etag: prefetched.etag });
}
this.store.load(runId, { tenantId, projectId });
if (enableRealTime) {
this.store.connect(runId, { tenantId, projectId, pollIntervalMs });
}
},
{ allowSignalWrites: true }
);
effect(
() => {
const state = this.state();
const delayMs = Math.max(0, Math.floor(this.skeletonDelayMs()));
if (state !== 'loading' || !!this.response()) {
this.clearSkeletonDelay();
this.showSkeletonSignal.set(false);
return;
}
this.showSkeletonSignal.set(false);
this.clearSkeletonDelay();
this.skeletonDelayHandle = setTimeout(() => this.showSkeletonSignal.set(true), delayMs);
},
{ allowSignalWrites: true }
);
}
ngOnDestroy(): void {
this.clearSkeletonDelay();
this.store.clear();
}
retry(): void {
this.store.load(this.runId(), {
tenantId: this.tenantId() ?? undefined,
projectId: this.projectId() ?? undefined,
});
}
private clearSkeletonDelay(): void {
if (!this.skeletonDelayHandle) return;
clearTimeout(this.skeletonDelayHandle);
this.skeletonDelayHandle = null;
}
private formatBadgeText(type: string | null | undefined): string {
if (!type) return 'Signal';
return type
.trim()
.replaceAll('_', ' ')
.replaceAll('-', ' ')
.replace(/\s+/g, ' ')
.replace(/^./, (c) => c.toUpperCase());
}
private formatBadgeClass(type: string | null | undefined): string {
const normalized = (type ?? '').trim().toLowerCase();
if (!normalized) return 'badge badge--unknown';
if (normalized === 'failed' || normalized === 'error') return 'badge badge--error';
if (normalized === 'blocked') return 'badge badge--warn';
if (normalized === 'queued' || normalized === 'pending') return 'badge badge--neutral';
if (normalized === 'started' || normalized === 'phase' || normalized === 'running') return 'badge badge--info';
if (normalized === 'succeeded' || normalized === 'completed' || normalized === 'done') return 'badge badge--ok';
return 'badge badge--neutral';
}
private formatStageText(signal: FirstSignalDto | null): string | null {
if (!signal) return null;
const stage = (signal.stage ?? '').trim();
const step = (signal.step ?? '').trim();
if (!stage && !step) return null;
if (stage && step) return `${stage} · ${step}`;
return stage || step;
}
}

View File

@@ -0,0 +1,109 @@
import { Injectable, OnDestroy, inject } from '@angular/core';
import { Subscription } from 'rxjs';
import { FIRST_SIGNAL_API, type FirstSignalApi } from '../../../core/api/first-signal.client';
import type { FirstSignalResponse } from '../../../core/api/first-signal.models';
export interface FirstSignalPrefetchEntry {
response: FirstSignalResponse;
etag: string | null;
fetchedAt: number;
}
@Injectable({ providedIn: 'root' })
export class FirstSignalPrefetchService implements OnDestroy {
private readonly api = inject(FIRST_SIGNAL_API) as FirstSignalApi;
private readonly cache = new Map<string, FirstSignalPrefetchEntry>();
private readonly inFlight = new Map<string, Subscription>();
private observer: IntersectionObserver | null = null;
private observed = new Map<Element, { runId: string; tenantId?: string; projectId?: string }>();
private readonly CACHE_TTL_MS = 60_000;
private readonly PREFETCH_THRESHOLD = 0.1;
observe(element: Element, runId: string, options: { tenantId?: string; projectId?: string } = {}): void {
const observer = this.ensureObserver();
if (!observer) return;
this.observed.set(element, { runId, tenantId: options.tenantId, projectId: options.projectId });
observer.observe(element);
}
unobserve(element: Element): void {
if (!this.observer) return;
this.observed.delete(element);
this.observer.unobserve(element);
}
get(runId: string): FirstSignalPrefetchEntry | null {
const entry = this.cache.get(runId);
if (!entry) return null;
if (Date.now() - entry.fetchedAt > this.CACHE_TTL_MS) {
this.cache.delete(runId);
return null;
}
return entry;
}
ngOnDestroy(): void {
this.observer?.disconnect();
this.observer = null;
this.observed.clear();
for (const sub of this.inFlight.values()) {
sub.unsubscribe();
}
this.inFlight.clear();
}
private ensureObserver(): IntersectionObserver | null {
if (this.observer) return this.observer;
if (typeof IntersectionObserver === 'undefined') return null;
this.observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const spec = this.observed.get(entry.target);
if (!spec) continue;
this.prefetch(spec.runId, { tenantId: spec.tenantId, projectId: spec.projectId });
}
},
{ threshold: this.PREFETCH_THRESHOLD }
);
return this.observer;
}
private prefetch(runId: string, options: { tenantId?: string; projectId?: string }): void {
if (this.get(runId)) return;
if (this.inFlight.has(runId)) return;
const sub = this.api
.getFirstSignal(runId, {
tenantId: options.tenantId,
projectId: options.projectId,
})
.subscribe({
next: (result) => {
if (!result.response) return;
this.cache.set(runId, {
response: result.response,
etag: result.etag ?? result.response.summaryEtag ?? null,
fetchedAt: Date.now(),
});
},
error: () => {
// Best-effort prefetch; ignore failures and rely on normal load path.
},
complete: () => {
this.inFlight.delete(runId);
},
});
this.inFlight.set(runId, sub);
sub.add(() => this.inFlight.delete(runId));
}
}

View File

@@ -0,0 +1,118 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
DecisionDrawerComponent,
type DecisionFormData,
} from './decision-drawer.component';
describe('DecisionDrawerComponent', () => {
let fixture: ComponentFixture<DecisionDrawerComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DecisionDrawerComponent],
}).compileComponents();
fixture = TestBed.createComponent(DecisionDrawerComponent);
});
afterEach(() => {
fixture.destroy();
});
it('disables submit until a reason is selected', () => {
const component = fixture.componentInstance;
component.isOpen = true;
fixture.detectChanges();
const submit = fixture.nativeElement.querySelector('button.btn-primary') as HTMLButtonElement;
expect(component.isValid()).toBeFalse();
expect(submit.disabled).toBeTrue();
component.setReasonCode('component_not_present');
fixture.detectChanges();
expect(component.isValid()).toBeTrue();
expect(submit.disabled).toBeFalse();
});
it('emits close when Escape is pressed while open', () => {
const component = fixture.componentInstance;
component.isOpen = true;
const closeSpy = jasmine.createSpy('close');
component.close.subscribe(closeSpy);
fixture.detectChanges();
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));
expect(closeSpy).toHaveBeenCalled();
});
it('applies A/N/U shortcuts while open', () => {
const component = fixture.componentInstance;
component.isOpen = true;
fixture.detectChanges();
expect(component.formData().status).toBe('under_investigation');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true }));
expect(component.formData().status).toBe('affected');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'n', bubbles: true, cancelable: true }));
expect(component.formData().status).toBe('not_affected');
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'u', bubbles: true, cancelable: true }));
expect(component.formData().status).toBe('under_investigation');
});
it('does not apply shortcuts when typing in the textarea', () => {
const component = fixture.componentInstance;
component.isOpen = true;
component.setStatus('not_affected');
fixture.detectChanges();
const textarea = fixture.nativeElement.querySelector('textarea.reason-text') as HTMLTextAreaElement;
textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true }));
expect(component.formData().status).toBe('not_affected');
});
it('emits decisionSubmit when valid and submitted', () => {
const component = fixture.componentInstance;
component.isOpen = true;
component.setStatus('affected');
component.setReasonCode('vulnerable_code_reachable');
component.setReasonText('notes');
const submitSpy = jasmine.createSpy('decisionSubmit');
component.decisionSubmit.subscribe(submitSpy);
fixture.detectChanges();
const submit = fixture.nativeElement.querySelector('button.btn-primary') as HTMLButtonElement;
submit.click();
expect(submitSpy).toHaveBeenCalledWith({
status: 'affected',
reasonCode: 'vulnerable_code_reachable',
reasonText: 'notes',
} as DecisionFormData);
});
it('emits close when close button is clicked', () => {
const component = fixture.componentInstance;
component.isOpen = true;
const closeSpy = jasmine.createSpy('close');
component.close.subscribe(closeSpy);
fixture.detectChanges();
const closeButton = fixture.nativeElement.querySelector('button.close-btn') as HTMLButtonElement;
closeButton.click();
expect(closeSpy).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,73 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import type { EvidenceBundle } from '../../models/evidence.model';
import { EvidencePillsComponent } from './evidence-pills.component';
describe('EvidencePillsComponent', () => {
let fixture: ComponentFixture<EvidencePillsComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EvidencePillsComponent],
}).compileComponents();
fixture = TestBed.createComponent(EvidencePillsComponent);
});
afterEach(() => {
fixture.destroy();
});
it('renders 4 pills and completeness badge', () => {
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelectorAll('button.pill').length).toBe(4);
expect(element.querySelector('.completeness-badge')?.textContent?.trim()).toBe('0/4');
});
it('computes pill classes and badge from evidence', () => {
const evidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date(0).toISOString(),
reachability: { status: 'available', hash: 'sha256:reach' },
callstack: { status: 'loading', hash: 'sha256:call' },
provenance: { status: 'pending_enrichment', hash: 'sha256:prov' },
vex: { status: 'error' },
hashes: { combinedHash: 'sha256:all', hashes: ['sha256:reach', 'sha256:call'] },
};
fixture.componentInstance.evidence = evidence;
fixture.detectChanges();
const pills = Array.from(fixture.nativeElement.querySelectorAll('button.pill')) as HTMLButtonElement[];
expect(pills.length).toBe(4);
expect(pills[0].classList.contains('available')).toBeTrue();
expect(pills[1].classList.contains('loading')).toBeTrue();
expect(pills[2].classList.contains('pending')).toBeTrue();
expect(pills[3].classList.contains('unavailable')).toBeTrue();
expect(pills[0].getAttribute('aria-label')).toBe('Reachability: available');
expect(pills[1].getAttribute('aria-label')).toBe('Call-stack: loading');
expect(pills[2].getAttribute('aria-label')).toBe('Provenance: pending_enrichment');
expect(pills[3].getAttribute('aria-label')).toBe('VEX: error');
expect((fixture.nativeElement as HTMLElement).querySelector('.completeness-badge')?.textContent?.trim()).toBe('1/4');
});
it('emits pillClick when a pill is clicked', () => {
const component = fixture.componentInstance;
const clicks: Array<'reachability' | 'callstack' | 'provenance' | 'vex'> = [];
component.pillClick.subscribe((value) => clicks.push(value));
fixture.detectChanges();
const pills = (fixture.nativeElement as HTMLElement).querySelectorAll('button.pill');
(pills[1] as HTMLButtonElement).click();
(pills[3] as HTMLButtonElement).click();
expect(clicks).toEqual(['callstack', 'vex']);
});
});

View File

@@ -0,0 +1,33 @@
import { EvidenceBitset, EvidenceBundle } from './evidence.model';
describe('EvidenceBitset', () => {
it('computes completeness score from flags', () => {
const bitset = EvidenceBitset.from({ reachability: true, callstack: false, provenance: true, vex: true });
expect(bitset.hasReachability).toBeTrue();
expect(bitset.hasCallstack).toBeFalse();
expect(bitset.hasProvenance).toBeTrue();
expect(bitset.hasVex).toBeTrue();
expect(bitset.completenessScore).toBe(3);
expect(bitset.value).toBe(1 + 4 + 8);
});
it('derives flags from evidence bundle availability', () => {
const bundle: EvidenceBundle = {
alertId: 'alert-1',
computedAt: '2025-12-15T00:00:00.000Z',
reachability: { status: 'available' },
callstack: { status: 'unavailable' },
provenance: { status: 'available' },
vex: { status: 'available' },
};
const bitset = EvidenceBitset.fromBundle(bundle);
expect(bitset.hasReachability).toBeTrue();
expect(bitset.hasCallstack).toBeFalse();
expect(bitset.hasProvenance).toBeTrue();
expect(bitset.hasVex).toBeTrue();
expect(bitset.completenessScore).toBe(3);
});
});

View File

@@ -0,0 +1,77 @@
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { EvidenceBitset } from '../models/evidence.model';
import { TtfsTelemetryService } from './ttfs-telemetry.service';
describe('TtfsTelemetryService', () => {
let service: TtfsTelemetryService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
});
service = TestBed.inject(TtfsTelemetryService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('flushes batched events on decision and clears timing state', fakeAsync(() => {
const times = [0, 300, 600, 1400, 1500, 1700, 2000];
spyOn(performance, 'now').and.callFake(() => times.shift() ?? 0);
service.startTracking('alert-1', new Date('2025-12-15T00:00:00.000Z'));
service.recordSkeletonRender('alert-1');
service.recordFirstEvidence('alert-1', 'reachability');
service.recordFullEvidence('alert-1', EvidenceBitset.from({ reachability: true, callstack: true }));
service.recordInteraction('alert-1', 'click');
service.recordInteraction('alert-1', 'click');
service.recordDecision('alert-1', 'accepted');
const req = httpMock.expectOne('/api/v1/telemetry/ttfs');
expect(req.request.method).toBe('POST');
const body = req.request.body as { events: Array<Record<string, unknown>> };
expect(Array.isArray(body.events)).toBeTrue();
const eventTypes = body.events.map((e) => e['event_type']);
expect(eventTypes).toContain('ttfs.start');
expect(eventTypes).toContain('ttfs.skeleton');
expect(eventTypes).toContain('ttfs.first_evidence');
expect(eventTypes).toContain('ttfs.full_evidence');
expect(eventTypes).toContain('decision.recorded');
expect(body.events.filter((e) => e['event_type'] === 'budget.violation').length).toBe(2);
const firstEvidence = body.events.find((e) => e['event_type'] === 'ttfs.first_evidence') as Record<string, unknown>;
expect(firstEvidence['evidence_type']).toBe('reachability');
const decision = body.events.find((e) => e['event_type'] === 'decision.recorded') as Record<string, unknown>;
expect(decision['decision_status']).toBe('accepted');
expect(decision['click_count']).toBe(2);
req.flush({});
tick();
expect(service.getTimings('alert-1')).toBeUndefined();
}));
it('flushes queued events after the timeout', fakeAsync(() => {
spyOn(performance, 'now').and.returnValue(0);
service.startTracking('alert-1', new Date('2025-12-15T00:00:00.000Z'));
tick(4999);
httpMock.expectNone('/api/v1/telemetry/ttfs');
tick(1);
const req = httpMock.expectOne('/api/v1/telemetry/ttfs');
expect((req.request.body as { events: unknown[] }).events.length).toBe(1);
req.flush({});
tick();
}));
});

View File

@@ -1,4 +1,5 @@
import { ComponentFixture, TestBed, fakeAsync, flush, flushMicrotasks } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ActivatedRoute } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { of } from 'rxjs';
@@ -58,7 +59,7 @@ describe('TriageWorkspaceComponent', () => {
vexApi.listDecisions.and.returnValue(of({ items: [], count: 0, continuationToken: null }));
await TestBed.configureTestingModule({
imports: [RouterTestingModule, TriageWorkspaceComponent],
imports: [HttpClientTestingModule, RouterTestingModule, TriageWorkspaceComponent],
providers: [
{ provide: VULNERABILITY_API, useValue: vulnApi },
{ provide: VEX_DECISIONS_API, useValue: vexApi },
@@ -70,7 +71,7 @@ describe('TriageWorkspaceComponent', () => {
});
afterEach(() => {
fixture.destroy();
fixture?.destroy();
});
it('filters findings by artifactId', fakeAsync(() => {
@@ -123,6 +124,8 @@ describe('TriageWorkspaceComponent', () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }));
expect(component.selectedVulnId()).toBe('v-2');
flush();
}));
it('switches to reachability tab with /', fakeAsync(() => {
@@ -130,7 +133,7 @@ describe('TriageWorkspaceComponent', () => {
flushMicrotasks();
const component = fixture.componentInstance;
expect(component.activeTab()).toBe('overview');
expect(component.activeTab()).toBe('evidence');
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/', bubbles: true, cancelable: true }));
expect(component.activeTab()).toBe('reachability');

View File

@@ -0,0 +1,122 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { EMPTY, Observable, of, throwError } from 'rxjs';
import type { FirstSignalApi } from '../../app/core/api/first-signal.client';
import { FIRST_SIGNAL_API } from '../../app/core/api/first-signal.client';
import type { FirstSignalResponse } from '../../app/core/api/first-signal.models';
import { FirstSignalStore } from '../../app/core/api/first-signal.store';
import { FirstSignalCardComponent } from '../../app/features/runs/components/first-signal-card/first-signal-card.component';
type Scenario = 'loaded' | 'waiting' | 'error' | 'loading';
function createApi(scenario: Scenario): FirstSignalApi {
return {
getFirstSignal: (runId: string) => {
const etag = '"first-signal-story-v1"';
if (scenario === 'loading') {
return new Observable(() => {});
}
if (scenario === 'error') {
return throwError(() => new Error('Synthetic first-signal error'));
}
const response: FirstSignalResponse = {
runId,
summaryEtag: etag,
firstSignal:
scenario === 'waiting'
? null
: {
type: 'started',
stage: 'fetch',
step: 'pull',
message: `Fetched base metadata for run ${runId}`,
at: '2025-01-01T00:00:00Z',
artifact: { kind: 'image', range: { start: 0, end: 123 } },
},
};
return of({ response, etag, cacheStatus: 'story' });
},
streamFirstSignal: () => EMPTY,
};
}
const meta: Meta<FirstSignalCardComponent> = {
title: 'Runs/First Signal Card',
component: FirstSignalCardComponent,
decorators: [
moduleMetadata({
imports: [FirstSignalCardComponent],
providers: [FirstSignalStore, { provide: FIRST_SIGNAL_API, useValue: createApi('loaded') }],
}),
],
parameters: {
a11y: {
element: '#first-signal-card-story',
},
},
render: (args) => ({
props: args,
template: `
<div id="first-signal-card-story" style="max-width: 960px; padding: 16px;">
<app-first-signal-card [runId]="runId" [enableRealTime]="enableRealTime" [pollIntervalMs]="pollIntervalMs" />
</div>
`,
}),
};
export default meta;
type Story = StoryObj<FirstSignalCardComponent>;
export const Loaded: Story = {
args: {
runId: 'run-1',
enableRealTime: true,
pollIntervalMs: 5000,
},
};
export const Waiting: Story = {
decorators: [
moduleMetadata({
providers: [{ provide: FIRST_SIGNAL_API, useValue: createApi('waiting') }],
}),
],
args: {
runId: 'run-2',
enableRealTime: true,
pollIntervalMs: 5000,
},
};
export const Error: Story = {
decorators: [
moduleMetadata({
providers: [{ provide: FIRST_SIGNAL_API, useValue: createApi('error') }],
}),
],
args: {
runId: 'run-3',
enableRealTime: true,
pollIntervalMs: 5000,
},
};
export const Loading: Story = {
decorators: [
moduleMetadata({
providers: [{ provide: FIRST_SIGNAL_API, useValue: createApi('loading') }],
}),
],
args: {
runId: 'run-4',
enableRealTime: false,
pollIntervalMs: 5000,
},
};

View File

@@ -0,0 +1,69 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import type { AlertSummary } from '../../app/features/triage/components/decision-drawer/decision-drawer.component';
import { DecisionDrawerComponent } from '../../app/features/triage/components/decision-drawer/decision-drawer.component';
const sampleAlert: AlertSummary = {
id: 'alert-1',
artifactId: 'asset-web-prod',
vulnId: 'CVE-2024-0001',
severity: 'high',
};
const meta: Meta<DecisionDrawerComponent> = {
title: 'Triage/Decision Drawer',
component: DecisionDrawerComponent,
decorators: [
moduleMetadata({
imports: [DecisionDrawerComponent],
}),
],
argTypes: {
close: { action: 'close' },
decisionSubmit: { action: 'decisionSubmit' },
},
parameters: {
a11y: {
element: '#decision-drawer-story',
},
},
render: (args) => ({
props: args,
template: `
<div id="decision-drawer-story" style="height: 640px; background: #f5f5f5; position: relative;">
<app-decision-drawer
[alert]="alert"
[isOpen]="isOpen"
[evidenceHash]="evidenceHash"
[policyVersion]="policyVersion"
(close)="close()"
(decisionSubmit)="decisionSubmit($event)">
</app-decision-drawer>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<DecisionDrawerComponent>;
export const Open: Story = {
args: {
alert: sampleAlert,
isOpen: true,
evidenceHash: 'sha256:deadbeef',
policyVersion: 'policy-v1',
},
};
export const Closed: Story = {
args: {
alert: sampleAlert,
isOpen: false,
evidenceHash: 'sha256:deadbeef',
policyVersion: 'policy-v1',
},
};

View File

@@ -0,0 +1,78 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import type { EvidenceBundle } from '../../app/features/triage/models/evidence.model';
import { EvidencePillsComponent } from '../../app/features/triage/components/evidence-pills/evidence-pills.component';
const baseEvidence: EvidenceBundle = {
alertId: 'alert-1',
computedAt: new Date(0).toISOString(),
reachability: { status: 'unavailable' },
callstack: { status: 'unavailable' },
provenance: { status: 'unavailable' },
vex: { status: 'unavailable' },
hashes: { combinedHash: 'sha256:00', hashes: [] },
};
const meta: Meta<EvidencePillsComponent> = {
title: 'Triage/Evidence Pills',
component: EvidencePillsComponent,
decorators: [
moduleMetadata({
imports: [EvidencePillsComponent],
}),
],
argTypes: {
pillClick: { action: 'pillClick' },
},
parameters: {
a11y: {
element: '#evidence-pills-story',
},
},
render: (args) => ({
props: args,
template: `
<div id="evidence-pills-story" style="max-width: 960px; padding: 16px; background: #fff;">
<app-evidence-pills [evidence]="evidence" (pillClick)="pillClick($event)"></app-evidence-pills>
</div>
`,
}),
};
export default meta;
type Story = StoryObj<EvidencePillsComponent>;
export const Unavailable: Story = {
args: {
evidence: baseEvidence,
},
};
export const MixedStates: Story = {
args: {
evidence: {
...baseEvidence,
reachability: { status: 'available', hash: 'sha256:reach' },
callstack: { status: 'loading', hash: 'sha256:call' },
provenance: { status: 'pending_enrichment', hash: 'sha256:prov' },
vex: { status: 'error' },
hashes: { combinedHash: 'sha256:all', hashes: ['sha256:reach', 'sha256:call'] },
},
},
};
export const Complete: Story = {
args: {
evidence: {
...baseEvidence,
reachability: { status: 'available' },
callstack: { status: 'available' },
provenance: { status: 'available' },
vex: { status: 'available' },
hashes: { combinedHash: 'sha256:complete', hashes: ['sha256:r', 'sha256:c', 'sha256:p', 'sha256:v'] },
},
},
};

View File

@@ -0,0 +1,92 @@
{
"url": "http://127.0.0.1:4400/console/status",
"violations": [
{
"id": "color-contrast",
"impact": "serious",
"tags": [
"cat.color",
"wcag2aa",
"wcag143",
"TTv5",
"TT13.c",
"EN-301-549",
"EN-9.1.4.3",
"ACT"
],
"description": "Ensures the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds",
"help": "Elements must meet minimum color contrast ratio thresholds",
"helpUrl": "https://dequeuniversity.com/rules/axe/4.8/color-contrast?application=playwright",
"nodes": [
{
"any": [
{
"id": "color-contrast",
"data": {
"fgColor": "#f05d5d",
"bgColor": "#f8fafc",
"contrastRatio": 3.12,
"fontSize": "12.0pt (16px)",
"fontWeight": "normal",
"messageKey": null,
"expectedContrastRatio": "4.5:1"
},
"relatedNodes": [
{
"html": "<app-root _nghost-ng-c1556698195=\"\" ng-version=\"17.3.12\">",
"target": [
"app-root"
]
}
],
"impact": "serious",
"message": "Element has insufficient color contrast of 3.12 (foreground color: #f05d5d, background color: #f8fafc, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1"
}
],
"all": [],
"none": [],
"impact": "serious",
"html": "<div _ngcontent-ng-c669690054=\"\" class=\"error\">Unable to load console status</div>",
"target": [
".error"
],
"failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 3.12 (foreground color: #f05d5d, background color: #f8fafc, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1"
},
{
"any": [
{
"id": "color-contrast",
"data": {
"fgColor": "#69707a",
"bgColor": "#0b0f14",
"contrastRatio": 3.84,
"fontSize": "12.0pt (16px)",
"fontWeight": "normal",
"messageKey": null,
"expectedContrastRatio": "4.5:1"
},
"relatedNodes": [
{
"html": "<div _ngcontent-ng-c669690054=\"\" class=\"events\"><!--bindings={\n \"ng-reflect-ng-for-of\": \"\"\n}--><p _ngcontent-ng-c669690054=\"\" class=\"empty\">No events yet.</p><!--bindings={\n \"ng-reflect-ng-if\": \"true\"\n}--></div>",
"target": [
".events"
]
}
],
"impact": "serious",
"message": "Element has insufficient color contrast of 3.84 (foreground color: #69707a, background color: #0b0f14, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1"
}
],
"all": [],
"none": [],
"impact": "serious",
"html": "<p _ngcontent-ng-c669690054=\"\" class=\"empty\">No events yet.</p>",
"target": [
".empty"
],
"failureSummary": "Fix any of the following:\n Element has insufficient color contrast of 3.84 (foreground color: #69707a, background color: #0b0f14, font size: 12.0pt (16px), font weight: normal). Expected contrast ratio of 4.5:1"
}
]
}
]
}

View File

@@ -0,0 +1,4 @@
{
"url": "http://127.0.0.1:4400/triage/artifacts/asset-web-prod",
"violations": []
}

View File

@@ -88,6 +88,14 @@ test.describe('a11y-smoke', () => {
});
});
test('console status first signal card', async ({ page }, testInfo) => {
const violations = await runA11y('/console/status', page);
testInfo.annotations.push({
type: 'a11y',
description: `${violations.length} violations (/console/status)`,
});
});
test('triage VEX modal', async ({ page }, testInfo) => {
await page.goto('/triage/artifacts/asset-web-prod');
await expect(page.getByRole('heading', { name: 'Artifact triage' })).toBeVisible({ timeout: 10000 });

View File

@@ -0,0 +1,57 @@
import { expect, test } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
const mockConfig = {
authority: {
issuer: 'https://authority.local',
clientId: 'stellaops-ui',
authorizeEndpoint: 'https://authority.local/connect/authorize',
tokenEndpoint: 'https://authority.local/connect/token',
logoutEndpoint: 'https://authority.local/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: 'https://authority.local',
scanner: 'https://scanner.local',
policy: 'https://scanner.local',
concelier: 'https://concelier.local',
attestor: 'https://attestor.local',
},
quickstartMode: true,
};
test.beforeEach(async ({ page }) => {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage errors in restricted contexts
}
(window as any).__stellaopsTestSession = session;
}, policyAuthorSession);
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
})
);
await page.route('https://authority.local/**', (route) => route.abort());
});
test('first signal card renders on console status page (quickstart)', async ({ page }) => {
await page.goto('/console/status');
const card = page.getByRole('region', { name: 'First signal status' });
await expect(card).toBeVisible({ timeout: 10000 });
await expect(card).toContainText('Mock first signal for run last');
});