Plain node activation no longer navigates, case cards stay quiet evidence rows, and the terminal keeps one top-to-bottom scrollport. Co-authored-by: Cursor <cursoragent@cursor.com>
478 lines
17 KiB
TypeScript
478 lines
17 KiB
TypeScript
import { DOCUMENT } from '@angular/common';
|
|
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
|
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
import { provideRouter, Router } from '@angular/router';
|
|
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
|
import { SITE_CONFIG } from '../../core/content/site-config';
|
|
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
|
|
import { LocaleService } from '../../core/i18n/locale.service';
|
|
import { NavigationService } from '../../core/navigation/navigation.service';
|
|
import { TerminalDock } from './terminal-dock';
|
|
import { TerminalDockService } from './terminal-dock.service';
|
|
|
|
describe('TerminalDock', () => {
|
|
afterEach(() => {
|
|
document.body.style.overflow = '';
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function createFixture(
|
|
locale: AppLocale = 'de',
|
|
extraProviders: { provide: unknown; useValue: unknown }[] = [],
|
|
): Promise<ComponentFixture<TerminalDock>> {
|
|
TestBed.resetTestingModule();
|
|
await TestBed.configureTestingModule({
|
|
imports: [TerminalDock],
|
|
providers: [provideRouter([]), ...extraProviders],
|
|
}).compileComponents();
|
|
|
|
TestBed.inject(LocaleService).setLocale(locale);
|
|
const fixture = TestBed.createComponent(TerminalDock);
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
return fixture;
|
|
}
|
|
|
|
async function flush(fixture: ComponentFixture<TerminalDock>): Promise<void> {
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
TestBed.inject(ApplicationRef).tick();
|
|
fixture.detectChanges();
|
|
await fixture.whenStable();
|
|
}
|
|
|
|
function trigger(fixture: ComponentFixture<TerminalDock>): HTMLButtonElement {
|
|
return fixture.nativeElement.querySelector('.terminal-dock-trigger');
|
|
}
|
|
|
|
function panel(fixture: ComponentFixture<TerminalDock>): HTMLElement | null {
|
|
return fixture.nativeElement.querySelector('.terminal-dock-panel');
|
|
}
|
|
|
|
function input(fixture: ComponentFixture<TerminalDock>): HTMLInputElement | null {
|
|
return fixture.nativeElement.querySelector('input');
|
|
}
|
|
|
|
function session(fixture: ComponentFixture<TerminalDock>): HTMLElement | null {
|
|
return fixture.nativeElement.querySelector('.terminal-dock-session');
|
|
}
|
|
|
|
function collapseControl(
|
|
fixture: ComponentFixture<TerminalDock>,
|
|
locale: AppLocale,
|
|
): HTMLButtonElement {
|
|
const label = SIGNATURE_COPY[locale].terminal.collapseLabel;
|
|
return fixture.nativeElement.querySelector(`[aria-label="${label}"]`);
|
|
}
|
|
|
|
async function collapseViaControl(
|
|
fixture: ComponentFixture<TerminalDock>,
|
|
locale: AppLocale,
|
|
): Promise<void> {
|
|
collapseControl(fixture, locale).click();
|
|
await flush(fixture);
|
|
}
|
|
|
|
async function openViaTrigger(fixture: ComponentFixture<TerminalDock>): Promise<void> {
|
|
trigger(fixture).click();
|
|
await flush(fixture);
|
|
}
|
|
|
|
async function submitQuery(
|
|
fixture: ComponentFixture<TerminalDock>,
|
|
value: string,
|
|
): Promise<void> {
|
|
const field = input(fixture);
|
|
expect(field).toBeTruthy();
|
|
field!.value = value;
|
|
field!.dispatchEvent(new Event('input', { bubbles: true }));
|
|
fixture.nativeElement
|
|
.querySelector('form')
|
|
?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
await flush(fixture);
|
|
}
|
|
|
|
function logText(fixture: ComponentFixture<TerminalDock>): string {
|
|
return session(fixture)?.textContent ?? '';
|
|
}
|
|
|
|
it.each(APP_LOCALES)(
|
|
'renders the expanded panel on the server without stealing focus (%s)',
|
|
async (locale) => {
|
|
const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
|
expect(trigger(fixture)).toBeTruthy();
|
|
expect(trigger(fixture).hasAttribute('hidden')).toBe(true);
|
|
expect(panel(fixture)).toBeTruthy();
|
|
expect(session(fixture)).toBeTruthy();
|
|
expect(session(fixture)?.contains(input(fixture))).toBe(true);
|
|
expect(document.activeElement).not.toBe(input(fixture));
|
|
},
|
|
);
|
|
|
|
it.each(APP_LOCALES)(
|
|
'defaults to expanded and does not steal focus on load (%s)',
|
|
async (locale) => {
|
|
const fixture = await createFixture(locale);
|
|
await flush(fixture);
|
|
const dock = TestBed.inject(TerminalDockService);
|
|
const copy = SIGNATURE_COPY[locale].terminal;
|
|
|
|
expect(dock.state()).toBe('expanded');
|
|
expect(panel(fixture)).toBeTruthy();
|
|
expect(trigger(fixture).hasAttribute('hidden')).toBe(true);
|
|
expect(document.activeElement).not.toBe(input(fixture));
|
|
expect(fixture.nativeElement.querySelector('[role="dialog"]')).toBeNull();
|
|
expect(fixture.nativeElement.querySelector('.command-palette-scrim')).toBeNull();
|
|
|
|
const size = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
|
|
const collapse = collapseControl(fixture, locale);
|
|
expect(size.getAttribute('aria-label')).toBe(copy.maximizeLabel);
|
|
expect(collapse.getAttribute('aria-label')).toBe(copy.collapseLabel);
|
|
expect(size.getAttribute('title')).toBe(copy.maximizeTooltip);
|
|
expect(collapse.getAttribute('title')).toBe(copy.collapseTooltip);
|
|
expect(size.getAttribute('aria-label')).not.toBe(collapse.getAttribute('aria-label'));
|
|
},
|
|
);
|
|
|
|
it.each(APP_LOCALES)(
|
|
'hides the trigger while open and shows it again after collapse (%s)',
|
|
async (locale) => {
|
|
const fixture = await createFixture(locale);
|
|
const dock = TestBed.inject(TerminalDockService);
|
|
|
|
expect(trigger(fixture).hasAttribute('hidden')).toBe(true);
|
|
expect(panel(fixture)).toBeTruthy();
|
|
|
|
await collapseViaControl(fixture, locale);
|
|
expect(panel(fixture)).toBeNull();
|
|
expect(dock.state()).toBe('collapsed');
|
|
expect(trigger(fixture).hasAttribute('hidden')).toBe(false);
|
|
expect(document.activeElement).toBe(trigger(fixture));
|
|
|
|
await openViaTrigger(fixture);
|
|
expect(panel(fixture)).toBeTruthy();
|
|
expect(dock.state()).toBe('expanded');
|
|
expect(trigger(fixture).hasAttribute('hidden')).toBe(true);
|
|
expect(document.activeElement).toBe(input(fixture));
|
|
},
|
|
);
|
|
|
|
it.each(APP_LOCALES)('moves through collapsed, expanded and maximized (%s)', async (locale) => {
|
|
const fixture = await createFixture(locale);
|
|
const dock = TestBed.inject(TerminalDockService);
|
|
expect(dock.state()).toBe('expanded');
|
|
|
|
fixture.nativeElement.querySelector('[aria-pressed]')?.click();
|
|
await flush(fixture);
|
|
expect(dock.state()).toBe('maximized');
|
|
expect(
|
|
fixture.nativeElement.querySelector('[aria-pressed]')?.getAttribute('aria-pressed'),
|
|
).toBe('true');
|
|
|
|
fixture.nativeElement.querySelector('[aria-pressed]')?.click();
|
|
await flush(fixture);
|
|
expect(dock.state()).toBe('expanded');
|
|
});
|
|
|
|
it.each(APP_LOCALES)('uses distinct maximize and restore names (%s)', async (locale) => {
|
|
const fixture = await createFixture(locale);
|
|
const copy = SIGNATURE_COPY[locale].terminal;
|
|
const button = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
|
|
|
|
expect(button.getAttribute('aria-label')).toBe(copy.maximizeLabel);
|
|
expect(button.getAttribute('title')).toBe(copy.maximizeTooltip);
|
|
|
|
button.click();
|
|
await flush(fixture);
|
|
|
|
expect(button.getAttribute('aria-label')).toBe(copy.restoreLabel);
|
|
expect(button.getAttribute('title')).toBe(copy.restoreTooltip);
|
|
expect(button.getAttribute('aria-pressed')).toBe('true');
|
|
});
|
|
|
|
it.each(APP_LOCALES)('toggles from Ctrl+K and Meta+K (%s)', async (locale) => {
|
|
const fixture = await createFixture(locale);
|
|
await flush(fixture);
|
|
expect(panel(fixture)).toBeTruthy();
|
|
|
|
for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) {
|
|
const event = new KeyboardEvent('keydown', {
|
|
key: 'k',
|
|
bubbles: true,
|
|
cancelable: true,
|
|
...modifier,
|
|
});
|
|
const prevent = vi.spyOn(event, 'preventDefault');
|
|
document.dispatchEvent(event);
|
|
await flush(fixture);
|
|
|
|
expect(prevent).toHaveBeenCalled();
|
|
}
|
|
|
|
expect(panel(fixture)).toBeTruthy();
|
|
});
|
|
|
|
it('does not toggle or preventDefault on Ctrl/Cmd+Shift+K', async () => {
|
|
const fixture = await createFixture();
|
|
await flush(fixture);
|
|
expect(panel(fixture)).toBeTruthy();
|
|
|
|
for (const modifier of [
|
|
{ ctrlKey: true, shiftKey: true },
|
|
{ metaKey: true, shiftKey: true },
|
|
] as const) {
|
|
const event = new KeyboardEvent('keydown', {
|
|
key: 'k',
|
|
bubbles: true,
|
|
cancelable: true,
|
|
...modifier,
|
|
});
|
|
const prevent = vi.spyOn(event, 'preventDefault');
|
|
document.dispatchEvent(event);
|
|
await flush(fixture);
|
|
|
|
expect(prevent).not.toHaveBeenCalled();
|
|
expect(panel(fixture)).toBeTruthy();
|
|
}
|
|
|
|
const closeEvent = new KeyboardEvent('keydown', {
|
|
key: 'k',
|
|
bubbles: true,
|
|
cancelable: true,
|
|
ctrlKey: true,
|
|
});
|
|
const preventClose = vi.spyOn(closeEvent, 'preventDefault');
|
|
document.dispatchEvent(closeEvent);
|
|
await flush(fixture);
|
|
expect(preventClose).toHaveBeenCalled();
|
|
expect(panel(fixture)).toBeNull();
|
|
|
|
const shiftWhileClosed = new KeyboardEvent('keydown', {
|
|
key: 'k',
|
|
bubbles: true,
|
|
cancelable: true,
|
|
ctrlKey: true,
|
|
shiftKey: true,
|
|
});
|
|
const preventShift = vi.spyOn(shiftWhileClosed, 'preventDefault');
|
|
document.dispatchEvent(shiftWhileClosed);
|
|
await flush(fixture);
|
|
expect(preventShift).not.toHaveBeenCalled();
|
|
expect(panel(fixture)).toBeNull();
|
|
|
|
const metaOpen = new KeyboardEvent('keydown', {
|
|
key: 'k',
|
|
bubbles: true,
|
|
cancelable: true,
|
|
metaKey: true,
|
|
});
|
|
const preventMeta = vi.spyOn(metaOpen, 'preventDefault');
|
|
document.dispatchEvent(metaOpen);
|
|
await flush(fixture);
|
|
expect(preventMeta).toHaveBeenCalled();
|
|
expect(panel(fixture)).toBeTruthy();
|
|
});
|
|
|
|
it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
|
|
const fixture = await createFixture(locale);
|
|
input(fixture)?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
await flush(fixture);
|
|
|
|
expect(panel(fixture)).toBeNull();
|
|
expect(document.activeElement).toBe(trigger(fixture));
|
|
});
|
|
|
|
it.each(APP_LOCALES)(
|
|
'closes on Escape from a panel control and returns focus to the trigger (%s)',
|
|
async (locale) => {
|
|
const fixture = await createFixture(locale);
|
|
const maximize = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
|
|
maximize.focus();
|
|
expect(document.activeElement).toBe(maximize);
|
|
|
|
maximize.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
await flush(fixture);
|
|
|
|
expect(panel(fixture)).toBeNull();
|
|
expect(document.activeElement).toBe(trigger(fixture));
|
|
},
|
|
);
|
|
|
|
it.each(APP_LOCALES)('does not lock scroll or mark the page inert (%s)', async (locale) => {
|
|
document.body.style.overflow = 'auto';
|
|
const fixture = await createFixture(locale);
|
|
await flush(fixture);
|
|
|
|
expect(document.body.style.overflow).toBe('auto');
|
|
expect(document.querySelector('.site')?.hasAttribute('inert')).toBeFalsy();
|
|
expect(fixture.nativeElement.querySelector('[aria-modal]')).toBeNull();
|
|
document.body.style.overflow = '';
|
|
});
|
|
|
|
it.each(APP_LOCALES)(
|
|
'echoes the prompt, keeps the log and walks command history (%s)',
|
|
async (locale) => {
|
|
const fixture = await createFixture(locale);
|
|
const copy = SIGNATURE_COPY[locale].terminal;
|
|
|
|
await submitQuery(fixture, 'help');
|
|
const helpText = logText(fixture);
|
|
expect(helpText).toContain(`${copy.prompt} help`);
|
|
expect(helpText).toContain(copy.helpIntro);
|
|
expect(helpText).toContain(copy.helpGrammar);
|
|
|
|
await submitQuery(fixture, 'brew');
|
|
expect(logText(fixture)).toContain(copy.responses.brew);
|
|
expect(logText(fixture)).toContain(`${copy.prompt} help`);
|
|
|
|
await submitQuery(fixture, 'rev');
|
|
expect(logText(fixture)).toContain(copy.responses.rev);
|
|
|
|
await submitQuery(fixture, 'history');
|
|
expect(logText(fixture)).toContain(copy.historyIntro);
|
|
expect(logText(fixture)).toContain('help');
|
|
|
|
const field = input(fixture);
|
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
|
await flush(fixture);
|
|
expect(field?.value).toBe('history');
|
|
|
|
await submitQuery(fixture, 'clear');
|
|
expect(logText(fixture)).toContain(copy.clearedMessage);
|
|
},
|
|
);
|
|
|
|
it.each(APP_LOCALES)(
|
|
'scrolls the session to keep the prompt visible after output (%s)',
|
|
async (locale) => {
|
|
const fixture = await createFixture(locale);
|
|
const sessionEl = session(fixture) as HTMLElement;
|
|
const scrollTo = vi.fn();
|
|
Object.defineProperty(sessionEl, 'scrollHeight', { configurable: true, get: () => 480 });
|
|
Object.defineProperty(sessionEl, 'scrollTo', { configurable: true, value: scrollTo });
|
|
|
|
await submitQuery(fixture, 'help');
|
|
|
|
expect(scrollTo).toHaveBeenCalled();
|
|
expect(scrollTo.mock.calls.at(-1)?.[0]).toEqual(
|
|
expect.objectContaining({
|
|
top: 480,
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each(APP_LOCALES)('walks command history like a conventional shell (%s)', async (locale) => {
|
|
const fixture = await createFixture(locale);
|
|
await submitQuery(fixture, 'help');
|
|
await submitQuery(fixture, 'brew');
|
|
await submitQuery(fixture, 'rev');
|
|
|
|
const field = input(fixture);
|
|
expect(field?.value).toBe('');
|
|
|
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
await flush(fixture);
|
|
expect(field?.value).toBe('');
|
|
|
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
|
await flush(fixture);
|
|
expect(field?.value).toBe('rev');
|
|
|
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
|
await flush(fixture);
|
|
expect(field?.value).toBe('brew');
|
|
|
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
|
await flush(fixture);
|
|
expect(field?.value).toBe('help');
|
|
|
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
|
await flush(fixture);
|
|
expect(field?.value).toBe('help');
|
|
|
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
await flush(fixture);
|
|
expect(field?.value).toBe('brew');
|
|
|
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
await flush(fixture);
|
|
expect(field?.value).toBe('rev');
|
|
|
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
await flush(fixture);
|
|
expect(field?.value).toBe('');
|
|
|
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
await flush(fixture);
|
|
expect(field?.value).toBe('');
|
|
});
|
|
|
|
it.each(APP_LOCALES)(
|
|
'restores an in-progress draft after walking off the newest history (%s)',
|
|
async (locale) => {
|
|
const fixture = await createFixture(locale);
|
|
await submitQuery(fixture, 'help');
|
|
const field = input(fixture);
|
|
field!.value = 'nav';
|
|
field!.dispatchEvent(new Event('input', { bubbles: true }));
|
|
await flush(fixture);
|
|
|
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
|
await flush(fixture);
|
|
expect(field?.value).toBe('help');
|
|
|
|
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
await flush(fixture);
|
|
expect(field?.value).toBe('nav');
|
|
},
|
|
);
|
|
|
|
it.each(APP_LOCALES)(
|
|
'navigates a known target, jumps to a fragment, and opens the CV asset (%s)',
|
|
async (locale) => {
|
|
const fixture = await createFixture(locale);
|
|
const router = TestBed.inject(Router);
|
|
const navigation = TestBed.inject(NavigationService);
|
|
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
|
const view = TestBed.inject(DOCUMENT).defaultView;
|
|
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
|
|
const copy = SIGNATURE_COPY[locale].terminal;
|
|
|
|
await submitQuery(fixture, 'navigate pitch');
|
|
expect(navigate).toHaveBeenCalledWith(navigation.link('pitch'), undefined);
|
|
expect(logText(fixture)).toContain(copy.navigating.replace('{target}', 'pitch'));
|
|
|
|
await submitQuery(fixture, 'navigate services software');
|
|
expect(navigate).toHaveBeenCalledWith(navigation.link('services'), {
|
|
fragment: 'software-data',
|
|
});
|
|
expect(logText(fixture)).toContain(copy.jumping.replace('{target}', 'services software'));
|
|
|
|
await submitQuery(fixture, 'navigate cv');
|
|
expect(open).toHaveBeenCalledWith(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
|
|
},
|
|
);
|
|
|
|
it('registers no document listener on the server', async () => {
|
|
const add = vi.spyOn(document, 'addEventListener');
|
|
await createFixture('de', [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
|
expect(add.mock.calls.some((call) => call[0] === 'keydown')).toBe(false);
|
|
});
|
|
|
|
it('removes the document keydown listener on destroy', async () => {
|
|
const add = vi.spyOn(document, 'addEventListener');
|
|
const fixture = await createFixture();
|
|
await flush(fixture);
|
|
|
|
const added = add.mock.calls.find((call) => call[0] === 'keydown');
|
|
expect(added).toBeTruthy();
|
|
|
|
const remove = vi.spyOn(document, 'removeEventListener');
|
|
fixture.destroy();
|
|
TestBed.resetTestingModule();
|
|
|
|
expect(remove).toHaveBeenCalledWith('keydown', added?.[1]);
|
|
});
|
|
});
|