Testing a keyboard-only Vue input in Laravel with Playwright
ShellTasks is keyboard-first: every action has to work without the mouse. That is easy to promise and easy to break. A test that clicks through the UI will pass while the keyboard path is broken, because it never uses the keyboard path.
So the end-to-end test for our input bar is not allowed to touch the mouse. It is one Playwright spec that types a task, creates a pack in a dialog, adds the task and copies a value, and it fails if a single pointer event fires. This post walks through how it is set up in a Laravel 12 app with Inertia and Vue 3. The code is copied from the repository, trimmed only where it says so.
What a clicking test misses
An action that only exists as a click. The suggestion list has a mousedown handler. If Enter stopped
picking the highlighted suggestion, a test that clicks the suggestion would still pass.
Focus that goes nowhere. A dialog closes and focus lands on <body>. The next keystroke does nothing. A
clicking test never notices, because it clicks the next thing it wants.
A list the keyboard cannot follow. Suggestions appear, but the arrow keys do not move through them, or the
highlighted one is not the one Enter picks.
A shortcut that only works on your machine. Ctrl+Enter saves on Linux and ⌘+Enter does nothing on a Mac.
1. Run Playwright against artisan serve, with its own database
Playwright’s webServer option starts the app before the tests. Ours migrates a fresh SQLite file and serves the
built assets, so no Vite dev server is needed and your development database is never touched:
// playwright.config.ts
import { resolve } from 'node:path';
import { defineConfig, devices } from '@playwright/test';
const port = 8123;
const database = resolve('database/e2e.sqlite');
export default defineConfig({
testDir: 'e2e',
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: `http://127.0.0.1:${port}`,
trace: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], permissions: ['clipboard-read', 'clipboard-write'] },
},
],
webServer: {
command: `touch ${database} && php artisan migrate:fresh --force && php artisan serve --host=127.0.0.1 --port=${port}`,
env: { APP_MODE: 'local', DB_CONNECTION: 'sqlite', DB_DATABASE: database },
url: `http://127.0.0.1:${port}`,
reuseExistingServer: !process.env.CI,
},
});
This works because Laravel lets real environment variables win: “Any variable in your .env file can be
overridden by external environment variables” (Laravel docs).
Playwright’s env is added to the environment it inherits, so DB_DATABASE points the server at the test file
while everything else still comes from your .env.
Two things to know. Run npm run build first, because the page loads the built assets. And locally,
reuseExistingServer reuses a server that is already listening on the port, which skips migrate:fresh. Stop
the old server if a run starts with yesterday’s data.
2. Fail the test on any pointer event
An init script runs in the page before any of the page’s own scripts, on every navigation. Ours sets a flag the moment a real pointer event happens:
await page.addInitScript(() => {
const flag = (e: Event) => {
if (e.isTrusted && !(e.type === 'click' && (e as MouseEvent).detail === 0)) {
(window as unknown as { pointerUsed: boolean }).pointerUsed = true;
}
};
for (const type of ['pointerdown', 'mousedown', 'click']) window.addEventListener(type, flag, true);
});
The last line of the test checks it:
expect(await page.evaluate(() => (window as unknown as { pointerUsed?: boolean }).pointerUsed ?? false)).toBe(false);
Three details matter:
- Capture phase (the
true). The listener onwindowruns before any component’s handler, so astopPropagation()inside the app cannot hide a click. isTrusted. Playwright’s mouse and keyboard go through the browser’s input pipeline, so its events are trusted, like a person’s. Aclick()called from your own code is not, and is not counted.detail === 0. PressingEnterorSpaceon a focused button also fires a trustedclick, but withdetail0. A real click has adetailof 1 or more. Without this exception, a keyboard user pressing a button would fail the test. This version is from the playground’s spec, which ends by pressing a Reset button; the app’s spec never presses a button, so it uses the same trap without thedetailcheck.
3. Find elements by role
Every locator in the test is a role and an accessible name, the way assistive technology finds them:
const bar = page.getByRole('combobox', { name: 'New task' });
await expect(bar).toBeFocused();
await page.keyboard.type('rotate keys for #acme-prod');
await expect(page.getByRole('option', { name: 'Create pack #acme-prod' })).toHaveAttribute('aria-selected', 'true');
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog', { name: 'New pack #acme-prod' });
await expect(dialog).toBeVisible();
Those roles only resolve if the component follows the
ARIA combobox pattern. Focus stays in the input; the
arrow keys move aria-activedescendant through the options. Trimmed from InputBar.vue:
<label :for="`${uid}-input`" class="sr-only">New task</label>
<input
:id="`${uid}-input`"
role="combobox"
aria-autocomplete="list"
:aria-expanded="listOpen"
:aria-controls="`${uid}-listbox`"
:aria-activedescendant="listOpen ? suggestions[active]?.id : undefined"
@keydown="onKeydown"
/>
<ul v-show="listOpen" :id="`${uid}-listbox`" role="listbox">
<li
v-for="(option, i) in suggestions"
:id="option.id"
:key="option.id"
role="option"
:aria-selected="i === active"
@mousedown.prevent="choose(option)"
>
…
</li>
</ul>
Checking aria-selected before pressing Enter checks two things at once: the option a screen reader announces
is the highlighted one, and it is the one Enter picks. The ids come from Vue’s useId(), so two input bars on a
page do not collide.
4. Assert focus after every step
The most common keyboard bug is focus that ends up somewhere useless, so the test asserts where it is after each move:
await expect(dialog.getByRole('textbox', { name: 'Key, row 1' })).toBeFocused();
// Enter goes from key to value; Tab on the last value adds a row.
await page.keyboard.type('db_host');
await page.keyboard.press('Enter');
await page.keyboard.type('db.acme.internal');
await page.keyboard.press('Tab');
await expect(dialog.getByRole('textbox', { name: 'Key, row 2' })).toBeFocused();
// …two more fields, then save:
await page.keyboard.press('ControlOrMeta+Enter');
await expect(dialog).toBeHidden();
await expect(bar).toBeFocused();
await expect(bar).toHaveValue('rotate keys for #acme-prod ');
Making those pass took one Vue detail. A new row is not in the DOM until Vue’s next render, so focusing it
straight after rows.value.push(row()) finds nothing. The modal waits for nextTick() first:
async function focusInput(index: number, field: 'key' | 'value') {
await nextTick();
dialog.value?.querySelector<HTMLInputElement>(`[id="${inputId(index, field)}"]`)?.focus();
}
The micro-modal is a native <dialog> opened with showModal(). The browser then makes the rest of the page
inert, so Tab cannot wander out of the dialog behind it, without a focus-trap library.
5. Type text, press keys, and use ControlOrMeta
page.keyboard.type() sends each character as a person would; page.keyboard.press() is for keys and
shortcuts. For shortcuts, Playwright’s ControlOrMeta “resolves to Control on Windows and Linux and to Meta
on macOS” (Playwright docs), so the same test runs on every
developer’s machine and on the Linux CI runner.
The handlers accept either modifier on every platform. They also ignore keys pressed while an input method is
composing text (Japanese or Chinese input, for example), so the Enter that confirms a character does not also
add a task or save a pack. From the micro-modal:
function onKeydown(event: KeyboardEvent) {
if (event.isComposing) return;
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
save();
}
// …
}
6. Check the clipboard, and that a secret is never on the page
Quick lookup copies a value when the input is only #pack.key. The Chromium project grants
clipboard-read and clipboard-write (in the config above), so the test can read what was copied:
const clipboard = (page: Page) => page.evaluate(() => navigator.clipboard.readText());
await page.keyboard.type('#acme-prod.db_password');
await page.keyboard.press('Enter');
await expect(page.getByRole('status')).toHaveText('Copied #acme-prod.db_password, clears in 30 s');
expect(await clipboard(page)).toBe('hunter2');
await expect(page.locator('body')).not.toContainText('hunter2');
The status line is a role="status" element, so the same message a screen reader announces is the one the test
reads. The last line is the cheap check that matters most: the secret was copied, and it never appeared in the
page’s text.
The test ends with page.reload() and checks that the task and the pack came back from the database through
the app’s JSON endpoints. That turns it from a component test into an end-to-end one.
7. Run it in CI
In GitHub Actions, Pest runs on both SQLite and PostgreSQL. The browser test runs once, on the SQLite leg, and keeps the trace when it fails:
- run: npm run build
- name: Playwright (keyboard flows)
if: matrix.db == 'sqlite'
run: npx playwright install --with-deps chromium && npm run e2e
- uses: actions/upload-artifact@v4
if: failure() && matrix.db == 'sqlite'
with: { name: playwright-traces, path: app/test-results, retention-days: 7 }
trace: 'retain-on-failure' in the config is what makes the artifact useful: open it with
npx playwright show-trace and you can step through every keypress and see where focus was.
What this does not cover
It proves the flow works from the keyboard in Chromium. It is not an accessibility audit: no screen reader, no colour contrast, no Firefox or WebKit. Those need their own checks. The desktop app’s global hotkey runs in NativePHP’s Electron window, which a browser test cannot reach, so it needs a manual check on a real desktop.
Where ShellTasks fits
The input bar in this post is ShellTasks’: keyboard-first tasks for your projects, where each task can carry its
context in a #pack of typed fields (URLs, commands, dates and secrets), and a CLI, tsk, reads packs in the
terminal. The desktop app and tsk are not released yet.
The same Vue components run in a browser playground on our website, which has its own keyboard-only spec with the same pointer trap. You can use it now, with no account.
Try ShellTasks in your browser. If it fits how you work, join the waitlist and we will email you when the beta opens.