Skip to content

test(device): local emulator layer — real Chrome/Gboard as normal CI - #3034

Closed
YousefED wants to merge 35 commits into
mobile/android-enterfrom
mobile/emulator-layer
Closed

test(device): local emulator layer — real Chrome/Gboard as normal CI#3034
YousefED wants to merge 35 commits into
mobile/android-enterfrom
mobile/emulator-layer

Conversation

@YousefED

@YousefED YousefED commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fifth layer of the stack, on #3031. Adds the OS-emulator layer between browser emulation and BrowserStack hardware, per the testing-foundation discussion.

What

The device suite's session becomes an interface with three backends; tests and page helpers are written once and run against whatever the machine can drive (activeDevices() probes availability):

  • localAndroid.ts — a local Android emulator via Playwright's first-party (experimental) _android support: real Chrome as a Playwright page over CDP, input as genuine OS events via adb shell (Playwright's device.input needs a companion APK; shell doesn't). Page→screen coordinates come from a one-time calibration tap on an app page — never Chrome's initial page, whose 980px virtual viewport poisons the mapping. This backend can press the on-screen keyboard itself: pressImeActionKey drives Gboard's real action key with a verify ladder.
  • browserstack.ts — the existing selenium client, unchanged behaviour, now one backend among several.
  • localIos.ts — an iOS simulator via Appium/XCUITest (WebDriverAgent), driven by the same selenium client as BrowserStack: the actual iOS build and actual Safari, headless (XCUITest owns the HID stack, so the software keyboard works without the Simulator GUI), no tunnel. mobile: tap is the same channel BrowserStack iOS uses, so the gesture layer's chrome-offset ladders apply unchanged. Two dead ends are documented in code so nobody re-walks them: safaridriver's input is synthetic at the WebKit layer (never summons the keyboard; injecting real HID alongside trips Safari's "stop the current automated test session?" guardrail), and Appium's web-context clicks are synthetic too (nativeWebTap included).

imeAction.device.test.ts is the previously-manual release-checklist item as a regression test: Gboard's real action key submits the link popover and focus stays in the editor — the flow no cloud automation can exercise (BrowserStack has no channel to the on-screen keyboard).

CI

emulator-tests.yml runs both targets on every PR and push to main — a KVM-backed API-35 emulator on ubuntu (3m29s) and an iPhone simulator via Appium on macos-15 (5m47s). Free minutes, no credentials, pinned actions, both green. Locally: boot any emulator (the setup boots the simulator itself) and pnpm run test:device — it runs whatever the machine can drive.

Verified

Local matrix green: 11 tests across the API-35 emulator (Chrome 124 + Gboard) and the iPhone simulator (real iOS Safari), plus two consecutive iOS runs proving the boot/teardown cycle. CI green on both jobs. Bugs found along the way by this layer: Array.findLast crashes the editor on Chrome <97, and isTouchDevice() fails on Chrome 91's pointer: coarse — both filed for a support-matrix decision rather than silently patched.

Puts the harnesses in place that the mobile fixes are verified with, so
those changes arrive with their tests rather than their scaffolding.

- A fourth browser instance running Android-emulated Chromium, for
  `end-to-end/mobile/`. Per-instance `contextOptions` are silently
  ignored by the runner, so the emulation is applied through the
  provider; `ensureTouchEmulation` asserts it actually took effect
  rather than letting a stubbed-out context pass as coverage.
- `imeComposition`, a browser command driving Chromium's real IME
  pipeline over CDP. Synthetic `CompositionEvent`s are untrusted and
  never mutate the DOM, so they cannot reproduce what a mobile keyboard
  does; `Input.imeSetComposition` can.
- A BrowserStack real-device suite (`tests/device/`) and its workflow.
  The gesture layer is where per-platform quirks are recorded.

The copypaste and keyboardhandlers suites gain skips for the cases that
don't translate to a touch-emulated context — positional mouse drags
have no touch equivalent, so those tests would fail for reasons
unrelated to what they cover.
Running the device suite meant exporting BROWSERSTACK_* by hand each time.
The config now loads the repo root's `.env` (gitignored; the entries are
documented in `.env.sample`, the repo's one sample file) — dotenv parsing
accepts its shell-style `export KEY=value` lines, so the same file keeps
working for `source`. Real environment variables take precedence, so CI is
unaffected, and the missing-credentials error points at the file.
zizmor (repo policy) requires actions pinned to hashes; the workflow also
diverged from how every other workflow sets up — checkout + setup-vp with
the shared pins, persist-credentials off, an explicit least-privilege
permissions block, and vp for install/run. The test:device script drops
its pnpm invocation for the same reason: CI only provides vp.
Review feedback: this should be part of normal CI, not a scheduled job.
Runs on pushes to main and on PRs — fork PRs have no secrets, so the
suite self-skips and the job is a green no-op there. Device minutes are
metered, so a superseding push cancels the in-flight PR run. Also
silences the actionlint unused-loop-variable warning.
Review feedback asked to simplify the tunnel setup, and the simplest form
turned out to be deleting the host-rewriting proxy entirely: it existed
only to satisfy Vite's allowedHosts check, and the playground config
already whitelists a hostname for the docker e2e setup — bs-local.com
joins it, so devices browse the dev server directly through the
BrowserStackLocal tunnel. Also removes the CodeQL-flagged error echo in
the proxy's 502 path, by removing the proxy.

The binary download now maps platform/arch explicitly — the old fallback
handed Windows and Linux-ARM the linux-x64 archive — and fails with
guidance on unsupported hosts. The header documents that this file runs
identically locally and in CI (a parity choice over BrowserStack's
GitHub Action, which wraps the same daemon).
Review feedback: the emulation is configured per instance already (the
playwright provider's contextOptions) — this util existed to self-heal
the one known way it gets lost, Playwright dropping the context's touch
emulation after an iframe-element screenshot. Stubbing detection back
made tests pass in a context where actual touch input behaves like a
desktop. Now it fails loudly, naming the cause, if the loss ever happens
— which the android instance's include list is supposed to prevent.
Two review findings about stacking hygiene:

- The link-popover device helpers (selectFirstWord, openLinkPopover, the
  LINK_* selectors, typeAndSubmit) lived in the shared lib but serve only
  the link tests — they move up to the layer that adds those tests, next
  to them.
- The copypaste/keyboardhandlers touch-emulation skips were carried here
  while the android instance only runs mobile/**; they belong in the
  layer that widens the instance to those suites.
Flagged by review: the sample carried a real-looking Nx remote-cache
access token (committed with the nx 21 upgrade in July 2025). A sample
file should hold placeholders; the value has been public in git history
the whole time, so if it is a live credential it needs rotating — see
the PR discussion.
…package

Review pushback was right to be surprised by the hand-rolled download
script: BrowserStack's documented Node.js integration is their
browserstack-local package, which downloads and manages the right daemon
for the host platform itself. The custom binary fetch, platform/arch
map, and daemon spawning all go away; the same code path runs locally
and in CI.
Review asked where the docs bless the client half of this rig — and for
the hand-rolled REST plumbing (or a bare protocol package) they don't.
The client BrowserStack's Automate Node.js documentation and samples
actually use is selenium-webdriver, with auth inside the capabilities'
bstack:options — which devices.ts already had. DeviceSession now wraps
that client and keeps only the domain layer: lifecycle with retry,
script polling, artifact screenshots, and the dashboard annotation (a
BrowserStack REST API, not a WebDriver route). Public API unchanged, so
gestures and tests don't move.

(The browserstack-node-sdk layered on top of selenium-webdriver wraps
supported runners — Jest, Mocha — and manages the tunnel and platform
matrix from a yml. Adopting it would mean a second test runner in a
vitest-standardized repo, for tunnel management we already get from the
official browserstack-local binding and a device matrix devices.ts
already expresses. Deliberately not taken; revisit if Test Observability
becomes interesting.)
Review feedback: the reasoning about which BrowserStack packages to use
(and why browserstack-node-sdk deliberately isn't) lived in a commit
message — durable documentation belongs in the README. Also removes a
doc comment orphaned by typeAndSubmit's move.
build.yml runs on the edited event; the device workflow now does too,
but only when the edit changed the base branch — that's what changes the
merge result (routine in a PR stack), while title and body edits would
just spend device minutes.
Creating a link on Android didn't work: the popover's URL never became a
link and focus jumped to the next editor instead.

The cause is that a mobile IME picks the action its Enter key performs,
and with a lone text field it picks "Next" — advancing focus and
dispatching no key event at all. A popover that only listens for Enter
therefore has nothing to hear. Putting the fields in a real `<form>` is
what makes the IME offer a submitting action instead, confirmed on a
device; `Form.Root` was a `<div>`, so `onSubmit` could never fire.

`Form.Root` now renders a `<form>`, and submission runs off its `submit`
event. That has three consequences worth calling out:

- HTML only submits implicitly when a form has a submit button or exactly
  one field, so the link *edit* form — url plus title — would still reach
  nothing. `Form.Root` renders a submit button to cover any field count.
  It is visually hidden rather than absent so assistive technology still
  has a labelled control, and outside the tab order so sighted keyboard
  users never land on a control they can't see.
- The browser performs implicit submission for an Enter that arrives with
  `isComposing: true`, so accepting an IME candidate would submit the
  popover mid-word. `useFormSubmit` guards that centrally, replacing the
  per-callsite `isComposing` checks that had already drifted apart.
- With one submission path, the five Enter handlers are redundant and are
  removed. `EmbedTab` had no form at all and gains one; the AI prompt
  menu's handler and `onSubmit` disagreed about whether Enter picks the
  highlighted suggestion or submits the typed text, and now share one
  decision.

`TextInput` also loses its `onSubmit` prop: every skin forwarded it to
the `<input>`, and `submit` only fires on a form and bubbles upward, so
it could never have fired. `EditLinkMenuItems` passed it, which is
plausibly why the gap went unnoticed.
Review follow-ups:

- The embed panel ended up with two submit controls: its own Embed button
  plus the hidden one `Form.Root` adds, so a screen reader announced two
  separate actions for the one thing that panel does. `Form.Root` now takes
  `hasOwnSubmitButton` for callers that supply their own.
- The three `TextInput`s hand-rolled ref merging. `mergeRefs` already exists
  here, but returns a fresh callback per call — which detaches and reattaches
  the ref every render — so this adds `useMergeRefs` alongside it, memoized
  the way `react-merge-refs` does, and uses that.
- The mantine popover keyed two behaviours off `portalRoot` while its
  comments explained them in terms of mobile. Same condition, but named, so
  the reason isn't hidden behind an unrelated prop.
- `useFormSubmit` documents that it exists for `Form.Root` implementations
  rather than applications.
…'t fail

Second review round, checking whether the tests added in the first one can
actually fail. Two could not:

- The composition tests built a synthetic form replicating what `Form.Root`
  does, so deleting the guard from `useFormSubmit` left them all green — the
  shipped code had no coverage at all. A test now drives the real link
  popover through a CDP composition, and fails when the guard is removed.
  The synthetic ones stay as what they are: the platform fact that a browser
  submits for an Enter carrying `isComposing: true`.
- "the embed tab commits exactly once" asserted one image was present, which
  is true whether the update ran once or twice. Its replacement counted the
  form's submit events, but that cannot fail either: only mantine runs in
  this suite and its panel button already defaults to `type="button"`. The
  structural check — no button inside the form — is what actually guards
  both the double-commit and the duplicate-control problems, and it does
  fail when the button is moved inside, so that one is kept and the
  outcome-based tests are dropped rather than left as decoration.

Also renames `hasOwnSubmitButton` to `omitSubmitButton`: EmbedTab's button
sits outside the form, so the form has no submit button at all and relies on
single-field implicit submission. The old name asserted something untrue of
its only caller, and hid the constraint the flag carries.
…oundary

The `from + 1` probe fixed the left-edge case (`marks()` excludes a link
at its left boundary) but is still fragile: browsers disagree by a
position on where a selection over a link starts, so a single-position
lookup can land outside the mark either way. For a non-empty selection,
scan the selected range for the first link mark instead; an empty
selection keeps the plain position lookup.
…es IMEs

The guard answered the wrong category of problem. `isComposing` checks are
needed in *keydown* handlers, because an IME-consumed key still dispatches
to JS — that is what the five removed Enter handlers were. Native form
submission never sees that key: the IME consumes the confirming Enter (it
reaches the page as keyCode 229, which the browser runs no default action
for), so implicit submission cannot fire mid-composition. This is why no
plain form on the web carries composition handling.

The state the guard defended — composition open, unconsumed trusted Enter
delivered — is one only CDP emulation can fabricate: `imeSetComposition`
sets composition state with no IME in the loop to consume the key. No real
IME produces the sequence. Worse, the guard carried real risk in the other
direction: Gboard's action key commits the composition and submits in one
press, so if any IME delivers `submit` before `compositionend`, the guard
would swallow a legitimate submission — the original bug, reintroduced for
exactly the users it claimed to protect.

`Form.Root` goes back to plain `preventDefault` wiring, `useFormSubmit` is
deleted, and the composition tests now pin the *native* contract against
the real popover: accepting a candidate does not submit, Enter afterwards
does.
… form

Review feedback (two threads): the link-flow device helpers belong next
to the tests that use them, not in the shared lib — moved here from
editorPage/gestures.

typeAndSubmit also changes how it submits, answering why it dispatched a
synthetic Enter: the on-screen keyboard's action key is unreachable by
any automation channel (see README), and the dispatched keydown only
worked while the popovers had key handlers. With submission running off
the form's submit event, an untrusted keydown does nothing — the helper
was silently broken by the form rework. requestSubmit() is the browser's
own submission path and exercises the popover's real onSubmit wiring;
the IME's own action-key choice stays a manual release check.
Two review questions shaped this. First: the old dispatched KeyboardEvent
could never submit once the popovers moved to the form's submit event —
synthetic events trigger no default action. Second: 'why not hit the
Enter key?' — no reason not to, and the rig already knew how: on iOS,
pressSoftKeyboardEnter taps the on-screen keyboard's actual return key
(the RETURN_KEY_RATIOS offset ladder) — the real user gesture. On
Android, where BrowserStack blocks native taps, a W3C protocol Enter is
used instead: trusted input, so the browser runs its default action and
the real path is exercised (key press -> implicit form submission -> the
popover's submit handling). Only Gboard's own choice of *which* action
its key performs stays out of reach, on the manual release checklist.
Callers supply the verify script the iOS tap ladder needs.
The 'no input channel can press that key' claim was BrowserStack-scoped
truth stated as absolute. A local Android emulator runs real Chrome and
real Gboard, and adb can tap the on-screen action key — verified end to
end (action key tapped, link created in the correct editor, focus
retained). Recorded as the known path to automating the release-checklist
item.
On Android, prosemirror-view deliberately bails out of its keydown
handling: the IME reports composing keys as keyCode 229, so the key
identity can't be trusted. Enter therefore never reached the keymap and
pressing it did nothing — no new block, no list continuation.

`beforeinput` carries the intent unambiguously (`insertParagraph` /
`insertLineBreak`) regardless of what the IME reports, so the shortcuts
extension intercepts it there and runs the same keymap command. Only on
Android, and only when not composing, so every other platform keeps the
existing path.

This also unblocks running the core behavioural suites under Android
emulation. They were held out of the android instance in the test-infra
change precisely because of this bug — every test that presses Enter to
make a second block failed there — so the instance's include list grows
here, where it can be green.
The beforeinput interception only covers the IME path. With a hardware or
synthetic keyboard, Enter arrives as a keypress instead — and
prosemirror-view's own keypress handler cancels the browser default for
cross-block selections without doing anything in their place (its
cross-parent branch skips newline characters), so Enter over a selection
spanning two blocks was a silent no-op.

Intercepting keypress too closes that hole, and the two paths now share one
`dispatchSynthesizedEnter` helper rather than repeating the flush-then-
synthesize sequence. The `domObserver` reach-through is typed against
`EditorView` instead of `typeof view`.

Test coverage goes from one path to three — keypress, beforeinput, and the
cross-block selection — and `Check Enter when selection is not empty` no
longer has to be skipped on the android instance, which is the suite-level
proof that the keypress hole is closed.

Also makes `Check Delete before shallower block` deterministic: it relied on
ArrowUp's goal-x landing on a particular side of a character boundary, which
varies with subpixel metrics and had been flaking across engines.
The popover form-submission tests exist because of Android bugs, yet only
ran on the desktop engines. The android instance is chromium, so even the
CDP composition tests run there. All 14 pass under the emulation.
…ening

These skips guard tests that drive selection/resizing with positional
mouse drags, which have no touch-emulation equivalent. They used to ship
with the test infrastructure; review pointed out they belong here, where
the android instance actually starts running the copypaste suite.
…terface

The device suite's session becomes an interface with three backends, so
tests and page helpers are written once and run against whatever a
machine can drive (activeDevices probes availability):

- browserstack.ts: the existing selenium-webdriver client, unchanged
  behaviour.
- localAndroid.ts: a local Android emulator via Playwright's first-party
  (experimental) Android support — real Chrome as a Playwright page over
  CDP, input as genuine OS events via adb shell (Playwright's
  device.input needs a companion APK; shell does not). Page-to-screen
  coordinates come from a one-time calibration tap on an app page —
  never Chrome's initial page, whose 980px virtual viewport poisons the
  mapping. The backend can press the on-screen keyboard itself, which
  no cloud channel can: pressImeActionKey drives Gboard's action key
  with a verify ladder.
- localIos.ts: Apple's safaridriver against a booted simulator (real
  iOS Safari, no tunnel — the simulator shares the host network).
  Known limitation, found empirically: safaridriver's synthetic input
  never summons the software keyboard, and mixing HID injection with an
  automation session trips Safari's "stop the current automated test
  session?" guardrail — so keyboard-gated flows don't run here yet.
  The sanctioned full-fidelity route is Appium's XCUITest driver;
  follow-up.

imeAction.device.test.ts is the previously-manual release-checklist
item as a test: Gboard's real action key submits the link popover and
focus stays in the editor. All three suites green on the emulator
(6 tests) with Chrome 124 on an API-35 image.
Real Chrome + real Gboard on a KVM-backed emulator (API 35, x86_64) on
every PR and push to main — free minutes, no credentials. Runs the same
tests/device suite as local development (DEVICE_FILTER=local-android),
including the previously-manual IME action-key check. Actions pinned per
repo policy.
@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
blocknote Error Error Sep 1, 2026 6:06am UTC
blocknote-website Error Error Sep 1, 2026 6:06am UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

'vp -C tests exec vitest' resolved through vite-plus's transitive bin
locally but not on a fresh CI install; a direct devDependency links the
bin deterministically (pinned to the workspace's vitest override).
@pkg-pr-new

pkg-pr-new Bot commented Sep 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

@blocknote/ariakit

npm i https://pkg.pr.new/@blocknote/ariakit@3034

@blocknote/code-block

npm i https://pkg.pr.new/@blocknote/code-block@3034

@blocknote/core

npm i https://pkg.pr.new/@blocknote/core@3034

@blocknote/diagram-block

npm i https://pkg.pr.new/@blocknote/diagram-block@3034

@blocknote/mantine

npm i https://pkg.pr.new/@blocknote/mantine@3034

@blocknote/math-block

npm i https://pkg.pr.new/@blocknote/math-block@3034

@blocknote/react

npm i https://pkg.pr.new/@blocknote/react@3034

@blocknote/server-util

npm i https://pkg.pr.new/@blocknote/server-util@3034

@blocknote/shadcn

npm i https://pkg.pr.new/@blocknote/shadcn@3034

@blocknote/xl-ai

npm i https://pkg.pr.new/@blocknote/xl-ai@3034

@blocknote/xl-docx-exporter

npm i https://pkg.pr.new/@blocknote/xl-docx-exporter@3034

@blocknote/xl-email-exporter

npm i https://pkg.pr.new/@blocknote/xl-email-exporter@3034

@blocknote/xl-multi-column

npm i https://pkg.pr.new/@blocknote/xl-multi-column@3034

@blocknote/xl-odt-exporter

npm i https://pkg.pr.new/@blocknote/xl-odt-exporter@3034

@blocknote/xl-pdf-exporter

npm i https://pkg.pr.new/@blocknote/xl-pdf-exporter@3034

commit: e407339

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://TypeCellOS.github.io/BlockNote/pr-preview/pr-3034/

Built to branch gh-pages at 2026-09-01 06:23 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

…targets

safaridriver turned out structurally unable to run keyboard-gated flows:
its input is synthetic at the WebKit layer (never summons the software
keyboard), and injecting real HID during its session trips Safari's
"stop the current automated test session?" guardrail. The sanctioned
stack is Appium's XCUITest driver — WebDriverAgent owns the HID, so the
keyboard appears even on a headless simulator, and `mobile: tap` is the
same channel the BrowserStack iOS backend uses, so the gesture layer's
chrome-offset ladders apply unchanged (iOS now routes through them for
every kind; Appium's web-context clicks are synthetic there too,
nativeWebTap included).

Setup boots (or reuses) a simulator and spawns Appium — an npm
devDependency whose xcuitest driver Appium discovers; postinstalls stay
off via allowBuilds, verified working. Appium needs an even-numbered
Node (.node-version's 24 qualifies; the check message points there).

CI gains a macos-15 job running the same suite. Local matrix: 11 tests
green across the API-35 emulator (Chrome 124 + Gboard) and the iPhone
simulator (real iOS Safari).
The README now leads with the target matrix (local-android, local-ios,
browserstack), what each uniquely reaches, and how to run any subset
locally; the iOS automation facts gained the safaridriver-guardrail and
synthetic-click findings so nobody re-walks that path; the former manual
IME checklist item is marked as automated by imeAction.device.test.ts.
The setup's env-var handoff never reached vitest's workers in CI (global
setup and workers don't share an environment), and the discovery regex
missed the UDID's closing paren, so sessions saw no booted simulator.
Sessions now discover the booted device themselves with a short retry;
the setup boots via 'bootstatus -b' — boots if needed, returns promptly
when already up — with no simulator state strings to pattern-match.
The layering policy from the testing-foundation discussion, as decision
rules: new mobile tests default to end-to-end/mobile/; the device suite
is only for behavior that emulation fakes; paired tests where possible;
flows-through-real-input, never editor-logic details.
The Booted-state polling raced on slow CI runners (one green run, one
'no booted simulator' on a docs-only push). The setup now writes the
chosen UDID to .artifacts/.booted-simulator and sessions read it —
deterministic, no cross-process environment, no state polling.
The #3001 test now goes through the keyboard itself where the backend
can reach it (the same bottom-right-key ladder as the action key),
closing the last emulated-only route — and proves the delivery: the page
must see an IME-mediated keydown 229, not the bare keydown 13 a
synthesized key event produces.

Doing so surfaced that the IME's Enter has two genuine variants: phone
Gboard emits `beforeinput: insertParagraph` (the route the beforeinput
interception handles), the emulator's AOSP LatinIME emits 229 followed
by a real keydown (the route the keypress interception handles). The
test documents both and asserts the invariant, so the suite now
validates the Android Enter fix against a second IME family.
The emulator/simulator targets reach everything the cloud backend did and
more (they can press the on-screen keyboard; no cloud channel can), run as
free per-PR CI, and need no credentials or tunnel. What only real hardware
has left is OEM keyboards, which stays a manual release-checklist item.
The backend stays revivable behind the unchanged session interface (PR #3034).
@YousefED

YousefED commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Folded into the stack: the emulator/simulator suite now is the test-infra layer (#3029, with the device tests landing in #3030/#3031 alongside the fixes they verify). The final commits here also removed the BrowserStack backend entirely — the local targets reach everything it did and more (they can press the on-screen keyboard; no cloud channel can), as free per-PR CI with no credentials or tunnel. This PR's history is the reference for reviving the BrowserStack backend behind the same session interface if real-hardware coverage (OEM keyboards) ever earns automation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant