diff --git a/.github/workflows/change-scope.yml b/.github/workflows/change-scope.yml
index edfd281..872d494 100644
--- a/.github/workflows/change-scope.yml
+++ b/.github/workflows/change-scope.yml
@@ -3,6 +3,8 @@ name: Detect affected platforms
on:
workflow_call:
outputs:
+ library:
+ value: ${{ jobs.scope.outputs.library }}
ios:
value: ${{ jobs.scope.outputs.ios }}
android:
@@ -10,22 +12,50 @@ on:
permissions:
contents: read
+ actions: read
jobs:
scope:
runs-on: ubuntu-latest
- timeout-minutes: 5
+ timeout-minutes: 140
outputs:
+ library: ${{ steps.detect.outputs.library }}
ios: ${{ steps.detect.outputs.ios }}
android: ${{ steps.detect.outputs.android }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
+ - name: Fingerprint merged code and test inputs
+ id: inputs
+ env:
+ PR_BASE: ${{ github.event.pull_request.base.sha }}
+ PR_TITLE: ${{ github.event.pull_request.title }}
+ run: node scripts/ci-reuse.mjs prepare
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ci-inputs-${{ steps.inputs.outputs.key }}
+ path: /tmp/keyflow-ci-inputs.txt
+ retention-days: 7
+ overwrite: true
+ - name: Reuse or await matching successful workflow
+ id: reuse
+ env:
+ GH_TOKEN: ${{ github.token }}
+ EVENT_NAME: ${{ github.event_name }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ INPUT_KEY: ${{ steps.inputs.outputs.key }}
+ run: node scripts/ci-reuse.mjs
- name: Detect changed platforms
id: detect
env:
+ REUSED: ${{ steps.reuse.outputs.reused }}
EVENT_NAME: ${{ github.event_name }}
- BASE_SHA: ${{ github.event.pull_request.base.sha }}
- HEAD_SHA: ${{ github.event.pull_request.head.sha }}
- run: node scripts/ci-scope.mjs
+ BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
+ HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
+ run: |
+ if [ "$REUSED" = true ]; then
+ printf 'library=false\nios=false\nandroid=false\n' >> "$GITHUB_OUTPUT"
+ else
+ node scripts/ci-scope.mjs
+ fi
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 742fce4..164ce7e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,15 +7,22 @@ on:
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
+# Keep matching earlier runs alive so documentation updates can await their result.
concurrency:
- group: library-${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+ group: ${{ github.workflow }}-${{ github.run_id }}
+ cancel-in-progress: false
permissions:
contents: read
+ actions: read
jobs:
+ scope:
+ uses: ./.github/workflows/change-scope.yml
+
check:
+ needs: scope
+ if: ${{ !cancelled() && (needs.scope.result != 'success' || needs.scope.outputs.library != 'false') }}
name: Format, types, tests, and package
runs-on: ubuntu-latest
timeout-minutes: 15
diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml
index ff5f63e..9936fd8 100644
--- a/.github/workflows/native.yml
+++ b/.github/workflows/native.yml
@@ -8,12 +8,14 @@ on:
schedule:
- cron: '20 7 * * 1'
+# Keep matching earlier runs alive so documentation updates can await their result.
concurrency:
- group: native-${{ github.ref }}
- cancel-in-progress: true
+ group: ${{ github.workflow }}-${{ github.run_id }}
+ cancel-in-progress: false
permissions:
contents: read
+ actions: read
jobs:
scope:
@@ -120,6 +122,9 @@ jobs:
matrix:
include: ${{ fromJSON(github.event_name == 'schedule' && '[{"label":"Android phone","profile":"pixel_6","api":36},{"label":"Android tablet","profile":"pixel_c","api":36},{"label":"Android older API","profile":"pixel_6","api":35}]' || '[{"label":"Android phone","profile":"pixel_6","api":36},{"label":"Android tablet","profile":"pixel_c","api":36}]') }}
steps:
+ - name: No android changes
+ if: ${{ needs.android.result == 'skipped' }}
+ run: echo "Android checks are unaffected or reuse verified successful results; see scope summary."
- name: Require successful shared build
if: ${{ needs.android.result != 'success' && needs.android.result != 'skipped' }}
run: exit 1
@@ -174,6 +179,9 @@ jobs:
matrix:
include: ${{ fromJSON(github.event_name == 'schedule' && '[{"label":"iPhone","device":"iPhone 17 Pro"},{"label":"iPad","device":"iPad Pro 11-inch (M5)"},{"label":"Large iPhone","device":"iPhone 17 Pro Max"},{"label":"Large iPad","device":"iPad Pro 13-inch (M5)"}]' || '[{"label":"iPhone","device":"iPhone 17 Pro"},{"label":"iPad","device":"iPad Pro 11-inch (M5)"}]') }}
steps:
+ - name: No ios changes
+ if: ${{ needs.ios.result == 'skipped' }}
+ run: echo "iOS checks are unaffected or reuse verified successful results; see scope summary."
- name: Require successful shared build
if: ${{ needs.ios.result != 'success' && needs.ios.result != 'skipped' }}
run: exit 1
diff --git a/README.md b/README.md
index 70f4fe7..ba22a13 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ Real iPad example captures. These themes use the public API; Story Studio is exa
## Get started
-The package is not published to npm yet. Run the example from this repository:
+The first npm release is being prepared. Until it is published, install a [local package in your app](docs/api.md#compatibility-and-local-installation), or run the example from this repository:
```sh
git clone https://github.com/MeliValesca/react-native-keyflow.git
@@ -39,7 +39,7 @@ stim ios
# Or: stim android
```
-The example uses **Expo SDK 57, React Native 0.86.3, and React 19.2.3**. Keyflow requires Expo Modules and a native development or production build; Expo Go and web are not supported. The example targets iOS 16.4+ and Android API 24+. See [compatibility and local installation](docs/api.md#compatibility-and-local-installation).
+The example uses **Expo SDK 57, React Native 0.86.3, and React 19.2.3**. Keyflow requires Expo Modules and a native development or production build; Expo Go is not supported. On web, render your own fallback instead of `KeyflowTextInput`; see [web fallback](docs/api.md#web-fallback). The example targets iOS 16.4+ and Android API 24+. See [compatibility and local installation](docs/api.md#compatibility-and-local-installation).
### Your first input
@@ -218,16 +218,28 @@ Watch the MP4s: [iPad transitions](docs/media/ios-transitions.mp4) · [Android t
### Long press and accent selection
-| iPad · Hold and move between accents | Android phone · Hold for accents and shortcuts |
+| iPad · Hold and slide between accents | Android phone · Hold and slide between accents |
- |
- |
+ |
+ |
Watch the MP4s: [iPad accent selection](docs/media/ios-accents.mp4) · [Android long press](docs/media/android-accents.mp4).
-The previews are reduced to 10 fps; the MP4s retain the recordings’ timing. These are examples of Keyflow’s current behavior, not native-parity or physical-device performance benchmarks.
+### Space-bar trackpad
+
+
+| iPad · Hold space, then move | Android phone · Slide on space |
+
+ |
+ |
+
+
+
+Watch the MP4s: [iPad trackpad](docs/media/ios-trackpad.mp4) · [Android trackpad](docs/media/android-trackpad.mp4).
+
+The Android accent and trackpad GIFs use 50 fps; the iOS versions use 25 fps; the transition previews use 10 fps. The MP4s retain the recordings’ timing. These are examples of Keyflow’s current behavior, not native-parity or physical-device performance benchmarks.
The clips demonstrate the named interactions only. The other behaviors in the table are covered by the relevant [native and app test suites](docs/coverage.md), with device-review limits documented there.
@@ -301,7 +313,7 @@ Use `keyboardMode="system"` for the installed keyboard and whatever features its
### Integration limits
- `KeyflowTextInput` is single-line and native-owned. It has `defaultValue`, not a controlled `value`, and does not expose the complete React Native `TextInput` API—including secure-entry and semantic/AutoFill configuration props.
-- Supported preview peers are Expo SDK 57, React Native 0.86.x (0.86.3+) and React 19.2.3+. Earlier combinations are not claimed as supported. A native build with Expo Modules is required; Expo Go and web are unsupported.
+- Supported preview peers are Expo SDK 57, React Native 0.86.x (0.86.3+) and React 19.2.3+. Earlier combinations are not claimed as supported. A native build with Expo Modules is required; Expo Go is unsupported. On web, rendering `KeyflowTextInput` throws; provide your own [fallback](docs/api.md#web-fallback).
- Keyflow is an **in-app keyboard component**, not a system-wide keyboard extension/IME that users can install for other apps.
See [automated coverage and remaining manual checks](docs/coverage.md) for the precise boundary of the CI guarantees.
diff --git a/docs/api.md b/docs/api.md
index 77a7420..f1505a1 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -22,6 +22,24 @@ npm install /tmp/react-native-keyflow.tgz
Use your app’s package manager if it differs, then rebuild its native app. To generate the standalone example repository instead, run `corepack yarn example:export` from Keyflow.
+## Web fallback
+
+The custom keyboard supports iOS and Android only. Importing the component is guarded against loading its native view on web, but rendering it on an unsupported platform throws. Choose your own fallback before rendering:
+
+```tsx
+import { Platform, TextInput } from 'react-native';
+import { KeyflowTextInput } from 'react-native-keyflow';
+
+export function CrossPlatformInput() {
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
+ return ;
+ }
+ return ;
+}
+```
+
+The fallback uses the browser’s normal input behavior; Keyflow’s keyboard theme does not apply to it.
+
## Input API
```tsx
@@ -81,6 +99,8 @@ await input.current?.setKeyboardMode('system');
await input.current?.focus();
```
+These promises resolve after the native command is applied, not after the keyboard’s presentation or dismissal animation finishes. Use frame updates and the expected visible state when coordinating UI or writing tests.
+
Await `setKeyboardMode` before focusing. A mode change preserves text and selection, cancels active holds, and resets the custom keyboard page. If `keyboardMode` is controlled, keep its state synchronized through `onKeyboardModeChange`.
System mode delegates layout, languages, composition and settings to the user’s installed keyboard. Its visibility and floating/hardware-keyboard configuration remain controlled by the OS and IME. Keyflow’s colors and fonts cannot reskin that system keyboard.
@@ -95,6 +115,7 @@ Use `KeyflowAvoidingView` for the shared iOS/Android integration, with the activ
keyboardVerticalOffset={headerOffset}
enabled
style={{ flex: 1 }}
+>
{/* Your content and KeyflowTextInput with onKeyboardFrameChange={setFrame} */}
```
@@ -137,14 +158,16 @@ The current native implementations are not fully aligned: Android’s active Cap
### Surface and material
```tsx
-keyboardTheme={{
- keyboard: {
- background: '#16324F',
- backgroundOpacity: 0.35,
- keyOpacity: 0.7,
- material: { type: 'raised', depth: 4, shadowColor: '#102030' },
- },
-}}
+
```
- `backgroundOpacity` (0–1) replaces the panel color’s alpha.
diff --git a/docs/media/README.md b/docs/media/README.md
index 61ae248..7daa101 100644
--- a/docs/media/README.md
+++ b/docs/media/README.md
@@ -1,28 +1,30 @@
# README media
-The themed Keyflow captures were made on 2026-09-13 from the working tree at `c4183a6`. The four `default-*.jpg` previews reuse the earlier PR evidence described below. These demonstrate the current implementation, not a comparison against Apple/Gboard or a claim of physical-device frame pacing.
-
-| Files | Device and action |
-| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
-| `ios-transitions.mp4` / `.gif` | Stim-owned iPad Pro 11-inch (M5), iPadOS 26.5. Story Studio composer: focus, dismiss, repeat, focus again |
-| `ios-accents.mp4` / `.gif` | Same iPad. Hold E, then hold e and move to another accent |
-| `android-transitions.mp4` / `.gif` | Stim Android phone emulator, Android API 36, 1080 × 2400. Story Studio composer: Back dismissal, refocus, repeat |
-| `android-accents.mp4` / `.gif` | Same Android phone. Two stationary long presses on E/e expose accents and the number shortcut |
-| `studio.jpg` | iPad Story Studio keyboard after accent input |
-| `transparent.jpg` | iPad transparency example, default 35% panel and 70% keys, with typed text |
-| `custom-font.jpg` | iPad custom-font example with bundled Quicksand SemiBold and typed text |
+The themed stills and transition captures were made on 2026-09-13 from the working tree at `c4183a6`. Accent and trackpad clips were recorded again on 2026-09-14 using the keyboard implementation merged in `63227a8`. The four `default-*.jpg` previews reuse the earlier PR evidence described below. These demonstrate the current implementation, not a comparison against Apple/Gboard or a claim of physical-device frame pacing.
+
+| Files | Device and action |
+| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
+| `ios-transitions.mp4` / `.gif` | Stim-owned iPad Pro 11-inch (M5), iPadOS 26.5. Story Studio composer: focus, dismiss, repeat, focus again |
+| `ios-accents.mp4` / `.gif` | Same iPad. Two holds on e, sliding across columns and into the other accent row |
+| `android-transitions.mp4` / `.gif` | Stim Android phone emulator, Android API 36, 1080 × 2400. Story Studio composer: Back dismissal, refocus, repeat |
+| `android-accents.mp4` / `.gif` | Same Android phone. Hold E, then slide diagonally and horizontally across accent choices |
+| `ios-trackpad.mp4` / `.gif` | Same iPad. Hold space twice and move the cursor left/right; key labels fade and return on release |
+| `android-trackpad.mp4` / `.gif` | Same Android phone. One continuous space gesture moves the cursor left, right and left again while preserving pressed styling |
+| `studio.jpg` | iPad Story Studio keyboard after accent input |
+| `transparent.jpg` | iPad transparency example, default 35% panel and 70% keys, with typed text |
+| `custom-font.jpg` | iPad custom-font example with bundled Quicksand SemiBold and typed text |
## Processing
- iOS recording: `xcrun simctl io recordVideo --codec=h264 ` while XCTest drives the visible app.
-- Android recording: `adb -s shell screenrecord --bit-rate 4000000 ` while input events drive the visible app.
+- Android recording: `adb -s shell screenrecord` at 4–6 Mbps while input events drive the visible app. The new gesture recordings use one continuous touch stream sampled approximately every 16 ms; note text is entered through the visible custom keys.
- MP4s trim preparation/navigation, remove audio, and resize to 720 px wide with H.264, CRF 24 and fast-start metadata. Playback timing is unchanged.
-- GIFs are 10 fps, 96-color previews. Transitions show the full screen at 280 px wide; accent previews crop to the bottom 45% at 360 px wide so the popup is legible.
+- Android accent and trackpad GIFs are 50 fps; iOS versions remain 25 fps. Both use 128 colors at 480 px wide. The Android exports use the original recordings directly to preserve captured frames. The export frame rate does not imply that the source captured a new frame at every interval. They crop the bottom 45% on iPad and 50% on Android to include the input, popup and keyboard. Existing transition GIFs remain 10 fps, 96 colors and 280 px wide. Frames are sampled from the recordings without motion interpolation or playback speed changes.
- JPGs crop the bottom 40% of actual screenshots, resized to 900 px wide. No keys, colors, text, or backgrounds were reconstructed.
The original screenshot capture actions completed successfully. This capture harness is not part of the regression-test count. These small documentation assets are outside the npm package’s `files` allowlist.
-To reproduce, launch the example with Stim, open Story Studio / Transparency / Custom app font, and perform the actions above. Keep media labels explicit about platform, form factor and capture processing.
+To reproduce, launch the example with Stim, open Story Studio / Transparency / Custom app font, and perform the actions above. For trackpad captures, enter a short sentence through the custom keyboard, then move the cursor in both directions on space. On iOS, hold space before moving. Keep media labels explicit about platform, form factor and capture processing.
## Default-layout previews
diff --git a/docs/media/android-accents.gif b/docs/media/android-accents.gif
index a0ea934..4689c50 100644
Binary files a/docs/media/android-accents.gif and b/docs/media/android-accents.gif differ
diff --git a/docs/media/android-accents.mp4 b/docs/media/android-accents.mp4
index 078dda2..54f7c47 100644
Binary files a/docs/media/android-accents.mp4 and b/docs/media/android-accents.mp4 differ
diff --git a/docs/media/android-trackpad.gif b/docs/media/android-trackpad.gif
new file mode 100644
index 0000000..4886605
Binary files /dev/null and b/docs/media/android-trackpad.gif differ
diff --git a/docs/media/android-trackpad.mp4 b/docs/media/android-trackpad.mp4
new file mode 100644
index 0000000..6a6fd3d
Binary files /dev/null and b/docs/media/android-trackpad.mp4 differ
diff --git a/docs/media/ios-accents.gif b/docs/media/ios-accents.gif
index fdf4fbd..7a4425b 100644
Binary files a/docs/media/ios-accents.gif and b/docs/media/ios-accents.gif differ
diff --git a/docs/media/ios-accents.mp4 b/docs/media/ios-accents.mp4
index 1667500..d9c4acf 100644
Binary files a/docs/media/ios-accents.mp4 and b/docs/media/ios-accents.mp4 differ
diff --git a/docs/media/ios-trackpad.gif b/docs/media/ios-trackpad.gif
new file mode 100644
index 0000000..89c1268
Binary files /dev/null and b/docs/media/ios-trackpad.gif differ
diff --git a/docs/media/ios-trackpad.mp4 b/docs/media/ios-trackpad.mp4
new file mode 100644
index 0000000..dd51390
Binary files /dev/null and b/docs/media/ios-trackpad.mp4 differ
diff --git a/docs/testing.md b/docs/testing.md
index f8531dd..2438f37 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -17,11 +17,13 @@ Device scripts require a running example and the appropriate device/session argu
The repository includes two automatic GitHub Actions workflows and an optional manual comparison workflow:
-- **Library checks** runs formatting, both TypeScript projects, Jest, visual-helper unit tests, source-integrity checks, and package generation on every pull request and push to `main`.
+- **Library checks** runs formatting, both TypeScript projects, Jest, visual-helper unit tests, source-integrity checks, and package generation on pull requests and pushes to `main` with code changes.
- **Native builds and device tests** builds the Android example/test APKs and iOS app/XCTest bundles once, then shares them with parallel phone and tablet jobs. Device jobs install those artifacts and run the recorded baseline test inventory without compiling again. Focused Android glyph and cross-platform modifier-state regressions supplement that inventory; see [coverage](coverage.md) for the exact retained suite. There is no optional expanded suite. Platform filtering avoids unrelated work; weekly runs add larger iOS devices and an older Android API. The seven protected check names stay unchanged.
- **Native keyboard regression** is a manual workflow for the real simulator/emulator comparisons. It builds and launches the current checkout, runs the phone feature suites, optionally runs both tablet matrices, and uploads screenshots, videos, metrics, reports, and Stim logs for 30 days.
-The device workflow needs a macOS self-hosted runner labeled `keyflow-mobile`. The runner must have Xcode, Android Studio/SDK, CocoaPods, Stim, and `agent-device`; Stim-owned iPhone, iPad, Android phone, and Android tablet devices; Gboard configured on Android; and the four `agent-device` sessions named by the workflow inputs. Supply both tablet device IDs when tablet tests are enabled. The Android tablet suite prepares Gboard and requires its real keys to be visible. The Android tablet must use an actual tablet hardware profile such as Pixel Tablet. A phone AVD with an overridden resolution is invalid because Gboard can retain its phone, external-keyboard, or floating-mode policy. Keep these reference devices on fixed OS, display-scale, locale, appearance, and keyboard versions so a baseline change reflects code rather than runner drift. The workflow serializes all device runs through one concurrency group because simulators, Metro, and keyboard state are shared resources.
+CI uses isolated GitHub-hosted runners for each job. Android device jobs run on
+Ubuntu with KVM; iOS device jobs run on macOS. Local visual comparisons still need
+configured reference keyboards and the devices reported by Stim.
Use **Native keyboard regression → Run workflow** in GitHub Actions and supply the iPhone Simulator UDID and Android emulator serial shown by `stim status`. Disable `run_tablets` while servicing a tablet runner. A device-suite failure still uploads the collected artifacts through the `always()` steps.
@@ -79,3 +81,53 @@ or iOS implementation. Keep the patch file tracked with the lockfile so immutabl
CI installs apply it. When upgrading screens, verify that upstream serializes
listener initialization before removing the patch. The device suites exercise
cold launches, background/resume, navigation, and customization on phone and tablet.
+
+## Which changes run tests?
+
+PRs use the full branch diff against the base branch. Pushes to `main` compare
+before and after the push, including all commits in that push.
+
+| Changed files | Suites |
+| -------------------------------------------------------------------------------- | ---------------------------------------------- |
+| Markdown or images/videos under `docs/media/` only | No code suites |
+| iOS native code or iOS-specific test/build helpers | Library checks and iOS phone/tablet suites |
+| Android native code or Android-specific test helpers | Library checks and Android phone/tablet suites |
+| Shared source, example code/assets, dependencies, CI workflows, or unknown paths | All suites |
+
+Documentation changes mixed with code do not broaden the code’s platform scope.
+`docs/package.json` is a dependency input, not documentation, so it runs all suites.
+Manual and scheduled runs always run everything. An empty diff, initial push, or
+failed scope detection never silently skips tests.
+
+Required check names and branch protection stay unchanged. Unaffected build/library
+jobs are skipped; device check entries report that no relevant changes were found
+without installing dependencies, building binaries, or launching a simulator.
+
+### Overlapping pushes
+
+PR workflows fingerprint the merged file contents (including file modes), excluding
+Markdown and documentation media. The PR base commit and title are also included.
+Within the same PR and workflow, a successful run with identical inputs can be
+reused. If it is still running, the new scope job waits up to 130 minutes for its
+successful completion. The scope summary links to the original evidence.
+
+Missing or expired evidence, API errors, failures, cancellation, and timeout all
+fall back to running tests. Fingerprint artifacts expire after seven days; lookup
+is limited to the most recent 100 PR runs of the workflow. Manual, scheduled, and
+main runs do not reuse results. Changes to code, dependencies, tests, CI, or the PR
+base invalidate reuse. Reuse currently applies to the whole workflow, not separate
+platform results. Required checks retain their names on the latest commit.
+
+Each PR run has its own concurrency group so a documentation update cannot cancel
+the checks it needs to await. Superseded code runs also finish; automatic cancellation
+of those runs is not currently implemented. This avoids restarting builds for
+README/GIF updates once a matching run has published its input fingerprint.
+
+Every `main` push has its own concurrency group in both workflows. Newer pushes
+cannot cancel or replace older running or pending pipelines. They may execute
+in parallel or wait for available GitHub runners; completion order is not guaranteed.
+Manual and scheduled runs also have separate groups.
+
+This deliberately avoids GitHub’s default single-pending-run concurrency queue,
+which can replace an older pending run even with `cancel-in-progress: false`.
+See [GitHub’s concurrency documentation](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency).
diff --git a/example/src/testing/settledKeyboard.ts b/example/src/testing/settledKeyboard.ts
index cbe2044..8635288 100644
--- a/example/src/testing/settledKeyboard.ts
+++ b/example/src/testing/settledKeyboard.ts
@@ -1,6 +1,6 @@
import type { KeyflowKeyboardMetrics } from 'react-native-keyflow/testing';
-/** Wait for presentation and stable geometry, independently of layout correctness. */
+/** Await stable, unobscured input geometry; preserve persistent overlap for diagnosis. */
export async function settledKeyboard(
read: () => Promise<{ visible: boolean; metrics: KeyflowKeyboardMetrics }>,
): Promise {
@@ -8,13 +8,14 @@ export async function settledKeyboard(
const deadline = started + 5000;
let previous = '';
let stableSince = Date.now();
+ let stable = false;
let last: Awaited> | undefined;
while (Date.now() < deadline) {
last = await read();
const { metrics, visible } = last;
const ready =
visible &&
- metrics.focused &&
+ metrics.focused === true &&
Number.isFinite(metrics.editorBottom) &&
Number.isFinite(metrics.screenY);
const signature = JSON.stringify([
@@ -25,11 +26,16 @@ export async function settledKeyboard(
]);
if (!ready || signature !== previous) stableSince = Date.now();
previous = signature;
- // Preserve the 600 ms presentation allowance from the green 08d1802
- // transparency check; stable geometry alone can be sampled too early.
- if (ready && Date.now() - started >= 600 && Date.now() - stableSince >= 300)
- return metrics;
+ stable =
+ ready && Date.now() - started >= 600 && Date.now() - stableSince >= 300;
+ // A temporarily stable overlap can precede a delayed avoiding-view update.
+ // Wait within the same deadline; never relax the caller's 1-point tolerance.
+ const unobscured = metrics.editorBottom! <= metrics.screenY! + 1;
+ if (stable && unobscured) return metrics;
await new Promise((resolve) => setTimeout(resolve, 100));
}
+ // Let the caller report the actual persistent overlap, rather than hide it
+ // behind a generic synchronization error.
+ if (stable && last) return last.metrics;
throw new Error(`Keyboard did not settle: ${JSON.stringify(last)}`);
}
diff --git a/package.json b/package.json
index 85ff708..703b93a 100644
--- a/package.json
+++ b/package.json
@@ -64,7 +64,7 @@
"test:visual:customization-layouts:report": "node scripts/visual/customization-layouts-report.mjs",
"test:languages:ios": "node scripts/test-language-resolution.mjs",
"test:visual:languages": "node scripts/visual/languages.mjs",
- "test:ci": "node --test scripts/ci/workflow.test.mjs scripts/launch-ios-ci.test.mjs scripts/ci-scope.test.mjs scripts/release.test.mjs",
+ "test:ci": "node --test scripts/ci-reuse.test.mjs scripts/ci/workflow.test.mjs scripts/launch-ios-ci.test.mjs scripts/ci-scope.test.mjs scripts/release.test.mjs",
"prepack": "yarn build",
"lint": "eslint src example/src --max-warnings 0",
"hooks:install": "lefthook install",
diff --git a/scripts/ci-reuse.mjs b/scripts/ci-reuse.mjs
new file mode 100644
index 0000000..ed155dc
--- /dev/null
+++ b/scripts/ci-reuse.mjs
@@ -0,0 +1,118 @@
+import { createHash } from 'node:crypto';
+import { execFileSync } from 'node:child_process';
+import { appendFileSync, writeFileSync } from 'node:fs';
+import { pathToFileURL } from 'node:url';
+
+export function fingerprint(tree, title = '', base = '') {
+ const entries = tree
+ .split('\0')
+ .filter(Boolean)
+ .filter((entry) => {
+ const path = entry.slice(entry.indexOf('\t') + 1);
+ return (
+ !/\.md$/.test(path) &&
+ !/^docs\/media\/.*\.(gif|mp4|png|jpe?g|webp|svg)$/.test(path)
+ );
+ });
+ return createHash('sha256')
+ .update(JSON.stringify([entries.sort(), title, base]))
+ .digest('hex');
+}
+
+export function eligible(run, current, pr) {
+ return (
+ run.id < current.id &&
+ run.event === 'pull_request' &&
+ run.repository?.id === current.repository.id &&
+ run.workflow_id === current.workflow_id &&
+ run.pull_requests?.some((item) => item.number === pr) &&
+ (run.status !== 'completed' || run.conclusion === 'success')
+ );
+}
+
+export async function waitForSuccess(
+ read,
+ sleep,
+ now = Date.now,
+ limit = 130 * 60_000,
+) {
+ const deadline = now() + limit;
+ while (now() < deadline) {
+ const run = await read();
+ if (run.status === 'completed') return run.conclusion === 'success';
+ await sleep(30_000);
+ }
+ return false;
+}
+
+export async function reusableRun(api, runId, pr, key, wait = waitForSuccess) {
+ const current = api(`actions/runs/${runId}`);
+ const runs = api(
+ `actions/workflows/${current.workflow_id}/runs?event=pull_request&per_page=100`,
+ ).workflow_runs;
+ for (const run of runs.filter((item) => eligible(item, current, pr))) {
+ const artifacts = api(
+ `actions/runs/${run.id}/artifacts?per_page=100`,
+ ).artifacts;
+ if (
+ !artifacts.some(
+ (item) => !item.expired && item.name === `ci-inputs-${key}`,
+ )
+ )
+ continue;
+ console.log(`Matching test inputs: ${run.html_url}`);
+ const success = await wait(
+ () => api(`actions/runs/${run.id}`),
+ (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
+ );
+ if (!success) break;
+ return run;
+ }
+ return null;
+}
+
+async function main() {
+ const env = process.env;
+ if (process.argv[2] === 'prepare') {
+ const key = fingerprint(
+ execFileSync('git', ['ls-tree', '-r', '-z', 'HEAD'], {
+ encoding: 'utf8',
+ }),
+ env.PR_TITLE,
+ env.PR_BASE,
+ );
+ writeFileSync('/tmp/keyflow-ci-inputs.txt', key + '\n');
+ appendFileSync(env.GITHUB_OUTPUT, `key=${key}\n`);
+ return;
+ }
+ if (env.EVENT_NAME !== 'pull_request') return;
+ const api = (path) =>
+ JSON.parse(
+ execFileSync('gh', ['api', `repos/${env.GITHUB_REPOSITORY}/${path}`], {
+ encoding: 'utf8',
+ timeout: 30_000,
+ }),
+ );
+ try {
+ const run = await reusableRun(
+ api,
+ env.GITHUB_RUN_ID,
+ Number(env.PR_NUMBER),
+ env.INPUT_KEY,
+ );
+ if (run) {
+ appendFileSync(env.GITHUB_OUTPUT, 'reused=true\n');
+ appendFileSync(
+ env.GITHUB_STEP_SUMMARY,
+ `Reused successful checks for identical code and CI inputs: ${run.html_url}\n`,
+ );
+ }
+ } catch (error) {
+ console.warn(
+ `Could not verify earlier checks; running tests: ${error.message}`,
+ );
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
+ await main();
diff --git a/scripts/ci-reuse.test.mjs b/scripts/ci-reuse.test.mjs
new file mode 100644
index 0000000..ad043db
--- /dev/null
+++ b/scripts/ci-reuse.test.mjs
@@ -0,0 +1,171 @@
+import { spawnSync } from 'node:child_process';
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ fingerprint,
+ eligible,
+ waitForSuccess,
+ reusableRun,
+} from './ci-reuse.mjs';
+
+const tree = '100644 blob abc\tsrc/index.ts\0';
+test('documentation-only updates preserve the merged input fingerprint', () => {
+ assert.equal(
+ fingerprint(tree + '100644 blob a\tREADME.md\0'),
+ fingerprint(tree + '100644 blob b\tdocs/media/android.gif\0'),
+ );
+});
+for (const path of [
+ 'ios/View.swift',
+ 'android/View.kt',
+ 'yarn.lock',
+ '.github/workflows/native.yml',
+ 'scripts/ci-reuse.mjs',
+ 'example/App.tsx',
+ 'docs/package.json',
+]) {
+ test(`changes to ${path} invalidate reuse`, () => {
+ assert.notEqual(
+ fingerprint(tree + `100644 blob a\t${path}\0`),
+ fingerprint(tree + `100644 blob b\t${path}\0`),
+ );
+ });
+}
+test('file deletion, modes and PR titles invalidate reuse', () => {
+ assert.notEqual(fingerprint(tree), fingerprint(''));
+ assert.notEqual(
+ fingerprint(tree),
+ fingerprint(tree.replace('100644', '100755')),
+ );
+ assert.notEqual(fingerprint(tree, 'valid'), fingerprint(tree, 'invalid'));
+});
+const current = { id: 20, repository: { id: 1 }, workflow_id: 2 };
+const run = {
+ ...current,
+ id: 19,
+ event: 'pull_request',
+ pull_requests: [{ number: 3 }],
+ status: 'completed',
+ conclusion: 'success',
+};
+test('only older matching PR/repository/workflow runs are eligible', () => {
+ assert.equal(eligible(run, current, 3), true);
+ for (const patch of [
+ { id: 20 },
+ { id: 21 },
+ { repository: { id: 9 } },
+ { workflow_id: 9 },
+ { event: 'push' },
+ { pull_requests: [] },
+ { conclusion: 'failure' },
+ { conclusion: 'cancelled' },
+ { conclusion: 'skipped' },
+ ]) {
+ assert.equal(Boolean(eligible({ ...run, ...patch }, current, 3)), false);
+ }
+});
+test('running checks must finish successfully before reuse', async () => {
+ const states = [
+ { status: 'in_progress' },
+ { status: 'completed', conclusion: 'success' },
+ ];
+ let sleeps = 0;
+ assert.equal(
+ await waitForSuccess(
+ () => states.shift(),
+ async () => {
+ sleeps++;
+ },
+ ),
+ true,
+ );
+ assert.equal(sleeps, 1);
+});
+for (const conclusion of ['failure', 'cancelled', 'timed_out', 'skipped']) {
+ test(`${conclusion} results are never reused`, async () => {
+ assert.equal(
+ await waitForSuccess(
+ () => ({ status: 'completed', conclusion }),
+ async () => {},
+ ),
+ false,
+ );
+ });
+}
+test('a stuck run has a bounded wait and falls back to executing tests', async () => {
+ let time = 0;
+ assert.equal(
+ await waitForSuccess(
+ () => ({ status: 'in_progress' }),
+ async (ms) => {
+ time += ms;
+ },
+ () => time,
+ 60_000,
+ ),
+ false,
+ );
+ assert.equal(time, 60_000);
+});
+
+test('base branch updates require new checks even with an identical final tree', () => {
+ assert.notEqual(
+ fingerprint(tree, 'title', 'base1'),
+ fingerprint(tree, 'title', 'base2'),
+ );
+});
+
+function fakeApi(artifacts, previous = run) {
+ return (path) => {
+ if (path === 'actions/runs/20') return current;
+ if (path.startsWith('actions/workflows/'))
+ return { workflow_runs: [previous] };
+ if (path.includes('/artifacts?')) return { artifacts };
+ if (path === 'actions/runs/19') return previous;
+ throw new Error(`Unexpected request ${path}`);
+ };
+}
+
+test('a published matching fingerprint plus successful completion permits reuse', async () => {
+ assert.equal(
+ await reusableRun(
+ fakeApi([{ name: 'ci-inputs-key', expired: false }]),
+ 20,
+ 3,
+ 'key',
+ ),
+ run,
+ );
+});
+for (const artifacts of [
+ [],
+ [{ name: 'ci-inputs-other', expired: false }],
+ [{ name: 'ci-inputs-key', expired: true }],
+]) {
+ test(`missing/mismatched/expired evidence does not skip tests: ${JSON.stringify(
+ artifacts,
+ )}`, async () => {
+ assert.equal(await reusableRun(fakeApi(artifacts), 20, 3, 'key'), null);
+ });
+}
+test('matching pending run that fails or times out cannot be reused', async () => {
+ const api = fakeApi([{ name: 'ci-inputs-key', expired: false }], {
+ ...run,
+ status: 'in_progress',
+ });
+ assert.equal(await reusableRun(api, 20, 3, 'key', async () => false), null);
+});
+
+test('API unavailability falls back to tests rather than granting reuse', () => {
+ const result = spawnSync(process.execPath, ['scripts/ci-reuse.mjs'], {
+ encoding: 'utf8',
+ env: {
+ ...process.env,
+ EVENT_NAME: 'pull_request',
+ PATH: '/nonexistent-keyflow-path',
+ },
+ });
+ assert.equal(result.status, 0);
+ assert.match(result.stderr, /Could not verify earlier checks; running tests/);
+ assert.doesNotMatch(result.stdout, /reused=true/);
+});
diff --git a/scripts/ci-scope.mjs b/scripts/ci-scope.mjs
index 889f539..5270ee9 100644
--- a/scripts/ci-scope.mjs
+++ b/scripts/ci-scope.mjs
@@ -2,11 +2,17 @@ import { execFileSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
-// Unknown/shared paths deliberately run both platforms.
+// Only known documentation assets skip code checks. Unknown/shared paths run all.
+const fullScope = () => ({ ios: true, android: true, library: true });
+const documentation = (path) =>
+ /\.md$/.test(path) ||
+ /^docs\/media\/.*\.(gif|mp4|png|jpe?g|webp|svg)$/.test(path);
export function changeScope(paths) {
- const scope = { ios: false, android: false };
- if (!paths.length) return { ios: true, android: true };
+ const scope = { ios: false, android: false, library: false };
+ if (!paths.length) return fullScope();
for (const path of paths) {
+ if (documentation(path)) continue;
+ scope.library = true;
if (
/^(ios\/|example\/ios\/|scripts\/ios-tests\/)/.test(path) ||
/^scripts\/(run-ios-qwerty-tests|launch-ios-ci(?:\.test)?|patch-stim-ios-readiness)\.mjs$/.test(
@@ -23,7 +29,7 @@ export function changeScope(paths) {
) {
scope.android = true;
} else {
- return { ios: true, android: true };
+ return fullScope();
}
}
return scope;
@@ -35,16 +41,17 @@ export function detectScope(
head,
diff = (args) => execFileSync('git', args, { encoding: 'utf8' }),
) {
- if (event !== 'pull_request') return { ios: true, android: true };
+ if (!['pull_request', 'push'].includes(event)) return fullScope();
+ if (event === 'push' && /^0{40}$/.test(base ?? '')) return fullScope();
if (![base, head].every((sha) => /^[a-f0-9]{40}$/.test(sha ?? ''))) {
- throw new Error('Expected full PR base and head commit SHAs');
+ throw new Error('Expected full base and head commit SHAs');
}
const paths = diff([
'diff',
'--name-only',
'--no-renames',
'-z',
- `${base}...${head}`,
+ `${base}${event === 'pull_request' ? '...' : '..'}${head}`,
])
.split('\0')
.filter(Boolean);
@@ -63,6 +70,6 @@ if (
console.log(scope);
appendFileSync(
process.env.GITHUB_OUTPUT,
- `ios=${scope.ios}\nandroid=${scope.android}\n`,
+ `ios=${scope.ios}\nandroid=${scope.android}\nlibrary=${scope.library}\n`,
);
}
diff --git a/scripts/ci-scope.test.mjs b/scripts/ci-scope.test.mjs
index fa16abe..b362938 100644
--- a/scripts/ci-scope.test.mjs
+++ b/scripts/ci-scope.test.mjs
@@ -12,7 +12,11 @@ for (const path of [
'.swift-format',
]) {
test(`iOS only: ${path}`, () =>
- assert.deepEqual(changeScope([path]), { ios: true, android: false }));
+ assert.deepEqual(changeScope([path]), {
+ ios: true,
+ android: false,
+ library: true,
+ }));
}
for (const path of [
'android/src/Keyboard.kt',
@@ -21,7 +25,11 @@ for (const path of [
'.github/workflows/android-ui.yml',
]) {
test(`Android only: ${path}`, () =>
- assert.deepEqual(changeScope([path]), { ios: false, android: true }));
+ assert.deepEqual(changeScope([path]), {
+ ios: false,
+ android: true,
+ library: true,
+ }));
}
for (const path of [
'src/index.ts',
@@ -34,18 +42,31 @@ for (const path of [
'unknown.file',
]) {
test(`Shared or unknown: ${path}`, () =>
- assert.deepEqual(changeScope([path]), { ios: true, android: true }));
+ assert.deepEqual(changeScope([path]), {
+ ios: true,
+ android: true,
+ library: true,
+ }));
}
test('Mixed platform changes run both', () =>
assert.deepEqual(changeScope(['ios/A.swift', 'android/B.kt']), {
ios: true,
android: true,
+ library: true,
}));
test('Empty diff runs both conservatively', () =>
- assert.deepEqual(changeScope([]), { ios: true, android: true }));
-for (const event of ['push', 'workflow_dispatch']) {
+ assert.deepEqual(changeScope([]), {
+ ios: true,
+ android: true,
+ library: true,
+ }));
+for (const event of ['schedule', 'workflow_dispatch']) {
test(`${event} always runs both`, () =>
- assert.deepEqual(detectScope(event), { ios: true, android: true }));
+ assert.deepEqual(detectScope(event), {
+ ios: true,
+ android: true,
+ library: true,
+ }));
}
test('Invalid revisions fail instead of skipping checks', () =>
assert.throws(() => detectScope('pull_request', '--bad', 'invalid')));
@@ -62,5 +83,76 @@ test('PR compares the full branch diff and handles deleted/renamed paths', () =>
]);
return 'ios/old.swift\0android/new.kt\0';
});
- assert.deepEqual(scope, { ios: true, android: true });
+ assert.deepEqual(scope, { ios: true, android: true, library: true });
});
+
+for (const path of [
+ 'README.md',
+ 'docs/api.md',
+ 'ios/README.md',
+ 'docs/media/ios-trackpad.gif',
+ 'docs/media/android-accents.mp4',
+]) {
+ test(`Documentation skips all suites: ${path}`, () =>
+ assert.deepEqual(changeScope([path]), {
+ ios: false,
+ android: false,
+ library: false,
+ }));
+}
+for (const [path, ios, android] of [
+ ['ios/Key.swift', true, false],
+ ['android/Key.kt', false, true],
+]) {
+ test(`Documentation does not broaden ${path}`, () =>
+ assert.deepEqual(changeScope(['README.md', path, 'docs/media/demo.gif']), {
+ ios,
+ android,
+ library: true,
+ }));
+}
+for (const path of [
+ 'docs/package.json',
+ 'example/assets/image.png',
+ 'docs/media/script.js',
+]) {
+ test(`Non-documentation still runs everything: ${path}`, () =>
+ assert.deepEqual(changeScope([path]), {
+ ios: true,
+ android: true,
+ library: true,
+ }));
+}
+for (const [paths, expected] of [
+ [
+ 'README.md\0docs/media/demo.gif\0',
+ { ios: false, android: false, library: false },
+ ],
+ ['android/Key.kt\0', { ios: false, android: true, library: true }],
+ ['ios/Key.swift\0', { ios: true, android: false, library: true }],
+]) {
+ test(`Main push uses before/after trees: ${paths}`, () => {
+ const base = 'a'.repeat(40),
+ head = 'b'.repeat(40);
+ assert.deepEqual(
+ detectScope('push', base, head, (args) => {
+ assert.equal(args.at(-1), `${base}..${head}`);
+ assert.ok(args.includes('--no-renames'));
+ return paths;
+ }),
+ expected,
+ );
+ });
+}
+test('Initial push runs everything', () =>
+ assert.deepEqual(detectScope('push', '0'.repeat(40), 'b'.repeat(40)), {
+ ios: true,
+ android: true,
+ library: true,
+ }));
+test('Diff failure cannot silently skip checks', () =>
+ assert.throws(() =>
+ detectScope('push', 'a'.repeat(40), 'b'.repeat(40), () => {
+ throw new Error('Missing base');
+ }),
+ ));
diff --git a/scripts/ci/workflow.test.mjs b/scripts/ci/workflow.test.mjs
index 75b5a65..324e487 100644
--- a/scripts/ci/workflow.test.mjs
+++ b/scripts/ci/workflow.test.mjs
@@ -192,3 +192,58 @@ test('device sources contain the proven inventory plus glyph and Shift regressio
[...baseline.androidInstrumentation, ...regressions].sort(),
);
});
+
+test('library checks skip only an explicitly unaffected scope and retain their protected name', () => {
+ const library = parse(readFileSync('.github/workflows/ci.yml', 'utf8'));
+ assert.equal(library.jobs.scope.uses, './.github/workflows/change-scope.yml');
+ assert.equal(library.jobs.check.needs, 'scope');
+ assert.equal(library.jobs.check.name, 'Format, types, tests, and package');
+ assert.match(library.jobs.check.if, /needs.scope.result != 'success'/);
+ assert.match(library.jobs.check.if, /needs.scope.outputs.library != 'false'/);
+});
+test('scope passes event-specific base and head for PRs and main pushes', () => {
+ const scope = parse(
+ readFileSync('.github/workflows/change-scope.yml', 'utf8'),
+ );
+ assert.equal(scope.jobs.scope.steps[0].with['fetch-depth'], 0);
+ const detect = scope.jobs.scope.steps.find((step) => step.id === 'detect');
+ assert.match(detect.env.BASE_SHA, /github.event.before/);
+ assert.match(detect.env.BASE_SHA, /github.event.pull_request.base.sha/);
+ assert.match(detect.env.HEAD_SHA, /github.event.pull_request.head.sha/);
+ assert.match(detect.env.HEAD_SHA, /github.sha/);
+ for (const key of ['ios', 'android', 'library']) {
+ assert.ok(scope.on.workflow_call.outputs[key]);
+ assert.ok(scope.jobs.scope.outputs[key]);
+ }
+});
+
+for (const file of ['ci.yml', 'native.yml']) {
+ test(`${file}: newer runs cannot cancel matching checks being reused`, () => {
+ const workflow = parse(readFileSync(`.github/workflows/${file}`, 'utf8'));
+ assert.equal(
+ workflow.concurrency.group,
+ '${{ github.workflow }}-${{ github.run_id }}',
+ );
+ assert.equal(workflow.concurrency['cancel-in-progress'], false);
+ assert.equal(workflow.permissions.actions, 'read');
+ });
+}
+
+test('reuse is verified before affected platforms can be skipped', () => {
+ const scope = parse(
+ readFileSync('.github/workflows/change-scope.yml', 'utf8'),
+ );
+ const steps = scope.jobs.scope.steps;
+ assert.ok(
+ steps.findIndex((step) => step.id === 'reuse') <
+ steps.findIndex((step) => step.id === 'detect'),
+ );
+ assert.equal(
+ steps.find((step) => step.id === 'detect').env.REUSED,
+ '${{ steps.reuse.outputs.reused }}',
+ );
+ assert.match(
+ steps.find((step) => step.id === 'detect').run,
+ /node scripts\/ci-scope.mjs/,
+ );
+});
diff --git a/src/__tests__/settledKeyboard.test.ts b/src/__tests__/settledKeyboard.test.ts
index 77f1f2a..c52bbe7 100644
--- a/src/__tests__/settledKeyboard.test.ts
+++ b/src/__tests__/settledKeyboard.test.ts
@@ -36,14 +36,21 @@ test('waits for presentation and then stable measurements', async () => {
expect(await result).toEqual(metrics);
});
-test('returns stable overlapping geometry so the caller can fail the layout check', async () => {
+test('persistent overlap remains a failure after the bounded settling deadline', async () => {
const overlapping = { ...metrics, editorBottom: 600 };
const result = settledKeyboard(async () => ({
visible: true,
metrics: overlapping,
}));
- await jest.advanceTimersByTimeAsync(600);
+ let finished = false;
+ void result.then(() => {
+ finished = true;
+ });
+ await jest.advanceTimersByTimeAsync(4900);
+ expect(finished).toBe(false);
+ await jest.advanceTimersByTimeAsync(100);
expect(await result).toEqual(overlapping);
+ expect((await result).editorBottom! > (await result).screenY! + 1).toBe(true);
});
test('fails when no visible keyboard arrives', async () => {
@@ -65,3 +72,43 @@ test('does not finish before the baseline presentation allowance', async () => {
await jest.advanceTimersByTimeAsync(100);
expect(await result).toEqual(metrics);
});
+
+test('waits through a stable CI overlap until delayed avoidance settles', async () => {
+ let bottom = 584.3333435058594;
+ const read = jest.fn(async () => ({
+ visible: true,
+ metrics: { ...metrics, screenY: 581, editorBottom: bottom },
+ }));
+ const result = settledKeyboard(read);
+ let finished = false;
+ void result.then(() => {
+ finished = true;
+ });
+ await jest.advanceTimersByTimeAsync(1200);
+ expect(finished).toBe(false);
+ bottom = 569;
+ await jest.advanceTimersByTimeAsync(200);
+ expect(finished).toBe(false);
+ await jest.advanceTimersByTimeAsync(200);
+ expect((await result).editorBottom).toBe(569);
+});
+
+test('one clear sample followed by overlap cannot pass', async () => {
+ let bottom = 600;
+ const result = settledKeyboard(async () => ({
+ visible: true,
+ metrics: { ...metrics, editorBottom: bottom },
+ }));
+ let finished = false;
+ void result.then(() => {
+ finished = true;
+ });
+ await jest.advanceTimersByTimeAsync(800);
+ bottom = 500;
+ await jest.advanceTimersByTimeAsync(100);
+ bottom = 600;
+ await jest.advanceTimersByTimeAsync(1000);
+ expect(finished).toBe(false);
+ await jest.advanceTimersByTimeAsync(3100);
+ expect((await result).editorBottom).toBe(600);
+});