Replace the modal command palette with a nonmodal terminal dock.
Ctrl+K now opens a corner session with a parsed navigate grammar, persistent history, and no page lock. Unknown targets stay on a closed alias table and never become URLs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
232
src/app/shared/terminal/terminal-dock.spec.ts
Normal file
232
src/app/shared/terminal/terminal-dock.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
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');
|
||||
}
|
||||
|
||||
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 fixture.nativeElement.querySelector('.terminal-dock-log')?.textContent ?? '';
|
||||
}
|
||||
|
||||
it.each(APP_LOCALES)('renders the collapsed trigger on the server (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
expect(trigger(fixture)).toBeTruthy();
|
||||
expect(panel(fixture)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('opens from the trigger and focuses the input (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
expect(panel(fixture)).toBeTruthy();
|
||||
expect(document.activeElement).toBe(input(fixture));
|
||||
expect(fixture.nativeElement.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.command-palette-scrim')).toBeNull();
|
||||
});
|
||||
|
||||
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('collapsed');
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
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)('opens from Ctrl+K and Meta+K (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await flush(fixture);
|
||||
|
||||
for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) {
|
||||
if (panel(fixture)) {
|
||||
input(fixture)?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
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.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
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)('does not lock scroll or mark the page inert (%s)', async (locale) => {
|
||||
document.body.style.overflow = 'auto';
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(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);
|
||||
await openViaTrigger(fixture);
|
||||
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).trim()).toBe(copy.clearedMessage);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)('navigates a known target 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);
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'navigate pitch');
|
||||
expect(navigate).toHaveBeenCalledWith(navigation.link('pitch'));
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user