Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions frontend/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ErrorHandler, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { Component, ErrorHandler, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, Router } from '@angular/router';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
Expand All @@ -10,6 +10,10 @@ import { AppComponent } from './app.component';
import { GlobalErrorHandler } from './error/global-error-handler';
import { API_BASE_URL } from './tokens';

/** Lightweight stub used as a route target to avoid recursively rendering AppComponent. */
@Component({ selector: 'app-stub', template: '', standalone: true })
class StubComponent {}

describe('AppComponent', () => {
let fixture: ComponentFixture<AppComponent>;
let component: AppComponent;
Expand Down Expand Up @@ -129,7 +133,8 @@ describe('AppComponent (mobile viewport)', () => {
imports: [AppComponent, NoopAnimationsModule],
providers: [
provideZonelessChangeDetection(),
provideRouter([]),
// Provide a non-root route so we can navigate away from `/` to reveal the app shell.
provideRouter([{ path: 'compiler', component: StubComponent }]),
provideHttpClient(),
provideHttpClientTesting(),
{ provide: ErrorHandler, useClass: GlobalErrorHandler },
Expand Down Expand Up @@ -160,8 +165,13 @@ describe('AppComponent (mobile viewport)', () => {
localStorage.clear();
});

it('should render menu button with aria-label', async () => {
it('should render menu button with aria-label on non-landing routes', async () => {
// The app shell hamburger is only shown on non-landing-page routes.
// Navigate away from `/` to make isLandingPage() = false and reveal the shell header.
const router = TestBed.inject(Router);
await router.navigate(['/compiler']);
await fixture.whenStable();
fixture.detectChanges();
const menuBtn = fixture.nativeElement.querySelector('button[aria-label="Toggle navigation"]');
expect(menuBtn).toBeTruthy();
});
Expand Down
35 changes: 31 additions & 4 deletions frontend/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@
*/

import { Component, inject, signal, viewChild, effect } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { RouterLink, RouterLinkActive, RouterOutlet, Router, NavigationEnd } from '@angular/router';
import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav';
import { MatListModule } from '@angular/material/list';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
import { filter, map } from 'rxjs/operators';
import { ThemeService } from './services/theme.service';
import { ErrorBoundaryComponent } from './error/error-boundary.component';
import { NotificationContainerComponent } from './notification/notification-container.component';
Expand Down Expand Up @@ -105,7 +105,8 @@ interface NavItem {
<mat-sidenav-content>
<div class="page-wrapper">

<!-- Header — Bloqr nav bar: sticky, glass morphism, orange accent -->
<!-- Header — Bloqr nav bar: sticky, glass morphism, orange accent (hidden on landing page) -->
@if (!isLandingPage()) {
<header class="app-header-shell">
<div class="app-title-row">
@if (isMobile()) {
Expand Down Expand Up @@ -157,6 +158,7 @@ interface NavItem {
}
</nav>
</header>
} <!-- end @if (!isLandingPage()) header -->

<!-- Main content area -->
<main id="main-content" class="app-main-content" role="main" aria-label="Main content" tabindex="-1">
Expand All @@ -166,10 +168,12 @@ interface NavItem {
<router-outlet />
</main>

<!-- Footer matching original -->
<!-- Footer (hidden on landing page which has its own footer) -->
@if (!isLandingPage()) {
<footer class="app-footer-shell">
<p>Powered by <a href="https://github.com/jaypatrick/adblock-compiler" target="_blank" rel="noopener noreferrer">@jk-com/adblock-compiler<span class="visually-hidden"> (opens in new tab)</span></a></p>
</footer>
} <!-- end @if (!isLandingPage()) footer -->

</div>
</mat-sidenav-content>
Expand Down Expand Up @@ -237,6 +241,29 @@ export class AppComponent {
*/
readonly themeService = inject(ThemeService);

/**
* Router — used to detect the landing page URL.
* isLandingPage drives @if blocks that hide the shell header/footer on `/`.
* initialValue reads router.url synchronously so direct navigation to non-root
* routes (e.g. /compiler) starts with the correct false value, preventing a
* brief flash of hidden/shown shell on deep-link access.
*/
private readonly router = inject(Router);

private isLandingPageUrl(url: string): boolean {
const normalizedUrl = url.split(/[?#]/u, 1)[0];

return normalizedUrl === '' || normalizedUrl === '/';
}

readonly isLandingPage = toSignal(
this.router.events.pipe(
filter(e => e instanceof NavigationEnd),
map(e => this.isLandingPageUrl((e as NavigationEnd).urlAfterRedirects)),
),
{ initialValue: this.isLandingPageUrl(this.router.url) },
);

constructor() {
// Close the mobile drawer whenever desktop layout is active (horizontal nav takes over).
// Runs whenever isMobile() is false to ensure the drawer stays closed on desktop.
Expand Down
128 changes: 19 additions & 109 deletions frontend/src/app/home/home.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,141 +1,51 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { provideRouter } from '@angular/router';
import { HomeComponent } from './home.component';
import { API_BASE_URL } from '../tokens';

describe('HomeComponent', () => {
let fixture: ComponentFixture<HomeComponent>;
let component: HomeComponent;
let httpTesting: HttpTestingController;
let router: Router;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HomeComponent, NoopAnimationsModule],
imports: [HomeComponent],
providers: [
provideZonelessChangeDetection(),
provideHttpClient(),
provideHttpClientTesting(),
provideRouter([]),
{ provide: API_BASE_URL, useValue: '/api' },
],
}).compileComponents();

fixture = TestBed.createComponent(HomeComponent);
component = fixture.componentInstance;
httpTesting = TestBed.inject(HttpTestingController);
router = TestBed.inject(Router);

// Flush the initial rxResource requests triggered by component creation
flushPendingRequests();
});

function flushPendingRequests(): void {
httpTesting.match('/api/metrics').forEach(req => req.flush({
totalRequests: 0, averageDuration: 0, cacheHitRate: 0, successRate: 0,
}));
httpTesting.match('/api/health').forEach(req => req.flush({
status: 'healthy', version: '0.0.0',
}));
}

afterEach(() => {
httpTesting.match(() => true).forEach(req => req.flush({}));
httpTesting.verify();
vi.restoreAllMocks();
await fixture.whenStable();
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('should have 6 navigation cards', () => {
expect(component.navCards.length).toBe(6);
});

it('should include Compiler card', () => {
const compiler = component.navCards.find(c => c.path === '/compiler');
expect(compiler).toBeTruthy();
expect(compiler!.title).toBe('Filter List Compiler');
});

it('should include Admin card with warn tag', () => {
const admin = component.navCards.find(c => c.path === '/admin');
expect(admin).toBeTruthy();
expect(admin!.tagColor).toBe('warn');
});

it('should derive live stats from metrics', () => {
// After flushing with zeroed metrics, stats show formatted values
const stats = component.liveStats();
expect(stats.length).toBe(5);
expect(stats[0].label).toBe('Total Requests');
expect(stats[0].value).toBe('0');
});

it('should navigate when navigateTo is called', () => {
const navigateSpy = vi.spyOn(router, 'navigate');
component.navigateTo('/compiler');
expect(navigateSpy).toHaveBeenCalledWith(['/compiler']);
});

it('should open absolute external URLs in a new tab via window.open', () => {
const openSpy = vi.spyOn(window, 'open');
component.navigateTo('https://example.com');
expect(openSpy).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer');
});

it('should open worker-handled relative paths in a new tab when external flag is true', () => {
const openSpy = vi.spyOn(window, 'open');
const navigateSpy = vi.spyOn(router, 'navigate');
component.navigateTo('/docs', true);
expect(openSpy).toHaveBeenCalledWith('/docs', '_blank', 'noopener,noreferrer');
expect(navigateSpy).not.toHaveBeenCalled();
});

it('should NOT call window.open for internal paths without external flag', () => {
const openSpy = vi.spyOn(window, 'open');
const navigateSpy = vi.spyOn(router, 'navigate');
component.navigateTo('/compiler', false);
expect(openSpy).not.toHaveBeenCalled();
expect(navigateSpy).toHaveBeenCalledWith(['/compiler']);
});

it('should navigate to performance on stat card click for Total Requests', () => {
const navigateSpy = vi.spyOn(router, 'navigate');
component.onStatCardClicked('Total Requests');
expect(navigateSpy).toHaveBeenCalledWith(['/performance']);
});

it('should navigate to performance on stat card click for Avg Response Time', () => {
const navigateSpy = vi.spyOn(router, 'navigate');
component.onStatCardClicked('Avg Response Time');
expect(navigateSpy).toHaveBeenCalledWith(['/performance']);
});

it('should not navigate for non-metric stat card clicks', () => {
const navigateSpy = vi.spyOn(router, 'navigate');
component.onStatCardClicked('Cache Hit Rate');
expect(navigateSpy).not.toHaveBeenCalled();
it('should render nav bar', async () => {
await fixture.whenStable();
const nav = fixture.nativeElement.querySelector('app-nav-bar');
expect(nav).toBeTruthy();
});

it('should show default health icon when no data', () => {
// After flushing with healthy status, icon reflects healthy state
expect(component.healthIcon()).toBe('check_circle');
it('should render hero section', async () => {
await fixture.whenStable();
const hero = fixture.nativeElement.querySelector('app-hero-section');
expect(hero).toBeTruthy();
});

it('should show default health color when no data', () => {
// After flushing with healthy status, color reflects healthy state
expect(component.healthColor()).toBe('var(--app-success, #4caf50)');
it('should render landing content wrapper', async () => {
await fixture.whenStable();
const wrapper = fixture.nativeElement.querySelector('.landing-content');
expect(wrapper).toBeTruthy();
});

it('should render the page heading', () => {
fixture.detectChanges();
const el: HTMLElement = fixture.nativeElement;
expect(el.querySelector('h1')?.textContent).toContain('Adblock Compiler Dashboard');
it('should render footer section', async () => {
await fixture.whenStable();
const footer = fixture.nativeElement.querySelector('app-footer-section');
expect(footer).toBeTruthy();
});
});
Loading
Loading