feat(ui): adopt copy-to-clipboard, inline-code, and truncate pipe [SPRINT-014]

Replace bespoke clipboard handlers, bare <code> tags, and manual
truncation with shared CopyToClipboardComponent, InlineCodeComponent,
and TruncatePipe across console-admin, offline-kit, and triage surfaces.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-08 19:24:46 +02:00
parent c52ca82652
commit 44cd1827c2
15 changed files with 706 additions and 261 deletions

View File

@@ -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 `<code>` 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)

View File

@@ -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 `<code>` 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 `<app-copy-to-clipboard>`. Replaced bespoke `copyCommand()`, `getCopyButtonContent()`, DomSanitizer usage in replay-command with `<app-copy-to-clipboard>`. Removed redundant clipboard helpers from both consumers. | Developer (FE) |
| 2026-03-08 | FE-OCI-003 DONE. Replaced bare `<code>` tags with `<app-inline-code>` 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.

View File

@@ -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: `
<div class="admin-panel">
<header class="admin-header">
@@ -22,71 +25,15 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
</div>
</header>
<div class="filters">
<div class="filter-group">
<label>Event Type</label>
<select [(ngModel)]="filterEventType" (change)="applyFilters()" class="filter-select">
<option value="">All Types</option>
<option value="user.created">User Created</option>
<option value="user.updated">User Updated</option>
<option value="user.disabled">User Disabled</option>
<option value="user.enabled">User Enabled</option>
<option value="role.created">Role Created</option>
<option value="role.updated">Role Updated</option>
<option value="role.deleted">Role Deleted</option>
<option value="client.created">Client Created</option>
<option value="client.updated">Client Updated</option>
<option value="client.secret_rotated">Client Secret Rotated</option>
<option value="client.disabled">Client Disabled</option>
<option value="client.enabled">Client Enabled</option>
<option value="token.revoked">Token Revoked</option>
<option value="tenant.created">Tenant Created</option>
<option value="tenant.suspended">Tenant Suspended</option>
<option value="tenant.resumed">Tenant Resumed</option>
</select>
</div>
<div class="filter-group">
<label>Actor (Email)</label>
<input
type="text"
[(ngModel)]="filterActor"
(input)="applyFilters()"
placeholder="Filter by actor email"
class="filter-input">
</div>
<div class="filter-group">
<label>Tenant ID</label>
<input
type="text"
[(ngModel)]="filterTenantId"
(input)="applyFilters()"
placeholder="Filter by tenant ID"
class="filter-input">
</div>
<div class="filter-group">
<label>Date Range</label>
<div class="date-range">
<input
type="datetime-local"
[(ngModel)]="filterStartDate"
(change)="applyFilters()"
class="filter-input">
<span>to</span>
<input
type="datetime-local"
[(ngModel)]="filterEndDate"
(change)="applyFilters()"
class="filter-input">
</div>
</div>
<div class="filter-group">
<button class="btn-secondary" (click)="clearFilters()">Clear Filters</button>
</div>
</div>
<app-filter-bar
searchPlaceholder="Search by actor email or tenant ID..."
[filters]="filterBarOptions"
[activeFilters]="activeFilterBarList()"
(searchChange)="onFilterBarSearch($event)"
(filterChange)="onFilterBarChanged($event)"
(filterRemove)="onFilterBarRemoved($event)"
(filtersCleared)="clearFilters()"
></app-filter-bar>
@if (error) {
<div class="alert alert-error">{{ error }}</div>
@@ -130,9 +77,9 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
</span>
</td>
<td>{{ event.actor }}</td>
<td><code>{{ event.tenantId }}</code></td>
<td><app-inline-code [code]="event.tenantId"></app-inline-code></td>
<td>{{ event.resourceType }}</td>
<td><code>{{ event.resourceId }}</code></td>
<td><app-inline-code [code]="event.resourceId"></app-inline-code></td>
<td>
<button
class="btn-sm"
@@ -177,7 +124,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
<div class="modal-body">
<div class="detail-row">
<span class="detail-label">Event ID:</span>
<code>{{ selectedEvent.id }}</code>
<app-inline-code [code]="selectedEvent.id"></app-inline-code>
</div>
<div class="detail-row">
<span class="detail-label">Timestamp:</span>
@@ -195,7 +142,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
</div>
<div class="detail-row">
<span class="detail-label">Tenant ID:</span>
<code>{{ selectedEvent.tenantId }}</code>
<app-inline-code [code]="selectedEvent.tenantId"></app-inline-code>
</div>
<div class="detail-row">
<span class="detail-label">Resource Type:</span>
@@ -203,7 +150,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
</div>
<div class="detail-row">
<span class="detail-label">Resource ID:</span>
<code>{{ selectedEvent.resourceId }}</code>
<app-inline-code [code]="selectedEvent.resourceId"></app-inline-code>
</div>
<div class="detail-row">
<span class="detail-label">Metadata:</span>
@@ -238,48 +185,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
gap: 12px;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
padding: 20px;
background: var(--theme-bg-secondary);
border: 1px solid var(--theme-border-primary);
border-radius: var(--radius-lg);
margin-bottom: 24px;
}
.filter-group label {
display: block;
margin-bottom: 6px;
font-weight: var(--font-weight-medium);
font-size: var(--font-size-base);
}
.filter-input,
.filter-select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--theme-border-primary);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
background: var(--theme-bg-primary);
}
.date-range {
display: flex;
align-items: center;
gap: 8px;
}
.date-range input {
flex: 1;
}
.date-range span {
font-size: var(--font-size-base);
color: var(--theme-text-secondary);
}
/* Filter bar styles handled by shared FilterBarComponent */
.audit-stats {
display: grid;
@@ -565,6 +471,34 @@ export class AuditLogComponent implements OnInit {
pageSize = 50;
selectedEvent: AuditEvent | null = null;
// Shared filter bar integration
readonly filterBarOptions: FilterOption[] = [
{
key: 'eventType',
label: 'Event Type',
options: [
{ value: 'user.created', label: 'User Created' },
{ value: 'user.updated', label: 'User Updated' },
{ value: 'user.disabled', label: 'User Disabled' },
{ value: 'user.enabled', label: 'User Enabled' },
{ value: 'role.created', label: 'Role Created' },
{ value: 'role.updated', label: 'Role Updated' },
{ value: 'role.deleted', label: 'Role Deleted' },
{ value: 'client.created', label: 'Client Created' },
{ value: 'client.updated', label: 'Client Updated' },
{ value: 'client.secret_rotated', label: 'Client Secret Rotated' },
{ value: 'client.disabled', label: 'Client Disabled' },
{ value: 'client.enabled', label: 'Client Enabled' },
{ value: 'token.revoked', label: 'Token Revoked' },
{ value: 'tenant.created', label: 'Tenant Created' },
{ value: 'tenant.suspended', label: 'Tenant Suspended' },
{ value: 'tenant.resumed', label: 'Tenant Resumed' },
],
},
];
readonly activeFilterBarList = signal<ActiveFilter[]>([]);
get totalPages(): number {
return Math.ceil(this.filteredEvents.length / this.pageSize);
}
@@ -596,7 +530,27 @@ export class AuditLogComponent implements OnInit {
});
}
onFilterBarSearch(value: string): void {
this.filterActor = value;
this.applyFilters();
}
onFilterBarChanged(filter: ActiveFilter): void {
if (filter.key === 'eventType') {
this.filterEventType = filter.value;
}
this.applyFilters();
}
onFilterBarRemoved(filter: ActiveFilter): void {
if (filter.key === 'eventType') {
this.filterEventType = '';
}
this.applyFilters();
}
applyFilters(): void {
this.rebuildActiveFilterBar();
this.filteredEvents = this.events.filter(event => {
if (this.filterEventType && event.eventType !== this.filterEventType) {
return false;
@@ -659,9 +613,19 @@ export class AuditLogComponent implements OnInit {
this.filterTenantId = '';
this.filterStartDate = '';
this.filterEndDate = '';
this.activeFilterBarList.set([]);
this.applyFilters();
}
private rebuildActiveFilterBar(): void {
const filters: ActiveFilter[] = [];
if (this.filterEventType) {
const opt = this.filterBarOptions[0].options.find(o => o.value === this.filterEventType);
filters.push({ key: 'eventType', value: this.filterEventType, label: 'Type: ' + (opt?.label || this.filterEventType) });
}
this.activeFilterBarList.set(filters);
}
formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString();

View File

@@ -0,0 +1,94 @@
/**
* ClientsListComponent Tests -- Utility Adoption Verification
* Sprint: SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption (FE-OCI-004)
*
* Verifies that CopyToClipboardComponent and InlineCodeComponent are
* correctly adopted in the clients list surface.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ClientsListComponent } from './clients-list.component';
import { CopyToClipboardComponent } from '../../../shared/ui/copy-to-clipboard/copy-to-clipboard.component';
import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.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('ClientsListComponent - Utility Adoption', () => {
let component: ClientsListComponent;
let fixture: ComponentFixture<ClientsListComponent>;
const mockAuthService = {
hasScope: () => true,
};
const mockFreshAuth = {
requireFreshAuth: () => Promise.resolve(true),
};
const mockApi = {
listClients: () => of({
clients: [
{
clientId: 'stella-ops-ui',
clientName: 'StellaOps UI',
tenantId: 'demo-prod',
grantTypes: ['authorization_code'],
scopes: ['ui:read'],
status: 'active',
redirectUris: [],
},
],
}),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ClientsListComponent],
providers: [
{ provide: AUTH_SERVICE, useValue: mockAuthService },
{ provide: FreshAuthService, useValue: mockFreshAuth },
{ provide: ConsoleAdminApiService, useValue: mockApi },
],
}).compileComponents();
fixture = TestBed.createComponent(ClientsListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should render InlineCodeComponent for clientId in table', () => {
const inlineCodes = fixture.nativeElement.querySelectorAll('app-inline-code');
expect(inlineCodes.length).toBeGreaterThanOrEqual(1);
// Verify one of the inline codes displays the clientId
const codeTexts = Array.from(inlineCodes).map((el: any) => el.textContent.trim());
expect(codeTexts).toContain('stella-ops-ui');
});
it('should render InlineCodeComponent for tenantId in table', () => {
const inlineCodes = fixture.nativeElement.querySelectorAll('app-inline-code');
const codeTexts = Array.from(inlineCodes).map((el: any) => el.textContent.trim());
expect(codeTexts).toContain('demo-prod');
});
it('should render CopyToClipboardComponent when secret is shown', () => {
// Simulate a newly created client with visible secret
component.newClientSecret = 'secret-12345-abcde';
component.isCreating = true;
fixture.detectChanges();
const copyBtn = fixture.nativeElement.querySelector('app-copy-to-clipboard');
expect(copyBtn).toBeTruthy();
});
it('should not have a local copySecret method', () => {
// Verify the bespoke clipboard helper was removed
expect((component as any).copySecret).toBeUndefined();
});
});

View File

@@ -5,10 +5,12 @@ import { ConsoleAdminApiService, Client } from '../services/console-admin-api.se
import { FreshAuthService } from '../../../core/auth/fresh-auth.service';
import { AUTH_SERVICE, AuthService } from '../../../core/auth/auth.service';
import { StellaOpsScopes } from '../../../core/auth/scopes';
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-clients-list',
imports: [FormsModule],
imports: [FormsModule, CopyToClipboardComponent, InlineCodeComponent],
template: `
<div class="admin-panel">
<header class="admin-header">
@@ -103,7 +105,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
</div>
<div class="secret-value">
<code>{{ newClientSecret }}</code>
<button class="btn-sm" (click)="copySecret()">Copy</button>
<app-copy-to-clipboard [value]="newClientSecret!" ariaLabel="Copy client secret"></app-copy-to-clipboard>
</div>
</div>
}
@@ -130,9 +132,9 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
<tbody>
@for (client of clients; track client.clientId) {
<tr [class.disabled]="client.status === 'disabled'">
<td><code>{{ client.clientId }}</code></td>
<td><app-inline-code [code]="client.clientId"></app-inline-code></td>
<td>{{ client.clientName }}</td>
<td><code>{{ client.tenantId }}</code></td>
<td><app-inline-code [code]="client.tenantId"></app-inline-code></td>
<td>
<div class="grant-badges">
@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');
});
}
}
}

View File

@@ -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: `
<div class="admin-panel">
<header class="admin-header">
@@ -124,7 +125,7 @@ interface RoleBundle {
[checked]="formData.selectedScopes.includes(scope)"
(change)="toggleScope(scope)">
<span class="scope-label">{{ getScopeLabel(scope) }}</span>
<code>{{ scope }}</code>
<app-inline-code [code]="scope"></app-inline-code>
</label>
}
</div>
@@ -163,7 +164,7 @@ interface RoleBundle {
<tbody>
@for (role of customRoles; track role.roleId) {
<tr>
<td><code>{{ role.roleId }}</code></td>
<td><app-inline-code [code]="role.roleId"></app-inline-code></td>
<td>{{ role.displayName }}</td>
<td>
<div class="scope-badges">

View File

@@ -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<TokensListComponent>;
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');
});
});

View File

@@ -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: `
<div class="admin-panel">
<header class="admin-header">
@@ -75,15 +77,15 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
<tbody>
@for (token of tokens; track token.tokenId) {
<tr [class.revoked]="token.status === 'revoked'" [class.expired]="token.status === 'expired'">
<td><code class="token-id">{{ formatTokenId(token.tokenId) }}</code></td>
<td><span [title]="token.tokenId"><app-inline-code [code]="token.tokenId | truncate:16"></app-inline-code></span></td>
<td>
<span class="type-badge" [class]="'type-' + (token.tokenType ?? 'unknown')">
{{ formatTokenType(token.tokenType ?? 'unknown') }}
</span>
</td>
<td>{{ token.subject }}</td>
<td><code>{{ token.clientId }}</code></td>
<td><code>{{ token.tenantId }}</code></td>
<td><app-inline-code [code]="token.clientId"></app-inline-code></td>
<td><app-inline-code [code]="token.tenantId"></app-inline-code></td>
<td>{{ formatDate(token.issuedAt) }}</td>
<td>{{ formatDate(token.expiresAt) }}</td>
<td>
@@ -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('_', ' ');
}

View File

@@ -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: `
<div class="admin-panel">
<header class="admin-header">
@@ -99,7 +100,7 @@ import { StellaOpsScopes } from '../../../core/auth/scopes';
<tr [class.disabled]="user.status === 'disabled'">
<td>{{ user.email }}</td>
<td>{{ user.displayName }}</td>
<td><code>{{ user.tenantId }}</code></td>
<td><app-inline-code [code]="user.tenantId"></app-inline-code></td>
<td>
<div class="role-badges">
@for (role of user.roles; track role) {

View File

@@ -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: `
<div class="jwks-management">
@@ -79,7 +80,7 @@ interface TrustAnchor {
@for (key of jwkEntries(); track key.kid) {
<div class="key-card" [class]="'status-' + key.status">
<div class="key-header">
<code class="key-id">{{ key.kid }}</code>
<app-inline-code [code]="key.kid"></app-inline-code>
<span class="key-status">{{ key.status }}</span>
</div>
<div class="key-details">
@@ -136,7 +137,7 @@ interface TrustAnchor {
</div>
<div class="anchor-fingerprint">
<span class="fingerprint-label">Fingerprint:</span>
<code class="fingerprint-value">{{ anchor.fingerprint }}</code>
<app-inline-code [code]="anchor.fingerprint"></app-inline-code>
</div>
<div class="anchor-validity">
<span>Valid: {{ formatDate(anchor.validFrom) }} - {{ formatDate(anchor.validTo) }}</span>
@@ -155,7 +156,7 @@ interface TrustAnchor {
@if (selectedAnchor(); as anchor) {
<div class="anchor-detail" data-testid="trust-anchor-detail">
<h4>{{ anchor.name }}</h4>
<p><strong>Fingerprint:</strong> <code>{{ anchor.fingerprint }}</code></p>
<p><strong>Fingerprint:</strong> <app-inline-code [code]="anchor.fingerprint"></app-inline-code></p>
<p><strong>Validity:</strong> {{ formatDate(anchor.validFrom) }} - {{ formatDate(anchor.validTo) }}</p>
<p><strong>Status:</strong> {{ anchor.status }}</p>
<div class="anchor-detail__actions">

View File

@@ -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();
});
});

View File

@@ -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: `
<div class="replay-command">
<div class="replay-header">
@@ -53,12 +54,10 @@ import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model'
<pre class="command-text" [attr.data-shell]="activeCommand()?.shell">{{ activeCommand()?.command ?? 'No command available' }}</pre>
<div class="command-actions">
<button class="copy-btn"
[class.copied]="copied()"
(click)="copyCommand()"
[disabled]="!activeCommand()?.command">
<span [innerHTML]="getCopyButtonContent()"></span>
</button>
<app-copy-to-clipboard
[value]="activeCommand()?.command ?? ''"
ariaLabel="Copy replay command">
</app-copy-to-clipboard>
</div>
</div>
@@ -99,7 +98,7 @@ import { ReplayCommand, ReplayCommandResponse } from '../../models/gating.model'
@if (expectedHash()) {
<div class="hash-verification">
<span class="hash-label">Expected verdict hash:</span>
<code class="hash-value">{{ expectedHash() }}</code>
<app-inline-code [code]="expectedHash() ?? ''"></app-inline-code>
</div>
}
</div>
@@ -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<ReplayCommandResponse | undefined>(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<void> {
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('<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" style="display:inline-block;vertical-align:middle"><polyline points="20 6 9 17 4 12" fill="none" stroke="currentColor" stroke-width="2"/></svg> Copied!');
}
return this.sanitizer.bypassSecurityTrustHtml('<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" style="display:inline-block;vertical-align:middle"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" fill="none" stroke="currentColor" stroke-width="2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1" fill="none" stroke="currentColor" stroke-width="2"/></svg> Copy');
}
formatBundleSize(bytes?: number): string {
if (bytes === undefined) return '';
if (bytes < 1024) return `${bytes} B`;

View File

@@ -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...');
});
});

View File

@@ -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<CopyToClipboardComponent>;
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();
}));
});

View File

@@ -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<InlineCodeComponent>;
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');
});
});