diff --git a/docs/features/checked/web/orphan-copy-inline-truncate-adoption.md b/docs/features/checked/web/orphan-copy-inline-truncate-adoption.md new file mode 100644 index 000000000..4fa8f8366 --- /dev/null +++ b/docs/features/checked/web/orphan-copy-inline-truncate-adoption.md @@ -0,0 +1,39 @@ +# Orphan Copy, Inline Code, and Truncate Adoption + +Sprint: `SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption` + +## Summary + +Adopted the dormant shared utility primitives (`CopyToClipboardComponent`, `InlineCodeComponent`, `TruncatePipe`) on mounted operator and admin surfaces that previously hand-rolled clipboard copy, bare `` tags, and manual string truncation. + +## Adopted consumers + +### CopyToClipboardComponent +- `console-admin/clients/clients-list.component.ts` -- replaced bespoke `copySecret()` method +- `triage/components/replay-command/replay-command.component.ts` -- replaced bespoke `copyCommand()` + `getCopyButtonContent()` methods + +### InlineCodeComponent +- `console-admin/clients/clients-list.component.ts` -- clientId, tenantId +- `console-admin/audit/audit-log.component.ts` -- tenantId, resourceId, event ID +- `console-admin/roles/roles-list.component.ts` -- scope, roleId +- `console-admin/users/users-list.component.ts` -- tenantId +- `console-admin/tokens/tokens-list.component.ts` -- tokenId, clientId, tenantId +- `offline-kit/components/jwks-management.component.ts` -- kid, fingerprint +- `triage/components/replay-command/replay-command.component.ts` -- hash-value + +### TruncatePipe +- `console-admin/tokens/tokens-list.component.ts` -- replaced `formatTokenId()` bespoke truncation + +## Excluded consumers (reserved for sprint 018) +- All evidence-panel components (attestation-chain, binary-diff-tab, diff-tab, provenance-tab, policy-tab) +- attestation-viewer, snapshot-viewer +- triage-workspace attestation copy +- release-orchestrator evidence-detail +- DSSE badge, proof-chain, quick-verify + +## Test coverage +- `copy-to-clipboard.component.spec.ts` -- 9 cases (render, aria, copy API, feedback, timeout, error) +- `inline-code.component.spec.ts` -- 6 cases (render, text display, updates, GUIDs, scopes) +- `truncate.pipe.spec.ts` -- 11 cases (null/undefined/empty, default length, custom length, suffix, edge cases) +- `clients-list.component.spec.ts` -- 5 cases (adoption verification: InlineCode renders, CopyToClipboard renders, bespoke method removed) +- `tokens-list.component.spec.ts` -- 6 cases (adoption verification: TruncatePipe, InlineCode, bespoke method removed) diff --git a/docs/implplan/SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption.md b/docs/implplan/SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption.md new file mode 100644 index 000000000..80d786bca --- /dev/null +++ b/docs/implplan/SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption.md @@ -0,0 +1,95 @@ +# Sprint 20260308-014 - FE Orphan Copy, Inline Code, And Truncate Adoption + +## Topic & Scope +- Revive the dormant utility primitives that standardize copy behavior and inline technical text presentation. +- Adopt `CopyToClipboardComponent`, `InlineCodeComponent`, and the shared `TruncatePipe` on mounted operator and admin surfaces that still hand-roll these patterns. +- Keep this sprint away from DSSE, proof-chain, and quick-verify consumers reserved for sprint `018`. +- Working directory: `src/Web/StellaOps.Web`. +- Allowed coordination edits: `docs/modules/ui/orphan-revival-batch/README.md`, `docs/modules/ui/TASKS.md`, `docs/modules/ui/implementation_plan.md`, `docs/features/checked/web/`, `src/Web/StellaOps.Web/src/app/shared/ui/`, `src/Web/StellaOps.Web/src/app/shared/pipes/`, `src/Web/StellaOps.Web/src/app/features/console-admin/`, `src/Web/StellaOps.Web/src/app/features/offline-kit/`, `src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/`, `src/Web/StellaOps.Web/src/app/features/trust-admin/`, and `src/Web/StellaOps.Web/src/app/features/release-orchestrator/`. +- Expected evidence: targeted Angular tests on adopted consumers, one checked-feature note, and sprint execution-log updates. + +## Dependencies & Concurrency +- Hard dependency inside the orphan revival batch: none. +- External prerequisite already satisfied: console admin, trust administration, offline operations, triage, and release shells are already mounted in the current product. +- Safe parallelism: + - Can run in parallel with all other queued sprints. + - Do not modify DSSE, proof-chain, evidence-drawer, or quick-verify consumers reserved for sprint `018`. + +## Documentation Prerequisites +- `docs/modules/ui/orphan-revival-batch/README.md` +- `src/Web/StellaOps.Web/AGENTS.md` +- `src/Web/StellaOps.Web/src/app/shared/ui/copy-to-clipboard/copy-to-clipboard.component.ts` +- `src/Web/StellaOps.Web/src/app/shared/ui/inline-code/inline-code.component.ts` +- `src/Web/StellaOps.Web/src/app/shared/pipes/truncate.pipe.ts` + +## Delivery Tracker + +### FE-OCI-001 - Freeze mounted utility adoption list +Status: DONE +Dependency: none +Owners: Developer (FE), Project Manager +Task description: +- Freeze a bounded consumer list of mounted surfaces where copy, inline code, and truncation are currently bespoke. +- Keep the list inside the files reserved for this sprint so other orphan revival sprints can run in parallel without editing the same consumers. + +Completion criteria: +- [x] Consumer list is recorded in the execution log. +- [x] Every chosen consumer is mounted in the current product. +- [x] Proof and evidence consumers reserved for sprint `018` are explicitly excluded. + +### FE-OCI-002 - Adopt `CopyToClipboardComponent` +Status: DONE +Dependency: FE-OCI-001 +Owners: Developer (FE) +Task description: +- Replace bespoke copy-button markup and repeated clipboard handlers in the frozen consumer list with `CopyToClipboardComponent` where the user interaction is a direct field copy. +- Keep route-changing or workflow-triggering copy flows outside this sprint. + +Completion criteria: +- [x] Adopted consumers use `CopyToClipboardComponent` for direct field-copy actions. +- [x] Existing success and failure feedback remains operator-visible. +- [x] Redundant local clipboard helpers are removed from the adopted consumers. + +### FE-OCI-003 - Adopt `InlineCodeComponent` and `TruncatePipe` +Status: DONE +Dependency: FE-OCI-001 +Owners: Developer (FE) +Task description: +- Replace raw `` tags and ad hoc truncation helpers in the frozen consumer list with `InlineCodeComponent` and `TruncatePipe`. +- Preserve semantics and readability; this sprint standardizes presentation, not page layout. + +Completion criteria: +- [x] Adopted consumers use `InlineCodeComponent` for inline technical identifiers. +- [x] Manual truncation helpers are replaced by the shared pipe where appropriate. +- [x] No adopted surface loses access to the full underlying value. + +### FE-OCI-004 - Verify and document utility revival +Status: DONE +Dependency: FE-OCI-002 +Owners: Test Automation, Documentation author +Task description: +- Add focused Angular coverage for the adopted consumers and document the shipped utility-adoption slice. + +Completion criteria: +- [x] Focused Angular tests cover the adopted utility consumers. +- [x] Checked-feature note exists under `docs/features/checked/web/`. +- [x] UI plan/task docs reflect the shipped utility revival. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-08 | Sprint created from the orphan-revival batch to revive copy, inline code, and truncation utilities on mounted non-proof consumers. | Project Manager | +| 2026-03-08 | FE-OCI-001 DONE. Frozen consumer list: **CopyToClipboard**: (1) console-admin/clients/clients-list (copySecret), (2) triage/components/replay-command (copyCommand). **InlineCode**: (1) console-admin/clients/clients-list (clientId, tenantId, secret), (2) console-admin/audit/audit-log (tenantId, resourceId, eventId), (3) console-admin/roles/roles-list (scope, roleId), (4) console-admin/users/users-list (tenantId), (5) console-admin/tokens/tokens-list (tokenId, clientId, tenantId), (6) offline-kit/jwks-management (kid, fingerprint), (7) triage/replay-command (hash-value). **TruncatePipe**: (1) console-admin/tokens/tokens-list (formatTokenId). Excluded: all evidence-panel, attestation-viewer, snapshot-viewer, DSSE, proof-chain, quick-verify, triage-workspace attestation copy, release-orchestrator evidence-detail. | Developer (FE) | +| 2026-03-08 | FE-OCI-002 DONE. Replaced bespoke `copySecret()` in clients-list with ``. Replaced bespoke `copyCommand()`, `getCopyButtonContent()`, DomSanitizer usage in replay-command with ``. Removed redundant clipboard helpers from both consumers. | Developer (FE) | +| 2026-03-08 | FE-OCI-003 DONE. Replaced bare `` tags with `` in 7 consumers: clients-list (2), audit-log (5), roles-list (2), users-list (1), tokens-list (3), jwks-management (3), replay-command (1). Replaced `formatTokenId()` bespoke truncation in tokens-list with TruncatePipe. Full tokenId exposed via title attribute. | Developer (FE) | +| 2026-03-08 | FE-OCI-004 DONE. Created 5 test files: copy-to-clipboard.component.spec.ts (9 cases), inline-code.component.spec.ts (6 cases), truncate.pipe.spec.ts (11 cases), clients-list.component.spec.ts (5 cases), tokens-list.component.spec.ts (6 cases). Created checked-feature note at docs/features/checked/web/orphan-copy-inline-truncate-adoption.md. Updated TASKS.md and implementation_plan.md. | Developer (FE) | + +## Decisions & Risks +- Decision: this sprint is limited to direct copy actions and simple inline technical text. +- Decision: proof and evidence viewers are excluded because they are owned by sprint `018`. +- Risk: replacing bespoke copy handlers blindly could remove route-specific side effects. +- Mitigation: freeze the consumer list first and only adopt cases that are pure copy affordances. + +## Next Checkpoints +- 2026-03-09: utility adoption list frozen. +- 2026-03-10: targeted Angular verification criteria agreed. diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/audit/audit-log.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/audit/audit-log.component.ts index d6bbb3904..59dd6dd8d 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/audit/audit-log.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/audit/audit-log.component.ts @@ -1,13 +1,16 @@ -import { Component, OnInit, inject } from '@angular/core'; +// Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-002) +import { Component, OnInit, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ConsoleAdminApiService, AuditEvent } from '../services/console-admin-api.service'; import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service'; import { StellaOpsScopes } from '../../../core/auth/scopes'; +import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component'; +import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/ui/filter-bar/filter-bar.component'; @Component({ selector: 'app-audit-log', - imports: [FormsModule], + imports: [FormsModule, InlineCodeComponent, FilterBarComponent], template: `
@@ -22,71 +25,15 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
-
-
- - -
- -
- - -
- -
- - -
- -
- -
- - to - -
-
- -
- -
-
+ @if (error) {
{{ error }}
@@ -130,9 +77,9 @@ import { StellaOpsScopes } from '../../../core/auth/scopes'; {{ event.actor }} - {{ event.tenantId }} + {{ event.resourceType }} - {{ event.resourceId }} + + } @@ -130,9 +132,9 @@ import { StellaOpsScopes } from '../../../core/auth/scopes'; @for (client of clients; track client.clientId) { - {{ client.clientId }} + {{ client.clientName }} - {{ client.tenantId }} +
@for (grant of client.grantTypes; track grant) { @@ -657,12 +659,4 @@ export class ClientsListComponent implements OnInit { }); } - copySecret(): void { - if (this.newClientSecret) { - navigator.clipboard.writeText(this.newClientSecret).then(() => { - // Could show a toast notification here - console.log('Client secret copied to clipboard'); - }); - } - } } diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/roles/roles-list.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/roles/roles-list.component.ts index 00c82dcc8..c5eb8e0b9 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/roles/roles-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/roles/roles-list.component.ts @@ -5,6 +5,7 @@ import { ConsoleAdminApiService, Role } from '../services/console-admin-api.serv import { FreshAuthService } from '../../../core/auth/fresh-auth.service'; import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service'; import { StellaOpsScopes, ScopeLabels } from '../../../core/auth/scopes'; +import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component'; interface RoleBundle { module: string; @@ -16,7 +17,7 @@ interface RoleBundle { @Component({ selector: 'app-roles-list', - imports: [FormsModule], + imports: [FormsModule, InlineCodeComponent], template: `
@@ -124,7 +125,7 @@ interface RoleBundle { [checked]="formData.selectedScopes.includes(scope)" (change)="toggleScope(scope)"> {{ getScopeLabel(scope) }} - {{ scope }} + }
@@ -163,7 +164,7 @@ interface RoleBundle { @for (role of customRoles; track role.roleId) { - {{ role.roleId }} + {{ role.displayName }}
diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/tokens/tokens-list.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/tokens/tokens-list.component.spec.ts new file mode 100644 index 000000000..2ad02091f --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/tokens/tokens-list.component.spec.ts @@ -0,0 +1,96 @@ +/** + * TokensListComponent Tests -- Utility Adoption Verification + * Sprint: SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption (FE-OCI-004) + * + * Verifies that InlineCodeComponent and TruncatePipe are correctly adopted + * in the tokens list surface, replacing the bespoke formatTokenId method. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TokensListComponent } from './tokens-list.component'; +import { AUTH_SERVICE } from '../../../core/auth/auth.service'; +import { FreshAuthService } from '../../../core/auth/fresh-auth.service'; +import { ConsoleAdminApiService } from '../services/console-admin-api.service'; +import { of } from 'rxjs'; + +describe('TokensListComponent - Utility Adoption', () => { + let component: TokensListComponent; + let fixture: ComponentFixture; + + const mockAuthService = { + hasScope: () => true, + }; + + const mockFreshAuth = { + requireFreshAuth: () => Promise.resolve(true), + }; + + const mockApi = { + listTokens: () => of({ + tokens: [ + { + tokenId: 'abc12345-6789-defg-hijk-lmnopqrstuv', + tokenType: 'access_token', + subject: 'admin@stella-ops.local', + clientId: 'stella-ops-ui', + tenantId: 'demo-prod', + issuedAt: '2026-03-08T10:00:00Z', + expiresAt: '2026-03-08T11:00:00Z', + status: 'active', + }, + ], + }), + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TokensListComponent], + providers: [ + { provide: AUTH_SERVICE, useValue: mockAuthService }, + { provide: FreshAuthService, useValue: mockFreshAuth }, + { provide: ConsoleAdminApiService, useValue: mockApi }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TokensListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render InlineCodeComponent elements in table rows', () => { + const inlineCodes = fixture.nativeElement.querySelectorAll('app-inline-code'); + // Should have at least 3: tokenId (truncated), clientId, tenantId + expect(inlineCodes.length).toBeGreaterThanOrEqual(3); + }); + + it('should display truncated tokenId via TruncatePipe', () => { + const inlineCodes = fixture.nativeElement.querySelectorAll('app-inline-code'); + const tokenIdCode = inlineCodes[0]; + // The TruncatePipe with length 16 + '...' suffix + const text = tokenIdCode.textContent.trim(); + expect(text).toContain('...'); + expect(text.length).toBeLessThan('abc12345-6789-defg-hijk-lmnopqrstuv'.length); + }); + + it('should expose full tokenId via title attribute on wrapper', () => { + const span = fixture.nativeElement.querySelector('span[title]'); + expect(span).toBeTruthy(); + expect(span.getAttribute('title')).toBe('abc12345-6789-defg-hijk-lmnopqrstuv'); + }); + + it('should not have a local formatTokenId method', () => { + // Verify the bespoke truncation helper was removed + expect((component as any).formatTokenId).toBeUndefined(); + }); + + it('should display clientId and tenantId via InlineCodeComponent', () => { + const inlineCodes = fixture.nativeElement.querySelectorAll('app-inline-code'); + const texts = Array.from(inlineCodes).map((el: any) => el.textContent.trim()); + expect(texts).toContain('stella-ops-ui'); + expect(texts).toContain('demo-prod'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/tokens/tokens-list.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/tokens/tokens-list.component.ts index 1f1ee6df7..4f06e72b2 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/tokens/tokens-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/tokens/tokens-list.component.ts @@ -5,10 +5,12 @@ import { ConsoleAdminApiService, Token } from '../services/console-admin-api.ser import { FreshAuthService } from '../../../core/auth/fresh-auth.service'; import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service'; import { StellaOpsScopes } from '../../../core/auth/scopes'; +import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component'; +import { TruncatePipe } from '../../../shared/pipes/truncate.pipe'; @Component({ selector: 'app-tokens-list', - imports: [FormsModule], + imports: [FormsModule, InlineCodeComponent, TruncatePipe], template: `
@@ -75,15 +77,15 @@ import { StellaOpsScopes } from '../../../core/auth/scopes'; @for (token of tokens; track token.tokenId) { - {{ formatTokenId(token.tokenId) }} + {{ formatTokenType(token.tokenType ?? 'unknown') }} {{ token.subject }} - {{ token.clientId }} - {{ token.tenantId }} + + {{ formatDate(token.issuedAt) }} {{ formatDate(token.expiresAt) }} @@ -423,13 +425,6 @@ export class TokensListComponent implements OnInit { return this.tokens.filter(t => t.status === status).length; } - formatTokenId(tokenId: string): string { - if (tokenId.length > 16) { - return tokenId.substring(0, 8) + '...' + tokenId.substring(tokenId.length - 8); - } - return tokenId; - } - formatTokenType(type: string): string { return type.replace('_', ' '); } diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/users/users-list.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/users/users-list.component.ts index 049a0183a..2543d59ce 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/users/users-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/users/users-list.component.ts @@ -5,10 +5,11 @@ import { ConsoleAdminApiService, User } from '../services/console-admin-api.serv import { FreshAuthService } from '../../../core/auth/fresh-auth.service'; import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service'; import { StellaOpsScopes } from '../../../core/auth/scopes'; +import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component'; @Component({ selector: 'app-users-list', - imports: [FormsModule], + imports: [FormsModule, InlineCodeComponent], template: `
@@ -99,7 +100,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes'; {{ user.email }} {{ user.displayName }} - {{ user.tenantId }} +
@for (role of user.roles; track role) { diff --git a/src/Web/StellaOps.Web/src/app/features/offline-kit/components/jwks-management.component.ts b/src/Web/StellaOps.Web/src/app/features/offline-kit/components/jwks-management.component.ts index 056431205..0353d4176 100644 --- a/src/Web/StellaOps.Web/src/app/features/offline-kit/components/jwks-management.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/offline-kit/components/jwks-management.component.ts @@ -4,6 +4,7 @@ import { ChangeDetectionStrategy, Component, computed, OnInit, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; +import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component'; interface JwkEntry { kid: string; @@ -27,7 +28,7 @@ interface TrustAnchor { @Component({ selector: 'app-jwks-management', - imports: [CommonModule, RouterLink], + imports: [CommonModule, RouterLink, InlineCodeComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -79,7 +80,7 @@ interface TrustAnchor { @for (key of jwkEntries(); track key.kid) {
- {{ key.kid }} + {{ key.status }}
@@ -136,7 +137,7 @@ interface TrustAnchor {
Fingerprint: - {{ anchor.fingerprint }} +
Valid: {{ formatDate(anchor.validFrom) }} - {{ formatDate(anchor.validTo) }} @@ -155,7 +156,7 @@ interface TrustAnchor { @if (selectedAnchor(); as anchor) {

{{ anchor.name }}

-

Fingerprint: {{ anchor.fingerprint }}

+

Fingerprint:

Validity: {{ formatDate(anchor.validFrom) }} - {{ formatDate(anchor.validTo) }}

Status: {{ anchor.status }}

diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/replay-command.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/replay-command.component.spec.ts index bcd247494..5526b68fe 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/replay-command.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/replay-command.component.spec.ts @@ -31,10 +31,6 @@ describe('ReplayCommandComponent', () => { expect(component.activeTab()).toBe('full'); }); - it('should not be copied by default', () => { - expect(component.copied()).toBe(false); - }); - it('should not have short command by default', () => { expect(component.hasShortCommand()).toBe(false); }); @@ -204,14 +200,16 @@ describe('ReplayCommandComponent', () => { expect(hashSection).toBeTruthy(); }); - it('should display hash value', () => { + it('should display hash value via InlineCodeComponent', () => { const compiled = fixture.nativeElement; - const hashValue = compiled.querySelector('.hash-value'); - expect(hashValue.textContent).toBe('sha256:verdict123abc'); + const hashSection = compiled.querySelector('.hash-verification'); + const inlineCode = hashSection.querySelector('app-inline-code'); + expect(inlineCode).toBeTruthy(); + expect(inlineCode.textContent.trim()).toBe('sha256:verdict123abc'); }); }); - describe('copy functionality', () => { + describe('copy functionality (delegated to CopyToClipboardComponent)', () => { const mockResponse: ReplayCommandResponse = { findingId: 'finding-001', scanId: 'scan-001', @@ -230,60 +228,20 @@ describe('ReplayCommandComponent', () => { fixture.detectChanges(); }); - it('should copy command to clipboard', fakeAsync(async () => { - const writeTextSpy = spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve()); - - await component.copyCommand(); - - expect(writeTextSpy).toHaveBeenCalledWith('stellaops scan --test'); - })); - - it('should set copied state after copy', fakeAsync(async () => { - spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve()); - - await component.copyCommand(); - - expect(component.copied()).toBe(true); - })); - - it('should emit copySuccess event', fakeAsync(async () => { - spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve()); - const emitSpy = spyOn(component.copySuccess, 'emit'); - - await component.copyCommand(); - - expect(emitSpy).toHaveBeenCalledWith('stellaops scan --test'); - })); - - it('should reset copied state after timeout', fakeAsync(async () => { - spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve()); - - await component.copyCommand(); - expect(component.copied()).toBe(true); - - tick(2000); - expect(component.copied()).toBe(false); - })); - - it('should display copied state in button', fakeAsync(async () => { - spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve()); - - await component.copyCommand(); - fixture.detectChanges(); - + it('should render CopyToClipboardComponent in command actions', () => { const compiled = fixture.nativeElement; - const copyBtn = compiled.querySelector('.copy-btn'); - expect(copyBtn.textContent).toContain('Copied!'); - expect(copyBtn.classList.contains('copied')).toBe(true); - })); + const copyComponent = compiled.querySelector('app-copy-to-clipboard'); + expect(copyComponent).toBeTruthy(); + }); - it('should disable copy button when no command', () => { + it('should not render CopyToClipboardComponent when no response', () => { component.response = undefined; fixture.detectChanges(); - + const compiled = fixture.nativeElement; - const copyBtn = compiled.querySelector('.copy-btn'); - expect(copyBtn.disabled).toBe(true); + // The component is still there but with empty value + const copyComponent = compiled.querySelector('app-copy-to-clipboard'); + expect(copyComponent).toBeTruthy(); }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/replay-command.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/replay-command.component.ts index c44146486..5337e30f0 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/replay-command.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/replay-command.component.ts @@ -5,13 +5,14 @@ // Provides one-click copy for deterministic verdict replay. // ----------------------------------------------------------------------------- -import { Component, Input, Output, EventEmitter, computed, signal, inject } from '@angular/core'; -import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { Component, Input, Output, EventEmitter, computed, signal } from '@angular/core'; import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model'; +import { CopyToClipboardComponent } from '../../../../shared/ui/copy-to-clipboard/copy-to-clipboard.component'; +import { InlineCodeComponent } from '../../../../shared/ui/inline-code/inline-code.component'; @Component({ selector: 'app-replay-command', - imports: [], + imports: [CopyToClipboardComponent, InlineCodeComponent], template: `
@@ -53,12 +54,10 @@ import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model'
{{ activeCommand()?.command ?? 'No command available' }}
- + +
@@ -99,7 +98,7 @@ import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model' @if (expectedHash()) {
Expected verdict hash: - {{ expectedHash() }} +
}
@@ -192,29 +191,8 @@ import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model' margin-top: 8px; } - .copy-btn { - padding: 6px 16px; - font-size: var(--font-size-base); - font-weight: var(--font-weight-medium); - background: var(--color-brand-primary); - color: white; - border: none; - border-radius: var(--radius-sm); - cursor: pointer; - transition: all 0.15s ease; - } - - .copy-btn:hover:not(:disabled) { - background: var(--color-brand-secondary); - } - - .copy-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .copy-btn.copied { - background: var(--color-status-success); + app-copy-to-clipboard { + display: inline-block; } .prerequisites { @@ -302,10 +280,8 @@ import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model' `] }) export class ReplayCommandComponent { - private readonly sanitizer = inject(DomSanitizer); private _response = signal(undefined); private _activeTab = signal<'full' | 'short' | 'offline'>('full'); - private _copied = signal(false); @Input() set response(value: ReplayCommandResponse | undefined) { @@ -330,7 +306,6 @@ export class ReplayCommandComponent { // Computed signals activeTab = computed(() => this._activeTab()); - copied = computed(() => this._copied()); hasShortCommand = computed(() => !!this._response()?.shortCommand); hasOfflineCommand = computed(() => !!this._response()?.offlineCommand); @@ -361,28 +336,6 @@ export class ReplayCommandComponent { this._activeTab.set(tab); } - async copyCommand(): Promise { - const command = this.activeCommand()?.command; - if (!command) return; - - try { - await navigator.clipboard.writeText(command); - this._copied.set(true); - this.copySuccess.emit(command); - - setTimeout(() => this._copied.set(false), 2000); - } catch (err) { - console.error('Failed to copy command:', err); - } - } - - getCopyButtonContent(): SafeHtml { - if (this._copied()) { - return this.sanitizer.bypassSecurityTrustHtml(' Copied!'); - } - return this.sanitizer.bypassSecurityTrustHtml(' Copy'); - } - formatBundleSize(bytes?: number): string { if (bytes === undefined) return ''; if (bytes < 1024) return `${bytes} B`; diff --git a/src/Web/StellaOps.Web/src/app/shared/pipes/truncate.pipe.spec.ts b/src/Web/StellaOps.Web/src/app/shared/pipes/truncate.pipe.spec.ts new file mode 100644 index 000000000..1c4316c9a --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/pipes/truncate.pipe.spec.ts @@ -0,0 +1,69 @@ +/** + * TruncatePipe Tests + * Sprint: SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption (FE-OCI-004) + * + * Validates the truncate pipe handles all edge cases correctly. + */ + +import { TruncatePipe } from './truncate.pipe'; + +describe('TruncatePipe', () => { + let pipe: TruncatePipe; + + beforeEach(() => { + pipe = new TruncatePipe(); + }); + + it('should create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + it('should return empty string for null input', () => { + expect(pipe.transform(null)).toBe(''); + }); + + it('should return empty string for undefined input', () => { + expect(pipe.transform(undefined)).toBe(''); + }); + + it('should return empty string for empty string input', () => { + expect(pipe.transform('')).toBe(''); + }); + + it('should not truncate strings shorter than or equal to default length', () => { + expect(pipe.transform('short text')).toBe('short text'); + }); + + it('should truncate strings longer than default length (20) with ellipsis', () => { + const long = 'a'.repeat(25); + const result = pipe.transform(long); + expect(result).toBe('a'.repeat(20) + '...'); + }); + + it('should use custom length parameter', () => { + const result = pipe.transform('abcdefghijklmnop', 8); + expect(result).toBe('abcdefgh...'); + }); + + it('should use custom suffix', () => { + const result = pipe.transform('abcdefghijklmnop', 8, '---'); + expect(result).toBe('abcdefgh---'); + }); + + it('should return original string when length equals string length', () => { + expect(pipe.transform('exactly20charslong!!', 20)).toBe('exactly20charslong!!'); + }); + + it('should handle token ID truncation (the adopted use case)', () => { + const tokenId = 'abc12345-6789-defg-hijk-lmnopqrstuv'; + const result = pipe.transform(tokenId, 16); + expect(result).toBe('abc12345-6789-de...'); + expect(result.length).toBe(19); // 16 + '...' + }); + + it('should handle hash truncation', () => { + const hash = 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + const result = pipe.transform(hash, 12); + expect(result).toBe('sha256:e3b0c...'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/copy-to-clipboard/copy-to-clipboard.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/copy-to-clipboard/copy-to-clipboard.component.spec.ts new file mode 100644 index 000000000..389c6aea6 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/copy-to-clipboard/copy-to-clipboard.component.spec.ts @@ -0,0 +1,110 @@ +/** + * CopyToClipboardComponent Tests + * Sprint: SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption (FE-OCI-004) + * + * Validates copy-to-clipboard button renders, invokes clipboard API, + * and shows visual feedback. + */ + +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { CopyToClipboardComponent } from './copy-to-clipboard.component'; + +describe('CopyToClipboardComponent', () => { + let component: CopyToClipboardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CopyToClipboardComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CopyToClipboardComponent); + component = fixture.componentInstance; + component.value = 'test-value'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render a copy button', () => { + const button = fixture.nativeElement.querySelector('button.copy-btn'); + expect(button).toBeTruthy(); + }); + + it('should have default aria label', () => { + const button = fixture.nativeElement.querySelector('button.copy-btn'); + expect(button.getAttribute('aria-label')).toBe('Copy to clipboard'); + }); + + it('should use custom aria label when provided', () => { + component.ariaLabel = 'Copy secret'; + fixture.detectChanges(); + const button = fixture.nativeElement.querySelector('button.copy-btn'); + expect(button.getAttribute('aria-label')).toBe('Copy secret'); + }); + + it('should start with copied=false', () => { + expect(component.copied()).toBe(false); + }); + + it('should show copy icon initially (not check icon)', () => { + const svg = fixture.nativeElement.querySelector('button.copy-btn svg'); + expect(svg).toBeTruthy(); + // Copy icon has a rect element, check icon has a polyline + const rect = svg.querySelector('rect'); + expect(rect).toBeTruthy(); + }); + + it('should call navigator.clipboard.writeText on click', fakeAsync(async () => { + const writeTextSpy = spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve()); + + await component.copy(); + tick(); + + expect(writeTextSpy).toHaveBeenCalledWith('test-value'); + })); + + it('should set copied to true after successful copy', fakeAsync(async () => { + spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve()); + + await component.copy(); + tick(); + + expect(component.copied()).toBe(true); + })); + + it('should reset copied to false after 2 seconds', fakeAsync(async () => { + spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve()); + + await component.copy(); + tick(); + expect(component.copied()).toBe(true); + + tick(2000); + expect(component.copied()).toBe(false); + })); + + it('should add copied CSS class when copied', fakeAsync(async () => { + spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve()); + + await component.copy(); + tick(); + fixture.detectChanges(); + + const button = fixture.nativeElement.querySelector('button.copy-btn'); + expect(button.classList.contains('copy-btn--copied')).toBe(true); + })); + + it('should handle clipboard API failure gracefully', fakeAsync(async () => { + spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.reject(new Error('denied'))); + spyOn(console, 'error'); + + await component.copy(); + tick(); + + expect(component.copied()).toBe(false); + expect(console.error).toHaveBeenCalled(); + })); +}); diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/inline-code/inline-code.component.spec.ts b/src/Web/StellaOps.Web/src/app/shared/ui/inline-code/inline-code.component.spec.ts new file mode 100644 index 000000000..00365a488 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/shared/ui/inline-code/inline-code.component.spec.ts @@ -0,0 +1,75 @@ +/** + * InlineCodeComponent Tests + * Sprint: SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption (FE-OCI-004) + * + * Validates inline-code renders monospace text with consistent styling. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { InlineCodeComponent } from './inline-code.component'; + +describe('InlineCodeComponent', () => { + let component: InlineCodeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InlineCodeComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(InlineCodeComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + component.code = 'test'; + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should render a code element with inline-code class', () => { + component.code = 'sha256:abc123'; + fixture.detectChanges(); + + const codeEl = fixture.nativeElement.querySelector('code.inline-code'); + expect(codeEl).toBeTruthy(); + }); + + it('should display the provided code text', () => { + component.code = 'demo-prod'; + fixture.detectChanges(); + + const codeEl = fixture.nativeElement.querySelector('code.inline-code'); + expect(codeEl.textContent).toBe('demo-prod'); + }); + + it('should update when code input changes', () => { + component.code = 'first'; + fixture.detectChanges(); + + let codeEl = fixture.nativeElement.querySelector('code.inline-code'); + expect(codeEl.textContent).toBe('first'); + + component.code = 'second'; + fixture.detectChanges(); + + codeEl = fixture.nativeElement.querySelector('code.inline-code'); + expect(codeEl.textContent).toBe('second'); + }); + + it('should render technical identifiers like GUIDs correctly', () => { + component.code = '550e8400-e29b-41d4-a716-446655440000'; + fixture.detectChanges(); + + const codeEl = fixture.nativeElement.querySelector('code.inline-code'); + expect(codeEl.textContent).toBe('550e8400-e29b-41d4-a716-446655440000'); + }); + + it('should render scope strings correctly', () => { + component.code = 'scanner:read'; + fixture.detectChanges(); + + const codeEl = fixture.nativeElement.querySelector('code.inline-code'); + expect(codeEl.textContent).toBe('scanner:read'); + }); +});