up
This commit is contained in:
@@ -45,3 +45,5 @@
|
||||
| UI-VEX-0215-001 | DONE (2025-12-12) | VEX-first triage modal with scope/validity/evidence/review sections and bulk apply; wired via `src/app/core/api/vex-decisions.client.ts`. |
|
||||
| UI-AUDIT-0215-001 | DONE (2025-12-12) | Immutable audit bundle button + wizard/history views; download via `GET /v1/audit-bundles/{bundleId}` (`Accept: application/octet-stream`) using `src/app/core/api/audit-bundles.client.ts`. |
|
||||
| WEB-TRIAGE-0215-001 | DONE (2025-12-12) | Added triage TS models + web SDK clients (VEX decisions, audit bundles, vuln-scan attestation predicate) and fixed `scripts/chrome-path.js` so `npm test` runs on Windows Playwright Chromium. |
|
||||
| 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. |
|
||||
|
||||
@@ -116,15 +116,16 @@ export class AuditBundlesHttpClient implements AuditBundlesApi {
|
||||
}
|
||||
|
||||
interface StoredAuditJob extends AuditBundleJobResponse {
|
||||
readonly createdAtMs: number;
|
||||
pollCount: number;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockAuditBundlesClient implements AuditBundlesApi {
|
||||
private static readonly BaseMs = Date.parse('2025-12-01T00:00:00Z');
|
||||
private readonly store: StoredAuditJob[] = [];
|
||||
|
||||
listBundles(): Observable<AuditBundleListResponse> {
|
||||
const traceId = generateTraceId();
|
||||
const traceId = 'mock-trace-audit-list';
|
||||
const items = [...this.store]
|
||||
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : a.bundleId.localeCompare(b.bundleId)))
|
||||
.map((job) => this.materialize(job));
|
||||
@@ -133,17 +134,17 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
|
||||
}
|
||||
|
||||
createBundle(request: AuditBundleCreateRequest, options: { traceId?: string } = {}): Observable<AuditBundleJobResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const createdAt = new Date().toISOString();
|
||||
const traceId = options.traceId ?? 'mock-trace-audit-create';
|
||||
const bundleId = this.allocateId();
|
||||
const createdAt = this.timestampForSeq(this.store.length + 1);
|
||||
|
||||
const job: StoredAuditJob = {
|
||||
bundleId,
|
||||
status: 'queued',
|
||||
createdAt,
|
||||
subject: request.subject,
|
||||
createdAtMs: Date.now(),
|
||||
traceId,
|
||||
pollCount: 0,
|
||||
};
|
||||
|
||||
this.store.push(job);
|
||||
@@ -153,6 +154,7 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
|
||||
getBundle(bundleId: string): Observable<AuditBundleJobResponse> {
|
||||
const job = this.store.find((j) => j.bundleId === bundleId);
|
||||
if (!job) return throwError(() => new Error('Bundle not found'));
|
||||
job.pollCount += 1;
|
||||
return of(this.materialize(job)).pipe(delay(150));
|
||||
}
|
||||
|
||||
@@ -173,9 +175,18 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
|
||||
}
|
||||
|
||||
private materialize(job: StoredAuditJob): AuditBundleJobResponse {
|
||||
const elapsedMs = Date.now() - job.createdAtMs;
|
||||
if (elapsedMs < 500) return job;
|
||||
if (elapsedMs < 1500) return { ...job, status: 'processing' };
|
||||
if (job.status === 'completed' || job.status === 'failed') {
|
||||
return job;
|
||||
}
|
||||
|
||||
if (job.pollCount <= 1) {
|
||||
return job;
|
||||
}
|
||||
|
||||
if (job.pollCount === 2) {
|
||||
return { ...job, status: 'processing' };
|
||||
}
|
||||
|
||||
return {
|
||||
...job,
|
||||
status: 'completed',
|
||||
@@ -190,4 +201,8 @@ export class MockAuditBundlesClient implements AuditBundlesApi {
|
||||
const seq = this.store.length + 1;
|
||||
return `bndl-${seq.toString().padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private timestampForSeq(seq: number): string {
|
||||
return new Date(MockAuditBundlesClient.BaseMs + seq * 60000).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ export class VexDecisionsHttpClient implements VexDecisionsApi {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockVexDecisionsClient implements VexDecisionsApi {
|
||||
private static readonly BaseMs = Date.parse('2025-12-01T00:00:00Z');
|
||||
private timestampSeq = 0;
|
||||
|
||||
private readonly store: VexDecision[] = [
|
||||
{
|
||||
id: '2f76d3d4-1c4f-4c0f-8b4d-b4bdbb7e2b11',
|
||||
@@ -158,7 +161,7 @@ export class MockVexDecisionsClient implements VexDecisionsApi {
|
||||
];
|
||||
|
||||
listDecisions(options: VexDecisionQueryOptions = {}): Observable<VexDecisionsResponse> {
|
||||
const traceId = options.traceId ?? generateTraceId();
|
||||
const traceId = options.traceId ?? 'mock-trace-vex-list';
|
||||
let items = [...this.store];
|
||||
|
||||
if (options.vulnerabilityId) {
|
||||
@@ -184,7 +187,7 @@ export class MockVexDecisionsClient implements VexDecisionsApi {
|
||||
}
|
||||
|
||||
createDecision(request: VexDecisionCreateRequest, options: VexDecisionQueryOptions = {}): Observable<VexDecision> {
|
||||
const createdAt = new Date().toISOString();
|
||||
const createdAt = this.nextTimestampIso();
|
||||
const decision: VexDecision = {
|
||||
id: this.allocateId(),
|
||||
vulnerabilityId: request.vulnerabilityId,
|
||||
@@ -211,7 +214,7 @@ export class MockVexDecisionsClient implements VexDecisionsApi {
|
||||
const updated: VexDecision = {
|
||||
...existing,
|
||||
...request,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAt: this.nextTimestampIso(),
|
||||
};
|
||||
|
||||
const idx = this.store.findIndex((d) => d.id === decisionId);
|
||||
@@ -223,5 +226,10 @@ export class MockVexDecisionsClient implements VexDecisionsApi {
|
||||
const seq = this.store.length + 1;
|
||||
return `00000000-0000-0000-0000-${seq.toString().padStart(12, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
private nextTimestampIso(): string {
|
||||
const next = new Date(MockVexDecisionsClient.BaseMs + (this.timestampSeq + 1) * 60000).toISOString();
|
||||
this.timestampSeq += 1;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,8 +294,12 @@ function withReachability(vuln: Vulnerability): Vulnerability {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MockVulnerabilityApiService implements VulnerabilityApi {
|
||||
private mockExports = new Map<string, VulnExportResponse>();
|
||||
|
||||
private mockExports = new Map<string, VulnExportResponse>();
|
||||
private exportSeq = 0;
|
||||
|
||||
private static readonly FixedNowIso = '2025-12-01T00:00:00Z';
|
||||
private static readonly FixedNowMs = Date.parse(MockVulnerabilityApiService.FixedNowIso);
|
||||
|
||||
listVulnerabilities(options?: VulnerabilitiesQueryOptions): Observable<VulnerabilitiesResponse> {
|
||||
let items = [...MOCK_VULNERABILITIES];
|
||||
|
||||
@@ -329,14 +333,14 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
|
||||
const offset = options?.offset ?? 0;
|
||||
const limit = options?.limit ?? 50;
|
||||
items = items.slice(offset, offset + limit);
|
||||
|
||||
const traceId = options?.traceId ?? `mock-trace-${Date.now()}`;
|
||||
|
||||
|
||||
const traceId = options?.traceId ?? 'mock-trace-vuln-list';
|
||||
|
||||
return of({
|
||||
items: options?.includeReachability ? items.map(withReachability) : items,
|
||||
total,
|
||||
hasMore: offset + items.length < total,
|
||||
etag: `"vuln-list-${Date.now()}"`,
|
||||
etag: '"vuln-list-v1"',
|
||||
traceId,
|
||||
}).pipe(delay(200));
|
||||
}
|
||||
@@ -353,9 +357,9 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
|
||||
}).pipe(delay(100));
|
||||
}
|
||||
|
||||
getStats(_options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats> {
|
||||
const vulns = MOCK_VULNERABILITIES;
|
||||
const stats: VulnerabilityStats = {
|
||||
getStats(_options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnerabilityStats> {
|
||||
const vulns = MOCK_VULNERABILITIES;
|
||||
const stats: VulnerabilityStats = {
|
||||
total: vulns.length,
|
||||
bySeverity: {
|
||||
critical: vulns.filter((v) => v.severity === 'critical').length,
|
||||
@@ -371,51 +375,51 @@ export class MockVulnerabilityApiService implements VulnerabilityApi {
|
||||
in_progress: vulns.filter((v) => v.status === 'in_progress').length,
|
||||
excepted: vulns.filter((v) => v.status === 'excepted').length,
|
||||
},
|
||||
withExceptions: vulns.filter((v) => v.hasException).length,
|
||||
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
|
||||
computedAt: new Date().toISOString(),
|
||||
traceId: `mock-stats-${Date.now()}`,
|
||||
};
|
||||
return of(stats).pipe(delay(150));
|
||||
}
|
||||
|
||||
submitWorkflowAction(request: VulnWorkflowRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnWorkflowResponse> {
|
||||
const traceId = options?.traceId ?? `mock-trace-${Date.now()}`;
|
||||
const correlationId = `mock-corr-${Date.now()}`;
|
||||
|
||||
return of({
|
||||
status: 'accepted' as const,
|
||||
ledgerEventId: `ledg-mock-${Date.now()}`,
|
||||
etag: `"workflow-${request.findingId}-${Date.now()}"`,
|
||||
traceId,
|
||||
correlationId,
|
||||
}).pipe(delay(300));
|
||||
}
|
||||
|
||||
requestExport(request: VulnExportRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse> {
|
||||
const exportId = `export-mock-${Date.now()}`;
|
||||
const traceId = options?.traceId ?? `mock-trace-${Date.now()}`;
|
||||
|
||||
const exportResponse: VulnExportResponse = {
|
||||
exportId,
|
||||
status: 'completed',
|
||||
downloadUrl: `https://mock.stellaops.local/exports/${exportId}.${request.format}`,
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
recordCount: MOCK_VULNERABILITIES.length,
|
||||
fileSize: 1024 * (request.includeComponents ? 50 : 20),
|
||||
traceId,
|
||||
};
|
||||
withExceptions: vulns.filter((v) => v.hasException).length,
|
||||
criticalOpen: vulns.filter((v) => v.severity === 'critical' && v.status === 'open').length,
|
||||
computedAt: MockVulnerabilityApiService.FixedNowIso,
|
||||
traceId: 'mock-trace-vuln-stats',
|
||||
};
|
||||
return of(stats).pipe(delay(150));
|
||||
}
|
||||
|
||||
submitWorkflowAction(request: VulnWorkflowRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnWorkflowResponse> {
|
||||
const traceId = options?.traceId ?? 'mock-trace-vuln-workflow';
|
||||
const correlationId = 'mock-corr-vuln-workflow';
|
||||
|
||||
return of({
|
||||
status: 'accepted' as const,
|
||||
ledgerEventId: 'ledg-mock-0001',
|
||||
etag: `"workflow-${request.findingId}-v1"`,
|
||||
traceId,
|
||||
correlationId,
|
||||
}).pipe(delay(300));
|
||||
}
|
||||
|
||||
requestExport(request: VulnExportRequest, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse> {
|
||||
const exportId = `export-mock-${(++this.exportSeq).toString().padStart(4, '0')}`;
|
||||
const traceId = options?.traceId ?? 'mock-trace-vuln-export';
|
||||
|
||||
const exportResponse: VulnExportResponse = {
|
||||
exportId,
|
||||
status: 'completed',
|
||||
downloadUrl: `https://mock.stellaops.local/exports/${exportId}.${request.format}`,
|
||||
expiresAt: new Date(MockVulnerabilityApiService.FixedNowMs + 3600000).toISOString(),
|
||||
recordCount: MOCK_VULNERABILITIES.length,
|
||||
fileSize: 1024 * (request.includeComponents ? 50 : 20),
|
||||
traceId,
|
||||
};
|
||||
|
||||
this.mockExports.set(exportId, exportResponse);
|
||||
return of(exportResponse).pipe(delay(500));
|
||||
}
|
||||
|
||||
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse> {
|
||||
const traceId = options?.traceId ?? `mock-trace-${Date.now()}`;
|
||||
const existing = this.mockExports.get(exportId);
|
||||
|
||||
if (existing) {
|
||||
return of(existing).pipe(delay(100));
|
||||
getExportStatus(exportId: string, options?: Pick<VulnerabilitiesQueryOptions, 'tenantId' | 'projectId' | 'traceId'>): Observable<VulnExportResponse> {
|
||||
const traceId = options?.traceId ?? 'mock-trace-vuln-export-status';
|
||||
const existing = this.mockExports.get(exportId);
|
||||
|
||||
if (existing) {
|
||||
return of(existing).pipe(delay(100));
|
||||
}
|
||||
|
||||
return of({
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<div class="modal">
|
||||
<div class="modal__backdrop" (click)="close()"></div>
|
||||
<div class="modal__container" role="dialog" aria-modal="true" aria-label="VEX decision">
|
||||
<div
|
||||
class="modal__container"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="vex-dialog-title"
|
||||
aria-describedby="vex-dialog-description"
|
||||
tabindex="-1"
|
||||
(keydown)="onDialogKeydown($event)"
|
||||
>
|
||||
<header class="modal__header">
|
||||
<div>
|
||||
<h2>VEX decision</h2>
|
||||
<p class="modal__subtitle">
|
||||
<h2 id="vex-dialog-title">VEX decision</h2>
|
||||
<p id="vex-dialog-description" class="modal__subtitle">
|
||||
Subject: <code>{{ subject().name }}</code>
|
||||
@if (isBulk()) {
|
||||
· applies to {{ vulnerabilityIds().length }} findings
|
||||
@@ -13,7 +21,16 @@
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="modal__close" (click)="close()" [disabled]="loading()">Close</button>
|
||||
<button
|
||||
#closeButton
|
||||
type="button"
|
||||
class="modal__close"
|
||||
aria-label="Close VEX decision dialog"
|
||||
(click)="close()"
|
||||
[disabled]="loading()"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="modal__body">
|
||||
@@ -22,8 +39,8 @@
|
||||
}
|
||||
|
||||
<section class="section">
|
||||
<h3>Status</h3>
|
||||
<div class="radio-grid">
|
||||
<h3 id="vex-status-heading">Status</h3>
|
||||
<div class="radio-grid" role="radiogroup" aria-labelledby="vex-status-heading">
|
||||
@for (opt of statusOptions; track opt.value) {
|
||||
<label class="radio">
|
||||
<input type="radio" name="status" [checked]="status() === opt.value" (change)="status.set(opt.value)" />
|
||||
@@ -110,7 +127,14 @@
|
||||
<span class="field__label">URL</span>
|
||||
<input class="field__control" placeholder="https://..." [value]="evidenceUrl()" (input)="evidenceUrl.set($any($event.target).value)" />
|
||||
</label>
|
||||
<button type="button" class="btn btn--secondary" (click)="addEvidence()">Add</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
(click)="addEvidence()"
|
||||
[disabled]="!evidenceUrl().trim()"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
@@ -134,7 +158,14 @@
|
||||
<li>
|
||||
<code>{{ ref.type }}</code>
|
||||
<a [href]="ref.url" target="_blank" rel="noopener noreferrer">{{ ref.title || ref.url }}</a>
|
||||
<button type="button" class="link" (click)="removeEvidence(ref)">Remove</button>
|
||||
<button
|
||||
type="button"
|
||||
class="link"
|
||||
[attr.aria-label]="'Remove evidence ' + (ref.title || ref.url)"
|
||||
(click)="removeEvidence(ref)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -144,11 +175,17 @@
|
||||
<section class="section">
|
||||
<h3>Review</h3>
|
||||
<p class="hint">Will generate signed attestation on save.</p>
|
||||
<button type="button" class="btn btn--secondary" (click)="viewRawJson.set(!viewRawJson())">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--secondary"
|
||||
(click)="viewRawJson.set(!viewRawJson())"
|
||||
[attr.aria-controls]="'vex-raw-json'"
|
||||
[attr.aria-expanded]="viewRawJson()"
|
||||
>
|
||||
{{ viewRawJson() ? 'Hide raw JSON' : 'View raw JSON' }}
|
||||
</button>
|
||||
@if (viewRawJson()) {
|
||||
<pre class="json-preview">{{ requestPreview() | json }}</pre>
|
||||
<pre id="vex-raw-json" class="json-preview">{{ requestPreview() | json }}</pre>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CommonModule, DOCUMENT } from '@angular/common';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
OnDestroy,
|
||||
output,
|
||||
signal,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { catchError, finalize, map } from 'rxjs/operators';
|
||||
@@ -70,6 +74,12 @@ function fromLocalDateTimeValue(value: string): string | undefined {
|
||||
})
|
||||
export class VexDecisionModalComponent {
|
||||
private readonly api = inject<VexDecisionsApi>(VEX_DECISIONS_API);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
|
||||
@ViewChild('closeButton', { static: true })
|
||||
private readonly closeButton?: ElementRef<HTMLButtonElement>;
|
||||
|
||||
private previouslyFocused: HTMLElement | null = null;
|
||||
|
||||
readonly subject = input.required<VexSubjectRef>();
|
||||
readonly vulnerabilityIds = input.required<readonly string[]>();
|
||||
@@ -162,6 +172,34 @@ export class VexDecisionModalComponent {
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.previouslyFocused = this.document.activeElement instanceof HTMLElement ? this.document.activeElement : null;
|
||||
queueMicrotask(() => this.closeButton?.nativeElement.focus());
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
try {
|
||||
this.previouslyFocused?.focus();
|
||||
} catch {
|
||||
// best-effort restore focus only
|
||||
}
|
||||
}
|
||||
|
||||
onDialogKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Tab') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.trapFocus(event);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (!this.loading()) this.closed.emit();
|
||||
}
|
||||
@@ -245,6 +283,33 @@ export class VexDecisionModalComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private trapFocus(event: KeyboardEvent): void {
|
||||
const root = this.closeButton?.nativeElement.closest<HTMLElement>('.modal__container');
|
||||
if (!root) return;
|
||||
|
||||
const focusable = Array.from(
|
||||
root.querySelectorAll<HTMLElement>(
|
||||
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex=\"-1\"])'
|
||||
)
|
||||
).filter((el) => el.offsetParent !== null);
|
||||
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = this.document.activeElement instanceof HTMLElement ? this.document.activeElement : null;
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (active === first || (active && !root.contains(active))) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private splitCsv(text: string): string[] {
|
||||
return text
|
||||
.split(',')
|
||||
|
||||
@@ -87,4 +87,29 @@ test.describe('a11y-smoke', () => {
|
||||
description: `${violations.length} violations (/graph)`,
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
await page.getByRole('button', { name: 'VEX' }).first().click();
|
||||
await expect(page.getByRole('dialog', { name: 'VEX decision' })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const results = await new AxeBuilder({ page })
|
||||
.withTags(['wcag2a', 'wcag2aa'])
|
||||
.include('.modal__container')
|
||||
.analyze();
|
||||
|
||||
const violations = [...results.violations].sort((a, b) => a.id.localeCompare(b.id));
|
||||
await writeReport('a11y-triage_vex_modal.json', { url: page.url(), violations });
|
||||
|
||||
if (shouldFail) {
|
||||
expect(violations).toEqual([]);
|
||||
}
|
||||
|
||||
testInfo.annotations.push({
|
||||
type: 'a11y',
|
||||
description: `${violations.length} violations (/triage VEX modal)`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user