Pengujian shortcut chording (seperti Ctrl+K Ctrl+B atau Cmd+Shift+P) pada tes End-to-End (E2E) sering kali memicu flakiness. Masalah ini bukan disebabkan oleh ketidakstabilan aplikasi web itu sendiri, melainkan oleh abstraksi tiruan event keyboard yang bocor di tingkat browser automation engine.

Ketika pengujian keyboard dilakukan tanpa presisi mekanis—seperti mengabaikan urutan pelepasan physical switch, timing debounce, dan propagasi status modifier—browser headless akan memproses urutan event secara inkonsisten. Menguji interaksi tingkat rendah ini membutuhkan pendekatan deterministik yang menyamai pipeline input sistem operasi.

Akar Masalah Flaky Test pada Synthetic Key Event

Mayoritas kegagalan tes keyboard E2E bersumber dari tiga masalah mendasar dalam cara runner mengemulasi input DOM.

1. Disparitas event.key vs event.code

API DOM membedakan antara nilai logis dan identitas fisik sebuah tombol:

  • event.key: Mengembalikan nilai tekstual tombol berdasarkan status aktif modifier dan layout keyboard sistem operasi (misal: menghasilkan "A" saat CapsLock aktif, atau "Å" pada layout tertentu).
  • event.code: Mengembalikan lokasi fisik tombol pada perangkat keras (misal: "KeyA"), tidak bergantung pada layout sistem operasi maupun modifier.

Ketika automation runner menggunakan event sintetis manual (element.dispatchEvent(new KeyboardEvent(...))), properti seperti keyCode, which, dan code sering kali tidak sinkron dengan key. Logika shortcut UI modern yang memvalidasi layout fisik (seperti pintasan game atau visual editor) akan gagal mendeteksi kombinasi jika runner hanya menginjeksikan key tanpa memetakan code secara presisi.

2. Modifier Stuck State dan Kebocoran State Antar-Tes

Synthetic event yang di-dispatch secara manual tidak memperbarui state internal browser window terkait status fisik keyboard. Sebaliknya, pemanggilan API automation tingkat tinggi yang terinterupsi assertion failure sering kali meninggalkan modifier keys (Shift, Control, Alt, Meta) dalam kondisi down.

Ketiadaan siklus keyup yang simetris menyebabkan modifier flags tetap bernilai true untuk interaksi berikutnya. Akibatnya, click atau typing biasa pada assertion berikutnya dieksekusi sebagai Ctrl+Click atau modifikasi teks yang merusak isolasi tes.

3. Race Condition pada Lifecycle Fokus di Headless Browser

Di browser berbasis headless (khususnya Chromium dan WebKit), rendering cycle dan focus transition tidak selalu terikat langsung dengan wall-clock time eksekusi JavaScript runner. Jika runner menembakkan event penekanan tombol sebelum document.activeElement selesai melakukan transisi fokus secara sinkron, event akan terbuang ke document.body alih-alih diterima oleh target handler lokal.

Solusi: Deterministic Key Simulation via Chrome DevTools Protocol (CDP)

Metode dispatchEvent(new KeyboardEvent(...)) menghasilkan event dengan flag isTrusted: false. Sebagian besar browser engine tidak menjalankan aksi default browser untuk event tidak terpercaya ini, dan penanganan bitmask modifier sering kali dilewati oleh browser event loop.

Untuk mencapai determinisme penuh, bypass layer DOM sintetis dan gunakan Chrome DevTools Protocol (CDP) domain Input.dispatchKeyEvent. CDP menyuntikkan event langsung ke native input pipeline Chromium. Pendekatan ini menghasilkan event dengan isTrusted: true dan mengelola bitmask modifier sistem operasi secara otomatis.

Parameter kritis pada Input.dispatchKeyEvent meliputi:

  • type: rawKeyDown, keyDown, keyUp, atau char.
  • windowsVirtualKeyCode: Kode virtual standar OS untuk verifikasi kompatibilitas cross-platform.
  • modifiers: Bitfield integer gabungan (1 = Alt, 2 = Control, 4 = Meta/Command, 8 = Shift).
  • code: Physical key identifier (misal: "KeyK").
  • key: Normalized visual character (misal: "k" atau "K").

Implementasi Test Harness pada Playwright

Playwright membungkus CDP di bawah kap mesin Chromium. Namun, untuk menjamin chording sekuensial bebas flake, modifier harus dieksekusi dengan jaminan blok try...finally serta pembersihan modifier pool secara eksplisit.

Berikut adalah arsitektur test harness deterministik untuk memverifikasi chording multi-key (seperti Ctrl+K kemudian dilepas lalu dilanjutkan Ctrl+C) beserta verifikasi defaultPrevented:

import { test, expect, Page, CDPSession } from '@playwright/test';

interface KeyStroke {
  key: string;
  code: string;
  keyCode: number;
}

export class DeterministicKeyboardHarness {
  private activeModifiers: Set<string> = new Set();
  private cdpSession: CDPSession | null = null;

  constructor(private page: Page) {}

  async init(): Promise<void> {
    this.cdpSession = await this.page.context().newCDPSession(this.page);
  }

  private calculateModifierBitmask(): number {
    let mask = 0;
    if (this.activeModifiers.has('Alt')) mask |= 1;
    if (this.activeModifiers.has('Control')) mask |= 2;
    if (this.activeModifiers.has('Meta')) mask |= 4;
    if (this.activeModifiers.has('Shift')) mask |= 8;
    return mask;
  }

  async sendNativeKeyEvent(
    type: 'rawKeyDown' | 'keyDown' | 'keyUp',
    stroke: KeyStroke
  ): Promise<void> {
    if (!this.cdpSession) {
      throw new Error('CDPSession belum diinisialisasi. Panggil init().');
    }

    if (['Control', 'Alt', 'Shift', 'Meta'].includes(stroke.key)) {
      if (type === 'rawKeyDown' || type === 'keyDown') {
        this.activeModifiers.add(stroke.key);
      } else if (type === 'keyUp') {
        this.activeModifiers.delete(stroke.key);
      }
    }

    await this.cdpSession.send('Input.dispatchKeyEvent', {
      type,
      modifiers: this.calculateModifierBitmask(),
      key: stroke.key,
      code: stroke.code,
      windowsVirtualKeyCode: stroke.keyCode,
      nativeVirtualKeyCode: stroke.keyCode,
    });
  }

  async executeChord(modifiers: KeyStroke[], keys: KeyStroke[]): Promise<void> {
    try {
      // 1. Tekan seluruh modifier
      for (const mod of modifiers) {
        await this.sendNativeKeyEvent('rawKeyDown', mod);
      }

      // 2. Eksekusi tombol aksi secara berurutan (chording sequence)
      for (const actionKey of keys) {
        await this.sendNativeKeyEvent('keyDown', actionKey);
        // Dispatch keyUp tombol aksi sebelum key berikutnya
        await this.sendNativeKeyEvent('keyUp', actionKey);
      }
    } finally {
      // 3. Teardown mutlak: pastikan seluruh modifier dilepas meski terjadi error
      for (const mod of modifiers) {
        await this.sendNativeKeyEvent('keyUp', mod);
      }
      this.activeModifiers.clear();
    }
  }
}

Contoh Test Case: Sequential Shortcut dengan Validasi event.preventDefault

Contoh berikut memverifikasi sequence chording Ctrl+K disusul Ctrl+S, memastikan action context tereksekusi tanpa memicu browser bookmark default:

test('Harus memicu command palette via sequential chording deterministik', async ({ page }) => {
  await page.goto('/editor');

  // Pastikan target fokus telah stabil
  const editorInput = page.locator('#code-canvas');
  await editorInput.focus();
  await expect(editorInput).toBeFocused();

  const harness = new DeterministicKeyboardHarness(page);
  await harness.init();

  const ctrlKey = { key: 'Control', code: 'ControlLeft', keyCode: 17 };
  const keyK = { key: 'k', code: 'KeyK', keyCode: 75 };
  const keyS = { key: 's', code: 'KeyS', keyCode: 83 };

  // Eksekusi sequence: Ctrl+K kemudian Ctrl+S
  await harness.executeChord([ctrlKey], [keyK]);
  await harness.executeChord([ctrlKey], [keyS]);

  // Validasi Command Palette terbuka
  const palette = page.locator('[data-testid="command-palette"]');
  await expect(palette).toBeVisible();

  // Validasi event cancellation: default browser save dialog tidak boleh menginterupsi
  const isPrevented = await page.evaluate(() => window.lastEventDefaultPrevented);
  expect(isPrevented).toBe(true);
});

Checklist Audit Regresi Aksesibilitas (a11y) Keyboard

Pengujian shortcut keyboard tidak hanya menyangkut keberhasilan pemanggilan callback, tetapi juga kepatuhan aksesibilitas standar WCAG 2.1/2.2. Gunakan checklist berikut saat menyusun regression suite:

  • No Keyboard Trap (WCAG 2.1.2): Pengguna harus dapat keluar dari komponen modular/modal yang dibuka via shortcut hanya menggunakan tombol standar (seperti Esc atau Tab) tanpa menekan modifier tambahan.
  • Character Key Shortcuts (WCAG 2.1.4): Jika aplikasi menggunakan shortcut karakter tunggal (tanpa Ctrl/Alt, misal: tombol ? untuk bantuan), sediakan mekanisme untuk mematikan atau mengonfigurasi ulang pintasan tersebut guna menghindari konflik dengan teknologi asistif.
  • Fokus Restorasi: Ketika modal shortcut ditutup, status fokus harus kembali secara deterministik ke elemen interaktif sebelumnya (document.activeElement terdokumentasi), bukan kembali ke document.body.
  • Roving Tabindex Synchronization: Shortcut navigasi (misal panah atas/bawah pada command menu) harus mengupdate properti tabindex="0" pada elemen aktif dan tabindex="-1" pada elemen pasif secara sinkron.
  • Visual Focus Indicator: Pastikan penekanan shortcut yang memindahkan selection tidak menghilangkan ring fokus visual (:focus-visible harus tetap aktif pada target baru).