Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0b01515
ci: esteira de instabilidade — 16 emuladores repetem em laço os teste…
yan-vidal Oct 10, 2026
79858f0
docs(ci): o cancelamento de rodada antiga não é mais por minutos — o …
yan-vidal Oct 10, 2026
48a7800
ci(esteira): cada emulador para na 1ª volta com falha — o job termina…
yan-vidal Oct 10, 2026
2c26e08
ci(esteira): os emuladores rodam o tempo todo e avisam cada falha na …
yan-vidal Oct 10, 2026
4eed57c
docs(ordem): V1-0 feita — PR #7 mesclada (4cd4ee4)
yan-vidal Oct 10, 2026
c099b53
test(teclado): a CanvasGridTest para o serviço no fim — a gravação do…
yan-vidal Oct 10, 2026
bbf4949
ci(esteira): o aviso tira o Unicode de 4 bytes e corta por caractere …
yan-vidal Oct 10, 2026
33b9d18
fix(testes): o 1º teste de teclado espera o sistema registrar o KeyFl…
yan-vidal Oct 10, 2026
b5e52ce
test(teclado): a falha de digitação na grade diz se o toque chegou ao…
yan-vidal Oct 10, 2026
97be265
test(teclado): a falha da grade traz a janela do IME vista pelo geren…
yan-vidal Oct 10, 2026
731b13a
test(teclado): toque injetado devagar não vira "segurar" nos testes q…
yan-vidal Oct 10, 2026
1f9ad86
test(teclado): probe opcional num processo separado do teclado, e a e…
yan-vidal Oct 10, 2026
11b453d
test(teclado): antes de tocar, espera a janela do teclado estar onde …
yan-vidal Oct 10, 2026
fc33f7d
test(teclado): medição de onde a janela do teclado fica ao abrir, par…
yan-vidal Oct 10, 2026
cbebd2b
chore(teclado): diagnóstico da abertura no log — 1º quadro, altura da…
yan-vidal Oct 10, 2026
aca4d5d
perf(teclado): uma leitura do DataStore ao montar o teclado, no lugar…
yan-vidal Oct 10, 2026
83bd0a8
test(teclado): medição de fora da posição da janela do teclado, també…
yan-vidal Oct 10, 2026
6589c68
test(teclado): na abertura, se a janela não assenta, o teste fecha e …
yan-vidal Oct 10, 2026
21668d2
test(overlay): os testes do cancelamento agem com a janela de desfaze…
yan-vidal Oct 10, 2026
3546d8f
fix(skins): o quadro que termina de renderizar depois do close() não …
yan-vidal Oct 10, 2026
b8a305c
test(teclado): sai a espera às cegas de 5 s pelo "🎙" — a espera de as…
yan-vidal Oct 10, 2026
cd4adb5
test(teclado): a reabertura só toca no campo com o teclado fora da tela
yan-vidal Oct 10, 2026
ef26350
docs(testes): convenções novas dos testes de teclado e da medição da …
yan-vidal Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ permissions:
# Os jobs do emulador baixam os APKs desta rodada com ela ainda em curso (`gh run download`) e
# consultam o estado do job de unitários — ver scripts/ci-device-gate.sh.
actions: read
# Um push novo no mesmo PR cancela a rodada anterior: o job de device leva dezenas de minutos e o
# repositório é privado (minutos contam).
# Um push novo no mesmo PR cancela a rodada anterior: ela testa um commit que já ficou para trás, e os
# runners que ela ocupa contam no limite de 20 jobs simultâneos do plano (o repositório é público desde
# 2026-10-10: os minutos não contam mais).
concurrency:
group: checks-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
Expand Down
162 changes: 162 additions & 0 deletions .github/workflows/stress.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
name: Stress
# Esteira de instabilidade: repete em laço, em vários emuladores ao mesmo tempo, os testes que já falharam
# "às vezes" no gate, até a falha aparecer com evidência (scripts/ci-stress.sh). Não bloqueia PR e não faz
# parte da regra da main — é ferramenta de caça.
#
# Rodar:
# - push numa branch `ci/esteira-lenta…`: testa o código DAQUELE commit — serve também para conferir uma
# correção sob carga antes de mesclar (`git push origin HEAD:ci/esteira-lenta`);
# - com este arquivo na main: Actions → Stress → Run workflow, escolhendo classes e minutos.
#
# Metade dos emuladores roda no runner como ele é; a outra metade com o QEMU limitado a 2 núcleos, perto do
# runner privado (2 vCPU) onde as falhas apareciam mais — no repositório público o runner tem 4.
# Cada emulador roda o tempo todo e junta todas as falhas; uma volta com falha publica na hora um status
# `esteira/…` no commit (log e artefato só saem de job terminado) — é o aviso antecipado.
on:
push:
branches: ['ci/esteira-lenta**']
workflow_dispatch:
inputs:
classes:
description: Classes ou Classe#metodo do AndroidJUnitRunner, separadas por vírgula
default: com.keyflow.ime.KeyFlowKeyboardCanvasGridTest,com.keyflow.ime.KeyFlowKeyboardSpellCheckerTest,com.keyflow.ime.KeyFlowKeyboardBarsTest,com.keyflow.overlay.OverlayServiceCancelTest,com.keyflow.skins.PackOrbBudgetTest
minutes:
description: Minutos de laço em cada emulador (até 45)
default: '40'
instrument_args:
description: Argumentos extras do teste, separados por espaço (ex. -e probeProcess remoto)
default: ''
permissions:
contents: read
# Os status `esteira/<emulador>/volta-N` do aviso antecipado (scripts/ci-stress.sh).
statuses: write
concurrency:
group: stress-${{ github.ref }}
cancel-in-progress: true
env:
# Os testes que deram "instável" ou falharam sem causa no gate entre 2026-10-07 e 2026-10-10.
STRESS_CLASSES: ${{ inputs.classes || 'com.keyflow.ime.KeyFlowKeyboardCanvasGridTest,com.keyflow.ime.KeyFlowKeyboardSpellCheckerTest,com.keyflow.ime.KeyFlowKeyboardBarsTest,com.keyflow.overlay.OverlayServiceCancelTest,com.keyflow.skins.PackOrbBudgetTest' }}
STRESS_MINUTES: ${{ inputs.minutes || '40' }}
jobs:
build:
name: APKs
runs-on: ubuntu-24.04
timeout-minutes: 30
# O mesmo preparo do job `unit` do checks.yml (ver os comentários de lá), sem os testes de unidade.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '17'
- name: JDK path and debug keystore
run: |
sudo ln -sfn "$JAVA_HOME" /usr/lib/jvm/java-17-openjdk
mkdir -p ~/.config/.android
keytool -genkeypair -keystore ~/.config/.android/debug.keystore -storepass android \
-keypass android -alias androiddebugkey -dname "CN=Android Debug,O=Android,C=US" \
-keyalg RSA -keysize 2048 -validity 10000
- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
- name: sherpa-onnx AAR version
id: sherpa
run: |
version=$(sed -n 's/^val sherpaOnnxVersion = "\(.*\)"$/\1/p' app/build.gradle.kts)
sha=$(sed -n 's/^val sherpaOnnxSha256 = "\(.*\)"$/\1/p' app/build.gradle.kts)
test -n "$version" && test -n "$sha"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: app/libs/sherpa-onnx-${{ steps.sherpa.outputs.version }}.aar
key: sherpa-onnx-${{ steps.sherpa.outputs.version }}-${{ steps.sherpa.outputs.sha }}
- name: APKs
run: ./gradlew --no-daemon assembleDebug assembleDebugAndroidTest
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: apks
path: |
app/build/outputs/apk/debug/app-debug.apk
app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk
if-no-files-found: error
retention-days: 1
compression-level: 0

stress:
name: Stress ${{ matrix.worker }} (CPU ${{ matrix.cpu && format('{0}%', matrix.cpu) || 'livre' }})
needs: build
runs-on: ubuntu-24.04
timeout-minutes: 75
strategy:
fail-fast: false
matrix:
worker: [1, 2, 3, 4, 5, 6, 7, 8]
cpu: ['', '200']
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: temurin
java-version: '17'
- name: Free disk space
run: |
sudo rm -rf /usr/share/dotnet /usr/local/.ghcup /opt/ghc /opt/hostedtoolcache/CodeQL \
/usr/share/swift /usr/local/share/boost /usr/local/share/powershell /usr/local/julia*
df -h /
- name: KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
test -c /dev/kvm
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: apks
path: app/build/outputs/apk
# O mesmo emulador do gate (checks.yml: versão fixa, swangle, Pixel 6, 2 núcleos, 4 GB).
- name: Stress loop on the emulator
timeout-minutes: 65
env:
GH_TOKEN: ${{ github.token }}
STRESS_WORKER: ${{ matrix.worker }}-cpu${{ matrix.cpu || 'livre' }}
STRESS_INSTRUMENT_ARGS: ${{ inputs.instrument_args }}
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
with:
api-level: 34
emulator-build: 15917651
target: google_apis
arch: x86_64
profile: pixel_6
cores: 2
ram-size: 4096M
disk-size: 6G
emulator-options: -no-window -gpu swangle_indirect -no-snapshot -no-boot-anim -camera-back none
disable-animations: true
script: scripts/ci-stress.sh "$STRESS_CLASSES" "$STRESS_MINUTES" "${{ matrix.cpu }}"
- name: Stress evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: stress-evidence-${{ matrix.worker }}-cpu${{ matrix.cpu || 'livre' }}
path: app/build/stress-evidence/
if-no-files-found: warn
retention-days: 14

report:
name: Stress report
needs: stress
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: stress-evidence-*
path: stress
- name: Failures across all emulators
run: python3 scripts/ci-stress-report.py stress
7 changes: 4 additions & 3 deletions .specs/DEVELOPMENT-ORDER.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ Groq, endpoint próprio) ou os modelos locais, offline.

### Tasks da v1 (em ordem; uma por vez, gate verde)

- **V1-0** — Mesclar a PR #7 (skins até 120 fps, mascote, provedores Anthropic/Google, agente fase 1)
com o CI verde.
- ~~**V1-0** — Mesclar a PR #7 (skins até 120 fps, mascote, provedores Anthropic/Google, agente fase 1)
com o CI verde.~~ **Feito em 2026-10-10** (merge `4cd4ee4`, CI #35 verde sem repetição). No caminho: repo
público, regra "main protegida", gate que repete 1× o que falha, esteira de instabilidade (TESTING.md).
- **V1-1** — "Em breve" e fim das cascas inertes (listas acima). Instrumentado + goldens das rotas.
- **V1-2** — Higiene do release, pendências herdadas do trabalho do Gemini: logs que imprimem o texto
ditado (`KeyFlowController`, `OverlayCoordinator` — `take(30)`) só no debug; leitura dos segredos fora
Expand Down Expand Up @@ -389,7 +390,7 @@ texto literal da doc da OpenAI). Ordem: RSN-T0 → T1 → … → T19 (uma por v

```
feito: Q1..Q11 → SB → GT → NM → RSN → SKP → AGT fase 1 (faltam só manuais [YAN])
agora: V1-0 (PR #7) → V1-1..V1-8 → v1 na Play Store
agora: V1-1..V1-8 → v1 na Play Store (V1-0, PR #7, mesclada em 2026-10-10)
em paralelo: API-T13 (staging) → [conta Play + service account] → v1.1 = MZ-T1..T15 + API-T14
depois: VC (visão) · TS (temas, spec a revisar) · AGT fases 2/3 · Fala/Fluxos/Computador (sem spec)
```
Expand Down
31 changes: 31 additions & 0 deletions .specs/codebase/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ DeckLock (actions fixadas por SHA, `permissions: contents: read`, evidência env
| `Unit tests and build` | `testDebugUnitTest assembleDebug assembleDebugAndroidTest`; publica os APKs (artefato `apks`, 1 dia) | `quick` + `build` |
| `Device (API 34, i/6)` | 6 emuladores Pixel 6 · API 34 · x86_64 **com KVM** → `scripts/ci-device-gate.sh all <i> 6`: 1/6 da suíte (`numShards`/`shardIndex` do AndroidJUnitRunner, por `am instrument` direto), app reinstalado do zero e 1/6 das rotas da galeria, claro e escuro (`ROUTE_SHARD`); depois `diff-screens.sh` do mesmo recorte, fora do emulador | `full` + `screenshot` |

- Teste de teclado não espera às cegas: o `awaitKeyboardAttached` (ImeTestTimeouts.kt) espera a janela do IME estar, no
`dumpsys input`, onde a raiz da view está (2 leituras seguidas) e, se não assenta em 5 s na abertura, fecha e reabre o
teclado (até 3×, só tocando no campo com o teclado fora da tela). Antes de calcular a posição de uma tecla, chame
`awaitImeWindowSettled` (sem reabrir). Motivo medido na esteira (2026-10-10): cada `am instrument` reinicia o processo, e o
executor de testes e o UiAutomator rodam no processo do teclado do KeyFlow — a abertura a frio passa do prazo de 1 s do
Android e a janela fica em transição (tamanho de Dialog, altura da tela, no topo, 126 px abaixo).
- Toque injetado em emulador lento pode passar dos 250 ms da tira de acentos e virar "segurar": teste que só toca usa
`accentHoldMsForTest` (os da tira de acentos, não).
- `scripts/ime-placement.py --ime keyflow|gboard [--cold]` mede de FORA do app (adb) onde a janela do teclado fica ao abrir
— sem teste no processo. A frio, no emulador lento: KeyFlow 6/8 aberturas em transição, Gboard 1/8 (desempenho da
abertura a frio é tarefa aberta).
- Teste que inicia gravação de verdade para o `OverlayService` no `@After` (a gravação vazava para o teste seguinte).
- Os 6 jobs de device sobem **junto** com o de unitários (sem `needs`): preparam o runner e ligam o
emulador enquanto ele compila, e então baixam o artefato `apks` da própria rodada (`gh run download`).
Não há Gradle no runner do emulador. Se o job de unitários falhar, eles param logo.
Expand Down Expand Up @@ -112,3 +124,22 @@ DeckLock (actions fixadas por SHA, `permissions: contents: read`, evidência env
- Artefatos: `unit-evidence` (relatórios JUnit) e `device-evidence-<i>` (saída bruta do `am instrument`
e da repetição, `passed.txt`/`failed.txt`, logcat da rodada, `host.txt`, PNGs capturados), 14 dias.
- O device físico (A32) e o emulador Android 17 seguem sendo gates manuais **[YAN]**.

### Esteira de instabilidade — `.github/workflows/stress.yml`

Caça ao teste que falha "às vezes": 16 emuladores (o mesmo do gate) repetem as mesmas classes em laço por
~40 min, reinstalando o app a cada volta como o gate faz (`scripts/ci-stress.sh`). Metade roda no runner como
ele é; a outra metade com o QEMU num cgroup limitado a 2 núcleos, perto do runner privado de 2 vCPU onde as
falhas apareciam mais. O job `Stress report` (`scripts/ci-stress-report.py`) soma tudo: falhas/execuções por
teste e condição de CPU, a 1ª linha do erro e onde está a evidência (artefato `stress-evidence-<n>-cpu…` →
volta: saída bruta, trecho do logcat, tela). Fica vermelho quando acha alguma falha. Não bloqueia PR.
Cada emulador roda o tempo todo (uma falha frequente não tira ninguém da caça às raras); como o GitHub só
libera log e artefato de job terminado, cada volta com falha publica na hora um status `esteira/<emulador>/
volta-N` no commit, com o teste e a 1ª linha do erro — o aviso antecipado. A evidência completa sai no fim.

- Rodar com o código de um commit qualquer: `git push origin HEAD:ci/esteira-lenta` (serve também para
conferir uma correção sob repetição antes de mesclar). Com o arquivo na `main`: Actions → Stress → Run
workflow, com as classes e os minutos.
- Por que no CI e não em laço local: ~15 emuladores ao mesmo tempo (~10× mais voltas por hora), a própria
máquina onde as falhas apareceram, e o computador do Yan livre. O emulador lento local
(`systemd-run --user --scope -p CPUQuota=300%`) serve para reproduzir uma falha já achada.
Original file line number Diff line number Diff line change
Expand Up @@ -453,4 +453,51 @@ class SettingsRepositoryTest {

assertEquals(saved, repository.overlayPositionFlow().first())
}

/**
* O teclado lê tudo de UMA vez ao ser criado (`onCreateInputView`, thread principal): eram oito idas ao DataStore, até
* 400 ms no emulador lento (esteira de instabilidade, 2026-10-10). O retrato tem que dizer exatamente o que as leituras
* avulsas dizem — gravado e no padrão.
*/
@Test
fun keyboardSnapshotMatchesTheSingleReads() = runTest {
val repository = newRepository()
repository.saveSettings(
Settings(themeDark = true, keyboardScalePercent = 85, keyboardNumberRowEnabled = true, keyboardSkin = "glitch", vibrationIntensity = 30),
)
repository.saveKeyboardLanguageOnce("en")

val snapshot = repository.keyboardSnapshotOnce()

assertEquals(repository.themeDarkOnce(), snapshot.dark)
assertEquals(repository.keyboardScalePercentOnce(), snapshot.scalePercent)
assertEquals(repository.keyboardLanguageOnce(), snapshot.language)
assertEquals(repository.keyboardNumberRowEnabledOnce(), snapshot.numberRowEnabled)
assertEquals(repository.keyboardSkinOnce(), snapshot.skin)
assertEquals(repository.barsConfigOnce(), snapshot.bars)
assertEquals(repository.shortcutGridsOnce(), snapshot.grids)
assertEquals(repository.vibrationIntensityOnce(), snapshot.vibration)
assertEquals(KeyboardSnapshot(true, 85, "en", true, "glitch", snapshot.bars, snapshot.grids, 30), snapshot)
}

@Test
fun keyboardSnapshotUsesTheSameDefaultsAsTheSingleReads() = runTest {
val repository = newRepository()

val snapshot = repository.keyboardSnapshotOnce()

assertEquals(
KeyboardSnapshot(
dark = repository.themeDarkOnce(),
scalePercent = repository.keyboardScalePercentOnce(),
language = repository.keyboardLanguageOnce(),
numberRowEnabled = repository.keyboardNumberRowEnabledOnce(),
skin = repository.keyboardSkinOnce(),
bars = repository.barsConfigOnce(),
grids = repository.shortcutGridsOnce(),
vibration = repository.vibrationIntensityOnce(),
),
snapshot,
)
}
}
Loading