Skip to content
31 changes: 27 additions & 4 deletions app/src/__tests__/full-panel-link.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ const capacitorPage = {
dropped: 0,
};

function fakeClient(capacitorPages: Record<string, unknown>) {
function fakeClient(capacitorPages: Record<string, unknown>, calls: unknown[][] = []) {
const rpc = {
call: () => new Promise(() => {}),
call: (...args: unknown[]) => {
calls.push(args);
return new Promise(() => {});
},
callEvent: () => Promise.resolve(),
sharedState: (name: string) =>
Promise.resolve(
Expand Down Expand Up @@ -82,11 +85,15 @@ async function settle(fixture: ComponentFixture<unknown>) {
}
}

async function open(path: string, capacitorPages: Record<string, unknown> = {}) {
async function open(
path: string,
capacitorPages: Record<string, unknown> = {},
calls: unknown[][] = [],
) {
history.replaceState(null, '', path);
const fixture = TestBed.createComponent(App);
await settle(fixture);
connection.resolve(fakeClient(capacitorPages));
connection.resolve(fakeClient(capacitorPages, calls));
await settle(fixture);
return { fixture, el: fixture.nativeElement as HTMLElement };
}
Expand Down Expand Up @@ -177,6 +184,22 @@ describe('Hub rail and the full panel', () => {
});
});

describe('Hub dock badges', () => {
it('tells the server which dock is open, on load and on each switch, to clear its count', async () => {
const hub = fakeHub('pangular:ngrx');
const calls: unknown[][] = [];
const { fixture } = await open(`${BASE}?view=ngrx`, {}, calls);
const seen = () => calls.filter(([name]) => name === 'hub-dock-seen').map(([, dock]) => dock);
expect(seen()).toEqual(['pangular:ngrx']);

await hub.activate('pangular:all');
await settle(fixture);
await hub.activate('pangular:all');
await settle(fixture);
expect(seen()).toEqual(['pangular:ngrx', 'pangular:all', 'pangular:all']);
});
});

describe('fullPanelHref', () => {
it('drops the view and sets or clears the tab', () => {
expect(fullPanelHref(`http://localhost${BASE}?view=ngrx#tab=store`, 'store')).toBe(
Expand Down
12 changes: 12 additions & 0 deletions app/src/__tests__/hub-rail-style.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,18 @@ describe('styleHubRail', () => {
expect(styleTags[0].textContent).toContain('#ffffff');
});

it('dims the icon of an inactive dock but not its badge, and darkens the badge palette', () => {
makeDock();
styleHubRail(document, 'dark');
const css = document
.querySelector('devframes-dock-standalone')!
.shadowRoot!.querySelector<HTMLStyleElement>('style[data-pangular]')!.textContent!;
expect(css).toContain('button:not(.scale-120) > :not(.absolute)');
expect(css).not.toMatch(/button:not\(\.scale-120\) \{/);
expect(css).toMatch(/\[style\*="59, 130, 246"\] \{\s*background-color: #2563eb !important;/);
expect(css).toMatch(/\[style\*="239, 68, 68"\] \{\s*background-color: #dc2626 !important;/);
});

it('retries when the shadow host is not yet in the DOM', () => {
vi.useFakeTimers();
styleHubRail(document, 'dark');
Expand Down
104 changes: 104 additions & 0 deletions app/src/__tests__/source-link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import type { DevframeRpcClient } from 'devframe/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EditorOpener, SourceLink } from '../ui/source-link';

@Component({
imports: [SourceLink],
template: `
<p class="plain"><app-source-link file="src/app/app.ts" [line]="12" /></p>
<p class="custom">
<app-source-link file="/src/app/pages/index.page.ts"
><span class="dir">pages/</span>index.page.ts</app-source-link
>
</p>
`,
})
class Host {}

function client(answer: unknown, backend = 'websocket') {
const call = vi.fn(async () => answer);
const fake = {
connectionMeta: { backend, configs: {} },
scope: () => ({ rpc: { call } }),
} as unknown as DevframeRpcClient;
return { fake, call };
}

async function render(rpc: DevframeRpcClient | null) {
TestBed.inject(EditorOpener).use(signal(rpc));
const fixture = TestBed.createComponent(Host);
await fixture.whenStable();
return fixture.nativeElement as HTMLElement;
}

describe('SourceLink', () => {
afterEach(() => TestBed.resetTestingModule());

it('shows plain text when no server is connected', async () => {
const el = await render(null);
expect(el.querySelector('button')).toBeNull();
expect(el.querySelector('[role="alert"], [role="status"]')).toBeNull();
expect(el.querySelector('.plain')!.textContent!.trim()).toBe('src/app/app.ts:12');
expect(el.querySelector('.custom')!.textContent!.trim()).toBe('pages/index.page.ts');
});

it('shows plain text in a static report', async () => {
const el = await render(client({ ok: true }, 'static').fake);
expect(el.querySelector('button')).toBeNull();
});

it('is a button named after the file that opens it at its line', async () => {
const { fake, call } = client({ ok: true, file: '/w/src/app/app.ts' });
const el = await render(fake);
const button = el.querySelector<HTMLButtonElement>('.plain button')!;
expect(button.type).toBe('button');
expect(button.getAttribute('aria-label')).toBe('Open src/app/app.ts:12 in your editor');
expect(button.textContent!.trim()).toBe('src/app/app.ts:12');
button.click();
await vi.waitFor(() =>
expect(call).toHaveBeenCalledWith('open-in-editor', { file: 'src/app/app.ts', line: 12 }),
);
expect(el.querySelector('[role="alert"], [role="status"]')).toBeNull();
});

it('keeps projected text, whose words are part of the accessible name', async () => {
const { fake, call } = client({ ok: true });
const el = await render(fake);
const button = el.querySelector<HTMLButtonElement>('.custom button')!;
expect(button.textContent!.trim()).toBe('pages/index.page.ts');
expect(button.getAttribute('aria-label')).toContain('pages/index.page.ts');
button.click();
await vi.waitFor(() =>
expect(call).toHaveBeenCalledWith('open-in-editor', {
file: '/src/app/pages/index.page.ts',
}),
);
});

it('says why the server did not open it', async () => {
const { fake } = client({ ok: false, error: 'src/app/app.ts is outside the project root.' });
const el = await render(fake);
el.querySelector<HTMLButtonElement>('.plain button')!.click();
expect(TestBed.inject(EditorOpener).available()).toBe(true);
await vi.waitFor(() => {
TestBed.tick();
expect(el.querySelector('.plain [role="alert"]')!.textContent).toBe(
'Not opened: src/app/app.ts is outside the project root.',
);
});
});

it('reports a server without the RPC', async () => {
const fake = {
connectionMeta: { backend: 'websocket', configs: {} },
scope: () => ({ rpc: { call: async () => Promise.reject(new Error('unknown')) } }),
} as unknown as DevframeRpcClient;
TestBed.inject(EditorOpener).use(signal(fake));
expect(await TestBed.inject(EditorOpener).open('a.ts', 1)).toEqual({
ok: false,
error: 'The server did not open it.',
});
});
});
21 changes: 17 additions & 4 deletions app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ import { hostPageId, scopeToPage } from './page-id';
import { angularNativePage, type PlatformPage } from './native-page';
import { initialTab, storeTab, storedTab } from './tab-memory';
import { detectBaseURL } from './base-url';
import { clearHighlightsOnHide } from './rpc';
import { clearHighlightsOnHide, rpcTry } from './rpc';
import { EditorOpener } from './ui/source-link';
import { viewAccent } from './view-accents';
import {
HUB_ALL_TOOLS,
Expand Down Expand Up @@ -992,6 +993,8 @@ export class App implements OnInit, OnDestroy {
return (view && (VIEW_TAB[view] ?? VIEW_TABS[view]?.[0])) || 'dashboard';
});
rpc = signal<DevframeRpcClient | null>(null);
/** The hub dock opened last, a new object each time, so opening it again counts. */
private readonly openedDock = signal<{ dock: string } | null>(null);
connected = signal(false);
readonly connectionFailed = signal(false);
private readonly hiddenPages = signal<string[]>([]);
Expand Down Expand Up @@ -1020,6 +1023,7 @@ export class App implements OnInit, OnDestroy {
readonly navFade = signal({ start: false, end: false });

constructor() {
inject(EditorOpener).use(this.rpc);
afterNextRender(() => {
const nav = this.nav()?.nativeElement;
if (!nav || typeof ResizeObserver === 'undefined') return;
Expand All @@ -1034,6 +1038,13 @@ export class App implements OnInit, OnDestroy {
this.measureNav();
});
});
// Opening a dock clears the count of unseen messages on it.
effect(() => {
const opened = this.openedDock();
const client = this.rpc();
if (!opened || !client) return;
untracked(() => void rpcTry(client, 'hub-dock-seen', hubDockId(opened.dock)));
});
effect(() => {
const theme = this.themeService.current();
untracked(() => {
Expand Down Expand Up @@ -1070,9 +1081,11 @@ export class App implements OnInit, OnDestroy {
ngOnInit() {
// The views and the full panel share one hub frame, so whichever loaded
// first follows the rail.
this.stopFollowing = followHubDocks(HUB_DOCKS, (dock) =>
dock === ALL_TOOLS ? this.showFullPanel() : this.showView(dock),
);
this.stopFollowing = followHubDocks(HUB_DOCKS, (dock) => {
this.openedDock.set({ dock });
if (dock === ALL_TOOLS) this.showFullPanel();
else this.showView(dock);
});
const restored = initialTab(
location.hash,
storedTab(this.tabScope()),
Expand Down
32 changes: 27 additions & 5 deletions app/src/hub-rail-style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,38 @@ function activeDockStyle(theme: 'dark' | 'light'): string {
const accentGlow = theme === 'light' ? 'rgba(146, 64, 14, 0.50)' : 'rgba(245, 165, 36, 0.60)';
return `
.devframes-dock-entry button {
transition: opacity 0.2s, filter 0.2s, background-color 0.2s, transform 0.3s;
transition: background-color 0.2s, transform 0.3s;
}
.devframes-dock-entry button:not(.scale-120) {
/* Dim the icon of an inactive dock, never its badge, so the count keeps
its contrast. hub-ui's own dim classes would fade the whole button. */
.devframes-dock-entry button.op50,
.devframes-dock-entry button.saturate-0 {
opacity: 1;
filter: none;
}
.devframes-dock-entry button > * {
transition: opacity 0.2s, filter 0.2s;
}
.devframes-dock-entry button:not(.scale-120) > :not(.absolute) {
opacity: 0.45;
filter: saturate(0);
}
.devframes-dock-entry button:not(.scale-120):hover,
.devframes-dock-entry button:not(.scale-120):focus-visible {
.devframes-dock-entry button:not(.scale-120):hover > :not(.absolute),
.devframes-dock-entry button:not(.scale-120):focus-visible > :not(.absolute) {
opacity: 1;
filter: none;
}
/* hub-ui's badge palette (blue-500 and red-500 on a pale tint) is below
4.5:1, and it sets the colors inline, hence the attribute selectors and
!important. blue-600 and red-600 with white text pass. */
.devframes-dock-entry .absolute[style*="59, 130, 246"] {
background-color: #2563eb !important;
color: #ffffff !important;
}
.devframes-dock-entry .absolute[style*="239, 68, 68"] {
background-color: #dc2626 !important;
color: #ffffff !important;
}
.devframes-dock-entry button:focus-visible {
outline: 2px solid ${accent};
outline-offset: 2px;
Expand All @@ -30,7 +51,8 @@ function activeDockStyle(theme: 'dark' | 'light'): string {
color-scheme: ${theme};
}
@media (prefers-reduced-motion: reduce) {
.devframes-dock-entry button {
.devframes-dock-entry button,
.devframes-dock-entry button > * {
transition: none;
}
}
Expand Down
Loading
Loading