# Technical Documentation: telegramdesktop/tdesktop

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/telegramdesktop/tdesktop)

### Overview (`README.md`)

# [Telegram Desktop][telegram_desktop] – Official Messenger

This is the complete source code and the build instructions for the official [Telegram][telegram] messenger desktop client, based on the [Telegram API][telegram_api] and the [MTProto][telegram_proto] secure protocol.

[](https://github.com/telegramdesktop/tdesktop/releases)
[](https://github.com/telegramdesktop/tdesktop/actions)
[](https://github.com/telegramdesktop/tdesktop/actions)
[](https://github.com/telegramdesktop/tdesktop/actions)
[](https://depot.dev)

[![Preview of Telegram Desktop][preview_image]][preview_image_url]

The source code is published under GPLv3 with OpenSSL exception, the license is available [here][license].

## Supported systems

The latest version is available for

* [Windows 7 and above (64 bit)](https://telegram.org/dl/desktop/win64) ([portable](https://telegram.org/dl/desktop/win64_portable))
* [Windows 7 and above (32 bit)](https://telegram.org/dl/desktop/win) ([portable](https://telegram.org/dl/desktop/win_portable))
* [macOS 10.13 and above](https://telegram.org/dl/desktop/mac)
* [Linux static build for 64 bit](https://telegram.org/dl/desktop/linux)
* [Snap](https://snapcraft.io/telegram-desktop)
* [Flatpak](https://flathub.org/apps/details/org.telegram.desktop)

## Old system versions

Version **4.9.9** was the last that supports older systems

* [macOS 10.12](https://updates.tdesktop.com/tmac/tsetup.4.9.9.dmg)
* [Linux with glibc < 2.28 static build](https://updates.tdesktop.com/tlinux/tsetup.4.9.9.tar.xz)

Version **2.4.4** was the last that supports older systems

* [OS X 10.10 and 10.11](https://updates.tdesktop.com/tosx/tsetup-osx.2.4.4.dmg)
* [Linux static build for 32 bit](https://updates.tdesktop.com/tlinux32/tsetup32.2.4.4.tar.xz)

Version **1.8.15** was the last that supports older systems

* [Windows XP and Vista](https://updates.tdesktop.com/tsetup/tsetup.1.8.15.exe) ([portable](https://updates.tdesktop.com/tsetup/tportable.1.8.15.zip))
* [OS X 10.8 and 10.9](https://updates.tdesktop.com/tmac/tsetup.1.8.15.dmg)
* [OS X 10.6 and 10.7](https://updates.tdesktop.com/tmac32/tsetup32.1.8.15.dmg)

## Third-party

* Qt 6 ([LGPL](http://doc.qt.io/qt-6/lgpl.html)) and Qt 5.15 ([LGPL](http://doc.qt.io/qt-5/lgpl.html)) slightly patched
* OpenSSL 3.2.1 ([Apache License 2.0](https://openssl-library.org/source/license/apache-license-2.0.txt))
* WebRTC ([New BSD License](https://github.com/desktop-app/tg_owt/blob/master/LICENSE))
* zlib ([zlib License](http://www.zlib.net/zlib_license.html))
* LZMA SDK 9.20 ([public domain](http://www.7-zip.org/sdk.html))
* liblzma ([public domain](http://tukaani.org/xz/))
* Google Breakpad ([License](https://chromium.googlesource.com/breakpad/breakpad/+/master/LICENSE))
* Google Crashpad ([Apache License 2.0](https://chromium.googlesource.com/crashpad/crashpad/+/master/LICENSE))
* GYP ([BSD License](https://github.com/bnoordhuis/gyp/blob/master/LICENSE))
* Ninja ([Apache License 2.0](https://github.com/ninja-build/ninja/blob/master/COPYING))
* OpenAL Soft ([LGPL](https://github.com/kcat/openal-soft/blob/master/COPYING))
* Opus codec ([BSD License](http://www.opus-codec.org/license/))
* FFmpeg ([LGPL](https://www.ffmpeg.org/legal.html))
* Guideline Support Library ([MIT License](https://github.com/Microsoft/GSL/blob/master/LICENSE))
* Range-v3 ([Boost License](https://github.com/ericniebler/range-v3/blob/master/LICENSE.txt))
* Open Sans font ([Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0.html))
* Vazirmatn font ([SIL Open Font License 1.1](https://github.com/rastikerdar/vazirmatn/blob/master/OFL.txt))
* Emoji alpha codes ([MIT License](https://github.com/emojione/emojione/blob/master/extras/alpha-codes/LICENSE.md))
* xxHash ([BSD License](https://github.com/Cyan4973/xxHash/blob/dev/LICENSE))
* QR Code generator ([MIT License](https://github.com/nayuki/QR-Code-generator#license))
* CMake ([New BSD License](https://github.com/Kitware/CMake/blob/master/Copyright.txt))
* Hunspell ([LGPL](https://github.com/hunspell/hunspell/blob/master/COPYING.LESSER))
* Ada ([Apache License 2.0](https://github.com/ada-url/ada/blob/main/LICENSE-APACHE))
* zxcvbn-c ([MIT License](https://github.com/tsyrogit/zxcvbn-c/blob/master/LICENSE.txt))
* FrequencyWords by Hermit Dave ([Creative Commons Attribution-ShareAlike 4.0](https://creativecommons.org/licenses/by-sa/4.0/))

## Build instructions

* [Windows (32-bit and 64-bit)][win]
* [macOS][mac]
* [GNU/Linux using Docker][linux]

[//]: # (LINKS)
[telegram]: https://telegram.org
[telegram_desktop]: https://desktop.telegram.org
[telegram_api]: https://core.telegram.org
[telegram_proto]: https://core.telegram.org/mtproto
[license]: LICENSE
[win]: docs/building-win.md
[mac]: docs/building-mac.md
[linux]: docs/building-linux.md
[preview_image]: https://github.com/telegramdesktop/tdesktop/blob/dev/docs/assets/preview.png "Preview of Telegram Desktop"
[preview_image_url]: https://raw.githubusercontent.com/telegramdesktop/tdesktop/dev/docs/assets/preview.png

## Thanks to

<a href="https://depot.dev">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="https://depot.dev/assets/brand/1693758816/depot-logo-horizontal-on-dark.svg">
    <source media="(prefers-color-scheme: light)" srcset="https://depot.dev/assets/brand/1693758816/depot-logo-horizontal-on-light.svg">
    <img alt="Depot" src="https://depot.dev/assets/brand/1693758816/depot-logo-horizontal-on-light.svg" width="150">
  </picture>
</a>

CI infrastructure sponsored by [Depot](https://depot.dev) — fast GitHub Actions runners.

---

### Overview (`tools/clangtidy/README.md`)

# clang-tidy for tdesktop

```bash
python3 tools/clangtidy/compile_db.py               # after each configure
git diff -U0 HEAD | clang-tidy-diff.py -p1 -path out/clang-tidy -j 10 -quiet
run-clang-tidy -p out/clang-tidy -j 10 -quiet <files>
```

Both runners ship with LLVM, under `share/clang`. Needs clang-tidy 20 or
newer: an older one ignores `CustomFunctions` without a word.

The commit hook, once per clone, submodules included; its tests run with
`python3 tools/rules/test_hook.py`:

```bash
for repo in . $(git config -f .gitmodules --get-regexp path \
        | awk '{print $2}' | grep -v ThirdParty); do
    hooks=$(git -C "$repo" rev-parse --git-path hooks)/pre-commit
    printf '#!/bin/sh\nexec python3 %s/tools/rules/hook.py --staged\n' \
        "$PWD" > "$hooks" && chmod +x "$hooks"
done
```

---

### Overview (`Telegram/Resources/zxcvbn/README.md`)

# zxcvbn frequency lists

The two word lists in this directory are unmodified copies of third-party
frequency lists. They are read only at build time: the rules in
`Telegram/cmake/lib_zxcvbn.cmake` transliterate them and compile the result
into the `zxcvbn-c` password dictionary behind
`Telegram/SourceFiles/ui/passcode_strength.cpp`. No built artifact contains
these files: they are build-time inputs to a generated header. They are
tracked, so every source distribution of Telegram Desktop ships them
verbatim, and the attribution below covers those copies as well as the
dictionary compiled from them.

## Provenance

- Title: *FrequencyWords*
- Creator: Hermit Dave
- Repository: <https://github.com/hermitdave/FrequencyWords>
- Upstream commit: `f8a65e6ddc17e0baa2e366a909986798d8dbe55b` (2019-02-15,
  "Additional languages including pl, pt and zh variants", the last upstream
  commit touching either file)
- Derivation: generated by the upstream author from the OpenSubtitles2018
  corpus (<http://opus.nlpl.eu/OpenSubtitles2018.php>); one `word count`
  pair per line, separated by a single space, in descending order of count;
  50,000 lines each, all lowercase, LF line endings, no BOM.

| Tracked file | Upstream path | Bytes | Lines |
| --- | --- | --- | --- |
| `ru_50k.txt` | `content/2018/ru/ru_50k.txt` | 998,861 | 50,000 |
| `uk_50k.txt` | `content/2018/uk/uk_50k.txt` | 887,259 | 50,000 |

sha256 digests, equal to the GitHub raw files at the pinned commit (verified
2026-09-06):

```
6095f507cc167488ec66ada5a85ac50433503a08ad24a07c6eabdf54352c4e7f  ru_50k.txt
881686fe66a168b79f6a9ee2f6cbbf944cfb12bfb7040913ef535cb29bd86ec9  uk_50k.txt
```

`.gitattributes` pins `Telegram/Resources/zxcvbn/*.txt` to `text eol=lf`, so
the digests hold on a Windows checkout as well.

## Licence

Upstream's `README.md` states, under "License", at the upstream head
`525f9b560de45753a5ea01069454e72e9aa541c6` (2022-02-07) — head on
2026-09-06, the day these copies were taken:

> MIT License for code.
> CC-by-sa-4.0 for content.

The licence *version* in that statement has changed exactly once. At the
pinned commit `f8a65e6ddc17e0baa2e366a909986798d8dbe55b` (2019-02-15, the
only commit that ever touched either list) the section read
`MIT License for codes.<br>` / `CC-by-sa-3.0 for contents.`; commit
`0c6a7e40cfd8c645f9e91ff70cd94d7aa8c85c09` (2019-02-15) reworded it to
`MIT License for code.<br>` / `CC-by-sa-3.0 for content.`, leaving the
version at 3.0; and commit `34bb6447a96d4434dc2d4b24e0cf946c523090b3`
(2020-08-11, "Update README.md") replaced `CC-by-sa-3.0` with
`CC-by-sa-4.0` in that one line, since when the section has read as quoted
above.

The word lists are content, not code, and the licence that governs these
copies is the Creative Commons Attribution-ShareAlike 4.0 International
licence (CC BY-SA 4.0), <https://creativecommons.org/licenses/by-sa/4.0/>,
through the licensor's standing offer at head — not through the pin, and
not merely because the bytes are unchanged. A public licence is an offer
made when the material is made available, and a licensor may add a new offer
over unchanged files without withdrawing the old one (CC BY-SA 3.0 §7(b),
CC BY-SA 4.0 §6(c)). The pin fixes which bytes are tracked, and the digests
above stay pinned to it; it does not fix which offer they were taken under.

The earlier 3.0 offer is perpetual and remains available, but it is not
relied on here. Were the 4.0 offer ever disputed, 3.0 alone would still
reach CC BY-SA 4.0, because 3.0 §4(b)(ii) permits distributing an Adaptation
under "a later version of this License with the same License Elements"; it
would not reach the GNU GPL directly, because Creative Commons records that
"Currently, no non-CC licenses have been designated as compatible with BY-SA
3.0".

The repository's `LICENSE` file (MIT, "Copyright (c) 2016 Hermit Dave")
covers the generator code only. Upstream supplies no separate copyright or
disclaimer notice for the content beyond the licence line quoted above.

## Changes made

The lists are used as build-time input only: the word column is kept, each
word is transliterated to Latin letters by `zxcvbn_translit`, words that are
still non-ASCII or shorter than two letters are dropped, four OpenSubtitles
subtitle-markup strings (`chffffff`, `fntahoma`, `b1`, `i0`) are dropped,
and the result is compiled into the zxcvbn dictionary; the tracked files are
unmodified copies.
The count column only orders the words: the generated dictionary keeps a
word's rank, not its count.

## Compatibility with the application licence

Telegram Desktop is distributed under the GNU General Public License, version
3 or (at your option) any later version (`/LICENSE`, `/LEGAL`), with the
OpenSSL linking exception. CC BY-SA 4.0 content may be adapted into a GPLv3
project on this basis:

- Creative Commons, "Compatible Licenses",
  <https://creativecommons.org/share-your-work/licensing-considerations/compatible-licenses/>:
  "GPLv3: The GNU General Public License version 3 was declared a
  'BY-SA–Compatible License' for version 4.0 …" and "Note that compatibility
  with the GPLv3 is one-way only, which means you may license your
  contributions to adaptations of BY-SA 4.0 materials under GPLv3, but you
  may not license your contributions to adaptations of GPLv3 projects under
  BY-SA 4.0." (declared 2015-10-08).
- Creative Commons wiki, "ShareAlike compatibility: GPLv3",
  <https://wiki.creativecommons.org/wiki/ShareAlike_compatibility:_GPLv3>:
  "When someone creates an adaptation of a BY-SA licensed work and includes
  it in a GPLv3-licensed project, both licenses apply and downstream users
  must comply with both licenses. However, Section 2(a)(5)(B) of BY-SA 4.0
  allows anyone who receives the adapted material downstream to satisfy the
  conditions of both BY-SA and GPLv3 (i.e. attribution and ShareAlike) in
  the manner dictated by the GPLv3." and "this compatibility determination
  only allows movement one way, from BY-SA 4.0 to GPLv3."
- Free Software Foundation, "Various Licenses and Comments about Them",
  <https://www.gnu.org/licenses/license-list.html#ccbysa>: "CC BY-SA 4.0 is
  one-way compatible with the GNU GPL version 3: this means you may license
  your modified versions of CC BY-SA 4.0 materials under GNU GPL version 3,
  but you may not relicense GPL 3 licensed works under CC BY-SA 4.0. Because
  Creative Commons lists only version 3 of the GNU GPL on its compatible
  licenses list, it means that you can not license your adapted CC BY-SA
  works under the terms of 'GNU GPL version 3, or (at your option) any later
  version.' However, Section 14 of the GNU GPL version 3 allows licensors to
  specify a proxy to determine whether future versions of the GNU GPL can be
  used. Therefore, if someone adapts a CC BY-SA 4.0 work and incorporates it
  into a GNU GPL version 3 licensed project, they can specify Creative
  Commons as their proxy …"

**Consequence for this repository — an owner decision this file records but
cannot make.** Creative Commons declared only GPL version 3 a
BY-SA-Compatible License, and the wiki page quoted above instructs adapters:
"You must specify version 3 of the GPL when redistributing a GPL project
into which BY-SA 4.0 content is adapted." Telegram Desktop's own offer is
broader: `/LEGAL` — and every source file header that points at it — says
"either version 3 of the License, or (at your option) any later version".
Once the dictionary compiled from this corpus is linked in, that broader
offer cannot be made for the combined work unless the Program itself names a
proxy under GPL §14 ("If the Program specifies that a proxy can decide which
future versions of the GNU General Public License can be used…"). This file
is not the Program's licence statement and cannot make that designation. The
repository owner has to pick one:

- (a) add a GPL §14 proxy designation naming Creative Commons
  (<https://creativecommons.org/compatiblelicenses>) to `/LEGAL`, after
  which "or any later version" works again for the combined work as and when
  Creative Commons declares a later GPL version compatible;
- (b) accept that builds containing this dictionary are offered under GPL
  version 3 only, and qualify `/LEGAL` accordingly;
- (c) drop this corpus — no BY-SA content, no constraint.

Until (a) or (b) is recorded in `/LEGAL`, this component and the tree's "or
any later version" offer are in tension. The in-app statement,
`lng_about_text2`, already reads "licensed under the GNU GPL version 3". The
tracked lists themselves remain verbatim copies under CC BY-SA 4.0.

## Reproducing the dictionary

The build performs these steps itself; run them by hand from the checkout
root after a Debug build to check that the generated header is reproducible.
`<dir>` is any scratch directory outside the checkout.

```
out/Telegram/Debug/zxcvbn_translit \
    Telegram/Resources/zxcvbn/ru_50k.txt <dir>/ru_50k-latin.txt
out/Telegram/Debug/zxcvbn_translit \
    Telegram/Resources/zxcvbn/uk_50k.txt <dir>/uk_50k-latin.txt
out/Telegram/Debug/zxcvbn_dictgen -o <dir>/dict-src.h \
    Telegram/ThirdParty/zxcvbn/words-eng_wiki.txt \
    Telegram/ThirdParty/zxcvbn/words-female.txt \
    Telegram/ThirdParty/zxcvbn/words-male.txt \
    Telegram/ThirdParty/zxcvbn/words-passwd.txt \
    Telegram/ThirdParty/zxcvbn/words-surname.txt \
    Telegram/ThirdParty/zxcvbn/words-tv_film.txt \
    <dir>/ru_50k-latin.txt \
    <dir>/uk_50k-latin.txt
cmp <dir>/dict-src.h out/Telegram/zxcvbn_gen/dict-src.h
```

The input order matters: the generator interleaves its inputs by rank, so
the six upstream lists come first, in the order `lib_zxcvbn.cmake` lists
them, followed by the Russian and then the Ukrainian intermediate. `cmp`
must report no difference.

---

### Overview (`Telegram/SourceFiles/test/README.md`)

# Telegram Desktop task-test harness

Read this short guide before designing, authoring or recovering a Telegram
`-testagent` overlay. Then read only the references needed by the task and
those helpers' headers. The [helper index](docs/helpers/README.md) is a lookup
tool; neither it nor the detailed references are a required reading bundle.

The permanent harness provides the runner, fixtures, inputs and evidence
instruments. The disposable overlay supplies task-specific setup, production
entry points and checks. Search the index before recreating a shared facility.
Keep the repository's `test_scenario.cpp` a no-op outside the saved overlay.

## The stage contract

- `run` acts once on prerequisites already established by an earlier stage.
- `until` observes readiness repeatedly; expected product outcomes belong in
  `then` or `captureAndInspect`. Do not turn a mismatch into a timeout.
- `then` asserts or acts on the exact object accepted by readiness. Prefer
  `actOnWidget` and lifetime guards over resolving another instance.
- `skipReason` names an inapplicable prerequisite, never a failing result.
  `timeoutDetails` reports the last identity, geometry and state.
- Assert and log measured values on both passing and failing paths. A helper
  refusal or undecidable reading is not evidence of product success.

Read the [runner contract](docs/helpers/test_runner.md) before writing stages;
it includes the narrowly documented activation exception to a pure wait.
Startup/session/chats waits are opt-in: select only those the fixture needs.

Every scenario first passes the [session gate](docs/helpers/test_session_gate.md),
which refuses authorized non-Test accounts unless the task explicitly permits
production legs. Configure `UseTestSession` and its restore stage when the
fixture needs an account switch. `SetupScenario` only registers stages,
releases and gate options; the gate does not guard actions taken during setup.

## Exact objects and actions

Use RTTI-based harness finders, not Qt `findChildren<CustomWidget*>()`.
Visibility alone does not prove identity, ownership or unobscured geometry.
Publish exact live objects/actions when descendant searches are ambiguous;
keep lifetime guards across stages and preserve a window's concrete type.
Read [object publication and input contracts](docs/helpers/test_widgets.md).

## Resolving a chat's message list

Resolve the section that owns the exact message; do not assume every chat uses
`HistoryWidget` or that `item->mainView()` is the visible element.
Read [chat-list resolution](docs/chat-lists.md) for message-row work.

## Input helpers

Choose the real event receiver and verify its resulting state. Settlement of
postponed calls does not wait for timers, network or animations. A synthetic
click is not a lasting hover. Read [input helpers](docs/helpers/test_widgets.md#input-helpers)
for input, [launch behavior](docs/launch.md) for activation/background painting,
and the selected module's reference for clipboard, menu or other special input.

## Capture and visual assertions

Capture the real paint owner and the complete requested rectangle. Keep
readiness separate from the expected pixels; save the accepted frame before
asserting on it. Report logical versus image pixels and DPR explicitly.
Read [capture selection](docs/helpers/test_capture.md), then select only the
needed [ink](docs/helpers/test_ink.md), [post-paint](docs/helpers/test_post_paint.md),
[clocked-frame](docs/helpers/test_animation_clock.md) or
[corner](docs/helpers/test_corner_patch.md) reference.

## Helper catalog

Find modules by purpose in the [helper index](docs/helpers/README.md).
Each entry links its detailed contract and source header. Entries marked
**Self-test** validate the harness; do not add them to every product scenario.
Search [failure diagnosis](docs/diagnostics.md) by observed symptom on recovery.

## Building one reliable packed scenario

Derive checks from the task and diff; use the most direct real production
entry point. Pack compatible checks into one launch; split only for a named
incompatible process-lifetime requirement. After a test flaw, repair all
visible flaws together. Keep nondestructive states first and closing actions
last. Read [scenario design](docs/scenarios.md) and, when useful, the
[minimal overlay example](docs/overlay-example.md).

## Account fixture secrets

Run only through the workflow's marked test environment. Fixture secrets stay
in process: never put them in code, prompts, logs or evidence artifacts. Read
[account fixtures](docs/account-fixtures.md) before using account secrets or a
funded wallet, including its required reconciliation and secrecy-scan links.
For public-identity or log-coverage checks, read the separate
[publication audit contract](docs/helpers/test_publication_identity_scan.md);
neither establishes fixture secrecy.
A required missing fixture is a blocker; do not substitute fabricated proof,
reset the funded wallet or discard a possibly unrewritten rotation.

## Media fixtures and fixture gates

Metadata alone does not establish playable media. A media-dependent scenario
must observe real playback and preserve the validated fixture across stages.
Read [media fixtures](docs/media-fixtures.md) before selecting one.

## Scenario teardown before quit

Register idempotent release of subscriptions, watchers and timers with
`Runner::onFinish`; a final stage can be skipped by timeout or watchdog.
Restore asynchronous UI state in a settling stage, not during quit. Read
[teardown](docs/teardown.md) when retaining state or changing the environment.

## Failure diagnosis

Read the actual logs/captures, classify the failure, and search the
[diagnostic reference](docs/diagnostics.md) for that signature. It is a lookup
history, not a preflight reading assignment. Preserve prior positive evidence;
do not weaken an oracle to get past a refusal.

## Adding or changing a helper

Follow [the documentation format](docs/adding-helpers.md): one short index
entry, an on-demand module reference, and a precise source-header contract.
Keep this README under 8 KiB; only universal rules and routing links belong
here. Examples, platform caveats, self-test procedures and failure histories
belong in the linked references. Cite symbols and files, never source line
numbers. The full execution workflow remains in
[the test loop](../../../.agents/shared/test-loop.md).

---

### Overview (`Telegram/SourceFiles/test/docs/helpers/README.md`)

# Harness helper index

[Harness guide](../../README.md) · [Documentation format](../adding-helpers.md)

Search this index by module or need, then read only the selected reference and
its source header. These are lookup pages, not a required reading bundle.
Self-tests exercise the harness; they are not routines every product overlay
should call.

| Module | Kind | Use |
| --- | --- | --- |
| [test_agent](test_agent.md) | API | Runtime gate, startup overrides and fixture access |
| [test_runner](test_runner.md) | API | Stages, bounded readiness and exact-object actions |
| [test_gated_stage](test_gated_stage.md) | Self-test | Self-test for explicit gated skips |
| [test_session_gate](test_session_gate.md) | API | Test-account gate, switching, restoration and explicit production legs |
| [test_log](test_log.md) | API | Evidence logs, assertions and telemetry formatting |
| [test_log_lines](test_log_lines.md) | Self-test | Self-test for one physical record per log call |
| [test_text_reads](test_text_reads.md) | API | Space-normalized text assertions |
| [test_lang_pack](test_lang_pack.md) | API | Temporary language overrides and restoration |
| [test_probe](test_probe.md) | API | Windowed observations and correlated requests |
| [test_widgets](test_widgets.md) | API | Typed discovery, live publications and input events |
| [test_wheel](test_wheel.md) | Self-test | Self-test for wheel delivery |
| [test_queued_drag](test_queued_drag.md) | API | Deliver queued drag starts before the synthetic release |
| [test_text_drop](test_text_drop.md) | Self-test | Self-test for text-drop routing |
| [test_clipboard](test_clipboard.md) | API | Clipboard round-trip and classified read-back |
| [test_console_lock](test_console_lock.md) | API | Current console-lock classification |
| [test_capture](test_capture.md) | API | Prepared grabs, paint owners and mapped targets |
| [test_mapped_target](test_mapped_target.md) | Self-test | Self-test for full mapped-target visibility |
| [test_post_paint](test_post_paint.md) | API | Samples after real product paints |
| [test_animation_clock](test_animation_clock.md) | API | Deterministic elapsed-time animation frames |
| [test_corner_patch](test_corner_patch.md) | API | Rounded-corner comparisons and controls |
| [test_marking_read](test_marking_read.md) | API | Prevent primary-window read marking |
| [test_window_exposure](test_window_exposure.md) | API | Keep covered windows painting without activation |
| [test_layer_root](test_layer_root.md) | Self-test | Self-test for the painting-layer resolver |
| [test_via_window](test_via_window.md) | Self-test | Self-test for window-mapped captures |
| [test_toast_capture](test_toast_capture.md) | API | Isolated toast-subtree captures |
| [test_hover](test_hover.md) | Self-test | Self-test for click/drag hover cleanup |
| [test_press_origin](test_press_origin.md) | Self-test | Self-test for press-point ripple origins |
| [test_activation](test_activation.md) | Self-test | Self-test for injected window activation |
| [test_box_button](test_box_button.md) | API | Resolve and click shell footer buttons |
| [test_ink](test_ink.md) | API | Painted bands, ink, color and contrast measurements |
| [test_style](test_style.md) | API | Settled palette samples and baseline checks |
| [test_palette](test_palette.md) | API | Switch and restore palettes while respecting system following |
| [test_panel](test_panel.md) | API | Separate-panel discovery and show-cache readiness |
| [test_menu](test_menu.md) | API | Same-turn menu readings, actions and captures |
| [test_chat_menu](test_chat_menu.md) | API | Attribute chat-list context menus to the exact message |
| [test_messages](test_messages.md) | API | Observe matching sent server messages |
| [test_history_fixtures](test_history_fixtures.md) | API | Lifetime-owned history service-message fixtures |
| [test_notify_override](test_notify_override.md) | API | Local notify-setting override and restoration |
| [test_custom_emoji](test_custom_emoji.md) | API | Explicitly registered ready emoji fixture |
| [test_launch_fuse](test_launch_fuse.md) | API | Assert intended OS launches while blocking them |
| [test_open_handoff](test_open_handoff.md) | API | Observe document-open handoff without OS launch |
| [test_transfer](test_transfer.md) | API | Observe document transfer success/failure |
| [test_web_cache](test_web_cache.md) | API | Clear the exact web-document cache entry |
| [test_rpc_retry](test_rpc_retry.md) | API | Observe server-error resends |
| [test_rpc_fixture](test_rpc_fixture.md) | API | Boxed controlled RPC replies |
| [test_wallet_ready](test_wallet_ready.md) | API | Judge served wallet freshness |
| [test_wallet_intercept](test_wallet_intercept.md) | API | Controlled wallet request interception |
| [test_gram_reconcile](test_gram_reconcile.md) | API | Reconcile golden-wallet custody and words |
| [test_secrecy_scan](test_secrecy_scan.md) | API | Certify fixture-secret absence from logs |
| [test_publication_identity_scan](test_publication_identity_scan.md) | API | Audit supplied public identities and launch-log coverage |
| [test_publication_identity_scan_self_test](test_publication_identity_scan_self_test.md) | Self-test | Self-test for identity, coverage and claim separation |
| [test_log_scan_internal](test_log_scan_internal.md) | Internal dependency | Shared reader for secrecy, identity and coverage scans |
| [test_scenario](test_scenario.md) | Scenario slot | Disposable task scenario entry point |

Read the selected module's header before using it; the contracts there are
more precise than the summary above. Search this directory before writing a
new local helper: an overlay that reimplements a shared facility is a test
flaw like any other, because the local copy carries none of the refusals the
shared one accumulated. One overlay rebuilt grab-check-save by hand after
`CaptureWidget` had already refused blank and hidden targets for months.

For new or changed modules, follow [Adding a harness helper](../adding-helpers.md).

---

### Api Credentials (`docs/api_credentials.md`)

## Obtaining your API credentials

To build your version of Telegram Desktop you're required to provide your own **api_id** and **api_hash** for the Telegram API access.

How to obtain your **api_id** and **api_hash** is described here: [https://core.telegram.org/api/obtaining_api_id](https://core.telegram.org/api/obtaining_api_id)

If you're building the application not for deployment, but only for test purposes you can use TEST ONLY credentials, which are very limited by the Telegram API server:

**api_id**: 17349
**api_hash**: 344583e45741c457fe1862106095a5eb

Your users will start getting internal server errors on login if you deploy an app using those **api_id** and **api_hash**.

---

### Building Linux (`docs/building-linux.md`)

## Build instructions for Linux using Docker

### Prepare folder

Choose a folder for the future build, for example **/home/user/TBuild**. It will be named ***BuildPath*** in the rest of this document. All commands will be launched from Terminal.

### Obtain your API credentials

You will require **api_id** and **api_hash** to access the Telegram API servers. To learn how to obtain them [click here][api_credentials].

### Clone source code and prepare libraries

Install [poetry](https://python-poetry.org), go to ***BuildPath*** and run

    git clone --recursive https://github.com/telegramdesktop/tdesktop.git
    ./tdesktop/Telegram/build/prepare/linux.sh

### Building the project

Go to ***BuildPath*/tdesktop** and run (using [your **api_id** and **api_hash**](#obtain-your-api-credentials))

    docker run --rm -it \
        -u $(id -u) \
        -v "$PWD:/usr/src/tdesktop" \
        tdesktop:centos_env \
        /usr/src/tdesktop/Telegram/build/docker/centos_env/build.sh \
        -D TDESKTOP_API_ID=YOUR_API_ID \
        -D TDESKTOP_API_HASH=YOUR_API_HASH

Or, to create a debug build, run (also using [your **api_id** and **api_hash**](#obtain-your-api-credentials))

    docker run --rm -it \
        -u $(id -u) \
        -v "$PWD:/usr/src/tdesktop" \
        -e CONFIG=Debug \
        tdesktop:centos_env \
        /usr/src/tdesktop/Telegram/build/docker/centos_env/build.sh \
        -D TDESKTOP_API_ID=YOUR_API_ID \
        -D TDESKTOP_API_HASH=YOUR_API_HASH

The built files will be in the `out` directory.

### Visual Studio Code integration

Ensure you've followed the instruction up to the [**Clone source code and prepare libraries**](#clone-source-code-and-prepare-libraries) step at least.

Open the repository in Visual Studio Code, install the [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension and add the following to `.vscode/settings.json` (using [your **api_id** and **api_hash**](#obtain-your-api-credentials)):

    {
        "cmake.configureSettings": {
            "TDESKTOP_API_ID": "YOUR_API_ID",
            "TDESKTOP_API_HASH": "YOUR_API_HASH"
        }
    }

After that, choose **Reopen in Container** via the menu triggered by the green button in bottom left corner and you're done.



[api_credentials]: api_credentials.md

---

### Building Mac (`docs/building-mac.md`)

## Build instructions for macOS

### Prepare folder

Choose a folder for the future build, for example **/Users/user/TBuild**. It will be named ***BuildPath*** in the rest of this document. All commands will be launched from Terminal.

**Note about disk space:** The full build process will require approximately **55 GB** of free space. This includes:
- **~35 GB** for libraries (when building for both x64 and arm64 architectures)
- **~20 GB** for the compiled Telegram app (in the `out` folder)

### Obtain your API credentials

You will require **api_id** and **api_hash** to access the Telegram API servers. To learn how to obtain them [click here][api_credentials].

### Clone source code and prepare libraries

Go to ***BuildPath*** and run

    /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
    brew install git automake libtool cmake wget pkg-config gnu-tar ninja nasm meson

    sudo xcode-select -s /Applications/Xcode.app/Contents/Developer

    git clone --recursive https://github.com/telegramdesktop/tdesktop.git
    ./tdesktop/Telegram/build/prepare/mac.sh

### Building the project

Go to ***BuildPath*/tdesktop/Telegram** and run (using [your **api_id** and **api_hash**](#obtain-your-api-credentials))

    ./configure.sh -D TDESKTOP_API_ID=YOUR_API_ID -D TDESKTOP_API_HASH=YOUR_API_HASH

Then launch Xcode, open ***BuildPath*/tdesktop/out/Telegram.xcodeproj** and build for Debug / Release.

[api_credentials]: api_credentials.md

---

### Building Mas (`docs/building-mas.md`)

## Build instructions for Mac App Store

**NB** These are used for Mac App Store build, after the [Build for macOS][mac] instructions.

### Prepare breakpad

Go to ***BuildPath*** and run

    MACOSX_DEPLOYMENT_TARGET=10.12

    cd Libraries

    git clone https://chromium.googlesource.com/breakpad/breakpad
    cd breakpad
    git checkout bc8fb886
    git clone https://chromium.googlesource.com/linux-syscall-support src/third_party/lss
    cd src/third_party/lss
    git checkout a91633d1
    cd ../../..
    git apply ../patches/breakpad.diff
    cd src/client/mac
    xcodebuild -project Breakpad.xcodeproj -target Breakpad -configuration Debug build
    xcodebuild -project Breakpad.xcodeproj -target Breakpad -configuration Release build
    cd ../../tools/mac/dump_syms
    xcodebuild -project dump_syms.xcodeproj -target dump_syms -configuration Release build
    cd ../../../../..

[xcode]: building-xcode.md

---

### Building Win (`docs/building-win.md`)

# Build instructions for Windows

- [Prepare folder](#prepare-folder)
- [Install third party software](#install-third-party-software)
- [Choose architecture and initialize terminal](#choose-architecture-and-initialize-terminal)
- [Clone source code and prepare libraries](#clone-source-code-and-prepare-libraries)
- [Build the project](#build-the-project)
- [Qt Visual Studio Tools](#qt-visual-studio-tools)

## Prepare folder

The build is done in **Visual Studio 2026** with **10.0.26100.0** SDK version.

Choose an empty folder for the future build, for example **D:\\TBuild**. It will be named ***BuildPath*** in the rest of this document. Create two folders there, ***BuildPath*\\ThirdParty** and ***BuildPath*\\Libraries**.

The default modern toolset from Visual Studio 2026 (`v145`) does not support Windows 7, so for Telegram Desktop you must use `-vcvars_ver=14.44` (`v144.4`, based on `v143` with Windows 7 support).

### Obtain your API credentials

You will require **api_id** and **api_hash** to access the Telegram API servers. To learn how to obtain them [click here][api_credentials].

## Install third party software

* Download **Python 3.10** installer from [https://www.python.org/downloads/](https://www.python.org/downloads/) and install it with adding to PATH.
* Download **Git** installer from [https://git-scm.com/download/win](https://git-scm.com/download/win) and install it.

## Choose architecture and initialize terminal

Before preparing libraries and running build commands, initialize the Visual Studio environment for your target architecture.
The default modern toolset from Visual Studio 2026 (`v145`) does not support Windows 7, so for Telegram Desktop you must use `-vcvars_ver=14.44` (`v144.4`, based on `v143` with Windows 7 support).

For `win` (32-bit):

    %comspec% /k "C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvars32.bat" -vcvars_ver=14.44

For `win64` (64-bit):

    %comspec% /k "C:\Program Files\Microsoft Visual Studio\18\Community\VC\Auxiliary\Build\vcvars64.bat" -vcvars_ver=14.44

Run both `Clone source code and prepare libraries` and `Build the project` sections in the terminal initialized with one of the commands above.

## Clone source code and prepare libraries

In the initialized terminal, go to ***BuildPath*** and run

    git clone --recursive https://github.com/telegramdesktop/tdesktop.git
    tdesktop\Telegram\build\prepare\win.bat

## Build the project

Go to ***BuildPath*\\tdesktop\\Telegram** and run (using [your **api_id** and **api_hash**](#obtain-your-api-credentials)):

For `win` (32-bit):

    configure.bat -D TDESKTOP_API_ID=YOUR_API_ID -D TDESKTOP_API_HASH=YOUR_API_HASH

For `win64` (64-bit):

    configure.bat x64 -D TDESKTOP_API_ID=YOUR_API_ID -D TDESKTOP_API_HASH=YOUR_API_HASH

* Open ***BuildPath*\\tdesktop\\out\\Telegram.slnx** in Visual Studio 2026
* Select Telegram project and press Build > Build Telegram (Debug and Release configurations)
* The result Telegram.exe will be located in **D:\TBuild\tdesktop\out\Debug** (and **Release**)

### Qt Visual Studio Tools

For better debugging you may want to install Qt Visual Studio Tools:

* Open **Extensions** -> **Manage Extensions**
* Go to **Online** tab
* Search for **Qt**
* Install **Qt Visual Studio Tools** extension

[api_credentials]: api_credentials.md

---

### Web Proxy Plan (`docs/web-proxy-plan.md`)

# Telegram Desktop WEB proxy: refined client design

The hosted half is specified in `../tproxy-server/PLAN.md`. Its multiplexing frame
format and `MessageChannel` contract are authoritative. This document records the
reviewed Telegram Desktop design and the implementation now present in this tree.
The server-dependent execution procedure is intentionally separate in
`docs/web-proxy-test-plan.md`.

## 1. Scope and invariant

WEB is an MTProxy whose primary carrier is one process-wide hidden native WebView:

```text
MTProto session threads
  -> TcpConnection (existing MTProxy obfuscation and AES-CTR)
  -> WebProxySocket (one logical stream)
  -> process-wide WebProxy::Transport (one worker thread)
  -> one hidden platform WebView
  -> injected exact-origin TelegramWebProxy bridge
  -> https://relay.example/[base-path/]?bridge=<derived-capability>#android=<nonce>
  -> HTTPS carrier
  -> hosted relay
  -> stock MTProxy
  -> Telegram
```

The central invariant is that Telegram Desktop opens no external MTProto socket
while WEB is active. The hidden WebView's platform web engine makes the external
HTTPS connection. The hosted relay sees only bytes already transformed by the
existing MTProxy protocol layer; the MTProxy secret is never placed in HTML or
JavaScript.

The previous local-page/system-browser path remains a fallback. Telegram offers it
only after the hidden WebView is unavailable or has failed its ten-second startup or
health deadline. It never opens a browser automatically.

## 2. Design decisions

The initial draft left several architectural choices open. They are now fixed:

1. There is one transport and one hidden WebView per process, not per account. Proxy
   selection is already process-wide, so all accounts using the selected WEB proxy
   share one multiplexed carrier.
2. The transport owns a dedicated `QThread`. `QTcpServer`, accepted fallback
   sockets, WebSocket framing, mux state, and queues live there. The native WebView
   stays on the application thread and exchanges bounded messages with the worker.
3. `WebProxySocket` and the transport are compiled in the main `Telegram` target.
   `connection_tcp.cpp`, the only factory site retaining the full `ProxyData`, is
   already in that target. No reverse dependency from `td_mtproto` is introduced.
4. The serialized `host` field stores only the canonical lowercase ASCII/IDNA
   A-label hostname. Scheme, port, path, query, fragment, user info, IP addresses
   (including WHATWG "ends in a number" shorthands such as `127.1` or `0x7f.1`),
   and single-label names are rejected. `port` is fixed to `443`; `password` stores
   the MTProxy secret; `user` stores the optional base path slug (§3.1).
   Operators should publish WEB hostnames in ACE (`xn--…`)
   form: ACE input round-trips unchanged on every platform, while a hand-typed
   Unicode host is mapped by the Qt version the build ships (IDNA2003/nameprep
   on the Qt 5.15 Windows builds, UTS #46 nontransitional on Qt 6), so hosts
   containing deviation characters (`ß`, `ς`, ZWJ, ZWNJ) or characters newer
   than Unicode 3.2 can normalise to different strings — and therefore different
   capabilities — per platform.
5. WEB entries are entered manually or imported from `tg://webproxy` /
   `https://t.me/webproxy` links (see §11); there is no `tg://proxy?…` form.
6. Inactive WEB entries are not checked and are removed from proxy rotation's
   candidate order in v1. Checking would require activating a WebView carrier and
   must not create background carriers for every saved proxy.
7. The loopback parent is one inline, dependency-free HTML response. A qrc asset
   adds no value for this small page and would create another generated-resource
   dependency.
8. While an explicitly opened fallback page's authenticated loopback WebSocket is
   open, the local parent maintains
   an empty `RTCDataChannel` between two same-page `RTCPeerConnection`s. This is a
   best-effort Chrome background-lifecycle guard: it uses no media, STUN, TURN, or
   remote signaling, and failure to establish it never fails the carrier.

## 3. Data model and persistence

`MTP::ProxyData::Type::Web` is appended to the enum and serialized as type code `4`.
The existing five-field proxy blob remains unchanged:

```text
type | host | port | user | password
```

WEB maps those fields as follows:

| Field | WEB meaning |
|---|---|
| `host` | canonical lowercase ASCII/IDNA A-label hostname |
| `port` | fixed value `443` |
| `user` | empty, or the canonical base path slug (§3.1) |
| `password` | existing MTProxy secret syntax |

Validation requires a non-empty valid DNS hostname, a canonical base path, and a
supported MTProxy secret. Plain 16-byte and `dd` random-padding secrets are
accepted; `ee` TLS-emulation secrets are
rejected because the stock MTProxy would expect an inner TLS-emulation record that
this raw relay deliberately does not add. Unknown future serialized type codes
deserialize to `None` instead of reaching
`Unexpected`, so downgrades skip an unsupported proxy rather than crashing.

### 3.1 Optional base path

A WEB proxy address is a hostname with an optional base path,
`proxy.example.com` or `proxy.example.com/dobry-cola-super-app`, so a production
origin can keep serving its own site and route only that prefix to the relay.
Every WEB endpoint moves under the prefix together; nothing stays at the root:

```text
no base path:                 base path `dobry-cola-super-app`:
  GET  /?bridge=…               GET  /dobry-cola-super-app/?bridge=…
  POST /api/v1/session          POST /dobry-cola-super-app/api/v1/session
  POST /api/v1/up               POST /dobry-cola-super-app/api/v1/up
  POST /api/v1/down             POST /dobry-cola-super-app/api/v1/down
  GET  /api/v1/ws               GET  /dobry-cola-super-app/api/v1/ws
```

The address is one field everywhere it is entered, displayed, or shared: the
editor, the confirmation box, the proxy row, and the `server` link parameter all
carry `host` or `host/path`. The path is one or more `/`-separated ASCII segments,
each matching `[A-Za-z0-9][A-Za-z0-9_-]*`, at most 128 characters in total, stored
with no leading or trailing `/`. `%xx` escapes, Unicode and empty segments are
rejected rather than repaired, so the serialized value and the wire path are the
same string; `.` is not in the alphabet, so dot segments cannot occur. The host is
case-insensitive and stored lowercased, while the path is case-sensitive and never
folded, so `Proxy.Example.COM/My-App` normalizes to `proxy.example.com/My-App`,
and a pasted `https://` prefix is stripped on save. Only the trailing-slash
form `/<slug>/` is served; the client never requests `/<slug>` and the relay is not
expected to redirect it, which would add another observable branch.

The slug is a namespace, not a credential: it isolates the relay from the rest of
the site and keeps generic scanners away from the carrier endpoints, but the
bootstrap capability, session bearer, and MTProxy secret remain the authentication.
A random slug raises the cost of finding the deployment; it does not replace any of
them. Storing it in the otherwise unused `user` field keeps the serialized proxy
blob unchanged and makes a client without this feature report the entry as invalid
instead of silently connecting to the host root.

WEB behaves like MTProxy throughout the existing model:

- `secretFromMtprotoPassword()` accepts WEB.
- Qt's application proxy is `NoProxy`; WEB does not affect update or generic HTTP
  traffic.
- custom DC/proxy DNS resolution is disabled because the WebView engine, or the
  explicitly selected browser fallback, resolves the relay hostname.
- calls remain unsupported.
- TCP MTProto is enabled and the plain MTProto HTTP connection is disabled.
- DC endpoints are ignored; the hosted relay chooses its fixed stock-MTProxy target.
- `initConnection` reports the relay hostname and port 443 as client proxy metadata.

## 4. `WebProxySocket`

`mtproto/details/mtproto_web_proxy_socket.*` implements `AbstractSocket` as a
logical byte stream over the shared transport.

On `connectToHost`, it registers a new 24-bit stream id. The address and port
arguments are intentionally ignored. It emits `connected` after the active carrier
has completed the relay `WELCOME` handshake and the transport has sent `OPEN` for
that stream.

Writes concatenate the one-time MTProxy connection prefix and body before queuing a
`DATA` frame. Incoming `DATA` is buffered and exposed through partial `read()` calls.
Every successful read replenishes exactly that many bytes of receive credit with a
`WINDOW` frame. Transport loss emits `disconnected`; protocol violations, queue
overflow, and explicit transport failures emit `error`.

The existing `TcpConnection` continues to own all MTProxy protocol work. For WEB it
uses `secretFromMtprotoPassword()` and `Protocol::Create(secret)` exactly as for
MTProxy, then selects `WebProxySocket` at the one socket-factory call site.

## 5. Process-wide transport and threading

`mtproto/web_proxy/web_proxy_transport.*` provides a main-thread lifecycle facade and
runs all I/O state on its worker thread.

Main-thread lifecycle:

- `Activate(proxy)` creates the worker on first use, synchronously installs the
  selected valid proxy and creates one hidden WebView when the selected WEB proxy
  changes; the fallback listener stays unbound.
- `OpenBrowser(proxy)` binds the loopback listener if needed, mints a fresh
  one-shot capability and opens a new tab on explicit user request after fallback
  has been offered.
- `Deactivate()` closes streams, accepted clients, and the listener when the app
  changes away from WEB, and destroys the hidden WebView.
- `Shutdown()` runs after MTP accounts have stopped and joins the worker thread.

The WebView candidate performs its own `HELLO` / `WELCOME` handshake before the
worker adopts it. Startup, bridge initialization, write acknowledgement, and health
are each bounded. A failed candidate is destroyed and another candidate is tried
after an exponentially growing delay (2 s to 30 s); fallback is offered only once
the failure is terminal (see §8). If a retry succeeds while the browser fallback
is connected, logical streams reconnect through the WebView and the fallback socket
is closed; no relay session is migrated across carriers.

Session-thread interaction uses queued calls into the worker. Each stream stores its
socket context, and worker-to-socket delivery is queued to that socket's owning
thread. `WebProxySocket` destruction unregisters synchronously on the worker before
the QObject base destructor can invalidate the context. This creates a strict
ordering boundary: notifications already posted remain owned by Qt and are removed
with the QObject, while the worker cannot inspect or post through the context after
unregistration returns. The global transport pointer is atomic and remains alive
until all MTP sessions have been destroyed.

The principal state transitions surfaced to settings are:

```text
Idle
  -> Connecting
  -> Connected
  -> WaitingForBrowser  (WebView unavailable, unhealthy, or failed)
  -> Connecting         (user confirmed the browser fallback)
  -> Connected

WaitingForBrowser
  -> Connected          (a 30-second WebView retry succeeds)
```

## 6. Shared relay frames

All integers are big-endian. The implementation mirrors server plan section 7:

```text
type:u8 | stream_id:u24 | length:u32 | payload:length
```

Each carrier message must contain one or more complete frames. The parser
accepts concatenated frames and rejects an empty message or trailing partial frame.
A payload is capped at 1 MiB. Known types are:

| Value | Name | Stream | Client behavior |
|---:|---|---:|---|
| `0x01` | `OPEN` | >0 | sent once after `WELCOME` |
| `0x02` | `DATA` | >0 | opaque MTProxy bytes |
| `0x03` | `CLOSE` | >0 | empty payload; closes one logical socket |
| `0x04` | `WINDOW` | >0 | four-byte credit delta |
| `0x05` | `PING` | 0 | relay-to-client keepalive; answered with `PONG` |
| `0x06` | `PONG` | 0 | sent only as the exact `PING` response |
| `0x10` | `HELLO` | 0 | client sends payload `01` for protocol v1 |
| `0x11` | `WELCOME` | 0 | empty payload; must be the first relay frame |
| `0x12` | `AUTH_CHAL` | 0 | reserved for relay-auth v2, rejected in v1 |
| `0x13` | `AUTH_RESP` | 0 | reserved for relay-auth v2 |
| `0x1f` | `BYE` | 0 | fails current logical streams and closes the carrier |

An incoming `OPEN`, a stream frame on stream zero, a session frame on a nonzero
stream, malformed `WINDOW`, data beyond granted credit, an unknown live stream, or
an unknown type is a protocol error for v1. The client retains up to 4096 recently
closed stream ids. Well-formed `DATA`, `WINDOW`, and `CLOSE` already in flight for a
retained id are discarded; this prevents an ordinary cross-direction close race from
failing unrelated multiplexed streams.

## 7. Flow control and memory bounds

Both directions start with an implicit 4 MiB per-stream window.

Downlink flow control is exact: relay `DATA` consumes client receive credit, and
Telegram Desktop grants it back only when `WebProxySocket::read()` drains bytes into
the MTProto engine. This naturally bounds each socket's unread data.

Uplink has a constraint the initial draft missed: `AbstractSocket::write()` returns
`void` and provides no writable/backpressure event, so it cannot stop the MTProto
caller and resume later. The client therefore:

- spends relay-granted send credit before emitting each `DATA` frame;
- splits outgoing data into at most 64 KiB frames;
- queues excess data per stream;
- coalesces adjacent writes up to 64 KiB and avoids front-removal copies;
- fails the stream if its pending uplink exceeds 8 MiB or 1024 queued items;
- caps all queued cross-thread uplink data at 64 MiB and 8192 items;
- pauses stream flushing when the process-wide loopback socket write queue reaches
  4 MiB and resumes it as bytes drain;
- reserves 64 KiB of that socket budget for control traffic and bounds a separate
  64 KiB / 1024-frame control queue; and
- schedules ready streams round-robin, with at most 256 frames per worker turn.

If the active carrier makes no write progress for 30 seconds, the carrier fails and
normal MTProto reconnect logic replaces it. Exhausting a stream or transport budget
also fails promptly rather than allowing unbounded queued worker events. If
measurements show sustained multi-megabyte uploads can exhaust these bounds, a
future change must add writable backpressure to the `AbstractSocket` contract rather
than silently growing memory.

### 7.1 Performance envelope and built-in HTTP comparison

The hosted bridge batches up to 2 MiB and runs uplink and downlink concurrently.
Each direction is sequenced stop-and-wait in v1, giving an RTT-only busy-direction
bound of 40, 20, 10, and 4 MiB/s at 50, 100, 200, and 500 ms web-engine-to-relay RTT,
respectively. Actual results include transfer time, the relay-to-MTProxy leg, and
web-engine scheduling. The 4 MiB stream window is two carrier batches so returned
credit does not reproduce the former 256 KiB bottleneck.

The built-in MTProto HTTP transport also copies request/response bodies and uses an
HTTP wait request, but `QNetworkAccessManager` may keep several POSTs active. WEB is
therefore more RTT-sensitive today. That serialization, fixed batch size, and most
buffer copies are implementation choices; a bounded ordered pipeline or compatible
streaming carrier can narrow them. Inherent WEB cost remains one platform web
engine, an extra relay/TLS path, a native JavaScript boundary, and shared-carrier
head-of-line exposure. The explicit browser fallback adds MessageChannel and
loopback crossings. With a well-placed relay, ordinary messaging and moderate media
should be in the same practical class as the built-in HTTP transport, while direct
TCP/MTProxy remains the latency and peak-throughput reference.

## 8. Hidden WebView boundary

`lib_webview` exposes `WindowMode::Hidden`, `HiddenSupported()`, and `Window::valid()`.
Hidden mode creates the platform web engine without a Telegram window or embedded
Qt widget:

- macOS retains a native `WKWebView` without wrapping it in a `QWindow` or widget;
- Windows uses modern WebView2 with an invisible controller and no widget container;
- all-other platforms keep WebKitGTK in the existing helper process and attach it to
  an unmapped native GTK toplevel, without creating an embed/compositor widget.

Hidden mode does not install the normal WebView dialog UI. New-window navigation is
rejected. The transport allows only the exact canonical HTTPS bridge navigation.
The bridge is injected only into the top-level document, and native messages are
accepted only from the configured HTTPS origin (compared in ACE form, so IDN hosts
on Qt-whitelisted TLDs work); a message without a source URL is rejected. The
scrubbed `https://host/` history URL is accepted for messages but not as a fresh
navigation.

The carrier opens the WebView with `restrictedOrigin` set, which puts `lib_webview`
into its restricted profile: an ephemeral, per-carrier storage area; cookies never
accepted; downloads, new windows, subframe navigations, permission prompts,
authentication dialogs and non-`https`/`wss` requests to any other host refused;
and a document-start lock script, injected into every frame (so a fresh
`about:blank` realm cannot bypass it), that installs a `<meta>` CSP allowing only
inline script and connections to the exact origin, and defines `undefined` over
storage, workers, audio, speech, WebRTC (`RTCPeerConnection` and friends),
`WebTransport`, `WebAssembly`, notifications, payment, presentation, media capture,
file pickers, `navigator.credentials/mediaDevices/getUserMedia/wakeLock/share/xr/
getGamepads/storage/locks/sendBeacon/permissions` and similar. The operator's page
therefore only ever gets inline script plus `fetch`/`WebSocket` to its own origin.

Engine-level enforcement backs the script per platform:

- Windows (WebView2): the restricted profile owns its own user-data folder and
  browser process; it runs with `--disable-features=msSmartScreenProtection`
  and `IsReputationCheckingRequired = FALSE` (so the capability URL is never
  reported to SmartScreen) and
  `--force-webrtc-ip-handling-policy=disable_non_proxied_udp` (no direct UDP);
  requests to other origins are answered 403 from `WebResourceRequested`; page
  messages are not echoed back and new-window requests never reach the system
  browser.
- macOS (WKWebView): `peerConnectionEnabled` and `mediaDevicesEnabled` are turned
  off through the same KVC path used for `developerExtrasEnabled`, and on macOS 13+
  the configuration enables Lockdown Mode (no JIT, WebAssembly or WebGL). The
  lock script is a `WKUserScript` with `forMainFrameOnly:NO`.
- all-other platforms (WebKitGTK): `enable-webrtc` and `enable-media-stream` are
  set to false when the installed WebKitGTK exposes them, the 4.0/4.1 API path
  enables the web-process sandbox (`webkit_web_context_set_sandbox_enabled`),
  the engine already runs in the separate `-webviewhelper` process, and the lock
  script is injected with `WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES`. The helper's
  script-message cap is 2 MiB, enough for a full 1 MiB relay frame in base64.

Debug builds evaluate a probe after the first bridge message and log a line if
`RTCPeerConnection`, `WebTransport` or `WebAssembly` is still reachable in the
top frame or in a freshly created `about:blank` iframe.

The page receives an exact-origin `TelegramWebProxy` object at document start. This
uses the same deployed bridge contract as Android: a fresh 32-byte URL-safe nonce in
`#android=`, `tproxy-android-init` version 1, and raw relay frames. Since the common
desktop WebView API carries strings, binary frames cross the native boundary as
strict base64 and are acknowledged by monotonically increasing write sequence. The
native queue is bounded to 8 MiB / 1024 items.

The candidate must receive exactly one valid `WELCOME`, and only after the nonce'd
`tproxy-android-init`. The handshake deadline is ten seconds, extended by another
ten seconds each time the bridge reports `status: connecting|reconnecting` (the
bridge's own retry budget is ~23–28 s), up to 45 seconds from navigation start.
Once adopted, the main thread probes JavaScript every three seconds; ten seconds
without a valid bridge message, or ten seconds without the acknowledgement for a
native write, fails the carrier.

The transport keeps at most 512 frames outstanding towards the WebView (control
frames may use the last 64 of them) and coalesces `WINDOW` grants per stream,
emitting them once 256 KiB accumulate or after 20 ms, so the carrier's hard
1024-item cap is unreachable in normal operation.

When a carrier is dropped after being adopted, the close control is evaluated in
the page and the WebView is kept alive for a further 200 ms so the bridge can send
its `DELETE /api/v1/session`.

WebView failures are retried with exponential backoff from 2 to 30 seconds (reset
on every successful adoption). The row shows `connecting` throughout; the
`WaitingForBrowser` state, and the confirmation box offering the system-browser
fallback, appear only for a terminal failure: hidden WebViews unsupported on the
platform (checked once per activation; nothing is retried), WebView creation
failed, or three consecutive failures without a single adoption. Retries continue
in the background at the maximum interval after that.

## 9. Explicit system-browser fallback boundary

The worker binds `QHostAddress::LocalHost` on an ephemeral port only when the user
asks for the fallback (`Open browser`), and advertises the numeric origin
`http://127.0.0.1:<port>`. The listener is closed again once a browser tab has
authenticated, when the minted capability expires unused, when the hidden WebView
takes over, and on deactivation, so there is no listening port unless the fallback
was requested. A page reload after that needs a fresh `Open browser`.

`GET /` serves the inline parent with `no-store`, `nosniff`, `no-referrer`, and a
fresh per-response script nonce. Its strict CSP permits only that nonce-bound
bootstrap, the configured HTTPS iframe origin, and its exact local WebSocket
endpoint.

`GET /transport` upgrades to RFC 6455 only when all of the following hold:

- peer address is loopback;
- method/path are exactly `GET /` or the `/transport` upgrade;
- `Host` is the exact numeric loopback host and current port;
- `Origin` is the exact loopback page origin;
- `Upgrade`, `Connection`, version 13, and a valid 16-byte key are present;
- duplicate HTTP header names are rejected;
- request bodies and transfer encodings are rejected on the local GET boundary;
- the HTTP header block is at most 16 KiB.

Client WebSocket frames must be masked. The parser supports 7/16/64-bit lengths,
text, binary, continuation, ping, pong, and close, with a 2 MiB message cap. Server
frames are unmasked as required by RFC 6455.

An accepted local client must complete capability authentication within ten seconds.
This bounds silent HTTP connections and unauthenticated WebSockets so they cannot
hold all 32 local client slots indefinitely.

The first complete WebSocket message must be UTF-8 JSON:

```json
{"t":"auth","token":"<capability>","browser":"<user agent summary>"}
```

The capability is 256 random bits, URL-safe base64, carried only in the fragment of
the browser URL. The page removes it from the visible URL immediately. It is
one-shot, expires after five minutes, and is replaced when another tab is opened. A
newly authenticated tab replaces the previous authenticated tab and causes MTProto
streams to reconnect rather than attempting unsupported cross-tab resume.

After authentication:

- the client first sends the text message
  `{"t":"bridge","url":"https://<host>/?bridge=<capability>"}`; the derived
  capability is therefore only ever handed to a tab that proved possession of the
  one-shot fragment token, never embedded in the unauthenticated `GET /` page;
- binary WebSocket messages carry one or more shared relay frames;
- text messages from the page may only report bridge state as
  `{"t":"status","state":"connecting|connected|reconnecting|failed"}`;
- pings from unauthenticated clients are ignored.

If an authenticated browser does not return the required `WELCOME` within 30
seconds, the client fails that carrier and closes its local WebSocket. This turns a
wrong bridge capability, iframe load failure, or ordinary public response into a
recoverable `unavailable` state instead of leaving the settings row connecting
forever.

## 10. Parent page and hosted iframe contract

The local parent reads and scrubs its independent one-shot loopback capability,
connects the local WebSocket, waits for the `bridge` text message, and only then
creates an iframe (with limited `sandbox` flags and `referrerPolicy` set before
`src`) for that URL, which must begin with `relayOrigin + base + '?bridge='`, and
establishes a `MessageChannel`.

The parent also creates two same-page `RTCPeerConnection`s with an empty ICE-server
list, exchanges their descriptions only in local JavaScript, rewrites exchanged host
candidates to `127.0.0.1`, and retains an open, otherwise idle `RTCDataChannel`.
This avoids mDNS/interface-dependent self-connect behavior and keeps RTC packets on
loopback. No RTC state is exposed to the hosted iframe. The guard starts with the
authenticated loopback WebSocket, closes with it or on `pagehide`, and is recreated
on `pageshow` or with bounded backoff if the local RTC connection fails. Browsers
without usable WebRTC continue with the ordinary carrier. The guard reduces Chrome
background freezing, intensive timer throttling, and normal automatic discard risk,
but it is not a correctness dependency: manual tab closure, browser or OS
termination, and urgent discard remain ordinary transport loss.

For a canonical hostname `H`, a canonical base path `P` (empty at the host root),
and decoded WEB secret bytes `S`, including the leading `dd` byte when present, it
computes:

```text
base     = P is empty ? "/" : "/" + P + "/"
context  = P is empty
             ? UTF-8("tdesktop-web-proxy-bridge-v1\n" + H)
             : UTF-8("tdesktop-web-proxy-bridge-v2\n" + H + "\n" + P)
bridge   = base64url-no-padding(HMAC-SHA256(key=S, message=context))
bridgeUrl = "https://" + H + base + "?bridge=" + bridge
```

The root derivation is the frozen v1 context, byte for byte. A base path uses its
own v2 context so a capability minted for one prefix authenticates nothing at
another prefix or at the root.

Normative vectors:

| Hostname | Base path | Decoded secret hex | `bridge` |
|---|---|---|---|
| `proxy.example.com` | | `000102030405060708090a0b0c0d0e0f` | `MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk` |
| `proxy.example.com` | | `dd000102030405060708090a0b0c0d0e0f` | `IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA` |
| `proxy.example.com` | `dobry-cola-super-app` | `000102030405060708090a0b0c0d0e0f` | `hHz99Xs93EN1j91G9gpNepXwGNNt5YdAFkEVk_LlqdQ` |
| `proxy.example.com` | `dobry-cola-super-app` | `dd000102030405060708090a0b0c0d0e0f` | `TGUkZaevsavLbHvlNWipnRoYxgzZ51ioWvbxgGT3wHo` |

The derived capability is constructed in memory in tdesktop and is neither stored
nor shown in proxy settings. On iframe load the parent sends exactly:

```javascript
iframe.contentWindow.postMessage(
  { t: 'tproxy-init', v: 1 },
  relayOrigin,
  [channel.port2]);
```

The target origin is exact and never `*`. Binary messages are transferred as
`ArrayBuffer`s in both directions. Frames received locally before iframe
initialization are queued briefly and transferred after initialization. The parent
does not parse shared relay frames and never receives the MTProxy secret. Both the
hosted uplink queue and the parent's local-WebSocket queue are capped at 32 MiB; the
hosted queue also caps retained buffer objects at 16384. Exceeding either bound
closes the carrier instead of growing browser memory without limit.

The iframe's status objects update the visible tab and are forwarded to tdesktop.
When the local WebSocket closes, the parent sends `{t:'close'}` so the bridge can
delete its relay session. Closing the fallback tab drops the local WebSocket and
disconnects its logical sockets. Telegram Desktop does not reopen a tab
automatically. It keeps trying the hidden WebView with the backoff from §8.
Reloading cannot reuse the scrubbed, one-shot loopback capability (and the
listener is closed once a tab authenticated); after another failure, the
confirmation or row menu can mint a fresh capability and open a new tab.

## 11. Settings and app integration

Proxy settings expose a fourth `WEB` radio option. The editor shows:

- one proxy address field, holding `host` or `host/path`, which also accepts a
  pasted `https://` prefix and strips it on save;
- one MTProxy secret field;
- no socket host/port pair and no username/password controls.

Rows display only that address. Inactive WEB rows show `not tested` without creating
a checker, WebView, or browser tab. Only the exact active WEB row shows the live
transport lifecycle. `Open browser` is offered only after the built-in carrier has
failed. WEB remains unsupported for calls. Because the backend is still MTProxy,
WEB keeps the existing sponsored-proxy disclosure (in the editor and in the link
confirmation) and promotion refresh behavior. Like MTProxy, the WEB hostname is
what `initConnection` reports as the proxy address; QNetworkAccessManager traffic
outside MTProto is not routed through the WEB carrier.

WEB links use `webproxy`, a canonical address, and the MTProxy secret. Port 443 is
implicit and is neither accepted from the link nor displayed in its confirmation:

```text
https://t.me/webproxy?server=<address>&secret=<secret>
tg://webproxy?server=<address>&secret=<secret>
```

`<address>` is the percent-encoded `host` or `host/path`, so a base path travels in
the same parameter as `proxy.example.com%2Fdobry-cola-super-app`. There is no
separate path parameter. A link that carries a path also encodes its secret as
unpadded base64url of `0x70` plus the real secret, so a client without path
support reports an unsupported proxy type and asks the user to update, instead of
normalizing the address to an empty host and offering to connect to it. A
root link keeps the plain secret and still works in those clients. Following
either link shows the address and secret with one connect action. It does not
check status or enable the proxy until that action is invoked. Saved WEB entries
can be shared as a public link or a direct-scheme QR link.

Application proxy changes configure/deconfigure the web transport before MTP
sessions restart. WEB follows the MTProxy path in `Session`, `SessionPrivate`, and
`TcpConnection`; the global Qt proxy remains disabled for it. Proxy rotation and the
settings availability checker deliberately skip inactive WEB entries instead of
opening a WebView or browser.

## 12. Constraints and boundaries

- The listener is IPv4 loopback-only and validates peer, host, and origin.
- Local authentication requires the minted fragment capability.
- The local protocol has no arbitrary destination command. `OPEN` originates only
  from tdesktop and the relay is expected to dial one configured stock MTProxy.
- The configured value is a canonical DNS hostname plus an optional canonical base
  path; HTTPS and port 443 are fixed.
- The bridge URL contains only the domain-separated derived capability, never the raw
  MTProxy secret.
- Frame, WebSocket, HTTP-header, local-client-count, receive-window, and
  pending-uplink bounds prevent unbounded buffering.
- The parent iframe uses only `sandbox="allow-scripts allow-same-origin"`.
- Payloads and secrets are never logged by this client code.
- WEB socket failures do not invoke tdesktop's direct HTTP time-sync fallback.
- `CLOSE` is an abort in both directions: undelivered `DATA` on a closed stream is
  dropped, exactly like tdesktop's existing TCP path, which never half-closes.
- Trust model: the operator is semi-trusted. Their page runs JavaScript inside the
  restricted profile (§8) and never sees the MTProxy secret; the derived
  capability is a stable bearer for (host, secret). A local process cannot obtain
  the capability from the loopback listener (§9); it needs `tdata`.
- Relay authentication (`AUTH_CHAL` / `AUTH_RESP`) is not implemented in v1. Adding
  it requires a fully specified challenge context and server test vectors; it must be
  computed in tdesktop without passing the secret to JavaScript.

## 13. Hosted-server requirements before execution testing

The server must provide all of these before the separate test plan can pass:

1. `https://<hostname>/[<base-path>/]?bridge=<derived-capability>` implements the
   exact derivation, ordinary-site fallback, `MessageChannel`, close, and status
   contracts above, with every carrier endpoint under the same prefix.
2. Its CSP allows framing by random numeric loopback origins. A suitable source is
   `http://127.0.0.1:*`; `X-Frame-Options` must not block the embed.
3. The bridge accepts the v1 `HELLO` frame, establishes a reliable ordered carrier,
   and returns `WELCOME` before stream traffic.
4. The relay implements all v1 stream frames, the implicit 4 MiB windows, and
   deduplicated/cursor-based reliability for polling carriers.
5. Every `OPEN` dials only the configured stock MTProxy endpoint.
6. The hosted code never logs frame payloads.
7. The v1 HTTPS long-poll carrier is operational; the deployed bridge does not
   require a public WebSocket or another carrier.

## 14. Implementation inventory

Core transport:

- `Telegram/SourceFiles/mtproto/web_proxy/web_proxy_frame.{h,cpp}`
- `Telegram/SourceFiles/mtproto/web_proxy/web_proxy_transport.{h,cpp}`
- `Telegram/SourceFiles/mtproto/web_proxy/web_proxy_webview.{h,cpp}`
- `Telegram/SourceFiles/mtproto/details/mtproto_web_proxy_socket.{h,cpp}`

Native WebView support:

- `Telegram/lib_webview/webview/webview_common.h`
- `Telegram/lib_webview/webview/webview_embed.{h,cpp}`
- the macOS, Windows WebView2, and WebKitGTK platform backends

Integration:

- `mtproto_proxy_data.*`, `core_settings_proxy.cpp`
- `connection_tcp.cpp`, `session.cpp`, `session_private.cpp`, `proxy_check.cpp`
- `application.cpp`, `main_account.cpp`
- `boxes/connection_box.{h,cpp}`, `lang.strings`
- `Telegram/CMakeLists.txt`

The client-side implementation is complete without the hosted server. Remaining
verification is the hosted protocol, native-WebView/platform matrix, and explicit
browser-fallback matrix in `docs/web-proxy-test-plan.md`.

## 15. Explicitly deferred

- checking inactive WEB proxies and auto-rotation into them;
- cross-tab or cross-process relay-session resume;
- relay-auth v2;
- alternate bridge paths, ports, or non-HTTPS relay origins;
- expanding `AbstractSocket` with true uplink writable backpressure.

---

### Web Proxy Test Plan (`docs/web-proxy-test-plan.md`)

# Telegram Desktop WEB proxy: server-ready execution test plan

Execute this document only after a hosted relay matching
`../tproxy-server/PLAN.md` is deployed. Record every requested value and artifact in
one run log. Do not put the proxy secret, Telegram auth keys, message contents, or
the loopback fragment capability in logs or screenshots.

## 1. Release gate

WEB is ready for wider testing only when all P0 and P1 cases below pass through the
native WebView on every supported desktop platform, and the explicit
system-browser fallback has no unexplained P0 failure in its browser matrix.

Severity:

- P0: transport boundary, corruption, crash, login, basic send/receive, or total
  reconnect failure.
- P1: large transfer, concurrency, bounded memory, lifecycle recovery, or carrier
  fallback failure.
- P2: status text, browser-specific lifecycle annoyance, or non-blocking polish.

## 2. Inputs to obtain from the server operator

Record these before starting:

| Input | Value |
|---|---|
| Relay hostname | `________________` |
| Bridge deployment/version | `________________` |
| Relay binary commit/version | `________________` |
| Stock MTProxy version | `________________` |
| MTProxy secret | store outside this document |
| Long-poll enabled | yes / no |
| Server log location | `________________` |
| Server metrics endpoint/dashboard | `________________` |
| Test start/end in UTC | `________________` |
| Tester network and country | `________________` |

The operator must confirm:

- the bridge root response can be framed by `http://127.0.0.1:*` and is not prevented
  by `X-Frame-Options`;
- `HELLO` payload `01`, `WELCOME`, implicit 4 MiB windows, and all v1 frame types
  match `docs/web-proxy-plan.md`;
- the polling carrier provides ordered retry/deduplication;
- `OPEN` can dial only the configured local stock MTProxy;
- payload logging is disabled.

Stop if any item is false. A client run cannot produce a meaningful result against
an incompatible bridge.

## 3. Client build and isolation

1. Build the exact candidate commit in Debug:

   ```bash
   cmake --build out --config Debug --target Telegram
   ```

2. Record the commit, build timestamp, OS version, native WebView engine/version,
   fallback browser name/version, and whether either is managed by enterprise policy.
3. Use a disposable Telegram test account and a separate portable/test profile. Do
   not overwrite an existing personal portable profile.
4. Preserve Debug logs for the run, but verify they contain no proxy secret or frame
   payload.
5. Start with WebView inspection and browser developer tools closed; either changes
   scheduling and would invalidate lifecycle observations.
6. Disable unrelated VPNs/proxies for baseline. Record DNS-over-HTTPS, browser proxy,
   and system proxy state.

## 4. Hosted endpoint preflight

Run from the client machine:

```bash
curl -fsS -D /tmp/tproxy-site.headers -o /tmp/tproxy-site.body \
  https://RELAY_HOSTNAME/
curl -fsS -D /tmp/tproxy-invalid.headers -o /tmp/tproxy-invalid.body \
  'https://RELAY_HOSTNAME/?bridge=invalid'
```

Verify:

- TLS certificate and hostname are valid with no warning or redirect to HTTP;
- `/` looks like the intended ordinary site and contains no transport error details;
- an invalid or missing `bridge` query returns the same ordinary site without
  transport-branded errors;
- server unit tests pass both capability vectors from `docs/web-proxy-plan.md`;
- the valid bridge response has a compatible `frame-ancestors` CSP and no
  incompatible `X-Frame-Options`, verified without recording its query;
- transport APIs reject unsupported methods and malformed session identifiers;
- server health shows the stock MTProxy backend reachable.

Delete the captured bodies after inspection if the deployment embeds any
configuration. Keep sanitized headers with the run artifacts.

## 5. Configuration and first connection (P0)

1. Launch the candidate.
2. Open Settings -> Advanced -> Connection type -> Proxy settings -> Add proxy.
3. Select `WEB`.
4. Enter the relay hostname only and the MTProxy secret.
5. Confirm that these invalid inputs are rejected without saving:

   - a value containing `http://` or `https://`;
   - a hostname with an explicit port;
   - a value with username, path, query, or fragment;
   - an IPv4 or IPv6 address, including shorthand forms such as `127.1`,
     `0x7f.1`, `0177.0.0.1` and `1.2.3`;
   - a single-label name such as `localhost`;
   - an invalid IDNA name, empty label, overlong label, or trailing dot;
   - invalid or unsupported MTProxy secret;
   - an `ee` TLS-emulation MTProxy secret;
   - empty hostname or secret.

6. Save the valid entry and enable it.
7. Confirm no system-browser tab opens.
8. Confirm exactly one hidden platform WebView loads
   `https://<canonical-host>/?bridge=<43-char-capability>#android=<43-char-nonce>`.
9. Confirm the settings row moves from `connecting…` to `online` within ten seconds.
10. Confirm Telegram loads dialogs and receives updates without a Telegram WebView
    window, Qt widget container, taskbar item, or dock item appearing.

Pass conditions:

- no crash, assertion, TLS warning, CORS/LNA prompt, mixed-content error, or iframe
  refusal;
- server sees one relay session and the expected MTProxy stream connections;
- the WebView requests exactly the canonical bridge URL and then scrubs its query
  and fragment from history;
- the page, DOM, network request headers, and console never contain the
  MTProxy secret;
- closing proxy settings does not affect the connection.

Verify both canonical link forms with the same hostname and secret:

```text
https://t.me/webproxy?server=proxy.example.com&secret=000102030405060708090a0b0c0d0e0f
tg://webproxy?server=proxy.example.com&secret=000102030405060708090a0b0c0d0e0f
```

Each must show a confirmation containing the canonical hostname and secret, no port
or status row, and one connect action. Dismissing it must not save or enable the
proxy. Sharing a saved WEB entry must reproduce the public form with `server` and
`secret`, without `port`. After disabling the saved proxy, its row must show
`not tested`; re-enabling that exact entry must replace it with the live transport
state. Inactive WEB rows must never start a proxy checker, WebView, or browser tab.

Repeat once with an IDN hostname on a TLD that Qt renders in Unicode (`.de` or
`.com`, e.g. `bücher.de`, not `.example`): the entry is stored as `xn--bcher-kva.de`,
the hidden WebView connects, and the capability derived for it is byte-identical on
a Qt 5.15 Windows build and a Qt 6 macOS/Linux build. Also confirm that
`xn--strae-oqa.example` is stored unchanged on both.

Repeat once with a profile secret that is valid MTProxy syntax but is not configured
on staging. The ordinary-site fallback must match other root responses. The row must
show `connecting…` while the hidden WebView is retried with growing delays (2 s,
4 s, 8 s); after the third consecutive failure — at most ~2 min including the
extended handshake — the client must offer to open the proxy page in the browser,
and keep retrying the hidden WebView every 30 seconds afterwards. Cancel the offer
and confirm no browser opens; then accept it and confirm exactly one numeric
`http://127.0.0.1:<ephemeral>/#<capability>` tab opens, and that no loopback port
was listening before that click.

## 6. Network-origin invariant (P0)

While WEB is the only enabled Telegram proxy, collect connection ownership with an
OS tool. On macOS, for example:

```bash
lsof -nP -iTCP -sTCP:ESTABLISHED | egrep 'Telegram|Chrome|Chromium|Edge|Safari|Firefox'
```

Also capture a short packet trace or firewall connection log if permitted.

Verify:

- in the primary mode, Telegram Desktop's WebView/WebContent process owns the HTTPS
  connection to the relay and no loopback WebSocket is connected;
- Telegram's ordinary MTProto sockets have no external connection to the relay,
  stock MTProxy, or Telegram DCs; the relay connection belongs only to the platform
  WebView engine;
- a WEB connection failure does not trigger tdesktop's HTTP time-sync fallback;
- DNS resolution of the relay is attributable to the WebView/system resolver, not a
  custom proxy/DC resolver in tdesktop;
- external carrier traffic remains inside the WebView-owned TLS connection.

Repeat after explicitly accepting browser fallback. In that mode Telegram Desktop
must connect only to `127.0.0.1:<ephemeral>` for this transport, and the browser must
own the external HTTPS connection to the relay.

Account for unrelated Telegram HTTP traffic such as update checks before declaring
a failure. The invariant applies to the MTProto transport, not every auxiliary HTTP
request made by the application.

## 7. Functional traffic (P0/P1)

Run in order, checking both client behavior and relay/MTProxy stream metrics:

1. Fresh-account login, including code entry and 2FA if available. P0.
2. Dialog/history load and live incoming updates. P0.
3. Send and receive plain messages in private and group chats. P0.
4. Send and receive stickers, reactions, edits, deletes, and read receipts. P1.
5. Download thumbnails and several small media files. P0.
6. Download one file larger than 1 GiB. P1.
7. Upload one file large enough to exceed the 4 MiB window many times. P1.
8. Stream a video while downloading another file. P1.
9. Open media from CDN-backed storage and confirm shifted/CDN DC streams work. P1.
10. Leave the client idle for 30 minutes, then send and receive immediately. P1.
11. On all-other platforms (WebKitGTK), download a large file while polling is
    throttled (rate-limit the link so the relay coalesces `DATA` into 1 MiB
    frames, i.e. ~1.4 MiB base64 native messages); the download must complete
    with a matching hash and no carrier restart. P0.

For large transfers record:

- bytes and final content hash;
- average and minimum throughput;
- tdesktop, WebView/WebContent, fallback browser when used, and relay peak memory;
- number of logical streams and stock-MTProxy sockets;
- reconnect/retry count;
- pending-uplink overflow or flow-control errors.

Run the same payload once through direct MTProxy and once through tdesktop's built-in
HTTP transport under the same controlled capacity/RTT when those controls are
reachable. On a controlled link with at least 100 Mbit/s capacity, WEB should
sustain at least 40 Mbit/s at 200 ms web-engine-to-relay RTT and 20 Mbit/s at 500 ms,
through the hidden WebView. Repeat the browser-specific target with the fallback tab
in both foreground and ordinarily hidden states.
Also report WEB/direct-HTTP ratios for message round-trip p50/p95 and bulk transfer;
route differences must be reported separately from transport overhead.

No transfer may corrupt, silently truncate, duplicate an upload, or grow client
memory without returning toward baseline after completion.

## 8. Multiplexing and concurrency (P1)

1. Start at least 16 simultaneous media downloads across chats.
2. Send messages continuously during the downloads.
3. Start a large upload at the same time.
4. If multi-account is available, sign in to two disposable accounts and generate
   traffic on both.
5. Confirm all logical streams use the same native bridge and relay session. Repeat
   through fallback and confirm one authenticated local WebSocket/browser session.
6. Confirm one slow or window-exhausted stream does not block unrelated streams.
7. Cancel half the transfers and verify the corresponding `CLOSE`s release relay and
   stock-MTProxy resources.
8. Let the remainder finish and compare hashes.

Monitor thread sanitizer output if a TSan build is practical. Otherwise run for at
least two hours while watching for cross-thread QObject warnings, stale stream
delivery, stream-id mixups, growing queues, and use-after-free crashes.

Add a focused destruction race run that repeatedly opens and destroys logical WEB
sockets while the worker concurrently delivers `connected`, `DATA`, `CLOSE`, and
failure notifications. Run it under ASan and TSan where supported. No notification
may begin after synchronous stream unregistration returns, and no callback may run
against a socket whose destructor has started.

## 9. WebView, browser, and transport lifecycle (P0/P1)

The Settings code `externalweb` toggles a process-local test override. The first
invocation blocks and tears down the hidden WebView carrier; the second removes the
block and starts a fresh WebView candidate immediately. Each invocation shows its
new state in a toast. Use it to drive the fallback and recovery cases without
changing the proxy or network.

Execute each case from a connected baseline:

| Case | Expected result |
|---|---|
| Hidden WebView unsupported on the platform | fallback is offered immediately, once; no WebView is created and nothing is retried |
| Initial WebView cannot be created | fallback is offered immediately; no browser opens without confirmation; retry begins after 30 seconds |
| Initial WebView handshake stalls | the deadline is extended while the bridge reports `connecting`/`reconnecting`, up to 45 s; the row stays `connecting…`; fallback is offered only after the third consecutive failure |
| Active WebView stops answering probes | logical sockets disconnect within ten seconds; the row shows `connecting…`; a retry starts after 2 s and no fallback box appears on the first failure |
| Cancel fallback offer | no browser opens; hidden WebView retries continue every 30 seconds |
| Confirm fallback offer | fresh fragment capability; one tab authenticates; Telegram reconnects |
| WebView retry succeeds during fallback | browser carrier closes only after WebView `WELCOME`; logical sockets reconnect through WebView |
| Close the fallback tab | fallback sockets disconnect; no tab auto-reopens; WebView retries continue |
| Refresh the fallback tab | consumed capability is not reusable; another explicit `Open browser` uses a fresh capability |
| Quit browser | same as fallback-tab loss; tdesktop remains responsive |
| Restart browser and use Open browser | clean fallback reconnection |
| Restart tdesktop with WEB saved/enabled | one hidden WebView starts; no browser tab opens; stale fallback tab cannot attach |
| Disable WEB | WebView, listener, and fallback socket close; normal connection policy resumes |
| Switch WEB A -> non-WEB -> WEB A | clean teardown and reactivation |
| Edit WEB hostname or secret | old transport closes; new settings take effect; no old relay traffic remains |
| System sleep 5 minutes | reconnect after wake without corruption or permanent spinner |
| Network down/up | WebView carrier and MTProto recover within normal retry bounds or offer fallback |
| RTC unavailable or disabled by browser policy | carrier remains usable; lifecycle guard failure is silent and bounded |

For Chromium browsers, confirm the page has one open `RTCDataChannel` between two
same-page peer connections in `chrome://webrtc-internals`, with the selected ICE
pair confined to `127.0.0.1` and no STUN or TURN server. In `chrome://discards`,
record the fallback tab's automatic freeze/discard eligibility and reasons; active WebRTC
should protect it from normal automatic freezing. Then leave the tab hidden for at
least 15 minutes with developer tools closed while continuously exchanging Telegram
traffic. Carrier traffic must continue without minute-scale stalls. Repeat once
with browser energy saving enabled. Manual or urgent discard may still terminate
the fallback carrier and must recover through an explicit `Open browser` as described
above. Run the corresponding 15-minute hidden test through the native WebView first;
it must not depend on the fallback page's RTC lifecycle guard.

## 10. Carrier reliability and server faults (P1)

Coordinate these with the server operator:

1. Drop an empty long-poll request or response. Verify bounded retry and continued
   Telegram usability.
2. Drop one nonempty downlink response after the relay has assigned a cursor. Verify the next
   request replays it once and tdesktop receives bytes once.
3. Drop an uplink response after the relay has processed the sequence. Verify retry
   deduplication prevents a second write to stock MTProxy.
4. Add 1%, then 5%, packet loss and 200-500 ms latency. Verify ordered recovery.
5. Restart the web relay while preserving or intentionally discarding session state;
   record expected bridge status and MTProto reconnection.
6. Restart stock MTProxy only. Affected logical streams must close/reconnect without
   breaking the active carrier.
7. Return malformed frame length, unknown frame type, invalid stream zero usage,
   zero/invalid `WINDOW`, and data beyond granted credit in a controlled staging
   environment. The client must close the carrier/streams cleanly, remain responsive,
   and show no memory error.
8. Send `BYE`. Current streams must fail and reconnect according to the bridge/server
   recovery policy.
9. Race client-side close against backend EOF and delayed `DATA`/`WINDOW`/`CLOSE`.
   The closed stream may discard late frames, but unrelated streams must remain live.
10. Stall downlink reads and send highly fragmented one-byte/empty-control patterns.
    Verify the relay enforces byte and item budgets, returns at most 4096 frames in
    one body, coalesces adjacent credit, and returns near baseline heap use afterward.
11. Issue bridge requests past the per-IP burst/rate and unused-token limits. Other
    source IPs must retain bounded access, expired tokens must release their slots,
    and the public fallback must not expose why a request was limited.

## 11. Explicit browser-fallback loopback validation tests (P0)

Accept the fallback offer, then use a purpose-built local test client; do not paste
the real capability into shell history.

Verify rejection of:

- a connection to a non-loopback interface (the port must not be listening there);
- wrong `Host`;
- absent, wrong, or cross-origin `Origin`;
- duplicate HTTP header names, including `Host` and `Origin`;
- invalid WebSocket key/version/upgrade headers;
- an unmasked client frame;
- invalid fragmentation or control-frame fragmentation;
- a WebSocket message larger than 2 MiB;
- an HTTP header block larger than 16 KiB;
- a GET with `Content-Length` or `Transfer-Encoding`;
- a silent connection or unauthenticated WebSocket held past ten seconds;
- first message not being an auth object;
- wrong, reused, expired, or empty capability;
- binary data before authentication;
- non-status text after authentication.

Then verify:

- a consumed capability cannot authenticate a second socket;
- minting via `Open browser` invalidates any unconsumed earlier capability;
- a newly authenticated browser replaces the old one and forces logical reconnect;
- `GET /` never includes the bridge capability (`bridge=`) or MTProxy secret; the
  bridge URL arrives only as the `{"t":"bridge"}` text message on the
  authenticated WebSocket;
- no loopback port is listening while the fallback was never requested, and the
  listener closes once a tab has authenticated, when the capability expires
  unused, and when the hidden WebView takes over;
- pings before authentication are not answered;
- the local protocol cannot request an arbitrary host or port;
- malformed input causes bounded close/failure, not a crash or growing buffer.
- the loopback parent CSP contains a fresh nonce and does not permit arbitrary
  inline script.

## 12. Persistence and compatibility (P1)

1. Save WEB, quit cleanly, relaunch, and verify hostname/secret/type survive with
   port fixed to 443.
2. Switch among System, Disabled, SOCKS5/HTTP/MTProxy, and WEB; verify Qt's global
   application proxy is never set to the WEB hostname.
3. Corrupt a copy of the serialized proxy type to an unknown future value and verify
   the candidate skips it instead of crashing. Never modify the only real settings
   file.
4. Launch an older binary against a disposable copy of settings containing WEB and
   document its behavior. The new binary handles unknown types; old binary
   behavior may still require a release-note warning.
5. Confirm WEB shares as `t.me/webproxy` or `tg://webproxy`, never as `tg://proxy`,
   that the `tg://webproxy` confirmation shows the sponsored-proxy warning, and
   that inactive WEB rows show `not tested` without creating a checker, WebView,
   or browser during availability checks or proxy rotation.
6. Confirm a saved and enabled WEB proxy creates one hidden WebView at application
   startup and opens no browser tab. Force the WebView to fail, cancel the offer, and
   confirm restart/retry still never opens a tab without confirmation.

## 13. WebView and fallback-browser matrix

At minimum execute sections 5, 6, 7 (small traffic), 9, and 11 on each supported
combination available:

| OS | Primary WebView | Fallback browser | Version | Result |
|---|---|---|---|---|
| Windows | WebView2 | Chrome | | |
| Windows | WebView2 | Edge | | |
| Windows | WebView2 | Firefox | | |
| macOS | WKWebView | Chrome | | |
| macOS | WKWebView | Safari | | |
| macOS | WKWebView | Firefox | | |
| all-other desktop | helper-process WebKitGTK | Chrome/Chromium | | |
| all-other desktop | helper-process WebKitGTK | Firefox | | |

For the primary path, verify the WebView is native, hidden, unique per process, and
has no Telegram `RpWindow` or embedded widget. On all-other platforms verify the
WebKitGTK helper process remains the owner and that the restricted carrier still
comes up in the Flatpak, Snap and AppImage packages (the 4.0/4.1 API path now
enables the WebKit web-process sandbox). For fallback, pay special attention to
iframe CSP, loopback WebSocket Origin, mixed-content rules, background-tab
throttling, confirmation-only browser launch, and managed-browser policies.

Restricted-profile probe, on every platform, with the debug build connected through
the hidden WebView: the debug log must not contain
`Restricted WebView profile probe failed` (the client evaluates
`typeof RTCPeerConnection === 'undefined' && typeof WebTransport === 'undefined'
&& typeof WebAssembly === 'undefined'` in the top frame and in a freshly created
`about:blank` iframe after the first bridge message). Additionally, with the
webview inspector available on a development build of the bridge page, confirm
that `new WebSocket('wss://example.com')` throws or fails, `fetch('https://example.com')`
is blocked, and `navigator.mediaDevices`, `navigator.credentials` and `Notification`
are `undefined`. On Windows, capture the network during connect and confirm no
SmartScreen (`*.smartscreen.microsoft.com`) traffic from the WebView2 process of
the restricted profile.

## 14. Unreliable-MTProto network field test (P0)

This is the product hypothesis test and cannot be replaced by a lab run.

1. Use a network where direct Telegram and ordinary MTProxy are demonstrably
   unreliable.
2. Record those comparison results immediately before WEB testing.
3. Confirm the relay's ordinary site is reachable in the chosen browser.
4. Enable WEB and repeat login/history/message/media cases.
5. Capture sanitized connection ownership and traffic metadata.
6. Confirm the observable external client is the platform WebView and all carrier
   requests are ordinary same-origin HTTPS; no public WebSocket is required. Repeat
   once through explicit fallback and confirm the observable client then becomes the
   selected browser.
7. Repeat at two times of day and, if possible, through two access providers.

Pass means WEB works while both direct Telegram and ordinary MTProxy controls fail,
without requiring a browser certificate exception or a nonstandard network setting.

## 15. Deployment-boundary checks (P1)

1. Run the Go unit suite and race detector, including concurrent carrier, bootstrap,
   queue-fragmentation, downlink-frame-count, and goroutine-shutdown cases.
2. Validate the shipped Caddyfile with the pinned Caddy build and exercise public
   root, bridge root, API, static asset, and error routes through Caddy.
3. Confirm `/debug/pprof/` is 404 on the admin listener by default and appears only
   when `enable_pprof` is explicitly enabled.
4. Confirm the MTProxy source archive matches the pinned commit checksum and its
   Makefile executes as `mtproxy`, not root.
5. From shells running as `caddy` and `tproxy`, verify the MTProxy command line is
   hidden by the supplied `/proc` restrictions. Record that root remains able to
   inspect the stock upstream `-S` argument.
6. Start sessions with active backend reads and writes, then stop the relay. Shutdown
   must complete inside its configured deadline with no backend goroutine left.

## 16. Exit criteria and run report

Attach or link:

- client/server/bridge/MTProxy versions;
- sanitized server headers and logs;
- Debug build result;
- functional and lifecycle checklist;
- transfer hashes and performance/memory table;
- connection-owner evidence;
- long-poll retry/replay evidence;
- loopback and protocol validation results;
- WebView and fallback-browser matrix;
- unreliable-MTProto network comparisons and outcome;
- every defect with severity, exact reproduction, expected/actual result, timestamps,
  and relevant sanitized logs.

Final decision:

| Gate | Result |
|---|---|
| All P0 passed | |
| All P1 passed or explicitly waived | |
| Logs contain no secrets or payloads | |
| Memory and queues bounded | |
| Long-poll reliability proven | |
| Unreliable-MTProto network result confirmed | |
| Ready for wider testing | yes / no |

---

### Telegram SourceFiles Test Docs Account Fixtures (`Telegram/SourceFiles/test/docs/account-fixtures.md`)

# Account fixture secrets

[Harness guide](../README.md) · [Helper index](helpers/README.md)

The golden `test_TelegramForcePortable` folder may carry secrets beside
`tdata`. Today those are `2svpassword.txt` and `test_gram_account.txt`.
`2svpassword.txt` is the test account's two-step verification (cloud)
password, which the server demands on `wallet.exportSecretPhrase`,
`wallet.replaceWallet` and the password route of `wallet.disableBackup`
(`PASSWORD_MISSING` without it, `SRP_ID_INVALID` for a fabricated proof).
`wallet.enableBackup`, which has no password field, and the proof route of
`wallet.disableBackup` prove key ownership instead of sending the password. A
scenario reads it at runtime through
`Test::TwoStepPassword()` — whitespace-trimmed, `std::nullopt` when the file
is absent or blank, always `std::nullopt` outside test-agent mode — and the
generic `Test::FixtureSecret(name)` reads any sibling file the same way. Both
look in the live portable folder first and then in the golden sibling, so a
file added to the golden folder after a marked live copy was made is still
found.

Type the value into the product's own `PasscodeBox` so the real SRP path
computes the proof; never assemble an `InputCheckPasswordSRP` by hand. When a
selected check needs the password, the file's presence is gated before the
scenario is authored and its absence blocks the task (`pipeline.md`, Phase 3
step 5); a scenario that still reaches a missing file at runtime refuses with
a named fixture gate (`fixture gate: 2svpassword.txt absent`), never a
product FAIL, and never substitutes a driven answer for the leg. The value
never leaves the process: no overlay literal, `Note`, check `details`,
capture, evidence file, `work/` artifact, receipt, prompt, or environment
variable recorded anywhere may carry it. The overlay is published into the
task repository with the result, so a literal there is a leak, not a
convenience.

`test_gram_account.txt` is the owner's funded golden wallet: the test
account's server-created wallet, recorded by hand and funded at its address.
The format is the word lines until the first empty line, then the address as
the app shows it; never assume a fixed word count, because a confirmed key
rotation lengthens it. A scenario reads it at runtime through
`Test::GramAccount()` — the live copy first, then the golden sibling — which
returns the words, the address line and `addressRaw`, the address normalized
through `Wallet::CanonicalAddress` so either form compares; `std::nullopt`
when the file is absent, has no word line or no address line, and always
outside test-agent mode. The golden sibling is consulted only when the live
copy is absent or blank — a present but malformed live copy answers
`std::nullopt` rather than masking a different golden file, because after a
rotation the live copy is the authority. The words are under the same rule as
the password: typed or compared in process only, never in overlay code, a
`Note`, check `details`, a capture, an evidence file, a `work/` artifact, a
receipt or a prompt. Only the COUNT of the words and the address may be
recorded.

A campaign that declares it needs the funded wallet is gated on it at
campaign start, before anything is spent: the file must be present;
`GramAccount()->addressRaw` must equal `*wallet.address()` once
`WalletRefreshSettled` accepted the served state — a mismatch FAILS the run
and is never repaired by `/wallet_reset`, `wallet.replaceWallet` or a minted
wallet; and the balance must cover every planned live leg plus a margin — a
shortfall refuses with a named fixture gate (`fixture gate: golden wallet
balance short`) and the performer publishes the task-local Block naming the
address and the amount required, never a driven workaround. Campaigns on the
golden wallet never reset it and spend only what a leg needs; state the count
of live legs the run issued. A live leg also satisfies the effective
`wallet_transfer_min_nanos` floor in force for that account, in addition to
the conservative 0.3 GRAM evidence guidance, which the configured floor does
not replace. No campaign spends a sub-floor amount to observe how the
backend answers: the client's own amount refusal is established from
controlled configuration and local send fixtures, and an at-the-floor
control may stop at fee preview.

After a confirmed key rotation the scenario calls
`Test::RewriteGramAccountWords(newWords, addressRaw)` from the stage that
observed the confirmation, before any later leg can cut the run off. It
rewrites the word lines of every existing copy — the marked live copy AND the
golden sibling — through `QSaveFile`, keeping the empty line and the address
line; each copy is parsed on its own and rewritten only when its own address
is the rotated wallet's (the scenario passes the served raw address); a copy
naming another wallet is left alone and reported `false`. It returns which
copies were written; the stage checks both and re-reads `Test::GramAccount()`
to compare the count and the words in process. The golden write is the one
owner-decided exception to the read-only golden folder; nothing else in the
harness or in a campaign ever writes there.

## Required wallet companion references

After the address gate, use [P0 reconciliation](helpers/test_gram_reconcile.md)
before any wallet leg. Read [secrecy proof](helpers/test_secrecy_scan.md) before
a secret-bearing run; the account prerequisites here do not replace its log
selection, control and canary requirements.

## Public identity and log coverage

For publication-identity or launch-log coverage checks, read the separate
[publication and coverage contract](helpers/test_publication_identity_scan.md).
Those claims do not certify fixture secrecy.

---

### Telegram SourceFiles Test Docs Adding Helpers (`Telegram/SourceFiles/test/docs/adding-helpers.md`)

# Adding or changing harness helpers

[Harness guide](../README.md) · [Helper index](helpers/README.md)

A permanent helper needs discoverable documentation without making every
product task read its implementation history. Apply this format when adding a
helper or changing its public contract. Existing reference prose was moved
from the old README; improve the affected section when needed, without
rewriting unrelated references.

## Where each kind of information belongs

| Location | Contents |
| --- | --- |
| `test/README.md` | Short mandatory rules and links chosen by task need; keep under 8 KiB. |
| `test/docs/helpers/README.md` | One row per module: link, kind and one-line purpose. |
| `test/docs/helpers/test_<name>.md` | The module's usage, requirements, observations, refusals, cleanup and verification. |
| `test/docs/<topic>.md` | A cross-module procedure such as account setup, scenario teardown or launch behavior. |
| `test_<name>.h` | Signatures and concise caller contracts: units, ownership, lifetime and return/refusal semantics. Link the reference for examples and longer explanations. |
| Task evidence/history | Full discovery chronology, repeated run logs and implementation deliberations. |

Use the existing module reference when extending a helper; do not add another
README paragraph for each new function. A separate module gets its own page.
Group related operations with headings. Link a shared procedure instead of
copying it into every helper page. Keep a brief historical example only when
it explains a non-obvious constraint; move the full chronology to task evidence.

Keep the mandatory README small by replacing detail with a precise routing
link, not by making an essential prerequisite optional. Its topic links tell
a reader when account, media, input, capture or teardown guidance is required.
Do not add a rule requiring all helper pages or the diagnostic history to be
read. A helper-specific platform caveat belongs with that helper.

## Module reference template

Use the module stem for the filename so a source/header search finds its page.
Fill the relevant sections concretely; omit a section only when it is truly
inapplicable, and say when an operation has no cleanup or platform restriction.
Keep the opening purpose and selection guidance short enough to choose a
helper before reading its complete contract.

```markdown
# test_<name>

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Reusable API / Harness self-test / Internal dependency.
Source contract: [test_<name>.h](../../test_<name>.h).

## Use when

What observable need this answers; when a nearby existing helper is preferable.
For an internal dependency, name its real callers instead of suggesting overlay use.

## Preconditions and limits

Required process/session/widget state, platform support, coordinate/time units,
identity or generation requirements, and what this helper does not establish.

## Minimal use

One small example against the real API. Include its readiness gate, assertion
and required cleanup; keep fixture values synthetic and secrets out of text.

## Readings and refusals

Return values and log markers; what an accepted reading proves; named refusals
and which ones make a measurement undecidable. Never equate a refusal with PASS.

## Lifetime and cleanup

What is owned or retained, cancellation, restoration, and onFinish obligations.
Name any lasting side effect, including clipboard changes or account writes.

## Verification

The self-test entry or focused command, required environment, a negative control
or deliberate mutation it catches, and known platform/evidence limitations.
Distinguish an executed check from a proposed check.

## Related guidance

Links to prerequisite topic guides and related helpers, without copying them.
```

A pure self-test page must name the production harness behavior it protects,
its fixture, expected verdict and teardown. It is not a general-purpose helper
and need not be used by product overlays. An internal dependency page names
its actual callers; an absence of direct overlay includes is not non-use.

## Before finishing the change

- Update the module page and its one-line index entry in the same change as the
  API. Update callers' topic links when a prerequisite or contract moves.
- Read the header and the example together: signatures, units, ownership and
  refusal behavior must agree. Do not invent an example API or promise a
  negative control that was never run.
- Link to the reference from the header when detailed guidance exists. Keep
  comments about the callable contract; do not paste the reference into the
  header or the mandatory README.
- Preserve useful constraints when moving old prose. Search existing references
  and diagnostic rows before creating a duplicate explanation.
- Check relative links and heading anchors, the index's coverage of public
  helper modules, and the mandatory README's byte size. Keep source citations
  symbolic rather than line-number based.

Documentation reorganization alone needs link/content checks, not a Telegram
build. Changes to helper behavior still need their normal focused evidence
and review; this format does not replace that workflow.

---

### Telegram SourceFiles Test Docs Chat Lists (`Telegram/SourceFiles/test/docs/chat-lists.md`)

# Resolving a chat message list

[Harness guide](../README.md) · [Helper index](helpers/README.md)

## Resolving a chat's message list

A chat's message list is not necessarily `HistoryWidget` / `HistoryInner`.
The legacy main-chat stack is `HistoryWidget` (`history/history_widget.h`)
plus `HistoryInner` (`history/history_inner_widget.h`); the modern stack is
`HistoryView::ChatWidget` (`history/view/history_view_chat_section.h`) plus
`HistoryView::ListWidget` (`history/view/history_view_list_widget.h`); and the
admin log is a third stack again, `AdminLog::InnerWidget`, which implements
`HistoryView::ElementDelegate` itself and constructs no `ListWidget`.
`ChatWidget` already backs replies threads, forum topics and both monoforum
and Saved Messages sublists (both are `Data::SavedSublist`); scheduled
messages, pinned messages, welcome messages and the chat preview popup each
construct their own `ListWidget`.

A scenario that assumes one named widget renders every chat fails opaquely.
Publishing a live widget from `HistoryWidget`'s construction seam and then
walking the window logged `RENDER_ROUTES: published=0 walked=0 chain=[none]`
against 403 root descendants — the `HistoryInner` was never constructed, and
the walk found none either — while the window grab plainly showed the chat
painting the row.

The rule is therefore about resolution, not about a name, and stays true as
the migration proceeds. Resolve the list from the section that constructs it
and prove the candidate owns the message:

- publish the exact live object at the statement that constructs the list —
  `_inner = _scroll->setOwnedWidget(object_ptr<ListWidget>(` in
  `history/view/history_view_chat_section.cpp` — through
  `Test::PublishLiveWidget`;
- keep `Test::FindVisible<HistoryView::ListWidget>` as an independent
  cross-check and log both answers;
- accept a candidate only when its own lookup answers for the exact message:
  `list->viewByPosition(item->position())` must return an `Element` whose
  `data()` is that item and whose `delegate()` is that same list;
- scroll with the product's own entry points, `showThread` and then the list's
  `showAtPosition`; decide where the row sits with the list's public
  `elementIntersectsRange(view, from, till)`, and frame it with
  `Test::CaptureMappedRect`.

`item->mainView()` is not the `Element` the visible section owns, so it is
neither a readiness gate nor a geometry oracle. For a row a `ListWidget` was
painting it answered `view=1` with `viewHeight=0` and `itemTop=-3`; use the
list's own `viewByPosition` for both jobs instead.

The parent chain a resolved `ListWidget` produced, as the shape to expect, was
`ListWidget < Ui::ElasticScroll < ChatWidget < MainWidget < Ui::RpWidget <
Ui::RpWidget < MainWindow`.

---

### Telegram SourceFiles Test Docs Diagnostics (`Telegram/SourceFiles/test/docs/diagnostics.md`)

# Failure diagnosis

[Harness guide](../README.md) · [Helper index](helpers/README.md)

Search for the observed symptom or named helper and read the matching row.
Do not read this entire history for an unrelated scenario. Each row retains
its measured cause, repair and limits; a different failure needs evidence.

| Symptom | Likely harness cause | Repair |
| --- | --- | --- |
| Run exits with no `TEST_COMPLETE` and no `SCENARIO_RESULT` | `TDESKTOP_TEST_WATCHDOG` is seconds; a millisecond-shaped value (for example `600000`) used to arm a multi-day timer that `test-run --deadline` always outruns, so the watchdog never wrote the markers. | Override is seconds in 1..600; implausible values fall back to 120s and the armed duration is logged next to `SCENARIO_START`. Use a value in that range, or omit the variable. |
| The run's only FAIL is `TEST_RESULT: FAIL: fixture gate: session gate - case=non-test-primary: the primary window shows an authorized account on the Production environment (index=0 primary=1 session=1 active=1), ...`, written before any `TEST_STEP`; every stage reads `TEST_RESULT: N/A: <stage> - fixture gate: session gate refused the scenario (case=non-test-primary)`, then come `SCENARIO_RESULT: FAIL (failures: 1, skipped: <every stage>)` and `TEST_COMPLETE`; the `NOTE: SESSION_GATE: account ...` rows show that account as `authorized=1 environment=Production primary=1` beside the Test account | Not the product: the marked live copy holds an authorized account on a non-Test environment beside the Test account, and the primary window opens on it (it was the active account when the copy last quit). `ApplySessionGate` (`test_session_gate.cpp`), called by `Runner::start()`, refuses because every stage and every shared helper that reads `Core::App().domain().active()` or `Core::App().activePrimaryWindow()` would act as that account. The sibling cases: `non-test-active` is the same hazard through `domain().active()` alone; `selection-unreadable` is a primary window whose account and shown session are missing or disagree; `domain-not-started` is a passcode-locked copy whose accounts cannot be read; `invalid-declaration` is a production-leg declaration with an empty or multi-line label, made twice or for another runner. A scenario using the Test-session helper reads one of its own cases instead: `no-test-account` (the copy holds no authorized Test account), `helper-selection-split` or `helper-original-unauthorized` (the original selection could not be restored as a whole, so nothing was switched), `switch-not-applied` (the read-back did not prove the switch; the finish backstop switches back), `helper-with-declaration` or `invalid-helper` (a misused request). | Make the stages run on a Test session, never around the gate. Repair 1, the default: `const auto session = Test::UseTestSession(runner);` in `SetupScenario` and `Test::AppendTestSessionRestore(runner, session);` as the scenario's last stage - the gate then switches the primary window to the lowest-index authorized Test account through the product's own switch and proves it by read-back before any stage, and the last stage switches back and proves the restore; the switch carries the product's presence updates and persists the active index locally (see the [session-gate contract](helpers/test_session_gate.md)). Repair 2, only when the task explicitly authorizes legs on that account: `Test::DeclareProductionLegs(runner, { .task = <task id>, .legs = <legs> })` in `SetupScenario` instead of the helper, never beside it. Never edit or bypass the gate, never log out, reset, re-copy or re-authorize any account, and never reach the account by another route. Confirm the gate with `AppendSessionGateSelfTest`. |
| Timeout before the task fixture exists | Generic account/chats preamble or unrelated navigation. | Remove unused startup gates; inject the fixture or publish the production object at its real seam. |
| Assertion/crash in a stage action | `.run` dereferenced an async object or a raw pointer outlived its owner. | Use `actOnWidget`, `QPointer`, or live publication. |
| Wrong custom widget/button found | Unsafe Qt typed search or ambiguous descendant order. | Use the RTTI finders; for repeated/layer-owned controls publish the exact object/action. |
| Expected mismatch reported as timeout | Product outcome was put in `until`. | Wait only for propagation/generation; assert and log the outcome in `then`/`captureAndInspect`. |
| A stage times out at exactly the length of a product deadline it was waiting through - `stage timed out: ... - waited 60000 ms` on a stage whose `until` awaits an outcome the product bounds with its own timer - while the product's own fail row, which that `until` already accepts, would have arrived a moment later | The stage timeout coincides with the product's deadline, so the harness ends the stage before the product decides and the run records an instrument timeout where a decided negative existed. The row above is the neighbouring fault: there the outcome was put in `until`; here it is in `then` already, but the wait was not given room to reach it. | Make a stage that waits through a product timer outlast it by a clear margin - 75 s over a 60 s deadline - accept the product's own fail row as the decided negative, and keep the outcome assertion in `then`. |
| A request never answers although the server did: no `.done()`, no `.fail()`, no product row, the stage waits until its timeout, while the application Debug log repeats `RPC Info: error received, code 500, type <T>` for the same request | `Instance::Private::onErrorDefault` auto-resends every code-500 and negative-code `rpc_error` after a doubling delay and never calls a product callback for it, so nothing a scenario can read ever changes. | Read the `rpc retry code=<code> type=<type> request=<constructor>` rows the permanent seam records into `Test::RpcRetryProbe()` (`test_rpc_retry.h`), through a `mark()` taken immediately before the action, and decide from those rows plus the product's own fail row - never from the stage timeout. `<constructor>` is the boxed body constructor id in hex, compared against the `mtpc_*` constant the scenario cares about. The Debug log's lines stay the fallback for a reader without the harness log. |
| A controlled success fixture never reaches `.done()`, or a later valid answer looks like a stale/missing callback, while the application Debug log shows `RESPONSE_PARSE_FAILED` / `Response parse failed` | A bare TL constructor factory or an incomplete buffer was written into `MTP::Response::reply` and handed to `processCallback`. `MakeDoneHandler` calls `senderRequestHandled` before `Result::read`, returns false on a missing constructor word or truncated body, and `processCallback` then unregisters the request, so the fixture mistake is consumed as a local parse failure. | Deliver through `test_rpc_fixture.h`: typed success takes the boxed `Request::ResponseType`, `DiagnoseControlledRpcResult` refuses empty/truncated/trailing/wrong-constructor buffers **before** `processCallback`, and explicit error/stale routes are separate. Confirm with `AppendControlledRpcSelfTest`. |
| Post-state rows read after a network turn - a chats-list row, a count, a tab strip, a late capture - show the entity that a mocked destructive answer removed, while the readings taken in the answer's own turn passed; a restore stage then reports nothing to restore, and a `Test::DiscriminatingScan` whose control was that restore's request reads a zero control and fails `discriminates` | The overlay answered a server-destructive request locally - the `mock-api` strategy - so the server still holds the data, and a later ordinary product read loads it again and re-creates the local entity the mocked answer removed. It is a fixture artifact, not a product defect: a real deletion would empty the server side. The fixture must not block that read, because the product's later server reads are legitimate behaviour. Observed in `2026/10/12/switch-channel-direct-messages-to-all-after-deleting-the-open-chat` (`work/test.md`, Attempt 1, Run 1): the overlay suppressed `messages.deleteSavedHistory` and answered it with `messages.affectedHistory` through `Test::DeliverControlledRpcSuccess`, which ran the product's deletion synchronously (`Data::SavedMessages::applySublistDeleted()` fired once), and every reading taken in that turn passed. Inside the same delivery the view the product showed after the deletion formed its own `messages.getHistory`; the server answered it about 250 ms after the mocked answer was parsed, still holding the deleted user's message, and `HistoryItem::savedSublist()` with `Data::SavedSublist::applyMaybeLast()` re-created that user's sublist and chats-list row. The after-refresh rows `shows All plus one tab per remaining chat` and `no chats-list row for CJ` (CJ being the deleted user) then failed (`rows=2`, the deleted peer listed again), the late capture showed the deleted tab back, the restore read `nothing to restore: the sublists are present` and sent no `messages.getSavedDialogsByID`, and because that request was the scan's control, the scan's `discriminates` row and its subject row failed with `subject=0 control=0`: four FAIL rows, a recovery leaf and a second run, although the product claim had read positively in the answer's turn. This differs from the controlled-success row above, where the answer never reached `.done()`: here it did, and the product applied it. It differs from the `Test::DiscriminatingScan` row below as well: there the enumeration cannot reach the subject; here the walk could, and the control it was given never happened. | Three steps, for any destructive request answered locally. (1) Read and capture in the stage that delivers the controlled answer, right after `DeliverControlledRpcSuccess` returns and before any network turn. (2) Gate every later post-state reading on a named re-sync reading - whether the entity exists again and holds a server message: when it does, the rows a re-creation can change are `TEST_RESULT: N/A` with a `fixture gate:` reason, and otherwise they are judged as designed. (3) Take the scan's control from a request known to be present in the same window - there, the shown view's own `messages.getHistory` - never from a restore that may have nothing to restore. Never suppress the product's later reads to keep the entity gone. Repaired that way in Run 2 of the same task: no FAIL row, `SCENARIO_RESULT: PASS`. |
| Blank or partial screenshot | Wrong paint owner, animation cache, or viewport clipping. | Use prepared capture, `PanelShowSettled`, the owning ancestor, or `CaptureMappedRect`; for a box inside a layer, `Test::PaintingLayerRoot`; for a `Ui::PopupMenu`, `Test::CapturePopupMenu`. Gate a visible mapped card/row on `Test::MappedTargetReady` / `prepare(owner, origin, rect)` so origin-local containment cannot pass a target that is still clipped. |
| A mapped capture is refused after readiness passed, or a HistoryInner-local (or other origin-local) rectangle was treated as visible while part of it sat outside the painted owner or scroll | Readiness checked the origin widget's own rect, which still contains a child that a negative scroll offset has moved outside `HistoryWidget` / `Ui::ElasticScroll`. `ElasticScroll::viewport()` returns the inner content, so using it as the clipper recreates the same pass. `PaintingLayerRoot` does not name a chat paint owner. | Use `Test::ReadMappedTarget(owner, origin, localRect)`: whole-rect containment in the painted owner and the relevant clipper (`ElasticScroll` itself, or `QAbstractScrollArea::viewport()`). Do not intersect or reframe. `CaptureMappedTarget` refuses an unready reading; `CaptureMappedRect` still refuses a rect that is not fully inside the grabbed widget. Confirm with `test_mapped_target.h`. |
| Old palette/colour sampled | Style had not settled or moved between reference and target. | Use `StyleSettled` and `StyleBaseline`. |
| A night or day variant is identical to the default - every "other palette" capture repeats the palette the run started in - or a toggle's own reading shows nothing changed, `night before=1 now=1` after `Window::Theme::ToggleNightMode()`, with no error and no log line | The theme follows the system dark mode, and the change is refused. `Core::Settings` starts `_systemDarkModeEnabled` true, so following is the default, and the sandbox of the first source below, a fresh copy of the golden portable folder, followed the system. `ChatBackground::nightModeChangeAllowed()` (`window/themes/window_theme.cpp`) takes the mode allowed after a change as `systemDarkMode().value_or(!_nightMode)` while `systemDarkModeEnabled()` holds and as `!_nightMode` otherwise, and allows the change only when that differs from `_nightMode`, so while the platform reports a system mode any change away from it is refused. `Window::Theme::ToggleNightMode()` reaches it through `ChatBackground::toggleNightMode()` and `ChatBackground::reapplyWithNightMode()`, which returns with no change and no log when `_nightMode != newNightMode && !nightModeChangeAllowed()`, and `ToggleNightMode()` returns nothing, so the caller is not told. `Window::Theme::IsNightMode()` does not decide it either: a toggle applies the palette at once, but `_nightMode` is committed only in `Window::Theme::KeepApplied()`, which runs the keep `reapplyWithNightMode()` set up. The product's own route is `Window::Theme::ToggleNightModeWithConfirmation()`, whose confirmation box turns following off (`setSystemDarkModeEnabled(false)`, `saveSettingsDelayed()`) before the toggle; and re-enabling following runs `Core::Application::checkSystemDarkMode()` through `startSystemDarkModeViewer()`, which switches back to the reported mode at once. Observed in `2026/10/12/recognize-tgw-widget-documents` (`work/test.md`, Test 22 in Runs 1-3; the recovery-plan rows "T22 palette" of Runs 1 and 2): Run 1's sandbox was already in night mode (`T22 night mode=1`, `restored=0`), so the day palette was never exercised; Run 2 flipped with a bare toggle and read `T22 palette flip: night before=1 now=1`, and the `A_T22_flipped_palette_*` frames were night frames again; Run 3 paused following, toggled, kept and restored both - `T22 palette flip requested: night before=1 now=0 systemDarkModeEnabled before=1 now=0`, `painted bubble background #effdde`, `T22 night mode restored to 1 (was 1)`. Earlier in `2026/10/08/paint-the-ton-connect-card-balance-like-the-money-card` (`work/test.md`, Attempt 1, Run 2), on another checkout and branch: `S0 fixture: the run starts in the day theme - night=1`, the app following the Windows dark mode, the toggle refused and every capture night; its recovery paused `systemDarkModeEnabled` before each toggle and restored it after the theme restore, with overlay-local helpers. | Switch with `Test::AppendPaletteSwitch` and put it back with `Test::AppendPaletteRestore` (`test_palette.h`), never with a bare `ToggleNightMode()` or a settings snapshot written by hand. The switch takes the confirmation's route without the box - following paused only when `nightModeChangeAllowed()` reads false, then `ToggleNightMode()` and `KeepApplied()` - and decides `applied` from a live palette sample taken after it settled, which must differ from the sample before; a probe that reads the same in both palettes is refused `sample-unchanged`, and a request for the palette already committed is a no-op that succeeds. Treat a refusal as a fixture gate. Capture the variant in later stages and restore in a stage before teardown: the restore toggles the night mode back first, with following paused if it prevents the change, then restores the follow flag and saves the settings, and compares the settled sample, the night mode and the follow flag with the ones the switch found; the module's `Runner::onFinish` restores what a skipped stage left, without waiting for the palette (the `d->allThreads.isEmpty()` row below). Never synthesize the platform's reported mode or change the OS appearance. Confirm with `AppendPaletteSwitchSelfTest`, whose premise arm reaches the refusal - following on, a reported system mode, `nightModeChangeAllowed()` false and `windowBg` unchanged after a bare toggle - before its switch reads the other palette's shipped `windowBg` and its restore the starting one. |
| A colour check on a widget inside the Wallet window fails on pixels that are correct: the measured colour matches none of the expected `st::` values but is the Wallet palette's value of that very key - a focused import field's line measured `#32aaff` where the check expected `#6396cb` - while a colour read beside it, such as the error red, passes; unlike the settling row above, the reading is stable and repeats run after run | `Core::Sandbox::notify` (`core/sandbox.cpp`) wraps every `QEvent::Paint` of a widget in `style::main_palette::Override(Ui::WindowPaletteFor(widget))` while any window palette is registered (`Ui::HasWindowPalettes`), and `Ui::WindowPaletteFor` (`ui/window_palette.cpp`) answers the palette of the nearest window registered with `Ui::SetWindowPalette`. The registry is generic; the Wallet window is its only registrant today, through `Wallet::UseWindowPalette` (`wallet/wallet_palette.cpp`, called from `Wallet::ShowWallet` in `wallet/wallet_panel.cpp`), which gives each Wallet window its own `Wallet::WindowPalette` owner. Under an embedded theme everything in that window paints with its owner's palette, while a scenario that reads an `st::` colour outside a paint event gets the main palette's value of the same key; under a non-embedded theme the owner resolves no palette and the two agree. Observed in `2026/09/29/keep-the-focused-word-field-in-view-when-importing-a-wallet` (`work/test.md`, Run 1, Test 12, the T11 control): in the night theme the oracle demanded equality with `st::walletImportField.borderFgActive` read in the scenario, the main palette's `activeLineFg` `#6396cb`, while the live line was `#32aaff`, the Wallet palette's active line, so a correct non-error line failed; `activeLineFgError` `#ef5959` is the same in both palettes, which is why the RED rows beside it passed. | Resolve every expected `st::` colour inside `Wallet::WindowPaletteScope(widget)` - the product's own expression, generically `style::main_palette::Override(Ui::WindowPaletteFor(widget))` - before comparing it or handing it to a harness colour reader, and print both palettes' values in the details so a mismatch names the palette the pixels match. Copy each expected value as a `QColor` (`->c`) while the scope is open: `style::main_palette::Override` (`lib_ui/ui/style/style_core_palette.cpp`) swaps colours into the main palette's slots in place and its destructor swaps them back, so a `style::color` held past the scope reads the main palette again. Where the check is "not X", deciding by distance from the painted X rather than by equality with one other colour also keeps it palette-proof. The source task's Run 2 (`work/test.md`, Test 12 rerun of the control) repaired its control this way and passed, with details `paint(windowPalette=1): error=#ef5959 active=#32aaff ... main(windowPalette=0): error=#ef5959 active=#6396cb`. |
| A clicked button's measured fill matches no style constant, or `DeriveBand` returns `ok=0` with no rows for a widget plainly on screen | The reading was taken while the widget was still hovered by an earlier synthetic click, so it painted `textBgOver` where the check named `textBg`; before the input helpers delivered a leave this latched for the whole process. | Take the reading through helpers that leave the target pointerless (`Click`/`Drag` deliver a `QEvent::Leave`), and when a hovered reading is what is wanted, set the hover deliberately and name the fill the state actually implies. Confirm the instrument with the `test_hover.h` self-test. |
| Press-feedback frames after a harness press read zero changed pixels inside a ripple button - `changedPixels=[0,0]` in clocked frames about 40 and 120 ms after a `Test::Click` on a `Ui::RippleButton` - or show an arc entering from the button's edge instead of a disc around the press point | `Ui::RippleButton::onStateChanged` (`lib_ui/ui/widgets/buttons.cpp`) starts the press ripple at `prepareRippleStartPosition()`, which every class computes as the real `QCursor::pos()` mapped into the button plus its own offset, not at the point the harness pressed. The harness has no pointer, so the ripple starts where the owner's mouse happens to be, usually hundreds of pixels outside the button: its `anim::easeOutQuint` radius over `showDuration` has not reached the button's mask in those frames, or reaches it only as an arc, or the class's guard (`Ui::IconButton`'s ripple area) refuses the point and starts no ripple at all. Found in `2026/10/20/replace-sell-with-card-and-color-actions-on-the-wallet-gift-cover` (`work/test.md`, Run 2: two clocked frames on a 107x56 button read `changedPixels=[0,0]` in both themes; Run 3 repaired it only in its disposable overlay, with an `#ifdef _DEBUG` override of that button's `prepareRippleStartPosition()`); `2026/08/03/redesign-add-funds-and-buy-grams` and `2026/08/10/correct-streamed-draft-stop-colour-and-lookups` hit the same flaw earlier and moved the real cursor instead. | Press with `Test::ClickWithPressOrigin` ([input helpers](helpers/test_widgets.md#input-helpers)): it moves only the pressed button for the synchronous press, so the class's own `prepareRippleStartPosition()` reads the press point, with its own offset and guard, and the real cursor is only read. Read the returned `PressOrigin` with `PressOriginDetails` - `refusal=none`, `originSeen` equal to the point, `restored=1`, `cursorStable=1` - and treat a refusal as a fixture gate. Never move the real cursor with `QCursor::setPos` or a platform equivalent (it changes hover on the owner's desktop), and never override `prepareRippleStartPosition()` in an overlay. Expect the ripple without the hover highlight: the closing leave cancels it before it paints. Confirm with `AppendPressOriginSelfTest` (`test_press_origin.h`). |
| Every in-flight band reading reports `derivedOk=0 inkPixels=0` on a footer button while a restored or control band still measures | `st::defaultBoxButton` derives from `defaultLightButton`, whose `textBg` is `lightButtonBg`, and `lightButtonBg: windowBg` (`ui/colors.palette`) — the box's own background. `DeriveBand`'s premise is a row whose modal is the requested fill, so on that style the fill cannot be separated from the image outside the candidate. The old answer was the same empty `ok=0` as the hovered-fill row above, or as "no row of the recovered box has the pill fill as its own background". | Read `DeriveBand.reason`: when the fill is the surroundings it now says `the requested fill is the image's background outside the candidate, so no band can be derived`, distinct from the no-rows refusal. `MeasurePaintedInk.report` carries the same reason instead of a bare zero. Confirm with `AppendDeriveBandSelfTest`. Do not repair this as latched hover. |
| Emoji split or custom entity absent | UTF-16 code units or the wrong editor event route were synthesized. | Use grapheme-safe `TypeText` or one `CommitText` on the raw editor. |
| Drag/wheel/cancel has no effect | Wrapper received an event owned by a child, presentation, or viewport. | For drag and keys, target the real event owner. For a wheel, `Test::Wheel` now climbs to the consuming ancestor (the viewport, not the `QAbstractScrollArea`); if it still does nothing, read `WheelDelivery.refusal` instead of retargeting by hand. Confirm a wheel with `test_wheel.h`. |
| A wheel-delta oracle reads identical values before and after, on a widget plainly inside a scroll area | `Test::Wheel` sent a non-spontaneous `QWheelEvent` that Qt 6 does not propagate; a covering button ignores it and the scroll area never sees it. Delivering to the `QAbstractScrollArea` itself is the neighbouring trap: `event()` returns false without ignoring, so an acceptance check certifies a no-op. | Use the repaired `Test::Wheel`, which replays Qt's parent ladder or refuses by name. Do not treat `isAccepted()` on a scroll area as success. Confirm with the `test_wheel.h` self-test. |
| A drag out of a message list driven by `Test::Drag` starts no drag: `Window::MainWindow::launchDrag()` is never called and no mime data is formed, with no error and no refusal, although the press landed on a draggable item or selection and the moves went past `QApplication::startDragDistance()` | `HistoryInner::mouseActionUpdate()` (`history/history_inner_widget.cpp`) and `HistoryView::ListWidget::mouseActionUpdate()` (`history/view/history_view_list_widget.cpp`) turn `MouseAction::PrepareDrag` into `MouseAction::Dragging` once the pointer leaves the pressed item or passes the drag distance, and queue `performDrag()` with `InvokeQueued`, a Qt queued metacall. `Test::Drag` delivers the press, the moves, the release and its `QEvent::Leave` in one synchronous call and drains only `Ui::PostponeCall`s, so the release reaches `mouseActionFinish()`, which resets the action to `MouseAction::None`, before the queued call runs; `prepareDrag()` returns null unless the action is still `Dragging`, and `performDrag()` does nothing. `Info::Media::ListWidget::mouseActionUpdate()` (shared media) and `Dialogs::InnerWidget::mouseMoveEvent()` (dragging a chat onto a folder) queue their `performDrag()` the same way, and their releases clear the state it checks. This differs from the drag/wheel/cancel row above: there a wrapper received an event its child owns; here the event owner received every event and queued the drag's start past the release. Found in `2026/10/12/keep-the-forwarded-from-line-in-copied-message-selections` (`work/test.md`, Attempt 1, Run 1, the reconciliation item "T11 instrument"): its convergence note had assumed that settling after each move would run `performDrag()` during the moves, which is false, and the run drove T11's drag by hand. | Drive the drag with `Test::AppendQueuedStartDrag` (`test_queued_drag.h`): one stage that delivers the press and the moves exactly as `Test::Drag` does, waits, bounded, until an `InvokeQueued` marker posted to the target and a `crl::on_main` marker have run - so every queued call posted while the press and moves were handled, the queued `performDrag()` included, has run - and only then delivers the release and the same `QEvent::Leave`; read the returned `QueuedStartDrag` from a later stage and treat a `no-target`, `target-destroyed` or `bound-expired` refusal as a fixture gate. The helper intercepts nothing, so before any launch hook, in the overlay and as its first statement, the function whose `QDrag::exec()` the queued start reaches. For these lists that is `Window::MainWindow::launchDrag()` (reached from `ListWidget` through `WindowListDelegate::listLaunchDrag()`): record the mime data, run `ClickHandler::unpressed()` and the callback, and return before `QDrag::exec()`, as the source run's hook did. For a label's own link or selection drag it is `Ui::FlatLabel::executeDrag()` (`lib_ui/ui/widgets/labels.cpp`), which builds its own `QDrag`: record the mime data, run `ClickHandler::unpressed()` and return before `QDrag::exec()`. This is a safety rule for every queued start that reaches `QDrag::exec()`: without the hook that call starts a real platform drag. In Qt 6.11.2's sources, on Windows OLE's modal `DoDragDrop` loop asks `QWindowsOleDropSource::QueryContinueDrag`, which reads the real mouse buttons, not the synthetic release, so the loop runs until the release of the next real click and then drops the data at the real cursor, into whatever window is there; on macOS `QCocoaDrag::drag()` returns at once only while no real mouse event has reached the app (`QCocoaDrag::setLastInputEvent` never ran) and otherwise starts a real AppKit drag that follows the real pointer. Inside `QDrag::exec()` the event loop keeps turning, and `Runner::tick` has no re-entrancy guard, so the helper's release and every later stage would run inside it. No hover is needed before the press: both lists' `mousePressEvent()` sets `_mouseActive` before `mouseActionStart()` runs `mouseActionUpdate()`, so the `!_mouseActive && !window()->isActiveWindow()` gate does not stop it. The legacy list still clamps the points (the text-selection drag row below), so scroll them into its viewport first. The source run drove the same order by hand - a hover, then the press and six moves in one stage, a queued marker, the release and the leave once it ran - and T11 passed in both stacks (`marker=1 calls=1`). `Test::Drag` is unchanged and stays right for a drag whose effect is synchronous. Confirm with `AppendQueuedStartDragSelfTest`, whose `Test::Drag` control reads the release first. |
| Test reaches a real external action | Missing expectation/fuse or mock seam. | Declare the exact blocked launch, mock the transport/payment boundary, and assert zero real calls. |
| Pixel probe misses only on Retina or at 125/150% | Logical rect indexed into the device-pixel grab, or a 100% literal reused at another interface scale. | Multiply by the image `devicePixelRatio()` once at the sampling boundary; derive expectations from the live scaled tokens. |
| Geometry oracle fails on plausible-looking rects | Rects from different widgets compared without a shared origin. | Map both through one declared frame (`Ui::MapFrom`, `mapToGlobal`) and log the mapped values in the failure details. |
| Process dies after `TEST_COMPLETE`, with no assertion line and often a 0-byte dump | Overlay teardown, not the product: a leaked scenario `State` still holds `rpl` subscriptions to session-owned streams while `~Main::Session` destroys them. | Destroy the scenario's lifetimes, release its watchers and null every raw cross-stage pointer from `Runner::onFinish` — a teardown stage is not enough, because a timed-out stage or the watchdog skips every stage after it. |
| `Telegram finished, result: 0` then `QObject::~QObject: Timers cannot be stopped from another thread`, with or without a fresh dump or a nonzero exit after it | An armed timer outlived the application: Qt's `QObjectPrivate::~QObjectPrivate` prints this warning for a `base::Timer` destroyed while still running after `QApplication` died, for example during static destruction after `main` returned. Sources include the product's accessibility focus manager - the function-static `FocusManager` behind `Ui::Accessible::Manager()` in `ui_accessible_widget.cpp`, whose cleanup timer is armed for five seconds after an accessible widget with a focus policy registers - and a scenario-owned `base::Timer` in a static or leaked `State`. The warning names no object, so its text alone does not tell which timer it was. `base::Integration` now clears its registration when `Core::Launcher` destroys it, so the later `base::LogWriteMain` call from the `Core::Launcher::initQtMessageLogging` handler skips the message instead of reaching the destroyed integration, while Qt's default output (stderr with `QT_FORCE_STDERR_LOGGING=1`, otherwise the debugger output on Windows) can still show the warning. | Judge the run by its exit, not by the warning: a normal zero exit with no death signal is not a crash merely because of this warning. A fresh dump, a nonzero exit or a forced termination needs its actual stack or report. When the scenario owns a timer, still cancel it (and destroy lifetimes / null cross-stage pointers) from `Runner::onFinish`; do not rely on a teardown stage, which timeout and the watchdog skip. |
| `SCENARIO_RESULT` and `TEST_COMPLETE` are written, then the process dies: `test-run` reports `died-after-complete` (exit `-11`), the crash report reads `Assertion: Unexpected: Qt FATAL message was generated!`, `QtFatal: ASSERT: "d->allThreads.isEmpty()" in file .../qthreadpool.cpp, line 462` and `Caught signal 11 (SIGSEGV)`, and the Crashpad dump has the main thread in `QThreadPool::~QThreadPool` under `QGuiApplication::~QGuiApplication` while a `crl` thread waits in `QPainter::drawImage` under `Ui::CacheBackgroundByRequest` | A main-window geometry change made just before quit - from `Runner::onFinish`, or from the last stage, after whose `then` `Runner::completeStage()` calls `finish()` at once (`test_runner.cpp`) - on a chat background that is not a plain colour. The next chat paint at the new size hands that area to `Ui::ChatTheme::backgroundState()` (`Window::SectionWidget::PaintBackground` in `section_widget.cpp`), which schedules a re-cache 200 ms later (`kCacheBackgroundFastTimeout` in `ui/chat/chat_theme.cpp`); `cacheBackgroundAsync()` runs `CacheBackgroundByRequest()` on a `crl::async` thread, one `QPainter::drawImage` per tile (its `drawTile` lambda), and Qt 6.11.2's `QT_THREAD_PARALLEL_FILLS` (`qdrawhelper.cpp`) splits each large blend from a thread outside Qt Gui's thread pool into tasks on that pool, waiting on a `QLatch`. `Runner::finish()` quits `kFinishDrainDelay` (500 ms, `test_runner.cpp`) after its `onFinish` callbacks. `~QGuiApplication` waits for and deletes the pool (`qguiapplication.cpp`); `~QThreadPool` waits again and asserts `d->allThreads.isEmpty()` (`qthreadpool.cpp`), but `waitForDone()` waits only until the pool was idle once (Qt's comment there: “New jobs might have started during reset”), and the painting thread's next blend refills it. `Q_ASSERT` is compiled out under `QT_NO_DEBUG` (`qassert.h`), so it is the Debug build's Qt that asserts. The two rows above and the “Scenario teardown before quit” rule are about releases, which `onFinish` must own; a geometry change releases nothing and starts asynchronous painting, which `onFinish` must not start, because `finish()` quits only `kFinishDrainDelay` later. Unlike the first of those rows, this death names an assertion and leaves a real dump. The row names the scenario's late geometry change as the trigger and neither claims nor rules out a product quit-time race. Observed in `2026/10/06/fit-the-rich-article-width-and-last-line-to-the-bubble` (`work/test.md`, the A-only launches of Runs 1 and 2; stack in `evidence/run-1-2-aonly-shutdown-stack.txt`). | Make the change in a teardown stage while the runner is still stepping: `run` sets the geometry (in the source run, back to the start geometry) and `until` waits until the window has it plus 1.5 s; keep releases in `onFinish`, as the two rows above require. Like every teardown stage it is skipped after a stage timeout or the watchdog, and that path was not exercised. Repaired that way from Run 2's B-only launch of the same campaign: without it 2 of 5 launches died this way (both A-only launches, not Run 1's FINAL and B-only or Run 2's FINAL), and with it both launches exited 0 (Run 2's B-only and Run 3's FINAL); 1.5 s is what held there, not a measured bound. The stack, not the launch count, ties the death to the late geometry change. |
| Media reading frozen at position `0`, with the length equal to the document's declared duration | Undecodable fixture document — often a synthetic upload left on the shared test account — accepted on metadata alone; the app debug log shows `Streaming Error: Error in avformat_open_input`. | Select the fixture with the playability probe: play each candidate and accept only one whose position strictly advances, then reuse that document everywhere. |
| A premise fails against a row its own fixture had to create, or a check passes without ever reaching its subject | The oracle read the probe's whole history, or bracketed a slice by wall time, so rows from an earlier stage or a slow neighbouring surface answered it. | Record through `Test::Probe`, take `mark()` immediately before the action, and query only `...Since(mark)`; there is no whole-history accessor to fall back to. |
| A sweep reports a confident `found=0` that no repair ever changes | The enumeration structurally cannot reach the subject, so the zero was guaranteed before the run started and measures nothing. | Count through `Test::DiscriminatingScan` and feed it a known-present control; `report()` refuses to certify a zero the walk cannot tell from absence. |
| A wallet settle that "completed" while nothing was refreshed, read later as a stamp age equal to the observation's own timestamp | `Wallet::Session::refreshHistory(done)`'s callback says nothing about the server's wallet state: it runs at once when the presence is not `Ready` or the lane is paged, and otherwise queues on `_historyDone`, which `finishHistoryWaiters()` drains when the `wallet.getTransactions` it issued answers or fails — a transaction-history round trip that stamps `_historyRefreshedAt`, never `_stateRefreshedAt`, which only the state lane writes — `applyState()`, and `applyEngineUpdate()`, which cannot run while the engine holds no client. The queued callback is not even guaranteed a round trip of its own: `requestTransactions()` issues nothing while one is already in flight, so the drain then comes from that other request, and `setPresence()` drains the same queue with no round trip at all on any transition away from `Ready`. So a completion certifies no wallet-state freshness stamp; an unwritten stamp then reads `crl::now() - 0`. | Gate on the state, not the callback: `Test::WalletRefreshSettled` over `Test::ReadWalletRefresh`, which reports a never-written stamp as `unstamped` and never as ready. A refusal there is a fixture gate — a harness or environment `TEST_FLAW`, never a product `FAIL` — unless the diff under test touches the wallet state lane itself, which is exactly `refreshState()` / `requestState()` / `applyState()` / `applyUpdate()` / `clearNetworkState()` and nothing wider, in which case the readiness condition is the behavior under test and the refusal is a product `FAIL`. |
| A funded-wallet leg gated off by `signingIsServed=0` (the fixture's phrase does not sign for the served key) while the device's current custody record signs for the served key and the address gate passed | The golden `test_gram_account.txt` predates later rotations of the golden wallet: a campaign rotated the key and was cut off before `RewriteGramAccountWords`, or the file was restored from an older copy. The fixture is stale, not the product. | Run the P0 helper right after the address gate - `Test::AppendGramReconcile` / `Test::ReconcileGramAccount` (`test_gram_reconcile.h`) - and gate the funded legs on `GramReconcileResult::reconciled()`; it rewrites the file from the local reveal (case (b)) or refuses by a named gate. Never hand-roll the reveal. |
| A Money-window controlled send's press stage times out with `send-job-queued` and no `send-job-entered`, the engine worker reading `started > finished` (`worker=[queued=4 started=11 finished=10]`), while the Debug log shows a registered `toncenter.performApiRequest` that is never answered and is cancelled exactly when its 15 s timeout expires; or, at teardown, `wallet.sendTransfer data_normal` followed by `adding request to toSendMap`, answered 400 `WALLET_TRANSFER_SEND_FAILED` | Suppressed, unanswered provider calls each hold the single engine worker in `StatuslessHost::execute_statusless` until their 15 s timeout - the send box's comment-recipient resolution and its three retries 1 s apart, the fee preview, and the fixture's first collectibles poll (`refresh_nfts`) - so a later press queues behind all of them. The teardown case is a fixture that disarmed before the queued send's routed `wallet.sendTransfer` was issued on main, so nothing intercepted it. Observed in `2026/10/02/keep-the-sending-row-one-stable-row-until-the-final-row` (`work/test.md`, Run 1), after four gram-wallet campaigns had each repaired the stall by hand, differently. | Use `test_wallet_intercept.h`: `AppendWalletEngineDrain` before every send box (it leaves the release on through the box and the press), `CheckNoEarlierProviderCallHeld` at the press, `teardown()` (seal first) from the teardown stage and again from `Runner::onFinish`, and the fixture route fixed when the send is queued. Never wait the provider timeouts out; that is the forbidden technique. Confirm the instrument with `AppendWalletInterceptSelfTest`. |
| A hand-rolled secrecy scan reports a confident zero while it read no app log and no `DebugLogs` part, or reports "leaks" of short secrets or phrase words that the client never wrote; or `CheckSecrecy` fails `no client-written fixture secret` on a plain-line short-secret hit that is a value the product computed and logged itself | Four traps: (1) selecting logs "modified since the scenario started" picked no app log or `DebugLogs` part on Windows, because a file the running process holds open keeps its old last-write time; (2) matching a short secret as a plain substring over the very large number of lines in a campaign's MTP dumps gives coincidental hits inside longer, unrelated values; (3) even a bounded match inside a dump can be content that the server sent (`Recv`), not anything the client wrote; (4) a value the product computes and formats into its own diagnostic line can equal the short secret, which a bounded count alone cannot tell from a leak. `Test::CheckSecrecy` and its checks for (1)-(3) come from `2026/09/25/add-fixture-reconciliation-and-secrecy-scan-helpers-to-the-harness`, its plain-line site report and declared computed fields for (4) come from `2026/09/27/report-the-site-of-short-secret-hits-in-the-secrecy-scan`, and its carried transport lines for (4) come from `2026/10/13/keep-the-secrecy-scan-from-counting-a-coincidental-transport-number`. | Call `Test::CheckSecrecy` (`test_secrecy_scan.h`): it selects by identity (the part's day index, `Logs::full()`), counts only bounded occurrences of a short secret, decides on plain lines and `Send` entries only and reports `Recv` hits by schema site and every plain-line short-secret hit at its `<file>\|<head>\|<field>` site, and refuses an undecided class. For (4) on a line with `name=` fields, confirm from the site row and the product source that the field is formatted from a value the product computes, never from fixture input, and declare that head and field in `SecrecyScanArgs::computedFields`: the hit moves to `SECRECY_COMPUTED` and no longer decides. For (4) on a product line with no `name=` field, nothing can be declared: the scan already reports an exact hit on a carried transport line's computed number in `SECRECY_COMPUTED`, and any other such hit follows the next row. Never settle it by reading the matched line. Confirm the instrument with `Test::AppendSecrecyScanSelfTest`. |
| `<what>: no client-written fixture secret` fails with a `plainSites` entry in a `DebugLogs/log_HH_MM.txt` part on a product diagnostic line with no `name=` field: the withheld `DebugLogs/log_HH_MM.txt\|?\|?`, or a carried transport line's `<file>\|<head>\|-` or `<file>\|<head>\|<slot>` | Such a line has no site a campaign could declare. The scan carries the MTProto transport's integer-only `DEBUG_LOG` lines itself (**Plain-line sites**, **Declared computed fields**), so an exact hit on one of their computed numbers is already in `SECRECY_COMPUTED` and does not decide. A hit that still decides is one the rule does not exempt: part of a value (the digits under a minus), the line's head or other text, the suffix's line number, a number read from the server, or a line the scan does not carry or whose shape differs from its format. | The FAIL stands: the harness has no sanctioned way to call it a coincidence. A deciding `<head>\|-` or `<head>\|<slot>` site on a carried line is not an exact hit on a computed number. A withheld site does not say why it was withheld - the line may not be carried, its shape may differ from its format, or its head and slot name would have printed a given secret - so it never proves that the line is missing from the carried table. Carrying another product line is harness work decided from the product source alone, never from a matched line: its exact format, source file and function, and the source reason each of its numbers is computed, added to `TransportLines()` in `test_secrecy_scan.cpp`, with synthetic cases in the `secrecy_self_transport` stage of `Test::AppendSecrecyScanSelfTest`; a number whose origin is uncertain, or that is read from the server's bytes, stays deciding. **Forbidden:** a `computedFields` declaration for such a line (it matches no printed site), rewriting a product line into `name=` form or moving product lines out of the scanned logs, carrying a harness-written or overlay row (its numbers take `Test::TelemetryNumber` and `Test::RequestTelemetryNumbers()`, the two rows below), and settling the hit by reading the matched line. Confirm the classification with the `secrecy_self_transport` stage. |
| `<what>: no client-written fixture secret` fails with a `plainSites` entry in the test log - `SECRECY_CLASS: TestLog ... plainSites=[test_log.txt\|NOTE: <head>\|- x<n>]` - on the overlay's own row head, with field `-` (or that field's name when the number is a field's whole value; a row with no `name=` field shows the withheld `test_log.txt\|?\|?` instead) | A bounded digit run inside a number the overlay formatted equals a short secret by coincidence. An ordinary decimal leaves every digit run bounded by `=`, `-`, `.` or a blank - in `value=-4821.75` both `4821` and `75` are bounded - so any of them can equal a short secret, and the more values a run prints, the likelier that is. The hit is part of a value, so its field is `-`. A declared computed field cannot exempt it: overlay telemetry is not a value the product computes, and a hit in part of a value still decides (**Declared computed fields**). | Print every number the overlay measured or computed into a scanned log with `Test::TelemetryNumber(value, decimals)` (`test_log.h`), the rule from `2026/10/08/give-overlay-telemetry-a-number-format-the-secrecy-scan-cannot-count`: a `p` for the point, so every digit run touches a letter - `-4821.75` prints `-4821p75` and `42` prints `42p` - and no digit run is ever bounded. Choose `decimals` per quantity, never from a secret; a judge script reads a value with `float(v.replace('p', '.'))`. A `TelemetryNumber` is a word to the phrase matcher - it ends a word run where a bare number is neutral - so never print one as a list index or between words copied from product or fixture text; keep such text out of telemetry rows. Within those limits, changing only how measured numbers are formatted cannot remove a real short-secret leak at that site, so a scan that passes after only the format changed, with its word-run and token counts unchanged, has told a coincidence from a leak. Never exempt the hit (a `computedFields` declaration for the overlay's own row, or a filter), move telemetry out of the scanned logs, choose a format from a secret, pass a fixture secret or a number read from one through `TelemetryNumber`, or settle the hit by reading the matched line. Confirm the rule with the `secrecy_self_telemetry` stage of `Test::AppendSecrecyScanSelfTest`. |
| `<what>: no client-written fixture secret` fails with a `plainSites` entry in the test log on a shared helper's row rather than on the overlay's own: a `GEOMETRY: <name>:` head (from `LogGeometry`, a capture helper or a saved `PreparedWidgetCapture`), a `NOTE: <name>:` head of a `kind=post`, `kind=stop`, `kind=frame` or `examined=` row, a probe check's details, a clocked gate or refusal row, or the withheld `test_log.txt\|?\|?` of a row with no `name=` field, such as a `CheckNear` verdict | A bounded digit run inside a number the helper measured, counted or timed equals a short secret by coincidence, exactly as in overlay telemetry: the helper printed ordinary decimals because the run did not ask for the telemetry format or because the helper is not converted. The overlay cannot reformat the row without reimplementing the helper, which the reuse rule forbids (an overlay that reimplements a shared facility is a test flaw), and a declared computed field cannot exempt it: a helper's number is not a value the product computes, and a hit in part of a value still decides (**Declared computed fields**). | **Converted helper, run did not ask** - the row is one the `test_log.h` (`TelemetryNumber`, `RequestTelemetryNumbers`) catalog row lists, and no `TELEMETRY_NUMBERS: requested` line precedes it: call `Test::RequestTelemetryNumbers()` (`test_log.h`) unconditionally at the campaign's first stage and re-run. Changing only the number format cannot remove a real short-secret leak at that site, so a scan that passes after only that change, with its word-run and token counts unchanged, has told a coincidence from a leak. When the run asked and the hit is still on a converted row, it lies in caller text (a probe row, a check's `what`, a scan detail), which the caller formats like its own telemetry, or in a text the helper formatted before the ask, such as a kept `RoundTrip::observation`; ask at the first stage. **Helper not converted** - for example `PostPaintSampler::details()`, `test_capture`'s own readings, `test_runner`'s rows or any other module's rows: convert that helper the same way, as harness work - a pure formatter taking a `NumberFormat` that its logging path calls with `HelperNumberFormat()`, its case in the `secrecy_self_helper_numbers` stage of `Test::AppendSecrecyScanSelfTest`, and its README grammar in both formats. A local copy of the helper is never the repair. **Forbidden:** everything the overlay-telemetry row forbids - exempting the hit (a `computedFields` declaration for the helper's row, or a filter), moving telemetry out of the scanned logs, choosing a format from a secret, passing a fixture secret or a number read from one through `TelemetryNumber`, and settling the hit by reading the matched line - plus a local re-implementation of the helper, and asking for the format under a condition or from code that read a fixture secret. **Caller numbers:** `CheckNear`'s `actual`, `expected` and `tolerance`, `Probe::checkCountSince`'s `expected` and `RunClockedFrames`' `elapsed` and `animationEnd` print in the requested format too, which hides digits from the scan, so they must never be a fixture secret or a number read from one. Confirm the route with the `secrecy_self_helper_numbers` stage of `Test::AppendSecrecyScanSelfTest`. |
| A green log that does not say what its checks were made against, so a passing run cannot be audited after the fact | The reading was handed to `Test::Check` as `details` back when `details` was written only on the failing branch, or worked around by folding it into `what` or by emitting a `Note` beside the check that a reader then has to re-correlate by position. | Pass the reading as `Check`'s third argument: it is printed on the passing verdict too, as `TEST_RESULT: PASS: <what> - <details>`. Keep only failure-only text behind `ok ? QString() : ...`, which still prints the bare passing line. |
| A round trip reported as a negative or otherwise impossible number, or a pair count that does not match the issue count | Two lists were related by position - an issue list against an answer list, or a row list against a parallel `crl::time` vector indexed at `mark + i` - so one extra or missing element on either side paired a row with another row's time, and the reading was emitted as a `Note` that failed nothing. | Record both sides into one `Test::Probe` with `recordIssue`/`recordAnswer` and read them through `checkRoundTripSince(mark, key)`: it pairs by key, discards and names every answer not strictly later than its issue, and refuses as a FAIL carrying the tallies rather than reporting an interval it did not positively pair. Read a bare time through `timedRowsSince`, which carries each plain row's own time. |
| A capture or `captureWidget` stage times out and its details carry `render root paints no background of its own: ... - grab N...BoxLayerWidget... instead (unpainted 0/1000)` | The render root was the box, and that box had cleared `Qt::WA_OpaquePaintEvent`. `Ui::BoxContent`'s constructor sets that attribute, so a plain box paints its own background and is accepted - but `setNoContentMargin(true)`, which 53 product call sites use, clears it again, and then the box paints no background of its own and `PreparedWidgetCapture::prepare()` refuses every frame it is offered; the poll can only end in a timeout. | Resolve the root with `Test::PaintingLayerRoot(box)` and capture the `Ui::BoxLayerWidget` it answers — `Test::CaptureBoxLayer(box, name)` is that whole-shell grab, including `_title` and the `addButton` footer row parented onto the shell — or use `Test::CaptureInLayerRoot(box, name)` for a frame cropped to the box content (it composes `CaptureMappedRect`, so a box that maps outside its layer is still a named FAIL). The refusal is naming the right widget - do not widen it. |
| A menu capture times out with `showingContent=0` in its last state, or the popup vanishes mid-stage; or, on a locked Windows console, `capture painted widget: <name>` times out with `last state: target is not visible: class Ui::PopupMenu ...` right after the stage that opened the menu passed | The readiness waited on the inner `Ui::Menu`'s visibility. `Ui::PopupMenu::startShowAnimation()` calls `hideChildren()`, and the children come back only from the final `paintEvent`'s `Ui::PostponeCall` - a side effect no `-testagent` run is guaranteed to reach - so the wait can last until the popup dies of a focus-out. A one-shot grab in the turn that opened the menu is the opposite failure: it lands on a show-animation frame with nothing drawn. A locked Windows console is a diagnosed cause of its own: the app is deactivated, so Qt closes a shown `Ui::PopupMenu` before the next turn - `QApplication::notify` closes every open popup on `QEvent::ApplicationDeactivate` (Qt 5.15.19 `closeAllPopups()` in `qapplication.cpp`) - and `CapturePopupMenu`, which needs the menu open across turns, times out on `target is not visible`; `Test::OpenContextMenu` still reads the menu in the turn that built it. Observed in `2026/10/07/add-a-same-turn-context-menu-reader-and-a-retried-clipboard-round-trip-to-the-test-harness` (`work/test.md`, Attempt 1): in Run 1 `popup menu self-test: open` passed 6/6 and `capture painted widget: popup_menu_open` timed out, which ended the scenario before the self-test's five later stages, while the reader's positive arm read `appState=active` at build and `visible=0 hidden=1 activePopup=none ... appState=inactive` one turn later and the reader's self-test passed 22/22; Run 2, the popup-capture self-test alone on the same locked console, reproduced the timeout; Run 3, unlocked, passed both frames. | Capture with `Test::CapturePopupMenu`: its readiness carries content identity only - the menu exists, is visible, has non-empty geometry and carries actions - and the prepared capture's own blank-frame refusal decides when the frame is good. `showingContent` belongs in the details, never in the gate. Do not use `Test::PaintingLayerRoot` on a popup: it sets `Qt::WA_NoSystemBackground`, so the blank-root refusal never applies, and it is its own window. On a locked Windows console, read the console with `Test::ReadConsoleLock()` (`test_console_lock.h`) and gate the stages that need the menu open across turns through `CapturePopupMenu`'s `skipReason` (`[] { return Test::ConsoleLockGate(Test::ReadConsoleLock()); }`). `AppendPopupMenuCaptureSelfTest` does exactly that: its `open` stage Notes the `CONSOLE_LOCK` row it read right before it popped the menu; the `popup_menu_open` capture, `close`, and the `popup_menu_reopened` opener and capture are `TEST_RESULT: N/A` naming the lock and printing the reading, read when each begins and kept once locked, because a menu popped or held while the console was locked is already closed; and `open` (decided from the opening turn), `refusal text` and the teardown run and decide, so the scenario continues past it. The rule: for these arms a locked console is a scheduling condition - rerun them on an unlocked console. This applies to Windows: on macOS a packed launch with `CGSSessionScreenIsLocked` true ran `AppendPopupMenuCaptureSelfTest` with 0 FAIL (`2026/10/06/add-a-post-paint-capture-sampler-to-the-harness`, `work/test.md`, Test 2) - a locked macOS console does not close the popup - so `Test::ConsoleLockGate` answers only in a Windows build: on macOS the probe reads the lock and the `open` stage prints it, and no gate fires there. The gated rows are `skipReason` N/A rows rather than a hard `fixture gate:` `Check` like this self-test's translucency premise (the `AppendSeparatePanelWalkSelfTest` row below): a host without translucent windows can never certify that premise, so a skip there would withdraw a certification the host can never give, while a locked console is a scheduling condition on a host that certifies the capture once unlocked (Run 3); the harness already skips `AppendMainWindowNotMarkingReadSelfTest`'s deciding half when its control cannot read `markingAsRead` true on a locked console the same way; a `Check` could not stop the next stage from timing out; and an N/A row never counts as a pass, is counted in `SCENARIO_RESULT`'s skipped clause and names the lock and the probe reading. |
| A context menu is never observed: an overlay delivers a `QContextMenuEvent` and polls on later event-loop turns for a visible, ready `Ui::PopupMenu`, and finds none for any target, the controls included - `popup=0 actions=[]` on `HistoryInner`, `HistoryView::ListWidget` and `Iv::Markdown::MarkdownDocumentWidget` alike, `elapsedMs~3020` per chat-list leg, for an ordinary keyboard URL button and a plain paragraph as well | Observed in `2026/10/07/show-url-tooltips-and-copy-link-on-rich-message-url-buttons` (`work/test.md`, Attempt 1 Run 1, Tests 2-4); its app log showed the main window re-activated once per menu leg. Each product handler builds its menu synchronously, inside the event, as a child of the widget that received it - `HistoryInner::showContextMenu` (`base::make_unique_q<Ui::PopupMenu>(this, ...)`), `ListWidget::showContextMenu` through `FillContextMenu(this, ...)` (`base::make_unique_q<Ui::PopupMenu>(list, ...)`) and `MarkdownDocumentWidget::contextMenuEvent` (`_contextMenu = base::make_unique_q<Ui::PopupMenu>(this, ...)`) - so the menu exists in the turn of the event; on that unattended console it was gone before the first poll, and the run did not diagnose why. Nothing guarantees that a popup outlives its turn: `Ui::PopupMenu::focusOutEvent` hides it once focus leaves its focus chain, `QApplication::notify` closes every open popup on `QEvent::ApplicationDeactivate`, and a menu left at `deleteOnHide(true)` then `deleteLater()`s from `hideEvent` - candidate routes, not a diagnosis. | Read the menu in the turn that built it with `Test::OpenContextMenu(receiver, point)` (`test_menu.h`): it snapshots every live `Ui::PopupMenu` in the application, sends the event inside `Test::Settle` and, in that same call, takes the one new menu - its ordered action texts and its lifecycle at build and after the postponed calls settled - or refuses by name (`no-menu`, `several-menus`, `receiver-gone`); trigger an entry with `Test::TriggerContextMenuAction`, which delivers its queued `Ui::Menu::CreateAction` callback before it returns. Run 2 of the same campaign decided every menu leg on four surfaces by reading in the building turn, `built=1 fresh=1` and `ready=1` at build. A `no-menu` reading counts as a product result only beside a positive control that the same helper built in the same widget - the source run's ordinary URL button and plain paragraph on the chat lists, and its Docs button in the window and the box (`a window / box no-menu reading is N/A by name unless the Docs control was built in the same widget`). A frame stays with `Test::CapturePopupMenu`, a supplementary leg taken only while the menu is still open. Confirm the instrument with `AppendContextMenuReaderSelfTest`. |
| A chat-list context menu read with `Test::OpenContextMenu` belongs to another message: readings for different target messages carry identical action lists, each `built=1` with no refusal, built with `atBuild={... appState=inactive activeWindow=none}`; or a "Select" triggered from such a reading selects another message - `selectedCount=1` while the target's own selection circle is empty | `HistoryInner::showContextMenu` and `HistoryView::ListWidget::showContextMenu` resolve a Mouse-reason event's point with `mouseActionUpdate(e->globalPos())`, and both lists' `mouseActionUpdate()` (`history/history_inner_widget.cpp`, `history/view/history_view_list_widget.cpp`) returns at once while `!_mouseActive && !window()->isActiveWindow()` - `HistoryInner`'s also while `hasPendingResizedItems()`, for its history or the migrated one. `_mouseActive` starts false, and only `enterEventHook`, a press or a move whose global position really changed sets it. So with the mouse not in the list and its window inactive, the menu is built from what the last successful update left: on `HistoryInner` `Element::Moused()`, `Element::Hovered()`, `Element::HoveredLink()`, the active click handler and `_dragStateItem` (its plain branch takes Hovered, else HoveredLink, and with neither still adds "Select" for `Element::Moused()`); on the `ListWidget` `_overElement` and `_overItemExact`. A leave does not drop that stale target: `HistoryInner::leaveEventHook()` clears `Element::Hovered()`, the active click handler (`ClickHandler::clearActive()`) and `_mouseActive`, and keeps `Element::Moused()` and `_dragStateItem`; `ListWidget::leaveEventHook()` clears the active click handler and `_mouseActive` and marks `_overState.pointState` `Outside`, and keeps `_overElement` and `_overItemExact`. `Test::OpenContextMenu` compares nothing with the target and neither refuses nor flags a menu built in an inactive window, so the wrong reading has the same texts, `built=1` and no refusal as a valid one. Observed in `2026/10/12/recognize-tgw-widget-documents` (`work/test.md`, Test 25 in Runs 1-3 and the selected-state leg of Test 22 in Runs 1-3; the recovery-plan rows "T25 menus" of Run 1, added after Run 2, and "T25 / T22 selected" of Run 2), on Windows: in Runs 1 and 2 the `bad.tgw`, `ask-cold.tgw`, `notes.txt` and own `widget.tgw` menus on `HistoryInner` were built with `appState=inactive` and read the same action list as the earlier `cold-small.tgw` target, while the uploading, `cold-small.tgw` and `ListWidget` readings, built with `appState=active`, stood; Run 1's Test 25 was first written as a PASS and re-assessed only while Run 2 was assessed; and the "Select" from the own `widget.tgw` menu selected another message (`selectedCount=1` while the card's own selection circle in Run 2's `A_T22_selected_widget.png` is empty). Run 3 called `Test::ForceWindowActive` in the same synchronous block as `OpenContextMenu` and checked `Element::Moused()` against the target: every reading read equal ids (`moused=762303:122213 target=762303:122213` for the first) and "Select" selected exactly the target (`selected=[762303:122197] target=762303:122197`). | Read a chat-list menu with `Test::OpenChatListContextMenu(list, target, point)` (`test_chat_menu.h`) and trigger only from a reading whose `built()` is true, in the same turn. It opens the gate for its own call with a `QEnterEvent` rather than by activating the window - activation runs `Window::MainWindow::handleActiveChanged`, which can mark the shown messages read (`Histories::readInboxTill` aborts on a harness-injected regular item whose id is no server id: the `markingAsRead=1` row below) and requests an online-status update - delivers through `Test::OpenContextMenu` with an at-build observer, always ends with a `QEvent::Leave`, and accepts the menu only when the gate was open at delivery, the view the list resolved at build is its own view of the target, every candidate a menu entry takes its message from names the target or nothing, and the menu is the list's own `_menu`. Anything else is a named refusal - `wrong-target` names the message the list resolved - whose menu is hidden and whose `TriggerContextMenuAction` refuses `menu-gone`. Do not read `Element::Moused()` or `_overElement` as hover after the leave: they keep the last resolved view. `Test::OpenContextMenu` stays the reader for every other widget. Confirm with `AppendChatListMenuSelfTest`, whose premise arm reaches the failing shape on both lists - A resolved last, the mouse outside the list, the window inactive through `Test::ClearWindowActive`, and an unprotected `OpenContextMenu` at B's point that the list resolved to A or to none - before its protected reading for B and the "Select" from it leave exactly B selected. |
| A stage times out although its own details show every precondition met and the action already fired - for example `box=1 toggle=1 popups=0` after a click on a control in a freshly shown layer | One synthesized click on a control in a layer that is still settling intermittently does not land, and nothing observes that until the stage's 10-second timeout ends the whole scenario. The input helpers are not at fault: `Test::Click` and `Test::Drag` deliver press, release and a `QEvent::Leave` synchronously and drain every `Ui::PostponeCall` before returning. | Make the click self-correcting in the scenario, not in the helper: read the effect, click the same `QPointer`-guarded widget once more if it did not appear, and `Note` which attempt worked so the flake rate stays visible. Establish first, from the production callback, that a repeat is provably a no-op - the details-menu toggle's first statement is `if (*menu) { return; }`. |
| A focus-routed affordance produces nothing at all — a submit that formed no request, a field that never took focus, `focused=0` / `isActiveWindow=0` — with no error and no event, especially on a locked console or on macOS after a default `test-run` launch, which does not activate the client there | The platform window is not active - which is what makes `QWidget::isActiveWindow()` false, because it falls back to `QPlatformWindow::isActive()` (`qwidget.cpp`) - so `QWidget::setFocus()` never promotes the target to `QApplication::focusWidget()`; clearing only the QPA focus window does not reproduce it where the OS window is still active. `Window::Controller::activate()` asks the window manager, which on a locked console does not comply. | Assert it with `Test::ForceWindowActive()` before the action. A one-shot activation expected to survive across polled turns is a forbidden technique, because the platform de-activates asynchronously between event-loop turns: re-assert it on every poll of the bounded wait, where the helper caps its note to about every tenth call and reports the attempt count at pass time. This is the one documented exception to the `until` purity rule above — it mutates only which window Qt considers focused, is idempotent, and encodes no expected product result. Confirm the instrument with the `test_activation.h` self-test. The other side of this fact is a window that is active and exposed and marks messages read: that is the `KeepMainWindowNotMarkingRead` row below, not another use of `ForceWindowActive`. |
| An injected or fixture message is read the moment its chat is shown, an injected incoming item's notification never appears, or `HistoryInner::checkActivation` aborts on `IsServerMsgId`, next to `markingAsRead=1`, with `screenLocked=0` on an unlocked console or `screenLocked=1` on a locked one | The main window is exposed and either active in Qt's sense - `Test::ForceWindowActive`, the scenario's `Window::Controller::activate()` or another product flow, a `test-run --activate` launch, or the owner's click - or under `auto-scroll-inactive-chat`, so `MainWindow::markingAsRead()` is true. It reads no lock state, so a locked console marks read the same way while those terms hold, and a window shown behind the lock screen keeps a stale `isExposed()`; `screenLocked` is `Test::ReadConsoleLock()`'s reading of the console itself (on macOS the session's `CGSSessionScreenIsLocked`, which also sees a lock taken before the launch, unlike `Core::App().screenIsLocked()`), so `screenLocked=0` no longer hides a console locked before the launch. On macOS a default `test-run` launch no longer activates it. Clearing the QPA focus window does not help while `auto-scroll-inactive-chat` is on, and `QWidget::hide()` makes `CaptureWidget` refuse the window. This is the other side of the activation row above: there the window is not active, here it is and it marks messages read. | Call `Test::KeepMainWindowNotMarkingRead`, and call it again after `Window::Controller::activate()` or any non-background `showHistory`. Confirm with `AppendMainWindowNotMarkingReadSelfTest`. |
| A window reads `isExposed()` false (`exposed=0`) and gets no product paints - a `Test::PostPaintSampler` with no rows, a paint-stamped product timer that never starts, a frame that never advances - while another application is frontmost (`test-run`'s `frontmost_app`), with or without input during the run (`input_during_run`), the screen not locked | On macOS `test-run`'s default background launch does not activate the client (`launch_activation: "suppressed"`, `launch_method: "background"`), so its windows open behind the frontmost application's. A window covered from the moment it is shown keeps reading `isExposed()` true until the window server first reports an occlusion change for it - for example when `RestoreWindowExposure` drops it back behind, or when the owner rearranges windows - so `exposed=1` is no proof that it is on screen. Once the window server reports a covered window occluded, Qt's cocoa plugin delivers an empty expose (`QCocoaWindow::windowDidChangeOcclusionState` calls `handleExposeEvent(QRegion())`, `qtbase/src/plugins/platforms/cocoa/qcocoawindow.mm`), so `isExposed()` turns false and the widgets' repaints are held - their dirty regions kept, not painted - until the window is exposed again. Observed, before that default, in `2026/10/07/animate-the-sending-row-for-a-collectible-transfer` (`work/test.md`, Attempt 1). Run 1 launched without `--wait-idle` while the console was in use (`input_during_run=true`, idle 0.19 s before the launch and 0.01 s after, frontmost `cmux` both times, screen not locked): its fixture gate `the Money window shows the controlled head page in an exposed window` read `pages=1 exposed=0`, and every later stage was `N/A`. Run 2 launched with `--wait-idle 20 --wait-idle-max 120`, which was not met after 120 s, and input arrived again (`input_during_run=true`); its overlay set `Qt::WindowStaysOnTopHint` on the panel's `QWindow` right after showing it, activating nothing, and waited for `isExposed()` in a pure `until`: the gate read `exposed=1` and the leg ran end to end (50 in-process PASS). | Call `Test::KeepWindowExposed` on the window right after it is shown, then wait for `isExposed()` (`ReadWindowExposure(...).exposed`) in a pure `until`; call it whatever the window reads before it, since a window covered from the moment it is shown can read `exposed=1` there. Undo with `RestoreWindowExposure` before teardown and before reading anything the product derives from the hint, such as the call window's pin (`Calls::Window::pinnedOnTop()` reads its `QWindow`'s flags); while applied the window floats above every application's ordinary windows, so keep it no longer than the paints are needed. Never activate the app or take focus (`Window::Controller::activate()`, `activateWindow()`, `raise()`, `requestActivate()`, `Ui::SeparatePanel::showAndActivate()`) - the owner's keystrokes would go into the test client - and never `QWidget::setWindowFlag` on a shown widget, which re-parents and hides it. Launch with `test-run --activate` instead only when OS activation is itself the subject. The `--wait-idle` gate does not change the occlusion. A minimized window is refused by name, since stacking cannot expose it. Marking read: `isExposed()` is one term of `MainWindow::markingAsRead()` (see `test_marking_read.h`), so on an unlocked console exposing the primary window with `auto-scroll-inactive-chat` on can make it mark messages read. Confirm with `AppendKeepWindowExposedSelfTest`. |
| A preview-dependent check measures a message with no link preview: a `fixture gate:` or premise row reads the injected item's media as not displayed (`media=0`), the bubble is only as large as its text, items drawn as outgoing fail like incoming ones, and the same item may show its preview later in the launch | The item's `out()` is false and its chat's bar settings are not known yet. `HistoryItem::hideLinks()` is `!out() && history()->peer->hideLinks()` (`history_item.cpp`); `PeerData::hideLinks()` holds while `barSettings()` is unknown or has `ReportSpam` (`data_peer.cpp`). `MediaWebPage::createView()` then makes no view and calls `setHasHiddenLinks(true)` (`data_media_types.cpp`), and `HistoryItem::translatedTextWithLocalEntities()` drops the text's `Mention`, `Hashtag`, `Cashtag`, `Url` and `CustomUrl` entities (its `isUrl` filter in `history_item.cpp`), so a link hit test on the text misses too. It keys on the flag, not on the bubble's side: `NewMessageFlags()` leaves `MessageFlag::Outgoing` off for a self peer (`history_item_helpers.cpp`), while `Message::hasOutLayout()` draws every non-forwarded Saved Messages item as outgoing (`history_view_message.cpp`), so there every item injected with `NewMessageFlags()` loses its preview while the settings are unknown. When the settings arrive, `PeerData::setBarSettings()` queues `History::refreshHiddenLinksItems()` (`data_peer.cpp`, `history.cpp`), which rebuilds the marked items' views, so a reading can flip mid-run. A user's settings come from `ApiWrap::requestPeerSettings()`, the full-user answer or `updatePeerSettings`; `ApiWrap::requestFullPeer()` requests them separately only for a non-user (`apiwrap.cpp`). Unlike the `markingAsRead=1` row above, where the active window marks an injected incoming item read, no window state is involved here: the item keeps its text but loses its preview and its text links. Observed in `2026/10/06/fit-the-rich-article-width-and-last-line-to-the-bubble` (`work/test.md`, Run 1, the B-only launch): both preview gates read `rich media=0 plain media=0`, and the same twins showed their previews in later stages. | Before injecting, request the settings when `barSettings()` is unknown (`ApiWrap::requestPeerSettings`, a read) and wait for them in `until`, bounded at 30 s, in a stage whose `timeout` is above that bound (the source run used 50 s; the default `kDefaultStageTimeout` of 10 s would end the run as a stage timeout before the gate is written); add a `fixture gate:` row that they are known and the peer's `hideLinks()` is false; and make every preview-dependent row first assert that its media is displayed, writing it N/A through `Test::Skipped` otherwise (see “Media fixtures and fixture gates”). Repaired that way from Run 2 of the same campaign: the gate passed in all four later launches (`barSettings known=1 hideLinks=0` after 66-101 ms), but the chats list loaded within 4 s in each, so none re-created Run 1's condition and the request was not measured without a loaded chats list. The gate is what keeps an oracle off a text-only bubble: opening a chat in `HistoryWidget` already requests its settings (`ContactStatus`, `PaysStatus`), and `requestPeerSettings` keeps one request per peer in flight. |
| A notification check finds nothing delivered although the item was injected or received - no toast, no native row, `NativeManager::doShowNotification` never runs - the notification system skips the item (`System::schedule` returns at its `skipNotification` reading, `SkipState::Skip`), and the chat reads muted (`isMuted=1`, `history->muted()=1`) with no `mute_until` of its own | Not the product and not the window: the golden test account's default user notify settings are served `mute_until=2147483647`, which 777000 and every private chat without a mute value of its own inherit, so `Data::NotifySettings::isMuted` reads true and `System::computeSkipState` answers `Skip`. Observed in `2026/10/04/show-hidden-sender-gram-transfers-in-the-service-chat` (`work/test.md`: Run 1 recorded its delivered-notification leg `N/A` by `fixture gate G-notify: 777000 muted`; Run 2 reached it through a hand-written local unmute in its overlay). Distinct from the `markingAsRead` row above, where the window reads the item the moment its chat is shown; here the chat itself reads muted. | Override the chat locally with `Test::OverrideNotify(runner, peer)` (`test_notify_override.h`; the default `UnmutedSilentNotify` is unmuted with no sound), restore at the window's end and again in teardown (the module's own `Runner::onFinish` restores or verifies once more), treat a `settings-unknown` refusal as a fixture gate, paste the `InterceptNotifyRequest` hook into the overlay and prove locality with `Test::AppendNotifyLocalityCheck`. Never unmute through `Data::NotifySettings::update`, `resetToDefault`, `defaultUpdate` or `clearExceptions`: each reaches `ApiWrap::updateNotifySettingsDelayed`, which sends `account.updateNotifySettings` after `kNotifySettingSaveTimeout` (1 s) and changes the shared test account's served settings for every later run on every checkout. Confirm the instrument with `AppendNotifyOverrideSelfTest`. |
| A stage times out while the button it aims at is plainly on screen, and `FindAll<Ui::RoundButton>(<box content>)` reports zero buttons | `Ui::BoxLayerWidget::addButton` re-parents the button onto the shell (`box_layer_widget.cpp`), so a footer button is a direct child of the shell and not a descendant of the published box content. | Click it with `Test::ClickBoxButton(box, label)`, which roots the search at `Test::PaintingLayerRoot(box)`: gate on `Test::BoxButtonReady` in `until` and click in `then`, and pair it with the self-correcting repeat of the settling-layer row above. A label no shell button carries is a named refusal listing every label seen, never a silent no-op. |
| `ReadBoxButtons` / `BoxButtonReady` / `ClickBoxButton` in the turn the box was resolved names every shell button `(hidden)`, or `BoxButtonReady` is false, while the box is plainly being shown and a later stage reads and clicks the same button | `LayerStackWidget::prepareForAnimation()` calls `layer->hide()` on the current layer (`ui/layers/layer_widget.cpp`) and `animationDone()` calls `layer->show()` again. The stack's `BackgroundWidget` paints the animation from cached pixmaps while that hide holds, so every descendant of the `Ui::BoxLayerWidget` — the `_title` label, the content, and the re-parented footer buttons — answers `QWidget::isVisible()` false. `ReadBoxButtons` records that as `(hidden)` (a merely disabled footer included, because `!visible` wins over `isDisabled()`), and `BoxButtonReady` / `ClickBoxButton` are correct to refuse: a click then really would be the silent no-op those helpers exist to remove. `anim::type::instant` skips this hide; the product default `anim::type::normal` does not. | Take the judgement on a bounded readiness wait in `until` (`Test::BoxButtonReady`) rather than as an assertion in the turn the box is resolved. Do not relax `BoxButtonReady`'s visible-and-enabled requirement. Confirm with the `test_box_button.h` self-test. Distinguish this whole-layer hide from a product-hidden or disabled footer (`BoxButtonHidden` / `BoxButtonDisabled` after the layer has shown). |
| Every busy reading reports no matched button (`matched=0` / `buttonFound=false`) while the footer button is plainly on screen and other clicks in the same run work | `ReadBoxButtons` refuses to *match* a disabled or hidden shell button on purpose — a click on one is the silent no-op the helper exists to remove — so `matched()` is false and `ClickBoxButton` Fails. The only previous answer was a formatted `labels` entry `"Save (disabled)"` / `"Save (hidden)"`, which a caller either string-matched or read as "the button is not there". | Read `present` / `disabled` / `hidden` on the same `BoxShellButtons` reading, or `BoxButtonDisabled` / `BoxButtonHidden` beside `BoxButtonReady`. `matched()` stays false and `ClickBoxButton` still Fails. Confirm with the `test_box_button.h` self-test. Do not parse `labels` for a boolean and do not treat `matched()==false` as absent. |
| A capture of a visible widget is blank or refused at perfectly sane geometry — `target grab still looks blank` or `render root paints no background of its own` — and the target is a toast or another fade-in wrapper | `Ui::Toast::internal::Widget::paintEvent` (`toast_widget.cpp`) draws its whole frame into a transparent proxy at the fade-in opacity, so the grab holds the harness base and nothing else. | For a **toast**, capture with `Test::CaptureToastSubtree(toast, name)` (`test_toast_capture.h`) gated on `Test::ToastSubtreeReady` — its settled-show term is this module's own answer to the same blankness, and its frame cannot compose the surface beneath; never `Test::CaptureViaWindow`, which would (see the row below). For another fade-in wrapper that is a child widget - a `Ui::ImportantTooltip` is one: `RpWidget(parent)`, painting its cached frame at the animation opacity - capture with `Test::CaptureViaWindow(widget, name)`, which grabs the widget's window cropped to the widget's rect mapped into it. A blank mid-fade frame is a `Note` and never a FAIL, because the decisive oracle is the joined `accessibilityName` of the toast's `Ui::FlatLabel`s and the capture is corroboration. Second fact for the same target: only inside a synchronously drained input helper, which starves `hideAnimated()`, a toast's fade-out never completes, so toasts persist far past `Ui::Toast::kDefaultDuration` (1500 ms) — a harness-environment effect of that drained helper, never a product defect to “fix”. Across the runner's real event-loop ticks the manager's own hide timer still fires and takes an ordinary toast down; see the live-toast walk row below. A `Ui::Tooltip` is not a fade-in wrapper, and `CaptureViaWindow` refuses it: it is constructed as `RpWidget(nullptr)` (`Telegram/lib_ui/ui/widgets/tooltip.cpp`), its own top-level `Qt::ToolTip` window, so `ReadViaWindow` answers `target is its own window, so there is no opaque window behind it to grab`. Find it in `QApplication::topLevelWidgets()` with `dynamic_cast<Ui::Tooltip*>`, keeping only a visible one (`Tooltip::Hide()` hides the instance and deletes it from a queued `deleteLater()`, so a hidden one can linger in the walk), with the main window as the walk's known-present control; read its text from the public `accessibilityName()` (`_text.toString()`, the full text `Tooltip::popup()` sets, which it paints elided); and capture it with `Test::CaptureWidget`: it sets `Qt::WA_NoSystemBackground`, which exempts it from the blank-root refusal, and paints its own `textBg` background and its text with no fade animation. Observed in `2026/10/07/show-url-tooltips-and-copy-link-on-rich-message-url-buttons` (`work/test.md`, Attempt 1 Run 1), whose test log read that refusal for `class Ui::Tooltip 878,549 703x60` and saved the tooltip through `CaptureWidget`. |
| A toast capture is perfectly legible and shows the sheet, box or row underneath it: words, a hint or a row the toast is not about, readable through the toast, and its four corners holding whatever was behind them | `Test::CaptureViaWindow` grabs the toast's **window** cropped to the toast's rect, and `st::toastBg` is `#2c3033e5` (`ui/colors.palette`) — alpha `0xE5`, about 90% opaque — so about a tenth of every pixel the product painted underneath composes into the frame. The corners are worse: the rounded background is `_roundRect.paint(p, rect())` (`Widget::paintBackground` → `Ui::DrawRoundedRect` → `Images::PrepareCornersMask`), whose corner mask is transparent outside the arc, so the rect's four corners are not covered at all and the surface beneath shows there unblended. | Capture with `Test::CaptureToastSubtree(toast, name)` (`test_toast_capture.h`), which grabs the toast subtree itself — `Ui::GrabWidgetToImage` fills every pixel the toast leaves uncovered with `st::windowBg->c`, so the frame holds the harness base and never the product surface. Gate on `Test::ToastSubtreeReady` and read the phrase through `Test::CheckToastReads`. A root that is not the live toast, or a rect outside its geometry, is a named FAIL quoting both rects — never a silent reframe and never a quiet no-op. Resolve the toast with `Test::FindLiveToast()`; `Ui::Toast::internal::Manager` enumerates nothing. |
| A check read `Test::FindLiveToast()` as null and concluded no toast was raised, or a reading taken after a longer settle found an empty walk and treated that as a confident absence | `Test::FindLiveToast()` is `return (all.size() == 1) ? all.front() : nullptr` (`test_toast_capture.cpp`), so one `nullptr` answers both zero live toasts and more than one — a null-versus-non-null assertion cannot tell "no toast was raised" from "two toasts were". Separately, the persist fact in the fade-in row above holds only inside a synchronously drained helper; across the runner's real event-loop ticks `Ui::Toast::internal::Manager` still arms `_hideTimer` for `_hideAt`, `hideByTimer` calls `Instance::hideAnimated()`, and the completed fade reaches `Instance::hide()`, so a reading taken on a longer settle finds an empty walk and reads as a confident absence of a toast that was raised correctly. | Record `Test::FindLiveToasts().size()` and every live toast's text (`Test::ReadToastText`) in the check's own `details`, which print on the passing verdict too. Take the reading inside the toast's lifetime on `Test::ToastSubtreeReady` rather than on a longer settle. Do not treat `FindLiveToast()`'s `nullptr` as "no toast" without that count and those texts. |
| A window driven to full screen reads taller than its screen (`0,0 2560x1476` on a `2560x1440` screen), a normal round trip restores a shorter window than it started from (`960x580` to `960x544`), or a maximized window reads `Qt::WindowNoState` at that taller geometry after `showFullScreen()` and keeps it after `showNormal()`, readings that look like product behaviour | The overlay called `showFullScreen()`, `showNormal()` or another of the six `Ui::RpWindow` members named in “Exact objects and actions” through a `QWidget*` or `QPointer<QWidget>`. They are declared without `virtual` and only hide the non-virtual `QWidget` members of the same names, so the static type picks the function and the call never reaches the window's `Ui::Platform::BasicWindowHelper` — the mechanism of the `focused=0` row below, on another class. On Windows it skips `WindowHelper::showFullScreen()` (`ui_window_win.cpp`), which sets `_isFullScreen` so that `WindowHelper::updateMargins()` drops the custom title margins before `QWidget::showFullScreen()` runs; without it Qt lays the screen-sized frame out with the normal margins still set, and `QWindowsGeometryHint::handleCalculateSize` (`qwindowswindow.cpp`) adds them to the client rectangle on `WM_NCCALCSIZE`, extending the client above the frame. `2026/10/12/keep-the-full-screen-state-when-a-maximized-window-goes-full-screen-on-windows` measured it (`work/context.md`, diagnostic run 2, on a `2560x1440` screen at 240 DPI with device pixel ratio 2, and a probe `Ui::RpWindow` starting at `960x580` whose normal custom margins are `(0,-72,0,0)` physical): in leg `bypass-control`, `QWidget::showFullScreen()` read `0,0 2560x1476` (native `5120x2952`, 72 physical px taller than the screen) and `QWidget::showNormal()` restored `960x544`; in leg `bypass-qtmax`, from the maximized state, the widget read `Qt::WindowNoState` at `0,0 2560x1476` and `showNormal()` left it there; in leg `helper-run2`, `Ui::RpWindow::showFullScreen()` and `showNormal()` from the normal state read `0,0 2560x1440` and restored `960x580` exactly. Every platform's helper overrides some of the six, `setGeometry()` among them, so the skip is not Windows-only, but only this Windows consequence has been measured. | Make the call through a `Ui::RpWindow` static type, as “Exact objects and actions” says: the product accessor's `not_null<Ui::RpWindow*>`, held across stages as `QPointer<Ui::RpWindow>`, or a `dynamic_cast<Ui::RpWindow*>` of a found window. A reading taken after a transition driven through a `QWidget*` is a harness artifact: repeat the transition through `Ui::RpWindow` before recording it as product behaviour. |
| `focused=0` for a field that is plainly focused, often beside `focusWindowSet=1 focusWidget=QTextEdit` | `Ui::InputField` declares its own non-virtual `bool hasFocus() const` returning `_inner->hasFocus()` (`input_field.cpp`), and `QWidget::hasFocus()` is non-virtual too, so a read through the `QWidget*` a generic finder returns answers for the wrapper, not for the inner editor that holds the focus. | `dynamic_cast<Ui::InputField*>` the resolved widget and call its own `hasFocus()`, or test `QApplication::focusWidget()` for descendancy of the field. `Ui::PasswordInput` is a `QLineEdit` subclass with no wrapper, so a direct read there is already right. |
| A passcode / cloud-password box accepted Enter and produced no request: no SRP computed, no API call, no error | `PasscodeBox::submit()` (`passcode_box.cpp`) is five per-field `hasFocus()` branches and nothing else; in a Qt-inactive window every branch reads false, Enter falls through and `save()` never runs. | Submit through the box's own shell button with `Test::ClickBoxButton` — `lng_passcode_submit` when a caller set `customSubmitButton`, otherwise `lng_settings_save` or `lng_passcode_remove_button` — whose handler calls `save()` with no focus dependency; assert activation first, per the activation row above. Two of those branches are irreversible on a live account: `lng_passcode_remove_button` is the turning-off submit that disables the cloud password (`PasscodeBox::prepare`), and a `customSubmitButton` is `tr::lng_theme_delete()` on the account self-destruction flow (`AddDeleteAccount` in `self_destruction_box.cpp`). Place that click last in the scenario, per step 3 of Building one reliable packed scenario above. `PressKey(Qt::Key_Enter)` is a last resort only, and log which path submitted (`submittedVia=`) so a fallback stays visible. |
| `descriptionRead=0` for a `PasscodeBox` whose description the product plainly shows, while the same run's title read-back answers `titleLabelRead=1` | `PasscodeBox` renders `CloudFields::customDescription` (`passcode_box.h`) into `Ui::Text::String _about` (`passcode_box.h`, set in `PasscodeBox::prepare`) and draws it from the box's own `paintEvent` (`_about.drawLeft(...)`). No `FlatLabel` exists for it, so every label and accessibility read-back returns nothing on a correct product. The title is different: `BoxContent::setTitle` reaches `Ui::BoxLayerWidget`, which creates an `object_ptr<Ui::FlatLabel> _title` (`box_layer_widget.h`, `Ui::BoxLayerWidget::setTitle`), so a title read-back does work. | Decide it in process from the box's own construction seam: publish the comparison of `fields.customDescription` against the expected `tr::` phrase through `Test::CheckTextReads` and record the boolean as a row (`descriptionIsRestore=1 descriptionIsPhrase=0`), keeping the label read-back as a detail only. The row is about the **description** alone; a title read-back needs no such repair. |
| Text was typed and nothing was inserted: the field's own text stays empty and `changes()` never fires, even with `focused=1 focusWidget=QTextEdit` | The events were aimed at the `Ui::InputField` wrapper, which does not override `keyPressEvent` (`input_field.h`), and Qt propagates an ignored key event up the parent chain and never down into a child - the `QEvent::KeyPress` case of `QApplication::notify` re-delivers it to `w->parentWidget()` until it is accepted or a window is reached - so the keys reach the wrapper's ancestors right up to the primary window and never the inner `QTextEdit` that owns the text. | Type into `dynamic_cast<Ui::InputField*>(widget)->rawTextEdit()`, fall back to `Test::CommitText` on that same editor when the text stays empty, and prove insertion by reading the field's own `getLastText()` afterwards. |
| A text oracle fails on a date, time or number whose two sides read identically in the details | The formatter's U+202F or U+00A0 against the read-back's U+0020. `Ui::Text::String`'s block parser maps every space-class character except U+00A0 to U+0020 (`text_block_parser.cpp`, `replaceWithSpace`), so a `Ui::FlatLabel` fed `langDateTime()`'s narrow no-break space before AM/PM reads back a plain space through `accessibilityName()`, while a U+00A0 survives the parse. | Compare through `Test::CheckTextReads` / `Test::NormalizeSpaces` (`test_text_reads.h`): they map U+202F and U+00A0 to U+0020 on both sides and print both raw strings' distinct `U+XXXX` whitespace tokens on the passing verdict as well as the failing one. Never loosen digits, month or AM/PM. |
| A check is missing from the log with no PASS and no FAIL | The stage's gate read false and it ran as a hand-rolled no-op - `run` and `then` skipped, `until` answered true - so it wrote nothing at all. Distinct from the stages a timed-out stage or the watchdog skips (see “Scenario teardown before quit” above), which leave the timeout's own FAIL behind them. | Give the stage a `skipReason` (`test_runner.h`): a non-empty return emits `TEST_RESULT: N/A: <stage> - <reason>` in the turn that begins it, counts in `SCENARIO_RESULT`'s skipped clause and in the `test-run` report's skipped list, and never waits or times out. Confirm the instrument with the `test_gated_stage.h` self-test. |
| A language-restore check reports `PASS` against the wrong language: after the “restore” the fixture's synthetic text is still live through the product's own accessor, or an already-painted reactive label still shows it, and the row written to observe the restore passed anyway | Two independent halves. `Lang::Instance::fillFromSerialized` (`lang_instance.cpp`) never clears `_values` or `_nonDefaultValues` and re-applies only the pairs that were **non-default** in the serialized snapshot (its closing `applyValue` loop), so a key that was *default* before the install is absent from that set and keeps the fixture's value. And neither it (which fires `_idChanges` only) nor `switchToId` on an ordinary id (its `_updated.fire` sits inside the `#TEST_X` / `#TEST_0` branch) fires `Lang::Updated()`, the only signal `Lang::details::Value` (`lang_instance.cpp`) re-reads on and the producer every `tr::` phrase funnels into (`Producer<>::Combine` in `lang_values.h`), so an already-painted `Ui::FlatLabel` keeps the fixture text. The vacuous PASS is the consequence rather than a second fault: an expectation rebuilt from the live producers at read time matches a restore that did nothing, so the check reports `PASS` against the wrong language — three rows did exactly that in run 27 of `2026/09/07/prepare-gasless-gram-transfers-for-server-selection`, and only an unrelated post-teardown control caught it. | Install and remove through `Test::InstallLangPack` / `LangPackFixture::remove()` (`test_lang_pack.h`), whose removal is `switchToId` + `fillFromSerialized` **plus** one empty `MTP_langPackDifference` for the instance's own lang code and version, which applies and resets nothing and exists only to fire the `_updated` notification at the end of `Lang::Instance::applyDifferenceToMe`. Freeze the expectation **before** the install through `frozen(key)` and never rebuild it from the live producers afterwards; `checkRestored` compares against those frozen bytes and against the key's frozen non-default state, so “was default before, is default again” is proved rather than “reads the same string”. Confirm the instrument with `AppendLangPackSelfTest`, whose `LangRestoreFault::LeaveOneInstalled` and `::SuppressNotification` arms fail exactly the restore row and the read-back rows. |
| A language-pack scenario or self-test writes nothing but `TEST_RESULT: N/A` rows, every stage gated with “no candidate language key resolves in the generated table” and every candidate quoted as already overridden (`nonDefault="Cancel"` beside `value="Cancel"`), and widening the candidate list changes nothing | The cached cloud language pack overrides **every** key the generated table knows, so a currently-default key does not exist to be found. The client's own log states the count: `Lang Info: Loaded cached, keys: 10993`, which `Lang::Instance::fillFromSerialized` writes from `nonDefaultValuesCount` (`lang_instance.cpp`), against `kKeysCount = 10948` (`lang_auto_counts.h`) — two counts over different sets, since `applyValue` writes `_nonDefaultValues` unconditionally and so counts cloud keys the generated table does not know. That holds on any client that has ever downloaded a cloud pack, which is every real client and every `-testagent` run against a real account. The property is universal over the key table rather than specific to the candidates tried, which is why a longer candidate list cannot help and why the gate is permanent rather than flaky. | Arrange the precondition instead of searching for it. Freeze the live pack FIRST — an outer `Test::InstallLangPack` fixture does exactly that, and its `remove()` restores it — then call `Lang::Instance::switchToId` with the identity read from the instance itself: `reset` (`lang_instance.cpp`) rewrites every `_values[i]` from `GetOriginalValue(i)`, clears `_nonDefaultValues`, zeroes `_nonDefaultSet` and sets `_version` to 0, so every key is default by construction, and on an ordinary id it fires `_idChanges` only and never `_updated`. Assert that premise with a check of its own — `Test::AppendLangPackSelfTest`'s first stage is that whole arrangement, and its `both keys are default after the live pack was held and reset` row FAILs if the reset did not take. One ordering trap for anyone reproducing the leftover-value half by hand: the snapshot to restore to must be taken **after** that reset, or `fillFromSerialized` re-applies the cloud override and the half does not reproduce. |
| A scenario dies inside its own ink scan: `SIGABRT` with no `TEST_COMPLETE` after a long run of `PASS` rows and saved screenshots, the dump pointing at `std::vector::operator[]`; or, with no crash at all, a candidate count read back as a plausible `0` from a reading that classified nothing | `ScanInk`’s refusing paths returned before `result.candidates = std::move(candidates)` and `result.counts.assign(...)`, so a refused reading carried `ok=0` with **both vectors empty** and no reason of any kind. `scan.counts[i]`, `front()` or `back()` on that reading is a read past the end the hardened Debug `std::vector` aborts, and the hand-rolled `counts.empty() ? 0 : counts[0]` guard three campaigns wrote instead of crashing turns the same refused reading into a number a log reader cannot tell from a measurement. `MeasurePaintedInk` compounded it: when the derivation succeeded and the scan then refused, neither branch of its report chain ran and `report` stayed the empty string. | Read a count only through `Test::InkScan::countAt(i)`, which names the candidate and answers a named refusal for an index the reading does not have, for a reading that classified nothing and for candidates that do not separate; branch on `InkScan::state` and `reason`, never on an empty vector. Print the reading through `Test::FormatInkScan`, which carries the candidate colours, the counts, the collision and the five thresholds on the passing verdict too, so a palette whose references do not separate is readable without re-running the campaign. The refusing paths now fill both vectors, so they are always the same length, and `MeasurePaintedInk` writes a non-empty `report` on every path. Confirm the instrument with `AppendDeriveBandSelfTest`. Do not read this as the latched-hover row or the light-footer-fill row above: those are `DeriveBand` refusing to derive a band at all, this is a scan that ran and could not classify what it scanned. |
| A `Classified` reading with `reason=none`, every candidate count zero and `ambiguous` equal to `inkPixels`, on pixels that are demonstrably painted | The candidates separate from each other and from the fill by the shipped thresholds, but one candidate lies on the segment from the measured background to another, so the classifier's margin never lets a pixel of it win. The app's dim text is this triple: `windowSubTextFg` `#999999` on the segment from `windowBgOver` `#f1f1f1` to `windowFg` `#000000` (`ui/colors.palette`). Observed in `2026/09/21/ui-show-the-recipient-in-the-gram-send-box`, whose day-theme arms failed on correct pixels. | The scan answers `background-collinear` instead of `classified`, and its `reason` names the unreachable candidate, the candidate whose segment swallows it, and the measured background. `countAt` refuses with `count=none`. Confirm with `AppendDeriveBandSelfTest`. `candidates-collide` and `no-ink` keep their own texts. |
| A "this surface paints a coloured glyph" claim decided by counting pixels that are neither the pen colour nor the fill, passing and failing on totals only about 2% apart | Subpixel text rendering puts chromatic fringes on ordinary glyphs, so that count cannot separate a coloured mark from plain text. The diagnosing run saw 794 such pixels (mean `#888988`) for an ASCII name against 813 (mean `#c94840`) for an emoji. Density does separate them: the emoji filled about 70% of a near-square window, the fringes about 26% of a wide smear. | Read `ReadChromaticRaster` (`test_ink.h`). It reports the densest chromatic window and the measured mean of the pixels it matched, and refuses by name as `no-chromatic` or `below-density` instead of handing back an empty box. Confirm with the chromatic arm of `AppendDeriveBandSelfTest`. |
| A "text is not link-coloured" (or "text is pen X") check fails on correct neutral text because `ScanInk` answers `classified` with a non-zero `countAt` for a chromatic candidate, in both themes | Subpixel antialiasing puts chromatic fringes on the glyph. `ScanInk` attributes each ink pixel to the candidate segment nearest it, so those fringes go to the link pen even when the solid cores are the neutral pen. A zero on `countAt(chromatic)` is not reachable, and no threshold on the non-zero count is principled. `ReadChromaticRaster` does not repair it either: it is a mark-density reading, not a pen reading, and its `below-density` refusal is no proof that the text is not a chromatic pen. | Read `ReadGlyphCore` (`test_ink.h`). It reports the modal colour of the solid cores and each named pen's solid-core count, and refuses by name as `no-paint` or `no-solid-core` instead of handing back a fringe colour. Confirm with the glyph-core arm of `AppendDeriveBandSelfTest`. |
| A panel reading that changed by one and was attributed to the panel the scenario expected, or a zero recorded as "no panel opened" by a walk that was never shown to reach a panel it could see | A count is not a difference: one panel closing while another opens reads as no change, and a count cannot name which pointer is new — and an `isVisible()` / `!isHidden()` filter counts a `Ui::SeparatePanel` that is still painting its show-animation cache, so the panel such a reading names may be the wrong one. A bare zero from a walk with no control cannot be told from an enumeration that structurally could not see a panel. Distinct from the `Test::DiscriminatingScan` row above, which is a sweep whose enumeration cannot reach its subject at all: here the walk reached the right widgets and read the wrong statistic out of them. | Take a `Test::PanelList` mark before the action and read `Test::WalkPanels(&scan, { .before = mark })`'s `added` — pointers, not a count — under the default `Settled` liveness, which `ReadPanelShowState` decides; certify the zero with `scan.report()`, and read `PanelWalkText` / `PanelWalk::refusal` where a pure reading safe to poll from `until` is wanted; refuse an ambiguous identity ask with `Test::PickPanel` instead of taking the front of the list. Confirm the instrument with `Test::AppendSeparatePanelWalkSelfTest`. |
| Exactly two `FAIL` rows in the first stage of `Test::AppendSeparatePanelWalkSelfTest` — the `fixture gate:` premise row reading `state=live translucentWindows=0` where `show_cache` was expected, and the settled reading holding the same panel it was expected to exclude — while every other row in the run passes, including the `!isHidden()` control in that same stage, the `panel_walk_show_cache discriminates` scan report, and all of the settle, exclusion and teardown stages | Not a harness fault but a host precondition. `Ui::SeparatePanel` builds its show-animation cache only inside `if (_useTransparency)` (`SeparatePanel::toggleOpacityAnimation` in `ui/widgets/separate_panel.cpp`), and `_useTransparency` is `Ui::Platform::TranslucentWindowsSupported()` latched once in `initGeometry` — an inline `return true` on macOS and Windows (`ui/platform/mac/ui_utility_mac.h`, `ui/platform/win/ui_utility_win.h`) and the display server's answer everywhere else (`ui/platform/linux/ui_utility_linux.cpp`: true on Wayland, the `_NET_WM_CM_S0` selection owner on X11, false otherwise). With no cache `hideChildren()` never runs, so the panel's children are never all hidden at once, `ReadPanelShowState` answers `live` in the turn the panel was shown, and the `Settled` walk keeps it. `Ui::PopupMenu` carries the same precondition on the same predicate and states it in `test_menu.h`. | Tell it from an instrument fault in one glance: the failing premise row names itself `fixture gate:`, and an instrument fault takes the controls down with it — the scan's `discriminates` row and the `!isHidden()` control would fail too, and so would the settle and difference stages; here both controls pass and only the two cache rows fail. Nothing in the harness is to be repaired: read the pair as the host precondition, keep the rest of the run's verdict, and prove the cache-versus-settled distinction on a host whose display server supports translucent windows. Do not convert it to a `skipReason`: the `popup menu self-test: open` stage of `Test::AppendPopupMenuCaptureSelfTest` (`test_menu.cpp`) answers this same predicate with a named hard `Check` (its translucency `fixture gate:`) rather than a skip, and `test_panel.h`'s doc block records why this module follows it — a skip would withdraw the cache-versus-settled certification on exactly the hosts that cannot perform it, instead of reporting it. The same self-test's locked-console gate is a skip, for the reason in the menu-capture row above: a lock passes, a host's missing translucency does not. This is a host property the run cannot arrange, so it is not a fixture gate in the “Media fixtures and fixture gates” sense above either. |
| A click check reads no effect in the very tick that clicked - for example `detailCount=0 before=0 id= via=handler guard=[]` after a tap that plainly reached a handler - and the stages that depend on that click read their `skipReason` in the same tick and skip as `N/A`, so whether the click worked is never read | `ActivateClickHandler` (`Telegram/lib_ui/ui/click_handler.cpp`) does not call `onClick` itself: it posts `strong->onClick(context)` through `crl::on_main(guard, ...)`, so the handler runs in a later main-queue turn. The production `HistoryInner` release path (`Telegram/SourceFiles/history/history_inner_widget.cpp`, the `mouseActionCancel()` followed by `ActivateClickHandler(window(), activated, prepareClickContext(...))`) and a direct call from a scenario defer alike. This differs from the settling-layer row above: there the synthesized click intermittently does not land at all; here the click landed and the handler ran, one tick later than the reading. Observed in `2026/09/17/open-the-chat-after-sending-grams-from-a-profile` (`work/test.md`, Run 3). | Click, then wait a bounded tick for the effect in `until` (the details count grew, or 2.5 s), and only then assert in `then`; a `skipReason` that depends on a click's effect is read after that wait, never in the tick that clicked. Repaired that way from Run 4 of the same campaign. |
| A tap on a service message that also displays a media activates the wrong link: the guard rows name a registered handler that is not the media's (`guard=[registered id=... constructor=...]`, no media guard row) and an unrelated request such as `payments.getSavedStarGifts` registers right after it - the recipient's profile opened instead of the media's own action | `HistoryView::Service::textState` (`Telegram/SourceFiles/history/view/history_view_service_message.cpp`) lays the service text above a displayed media: with the media displayed it shrinks the text geometry by `st::msgServiceMargin.top() + media->height()`, computes `mediaTop` below that geometry, and hands the point to `media->textState` only in its `mediaDisplayed && point.y() >= mediaTop` branch. A link scan that walks the element from its top therefore meets the text's own links first - for a transfer card that is the recipient-name peer link - and never reaches the media's link. Observed in `2026/09/17/open-the-chat-after-sending-grams-from-a-profile` (`work/test.md`, Run 4). | Scan only the media rows: from the element's bottom up to `height - media->height()`, and print `mediaTop` and `mediaHeight` in the details so a miss names the geometry it searched. Repaired that way from Run 5 of the same campaign, which found the link at `mediaTop=47 mediaHeight=196`. |
| A `QWidget::childAt` identity check on a grid of gift buttons answers the neighbouring button at a point inside the expected square's ink - next to its top or bottom edge, or anywhere in a one-row sliver that a clip leaves of it - while the probes at square centres pass: `G18 childAt hit targets - measured=16 expectAfter=22 ... k100 visible part@76,537:gift:k100=gift:k200!` | Adjacent `Info::PeerGifts::GiftButton` widgets overlap. `GiftButton::setGeometry(inner, extend)` (`Telegram/SourceFiles/info/peer_gifts/info_peer_gifts_common.cpp`) sets the widget to the square plus `extend`. The Money grid and the profile list pass `GiftButtonDelegate::buttonExtend()`, implemented by `Info::PeerGifts::Delegate::buttonExtend()`, which returns `st::defaultDropdownMenu.wrap.shadow.extend`, and `Ui::MakeGiftsList` (`Telegram/SourceFiles/boxes/star_gift_box.cpp`) reads that extend directly: `defaultInnerDropdown`'s `roundShadowRadius8px`, `margins(10px, 10px, 10px, 10px)` (`Telegram/lib_ui/ui/widgets/widgets.style`). At the Money grid's regular pitch, the squares are `st::giftBoxGiftSkip` apart, `point(10px, 8px)` (`Telegram/SourceFiles/ui/effects/credits.style`). So vertically adjacent widgets overlap by 12 px across the 8 px gap, and the two ink rows of a square nearest each vertical neighbour (four at 200 %) also lie inside that neighbour's widget; horizontally they overlap only inside the 10 px gap and cover no ink. Where widgets overlap, `QWidget::childAt` descends through the highest eligible sibling in the stacking order - `QWidgetPrivate::childAtRecursiveHelper` (Qt's `qwidget.cpp`) walks the children from the last one down, skipping hidden and `Qt::WA_TransparentForMouseEvents` children such as the grid's thumbnail buttons - which is creation order unless something restacks. The Money window's `CollectiblesGrid` (anonymous namespace, `Telegram/SourceFiles/wallet/wallet_collectibles.cpp`) creates its missing buttons in index order in `CollectiblesGrid::refreshSquares()`, keeps the live ones by address and never calls `raise()`, `lower()` or `stackUnder()`, so higher indices are on top when all buttons are newly created in one pass. Retained buttons keep their previous stacking order: a newly created earlier row can be above a retained later row after scrolling up, and scrolling down does not reset that retained order. The profile list (`Info::PeerGifts::InnerWidget::validateButtons()`, `Telegram/SourceFiles/info/peer_gifts/info_peer_gifts_widget.cpp`) and `MakeGiftsList` have the same 12 px vertical widget overlap at rest, from the same vertical gap `giftBoxGiftSkip.y()` and shadow extend, and recycle buttons; the profile list raises a dragged one. Each layout's vertical pitch is still its own button height plus that gap: `Info::PeerGifts::Delegate::buttonSize()` picks the height by `GiftButtonMode`, `st::giftBoxGiftSmall` (108 px) for the Money grid's `Minimal` and the profile list's `Minimal` or `Selection`, and `st::giftBoxGiftHeight` (164 px) for the `Full` mode that `MakeGiftsList` uses for its default `GiftsListMode::Send`, so the pitch is 116 px in the Money grid and the profile list but 172 px in the default send list at 100 %. `MakeGiftsList` right-aligns the last column, so its last horizontal gap can exceed `giftBoxGiftSkip.x()`; do not assume every horizontal gap is exactly 10 px. The grid's own press handling is unaffected: `CollectiblesGrid::createGiftButton` takes the button's `GiftButton::mouseEvents()`, and `CollectiblesGrid::mousePressEvent` / `mouseReleaseEvent` resolve the global point by square rects with `CollectiblesGrid::squareAt`; its context menu, `GiftButton::contextMenuRequests()` into `CollectiblesGrid::showCardMenu`, goes by the receiving button, as `childAt` does. This differs from the service-message row above: there a link scan walking the element from its top met the text's own links before the media's; here the probe point is right, Qt answers the button on top, and the instrument's one-owner assumption is wrong. Observed in `2026/10/20/start-collectibles-at-the-transactions-top-skip` (`work/test.md`, Attempt 1, Run A1, judged `TEST_FLAW`): four G18 rows of the source's disposable overlay (`work/test-overlay.patch`) failed. The bottom two ink rows can lie under the next row's widget even at rest (the B2 Test 3 geometry gives k100 ink `[441,549)` and k200 widget start 547 at 100 %), and the grid, revealed in place, was clipped to a one-row sliver of k100 at clip top 537, which lay wholly inside k200's widget, on top there. | Expect a square's own button only at points that no other button's widget rect contains. For shown, mouse-eligible gift buttons, build each probe's owner set from live geometry - every button whose widget rect, its square plus the extend, contains the point - and require the square's own button where that set holds it alone. Move a visible-part probe whose centre is shared to the middle of the longest exclusive run of rows at that x, and accept any button of the set only where the part has no exclusive point (a sliver wholly inside a neighbour's overlap), saying so in the probe's name, as the source overlay's `(exclusive rows)` and `(inside a neighbour's shadow overlap)` probes do. Respect Qt's hidden/transparent exclusions when applying this to another grid. Clip-boundary positives take the same set rule; keep the clipped negatives - the pixel beyond a clip edge hits no gift - as they were. **Forbidden:** dropping the identity checks at exclusive points, dropping the clipped negatives, accepting any gift at a point only one button's widget covers, and assuming which neighbour is on top. The G18 check and these probe names belong to the source's disposable overlay (`work/test-overlay.patch`), not the permanent harness. Repaired that way for Runs A2 and A3 of the same campaign, which both passed: every G18 probe was correct, with two A2 frames using the set rule at y 537 and none needed at 200 %. |
| A readiness gate on a served one-page history times out with the list resting at its bottom - `scrollTop=5055 scrollTopMax=5055`, the card resolved and mapped whole inside its owner - and every later stage is skipped | The legacy `HistoryWidget` requests its older page only from `HistoryWidget::preloadHistoryByScroll` (`Telegram/SourceFiles/history/history_widget.cpp`), which calls `loadMessages()` only while `scrollTop <= kPreloadHeightsCount * scrollHeight` (`kPreloadHeightsCount` is 3 screens). A served history shown at its bottom never issues that request, so `historyLoadedAtTop()` stays false while `historyLoadedAtBottom()` is already true, and a gate that requires both edges before the first scroll can never turn true. Observed in `2026/09/17/open-the-chat-after-sending-grams-from-a-profile` (`work/test.md`, Run 1). | Gate an at-bottom reading on the bottom edge only (`historyLoadedAtBottom()`), which is the edge the claim is about, and print `loadedTop`, `loadedBottom` and the scroll movement in the readout so a timeout names its failing term. Repaired that way from Run 2 of the same campaign. |
| A "the box stays open after the refusal" check reads `isVisible()` false on a box that plainly exists - `Ui::GenericBox 0,48 364x176`, later closed by `closeBox` - and the same tick's Info layer readout says `(visible 0)` where a passing leg of the same check says `(visible 1)` | The sibling of the layer-hide row above: `LayerStackWidget::prepareForAnimation()` (`Telegram/lib_ui/ui/layers/layer_widget.cpp`) hides the current layer while a show or hide animation paints from cached pixmaps, so a refusal that lands inside the box's own show animation is read while the whole layer, the box included, answers `QWidget::isVisible()` false. That row covers the shell buttons reading `(hidden)` in the turn the box was resolved; this one covers a synchronous `isVisible()` read on the box itself, taken in the tick a controlled refusal arrived, on a box that is being shown rather than resolved. Observed in `2026/09/17/open-the-chat-after-sending-grams-from-a-profile` (`work/test.md`, Run 4, C5b and C5c). | Read the box after a bounded settle (visible, or 1.5 s) and print `visible`, `hidden` and `layerShown` so the row shows which term answered. Repaired that way from Run 5 of the same campaign, whose settles measured 152 ms and 37 ms. |
| A visible-range or row-window oracle read on a construction or reopen turn, before its window is shown, finds an empty on-screen clip and expects only the top preload rows, while the product reports the window of its logical scroll range - `IL100 E3 reopen construction turn G17 square window - measured=0-5/15 expectBefore=0-5/15 expectAfter=0-2/6 ... clamp=0,254 actual=0,0` | The oracle gated its clip on `QWidget::isVisible()`, which every widget of a hierarchy not shown yet answers false, while visible ranges are forwarded by geometry alone. `Content::updateVisibleArea()` (`Telegram/SourceFiles/wallet/wallet_content.cpp`, anonymous namespace) hands its column the range from `_scroll->scrollTop()` to that plus `_scroll->height()`; `RpWidget::setVisibleTopBottom` (`Telegram/lib_ui/ui/rp_widget.h`) only clamps a range to `[0, height()]` before calling `visibleTopBottomUpdated`; `RpWidget::setChildVisibleTopBottom` shifts it by the child's `y()`; `VerticalLayout::visibleTopBottomUpdated` (`Telegram/lib_ui/ui/wrap/vertical_layout.cpp`) forwards it to every row and the `Ui::Wrap` override (`Telegram/lib_ui/ui/wrap/wrap.h`) to the wrapped widget; none of them tests visibility. `Wallet::CollectiblesViewport::visibleTopBottomUpdated` (`Telegram/SourceFiles/wallet/wallet_collectibles_viewport.cpp`) passes on its page's intersection with the range, or `0, 0` when there is none, and `CollectiblesGrid::visibleTopBottomUpdated` (`Telegram/SourceFiles/wallet/wallet_collectibles.cpp`) stores it and calls `refreshWindows()`, whose `refreshSquares()` uses `rowsRange(_visibleTop, _visibleBottom, kPreloadRows)` to include two preload rows on each side, clamped to the available rows. So a hierarchy not shown yet still preloads its logical window. Geometric forwarding while unshown also occurred before the source task; the new viewport preserves that property. The unshown preload is not a defect. Like the box-visibility row above, every widget of the hierarchy answers `isVisible()` false; unlike it, the reading is about what the product does on that very turn, so waiting for the show does not repair it. Observed in `2026/10/20/start-collectibles-at-the-transactions-top-skip` (`work/test.md`, Attempt 1, Run A1, judged `TEST_FLAW`): the source overlay's G17 check ran while the Money window's `Content` was not shown on the reopen construction turn, so the overlay's visibility-gated clip was empty (`actual=0,0`) and its oracle expected `0-2/6`, while the product preloaded `0-5/15` from the logical range `0,254`, equal to the BEFORE clamp-chain oracle and to the BEFORE v2 measurement of the same row; the first visible frame then read `0-5/15` on both oracles. | While the owner is not visible, take the expected range from the geometric intersection of the widget with every ancestor up to the owner and with the scroll viewport, without the visibility gate - the source overlay's `LogicalClip`, beside its visibility-gated `ActualClip` (`work/test-overlay.patch`) - and print which oracle decided in the trace row, as that overlay's `range=logical-unshown` does. Keep the visibility-gated clip, unchanged, for every shown frame. **Forbidden:** reporting the unshown turn's preload as a product fault or adding a visibility test to the product for it, dropping the row, judging a shown frame by the logical range, and changing a paired BEFORE oracle (the source's clamp chain). G17, `LogicalClip`, `ActualClip` and the trace label are that source overlay's identifiers, not permanent harness symbols. Repaired that way for Runs A2 and A3 of the same campaign, which both passed: the reopen construction turn read `0-5/15` on the logical range at 100 % and `0-4/12` on `0,263` at 200 %, both equal to BEFORE. |
| A web-document leg that expected an `upload.getWebFile` finds no request - the held or counted request never appears, or its canned answer names an unregistered request id (`request is not registered`) - while the loaded entry reports its bytes anyway (`state=5 bytes=227`) and paints | The document was served from the persistent local cache, not the network. `Data::WebDocumentCacheKey` (`data/data_types.cpp`) is the SHA-256 of the URL alone, not of its `access_hash`, and every `-testagent` run reuses the marked live portable folder with its caches. So a canned or fabricated URL an earlier run (or an earlier leg of this one) loaded is found by `FileLoader::loadLocal` in `session->data().cache()` and finished locally, and no request is issued. The same trap fakes a pass: a "no request was issued" check, or a zero on a network counter, reads true on a cache hit. Observed in `2026/09/29/load-collectible-media-only-through-upload-get-web-file` (`work/test.md`, Run 2, Test C6). | Remove the key before the leg that must reach the network: `Test::RemoveWebDocumentCache(session, url)` (`test_web_cache.h`) in a stage's `run`, `finished()` in its bounded `until`, `Check(ok())` in `then`. It clears both `cache()` and `cacheBigFile()` and reports finished only after both answered. Repaired that way in Run 3 of the same campaign. A "no request" or zero-count check on such a document is not certified until its key has been removed in that run. Confirm the instrument with `AppendWebDocumentCacheSelfTest`. |
| A paste check never pastes: the clipboard reads back empty right after `QGuiApplication::clipboard()->setText` (`formats=0`, text length 0, often already empty at the start), so the Paste accessory is never clicked or Ctrl+V inserts nothing, and every paste row stays undecided; or a single round trip fails on a transient lock - the app log reads `OleSetClipboard: Failed to set mime data (text/plain) on clipboard: COM error 0x800401d0 (OpenClipboard Failed)`, then Qt's own `Retrying to obtain clipboard.` / `Unable to obtain clipboard.` for about 0.9 s - and the clipboard reads back again within about a second | The agent-launched `-testagent` process could not read the system clipboard back on that host. Observed in `2026/09/29/keep-the-focused-word-field-in-view-when-importing-a-wallet` (`work/test.md`, Run 1, Tests 9 and 10) on a Windows host: `CLIPBOARD saved formats=0`, `clipboardTextLength=0` right after `setText`, eight paste rows undecided, `TEST_FLAW`. The cause is host-level and not diagnosed: earlier campaigns on other hosts, Windows included, wrote and read the clipboard. A save and restore does not protect the owner either: restoring a clipboard that read back empty writes the empty reading back, which clears it. The transient lock is a distinct case, observed in `2026/10/07/show-url-tooltips-and-copy-link-on-rich-message-url-buttons` (`work/test.md`, Attempt 1 Run 1, Test 0): `OpenClipboard` failed at that moment, the run did not identify what held the clipboard, a later `Get-Clipboard` read fine and `GetOpenClipboardWindow()` was 0, and that run's single, un-retried round trip left every clipboard oracle undecided. Qt's own retries are short and sleep the main thread: `OleSetClipboard` three times 100 ms apart on `CLIPBRD_E_CANT_OPEN`, `OleGetClipboard` three times 50 ms apart (`qwindowsclipboard.cpp`). A locked Windows console is a diagnosed cause of the persistent shape: no process on the console's desktop can open the clipboard while it is locked, so every write fails `OpenClipboard` and the round trip ends in `read-empty` after the whole bound. Qt does not retry `OleSetClipboard` while `QWindowsContext::isSessionLocked()` - the same `WTSSessionInfoEx` `SessionFlags` reading `Test::ReadConsoleLock()` takes, but of the active console session - answers locked (`QWindowsClipboard::setMimeData` in `qwindowsclipboard.cpp`), so a locked console's writes return at once (`writeMs` 0-26 in Run 1 below), while a retried write on an unlocked console sleeps 100 ms per retry. Observed in `2026/10/07/add-a-same-turn-context-menu-reader-and-a-retried-clipboard-round-trip-to-the-test-harness` (`work/test.md`, Attempt 1 Runs 1 and 2): Run 1's live arm logged 10 attempts, each `read=empty owns=0`, and ended in `read-empty`, beside ten `OleSetClipboard ... 0x800401d0 (OpenClipboard Failed)` lines in the app log, and an independent PowerShell reading right after it found `LogonUI.exe` in session 1, `GetForegroundWindow()` 0 and `OpenClipboard(NULL)` failing with `GetLastError` 5 while Telegram was not running; Run 2, the popup-capture self-test alone, launched with `LogonUI.exe` still present; Run 3, unlocked, read the sentinel back at attempt 1. The 2026-09-29 run did not record whether its console was locked, so a locked console is a candidate cause for that case, not its diagnosis. | For checks about what a field does with pasted text, use `Test::DropText(field, text)` (`test_widgets.h`), which never reads or writes the clipboard, and judge its `TextDrop` (`delivered`, or the named refusal) before the product result. Run 2 of the same campaign repaired it this way in its overlay. Do not save or restore the clipboard around it. The helper does not exercise the Paste accessory's or Ctrl+V's own clipboard read: a check whose subject is that read still needs a host where the clipboard reads back, and it refuses by name rather than FAILing when it does not. Confirm the instrument with `AppendTextDropSelfTest`. Tell the transient lock from the persistent case with `Test::AppendClipboardRoundTrip` (`test_clipboard.h`): a caller-labelled sentinel written and read back up to 10 times 300 ms apart, yielding to the event loop between attempts instead of sleeping, each attempt logged as a `CLIPBOARD_ROUND_TRIP_ATTEMPT` row. A transient lock reads its sentinel back at a later attempt; the persistent case ends in the named refusal `read-empty`, `read-stale-sentinel` or `read-foreign`, which never prints text the harness did not write. Run 2 of `2026/10/07/show-url-tooltips-and-copy-link-on-rich-message-url-buttons` decided every clipboard leg behind a bounded retry of the same size, written by hand with a main-thread sleep between attempts, and its premise read `ok:1 attempts:1`. Judge a product copy with `Test::ReadClipboard`. Confirm with `AppendClipboardRoundTripSelfTest`. On a locked Windows console, read the console with `Test::ReadConsoleLock()` (`test_console_lock.h`): a refused round trip's reason already ends with it (`; console at refusal: CONSOLE_LOCK: state=...`), so a campaign's own refusal names a locked console with no wiring, and `AppendClipboardRoundTripSelfTest`'s live arm is `TEST_RESULT: N/A` naming the lock - the probe row, the refusal and its attempts - instead of failing `read-empty`, only when the probe reads locked as its judging stage begins and the round trip did not read back, so a round trip that read back is never withdrawn; its injected-accessor arms and its foreign-text scan still run and decide. A campaign gates its own clipboard leg the same way, through its judging stage's `skipReason`. The rule: for these arms a locked console is a scheduling condition - rerun them on an unlocked console. This applies to Windows; `Test::ConsoleLockGate` answers only in a Windows build, so no gate fires elsewhere, while a refusal on macOS still prints the session's lock reading (the macOS observation is in the menu-capture row above). |
| A check that samples an effect through a driven sequence reads it unchanged at every sample although the drive itself acted - for example `E6_SWIPE build=before samples=58 preShown=15.0000 maxTurn=0.0000` with `shownMoves=0 centreMoves=0` beside `minShift=-40` and a passing "the swipe moved the card on screen" control - while the product's own pending flag, wherever a sample reads it, stays set. The build or leg that must show the effect reads it unchanged as well, so a "nothing changed" check on the other build passes vacuously | The product applies the effect in a callback it posted with `crl::on_main`, and the sampler never returned to the event loop between samples, so that callback could not run until the sequence was over. `crl::on_main` never runs its callback inline: `Telegram/lib_crl/crl/crl_on_main.h` picks `crl/dispatch/crl_dispatch_on_main.h` when `CRL_USE_DISPATCH` is defined without `CRL_FORCE_COMMON_QUEUE` (`Telegram/lib_crl/CMakeLists.txt`; that is macOS outside the Mac Store build), whose `on_main` is a `dispatch_async_f` onto `dispatch_get_main_queue()` (`crl/dispatch/crl_dispatch_async.cpp`), and `crl/common/crl_common_on_main.h` otherwise, whose `on_main` pushes onto a `crl::queue` (`crl/common/crl_common_queue.h`) that wakes `PushToMainQueueGeneric` in `Telegram/lib_ui/ui/main_queue_processor.cpp`, which only posts a `QEvent` to `Ui::MainQueueProcessor`. The queued callback then runs on a later event-loop turn, or when a `QEvent::UpdateRequest` reaches `Core::Sandbox::notify` (`Telegram/SourceFiles/core/sandbox.cpp`), whose `widgetUpdateRequests()` stream makes `Ui::MainQueueProcessor` drain the queue (`Platform::DrainMainQueue()` or `DrainMainQueueGeneric()`): `QWidget::repaint()` can send one synchronously, while `QWidget::update()` only schedules one and an offscreen frame through `Ui::GrabWidgetToImage` or `Test::ForceWindowActive`, which flushes only window-system events, sends none. The observed sampler was the `Script` sequencer of the run's saved overlay (`work/test-overlay.patch` of the task below): `Script::advance()` is a `while` loop that stamps `_lastRun` before each step's action and arms its timer only for a step not yet due, so a step whose action outlasts the next step's delay runs that next step inline. The campaign's saved CSVs (`evidence/swipe-before.csv` from Run 3, `evidence/swipe-after.csv` from Run 4, whose build never pumped) put consecutive samples' `at` 22-40 ms apart against a 16 ms delay, so each next step was already due and all 58 samples ran inside one `advance()` call. Observed in `2026/10/02/turn-gram-cards-only-by-visible-angle-steps` (`work/test.md`, Run 2): the card-angle tracker's follow, posted through `crl::on_main`, never ran during the 58-sample drive of about 1.5 s, so the build before the change showed no turn and the after build's earlier pass decided nothing. This differs from the `ActivateClickHandler` deferral row above: there the click landed and the handler ran one main-queue turn after a reading taken in the clicking tick, and a bounded `until` wait repairs it; here the sampler does not return to the event loop between samples, so the posted callback does not run during the sequence at all, and no wait after the sequence repairs samples already taken. | After each driven frame, pump `QCoreApplication::processEvents()` with the window re-activated before each pass (`Test::ForceWindowActive`), bounded to at most 5 passes, until the product's own pending flag clears; record the pass count and the flag after the pump in the sample's row, and only then read the effect. Never judge an effect from samples taken before the posted callback could run. Keep a control build or leg that must show the effect, so a callback that never ran fails it instead of passing silently. Repaired that way from Run 3 of the same campaign: the posted follow ran within 2 passes on each of the 20 samples that had scheduled one, and the before build turned 4.8204 deg and sprang back. |
| A decisive bracket spans a sample gap far above the sampler's cadence while the measured surface paints back to back, and the product's own timers report similar lateness - for example a 16 ms timer sampler whose grab 25 (`hi` 44159) and grab 26 (`lo` 44211) are 52 ms apart across product frames 44159, 44173 and 44193, beside the product's `arrive due=44159 late=52`, so `vstart(B2).hi - turn` read 75 against 50 and stayed undecided | A timer-driven sampler is not synchronized with the paints it measures: whatever runs between two of its ticks falls into one gap, and the tick itself can fire late. Observed in `2026/10/05/play-gram-card-read-animations-one-after-another` (`work/test.md`, Attempt 1 Run 1, Test 2), which read that lateness as back-to-back product paints starving the timer queue without measuring it. Four launches of `AppendPostPaintSamplerSelfTest` (task `2026/10/06/add-a-post-paint-capture-sampler-to-the-harness`: a calibration launch, one with every self-test, and two whose mutated sampler left the control unchanged) measured it on its 18 ms synthetic owner, whose every paint requests the next. In every one the event loop started product paints while the 16 ms control's tick was already overdue (`overdueStartsTotal` 11, 22, 11 and 8 in that order), and each launch's latest tick had such paints in its overdue interval (`lateMax` 87, 34, 107 and 74 ms, with `overdueStarts` 1-2 and 36-54 ms of overlapping paint, less than the lateness in all but the launch with every self-test). Ticks with no fixture paint in their overdue interval were at most 2 and 4 ms late in the two mutated launches, did not occur in the launch with every self-test, and reached 36 ms in the calibration launch (`lateNoPaintMax`); the median tick was 2-32 ms late (`lateMedian`). So the measured lateness is consistent with back-to-back product paints holding an overdue timer, with something besides fixture paints adding to it, and the product's own timers can wait the same way. In the launch with every self-test the control's largest gap was `gapMax=50`, with `missed=22/45` product paints that no sample followed, while the post-paint sampler on the same owner read `gapMax=20` and `missed=0/41`, its largest delay from a paint's return to the covering sample `delayMax=2` within `bound=36` (one product paint plus one grab). That is a measurement of these launches on one host, not a general law. | Sample the owner with `Test::PostPaintSampler` (`test_post_paint.h`) over sampling windows around the transition, with grab-free windows wherever the product's own paint must not be preceded by a grab, and gate each decisive bracket on its distance to the previous sample, recording a bracket that still spans a gap as undecided. Repaired that way from Run 2 of the same campaign: 9 post-paint grabs in `[turn - 100, turn + 300]`, the bracket 20 ms after the previous sample (its `G-sample` gate, bound 34 ms), and the bound read 46, decided. Confirm with `AppendPostPaintSamplerSelfTest`. |
| A containment or compare row on a top-right or bottom corner patch names a media colour as the background, or counts every "background" pixel changed while the capture shows the arc intact - for example `right background #e0189c: 1046 of 1046 background pixels changed` beside `left background #ffffff: 0 of 191` | The background was read at patch pixel (0, 0) for every patch. That pixel lies beyond the arc only in a top-left patch; in a top-right or bottom patch it is a media pixel, so the "background" mask was the media, and any slide, spoiler or frame change inside it counted as a changed background pixel. Observed in `2026/10/06/round-rich-message-edge-media-like-ordinary-media` (`work/test.md`, Run 1, failed assumption 9: 14 FAIL rows). | Read the background with `Test::ReadCornerBackground` (`test_corner_patch.h`) and the patch's `PatchCorner`: it reads the patch's own corner-most pixel (`CornerMostPixel`), keeps only the component of that colour connected to it, and refuses `media-at-corner` instead of returning a media colour; `ReadCornerContainment`, `CompareCornerCoverage` and `GateCornerChrome` take their background from it. Run 2 of the same campaign repaired its rows by reading each patch's own corner. Confirm with `AppendCornerPatchSelfTest`. |
| A pixel-identity or "within N of the media colour" corner compare fails between twins whose media colours legitimately differ - letterbox fill, map tiles, another media kind - with the differences only at the arc's ends, or a tiny channel delta on nearly every pixel: `shape sets 1048 vs 1046 pixels, only-a=2 ... a(31,0) a(0,31)`, `1273/1296 maxChannelDelta=3` | An arc pixel is a blend of the media colour over the background, so its colour moves with the media colour: a device-exact compare differs wherever the media differs at all, and a fixed-colour set such as "within 2 of the media colour" gains or loses the arc-end pixels with the contrast. The source run's recomputation of those patches by coverage found the same arcs (largest deltas 0.0034-0.047). This differs from the (0, 0) background row above: there the wrong pixel was taken for the background; here the background is right and the colour model is wrong. Observed in `2026/10/06/round-rich-message-edge-media-like-ordinary-media` (`work/test.md`, Run 1, failed assumption 3: 3 FAIL rows). | Compare by coverage with `Test::CompareCornerCoverage` (`test_corner_patch.h`): each pixel's media share over the reference background, on the channel where they contrast most, within `kCoverageSameKind` (0.03) for one media kind or `kCoverageCrossKind` (0.06) across kinds, with each twin's media colour sampled inside its media away from the arc and from chrome. Colour twins with the same arc compare equal, while another radius, a square corner or a one-pixel arc shift moves pixels by 0.3-1.0. Repaired that way from Run 2 of the same campaign. Confirm with `AppendCornerPatchSelfTest`. |
| A corner compare reports hundreds of differing pixels, all inside one compact shape within the media and none outside it - `mismatching device pixels=742/1296` - or one side's containment mask holds a few more "background" pixels than the other, and they change in every frame (`right corner TR background #ffffff: 6 of 197` beside `left corner TL background #ffffff: 0 of 191`); or a media colour sampled at a corner inset reads a badge's text or fill (`#e1edf0`) | Chrome is painted inside the patch, over the media. A video or GIF draws its corner-status badge at the top left (`Gif::drawCornerStatus()` -> `PaintVideoCornerStatus()`, `st::msgDateImgDelta` in from the media's corner). `HistoryView::Photo` draws its enlarge badge at `Photo::enlargeRect()`, `st::historyPageEnlargeSkip` in from the top right, when `_showEnlarge` is set: `Photo::countCurrentSize()` sets it for a photo that is not its message's own media, on a message that is not sponsored and whose media has a web page that `suggestEnlargePhoto()` (a site name, a title or a description), with the photo at least `2 * st::historyPageEnlargeSkip + st::historyPageEnlargeSize` in both dimensions, and `Photo::draw()` paints it only once the photo is loaded. Its white glyph equals a white background exactly, so a mask of "every pixel of the background colour" takes it in. This differs from the colour-twin row above: there the arc pixels move with the media colour; here the arc matches and the difference is a shape inside the media that the twin does not draw. Observed in `2026/10/06/round-rich-message-edge-media-like-ordinary-media` (`work/test.md`, Run 5: all 8 FAIL rows, from the enlarge badge in the top-right patch; Run 1: 5 FAIL rows from colours sampled inside the corner-status badge). | Exclude the chrome's exact shape, built from the product geometry and grown by a device pixel, only through `Test::GateCornerChrome` (`test_corner_patch.h`) on a twin or reference that does not draw it - it refuses a region touching an arc or background pixel there (`chrome-touches-arc`, `chrome-touches-background`) - and pass the accepted chrome to `CompareCornerCoverage` / `ReadCornerContainment`, which refuse one the gate did not accept. `ReadCornerBackground`'s corner-connected mask keeps an enclosed glyph out of the background on its own. Sample media colours away from badges. Repaired that way in Run 6 (the badge's shape excluded behind a twin gate) and Run 2 (badge-free colour sampling) of the same campaign. Confirm with `AppendCornerPatchSelfTest`. |
| A transition or reveal of a few hundred milliseconds is always captured already finished - only the destination slide or the fully revealed state, `Cm1=0 Cs2=72364` - or a clocked stage's frames all read the state from before a clicked effect: `media centre #9f116f (hidden #9f116f, previous frame #9f116f, ...); one animation-manager tick 41 ms after the click`, and the same at 100 and 160 ms | A `Ui::Animations` value changes only on an animation-manager tick (`Simple::value()` returns what the last tick stored), and the manager ticks from the event loop; the runner reads a stage every 50 ms, so a 200 ms animation is often over by the next reading, and a minimized window gives no paint to wait for. A stage that ticks the manager itself still reads nothing if the click went through `ActivateClickHandler()` (`Telegram/lib_ui/ui/click_handler.cpp`): it posts `onClick(context)` with `crl::on_main`, and `Test::Settle` drains only `Ui::PostponeCall`, so the handler runs only after the synchronous stage has returned, after every frame. This differs from the `ActivateClickHandler` row above: there the handler ran one main-queue turn after a reading taken in the clicking tick, and a bounded `until` wait repairs it; here the clocked stage never returns to the event loop before its frames, so no wait inside it helps. It differs from the `crl::on_main` sampler row above as well: there the product's own posted callback needs pumping between samples; here the harness's own click must not be posted at all. Observed in `2026/10/06/round-rich-message-edge-media-like-ordinary-media` (`work/test.md`, Run 2, failed assumption 2: 12 transition premises; Run 3, failed assumption 1: three reveal premises). | Clock the frames with `Test::RunClockedFrames` (`test_animation_clock.h`): it calls the handler's `onClick(context)` inside `Test::Settle`, drains the postponed calls, waits on the main thread until each requested elapsed time, ticks `Core::App().animationManager().update()` once with a probe proving the tick, renders synchronously, prints each frame's reached elapsed bounds and reports a time past the declared end as `N/A`. Keep each render cheap: a grab, no save. Repaired that way in Runs 3-5 of the same campaign: Run 3 decided every clocked transition, Run 4's direct `onClick` decided the reveal, and Run 5, grabbing only the subject's strip between ticks, took all three reveal frames at 41, 100 and 160 ms. Confirm with `AppendAnimationClockSelfTest`. |
| Every normal-speed clocked frame or sampler row of a switch is `N/A` with no visible subject - `RunClockedFrames` frames, timer samples or `PostPaintSampler` samples, each with an empty clip and item list (`clip=[0,0 0x0] squares=[]`) - while the switch itself acted and its first state was read | The incoming section is a tall `Ui::SlideWrap` that covers or pushes out the subject by the first captured animation tick. `Ui::SlideWrap<RpWidget>::toggle` (`Telegram/lib_ui/ui/wrap/slide_wrap.cpp`) starts its `Ui::Animations::Simple` (`Telegram/lib_ui/ui/wrap/slide_wrap.h`) with `anim::linear` over `st::slideWrapDuration` (150 ms, `Telegram/lib_ui/ui/basic.style`) unless `setDuration` changed it, so at normal speed a section thousands of pixels tall grows by its whole height in those 150 ms. The subject's visible window can end within milliseconds, leaving nothing for the first clocked frame or later sample to judge. `Ui::Animations::Simple::startPrepared` (`Telegram/lib_ui/ui/effects/animations.h`) multiplies the duration by `anim::SlowMultiplier()` (`Telegram/lib_ui/ui/effects/animation_value.{h,cpp}`) once, when the animation starts, so changing `anim::SetSlowMultiplier` does not alter a running `Simple`'s stored duration. `Ui::Animations::Basic` has no duration or built-in slowing; its owner may scale its own times, as `Slowing()` in `Telegram/SourceFiles/boxes/star_gift_craft_animation.cpp` does. The multiplier is process-global; nothing under `Telegram/SourceFiles/test/` sets it, and a Debug build's `MainWindow::eventFilter` (`Telegram/SourceFiles/mainwindow.cpp`) toggles it with Ctrl+F11 (×10) and Ctrl+F12 (×50). This differs from the clocked-animation row above: there the frames read a finished or untouched state because nothing ticked at the chosen times; here a tick is proved but its subject is already gone. Observed in `2026/10/20/start-collectibles-at-the-transactions-top-skip` (`work/test.md`, Baseline B0, Run B1, Test 2, judged `TEST_FLAW`): switching the Money window from Collectibles to Transactions, the 30-row incoming list (2326 px) grew about 15.5 px/ms and pushed the grid below the viewport end (513) within about 13 ms; the first captured tick reached `[4,16]`, so all five normal-speed reverse frames and the real-click timer samples were N/A (`clip=[0,0 0x0] squares=[]`); its `work/evidence-before-result.md` ("Harness gaps and findings", item 1) gives about 7 ms at 200 %. | Slow one switch and judge it on what it reached. Read and print `anim::SlowMultiplier()` and register restoration in `Test::Runner::onFinish` (`Telegram/SourceFiles/test/test_runner.h`) first, then call `anim::SetSlowMultiplier(k)` before the action that starts the switch - in the `run` that calls `Test::RunClockedFrames` (`Telegram/SourceFiles/test/test_animation_clock.h`), or before the `Test::Click` that a `Test::PostPaintSampler` (`Telegram/SourceFiles/test/test_post_paint.h`) window watches - never after it. Scale the requested `elapsed` times and `animationEnd` by `k`, as the animation-clock paragraph under “Capture and visual assertions” asks ("scale times by `anim::SlowMultiplier()`"), keep its `anim::Disabled()` gate, and judge each frame at both ends of its printed `reached=[<lo>,<hi>]` against the scaled duration. Restore the value read first right after the leg, on every refusal or gate path that set it, and again from `Test::Runner::onFinish`, which a stage timeout or the watchdog does not skip. Slow one switch, not the scenario: every `Simple` started while the multiplier is set is slowed too. A slowed leg can still lack coverage: write each frame or sample with no visible subject as a named `TEST_RESULT: N/A`, and a sampled leg with too few decided samples (the source's rule: fewer than 3, or an empty third of the window) as a named N/A for insufficient coverage, never a pass. **Forbidden:** counting a vacuous frame or sample as proof (the source's recovery plan forbade "treating vacuous N/A frames as mechanism proof"), and setting the multiplier after the switch started. The slowed clocked leg recovered coverage in Run B2 of the same campaign (Test 4): a ×10 reverse leg reached `[0,23]` to `[77,100]` with squares visible in frames 1-5, while frames 6-9 were N/A again; and in Attempt 1, Run A2, Test 5, the ×10 real-paint reverse leg stayed a named N/A (3 decided samples, thirds 0/2/1), the list covering the first square within about 76 ms. |
| A tail-presence or other edge-shape reading of an incoming bubble in the default theme reads nothing on every twin, including the control that must show a tail - `tail present - ... 0 of 240 device pixels differ` - so every "no tail on either twin" row beside it passes vacuously, and the saved list capture shows white bubbles on white with only their one-pixel shadow line | In the default palette `msgInBg` is `windowBg` (`#ffffff`, `Telegram/lib_ui/ui/colors.palette`), and `Test::GrabWidget` / `Test::GrabRect` hand `st::windowBg` to `Ui::GrabWidgetToImage`, which fills the image of a target without `Qt::WA_OpaquePaintEvent` with it before rendering, so every pixel beyond the bubble reads `#ffffff` whether a white tail covers it or not. This differs from the `lightButtonBg: windowBg` row above: there the requested fill is the box's own background and `DeriveBand` names that refusal; here the subject's own colour equals the capture base, nothing refuses, and a present and an absent tail read the same. Observed in `2026/10/06/round-rich-message-edge-media-like-ordinary-media` (`work/test.md`, Run 2, failed assumption 1: 2 FAIL rows, and five "no tail" rows vacuous in Runs 1 and 2). | Render the tail area directly - `Element::draw()` with the list's own paint context (`HistoryInner::preparePaintContext()` for the legacy history, `ListDelegate::listPreparePaintContext()` for a `HistoryView::ListWidget`) - into an image filled first with a mid grey (`#808080`), for both twins, and keep a twin that must show the tail as the positive control in the same render. Repaired that way from Run 3 of the same campaign: the tail-present controls read 105 of 240 and every no-tail row 0 of 240 on both twins. |
| A twin row on a message drawn without a bubble - a map or a photo with no caption - compares the wrong pixels because its media rect, built from `mediaTopLeft()`, lies outside the element's bubble geometry: `twin media x 0..320 bubble 132..452`, `mediaTop-G=-6` | `HistoryView::Message` overrides `Element::mediaTopLeft()`, which answers `innerGeometry().topLeft()`, with `_lastMediaPosition`, and the only writer of that member is the `paintMedia` lambda inside the `if (bubble)` branch of `Message::draw()` (`history/view/history_view_message.cpp`). A media-only message has `Message::drawBubble()` false - no visible text, and media that does not `needsBubble()`, such as a map whose `Location::needsBubble()` finds no title, description, comments, reply, forward, sender name, via bot or topic button - so the bubble-less `else if (media && media->isDisplayed())` branch draws it and never writes the member: the accessor answers its default `(0, 0)`, the element's own top-left, or a position an earlier bubble paint of that element stored. This differs from the geometry-oracle row above: there rects of different widgets were compared without a shared origin; here one element's own accessor answers a position from another paint, or none. Observed in `2026/10/06/round-rich-message-edge-media-like-ordinary-media` (`work/test.md`, Run 1, failed assumption 2: 3 FAIL rows). | For a message with no visible text (`hasVisibleText()` false) whose `mediaTopLeft()` falls outside its bubble geometry, take the media rect from that geometry - left `innerGeometry().x()`, top the element's top plus `marginTop()` - with the media's own width and height, and print which source the rect came from. Repaired that way from Run 2 of the same campaign. |
| A synthesized text-selection drag over the legacy chat history selects nothing - `drag 439,36695 -> 78,36407 selected text ""` - although its press, moves and release all reached `HistoryInner` | The press (`mouseActionStart()`), every move and the release (`mouseActionFinish()`) go through `HistoryInner::mouseActionUpdate()`, which maps the pointer and passes it through `HistoryWidget::clampMousePosition()`: x is clamped into `[0, scroll width)` and y into `[scrollTop, scrollTop + scroll height)`. A drag whose points lie in list coordinates outside the visible area therefore lands on the viewport's edge row and selects nothing there. This differs from the drag/wheel/cancel row above: there a wrapper received an event that its child, presentation or viewport owns; here the event owner received every event and clamped its point. Observed in `2026/10/06/round-rich-message-edge-media-like-ordinary-media` (`work/test.md`, Run 2, failed assumption 5; Run 1 had blamed a press past the glyphs). | Scroll both drag points into the viewport first, and gate the drag on a premise row that prints both points, `scrollTop` and the viewport height. Repaired that way from Run 3 of the same campaign: `drag rows [36407,36695] scrollTop=36294 viewport=514`, then the same drag selected the text. |
| A selection-paint mask over a horizontally scrolled block of an `Iv::Markdown::MarkdownArticle` fails on a selected glyph cell at the scroll viewport's left or right edge: the cell reads zero changed pixels while its selected neighbours change - `25:sel:0.00:0` - although selection and copy are right, and cells only partly at the edge pass on a reduced share (0.57-0.75). Or a painted extent read at that edge, such as an inline pill's painted run, is short by one or two whole indicator widths - `394` for `400`. The grabs show a uniform column in the owner's accent colour there (3 logical px in the recorded chat run), the same in the plain and selected frames | The scroll owner paints overflow-indicator strips over the content it scrolls. `PaintHorizontalOverflowIndicators()` (`iv_markdown_article_paint.cpp`) does nothing while `horizontalScrollMax <= 0` or the passed viewport is empty; otherwise it fills a left strip while `horizontalScrollLeft > 0` and a right strip while `HorizontalRightEdgeHidden()` (`horizontalScrollLeft < horizontalScrollMax`), each `OverflowIndicatorWidth()` wide - the layout style's `displayMath.overflowWidth` for display math and `table.overflowWidth` for every other owner, clamped to `[1, viewport width]`: 3 logical px in the source run's in-chat `st::messageMarkdown` (`iv.style`), not a fixed width for every scale - in `ScrollOwnerAccentColor()`, the paint style's colour for the owner's kind: `supplementaryTextColor` for a Details, `table.overflowFg` for a table, list or flow text, and the kind's own accent for display math, code, a quote or an EmbedPost. It intersects the painter's existing clip with the viewport its caller passes (and any supplied shape clip), which differs by owner: `LaidOutBlock::scrollViewportRect` (`iv_markdown_article_layout_blocks.h`) for a Details, quote, EmbedPost, list or list item; `visibleTableRect`, inside the table's shape clip path, for a table (`PaintWholeTable()`, `PaintTableRowBand()`); `scrollViewportRect`, or when that is empty `visibleFormulaRect` for display math and `contentRect` for a code block; `FlowTextViewportRect()` (`scrollViewportRect`, or `outer` when that is empty) for a paragraph, heading or thinking block. The indicator calls follow the scrolled text or children when those are drawn, nested owners included. An EmbedPost paints its author and date labels and children before its own call, and its caption after; an enclosing owner's later strips can still cover that caption. `HorizontalOverflowContentClip()` (declared in `iv_markdown_article_paint.h`), which a table clips its cell text to, returns the rectangular viewport minus its edge strips; it does not apply the shape clip. For strips proved opaque and unchanged on the measured line, selection or text changes beneath them cannot alter the grabbed pixels: these strips are intended product paint, identical in both versions of the paired run, not a product defect. Observed in `2026/10/19/make-embed-post-labels-selectable-in-the-right-details-padding` (`work/test.md`, Run 1, the base half, judged `TEST_FLAW`): its only two FAIL rows, `A3.S_rdate.dateB.{forward,reverse} (base): selection paint …`, read `25:sel:0.00:0` for glyph 25, a U+202F in an EmbedPost date at article x `[382,385)` with the Details scrolled to 12, wholly under the right strip, while glyph 43 at 0.67 and glyphs near the left strip at 0.70, 0.67 and 0.71 passed only because enough of them stayed visible. `2026/10/17/make-scrolled-details-captions-selectable-in-the-body-padding` (`work/test.md`, Runs 2, 3, 6 and 7) passed glyphs partly under the Details strip at shares of 0.57 to 0.75 the same way. Before the current paint code, `2026/08/04/bound-nested-inline-pill-by-its-band` (`work/test.md`, Run 2 identification, Run 7 Test 19 accounting) found an enclosing blockquote's two 3 px indicators over a pill's caps and later accounted for them as `394 + 3 + 3 = 400` (a 400 px run painted as 394 at `scroll=7/14`), and `2026/08/10/verify-pill-band-rebuild-and-translate-quote-toggle` (`work/test.md`, Group P1, Test 7 oracle used in the recorded runs) names a painted number short by one or two `overflowWidth` units as this overdraw. This differs from the text-selection drag row above: there the drag selected nothing; here selection and copy are right and the strips hide the pixels. | Four steps, from the source's "Recovery plan after Run 1". (1) Derive the strips from the owner's own laid-out fields (`horizontalScrollLeft`, `horizontalScrollMax`) and the viewport its paint passes, with the paint's width, clamp, conditions and clipping, and print them and the expected colour in a premise row. (2) Prove them with a pixel control on the measured line: every strip pixel is the opaque expected colour in the plain frame and unchanged in the selected frame, and where the highlight spans a strip, a strip-wide flank on each side of it changes. A failed control is a `TEST_FLAW` gate, never a product result. (3) Exclude only the proven strip pixels from each glyph cell: a wholly covered selected cell reads occluded and gets no share; a partly covered cell is judged on its visible remainder with the share threshold unchanged; the mask requires at least one visible selected cell; the changed, outside-highlight and outside-clip counts still cover the whole line. (4) Forbidden: judging a glyph cell on pixels hidden by an opaque strip; lowering the selected-share threshold or widening the mask inflation; dropping the occluded glyph from the plan or the copy literal; moving the drag endpoints or picking another scroll state to dodge the strip. For a painted extent, account for each strip actually drawn over that measured run by name from the same derivation, as in the recorded `394 + 3 + 3 = 400`, instead of widening a tolerance. No helper under `Telegram/SourceFiles/test/` reads selection masks: the source's `ReadOccluders()`, `ReadOcclusionControl()`, `ReadMask()` and `MaskCoherent()` live only in its disposable overlay (`work/test-overlay.patch`). Repaired that way in Run 2 of the same campaign, which repeated the base half: `occluded=[25]`, neighbours 24 and 26 changed, flanks `left=192 right=192`; Run 3 then ran the candidate half against it and was approved. |
| A paint-cost bound written as a ratio of Debug timings fails by about 2x on a frame whose paint is one or two full-frame blits - `median ms edge=31.28 control=16.31 (bound: <= 2x and <= +4 ms)` - or a timing delta between two different views lies far above the added work's own cost (`delta 53.87` against `0.98 ms`) | This macOS checkout's Debug build links Qt's Debug-configuration static library: `Qt6GuiTargets-debug.cmake` imports `libQt6Gui_debug.a` as `IMPORTED_LOCATION_DEBUG`, and the generated Xcode project links the `Telegram` target's Debug configuration with it and its other configurations with `libQt6Gui.a` (on another platform, read its own link line). In the source run's standalone probe, built against the same Qt prefix, one 792x594 SourceOver blit cost about 14-15 ms in `libQt6Gui_debug.a` against about 0.14 ms in `libQt6Gui.a`, so a Debug paint timing is mostly unoptimised Qt raster time: one more full-frame composite doubles it whatever the code around it costs, and two different views also differ in unrelated work. Observed in `2026/10/06/round-rich-message-edge-media-like-ordinary-media` (`work/test.md`, Run 1, failed assumption 13; Run 2, failed assumption 8: a cross-view delta against a transparent-layer replica). | Decompose in one binary: time one view in both arms (Run 3 switched only the arm under test, on the same view) beside the added raster operations timed alone on an opaque replica, bound `delta <= 1.25 x replica + 1 ms`, and add a narrow-band case whose delta must stay below the full frame's; take the absolute cost from a standalone probe built against the optimised Qt (`evidence/p1-probe/` of the source task: +0.166 ms per full frame). Repaired that way from Run 3 of the same campaign; Run 2's cross-view delta is not a repair. |
| An inline video or GIF in a chat stays on one frame across samples seconds apart - `fixture gate: autoplay advancing (...) - media centre colour per sample: [#a88615, #a88615, #a88615]`, a GIF moving only from its thumbnail to its first decoded frame - and a check that needs its progress line reads `inside the frame 0, outside 0`, while the window is minimized or not exposed (`isMinimized=1 ... exposed=0`) | `Gif::startStreamedPlayer()` starts the inline player with `waitForMarkAsShown = true`, so `Media::Streaming::Player::renderFrame()` holds each next frame until `Player::markFrameShown()`, and in the view only a paint calls that: `Gif::draw()` and `Gif::drawGrouped()` mark the drawn frame shown unless the paint is paused, and a paint context is paused while `SessionController::isGifPausedAtLeastFor()` holds, which includes an inactive window. A window that gets no compositor paint never presents the next frame: its GIFs stop after the first decoded frame and a video never leaves its first. Read the AutoPlayVideo limit before blaming it: Run 1 did, and Run 2 read it as already 50 MB (`AutoPlayVideo limit 67108864 (was 52428800)`). This differs from the undecodable-fixture row above: there the document cannot be decoded; here it decodes and its inline player exists, but no frame after the first is presented. `Test::KeepMainWindowNotMarkingRead` minimizes the primary window, which is exactly what stops such players: a scenario that uses it gets no inline playback without a pump. It differs from the clocked-animation row above too: `Test::RunClockedFrames` ticks `Ui::Animations::Manager`, which a streamed player does not use (it paces frames with its own `base::Timer`), so a clocked stage does not advance it, minimized or not. Observed in `2026/10/06/round-rich-message-edge-media-like-ordinary-media` (`work/test.md`, Run 2, failed assumptions 3 and 4: 4 video gates and 3 progress-line rows). | A frame pump: render the target media rect directly every 30 ms (`Element::draw()` with the list's own paint context, the paint a compositor paint would run), re-asserting `Test::ForceWindowActive` on each pass so the context is not paused, until each twin's frame visibly advanced, and log the playback facts (`canBePlayed`, `loaded`, `inappPlaybackFailed`, `ShouldAutoPlay`, window exposure, `isGifPausedAtLeastFor`) at every gate. Repaired that way from Run 3 of the same campaign: 8 pump gates decided, at three playback positions per twin. Its proven scope is "the frame advanced": reading a playing video's progress line through the pump stalled in Runs 3 and 4, and the campaign retired "pump until the video plays" (`work/test.md`, Run 4 "Forbidden repeated techniques"); for a line, see the next row. |
| A check that needs an inline video's progress line has nothing to read: no line appears until the playback position passes 200 ms, and pumped playback stalls in the player's waiting state - `pump ticks=46 in 15227 ms; ... moved=0 line=0 (max 0)`, or eight stalled activations with FFmpeg decode errors on the fixture in the app's stderr | `Gif::paintTimestampMark()` returns while `_videoTimestamp <= 0` and `_videoPosition` is below 200 ms; otherwise it calls `PaintVideoTimestampMark()` at `_videoPosition`, or at `_videoTimestamp * 1000` while the position is 0 - on the static frame too, with no player at all - and `PaintVideoTimestampMark()` draws nothing for a position past the video's duration. Every `HistoryView::Gif` reads `_videoTimestamp` once, in its constructor, from `Media::View::ExtractVideoTimestamp()`: the media's own `videoTimestamp()`, else its web page's `extractVideoTimestamp()`, which parses the `t=` parameter of the link preview's URL. With video autoplay allowed, such a video still gets a player on an item that `isSending()`, as the source run's injected local items were: `Gif::draw()` stops a timestamped video only while `underCursor()` is false, and `underCursor()` compares `ClickHandler::getActive()` with `currentVideoLink()`, which is null because `currentAction()` is `Action::None` for a sending item, so with no active click handler the two nulls are equal and `playAnimation()` runs whenever autoplay is eligible. Observed in `2026/10/06/round-rich-message-edge-media-like-ordinary-media` (`work/test.md`, Runs 3 and 4, and Run 4's failed assumption 4: the line does not need playback). | A fixture whose link preview URL carries `?t=<seconds>` within the video's duration, with the in-memory AutoPlayVideo limit set to 0 for the leg (`Data::AutoDownload::Full::setBytesLimit(source, Type::AutoPlayVideo, 0)` on the session's `autoDownload()` settings), restored after it and again in teardown, and a premise row reading `Data::AutoDownload::ShouldAutoPlay()` false for the document (a per-peer `ForceAllow` override falls back to the default limit when the limit is 0), so that no inline player is created; every paint then draws the line at the timestamp. Repaired that way in Runs 5 and 6 of the same campaign (`ExtractVideoTimestamp(item)=7 s ... AutoPlayVideo limit 0, ShouldAutoPlay=0`). This differs from the inline-player row above: there the pump makes frames advance; here no playback is needed when only the line is read. A link preview with a site name, title or description also turns on the enlarge badge of the message's other photos: see the corner-chrome row above. |

Classify a sound assertion against changed behavior as an implementation bug,
not a test flaw. Classify a wrong fixture, target, readiness model, event
route, capture owner, or oracle as a test flaw. Preserve everything a failed
run proved, repair all visible harness faults together, and rerun the packed
scenario rather than fragmenting it into isolated launches.

When a flaw's cause is the instrument idiom rather than this task's fixture,
repair it here as well as in the overlay: add the missing helper, tighten an
existing contract, or add its row to the table above, in the same run that
diagnosed it. A diagnosis that stays in one task's notes is rediscovered by
the next task, which is how the `Test::Probe` window row and the
`Test::DiscriminatingScan` row above cost four runs each before they were
written down.

---

### Telegram SourceFiles Test Docs Launch (`Telegram/SourceFiles/test/docs/launch.md`)

# Launch, activation and background painting

[Harness guide](../README.md) · [Helper index](helpers/README.md)

On macOS a default `workspace.py test-run` launch of an app bundle's main
executable does not activate the client: it launches the bundle through
LaunchServices in the background, with Qt's own launch activation switched off
(`.agents/shared/test-loop.md`, Launch activation; the report reads
`launch_method: "background"`). The client starts as an inactive application
and its windows open behind the frontmost application's. A window shown
already covered reads `isExposed()` true until the window server first reports
a change for it, so `exposed=1` is no proof that it is on screen; after such a
change a covered window reads unexposed and gets no paints. Drive focus-routed
actions with `ForceWindowActive`, keep a window a stage needs painted exposed
with `KeepWindowExposed` either way, and use `test-run --activate` when
genuine OS activation is the subject. Product flows that raise or activate a
top-level window - `Ui::PopupMenu`, `Ui::SeparatePanel::showAndActivate()`,
`Window::Controller::activate()`, `DropText` into a shown field - still
request activation of the client on macOS, and so do the not-marking-read,
separate-panel-walk and popup-menu self-tests. After a background launch the
window server may grant or refuse that request: on this host it granted it
about 11 s after launch in two default runs and refused it about 20 s after
launch in a third, under a condition those runs did not establish
(`2026/10/08/launch-the-testagent-client-without-activating-it`, Runs 4, 8 and
9). A granted request takes the key window from the frontmost application.
After a refused one the client stays inactive and the frontmost application
keeps its key window, but the client's window may still be ordered in front of
the frontmost application's window by the request's own window ordering: in
Run 9 above the primary window stayed covered after its restore, while with an
overlay that made the client's own application-activation calls no-ops,
reproducing that refusal under another stage sequence, the inactive primary
window was in front for about 4 s after the not-marking-read self-test's
restore through `Window::Controller::activate()`, until a later request put it
behind again
(`2026/10/09/keep-background-testagent-runs-on-macos-unthrottled-and-painting`,
Runs 0 and 3). So the scenario's own activation is no reliable route to OS
activation under the default. A client the launch did not activate is a
background application, which macOS may nap and whose timers it may coalesce:
in background runs on this host without a hold, a sampler's heartbeat gaps
grew to 100-290 ms after about 15 s of normal cadence
(`2026/10/01/animate-gram-card-sending-and-settle-effects`, Run 1), and a 4 ms
precise sampler ticked every 50-1000 ms from about the 80th second
(`2026/10/02/show-the-input-method-composition-in-the-gram-send-amount`, Run
1). On a locked console macOS napped such a client without a hold, from about
30 s after process start
(`2026/10/09/keep-background-testagent-runs-on-macos-unthrottled-and-painting`,
Runs 0 and 4), but another application's window covering the client did not
guarantee it: on an unlocked console, a client without a hold that the
frontmost application's window covered kept an exact 16 ms timer cadence over
60 s and over 300 s (Runs 1 and 2). Those runs did not establish what made
macOS nap the client. So on macOS the harness holds one user-initiated,
latency-critical `NSProcessInfo` activity
(`NSActivityUserInitiatedAllowingIdleSystemSleep | NSActivityLatencyCritical`;
idle system sleep stays allowed) for the whole `-testagent` run:
`Test::ApplyStartupOverrides()` (`test_agent.cpp`) begins it early in
`Application::run()`, before `Test::Start()`, and never ends it, so it lasts
until the process exits, a watchdog or stage-timeout ending included. The log
says once which case a run is in: a held run reads
`NOTE: TDESKTOP_TEST_APP_NAP=[] applied: hold=active ...`, and a run without
the hold reads `hold=off` with the reason. An overlay no longer holds an
activity of its own: the per-overlay `BeginLatencyActivity` of the
`2026/10/07/animate-the-sending-row-for-a-collectible-transfer` overlay is
history, and a second activity is harmless but redundant. A campaign whose
subject is background scheduling itself (App Nap, timer coalescing,
throttled-timer behaviour) launches with
`test-run --env TDESKTOP_TEST_APP_NAP=allow`, and its log then reads
`hold=off source=environment`; any other non-empty value is logged as
rejected and the hold is kept. Off macOS nothing reads
`TDESKTOP_TEST_APP_NAP` and nothing is held.

Read [input targeting](helpers/test_widgets.md#input-helpers) for the actual
event route, [window exposure](helpers/test_window_exposure.md) for painting
and [console state](helpers/test_console_lock.md) when diagnosing a refusal.

---

### Telegram SourceFiles Test Docs Media Fixtures (`Telegram/SourceFiles/test/docs/media-fixtures.md`)

# Media fixtures and fixture gates

[Harness guide](../README.md) · [Helper index](helpers/README.md)

A media document is a fixture only after the run has watched it play. Metadata
does not decide this: an undecodable upload left on the shared test account can
report `song=1 audioFile=1 sharedMusic=1` with a plausible `duration`, so no
predicate over flags, title, performer, filename, or membership in some list can
tell it apart from real music. “The first song not already in profile Saved
Music” is exactly such a predicate, and it accepts an undecodable upload.
Observe playback instead:

1. Order the candidates the account already exposes by playability signals —
   already downloaded, then a longer duration, then a non-empty title or
   performer — and push known-synthetic names behind every other candidate.
   The order is a preference; nothing on it is trusted yet.
2. Really play each candidate in turn, through the ordinary production path
   (`Window::SessionController::openDocument()` with that candidate's own
   message as its `MessageContext`), and sample
   `Media::Player::instance()->getState(AudioMsgId::Type::Song)` around a
   bounded wait. Accept the first candidate whose `TrackState::position`
   strictly advances while `length > 0` and the playing id and context are
   still that candidate's own document and message.
3. Stop the probe with `Media::Player::instance()->stop(AudioMsgId::Type::Song)`
   after every attempt and assert the player is stopped, so no probe state
   leaks into the measurement that follows.
4. Feed the one validated document to every stage that needs it — the fixture,
   any injection, and the negative control — instead of re-deriving it per
   stage.

The reported `length` corroborates a probe's verdict but never decides it.
Until the stream reports its own duration,
`Media::Streaming::Player::prepareLegacyState()` substitutes the document's
declared duration, so a length equal to the declared duration means only that
no stream duration has arrived yet — a healthy candidate sampled early reads
the same way. It is a bad sign only together with a position that never
advances, while a document that really opened reports a stream-measured length
instead (`len=272910` against a declared `272000`). Keep acceptance anchored on
the strict position advance.

When a scenario's subject needs a streaming fixture, a stage immediately before
the measured action must prove the track is already advancing while the subject
still exists. Classify that stage as a fixture gate, not a check: reverting the
diff under test cannot change its reading, so its failure makes the acceptance
criteria `N/A` and the run a test flaw — never a `FAIL`. Without that gate, a
frozen reading taken after the action cannot be told apart from the product
failing.

---

### Telegram SourceFiles Test Docs Overlay Example (`Telegram/SourceFiles/test/docs/overlay-example.md`)

# Overlay APIs and minimal scenario

[Harness guide](../README.md) · [Helper index](helpers/README.md)

- `test_runner.h` — the staged scenario engine: `Stage{name, run, until, then, timeout}`,
  `waitEvent`, `waitForSessionReady`, the normal bounded non-fatal `waitForChatsLoaded()`, and
  explicit strict `waitForChatsLoadedStrict()`; timing out an ordinary `Stage` ends the whole
  scenario, while the wall-clock watchdog (default 120s; `TDESKTOP_TEST_WATCHDOG` override in seconds, 1..600, otherwise the default; the armed duration is logged at scenario start)
  guarantees `TEST_COMPLETE` + quit on every exit path including timeout. `actOnWidget` waits for
  and lifetime-guards the exact target before acting once. `captureAndInspect` saves the accepted
  prepared frame and then runs numeric/raster assertions against that same widget and image.
- `test_log.h` — evidence dir from `TDESKTOP_TEST_EVIDENCE_DIR` (the workspace `test-run`
  helper sets it), flushed absolute-path logging, `Step/Pass/Fail/Check/Note`, `CheckNear`
  tolerance assertions, `LogGeometry`, the standard markers.
- `test_widgets.h` — `FindAll<T>`/`FindFirst<T>`/`FindVisible<T>` (the `dynamic_cast`-based
  finders that avoid the guaranteed `findChildren<CustomWidget*>` crash), `Click`, `TypeText`,
  `CommitText`, `PressKey`, `Drag`, and `Wheel` via real Qt events, plus generation-counted,
  lifetime-guarded `PublishLiveWidget` / `PublishLiveAction` seams for exact layer-owned objects;
  each delivered event runs with postponed-call processing
  deferred and is followed by a drain of every pending `Ui::PostponeCall` to empty, so
  postponed input fix-ups AND their own change handling are settled when the helper returns;
  wrap programmatic `setText` in `Test::Settle`.
- `test_capture.h` — `CaptureWidget`/`CaptureRect` (visibility check, `QWidget::grab()` so
  floating elements and locked desktops cannot occlude, automatic blank-image FAIL, geometry
  log, `SCREENSHOT` marker), plus `PreparedWidgetCapture`; `Runner::captureWidget` polls an
  exact target until it has a valid painted frame and saves that same accepted frame,
  `Crop`/`Zoom`/`ContactSheet` for tight same-scale evidence.
- `test_ink.h`, `test_style.h`, and `test_panel.h` — painted-ink/contrast measurement, settled
  palette baselines, and detection of a `Ui::SeparatePanel` show-animation cache.
- `test_messages.h`, `test_transfer.h`, `test_open_handoff.h`, and `test_launch_fuse.h` — direct
  sent-message, document-transfer, document-open, and safely blocked OS-launch oracles. Prefer
  these established task-specific observers over reconstructing the same state in a scenario.
- `test_agent.h` — `Test::Fire(name)` / `HasFired(name)` named waitpoints;
  `launch_finished` fires at the end of `Application::run()`. `TDESKTOP_TEST_SCALE` is applied
  by the harness at startup, and on macOS the harness holds the App Nap opt-out for the whole run
  (`TDESKTOP_TEST_APP_NAP=allow` leaves it off; see [launch behavior](launch.md)).
- `test_scenario.cpp` — the overlay-owned slot: it defines `Test::SetupScenario(runner)` and
  is a no-op in the repository.

A minimal scenario shape:

```cpp
void SetupScenario(not_null<Runner*> runner) {
	runner->waitEvent(u"launch_finished"_q);
	runner->actOnWidget(
		u"activate the published target"_q,
		[] { return Test::ReadLiveWidget(u"task.target"_q).widget; },
		[](QWidget *widget) { Test::Click(widget); },
		[](QWidget *widget) {
			return widget->isVisible() && widget->isEnabled();
		},
		Test::kDefaultStageTimeout,
		[](QWidget *widget) {
			return u"visible=%1 enabled=%2 size=%3x%4"_q
				.arg(widget->isVisible())
				.arg(widget->isEnabled())
				.arg(widget->width())
				.arg(widget->height());
		});
	runner->captureAndInspect(
		u"target_surface"_q,
		[] { return Test::ReadLiveWidget(u"task.surface"_q).widget; },
		[](QWidget *widget) {
			return widget->property("contentGeneration").toInt() > 0;
		},
		[](QWidget *widget, const QImage &image) {
			Test::LogGeometry(u"surface"_q, widget->geometry());
			Test::Check(
				image.width() >= widget->width(),
				u"capture covers the surface width"_q,
				u"image=%1 widget=%2"_q
					.arg(image.width())
					.arg(widget->width()));
		});
}
```

---

### Telegram SourceFiles Test Docs Scenarios (`Telegram/SourceFiles/test/docs/scenarios.md`)

# Designing one packed scenario

[Harness guide](../README.md) · [Helper index](helpers/README.md)

The permanent Debug-only harness supports `perform-task` overlays. Use the
[helper index](helpers/README.md) to select references for this scenario.
The overlay adds task-specific fixtures, production entry points, checks
and captures; do not recreate facilities already here.

The default is one packed scenario and one app launch. Put all compatible
checks, themes, values, interactions, and captures into that execution. Plan
another launch only when you can name an incompatible process-lifetime
requirement, such as a startup-only setting or persisted state that the first
flow necessarily contaminates. “The scenario is large” and “a smoke run is
simpler” are not reasons to split it. After a `TEST_FLAW`, repair every flaw
visible in the run together and rerun the packed scenario.

Cite code by symbol, never by line number, here and in harness comments:
`Runner::finish()` in `test_runner.cpp`, not `test_runner.cpp:NNN` or
`:NNN-MMM`. Line numbers drift with every edit, including the next harness
task's, and nothing updates them. A failure-diagnosis row names the
functions, constants and macros on its causal path, in product, submodule
and Qt sources alike. Quoted log or crash text stays verbatim.

## Building one reliable packed scenario

1. Derive every falsifiable check from the task, visual contract, and retained
   diff before writing overlay code.
2. Choose the most direct fixture that still executes the changed production
   code. Publish exact objects/actions at construction and callback seams when
   unrelated navigation would be the fragile part.
3. Arrange nondestructive states first and destructive/closing actions last.
   Reuse one live fixture through its empty, rich, long, error, retry, light,
   and dark states when those transitions are production behavior.
4. Put readiness and generation checks in `until`; put actual-versus-expected
   values in `then` or `captureAndInspect`.
5. Log every value needed to diagnose a failure in the first run. A timeout
   should name the last object generation, visibility, geometry, state, and
   callback count instead of forcing a speculative second overlay edit.
6. Save decisive tight captures as the scenario reaches each state. Do not
   postpone all visuals to the end, after later actions have replaced them.
7. Finish with guards: no undeclared OS launch, no real payment/network call
   when mocked, expected callbacks exactly once, and no leftover expectation.

Read the [runner contract](helpers/test_runner.md) and [teardown](teardown.md)
for the selected scenario. A [minimal overlay example](overlay-example.md) is
available when needed; it is not a mandatory fixture preamble.

---

### Telegram SourceFiles Test Docs Teardown (`Telegram/SourceFiles/test/docs/teardown.md`)

# Scenario teardown before quit

[Harness guide](../README.md) · [Helper index](helpers/README.md)

A scenario that deliberately leaks its `State` so it outlives a stage owns the
release of everything that `State` holds: every `rpl::lifetime`, every
watcher, every raw cross-stage pointer, and every `base::Timer`. Register
that release with `Runner::onFinish`. `finish()` runs those callbacks on
every path that reaches it — stage timeout, the scenario watchdog,
skip-to-end, and normal completion — exactly once, after the ticker and
watchdog are cancelled, before the `kFinishDrainDelay` fuse drain, and
before `Complete()` / `Core::Quit()`. A callback registered after `finish()`
has already run executes immediately and is never silently dropped.

A final teardown stage is still useful for work that must happen while the
session still exists and the runner is still stepping, but it is not the
release point. The stage is not guaranteed to run: a stage that times out,
and the scenario watchdog, make `Runner` finish immediately and skip every
stage after it, while a stage whose assertions `FAIL` does not — that run
still reaches its teardown. `onFinish` still runs in those abort paths, and
it also runs when a teardown stage already ran, so the callback must be safe
to call after teardown.
A main-window geometry change belongs in such a stage, one that waits for
the change to settle, and never in `onFinish`: it releases nothing and
starts an asynchronous chat-background re-cache that must not still be
painting at quit (see the `d->allThreads.isEmpty()` row under “Failure
diagnosis” below).

The Test-session helper's account restore is such a stage too:
`Test::AppendTestSessionRestore` is the scenario's last stage and waits for
the re-shown window to settle, while the helper's finish verification in
`Runner::finish()` runs after every `onFinish` callback and is only the
backstop of a run whose restore stage did not run or did not finish. When
that backstop does switch the account back, or the scenario watchdog ends
the run while the restore stage is still waiting its drain, `finish()` waits
what is left of that post-switch drain, yielding to the event loop and
waiting again when its timer arrives before the drain's recorded end, and
only then checks a fresh reading before its `kFinishDrainDelay` wait,
`SCENARIO_RESULT`, `TEST_COMPLETE` and `Core::Quit()`; its quit fuse is
armed before that wait, with the deadline extended by it. The drain
mitigates the re-cache and does not prove it finished; `finish()` goes on at
once only when the restore is already settled - nothing was switched back,
or the restore stage waited its drain and checked a fresh reading. Because
the restore stage runs before the `onFinish` releases, every release of
such a scenario holds the exact Test session or object it acts on and never
re-reads `domain().active()` at finish.

Skipping the hook costs a whole run. A leaked `State` whose two
`rpl::lifetime`s were never released kept observers subscribed to
`Storage::Facade`'s and `Data::Session`'s streams while `~Main::Session` tore
those streams and their items down. The run reached `TEST_COMPLETE` after a
clean sweep and then died with `Caught signal 11 (SIGSEGV)`, no assertion line
anywhere in the run's logs, and a 0-byte minidump because the runner had to
kill the process. With the release registered, three consecutive runs exited
0, wrote no crash report and left no minidump. After a timeout the same death
can follow `TEST_COMPLETE`, so read it as this signature rather than as a
second product fault, and keep the leaked `State`'s session-observing
subscriptions no wider than the stages that need them.

---

### Telegram SourceFiles Test Docs Helpers Test Activation (`Telegram/SourceFiles/test/docs/helpers/test_activation.md`)

# test_activation

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness self-test.

Source contract: [test_activation.h](../../test_activation.h).

## Use when

Self-test for injected window activation. Read the contract below and the source header before use.

## Contract and usage

The window-activation helper's own self-test: the application deliberately
de-activated and re-activated through the same QPA seam inside one stage, the
wrapper-versus-inner `hasFocus()` contrast and the
wrapper-versus-`rawTextEdit()` typing contrast measured on one real
`Ui::InputField`.

---

### Telegram SourceFiles Test Docs Helpers Test Agent (`Telegram/SourceFiles/test/docs/helpers/test_agent.md`)

# test_agent

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_agent.h](../../test_agent.h).

## Use when

Runtime gate, startup overrides and fixture access. Read the contract below and the source header before use.

## Contract and usage

Runtime gate, startup scale override, the macOS App Nap hold
(`ApplyStartupOverrides()` begins one
`NSActivityUserInitiatedAllowingIdleSystemSleep | NSActivityLatencyCritical`
`NSProcessInfo` activity early in `Application::run()`, before
`Test::Start()`, and holds it until the process exits, logging one `NOTE:
TDESKTOP_TEST_APP_NAP=[<value>] applied: hold=<active|off>
source=<default|environment> - <reason>` row; `TDESKTOP_TEST_APP_NAP=allow`
leaves it off and any other value is rejected; nothing off macOS), sticky
named events, scenario start, account fixture secrets (`TwoStepPassword()`
reads `2svpassword.txt`, `GramAccount()` reads `test_gram_account.txt`,
`RewriteGramAccountWords()` rewrites the live and golden copies at the rotated
wallet's address after a confirmed rotation, `StageGramAccountLiveWords()`
stages a proper prefix of the live copy's own words for a self-test and
`RestoreGramAccountLiveWords()` lengthens such a staged copy back to the words
it was cut from, neither touching the golden sibling; `GramAccountLivePath()`
/ `GramAccountGoldenPath()` name the two copies for metadata-only checks).

## Related guidance

[Launch and background behavior](../launch.md); [account fixtures](../account-fixtures.md).

---

### Telegram SourceFiles Test Docs Helpers Test Animation Clock (`Telegram/SourceFiles/test/docs/helpers/test_animation_clock.md`)

# test_animation_clock

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_animation_clock.h](../../test_animation_clock.h).

## Use when

Deterministic elapsed-time animation frames. Read the contract below and the source header before use.

## Contract and usage

`RunClockedFrames`: captures a short `Ui::Animations`-driven animation at
chosen elapsed times after one synchronous action, or a `ClickHandler` invoked
directly with its context, never through `ActivateClickHandler()`. The action
runs inside `Test::Settle` with `crl::now()` stamped right before the action
and right after its postponed calls were drained (`actionStarted` /
`actionFinished`); then, for each strictly increasing requested time, the main
thread sleeps in 1 ms steps until that many milliseconds after
`actionStarted`, the postponed calls are drained,
`Core::App().animationManager().update()` runs once - the helper's only
manager call - proven by a probe `Ui::Animations::Basic` of the helper's own,
whose received time is the tick, and a synchronous caller render follows. Each
frame reports its reached elapsed bounds `[tick - actionFinished, tick -
actionStarted]`. A requested time at or after the declared `animationEnd`, or
a tick whose upper bound reached it, is a first-class `TEST_RESULT: N/A` gate
with nothing rendered; a malformed request (`clocked frames refused`), a tick
that did not call the probe (`manager-did-not-tick`) and a render that did
(`render-advanced-the-manager`) are named `fixture gate:` FAIL rows, and the
loop goes on. `ClockedRequest`, `ClockedFrame` (`taken()`) and `ClockedRun`
carry the request, each frame and the run; `ClockedFrameText` prints a frame,
and every taken frame is one `NOTE: <name>: kind=frame ...` row. Its frame,
gate and refusal rows follow `Test::RequestTelemetryNumbers()` (`test_log.h`),
read once per call, through `ClockedFrameText`, `ClockedFrameSubject`,
`ClockedEndGateText`, `ClockedReachedGateText`, `ClockedNoTickText`,
`ClockedRenderTickText` and `ClockedRequestRefusal`, each also taking an
explicit `NumberFormat`, and the caller's `elapsed` and `animationEnd` print
among them, so they must never be a fixture secret or a number read from one.
With its own self-test needing only the primary window, as the parent of a
never-shown opaque bar widget, and no session, chats or account: a 200 ms
`anim::linear` `Ui::Animations::Simple` (scaled by `anim::SlowMultiplier()`)
started by a `LambdaClickHandler` is clocked at 25, 50, 75 and 125 % of its
duration in one synchronous stage; the handler runs once inside the action
window, each in-progress value read from the rendered pixels lies within the
transition at its reached bounds (within 1.5 device pixels), the three values
strictly increase, the fourth time is the helper's `N/A` row with no image,
and the bar stays hidden and not visible with no paint outside the renders and
one render paint per taken frame, with the bar's and the window's state
(`minimized`, `hidden`, `exposed`, and `screenLocked` from
`Test::ReadConsoleLock().locked()`) printed beside the frames; the window
itself is read, never changed. Two stages, eight checks plus that `N/A` row,
and an `animation_clock_frames` contact sheet. Its `Simple` lives in the bar's
`lifetime()`, so it needs no `Runner::onFinish`.

## Clocked frame capture

Capture a short `Ui::Animations`-driven animation — a slide transition, a
reveal, a button or card animation of a few hundred milliseconds — at chosen
elapsed times with `Test::RunClockedFrames` (`test_animation_clock.h`), not
at whatever runner tick comes next. Its value changes only on an
animation-manager tick: in Run 2 of
`2026/10/06/round-rich-message-edge-media-like-ordinary-media` twelve 200 ms
transitions sampled at the next 50 ms runner tick were already over. A
`ClockedRequest` names its rows (`name`) and carries exactly one of an
`action` or a `handler` with its `context`, strictly increasing `elapsed`
times in milliseconds after the action, an optional `animationEnd` and a
synchronous `render`. The helper starts a probe `Ui::Animations::Basic` of
its own, runs the action inside `Test::Settle` with `crl::now()` stamped
right before the action and right after its postponed calls were drained,
and calls a handler's `onClick(context)` directly, never through
`ActivateClickHandler()`, whose `crl::on_main` call would run only after the
stage returned. For each time it sleeps on the main thread until then,
drains the postponed calls — `Manager::update()` skips while the manager's
own `schedule()` callback is pending, and the drain runs it — calls
`Core::App().animationManager().update()` once, its only manager call, and
renders. The probe proves each tick and supplies its time: a tick that did
not reach it is refused as `manager-did-not-tick`, a render that did as
`render-advanced-the-manager`, each a `TEST_RESULT: FAIL: fixture gate:
<name>: frame <k> at <ms> ms` row, and that frame is not taken. An animation
started inside the action or in a postponed call it posted stands, at a tick
`T`, somewhere in the reached bounds
`[T - actionFinished, T - actionStarted]`, so judge a painted value against
the transition at both bounds. A time at or after the declared
end, or a frame whose upper bound reached it, is a first-class `TEST_RESULT:
N/A: <name>: frame <k> at <ms> ms - <reason>` gate with nothing rendered,
never a FAIL; a malformed request is one `TEST_RESULT: FAIL: fixture gate:
<name>: clocked frames refused` row, and nothing runs. Each taken frame is
one row, `NOTE: <name>: kind=frame index=<k> requested=<ms>
reached=[<lo>,<hi>] tick=<ms> waited=<ms> probeTicks=<n> renderTicks=<n>`
(`ClockedFrameText`). Every number in these rows is an ordinary integer
until `Test::RequestTelemetryNumbers()` (`test_log.h`) and a
`TelemetryNumber` after it, read once per call, so one call's rows share a
format: the frame row (`index=37p requested=4821p reached=[3764p,4829p]
tick=37645p ...`), the `frame 37p at 4821p ms` subject, the gate and
refusal reasons (`requested 4821p ms is at or after the declared animation
end 3764p ms`, `the tick reached [5937p,6148p] ms`, `probe calls 59p before
and after it, 7293p ms after the action started, waited 8352p ms`, `called
41p time(s) during the render`) and the request refusal's details
(`exactly one of action and handler must be set (action=1p handler=1p)`,
`elapsed times must be non-negative and strictly increasing, read
4821p,3764p`). A judge reads a value of either grammar with
`float(v.replace('p', '.'))`. `ClockedFrameText`, `ClockedFrameSubject`,
`ClockedEndGateText`, `ClockedReachedGateText`, `ClockedNoTickText`,
`ClockedRenderTickText` and `ClockedRequestRefusal` also take an explicit
`NumberFormat`; a gate or refusal text kept in a `ClockedFrame` or
`ClockedRun` keeps the format it was formatted in. The caller's `elapsed`
times and `animationEnd` are among those numbers, so under the request they
print in a format that hides digits from the secrecy scan: they must never
be a fixture secret or a number read from one.
Render with `Test::GrabWidget` / `Test::GrabRect`,
which are `QWidget::render`, never with `repaint()` or `processEvents()`;
keep the render cheap and save images after the run: Run 4 of the same
campaign took a ~140 ms pair capture per frame, and its +100 / +160 ms ticks
landed at 181 / 324 ms. Gate `anim::Disabled()`, which makes the first tick
jump to the end, and scale times by `anim::SlowMultiplier()`. Choose by the
subject: when the product's own paint sequence is measured — back-to-back
paints, a bound from a paint — use `PostPaintSampler`, which samples only
after real product paints and so needs a shown, exposed, painting window
(`Test::KeepWindowExposed` keeps one that another application covers
exposed without activating anything);
when a `Ui::Animations` value at a known elapsed time is measured, use the
clock, which awaits no paint and so runs the same in a minimized window or
on a never-shown widget. Neither advances a streamed video player, which
paces itself with its own `base::Timer` (see the failure row for an inline
video or GIF that stays on one frame). Confirm with
`AppendAnimationClockSelfTest`, which clocks a 200 ms linear bar started
from a click handler on a never-shown child of the primary window.

---

### Telegram SourceFiles Test Docs Helpers Test Box Button (`Telegram/SourceFiles/test/docs/helpers/test_box_button.md`)

# test_box_button

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_box_button.h](../../test_box_button.h).

## Use when

Resolve and click shell footer buttons. Read the contract below and the source header before use.

## Contract and usage

Click a box's shell footer button by label, rooted at
`Test::PaintingLayerRoot`, with its own self-test: a content-rooted
`Ui::RoundButton` search that reaches none of the footer buttons beside the
rooted search that finds and clicks one, a named refusal for a label no shell
button carries, a disabled or hidden footer readable as
`present`+`disabled`/`hidden` with `matched()` still false
(`BoxButtonDisabled` / `BoxButtonHidden` beside `BoxButtonReady`),
`box_button_shell` saved through `CaptureBoxLayer` so the frame contains the
footer row, and a same-launch hidden-then-ready shell-button reading while the
layer show animation runs. `ClickBoxButton` still Fails on an unusable button.

---

### Telegram SourceFiles Test Docs Helpers Test Capture (`Telegram/SourceFiles/test/docs/helpers/test_capture.md`)

# test_capture

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_capture.h](../../test_capture.h).

## Use when

Prepared grabs, paint owners and mapped targets. Read the contract below and the source header before use.

## Contract and usage

In-process grabs, paint-root validation, mapped rects, complete-target mapped
readiness (`ReadMappedTarget` / `MappedTargetReady` / `CaptureMappedTarget` /
`PreparedWidgetCapture::prepare(owner, origin, rect)`), blank detection,
painting-layer-root resolution for boxes inside a layer, `CaptureBoxLayer` for
the whole `Ui::BoxLayerWidget` (title + `addButton` footer row, which
`setTitle` / `addButton` parent onto the shell) versus `CaptureInLayerRoot`
for a frame cropped to the box content, crops, zoom, contact sheets,
window-mapped capture for widgets that paint no opaque background of their
own; a **toast** goes to `test_toast_capture.h` instead, whose frame cannot
compose the surface beneath it.

## Capture and visual assertions

Choose the narrowest helper that captures the real paint owner:

| Need | Helper |
| --- | --- |
| Static visible widget or local rect | `CaptureWidget` / `CaptureRect` |
| Rect expressed in a child or offscreen content widget's coordinates | `CaptureMappedRect` |
| Complete mapped target visible in painted owner and scroll viewport before that capture | `ReadMappedTarget` / `MappedTargetReady` / `PreparedWidgetCapture::prepare(owner, origin, rect)` / `CaptureMappedTarget` |
| Full box, layer owner, animation, or asynchronously populated surface | `Runner::captureWidget` |
| Animating owner whose product paints can run back to back (several animations at once, especially in Debug), sampled right after each of them instead of on a timer | `PostPaintSampler` (`test_post_paint.h`) |
| Short `Ui::Animations`-driven product animation (a slide transition, a reveal) captured at chosen elapsed times after one synchronous action or click handler, whether or not the target receives compositor paints (a minimized window, a never-shown widget) | `RunClockedFrames` (`test_animation_clock.h`) |
| Whole box inside a layer (title + footer shell) | `PaintingLayerRoot` + `Runner::captureWidget`, or `CaptureBoxLayer` |
| Box content cropped inside a layer | `CaptureInLayerRoot` |
| Open `Ui::PopupMenu` | `CapturePopupMenu`, a supplementary frame taken only while the menu is still open; its action texts and lifecycle come from `OpenContextMenu` (`test_menu.h`), which reads the menu in the turn that built it and grabs no pixels |
| Toast whose frame must not compose the product surface painted beneath it | `CaptureToastSubtree` |
| Other widget that paints no opaque background of its own: a fade-in wrapper that is a child widget, such as `Ui::ImportantTooltip` | `CaptureViaWindow` |
| A `Ui::Tooltip`, which is `RpWidget(nullptr)` and so its own top-level window that `CaptureViaWindow` refuses | Find it in `QApplication::topLevelWidgets()` (`dynamic_cast<Ui::Tooltip*>`, visible only), read its text from `accessibilityName()`, capture it with `CaptureWidget` |
| Exact accepted frame plus numeric/raster assertions | `Runner::captureAndInspect` |
| Small target comparison | `Crop`, `Zoom`, `ContactSheet` |
| Foreground colour, contrast, or painted-band measurement | `MeasurePaintedInk` and the helpers in `test_ink.h` |
| Rounded corner of a media or bubble against a twin or reference: the background beyond the corner, arc coverage across twins whose media colours differ, containment, chrome inside the patch | `ReadCornerBackground` / `CompareCornerCoverage` / `ReadCornerContainment` / `GateCornerChrome` (`test_corner_patch.h`) |
| Day or night palette variant of the same surface, whatever the current night mode and whether the theme follows the system dark mode | `AppendPaletteSwitch` (`test_palette.h`) in a stage before the captures, judged by its settled live palette sample, and `AppendPaletteRestore` in a stage after them, which puts back the night mode and the system-follow setting the switch found; a bare `Window::Theme::ToggleNightMode()` is silently refused while the theme follows the system (Failure diagnosis). Confirm with `AppendPaletteSwitchSelfTest`. |

`CaptureInLayerRoot` crops to the box content's own rect. That is not the
whole box: `Ui::BoxLayerWidget::setTitle` creates `_title` parented to the
shell, and `addButton` re-parents each footer button onto the shell, so
both live outside `box->rect()`. `CaptureBoxLayer` grabs that shell.

`captureWidget` and `captureAndInspect` reject missing, hidden, empty,
near-uniform, and invalid transparent-root frames. Their readiness predicate
should identify current content: model id, generation, title, child count,
paint generation, or settled animation state. It must not assert that the
result has the expected height or colour.

`captureAndInspect` saves the accepted image before it runs assertions, so a
failure still has decisive evidence:

```cpp
runner->captureAndInspect(
	u"gift_composer_150"_q,
	[] { return Test::ReadLiveWidget(u"gift.composer"_q).widget; },
	[](QWidget *widget) {
		return widget->property("contentGeneration").toInt() > 0;
	},
	[](QWidget *widget, const QImage &image) {
		Test::Note(u"composer image=%1x%2"_q
			.arg(image.width())
			.arg(image.height()));
		const auto content = widget->childrenRect();
		Test::Check(
			widget->rect().contains(content),
			u"composer contains its child geometry"_q,
			u"owner=%1,%2 %3x%4 children=%5,%6 %7x%8"_q
				.arg(widget->rect().x())
				.arg(widget->rect().y())
				.arg(widget->rect().width())
				.arg(widget->rect().height())
				.arg(content.x())
				.arg(content.y())
				.arg(content.width())
				.arg(content.height()));
	});
```

If a child grab exposes the harness background, capture the ancestor that
paints the background. If a row is clipped by a scroll viewport, grab the
content widget and map the row rect with `CaptureMappedRect`; do not treat a
partial screenshot as product evidence.

For a capture whose subject is the currently visible mapping — a card, row,
or child rectangle that must appear whole in the painted owner — the caller
supplies that origin widget, the painted owner, and the exact origin-local
rectangle to `ReadMappedTarget` / `MappedTargetReady`. Readiness is
whole-rectangle containment in the owner **and** the relevant viewport
(`Ui::ElasticScroll` itself, because its `viewport()` is Dummy and returns
the inner content; `QAbstractScrollArea::viewport()` otherwise). It never
intersects, clips, or reframes the requested rect. Origin-local containment
alone is not visibility: a HistoryInner-local card rect can sit fully
inside the inner widget while a negative scroll offset leaves part of it
outside HistoryWidget. `CaptureMappedTarget` refuses that unready reading
and then composes `CaptureMappedRect`, which still independently refuses a
rect that is not fully inside the grabbed widget. A nonpainting owner whose
geometry fits is still not capture-ready (`PreparedWidgetCapture::prepare`).
`PaintingLayerRoot` resolves boxes inside layers specifically; it is not a
history/list paint-owner resolver. Callers still own semantic navigation
and exact item identity.

## Coordinate systems

Measurements cross three coordinate systems, and each has a fixed contract.
`CaptureWidget` / `CaptureRect` / `CaptureMappedRect` take widget LOGICAL
rects; the image they produce — like every `Ui::GrabWidgetToImage` grab —
holds logical-size × `style::DevicePixelRatio()` DEVICE pixels, so `Crop`
and any direct `QImage` sampling index device space. Multiply logical values
by the image's `devicePixelRatio()` exactly once, at the sampling boundary.
`TDESKTOP_TEST_SCALE` is a third, independent multiplier: it scales the
logical style metrics themselves, so derive expected geometry from the live
scaled tokens, never from literals recorded at 100%. Rects from different
widgets never share an origin by accident — map both through one declared
frame (`Ui::MapFrom`, `mapToGlobal`) before comparing, cropping, or
clicking.

## Related guidance

[Post-paint sampling](test_post_paint.md); [clocked frames](test_animation_clock.md); [corner comparisons](test_corner_patch.md); [failure diagnosis](../diagnostics.md).

---

### Telegram SourceFiles Test Docs Helpers Test Chat Menu (`Telegram/SourceFiles/test/docs/helpers/test_chat_menu.md`)

# test_chat_menu

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_chat_menu.h](../../test_chat_menu.h).

## Use when

Attribute chat-list context menus to the exact message. Read the contract below and the source header before use.

## Contract and usage

The chat-list context-menu reader, `OpenChatListContextMenu(list, target,
point)` ([input helpers](test_widgets.md#input-helpers)), in a module of its own because it depends on history
view types, which `test_menu.h` does not: `ChatListMenuReading` (the
underlying `ContextMenuReading`, with its menu pointer cleared on every
refusal; the requested message and the list's own view of it; the four
`ChatListResolution`s; `enterSent`, `leaveSent`, `menuRead`, `observed`,
`listMenu`, `menuHidden` and `attributed`; `built()`, never true on default
state), the fourteen refusals `ChatListMenuRefusalName` names, and
`ChatListMenuDetails`, which prints recorded values only. The private state it
reads - `_mouseActive`, `_dragStateItem`, `_overElement`, `_overItemExact`,
the visible message area, `_menu` and `HistoryInner::hasPendingResizedItems()`
- goes through the test-only explicit-instantiation accessor
`test_notify_override.cpp` uses, confined to `test_chat_menu.cpp`: no
production accessor, no pixel scan, no registry. Its own self-test,
`AppendChatListMenuSelfTest`, in twelve stages, needs a logged-in session and
a Saved Messages history whose folder is known. It injects two fixed
plain-text outgoing messages A and B there, a private fixture added with
`History::addNewMessage` and ids from `Data::Session::nextNonHistoryEntryId()`
- regular and so selectable, nothing sent, "Select" the only entry ever
triggered - and keeps the main window minimized with
`KeepMainWindowNotMarkingRead` from before the injection until both are
destroyed. On the legacy `HistoryInner`, and then on the
`HistoryView::ListWidget` of a `HistoryView::ChatWidget` opened through the
`use-new-chat-view` option: a premise arm (A resolved last, the mouse outside
the list, the window inactive through `ClearWindowActive`, and an unprotected
`OpenContextMenu` at B's point that the list resolved to A or to none, never
to B, read at build through the observer); the protected reading for B under
the same conditions, built and attributed to B; "Select" from it leaving
exactly B selected (`HistoryInner::getSelectedItems()`,
`ListWidget::getSelectedIds()`); B requested at A's point refused
`wrong-target` with A in its details, its menu hidden, and "Select" through it
refused `menu-gone` with the selection unchanged; `no-list`, `not-a-chat-list`
and `outside-viewport` controls; and an active control through
`ForceWindowActive`, the window still minimized and not marking read, that
resolves B. Every `ClearWindowActive` and `ForceWindowActive` is undone in the
same turn, back to the activation captured at the start. The teardown destroys
A and B first and proves that neither id resolves to a message or to a
`ListWidget` view, then restores the option, the chat, the window state and
the Qt activation, and the same idempotent release runs from
`Runner::onFinish`. The `History::messages()` index the `ChatWidget` leg seeds
may stay allocated and keep listing both ids: an id above `ServerMaxMsgId`
lies above every slice's `range.till`, where `SparseIdsList::removeOne` cannot
reach it, and every reader of the index looks ids up through
`Data::Session::message()` and skips one with no message behind it; its
`FIXTURE_INDEX` row reports what it lists. Confirm with
`AppendChatListMenuSelfTest`.

## Related guidance

[Input contracts](test_widgets.md#input-helpers); [failure diagnosis](../diagnostics.md).

---

### Telegram SourceFiles Test Docs Helpers Test Clipboard (`Telegram/SourceFiles/test/docs/helpers/test_clipboard.md`)

# test_clipboard

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_clipboard.h](../../test_clipboard.h).

## Use when

Clipboard round-trip and classified read-back. Read the contract below and the source header before use.

## Contract and usage

The retried clipboard round trip: `AppendClipboardRoundTrip(runner, { label,
accessor, delay, attempts })` appends one stage, `clipboard round trip:
<label>`, that writes a sentinel `harness-clipboard:<label>:<serial>:a<k>` and
reads it back, up to `kClipboardRoundTripAttempts` (10) times
`kClipboardRoundTripDelay` (300 ms) apart: attempt 1 in the stage's `run`,
every later one from a `Qt::PreciseTimer` `base::Timer` across event-loop
turns, never a main-thread sleep, and a pure `until` on `finished`. Each
attempt is one `CLIPBOARD_ROUND_TRIP_ATTEMPT: label=<label> ...` row
(`read=sentinel`, `empty`, `stale-sentinel:<held>` or `foreign`, then `owns`,
`yielded`, `atMs`, `writeMs`), so keep the label one token without spaces, and
the stage's `then` Notes the `CLIPBOARD_ROUND_TRIP` summary. The returned
`ClipboardRoundTrip`, read from a later stage, carries `succeededAt` or a
refusal naming what the last read held - `read-empty`, `read-stale-sentinel`,
`read-foreign` - or `invalid-bound` (fewer than one attempt or a negative
delay; nothing written). A `read-*` refusal's reason also ends with the
console-lock reading taken at refusal, `; console at refusal: CONSOLE_LOCK:
...` (`Test::ReadConsoleLock()`, `test_console_lock.h`) - the host console's
state, printed for every accessor, fakes included - so a campaign's own
refusal names a locked Windows console with no wiring; a success and
`invalid-bound` carry none. A read is only classified, inside a scope its text
never leaves: every sentinel is registered process-wide before it is written
and only those are ever printed, foreign text is never stored, printed,
measured or hashed, a sentinel from an earlier process counts as foreign, and
the owner's clipboard is overwritten and never saved or restored.
`ReadClipboard(expected, accessor)` is a one-shot reading for a product copy,
deliberately not a wait: `expected`, `empty`, `harness-sentinel` (naming it)
or `foreign`, with `owns` (`ownsClipboard()`, meaningful on Windows, macOS and
X11) and the platform name; `ClipboardReadingText` prints neither the expected
nor the read text. On Windows a read while the process owns the clipboard is
served in process, so a success there proves the set took, not an OS-level
read. `ClipboardAccessor` (`write`, `read`, optional `owns`) is the injection
seam, and `SystemClipboardAccessor()` the default. Its own self-test,
`AppendClipboardRoundTripSelfTest`, counts rows only from a mark it takes in
the test log: an accessor failing three times reads back at attempt 4 with
exactly 4 rows; an always-empty one with the shipped defaults is refused
`read-empty` after exactly 10 logged attempts; a stale sentinel is refused
`read-stale-sentinel` naming it, and an injected foreign text `read-foreign`;
`ReadClipboard` classifies the same fakes; the always-empty refusal carries
the console reading and the three-failure success none; one live round trip
runs against the system clipboard, and is `TEST_RESULT: N/A` by name - the
lock, the probe row, the refusal and its attempts - instead of failing when
`Test::ReadConsoleLock()` reads a locked Windows console as its judging stage
begins and it did not read back, while a judging stage that runs Notes the
`CONSOLE_LOCK` row its gate read; and a `Test::DiscriminatingScan` finds the
foreign text in no line the self-test appended, with the foreign arm's own
attempt rows as its control.

---

### Telegram SourceFiles Test Docs Helpers Test Console Lock (`Telegram/SourceFiles/test/docs/helpers/test_console_lock.md`)

# test_console_lock

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_console_lock.h](../../test_console_lock.h).

## Use when

Current console-lock classification. Read the contract below and the source header before use.

## Contract and usage

Whether the console session this process runs in is locked, read at the moment
of the call and never cached, on Windows and macOS: `Test::ReadConsoleLock()`.
On Windows it reads the `SessionFlags` of
`WTSQuerySessionInformationW(WTS_CURRENT_SERVER_HANDLE, <own session>,
WTSSessionInfoEx)`, the own session taken from `ProcessIdToSessionId` - the
reading Qt's own clipboard takes before it stops retrying `OleSetClipboard`
(`QWindowsContext::isSessionLocked()`, which reads the active console session
instead). `WTS_SESSIONSTATE_LOCK` reads `locked` and `WTS_SESSIONSTATE_UNLOCK`
`unlocked`; any other value, a failed call, a short answer, a level other than
1, and Windows 7 / Server 2008 R2, which report the two flags reversed, read
`unknown`. On macOS it reads a fresh `CGSessionCopyCurrentDictionary()` per
call, released before it returns, and two booleans in it: the undocumented
`CGSSessionScreenIsLocked` - true while the session's lock screen is up,
measured absent on an unlocked console on macOS 27 - and
`kCGSessionOnConsoleKey` (the key `kCGSSessionOnConsoleKey`). In this order: a
null dictionary (no window-server session) reads `unknown`; a
`CGSSessionScreenIsLocked` that is true reads `locked`, and one that is not a
boolean `unknown`; `kCGSessionOnConsoleKey` true, with the lock key absent or
false, reads `unlocked`; anything else - a session off the console (fast user
switching), whose lock bit does not say whether its screen is shown - reads
`unknown`. So it also sees a console locked before the launch; `test-run`'s
`screen_locked` reads the same two keys out of process through `ioreg`'s
`IOConsoleUsers`, the independent control. Elsewhere it reads `not-applicable`
with source `none`, and `locked` is reachable only in the Windows and macOS
branches. `ConsoleLockText` prints the one `CONSOLE_LOCK:
state=<locked|unlocked|unknown|not-applicable> source=<call> <raw> - <reason>`
row, naming that source
(`CGSessionCopyCurrentDictionary().CGSSessionScreenIsLocked` on macOS) and its
raw values - on Windows session, console session, bytes, level, reported
session, session state, flags in hex; on macOS
`kCGSSessionOnConsoleKey=<1|0|absent|not-boolean>
CGSSessionScreenIsLocked=<1|0|absent|not-boolean>`, none for a null dictionary
- never the user, domain or station name the Windows answer carries, nor the
user's short and long names, UID, GID, session UUID or audit and security
session ids the macOS dictionary carries. `ConsoleLockGate` answers `locked
console: <row>` for a `locked` reading in a Windows build only
(`Platform::IsWindows() && reading.locked()`) and an empty string for every
other reading and on every other platform, so it can be (part of) a stage's
`skipReason`; `unknown` and `not-applicable` never gate. So the gates keyed on
it stay Windows-only - `AppendClipboardRoundTripSelfTest`'s live arm,
`AppendPopupMenuCaptureSelfTest`'s across-turn stages and every campaign leg
gated the same way - because those are the legs a locked Windows console
breaks: no process on its desktop can open the clipboard, and Qt closes a
shown popup on `ApplicationDeactivate`. A locked macOS console did not close
the popup: a packed launch with `CGSSessionScreenIsLocked` true ran
`AppendPopupMenuCaptureSelfTest` with 0 FAIL
(`2026/10/06/add-a-post-paint-capture-sampler-to-the-harness`, `work/test.md`,
Test 2). On macOS the reading is printed in those places and gates nothing; a
check that a locked macOS console does break gates on `locked()` itself, as
`AppendKeepWindowExposedSelfTest` does. Not `Core::App().screenIsLocked()`,
which is not consulted on any platform: a product flag set only by
notifications that arrive after the launch (`WM_WTSSESSION_CHANGE`, the
`com.apple.screenIsLocked` / `com.apple.screenIsUnlocked` observers, the
xdg-desktop-portal Inhibit monitor's `screensaver-active`), so it reads false
on a console already locked at launch; not the input desktop's name
(`OpenInputDesktop` named it `Default` on a locked Windows 11 console); not an
`OpenClipboard` attempt (the measurement a clipboard check makes). Read by
`AppendClipboardRoundTrip`'s `read-*` refusal, by the gates of
`AppendClipboardRoundTripSelfTest`'s live arm and
`AppendPopupMenuCaptureSelfTest`'s across-turn stages, by
`AppendKeepWindowExposedSelfTest`'s lock `N/A` and its control's
`screenLocked=`, and into the printed `screenLocked` of
`ReadMainWindowMarking` and of the animation-clock window reading
(`test_animation_clock.h`); a campaign gates its own clipboard or
popup-capture legs on it the same way (its judging stage's `skipReason`,
`CapturePopupMenu`'s `skipReason`), Windows-only through the same gate. It has
no self-test of its own: on Windows those two self-tests' gated and ungated
rows, and on macOS the window-exposure lock `N/A` and the marking-read and
animation-clock `screenLocked=` rows, each beside an independent reading of
the console, confirm it.

---

### Telegram SourceFiles Test Docs Helpers Test Corner Patch (`Telegram/SourceFiles/test/docs/helpers/test_corner_patch.md`)

# test_corner_patch

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_corner_patch.h](../../test_corner_patch.h).

## Use when

Rounded-corner comparisons and controls. Read the contract below and the source header before use.

## Contract and usage

Rounded-corner parity readings over device-pixel corner patches, each a
`CornerPatch` (the image, its `PatchCorner`, and a media colour sampled away
from the arc and chrome). `ReadCornerBackground` reads the background at the
patch's own corner-most pixel (`CornerMostPixel`), restricted to the
4-connected component of that exact colour, and refuses a corner-most pixel
within `kCoverageMinContrast` (24) of the media (`media-at-corner`) or a
component that reaches the opposite corner
(`background-reaches-inner-corner`); `ReadCornerCoverage` maps each pixel's
media share on the max-contrast channel, unclamped, and refuses
`contrast-too-low`; `CompareCornerCoverage` compares a subject with a
reference of the same corner, both against the reference's background, at a
caller tolerance (`kCoverageSameKind` 0.03, `kCoverageCrossKind` 0.06),
classifies the subject's corner-most pixel as `background` or `media`, and
prints `maxDelta` with its pixel and both map values, `over/total` and
`excluded` on both verdicts; `ReadCornerContainment` counts the
reference-background pixels that changed in the subject, its mask widened by
an optional second reference's shared zero-coverage pixels connected to the
corner; `GateCornerChrome` accepts a chrome region only when every pixel of it
is full media on a gate patch without the chrome (`chrome-touches-arc`,
`chrome-touches-background` otherwise), and the compare and containment refuse
a chrome that was not accepted (`chrome-refused`) or was gated on another
corner or size (`chrome-mismatch`). Every reading has a named refusal set and
a formatter (`CornerBackgroundText`, `CornerCoverageText`,
`CornerCoverageCompareText`, `CornerContainmentText`, `CornerChromeText`) that
prints `none` for a value it did not measure. Own self-test over synthetic
patches painted with `Images::Round` and `Images::CornersMask` at
`Ui::BubbleRadiusLarge()` and `Ui::BubbleRadiusSmall()`, needing no window,
session or account: a Large and a Small arc (`N/A` when the
`use-small-msg-bubble-radius` option makes the radii equal), a square and a
rounded corner, and an arc shifted by one device pixel told apart; colour
twins equal by coverage with their worst pixel printed; all four corners read
at their own corner-most pixel; `media-at-corner` and `contrast-too-low`
refusals; an enclosed background-coloured glyph kept out of the background and
ignored by containment, which still catches a media pixel beyond the arc; a
chrome region touching the arc refused by its gate, a clear one accepted and
left out of a compare that differs without it; and a second reference widening
the mask by a shared shadow to catch a changed shadow pixel. Nine stages,
eighteen checks, a `corner_patch_self_test` contact sheet.

## Comparing corner patches

Compare a rounded corner of a media or bubble with a twin or a reference
through `test_corner_patch.h`, not through pixels read by hand. Each reading
takes a `CornerPatch`: a device-pixel `QImage` cropped at a named
`PatchCorner` inside the media's bounding box, about
`Ui::BubbleRadiusLarge() + 2` logical px square so that its corner-most
pixel lies beyond the arc and its opposite corner inside the media, and a
media colour sampled inside the media away from the arc and from any chrome;
a coverage map assumes that colour is locally uniform near the arc.
`ReadCornerBackground` reads the background at the patch's own corner-most
pixel (`CornerMostPixel`) and keeps only the 4-connected component of pixels
exactly that colour, so a background-coloured glyph the media encloses is
never background. `ReadCornerCoverage` maps each pixel's media share on the
channel where media and background contrast most. `CompareCornerCoverage`
compares two patches of one corner by those maps, both read against the
reference's background, so capture both twins over one base: twins whose
media colours differ but whose arcs match compare equal, while another
radius, a square corner or a one-pixel arc shift does not, and the worst
pixel, its delta and the count over the tolerance are printed on both
verdicts. Pass `kCoverageSameKind` (0.03) within one media kind and
`kCoverageCrossKind` (0.06) across kinds. `ReadCornerContainment` checks
that every background pixel of the reference stays background in the
subject; a second reference with another media colour widens the mask by the
zero-coverage pixels both share that stay connected to the corner, such as a
shadow. Chrome — a corner-status badge, an enlarge badge — is left out only
through `GateCornerChrome`, gated on a twin or reference that does not draw
it: the gate refuses a region touching an arc or background pixel there, and
the compare and the containment refuse a chrome the gate did not accept
instead of applying it. A reading that cannot be made is refused by name,
its predicate is false and every value it did not measure prints `none`:
`ReadCornerBackground` refuses `empty-patch`, `media-missing`,
`media-at-corner` and `background-reaches-inner-corner`;
`ReadCornerCoverage` `empty-patch`, `background-missing`, `media-missing`
and `contrast-too-low`; `GateCornerChrome` `region-empty`,
`region-outside-patch`, `gate-background`, `gate-coverage`,
`chrome-touches-arc` and `chrome-touches-background`;
`CompareCornerCoverage` `corner-mismatch`, `size-mismatch`, `reference`,
`chrome-refused`, `chrome-mismatch`, `subject` and
`subject-background-differs`; `ReadCornerContainment` `size-mismatch`,
`second-reference-size-mismatch`, `reference`, `chrome-refused`,
`chrome-mismatch` and `empty-mask`. The hand-written readings it replaces
produced false FAILs in Runs 1 and 5 of
`2026/10/06/round-rich-message-edge-media-like-ordinary-media` (the three
corner-patch rows in [failure diagnosis](../diagnostics.md)). Confirm with
`AppendCornerPatchSelfTest`, over synthetic patches rounded with the
product's own `Images::CornersMask` at both bubble radii.

---

### Telegram SourceFiles Test Docs Helpers Test Custom Emoji (`Telegram/SourceFiles/test/docs/helpers/test_custom_emoji.md`)

# test_custom_emoji

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_custom_emoji.h](../../test_custom_emoji.h).

## Use when

Explicitly registered ready emoji fixture. Read the contract below and the source header before use.

## Contract and usage

Supply an always-ready `Ui::Text::CustomEmoji` that fills the large-emoji box
with worst-case ink, handed out only for document ids the scenario itself
registered.

---

### Telegram SourceFiles Test Docs Helpers Test Gated Stage (`Telegram/SourceFiles/test/docs/helpers/test_gated_stage.md`)

# test_gated_stage

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness self-test.

Source contract: [test_gated_stage.h](../../test_gated_stage.h).

## Use when

Self-test for explicit gated skips. Read the contract below and the source header before use.

## Contract and usage

The first-class gated skip's own self-test: a stage whose `skipReason` returns
a reason, writing one `TEST_RESULT: N/A:` row and skipping `run`, `until` and
`then` without waiting - its never-ready `until` under a one-second timeout is
the falsifier - beside a stage whose gate returns an empty string and runs
normally in the tick that begins it.

---

### Telegram SourceFiles Test Docs Helpers Test Gram Reconcile (`Telegram/SourceFiles/test/docs/helpers/test_gram_reconcile.md`)

# test_gram_reconcile

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_gram_reconcile.h](../../test_gram_reconcile.h).

## Use when

Reconcile golden-wallet custody and words. Read the contract below and the source header before use.

## Contract and usage

The P0 fixture reconciliation in [account fixtures](../account-fixtures.md):
`ReconcileGramAccount` / the one-stage `AppendGramReconcile` reconcile the
golden `test_gram_account.txt` with the marked live copy's own custody through
the product's local reveal and restore only - `NoOp`, case (b)
`RewroteFromReveal`, case (a) `RestoredFromFile`, or a refusal by a named
`GramReconcileGate` that writes nothing - and print one public
`GRAM_RECONCILE:` row; `GramReconcileResult::phrases` and
`GramCustodySecretTokens` hand the secrets to the secrecy scan in memory;
`GramReconcileDrive` is the self-test seam. Its own acceptance on the marked
live copy is `AppendGramReconcileSelfTest`.

## P0 reconciliation

The golden `tdata` is never modified while the file is, so a later campaign's
P0 reconciles the two before anything else — in process, after the address
gate has already passed. Do not hand-roll it: call `Test::AppendGramReconcile`
(one stage) or `Test::ReconcileGramAccount` (the flow) from
`test_gram_reconcile.h` right after the address gate. The two cases remain the
definition: (a) the local custody has FEWER words than the file: the golden
`tdata` predates a confirmed rotation, so restore custody from the file's
words through the product's own import (`Wallet::Session::restoreFromPhrase`)
before any other stage; (b) the local reveal differs from the file with an
equal or greater count: the live copy was promoted after a chain-confirmed
rotation but the rewrite was cut off, so rewrite the file from the local
reveal.

A phrase *qualifies* when it signs for the served key (its signing key — words
13-24 of a 24-word phrase, the anchor of a 12-word one — is
`Wallet::Session::publicKey()`) and derives the served address (its anchor,
words 1-12, derives `*wallet.address()`). The helper decides in this order,
and each refusal is a named gate (`GramReconcileGate`) that writes nothing:

1. Preconditions: `NotActive`; `ServedUnknown` (no served address or no
   32-byte served key); `FixtureAbsent`; `FixtureOtherWallet`;
   `CustodyUnreadable`; `CustodyBusy` (`custodyBusy()`, a pending rotation,
   or a record still awaiting its server key — such a record "signs with" any
   served key, so it cannot certify the file).
2. `NoOp`: the file qualifies and a readable current record exists. Nothing is
   written.
3. Case (a), `RestoredFromFile`: the file qualifies, no current record exists,
   and the local count is below the file's. The local count is 0 when no
   record of the served wallet is held — the fresh golden copy — and otherwise
   implied by the held obsolete record's keys: 12 words when its signing key
   is its anchor, else 24. It is never read by a local reveal: under the
   rotation-following custody model an obsolete record is not the current
   record, so `revealPhrase` would fall through to the server's
   `wallet.exportSecretPhrase`. The helper restores with the silent Open grant
   (no chooser opens), then requires a local reveal equal to the file
   (`RestoreFailed` / `ConfirmMismatch` otherwise).
4. The file qualifies but (a) does not apply: `NoReadableCurrentRecord`, the
   detail naming why — the current record is unreadable, the held obsolete
   record is not shorter than the file, or a pre-v4 unresolved record whose
   count is unknown (never `revealParked`, whose reveal writes custody).
5. The file does not qualify: `NoReadableCurrentRecord` unless
   `revealsLocally()`; `NoOpenGrant` without a silent grant; otherwise the
   local reveal (`RevealFailed <PHRASE_*>` when it fails), then
   `RevealedNotServedKey`, `RevealedNotServedAddress` or
   `RevealedShorterThanFile`. Otherwise case (b), `RewroteFromReveal`:
   `RewriteGramAccountWords(revealed, served address)`, then `GramAccount()`
   is re-read and compared in process (`RewriteFailed` when the live copy was
   not written, `RereadMismatch` when the re-read differs).

The stage writes one public `GRAM_RECONCILE:` row (case, gate, counts,
booleans, public keys and addresses), reports `NoOp`, (a) and (b) as `PASS`,
and a refusal as `TEST_RESULT: N/A` with `fixture gate: P0 <gate> - <detail>`;
a campaign's funded-wallet legs gate on `GramReconcileResult::reconciled()`.
A fresh golden copy whose keyring is not Open refuses `NoOpenGrant` (the
detail names the keyring kind) until the protection chooser has been driven
once on that copy. The helper never calls `wallet.replaceWallet`,
`wallet.exportSecretPhrase` or any other server method that mutates or
reveals the wallet, never resets and never mints. Its `GramReconcileDrive`
seam fakes only the served identity and the record facts it names, never
writes a fixture file (`DrivenWriteSuppressed`), and restores only when the
undriven facts show a same-phrase re-import. Its own acceptance is
`Test::AppendGramReconcileSelfTest`, on the marked live copy. Every stage of it
that reaches a reveal or a restore is a fixture gate, `NoOpenGrant
keyringKind=<N>`, without the silent Open grant, and a live copy staged to 12
words that case (b) did not rewrite is put back to its earlier words through
`RestoreGramAccountLiveWords` (the `gram_reconcile_restore_staged` stage, and
again when the scenario finishes).

Never delete or reset the marked live copy while a campaign may have left a
rotation in flight or unrewritten: SETUP keeps a marked live copy, and only a
manual wipe or `test-account-reset` discards it — and with it the only copy of
a not-yet-rewritten signing half.

## Related guidance

[Account prerequisites](../account-fixtures.md); [secrecy proof](test_secrecy_scan.md).

---

### Telegram SourceFiles Test Docs Helpers Test History Fixtures (`Telegram/SourceFiles/test/docs/helpers/test_history_fixtures.md`)

# test_history_fixtures

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_history_fixtures.h](../../test_history_fixtures.h).

## Use when

Lifetime-owned history service-message fixtures. Read the contract below and the source header before use.

## Contract and usage

Inject a caller-supplied service action into a real history as a regular or
(negative-control) local item, with a caller-owned lifetime that removes it,
and log the menu-gating predicates.

---

### Telegram SourceFiles Test Docs Helpers Test Hover (`Telegram/SourceFiles/test/docs/helpers/test_hover.md`)

# test_hover

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness self-test.

Source contract: [test_hover.h](../../test_hover.h).

## Use when

Self-test for click/drag hover cleanup. Read the contract below and the source header before use.

## Contract and usage

The input helpers' own self-test: a clicked-then-dragged and a never-touched
`Ui::RoundButton` measured against the two fills their style names, proving a
completed synthetic click and drag leave no hover.

---

### Telegram SourceFiles Test Docs Helpers Test Ink (`Telegram/SourceFiles/test/docs/helpers/test_ink.md`)

# test_ink

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_ink.h](../../test_ink.h).

## Use when

Painted bands, ink, color and contrast measurements. Read the contract below and the source header before use.

## Contract and usage

### `test_ink.h`

Derived paint bands, colour separation, ink scans, counts, and contrast
reports. `DeriveBand` names the case where the requested fill is the image's
background outside the candidate, distinct from no-rows; `MeasurePaintedInk`
carries that reason in `report`, and can no longer answer with an empty
`report` on any path. A scan that cannot classify a frame refuses by name as
well: `InkScanState` answers `outside-image`, `no-rows-in-band`,
`candidates-collide`, `background-collinear`, `no-ink` or `classified` beside
a `reason` that is never empty on a returned reading, while `ok` keeps its one
meaning — this band was scanned — so only the two states that looked at no
pixel are false and every frame-level number on the other five is a real
measurement. `background-collinear` is a candidate within `kInkMargin` of the
segment from the scan's measured modal background to another candidate: the
reading is not `classified`, and `countAt` refuses every count as `count=none`
instead of returning the zero that candidate can never earn. It loses to
`candidates-collide` and to `no-ink`, which keep their texts. `candidates` and
`counts` are filled before either refusal returns, so the two are always the
same length, and one candidate's count is read through `InkScan::countAt(i)`,
which names the candidate and refuses an index the reading does not have, a
reading that classified nothing and a candidate pair that does not separate,
instead of indexing past the end of a vector the scan never filled;
`InkCountDetails` prints that refusal as `count=none`, never as a zero.
`FormatInkScan` starts with `FormatInkReport`'s unchanged text and then names
the candidate colours, their counts, `CollisionDump`'s collision and all five
thresholds on the **passing** verdict as well as the failing one. Own
self-test over synthetic images.

### `test_ink.h` (`ReadChromaticRaster`)

Where a coloured glyph clusters in a captured band. Given a pen and a fill to
exclude, it keeps pixels whose channel spread clears `kChromaticFloor` and
that match neither excluded colour within `kSameTolerance`, slides a square
window across their x range, and reports the densest window's box, matched
count, total chromatic count, density and the mean colour of the pixels it
matched — never a colour the caller passed in. `no-chromatic` (the band was
scanned and no pixel cleared the floor) and `below-density` (chromatic pixels
exist but no window reaches `kChromaticDensityFloor`) are named refusals;
`readBox`, `readMean` and `readDensity` refuse on both, so an empty box is
never a measurement. A band that misses the image is `raster-outside-band`,
distinct from both. `FormatChromaticRaster` prints the box, both counts, the
density and the measured mean on a found reading and names the refusal on the
others. Own self-test over synthetic images, registered from
`AppendDeriveBandSelfTest`.

### `test_ink.h` (`ReadGlyphCore`)

Which pen painted a text run, read from its solid glyph cores rather than from
nearest-candidate attribution. The band's background is its modal colour. Ink
is a pixel at least `kInkDelta` from that background. A solid core is an ink
pixel within `kGlyphCoreTolerance` (8) of the per-channel extreme away from
the background, which is full coverage rather than a fringe or a partial
blend. The reported modal colour is the mode of those cores, with its share
and the core count; a named pen is only a count of solid cores within the same
tolerance and is never copied into the modal. `no-paint` is a band with no
ink. `no-solid-core` is a band that has ink but no pixel within the tolerance
of the extreme, so a fringe colour is not returned as the pen. `readModal` and
`solidAt` refuse both, and `core-outside-band` is a band that misses the
image. Distinct from `background-collinear`, which is an attribution refusal,
and from `ReadChromaticRaster`, which is a mark-density reading and not a pen
reading. Own self-test over synthetic images, registered from
`AppendDeriveBandSelfTest`.

## Related guidance

[Capture coordinate systems](test_capture.md#coordinate-systems); [failure diagnosis](../diagnostics.md).

---

### Telegram SourceFiles Test Docs Helpers Test Lang Pack (`Telegram/SourceFiles/test/docs/helpers/test_lang_pack.md`)

# test_lang_pack

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_lang_pack.h](../../test_lang_pack.h).

## Use when

Temporary language overrides and restoration. Read the contract below and the source header before use.

## Contract and usage

Install synthetic key/value overrides into the RUNNING language pack and take
them back down symmetrically: `Test::InstallLangPack` freezes the pack
identity, the `serialize()` snapshot and one `LangFrozenValue` per key before
applying one `Instance::applyDifference`, whose two `Expects` it holds by
construction, and `LangPackFixture::remove()` restores through `switchToId` +
`fillFromSerialized` and then one EMPTY difference purely to deliver
`Lang::Updated()`, so a key that was default before the install comes back and
an already-painted reactive label re-reads with no interaction. `frozen(key)`
is the only value-returning read, so an expectation taken after the install —
the one that passes vacuously — is not expressible through it; `checkRestored`
compares live against frozen on both the value and the non-default axis and
prints every reading on the passing verdict. `checkInstalled` certifies the
caller's requested override: with no placeholder the expectation is that text
and the reading is `getValue`; with a placeholder the expectation is the
`QString` `Instance::ParseStrings` returns for that requested text — the
encoding `applyValue` stores — and the reading is still `getValue`, so
`getNonDefaultValue` stays only the non-empty axis. A placeholder
`ParseStrings` omits (unknown, not accepted by that key, repeated, or
malformed) is not certified: `checkInstalled` FAILs by name and the verdict
quotes the raw non-default value and whether `getValue` stayed at the frozen
reading. A `#custom` pack, an empty override list, a key `GetKeyIndex` answers
`kKeysCount` for (a plural phrase's base name included), an empty override
value, a `frozen()` miss, an oracle on a fixture that never installed and an
out-of-order `remove()` are seven named refusals rather than silences. With
its own arm-selectable self-test, whose two `LangRestoreFault` arms
deliberately leave one key installed or suppress the notification, and whose
placeholder arm on `lng_dlg_search_from` shows a `{user}` install PASS and a
deliberate `{amount}` install FAIL.

---

### Telegram SourceFiles Test Docs Helpers Test Launch Fuse (`Telegram/SourceFiles/test/docs/helpers/test_launch_fuse.md`)

# test_launch_fuse

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_launch_fuse.h](../../test_launch_fuse.h).

## Use when

Assert intended OS launches while blocking them. Read the contract below and the source header before use.

## Contract and usage

Declare and verify operating-system launches while refusing every real launch
in test-agent mode.

---

### Telegram SourceFiles Test Docs Helpers Test Layer Root (`Telegram/SourceFiles/test/docs/helpers/test_layer_root.md`)

# test_layer_root

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness self-test.

Source contract: [test_layer_root.h](../../test_layer_root.h).

## Use when

Self-test for the painting-layer resolver. Read the contract below and the source header before use.

## Contract and usage

The painting-layer-root resolver's own self-test: a plain `Ui::GenericBox`
accepted as its own render root beside one that cleared
`Qt::WA_OpaquePaintEvent`, refused and then captured through its
`Ui::BoxLayerWidget`; `CaptureBoxLayer` saves the whole shell beside
`CaptureInLayerRoot`'s content crop, and `MisframedDetails` is quoted on a
rect outside the layer.

---

### Telegram SourceFiles Test Docs Helpers Test Log (`Telegram/SourceFiles/test/docs/helpers/test_log.md`)

# test_log

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_log.h](../../test_log.h).

## Use when

Evidence logs, assertions and telemetry formatting. Read the contract below and the source header before use.

## Contract and usage

### `test_log.h`

Absolute flushed logs, steps, notes, checks whose `details` are printed on the
passing verdict as well as the failing one, tolerances, geometry, completion
markers, N/A rows for stages that did not apply, and their count. One `LogRaw`
call always writes exactly one physical line, whatever it is handed: every
character Python's `str.splitlines()` breaks on - U+000A, U+000B, U+000C,
U+000D, U+001C, U+001D, U+001E, U+0085, U+2028, U+2029, and so a CRLF pair as
its two code points - is written as a visible `\uXXXX` escape, so a record
carrying a break stays one row the external readers' line grammar reads whole
and cannot mistake for a completion, while text with no separator is passed
through byte for byte and the escape adds no trailing whitespace.
`LogGeometry(name, rect)` writes `GEOMETRY: <name>: x=<x> y=<y> w=<w> h=<h>` -
so do `CaptureWidget`, `CaptureRect`, `CaptureViaWindow`,
`CaptureToastSubtree` and a saved `PreparedWidgetCapture` - and
`CheckNear(actual, expected, tolerance, what)` writes `TEST_RESULT: PASS:
<what> (actual <a>, expected <e> ±<t>)`, or the same `FAIL` row with ` - out
of tolerance` appended. Their numbers are ordinary integers (`x=-4821 y=3764
w=5937 h=6148`, `actual 4826, expected 4821 ±37`), and `TelemetryNumber`s in a
row formatted after `RequestTelemetryNumbers()` (`x=-4821p y=3764p w=5937p
h=6148p`, `actual 4826p, expected 4821p ±37p`); a judge reads a value of
either format with `float(v.replace('p', '.'))`. `GeometryText` (the whole
`GEOMETRY:` row) and `CheckNearText` (the check text without its verdict
prefix) are the pure formatters, taking an explicit `NumberFormat`.
`CheckNear`'s `actual`, `expected` and `tolerance` are the caller's numbers
and print in the requested format too, which hides digits from the secrecy
scan, so they must never be a fixture secret or a number read from one.

### `test_log.h` (`TelemetryNumber`, `RequestTelemetryNumbers`)

The number format for telemetry printed into a scanned log, from
`2026/10/08/give-overlay-telemetry-a-number-format-the-secrecy-scan-cannot-count`,
and the one request that makes the shared helpers print their own numbers in
it. `TelemetryNumber(value, decimals)` prints `-` for a value below zero, the
integer digits, `p` and exactly `decimals` fraction digits (`decimals`
defaults to 0, and a negative one counts as 0), rounding as
`QString::number(value, 'f', decimals)` does, and `nan`, `inf` or `-inf` for a
non-finite value: `42p`, `16p31`, `-4821p75`. Every digit run touches the `p`,
so under the scan's bounded rule no digit run of a formatted number is ever
counted. The grammar is an optional `-`, digits, `p` and optional digits, or
`nan`, `inf` or `-inf`; a judge script reads a value with
`float(v.replace('p', '.'))`, and a judge that matches a value with a regular
expression takes the `p` and the fraction digits too (`-?\d+(p\d*)?`), since
`-?\d+` alone silently stops at the `p`. Format only numbers an overlay, or a
converted helper under the request, measured or computed, never a fixture
secret or a number read from one, and choose `decimals` per quantity, never
from a secret. A `TelemetryNumber` is a word to the phrase matcher - it ends a
word run where a bare number is neutral - so never print one as a list index
or between words copied from product or fixture text; keep such text out of
telemetry rows. `RequestTelemetryNumbers()` is one explicit, process-wide,
one-way and idempotent ask that the converted shared helpers print the numbers
they themselves measure, count or time as `TelemetryNumber`s. It takes no
argument and reads nothing, so it cannot depend on a secret: a campaign calls
it unconditionally at its first stage, before any row it may scan, never under
a condition and never from code that read a fixture secret. The first call
logs exactly `TELEMETRY_NUMBERS: requested`; a later call does nothing and
logs nothing, and there is no undo. `HelperNumberFormat()` answers
`NumberFormat::Ordinary` until then and `NumberFormat::Telemetry` after it. A
converted helper formats each row through a pure formatter taking a
`NumberFormat`, which its logging path calls with `HelperNumberFormat()`, and
prints each number through `HelperNumber(value, format)`
(`QString::number(value)` or `TelemetryNumber(double(value))`) or, in an
`.arg` chain that already substituted caller text, `ArgNumber(text, value,
format)`, which under `Ordinary` is the same integer `QString::arg` call as
before, so a row printed without the request stays byte-identical for any
caller text. Every converted quantity is an integer - pixels, counts,
ordinals, 0/1 flags, `crl::now()` milliseconds - and prints with `decimals` 0:
`4821p`, `-12p`, `0p`. The converted rows: `GEOMETRY:` (`LogGeometry`); the
`CheckNear` verdict; the `PostPaintSampler` `kind=post` and `kind=stop` rows;
the `RunClockedFrames` `kind=frame` row, its `frame <k> at <ms> ms` subject,
its gate and refusal reasons and its request refusal; the `Probe` window
bounds, `checkCountSince`'s counts and the round-trip tallies and times; and
the `DiscriminatingScan` `examined=` tally row and its refusal, the secrecy
scan's `SECRECY: <class>` reports among them. Their grammars in both formats
are in the rows of `test_log.h`, `test_probe.h`, `test_post_paint.h` and
`test_animation_clock.h` and in [capture guidance](test_capture.md). Not
converted, ordinary in both modes: caller text and detail lists (a probe row,
a check's `what`, a scan's subject or control name and its details, a
self-test's own numbers), `PostPaintSampler::details()`, the sampler's
empty-window note, the scan's `SECRECY_*` rows and its three check rows,
`test_runner`'s rows, and every other module, such as `test_capture`'s own
readings, `test_ink`, `test_corner_patch` or `test_wallet_intercept`. In every
converted row a helper-written word sits on at least one side of each
converted number, so a number never splits a word run made only of caller
text. A judge splits `test_log.txt` at the first line equal to
`TELEMETRY_NUMBERS: requested`: a converted row before it holds ordinary
integers and one after it `TelemetryNumber`s, and a log without that line
never asked. A text a helper formatted before the ask and a caller prints
after it, such as a kept `RoundTrip::observation` or `ClockedRun::refusal`,
stays ordinary; asking at the first stage avoids that. Its proofs are two
stages of `AppendSecrecyScanSelfTest`: `secrecy_self_telemetry` for the
format, and `secrecy_self_helper_numbers` for the converted rows, which reads
each of them through the helper's own formatter for fixed synthetic values -
without the request it prints today's text, and under it the same text with
only a `p` after each number; the ordinary text's digit runs, as synthetic
short secrets, fail the scan at that row, while the requested row holds none
bounded and scans clean; and a synthetic phrase in its caller text gives the
same word runs in both formats - and never asks the request itself.

## Verdict details

`Test::Check`'s third argument is an observation, not a failure excuse. It is
printed on both verdicts — `TEST_RESULT: PASS: <what> - <details>` and
`TEST_RESULT: FAIL: <what> - <details>` — so a green log says what each check
was made against and a passing run can be audited without re-running it. Pass
the values the check judged: the measured geometry, the observed identity, the
window and the rows behind the verdict. Text that is only true after a failure
stays conditional at the call site — `ok ? QString() : u"out of tolerance"_q` —
which leaves the passing line exactly `TEST_RESULT: PASS: <what>`, the same
line an empty `details` produces. Do not emit a `Note` beside a check only to
print a reading that check's own `details` could carry; that duplication is
what this argument replaces. Where the failure text cannot double as a true
passing observation, the `details` stay conditional and an adjacent `Note`
remains the carrier — `CheckBlockedLaunchesExactly` suppresses its mismatch
text on a pass and keeps its `Test::Note(u"blocked launch record: [...]")`,
which is what `test_launch_fuse.h` promises. None of this loosens a refusal:
failure text still has to name what it judged, and an undecidable reading is
still refused rather than printed as a `Note` a reader would take as a
measurement.

---

### Telegram SourceFiles Test Docs Helpers Test Log Lines (`Telegram/SourceFiles/test/docs/helpers/test_log_lines.md`)

# test_log_lines

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness self-test.

Source contract: [test_log_lines.h](../../test_log_lines.h).

## Use when

Self-test for one physical record per log call. Read the contract below and the source header before use.

## Contract and usage

The one-physical-line guarantee's own self-test: one `LogRaw`-family call
driven with each of the eleven separator forms in turn, with one payload
mixing them all, with a trailing separator and with a separator-only payload,
each read back out of `test_log.txt` by byte offset and asserted to have added
exactly one physical line under an independent transcription of
`str.splitlines()` - beside a separator-free control that must read one line
under any writer, and a last stage whose payload's middle line would be
byte-equal to `TEST_COMPLETE` and which asserts no produced line is.

---

### Telegram SourceFiles Test Docs Helpers Test Log Scan Internal (`Telegram/SourceFiles/test/docs/helpers/test_log_scan_internal.md`)

# test_log_scan_internal

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Internal dependency.

Source contract: [test_log_scan_internal.h](../../test_log_scan_internal.h).

## Use when

Maintaining the shared log reader used by `test_secrecy_scan.cpp` and
`test_publication_identity_scan.cpp`. Product overlays use the public
[secrecy](test_secrecy_scan.md) or
[publication and coverage](test_publication_identity_scan.md) APIs.
An absence of direct overlay includes does not mean this module is unused.

## Contract and ownership

`Internal::ReadLogClasses` is implemented in `test_secrecy_scan.cpp`.
It reads the supplied `SecrecyLaunchLogs` and planted control, returning
class readings, candidate identities and accepted, foreign, missing and
unreadable counts. Optional computed fields and usable secrets select the
strict scan; a null `usable` skips strict parsing. `LogScanObserver` receives
source and line callbacks synchronously, so its state must remain valid
through the call. A coverage reading alone does not prove fixture secrecy.

## Verification

Changes to this reader affect both `AppendSecrecyScanSelfTest` and
`AppendPublicationIdentityScanSelfTest`; exercise the relevant controls in
both suites. Their contracts and fixture requirements live in the public
references above.

---

### Telegram SourceFiles Test Docs Helpers Test Mapped Target (`Telegram/SourceFiles/test/docs/helpers/test_mapped_target.md`)

# test_mapped_target

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness self-test.

Source contract: [test_mapped_target.h](../../test_mapped_target.h).

## Use when

Self-test for full mapped-target visibility. Read the contract below and the source header before use.

## Contract and usage

Complete-target mapped readiness measuring itself: a painted owner taller than
its `Ui::ElasticScroll`, a child rectangle that fits the owner while remaining
partly outside the scroll, then the same origin/owner/local rect capturable
after ordinary `scrollToY`, plus a sibling `Ui::ScrollArea` whose clipper is
`viewport()`, overlap-only / empty / hidden refusals, a nonpainting root that
geometry would otherwise accept, and `PaintingLayerRoot` unresolved on the
fixture.

---

### Telegram SourceFiles Test Docs Helpers Test Marking Read (`Telegram/SourceFiles/test/docs/helpers/test_marking_read.md`)

# test_marking_read

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_marking_read.h](../../test_marking_read.h).

## Use when

Prevent primary-window read marking. Read the contract below and the source header before use.

## Contract and usage

Keep the primary window from marking messages read on an unlocked console.
`ReadMainWindowMarking` / `MarkingReadDetails` carry `markingAsRead`,
`isActive`, `isMinimized`, `isHidden`, `exposed` and `screenLocked` -
`Test::ReadConsoleLock().locked()` (`test_console_lock.h`), read at each call
on every platform, so a console locked before the launch reads `1` on Windows
and macOS and every other platform reads `0` - plus the `WindowActivation`
reading, and a refusal. `KeepMainWindowNotMarkingRead` sets
`Qt::WindowMinimized` (not `Controller::minimize()`, which tray-hides in
`WorkMode::TrayOnly`, and not `QWidget::hide()`, which makes `CaptureWidget`
refuse the window) and refuses by name if that lever is not holding within
`kNotMarkingReadBound`. A null controller refuses without searching for
another window. The self-test covers a true control, the helper,
`Controller::activate()` undoing the lever, a second call, a non-blank
`PreparedWidgetCapture` of a main-window widget while the lever holds, the
null refusal, and showing the window again. From its control until its restore
stage it keeps the primary window exposed with `Test::KeepWindowExposed`, so a
window another application covers - the usual state after a default `test-run`
launch - still reads exposed, and its `Controller::activate()` stages request
activation of the client on macOS, which the window server may grant or refuse
after a background `test-run` launch; the self-test passes either way
(`2026/10/08/launch-the-testagent-client-without-activating-it`, Runs 4, 8 and
9). The lock never ends the control's wait on its own: the control waits for
`markingAsRead` true, and only a control still false at `kNotMarkingReadBound`
on an unexposed window or on a console `Test::ReadConsoleLock()` reads locked
ends it, which makes the deciding half a named `N/A` whose reason names the
lock first (`the console is locked`) - never a pass; an exposed window on an
unlocked console that never reads it true runs to the stage timeout. A locked
console whose control reads true runs and decides the deciding half: two
packed launches on a locked macOS console read 8 PASS
(`2026/10/04/add-a-local-restored-notify-settings-override-to-the-harness` and
`2026/10/06/add-a-post-paint-capture-sampler-to-the-harness`, `work/test.md`).

---

### Telegram SourceFiles Test Docs Helpers Test Menu (`Telegram/SourceFiles/test/docs/helpers/test_menu.md`)

# test_menu

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_menu.h](../../test_menu.h).

## Use when

Same-turn menu readings, actions and captures. Read the contract below and the source header before use.

## Contract and usage

Deterministic `Ui::PopupMenu` capture whose readiness is content identity
only, its `showingContent` reading, and its own self-test. `CapturePopupMenu`
takes an optional trailing `skipReason` that gates both stages it appends, the
opener and the capture; left empty the helper is unchanged. The self-test
reads `Test::ReadConsoleLock()` (`test_console_lock.h`) right before it pops
its menu and when each stage that needs the menu open across turns begins,
keeping the first locked reading, so on a locked Windows console the
`popup_menu_open` capture, `close`, and the `popup_menu_reopened` opener and
capture are `TEST_RESULT: N/A` naming the lock while `open`, `refusal text`
and the teardown run and decide. The context-menu reader beside it:
`OpenContextMenu(receiver, point, reason)` snapshots every live
`Ui::PopupMenu` in the application (`QApplication::allWidgets()`, held as
`QPointer`s, so a menu at a reused address never reads as an old one), sends
the `QContextMenuEvent` inside `Settle` and, in that same call, takes the one
menu that was not alive before - a submenu, whose parent is a `Ui::PopupMenu`,
is not counted - into a `ContextMenuReading`: the ordered non-separator texts
(an entry with no text kept in place as an empty string), the action and
separator counts, the parent and `parentIsReceiver`, and a
`ContextMenuLifecycle` at build, inside the settle action right after
`sendEvent`, and again after the postponed calls settled (alive, visible,
hidden, `activePopupWidget()` as self / other / none, geometry, actions,
`PopupMenuReady`, application state, active window, focus window), with the
receiver, the menu and its parent held as `QPointer<QWidget>` and everything
else recorded by value. No event-loop turn runs inside the call, though on
Windows `PopupMenu::showPrepared`'s synchronous repaint can drain
`crl::on_main` work there. A menu that exists but is hidden or empty is
reported, not refused; the refusals are `receiver-gone`, `no-menu` (with
`reshown` counting a kept menu shown again) and `several-menus` (naming every
candidate). `ReadContextMenuLifecycle` is the pure reading a later poll takes,
and `ContextMenuDetails` prints recorded values only. An optional trailing
at-build observer, `OpenContextMenu(receiver, point, reason, observeAtBuild)`,
is called once, synchronously, inside the settle action after the event was
delivered and the fresh menu was collected, immediately before the at-build
lifecycle is read - so before any `Ui::PostponeCall` the delivery queued has
drained - only while the receiver is alive (neither `receiver-gone` refusal
calls it), and also when no fresh menu exists; it observes only - no event, no
product mutation, no menu popped or hidden, no event-loop turn - and left
empty the reader behaves exactly as before. On a chat list - `HistoryInner` or
`HistoryView::ListWidget` - this reader alone may read a menu the list built
for another message: read those with `OpenChatListContextMenu`
([chat-list menu reference](test_chat_menu.md)), which reads the list's resolution through
that observer. `TriggerContextMenuAction(reading, text)` triggers the one
entry with exactly that text on the still-live menu and, inside `Settle`,
delivers its queued `Ui::Menu::CreateAction` callback with
`QCoreApplication::sendPostedEvents(action, QEvent::MetaCall)` - that action's
calls and no other receiver's - before returning, without hiding the menu; its
refusals are `menu-gone`, `no-such-action` (listing the menu's texts),
`several-actions`, `action-disabled` (Qt 5 would emit `triggered` for a
disabled entry, Qt 6 would not, a user cannot) and `submenu-entry` (decided by
`action->menu()`, so an entry given a submenu by a bare `ensureSubmenu` is
triggered like an ordinary one), printed by `MenuTriggerDetails`. Neither
grabs pixels, Notes or Fails. Its own self-test,
`AppendContextMenuReaderSelfTest`, runs over hidden harness widgets on the
primary window (a missing one is its named fixture gate): a menu built in the
receiving widget, the Keyboard reason and a reading that outlives its menu, a
menu parented to another widget that a receiver-rooted search misses,
`no-menu` beside those positive controls, the reader and trigger refusals, an
entry's callback run when the trigger returns while a marker queued just
before it has not, beside a raw `QAction::trigger()` premise whose callback
stays queued, and the at-build observer, which runs exactly once, after the
handler ran and before a `Ui::PostponeCall` marker the handler queued drained,
with the marker run by the time the reader returns, and is not called for a
receiver destroyed before the call (`receiver-gone`).

## Related guidance

[Input contracts](test_widgets.md#input-helpers); [failure diagnosis](../diagnostics.md).

---

### Telegram SourceFiles Test Docs Helpers Test Messages (`Telegram/SourceFiles/test/docs/helpers/test_messages.md`)

# test_messages

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_messages.h](../../test_messages.h).

## Use when

Observe matching sent server messages. Read the contract below and the source header before use.

## Contract and usage

Lifetime-owned watcher for a matching newly sent server message.

---

### Telegram SourceFiles Test Docs Helpers Test Notify Override (`Telegram/SourceFiles/test/docs/helpers/test_notify_override.md`)

# test_notify_override

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_notify_override.h](../../test_notify_override.h).

## Use when

Local notify-setting override and restoration. Read the contract below and the source header before use.

## Contract and usage

A local, restored override of one peer's notify settings, for a campaign that
must see a notification delivered on a chat the golden account serves muted:
its default user settings are `mute_until=2147483647`, which 777000 and every
private chat without a mute value of its own inherit, so
`System::computeSkipState` answers `Skip`. `Test::OverrideNotify(runner, peer,
values)` first captures the peer's whole `PeerNotifyState`
(`ReadPeerNotifyState` / `PeerNotifyStateText`) - known, value presence,
`mute_until`, sound, silent, show_previews and stories_muted, each with its
presence, plus `muteUnknown`, `isMuted`, `History::muted()` and exception
membership; value presence is read through an explicit-instantiation accessor
confined to the module's .cpp, because public API cannot tell an all-unset
value object from none - and then applies the capture with `values` laid over
it through `Data::NotifySettings::apply(MTPNotifyPeer,
MTPPeerNotifySettings)`, the exact local call the `updateNotifySettings`
handler makes, which forms no request. The default `UnmutedSilentNotify()` is
`mute_until=0` with sound none, so the notification is delivered silent (no
sound, no dock bounce); `MutedSilentNotify()` is its inverse. Two named gates
apply nothing and still return the never-null handle: `settings-unknown` (an
apply would make unknown settings known, and no apply can make them unknown
again) and `already-overridden` (an applied, unrestored override of the same
peer in the same live session). `NotifyOverride::restore()` applies the
capture back once and then only re-reads and compares on every later call, so
it is safe from the window's end, teardown and `Runner::onFinish` alike; a
served writer that replaced the override inside the window (an
`updateNotifySettings` push, `History::applyDialog`, `users.getFullUser`,
`ApiWrap::requestNotifySettings`) reads `intact=0`, and the capture is
restored anyway. `CheckNotifyRestored` prints `applied`, `intact` and both
whole states on either verdict, and `DefaultNotifyStateText` reads the three
defaults for a campaign's "unchanged" checks. The module registers one
`Runner::onFinish` on the first override, which restores or verifies every
handle LIFO. A handle holds a `base::weak_ptr` to the session and a `PeerId`
only; notify settings are not persisted, so an override also ends with the
process. Forum topics and the community fallback are not covered.
`Test::InterceptNotifyRequest` is the request observer the overlay pastes,
quoted in the header, as the first statement of
`MTP::details::Session::sendPrepared`: it records every request formed and,
under `Test::Active()`, suppresses every `account.updateNotifySettings`.
`Test::AppendNotifyLocalityCheck` waits more than 2 s (over
`kNotifySettingSaveTimeout`, 1 s) after the last restore, sends one raw
read-only `account.getNotifySettings` control with no handlers and decides
through `Test::DiscriminatingScan` - subject any
`account.updateNotifySettings` formed from the first override's request mark,
control the record carrying the control's own request id and constructor - and
is N/A by name without the hook. Own session-backed self-test:
`AppendNotifyOverrideSelfTest` (inverts 777000's, or Saved Messages', served
`isMuted`; both refusals; the restore, a second restore that applies nothing,
and the locality check).

---

### Telegram SourceFiles Test Docs Helpers Test Open Handoff (`Telegram/SourceFiles/test/docs/helpers/test_open_handoff.md`)

# test_open_handoff

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_open_handoff.h](../../test_open_handoff.h).

## Use when

Observe document-open handoff without OS launch. Read the contract below and the source header before use.

## Contract and usage

Inspect and assert the document-open branch without handing anything to the
OS.

---

### Telegram SourceFiles Test Docs Helpers Test Palette (`Telegram/SourceFiles/test/docs/helpers/test_palette.md`)

# test_palette

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_palette.h](../../test_palette.h).

## Use when

Switch and restore palettes while respecting system following. Read the contract below and the source header before use.

## Contract and usage

A switch to the day or the night palette, and its restore, that work while the
theme follows the system dark mode, where a bare
`Window::Theme::ToggleNightMode()` is silently refused (Failure diagnosis).
`AppendPaletteSwitch(runner, { name, mode, skipReason, sampleLabel, sample,
stableFor, bound })` appends one stage, `palette switch: <name>`, and returns
the `PaletteSwitch` reading it fills; `AppendPaletteRestore(runner, switched,
name, skipReason)` appends `palette restore: <name>` for that switch and
returns a `PaletteRestore`; both read everything when their stage runs, never
when it is appended. The switch takes the route of
`Window::Theme::ToggleNightModeWithConfirmation()`'s confirmation without its
box: only when `ChatBackground::nightModeChangeAllowed()` reads false,
`Core::Settings::setSystemDarkModeEnabled(false)` and `saveSettingsDelayed()`;
then `ToggleNightMode()` and `KeepApplied()`. The platform's reported mode is
never synthesized and the OS appearance is never changed. A request for the
palette already committed touches nothing and succeeds with `needed=0
applied=0`. After its action each stage samples its probe on every runner tick
until the sample has not changed for `stableFor` (`kPaletteStableFor`, 400
ms), or refuses `not-settled` once `bound` (`kPaletteSettleBound`, 4000 ms)
has passed; the probe is `st::windowBg->c` by default (`windowBg`: day
`#ffffff`, night `#17212b`) or the caller's, and the restore reads the
switch's own probe. `applied` needs the requested mode committed and a settled
sample that differs from the sample before, never `IsNightMode()` alone. Each
reading carries the modes before and after (`PaletteModes`: night,
system-follow, the reported system mode or none, `nightModeChangeAllowed()`,
started, editing; `PaletteModesText`), both samples, the waited time, the
sample count and a named refusal, and is Noted as one `PALETTE_SWITCH:` or
`PALETTE_RESTORE:` row (`PaletteSwitchText`, `PaletteRestoreText`); the
helpers never Pass or Fail. The refusals (`PaletteRefusalName`) are
`invalid-arguments`, `not-started`, `theme-editing`, `already-switched`
(another switch's restore is still outstanding; nothing is touched),
`invalid-sample`, `route-refused`, `not-settled` and `sample-unchanged` (the
probe cannot tell the two palettes apart, so `applied` is not claimed), and
for the restore also `nothing-captured`, `system-mode-changed`, `modes-differ`
and `sample-differs`. The restore puts the night mode back first, with
following paused if it prevents the change, then the follow flag - re-enabling
following runs `Core::Application::checkSystemDarkMode()` at once, and the
reading is taken after it - then `Core::App().saveSettings()`; a later restore
of the same switch only verifies. A switch's first change registers a
restoration obligation, and one module-owned `Runner::onFinish` restores every
outstanding one, newest first, with the same routine and Notes one
`PALETTE_CLEANUP:` row; it cannot wait for the palette to settle, so it is a
backstop for an already failed run, and the normal restore belongs in a stage
(the `d->allThreads.isEmpty()` row under Failure diagnosis). It is no
byte-for-byte rollback of the portable data: `KeepApplied()` writes the
settings when the mode changed and, on a first switch, the other mode's theme
slot. Its own self-test, `AppendPaletteSwitchSelfTest`, needs no session,
network or chat and turns following on when it was off at entry: the premise
(following on, a reported system mode, `nightModeChangeAllowed()` false, and a
bare `ToggleNightMode()` leaving `windowBg` and the night mode unchanged at
once and over a settled interval; a host that reports no system mode is a
named FAIL, never a pass), the switch to the other palette at its shipped
`windowBg`, the restore to the switch's starting values and a second restore
that only verifies, a no-op request, a constant probe refused
`sample-unchanged` while a live `windowBg` shows the palette switched, a
second switch refused `already-switched` while an unfinished one is
outstanding, the finish cleanup restoring that unfinished switch and leaving
the restored ones alone, a second cleanup that changes nothing, and the
palette, the night mode and following left as it found them. Confirm with
`AppendPaletteSwitchSelfTest`.

## Related guidance

[Capture selection](test_capture.md); [teardown](../teardown.md);
[failure diagnosis](../diagnostics.md).

---

### Telegram SourceFiles Test Docs Helpers Test Panel (`Telegram/SourceFiles/test/docs/helpers/test_panel.md`)

# test_panel

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_panel.h](../../test_panel.h).

## Use when

Separate-panel discovery and show-cache readiness. Read the contract below and the source header before use.

## Contract and usage

Distinguish a live `Ui::SeparatePanel` from its faded/squeezed show-animation
cache, and answer which top-level `Ui::SeparatePanel`s exist.
`Test::WalkPanels(&scan, query)` is the one walk eight disposable overlays
each wrote by hand: it enumerates them with a caller-supplied `exclude` of
panels the caller already holds — never a type — and a caller-chosen
`Test::PanelLiveness`, where `Settled` is decided by this module's own
`ReadPanelShowState` and not by `isVisible()`, so a panel still painting its
show cache is not counted as one that opened, while `Shown` is kept beside it
as that reading's control. A mark is simply a previous answer:
`Test::PanelList` is `std::vector<QPointer<QWidget>>`, so it keeps nothing
alive and a panel at a reused address cannot read back as one the mark held,
and `query.before` — or `Test::PanelsAdded`, the same implementation — reads
the difference back as pointers and never as a count. A walk that reached no
`Ui::SeparatePanel` of any kind refuses by name instead of answering a zero,
certified through `Test::DiscriminatingScan` whose control is every panel the
walk reached, so "panels reached, none settled" is a certified zero while "no
panel reached" is not; and `Test::PickPanel` refuses an ambiguous identity
ask, naming every candidate it saw, in three refusal sentences no two of which
read alike. `PanelWalkText` / `PanelListText` are the pure, pollable readings
and `PANEL_WALK` the one reporting row. Identifying a panel from its labels is
`FindAll<Ui::FlatLabel>` + `CheckTextReads` composed at the call site; no
label-join idiom is promoted here. Own self-test over a synthetic top-level
panel it creates, shows and takes down itself.

---

### Telegram SourceFiles Test Docs Helpers Test Post Paint (`Telegram/SourceFiles/test/docs/helpers/test_post_paint.md`)

# test_post_paint

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_post_paint.h](../../test_post_paint.h).

## Use when

Samples after real product paints. Read the contract below and the source header before use.

## Contract and usage

`PostPaintSampler`: samples one painted owner right after each of its product
paints instead of on a timer. A `base::install_event_filter` on the owner sees
each `QEvent::Paint`; a product paint inside a `sampleDuring` window posts at
most one queued sample that runs after the paint returned, and later paints
share a pending one (`covered`); a recursion guard ignores the paints the
sampler's own action causes; `grabFreeDuring` windows, with a lead of the
longest action so far and at least 40 ms, take no sample and count the queued
ones they drop (`dropped=`); the owner is held weakly and its destruction logs
one `kind=stop reason=owner-destroyed` row; rows are read through `mark()` /
`samplesSince()` and printed by `PostPaintSampleText`. Both rows follow
`Test::RequestTelemetryNumbers()` (`test_log.h`): `PostPaintSampleText` and
`PostPaintStopText` (over a `PostPaintStop`) also take an explicit
`NumberFormat`, and a row formatted after the ask prints every number as a
`TelemetryNumber`, while `details()` and the empty-window note keep ordinary
integers. With its own self-test on a harness-owned opaque `Ui::RpWidget`
whose every paint costs 18 ms, needing only a shown, non-minimized primary
window, which its fixture stage keeps exposed with `Test::KeepWindowExposed`
until its last stage (a minimized or hidden window still fails the gate once
and every later stage is `N/A`): a timer-only control at a 16 ms cadence over
1500 ms printing its largest gap, missed paints, lateness, the paints started
while a tick was overdue and the lateness of ticks with no paint inside, as
context; a 1500 ms stressed window of at least 20 back-to-back paints in which
every product paint is covered by a sample taken after it returned, within one
product paint plus one grab; one product paint yielding exactly one sample and
none over an idle window of at least 500 ms although a grab painted the
widget; a grab-free window `[from + 400, from + 700)` holding at least 3
product paints and no sample, with the first paint after it sampled; and the
owner destroyed with a sample pending, after which no action runs, the stop is
logged and the next stage runs.

## Sampling product paints

Sample an animating owner whose product paints can run back to back —
several animations at once, especially in Debug — with `PostPaintSampler`
(`test_post_paint.h`) instead of a timer-driven sampler. In Attempt 1 Run 1
of `2026/10/05/play-gram-card-read-animations-one-after-another` a one-shot
16 ms `base::Timer` sampler of a chat list had a 52 ms gap, from grab 25
(`hi` 44159) to grab 26 (`lo` 44211), while three product paints ran back to
back (frames 44159, 44173 and 44193, about 16-20 ms each in Debug), and the
product's own precise timer reported `arrive due=44159 late=52`; the bound
`vstart(B2).hi - turn <= 50` spanned that gap, read 75 and stayed undecided.
Hand the sampler a name for its rows, the owner whose own `paintEvent`
paints the measured surface (a child's paints are not seen), one action —
normally one `Test::GrabRect` of an owner-local rect — and
`sampleDuring(from, till)` windows in `crl::now()` milliseconds; outside
every window it is idle. A product paint received inside a window posts at
most one queued sample, which runs after that paint returned; a paint
arriving while one is pending shares it. Each sample is one row and one log
line, `NOTE: <name>: kind=post seq=<n> paint=<ms> last=<ms> covered=<k>
lo=<ms> hi=<ms>`: `paint` and `last` are the receipt times of the first and
the last product paint it covers, `covered` counts them, and `lo` / `hi` are
`crl::now()` taken by the sampler itself right before and right after the
action. Read rows through `mark()`, taken immediately before the action
under test, and `samplesSince(mark)`, and key images by the `seq` the action
receives, never by position. A sample's `lo` never precedes the end of the
paint it follows, so a bound from that paint's start shorter than the paint
plus one action cannot be decided with it.

`grabFreeDuring(from, till)` declares a window in which no sample runs. Its
lead is the longest action so far, at least 40 ms: a paint received from
`from` minus the lead until `till` posts nothing and joins no pending
sample, a queued sample that would start there is dropped without a row and
counted in `dropped=`, and sampling resumes with the first product paint
received at or after `till`. The recursion guard ignores every paint
received during the action: a grab delivers `QEvent::Paint` to the owner
through `QWidget::render`, so a sample never posts another, while an owner
that requests a repaint from every paint keeps being sampled because it
keeps painting. Any other grab of the owner or of an ancestor taken inside a
sampling window looks like a product paint: route it through the action or
take it outside the windows. There is no timer fallback, so nothing is
sampled without a product paint; a frame of an idle surface is a plain grab
taken outside the windows. The owner is held weakly. When it is destroyed, a
pending sample does nothing and the sampler logs one `kind=stop
reason=owner-destroyed` row with its `pending=`, `samples=`, `paints=`,
`ignored=` and `dropped=` counts; when the sampler is destroyed first, its
queued call does nothing, and its destructor logs nothing, so read the rows
before releasing it. The stop row is `NOTE: <name>: kind=stop
reason=owner-destroyed seq=<n> pending=<0|1> samples=<n> paints=<n>
ignored=<n> dropped=<n>` (`PostPaintStopText`). Both rows print ordinary
integers until `Test::RequestTelemetryNumbers()` (`test_log.h`); a row
formatted after it prints every one of its numbers as a `TelemetryNumber`
instead, for example `kind=post seq=4821p paint=37645p last=37693p
covered=59p lo=61482p hi=61537p` and `kind=stop reason=owner-destroyed
seq=4821p pending=1p samples=3764p ...`. A judge reads a value of either
grammar with `float(v.replace('p', '.'))`. `PostPaintSampleText` and
`PostPaintStopText` also take an explicit `NumberFormat`, so a self-test
reads both formats. `details()` and the empty-window note (`NOTE: <name>:
ignored an empty sampling|grab-free window from=<ms> till=<ms>`) keep
ordinary integers either way. Confirm with
`AppendPostPaintSamplerSelfTest`.

---

### Telegram SourceFiles Test Docs Helpers Test Press Origin (`Telegram/SourceFiles/test/docs/helpers/test_press_origin.md`)

# test_press_origin

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness self-test.

Source contract: [test_press_origin.h](../../test_press_origin.h).

## Use when

Self-test for press-point ripple origins. Read the contract below and the source header before use.

## Contract and usage

`ClickWithPressOrigin`'s own self-test, `AppendPressOriginSelfTest`, from
`2026/10/20/let-harness-presses-start-button-ripples-at-the-press-point`. It
builds its own never-shown `Qt::WA_DontShowOnScreen` top-level window, placed
before `show()` at the corner of the cursor's screen farthest from the real
cursor, holding a test-local `Ui::RippleButton` subclass that paints only a
background and its ripple, a `Ui::RoundButton` whose `textBgOver` differs from
its `textBg`, a `Ui::IconButton` whose ripple area is two thirds of the
button, each 120 x 120 logical pixels at 100 % scale with its own owned ripple
colour and `st::defaultRippleAnimation`'s durations, and a doomed probe. At
one off-centre point per button a plain `Click` (the negative control) and the
facility are each clocked by `RunClockedFrames` at about 40 and 120 ms, and
every frame is read against a fresh baseline: a Kasa least-squares circle
fitted to the disc's own edge pixels inside the class's mask (from a
`setForceRippled` reading of the mask and the button's own ripple colour) must
be centred at the press point within `max(2, 0.05 x R)` device pixels (R the
expected radius in device pixels), with its radius in the `anim::easeOutQuint`
window at the frame's reached bounds, its interior the button's own ripple
colour at the hide opacity, no changed pixel outside it (so no hover
highlight) and growth between the frames, while the control shows no disc
centred there. The facility's reading and each button's own `events()` record
show exactly `Click`'s press, release and leave, the press delivered while the
button was displaced and mapped the cursor to the point; an `IconButton` press
outside its ripple area changes no pixel (the class's guard kept, beside the
in-area press as its positive control); afterwards every button grabs
identical to its baseline with its geometry, `Qt::WA_Moved`,
`Qt::WA_NoMousePropagation` and `Qt::WA_PendingMoveEvent` as before, and a
plain `Click` reads like the control; the window is refused
`top-level-target`, a native child and a plain child with a native descendant,
each in its own never-shown container, `native-target`, a disabled probe
`disabled-target` and a point outside `point-outside`, each sending and moving
nothing; the doomed probe, destroyed by its own press's postponed call, is put
back first and gets no release or leave; and `QCursor::pos()` reads the same
at the start, at the end and around every press. It needs no session, primary
window, network or account, and saves the baselines, mask readings and every
clocked frame.

---

### Telegram SourceFiles Test Docs Helpers Test Probe (`Telegram/SourceFiles/test/docs/helpers/test_probe.md`)

# test_probe

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_probe.h](../../test_probe.h).

## Use when

Windowed observations and correlated requests. Read the contract below and the source header before use.

## Contract and usage

Append-only observation records read only through a declared window, each
carrying the time it was recorded; keyed issue/answer rows correlated into one
round trip by key rather than by list position, refusing every reading it
cannot positively pair; and scans that must match a control before a zero
counts as absence. `record(row)` logs `NOTE: <name>: <row>` and `recordIssue`
/ `recordAnswer` log `NOTE: <name>: <row> role=issue|answer key=<key>`: caller
text only, holding no helper number. Every check's details are `probe=<name>
window=[<from>,<till>) rows=<rows>` (`ProbeWindowText`: the window's rows
joined by `; `, or `<none>`); `checkCountSince`'s are `expected=<n> actual=<n>
<window>` (`ProbeCountText`); and `checkRoundTripSince` prints the round
trip's observation, `key=<key> state=<state> issues=<n> answers=<n>
dropped=<n> preIssues=<n> preAnswers=<n> issueAtMs=<ms> answerAtMs=<ms>
roundTripMs=<ms> discarded=<rows> <window>` (`RoundTripText`, which
`roundTripSince` builds into `RoundTrip::observation`).
`DiscriminatingScan::report()` writes `NOTE: <name>: examined=<n>
subject(<subjectWhat>)=<n> control(<controlWhat>)=<n>` (`tallyText`), then the
caller's details as `NOTE: <name> subjects: ...` and `NOTE: <name> controls:
...`, then the check `<name> discriminates`, whose FAIL details are `the walk
matched no <controlWhat>, so its <subjectWhat> count of <n> over <n> examined
items cannot tell absence from an enumeration that never reaches the subject`
(`refusalText`). The numbers the helper itself counts or times there - window
bounds, counts, tallies and times - are ordinary integers
(`window=[4821,5937)`, `expected=6148 actual=6148`, `issues=41 ...
roundTripMs=48`, `examined=4821`), and `TelemetryNumber`s in a text formatted
after `Test::RequestTelemetryNumbers()` (`window=[4821p,5937p)`,
`expected=6148p actual=6148p`, `issues=41p ... roundTripMs=48p`,
`examined=4821p`); a judge reads a value of either format with
`float(v.replace('p', '.'))`. `ProbeWindowText`, `ProbeCountText`,
`RoundTripText`, `tallyText` and `refusalText` are pure and take an explicit
`NumberFormat`. `checkCountSince`'s `expected` is the caller's number and
prints in the requested format too, so it must never be a fixture secret or a
number read from one. Caller text - a row, a key, a probe or scan name, a
subject or control name, a detail - prints exactly as given, and a
`timedRowsSince` time or a `RoundTrip` field that a caller prints itself is
the caller's number to format.

---

### Telegram SourceFiles Test Docs Helpers Test Publication Identity Scan (`Telegram/SourceFiles/test/docs/helpers/test_publication_identity_scan.md`)

# test_publication_identity_scan

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_publication_identity_scan.h](../../test_publication_identity_scan.h).

## Use when

Audit supplied public identities and launch-log coverage. Read the contract below and the source header before use.

## Contract and usage

Separate public-presentation and launch-log coverage audits:
`ReadPublicationIdentity` / `CheckPublicationIdentity` check explicit supplied
public identities; `ReadLaunchLogEnumeration` / `CheckLaunchLogEnumeration`
establish controlled coverage only. Fixed `PublicationIdentityRows` /
`LaunchLogEnumerationRows` contain classifications and counts. Neither proves
fixture secrecy. `AppendPublicationIdentityScanSelfTest` registers six
synchronous, session-free stages over synthetic log trees, independently of
the eleven strict-secret stages. See the finite grammar and refusals below.

## Claims, coverage and identity grammar

**Public identity and log coverage are separate claims.** The helpers in
`test_publication_identity_scan.h` answer two different questions. A
`LaunchLogEnumerationReading::decided` result establishes which declared
launch log classes were read with controls; it has no `clean` or secret
verdict. A `PublicationIdentityReading` decides whether supported complete
presentations of explicitly supplied public inputs occur in those bytes.
Its `clean` result requires decided coverage, valid supplied scope, completed
bounded parsing, passing matcher controls and zero findings. Neither result
proves that fixture secrets were audited. Only `ReadSecrecy` / `CheckSecrecy`
with usable supplied fixture secrets can support that claim; an empty secret
set remains undecided and not clean.

A late synchronous campaign stage can use the actual wrappers directly:

```cpp
Test::CheckLaunchLogEnumeration();
Test::CheckPublicationIdentity({
    .identity = {
        .firstName = u"Ada"_q,
        .lastName = u"Syntheticriver"_q,
        .username = u"synthetic_public_handle"_q,
        .phone = u"+15551234567"_q,
        .userId = u"7"_q,
    },
});
```

These example identities are synthetic. The helper never discovers an
account identity or reads a credential fixture. Each wrapper plants a fresh
public control through `Note`, `LOG` and `MTP_LOG`, then uses
`SelectThisLaunchLogs`. `CheckPublicationIdentity` takes
`PublicationIdentityScanArgs`, whose `logs` member is
`LaunchLogEnumerationArgs`; both wrappers support its optional
`testLogControl`, `appLogControl`, `debugLogControl`, `mtpLogControl` and
`earlierParts`. The pure readers instead take an actual
`SecrecyLaunchLogs`, a nonempty planted control and optional enumeration
arguments, without planting or printing anything:

```cpp
const auto logs = Test::SelectThisLaunchLogs({});
const auto coverage = Test::ReadLaunchLogEnumeration(logs, plantedControl);
const auto identity = Test::ReadPublicationIdentity(
    suppliedIdentity,
    logs,
    plantedControl);
```

`plantedControl` must already be present in every required class. The four
mandatory ordered rows are `TestLog`, `AppLog`, `DebugLog` and `MtpLog`, even
when a class has no source. Coverage reuses the strict reader's accepted
bytes: TestLog file, supplied main-log text, and quarter-hour DebugLogs parts
whose first line is their own day index. Window parts start at their last
`NEW LOGGING INSTANCE STARTED!!!` banner. No modification time participates.
`MtpLog` requires both Send and Recv headers regardless of source display
name; every required class needs an actual accepted readable source, the
planted control, and any requested named control. Enable DebugLogs for real
campaigns, since absent MTP logging cannot establish coverage.

Only selector metadata marked complete can decide coverage. Invalid or
reversed times, an unspecified/manual selection, the 200-quarter cap,
inconsistent candidate inventories, unreadable sources, absent classes or
controls all refuse by fixed scan/class categories. Exactly 200 quarters is
complete when the next quarter is beyond the end time; 201 quarters is
capped. A missing or foreign window candidate alongside an accepted
controlled part stays visible in counts without claiming its bytes were
examined. Unreadability still refuses. Explicit `EarlierParts` are scanned
whole and require a valid part name, valid matching day and their own
nonempty present per-part control; they are never inferred from other files.

The identity grammar is finite and applies only to supplied descriptors:

- **Profile:** a complete multipart display name with Unicode
  letter/number/mark/underscore boundaries, or supplied `first_name` and
  `last_name` strings in one recognized TL `user` object. An isolated
  one-part name is insufficient; a structured one-part profile also needs a
  supplied username, phone or id matched in that same object. Names use NFC,
  Unicode case folding and collapsed whitespace, with at most 256 UTF-16
  units per supplied component. Objects, siblings, entries and sources do
  not share profile fields.
- **Username/link:** the complete supplied username as a bounded token,
  `@username`, `t.me/username`, `http[s]://t.me/username`,
  `http[s]://telegram.me/username` or `tg://resolve?domain=username`.
  A complete username path component in screenshot metadata counts too.
  Longer handles, partial names and underscore continuations do not count;
  overlapping spellings of one occurrence count once. Inputs use ASCII
  letters, digits and underscores, include at least one letter, are at most
  64 characters and match with ASCII case folding.
- **Phone:** the complete international number, canonical or with ASCII
  spaces, balanced nonnested parentheses and hyphens, or full digits at a
  plain `phone` label or recognized `user.phone` field. The descriptor is
  `+` and one to fifteen ASCII digits, starting with a nonzero digit.
  Candidate-local normalization never joins arbitrary numbers across a log
  line. Suffixes, partial numbers and longer digit runs do not count.
- **User id:** an exact canonical positive decimal of at most twenty ASCII
  digits, at plain `user_id=<decimal>` / `user_id: <decimal>`, or the TL
  owner/field pairs `user.id`, `userEmpty.id`, `userFull.id`,
  `peerUser.user_id`, `inputPeerUser.user_id`, `inputUser.user_id`.
  The innermost object owns the field. Tiny positive ids still count there;
  generic `id`, wrong-parent fields, request ids, timing, coordinates,
  counters and transport integers do not count. Caller-defined schemas and
  computed-field exemptions are unavailable.

The TL writer grammar is `{ constructor`, `field: value`, `}` with quoted
strings. It decodes `\\`, `\"`, `\n` and the logger's visible separator
escapes `\u000A`, `\u000B`, `\u000C`, `\u000D`, `\u001C`, `\u001D`,
`\u001E`, `\u0085`, `\u2028`, `\u2029` deliberately, without repeatedly
decoding escaped backslashes. Quotes keep braces and apparent fields inside
strings from changing ownership. Each physical TestLog row is an entry;
visible separators can encode a multiline profile within that row. The
additional parser state is bounded to 32 object frames, 4096 decoded UTF-16
units per string and 256 logical lines per open profile. Relevant
unterminated strings/profiles, unsupported escapes, lost object scope and
limit exhaustion refuse with fixed parser categories, including at entry,
source and final EOF boundaries. Empty/unusable supplied scope or any
nonempty malformed/over-limit descriptor refuses, even if another supplied
family is usable. Every usable family also needs its independent positive
and coincidence-negative in-memory matcher controls.

Plain, Send **and Recv** presentations count for identity publication; server
origin is no exemption. Identity has no computed-field exemptions. These
are bounded claims about the supplied identities and supported shapes,
not a general PII detector or an audit of unread credentials.
`LOG_ENUMERATION_*` and `PUBLICATION_IDENTITY_*` rows contain only fixed
class/family/selection/refusal names and counts. They never echo identities,
controls, paths, source names or matched lines. Wrapper nonce setup is a
separate `PUBLICATION_AUDIT_CONTROL` row. Fixed schema words or small counts
may coincide with a supplied tiny name/id; that coincidence is not value
emission. `AppendPublicationIdentityScanSelfTest` independently exercises
coincidences, presentations, enumeration, accepted selection, claim
separation, parser bounds and actual formatter/wrapper output, without an
account or network.

## Related guidance

[Strict fixture secrecy](test_secrecy_scan.md); [account prerequisites](../account-fixtures.md);
[self-test](test_publication_identity_scan_self_test.md).

---

### Telegram SourceFiles Test Docs Helpers Test Publication Identity Scan Self Test (`Telegram/SourceFiles/test/docs/helpers/test_publication_identity_scan_self_test.md`)

# test_publication_identity_scan_self_test

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness self-test.

Implementation: [test_publication_identity_scan_self_test.cpp](../../test_publication_identity_scan_self_test.cpp).
Entry contract: [test_publication_identity_scan.h](../../test_publication_identity_scan.h).

## Use when

Verifying the [publication-identity and launch-log coverage readers](test_publication_identity_scan.md),
especially their claim separation from strict fixture secrecy. This suite
does not need routine calls from product overlays.

## Fixture and verification

`AppendPublicationIdentityScanSelfTest(runner)` adds six synchronous stages
over synthetic log trees: coincidences, supported presentations, enumeration,
accepted selection, claim separation and formatter/wrapper output. It needs
no account or network. The fixture uses `QTemporaryDir` to remove its log
tree when the synchronous reading finishes.

The checks cover positive matches, coincidence negatives, missing coverage,
selection and parser bounds, and actual output. A passing identity or
coverage result must never become a strict-secret verdict. Read the public
reference for the finite grammar and named refusals these controls protect;
record executed self-test results in the change's evidence.

---

### Telegram SourceFiles Test Docs Helpers Test Queued Drag (`Telegram/SourceFiles/test/docs/helpers/test_queued_drag.md`)

# test_queued_drag

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_queued_drag.h](../../test_queued_drag.h).

## Use when

Deliver queued drag starts before the synthetic release. Read the contract below and the source header before use.

## Contract and usage

The queued-start drag: `AppendQueuedStartDrag(runner, { name, resolve,
skipReason, steps, modifiers, bound })` appends one stage, `queued-start drag:
<name>`, built from `Test::DragPressAndMoves` and `Test::DragRelease`, the two
halves `Test::Drag` is made of (`test_widgets.h`), and returns the
`QueuedStartDrag` reading it fills, which the stage Notes as one
`QUEUED_DRAG:` row (`QueuedStartDragText`); a skipped stage leaves it
`finished=0`, and the helper never Passes or Fails. The header states the
consumer's safety hook at `Window::MainWindow::launchDrag()` and
`Ui::FlatLabel::executeDrag()`, whichever the queued start reaches. With its
own self-test, `AppendQueuedStartDragSelfTest`, over purpose-built probe
widgets in a never-shown container on the primary window: a probe whose move
handler, once past `QApplication::startDragDistance()`, posts one
`InvokeQueued` and one `crl::on_main` call that each record whether the
release has arrived reads both before the release under the helper and both
after it under `Test::Drag`, the control; `Test::Drag` delivers exactly the
press, interpolated moves, release and leave its contract names, and the
helper delivers the same records; a probe destroyed by a queued call while the
helper waits is refused `target-destroyed` with no release; an absent target
is refused `no-target`; and a probe that swallows the queued calls aimed at it
is refused `bound-expired` after the bound and still gets its release and
leave. It needs no session, network or account, and starts no `QDrag`.

## Related guidance

[Input delivery and the required safety hook](test_widgets.md#input-helpers);
[failure diagnosis](../diagnostics.md).

---

### Telegram SourceFiles Test Docs Helpers Test Rpc Fixture (`Telegram/SourceFiles/test/docs/helpers/test_rpc_fixture.md`)

# test_rpc_fixture

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_rpc_fixture.h](../../test_rpc_fixture.h).

## Use when

Boxed controlled RPC replies. Read the contract below and the source header before use.

## Contract and usage

Type-safe controlled RPC replies for a registered request. Successful answers
take the generated request's boxed `ResponseType` (`tl::boxed`), which writes
the constructor word; a bare factory such as `MTP_messages_messages(...)`
omits that word and must not be `.write()`'d into the reply.
`DiagnoseControlledRpcResult` requires a complete decode with no leftover
primes (truncation and trailing words included) **before**
`MTP::Instance::processCallback`. `DeliverControlledRpcSuccess` /
`DeliverControlledRpcPrepared` also require `hasCallback` and refuse delivery
on a diagnosis so the parser stays registered. `DeliverControlledRpcError` is
the explicit boxed `rpc_error` route (registered or not).
`DeliverControlledRpcStale` is the explicit canceled/unknown-id success route:
it still calls `processCallback` after a valid boxed body. Own self-test over
`messages.getMessages` (empty and nonempty boxed `messages.Messages`,
bare/truncated/trailing/incompatible rejects, code-400 error,
cancel-then-stale).

---

### Telegram SourceFiles Test Docs Helpers Test Rpc Retry (`Telegram/SourceFiles/test/docs/helpers/test_rpc_retry.md`)

# test_rpc_retry

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_rpc_retry.h](../../test_rpc_retry.h).

## Use when

Observe server-error resends. Read the contract below and the source header before use.

## Contract and usage

The permanent MTP resend seam: `Test::RecordRpcRetry` records one `rpc retry
code=<code> type=<type> request=<constructor>` row for every code-500 or
negative-code answer the transport auto-resends without calling the request's
fail handler, read through the `Test::RpcRetryProbe()` accessor; with its own
self-test for the recorded 500, the non-500 that reaches `.fail()` instead,
and the answer for a request id this process never sent. Its synthesized
`rpc_error` answers now go through `test_rpc_fixture.h` rather than a
hand-rolled `processCallback`.

---

### Telegram SourceFiles Test Docs Helpers Test Runner (`Telegram/SourceFiles/test/docs/helpers/test_runner.md`)

# test_runner

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_runner.h](../../test_runner.h).

## Use when

Stages, bounded readiness and exact-object actions. Read the contract below and the source header before use.

## Contract and usage

Stages, bounded waits, exact-widget actions, prepared capture/inspection
(`captureAndInspect` takes an optional trailing `skipReason` for its stage),
first-class gated skips (`skipReason`), `onFinish` release hook (and its
finish-release self-test), watchdog (`TDESKTOP_TEST_WATCHDOG` in seconds) and
termination.

## The stage contract

`Runner` executes each `Stage` as three distinct operations:

| Member | Contract | Must not do |
| --- | --- | --- |
| `run` | One action whose prerequisites were established by an earlier stage. | Dereference an object that this stage is supposed to wait for. |
| `until` | A pure, repeatable readiness observation. | Click, mutate state, emit a PASS/FAIL, or encode the expected product result. The one documented exception is the `Test::ForceWindowActive` re-assertion in the activation row in [failure diagnosis](../diagnostics.md). |
| `then` | Assertions and the next action after readiness succeeded. | Resolve an unguarded replacement for the object that readiness accepted. |
| `timeoutDetails` | Return the latest observed values and identities. | Repeat only “not ready”; omit the values needed to diagnose why. |
| `skipReason` | Return why this stage does not apply, or an empty string when it does. A non-empty reason writes `TEST_RESULT: N/A: <stage> - <reason>`, skips `run`, `until` and `then`, and moves on in the same turn. | Decide the product outcome. A gate that reads a measurement instead of a precondition turns a would-be FAIL into a silent N/A. |

A stage timeout is a harness failure unless the readiness condition itself is
the behavior under test. Keep expected geometry, text, pixels, counts, and
other falsifiable outcomes in `then` or `captureAndInspect`, not in `until`.
Otherwise a real product mismatch is mislabeled as an opaque timeout.

Use `Runner::actOnWidget` when an action depends on a widget that may appear
asynchronously. It polls the resolver and optional readiness predicate, keeps
a `QPointer` to the exact accepted object, and invokes the action once. This
removes both the eager-`.run` crash and the “resolve again in `.then`” race.
The same rule is built into the harness's widget-carrying readings:
`Test::BoxShellButtons`, `Test::WindowMappedCapture`,
`Test::PaintingLayerRootResult`, `Test::ToastSubtreeReading` and
`Test::ContextMenuReading` hold `QPointer<QWidget>`, so a reading kept
across a stage boundary answers `matched()` / `resolved()` false (a
context-menu reading's `menu` reads null) once its subject is destroyed and
still prints its counts, labels, identity and refusal; format a
`WidgetDescription` of the pointer while the reading is still matched,
because there is nothing to format afterwards.

The generic startup waits are opt-in:

- `waitEvent("launch_finished")` is the normal first gate.
- `waitForSessionReady()` is needed only when the scenario consumes a
  session.
- `waitForChatsLoaded()` is needed only when the tested setup consumes the
  chats list; it is non-fatal and records whether loading completed.
- `waitForChatsLoadedStrict()` is appropriate only when chats loading is part
  of the required fixture or result.

Do not put all three waits in every scenario preamble. An injected box,
helper oracle, or direct production seam often needs none of the account or
chats gates.

Before the first stage begins, `Runner::start()` runs the session gate
([`test_session_gate.h`](../../test_session_gate.h)) for every scenario, self-test packs included. It
writes one row per account,
`NOTE: SESSION_GATE: account index=<i> authorized=<0|1> environment=<Production|Test> primary=<0|1> active=<0|1>`,
then a `reading` row, and on a pass a `decision pass` row and nothing else:
it changes no result when every authorized account that the primary window,
its shown session and `domain().active()` select is on the Test environment,
or when no account is authorized. When one of them selects an authorized
account on another environment, it writes exactly one
`TEST_RESULT: FAIL: fixture gate: session gate - case=...` row naming that
environment before any `TEST_STEP`, and every stage then reads
`TEST_RESULT: N/A: <stage> - fixture gate: session gate refused the scenario (case=<case>)`
with none of its callbacks run; the run still reaches `TEST_COMPLETE`. The
gate decides once, at start, and is not a continuous
account lock: code that `SetupScenario` itself executes is not a stage and is
not gated, so `SetupScenario` only appends stages, registers releases and
configures the gate, and no stage may switch the primary window or
`domain().active()` to another account behind it. The one account switch
the harness makes is the Test-session helper's (`Test::UseTestSession`),
inside the gate and before any stage, undone by its last stage.

## Related guidance

[Scenario design](../scenarios.md); [overlay example](../overlay-example.md); [teardown](../teardown.md); [failure diagnosis](../diagnostics.md).

---

### Telegram SourceFiles Test Docs Helpers Test Scenario (`Telegram/SourceFiles/test/docs/helpers/test_scenario.md`)

# test_scenario

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Overlay entry point.

Source contract: [test_scenario.cpp](../../test_scenario.cpp).

## Use when

Disposable task scenario entry point. Read the contract below and the source file before use.

## Contract and usage

The only permanent overlay slot; the repository version remains a no-op.

## Related guidance

[Scenario design](../scenarios.md); [overlay example](../overlay-example.md); [teardown](../teardown.md).

---

### Telegram SourceFiles Test Docs Helpers Test Secrecy Scan (`Telegram/SourceFiles/test/docs/helpers/test_secrecy_scan.md`)

# test_secrecy_scan

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_secrecy_scan.h](../../test_secrecy_scan.h).

## Use when

Certify fixture-secret absence from logs. Read the contract below and the source header before use.

## Contract and usage

Prove that no fixture secret reached the logs: `CheckSecrecy` takes phrases,
short secrets and tokens once, selects the test log, the app log
(`Logs::full()`) and this launch's `DebugLogs` parts by identity (the day
index in the first line, never a last-write time), counts only what the client
wrote (plain lines and `Send` dump entries; `Recv` hits are reported with
their schema site), reports every client-written short-secret hit in a plain
line at its `<file>|<head>|<field>` site (the message head and the name of the
field whose whole value the hit is), lets a campaign declare computed fields
(`SecrecyScanArgs::computedFields`, message head and field name) whose
exact-value hits are reported in `SECRECY_COMPUTED` and never decide, carries
the MTProto transport's own integer-only `DEBUG_LOG` lines itself (each
format, its source file and which of its numbers the client computes) so that
a hit that is exactly a computed number's whole integer is reported there too
with no declaration, refuses to certify a zero without a planted nonce, named
controls and passing canaries, and prints counts and public identifiers only
(`SECRECY_SCAN`, `SECRECY_WINDOW`, `SECRECY_CLASS`, `SECRECY_RECEIVED`,
`SECRECY_COMPUTED`). Its `SECRECY: <class>` reports are `DiscriminatingScan`
rows, so they follow the number-format request of `test_log.h` like every
converted helper row, while its `SECRECY_*` rows and its three checks keep
ordinary integers in both modes. Its session-free self-test over synthetic log
trees is `AppendSecrecyScanSelfTest`, which also covers the number format of
overlay telemetry, the shared helpers' numbers in both formats and the carried
transport lines.

## Proving no fixture secret reached logs

**Proving no fixture secret reached the logs.** A funded-wallet campaign proves that no recovery word, 2SV password, custody
record id or secret ref reached the test log, the app log or `DebugLogs` by
calling `Test::CheckSecrecy(args, what)` (`test_secrecy_scan.h`) once, from a
late stage's `then`, instead of grepping by hand. Hand it, in
`SecrecyScanArgs::secrets`:

- `phrases`: `GramReconcileResult::phrases` (the file's words and every
  revealed phrase), plus any rotated phrase the run produced later;
- `shortSecrets`: `*Test::TwoStepPassword()` when the campaign used it;
- `tokens`: `Test::GramCustodySecretTokens(session)` taken at P0 AND again at
  the scan (a restore or rotation mints a new record id), which lists every
  record id, secret ref and `secret/` storage key of the custody store.

Declare computed fields in `SecrecyScanArgs::computedFields` only as described
under **Declared computed fields** below. The MTProto transport's own
integer-only `DEBUG_LOG` lines need no declaration: the scan carries them
(**Plain-line sites**).

A campaign that calls `CheckSecrecy` also calls
`Test::RequestTelemetryNumbers()` (`test_log.h`) unconditionally at its first
stage, before any row the scan may read, never under a condition and never
from code that read a fixture secret. From then on the shared helpers print
the numbers they measure, count or time - the `GEOMETRY:`, `CheckNear`,
post-paint, clocked-frame, probe and scan rows - as `TelemetryNumber`s, whose
digit runs the scan never counts, so no number such a helper printed can
fail the scan by coincidence (the shared-helper row under Failure
diagnosis); the caller text in those rows stays the caller's to format. The
call logs `TELEMETRY_NUMBERS: requested` once, and a row formatted before it
keeps ordinary integers.

The values stay in memory: the helper prints counts, public file names, TL
schema identifiers and the heads and field names of plain-line sites only,
never a secret, a field's value, a length or a whole scanned line.

- **Classes and identity.** It reads five classes: `TestLog`
  (`test_log.txt`), `AppLog` (the main log through `Logs::full()`, the text
  the logger holds), `DebugLog` and `MtpLog` (every `DebugLogs/log_HH_MM.txt`
  / `mtp_HH_MM.txt` part named for a quarter hour in [process start, scan]),
  and `EarlierParts` (only parts named in `earlierParts`). A part is accepted
  only when its first line is its own day index (`yyyyMMdd`); otherwise it is
  `Foreign`, `Missing` or `Unreadable`. A window part is scanned from its last
  `NEW LOGGING INSTANCE STARTED!!!` banner, so an earlier same-day launch's
  lines are not charged to this one. No last-write time takes part anywhere.
- **Matching.** A phrase counts as any ordered adjacent pair of its words
  (case-insensitive). Words are letter-and-digit runs: whitespace,
  punctuation (commas, quotes, brackets), a dump string's `\n`, `\t`,
  `\\` and `\"` escapes, the dump's type tags (`[STRING]`) and the test
  log's `\uXXXX` escapes all separate them, and a run carries across the
  lines of one file. A phrase one word per line, comma-joined, escaped in a
  dump string or dumped as a one-element-per-line vector therefore still
  counts; a number is neutral, and any other word or an entry header ends
  the run. A short secret counts only when bounded — no letter or
  digit right before or after it; embedded occurrences are reported and never
  count. A token counts on every substring occurrence; a token shorter than 16
  characters is refused as "not a token; hand it as a short secret".
- **Direction.** MTP dump entries are classed by the transport's own
  `Send: ` / `Recv: ` header. Only what the client wrote — plain lines and
  `Send` entries — decides. `Recv` hits are server-sent content (a received
  value that holds a short secret by coincidence, or chat text that happens
  to hold two adjacent BIP-39 words) and are reported with their
  `<top>/<inner>.<field>` schema site in `SECRECY_RECEIVED`, never deciding.
- **Plain-line sites.** Every bounded short-secret hit in a plain line is
  reported at its `<file>|<head>|<field>` site, in its class's
  `SECRECY_CLASS` row (`plainSites`) and in that class's `SECRECY: <class>`
  subjects. The head is the message text after the app log's
  `[yyyy.MM.dd hh:mm:ss] `, a `DebugLogs` entry's `[hh:mm:ss.zzz tid-index] `
  or an `mtp_` entry's `(dc:<dc>) ` (a test-log row has no prefix, so its
  head keeps `NOTE: `), up to the first `name=` field: an ASCII identifier
  at the message start or after a space, outside double quotes. A value runs
  to the next space, less a trailing `;`, `,`, `.` or `:` and one enclosing
  pair of quotes; `<field>` is the name of the field whose whole value the
  hit is, or `-`. The whole site is withheld as `<file>|?|?`, for every
  reason alike, when the line has no field, the hit lies in the head, the
  head is longer than 64 characters or holds anything but ASCII letters,
  digits, spaces and `_.:-/`, or the head (with the field name) holds any
  given secret. No value, length, offset or line is printed.
  One kind of line with no field still gets a site: the scan carries the
  MTProto transport's own `DEBUG_LOG` lines whose every placeholder prints
  one integer (`TransportLines()` in `test_secrecy_scan.cpp`: each format as
  its function in `mtproto/session_private.cpp`, `mtproto/session.cpp` or
  `mtproto/mtp_instance.cpp` writes it, and which of its numbers the client
  computes). A line is carried only in a `DebugLogs/log_HH_MM.txt` part (a
  window part or an accepted earlier part; never `test_log.txt`, the app
  log or an `mtp_` part), and only when it is exactly a
  `[hh:mm:ss.zzz tid-index] ` entry start in ASCII digits with no
  `(dc:<dc>) `, then the format with each placeholder one integer - an
  optional `-` and ASCII digits, nothing else - then `DEBUG_LOG`'s
  ` (<that source file> : <digits>)` and the end of the line. Its site is
  `<file>|<head>|<slot>`: the head is the format's own text before its
  first number, cut at the first character a head may not hold, and
  `<slot>` the table's name of the number whose whole integer, minus
  included, the hit is, or `-` for any other position. A hit before the
  first number, or a head and slot name that would print a given secret,
  keeps the withheld site, and so does every other shape: an added field,
  trailing text, another source file in the suffix, or a sign, decimal or
  digit the product never prints there.
- **Declared computed fields.** The fourth trap: a value the product
  computes and formats into its own diagnostic line can equal a short
  secret, and a count alone cannot tell that from a leak. Declare such a
  field in `SecrecyScanArgs::computedFields` as `{ .head, .field }`, spelled
  exactly as its site prints them, only when the product source shows that
  the field is formatted from a value the product computes, never from
  fixture input; name that formatting site (file and function) in the
  campaign's test design, and decide from the site row and the source,
  never from the matched line. A hit that is exactly that field's value on
  a line with that head is reported in `SECRECY_COMPUTED` and never decides.
  The same value in any other field, in free text or in part of the value,
  a withheld site, and every word run, token and `Send`-entry hit still
  decide; a declaration that matches no printed site changes nothing.
  A carried transport line (**Plain-line sites**) needs no declaration, and
  a campaign declares nothing for it: its numbers belong to the product,
  not to a campaign - a launch with `DebugLogs` on can print them whatever
  the campaign does - a declaration needs a `name=` site such a line does
  not have, and a table tied to its product functions and proven by the
  scan's own self-test keeps the exemption out of a campaign's reach, so
  no harness or overlay row can be exempted this way. Only a slot that the
  product source shows the client computes or keeps itself - a size or
  count, its own clock, its routing state such as a dc id, a request id of
  its own maps, an id it generated - is computed; a number read straight
  from the server's bytes (an acked or received `msg_id`, a server time, a
  server salt) and a number glued to a letter stay deciding, and no
  declaration changes that. A hit that is exactly a computed slot's whole
  integer is reported in `SECRECY_COMPUTED` and never decides; the digits
  under its minus, another slot or position of the line, the suffix's line
  number, and every word run, token and `Send`-entry hit still decide, and
  the bounded and embedded classes are unchanged. The carried lines come
  from
  `2026/10/13/keep-the-secrecy-scan-from-counting-a-coincidental-transport-number`.
  A number an overlay prints into its own telemetry row is never such a
  field: print it with `Test::TelemetryNumber` instead (the overlay
  telemetry row in [failure diagnosis](../diagnostics.md)). Nor is a number a shared helper
  prints into its own row - a `GEOMETRY:` row, a `CheckNear` verdict, a
  post-paint, clocked-frame, probe or scan row: ask with
  `Test::RequestTelemetryNumbers()` at the first stage, or convert the
  helper (the shared-helper row in [failure diagnosis](../diagnostics.md)).
- **Controls and canaries.** A zero is certified only when it can be told
  from absence. The call plants a fresh public nonce through `Note`, `LOG` and
  `MTP_LOG` and requires it in every non-earlier class; a named control
  (`testLogControl`, `appLogControl`, `debugLogControl`, `mtpLogControl`, for
  example the served key hex or `wallet_getState`) must also be hit. A class
  is undecided with no accepted file, an unreadable part, no planted control,
  an absent named control, or an `mtp_` part without headers of both
  directions. In-memory canaries run every given secret through the same
  matcher and dump walk on every call; a failing canary leaves the scan
  undecided.
- **Output.** One `SECRECY: <class>` `DiscriminatingScan` report per class,
  then the rows `SECRECY_SCAN`, `SECRECY_WINDOW` (every window candidate's
  identity), one `SECRECY_CLASS` per class (with its `plainSites`),
  `SECRECY_RECEIVED` and `SECRECY_COMPUTED` (the count of exact-value hits
  on declared fields and carried computed slots, with each one's
  `<class>|<file>|<head>|<field>` site, never the value), then three
  checks: `<what>: every class decided`, `<what>: no client-written fixture
  secret`, and `<what>: the scan's own rows carry no fixture secret` (the bytes
  it appended to `test_log.txt`, re-read). It returns `decided && clean`.
  The `SECRECY: <class>` reports are `DiscriminatingScan` rows and follow
  `Test::RequestTelemetryNumbers()` like any other: after the ask their
  `examined=`, `subject(...)=` and `control(...)=` counts, and a refusal's
  counts, print as `TelemetryNumber`s. The `SECRECY_SCAN`, `SECRECY_WINDOW`,
  `SECRECY_CLASS`, `SECRECY_RECEIVED` and `SECRECY_COMPUTED` rows and the
  three checks keep ordinary integers in both modes: they also print
  identifiers whose digit runs no number format removes (window times,
  `DebugLogs` part names), and judges read their counts as integers. The
  own-rows check re-reads exactly the bytes the call appended, whatever
  their format.

`DebugLogs` must be enabled (`tdata/withdebug` = `1`), or the `DebugLog` and
`MtpLog` classes are undecided — `MTP_LOG` writes nothing without it. The scan
covers logs only: the post-run grep of promoted evidence, `work/` artifacts
and receipts for 64-hex and UUID tokens stays the performer's job. The
matcher's own acceptance is `Test::AppendSecrecyScanSelfTest`, session-free,
over synthetic log trees and synthetic secrets.

## Related guidance

[Account prerequisites](../account-fixtures.md); [reconciliation](test_gram_reconcile.md).

For public identity and controlled log coverage, use the separate
[publication and coverage reference](test_publication_identity_scan.md).
Neither claim substitutes for strict fixture secrecy.

---

### Telegram SourceFiles Test Docs Helpers Test Session Gate (`Telegram/SourceFiles/test/docs/helpers/test_session_gate.md`)

# test_session_gate

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Reusable API (includes a harness self-test).

Source contract: [test_session_gate.h](../../test_session_gate.h).

## Use when

Every scenario uses the gate automatically. Use its helper to select and restore
a Test session, or its declaration only for explicitly authorized production legs.

## Readings and refusals

The `-testagent` session gate that `Runner::start()` runs before the first
stage of every scenario, self-test packs included (see [the stage
contract](test_runner.md#the-stage-contract)). `ReadSessionGate()` reads,
through public core APIs only, each account's storage index, `sessionExists()`
and `mtp().environment()`, which account the primary window names, which
account owns the session it shows and which is `domain().active()` - never a
phone number, username, name, user id or `uniqueId()`. `ApplySessionGate`
writes the `SESSION_GATE:` account, reading, declaration and decision `NOTE`
rows and refuses with exactly one `fixture gate: session gate` FAIL -
`domain-not-started`, `invalid-declaration`, `non-test-primary`,
`non-test-active` or `selection-unreadable` - after which `RefusedStage` gives
every stage the fixed skip reason, so each writes `N/A` and none of its
callbacks runs; `test-session`, `no-authorized-account`,
`selected-unauthorized` and `declared` pass with `NOTE` rows only.

## Production-leg declaration

`DeclareProductionLegs(runner, { .task = ..., .legs = ... })` is the explicit,
never-default production-leg declaration for a task that itself authorizes
legs on a production-environment account: made once in `SetupScenario` with
non-empty single-line labels (an invalid, repeated or foreign one refuses
without echoing them), it switches no account, waives neither an unstarted
domain nor an unreadable selection, and logs `production-leg declaration
task=[...] legs=[...] accepted=<environment>`.

## Test-session helper

The Test-session helper is the other repair, mutually exclusive with the
declaration (`helper-with-declaration`): `const auto session =
Test::UseTestSession(runner);` in `SetupScenario` only records the request,
and the gate acts on it at start, before any stage. When `domain().active()`,
the primary window's account and the session it shows are already one
authorized Test account it writes `NOTE: SESSION_GATE: test session helper:
switched nothing - ...` and calls no switch; otherwise it selects the
lowest-index authorized Test account, records the restoration obligation first
(the original account held weakly), switches with `Main::Domain::activate()`,
the product's own synchronous account switch, and reads back
`domain().active()`, the window's account, the session the window actually
shows and that session's test mode - only that proof opens the stages. Its
refusals are the gate's one FAIL like the others: `no-test-account` (no
authorized Test account), `helper-selection-split` (the readers do not name
one coherent account, so one restore could not put them all back),
`helper-original-unauthorized` (`Main::Domain::removeRedundantAccounts()` may
drop a session-less original), `switch-not-applied` (the read-back did not
prove the switch; the obligation stays for the finish backstop) and
`invalid-helper` (a request for another runner); a request made after the gate
decided changes nothing and writes a `NOTE`.

## Restoration

`Test::AppendTestSessionRestore(runner, session)` is the scenario's last
stage, after its own teardown: `RestoreTestSession` switches back through the
same route only while a real switch is owed, waits about 1.5 s for the
re-shown window (no wait when nothing was switched), re-reads at the end of
that wait and checks that every reader shows the original again, then calls
the routine again and checks `applied=0` and equality; the `test session
restore` row prints the original, selected and current storage indices, and a
gone original is never equal.

## Finish backstop

The finish backstop: the helper's `Runner::onFinish` registration makes
`Runner::finish()` call `FinishSessionGate` after every release ran, which
restores an obligation a skipped or timed-out restore stage left owed and
verifies - a `NOTE` for a verified no-op, a FAIL under its own name for an
unequal reading that applied nothing. Every restore that switches back, in the
stage or at finish, records its immediate reading and the time of the switch
as the module's outstanding post-switch drain, which only a fresh reading at
the end of that drain clears: the restore stage's check, or the finish
continuation. When none is outstanding - nothing was switched back, or the
restore stage already waited its drain and checked - the run is already
settled and `finish()` goes on at once, as without the helper. When one is
outstanding - `FinishSessionGate` itself switched back (`applied=1`, whatever
its immediate reading, which it Notes), or the scenario watchdog ended the run
while the restore stage was still waiting its drain, so the finish call
applies nothing - `finish()` arms its quit fuse with the deadline extended by
what is left of that drain (counted from the recorded switch, the full 1.5 s
or so after a switch back at finish), yields to the event loop for it, then,
once the drain's recorded end has passed (a timer that arrives earlier only
waits the rest again), `FinishSessionGateAfterDrain` takes a fresh reading
(`source=[finish-settled]`) and checks it and the one taken right after the
switch back equal, so an unequal reading fails the run under its own name
before `SCENARIO_RESULT`; only then come the usual `kFinishDrainDelay` wait,
`SCENARIO_RESULT`, `TEST_COMPLETE` and `Core::Quit()`, once. A drain whose
time is already over but whose fresh reading never ran gets that reading at
once. Like the stage's, that drain mitigates the re-shown window's
chat-background re-cache (the `d->allThreads.isEmpty()` row in [failure
diagnosis](../diagnostics.md)); it does not prove worker quiescence.

## Release binding and side effects

Release binding: a release of a scenario using the helper holds the exact Test
session or object it acts on (a `base::weak_ptr<Main::Session>` taken while
the Test session was shown) and never re-reads `domain().active()` or reuses
the primary window's controller at finish, because the restore stage has
already switched back and `Window::Controller::showAccount` reuses that
controller for the original account; a self-test whose release acts on the
primary window is not packed with the helper without such binding. The switch
is the product's own and carries the product's presence updates:
`Window::Controller::showAccount` calls `Api::Updates::updateOnline` for the
session it shows and, a turn later, for the one it stopped showing, which can
send `account.updateStatus` and, for a session going offline,
`ApiWrap::saveCurrentDraftToCloud()`, as a user's own switch does; the gate
and the helper form no request of their own, and switching before the stages
does not prove the session left behind has no draft.
`Main::Domain::activate()` also persists the active storage index into the
copy (`scheduleWriteAccounts()`); the restore writes it back, and a run killed
before restoring leaves the copy opening on the Test account.

## Verification

Confirm with `AppendSessionGateSelfTest`.

## Related guidance

[Runner contract](test_runner.md); [teardown](../teardown.md); [failure diagnosis](../diagnostics.md).

---

### Telegram SourceFiles Test Docs Helpers Test Style (`Telegram/SourceFiles/test/docs/helpers/test_style.md`)

# test_style

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_style.h](../../test_style.h).

## Use when

Settled palette samples and baseline checks. Read the contract below and the source header before use.

## Contract and usage

Wait for palette/style samples to stabilize and assert a recorded baseline
still holds.

---

### Telegram SourceFiles Test Docs Helpers Test Text Drop (`Telegram/SourceFiles/test/docs/helpers/test_text_drop.md`)

# test_text_drop

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness self-test.

Source contract: [test_text_drop.h](../../test_text_drop.h).

## Use when

Self-test for text-drop routing. Read the contract below and the source header before use.

## Contract and usage

The text-drop helper's own self-test: a real `Ui::InputField` inside a
parentless, never-shown container that accepts drops and counts every drag
event, where the field's mime hook taking the drop receives exactly the text
and leaving it makes the field hold exactly the text, beside three refusal
controls - `acceptDrops` off and a disabled field (`viewport-refuses-drops`),
a read-only editor under the container (`enter-not-accepted`, preceded by a
raw-route premise showing the same enter and drop landing in the container)
and an editor made read-only on the drop (`drop-not-accepted`) - each leg
reading the container's counters, `QDragManager`'s current target by pointer
and the `QClipboard::changed` count within the call, never the clipboard
itself.

---

### Telegram SourceFiles Test Docs Helpers Test Text Reads (`Telegram/SourceFiles/test/docs/helpers/test_text_reads.md`)

# test_text_reads

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_text_reads.h](../../test_text_reads.h).

## Use when

Space-normalized text assertions. Read the contract below and the source header before use.

## Contract and usage

Space-class-normalizing text comparison: `Test::NormalizeSpaces` maps U+00A0
and U+202F to U+0020 and changes nothing else, `Test::CheckTextReads` compares
a read-back against an expectation through it and prints both raw strings and
their whitespace code points on either verdict; with its own self-test, in
which a real `Ui::FlatLabel`'s narrow-no-break-space read-back is accepted
beside two deliberate FAIL negative controls - a different minute and a
different day - that keep the check from becoming permissive.

---

### Telegram SourceFiles Test Docs Helpers Test Toast Capture (`Telegram/SourceFiles/test/docs/helpers/test_toast_capture.md`)

# test_toast_capture

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_toast_capture.h](../../test_toast_capture.h).

## Use when

Isolated toast-subtree captures. Read the contract below and the source header before use.

## Contract and usage

The toast capture whose frame cannot compose the product surface beneath it:
the live-toast walk over the top level widgets (`Ui::Toast::internal::Manager`
keeps `_toastByWidget` private and enumerates nothing), a grab rooted at the
toast subtree that `Ui::GrabWidgetToImage` fills with `st::windowBg` wherever
the toast does not cover, a settled-show readiness term measured against
`st::toastBg`'s own blend over that base because `_shownLevel` is private,
named refusals for a root that is not the live toast and for a rect outside
the toast's geometry that quote both rects and never reframe, the joined-label
text oracle read through `Test::CheckTextReads`, and its own self-test over a
synthetic sentinel surface it paints, which counts sentinel pixels in both
saved frames and then hides that surface to show no pixel of the subtree frame
depending on it, and which drives both documented ambiguity branches of the
resolver — a second live toast shown and taken down inside one stage, and the
empty walk its own teardown arranges — showing `FindLiveToast()` answering
`nullptr` for each, and which measures a default-duration toast live on
`ToastSubtreeReady` inside its lifetime and gone from the walk after it,
quoting the live count, the texts and the elapsed time.

---

### Telegram SourceFiles Test Docs Helpers Test Transfer (`Telegram/SourceFiles/test/docs/helpers/test_transfer.md`)

# test_transfer

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_transfer.h](../../test_transfer.h).

## Use when

Observe document transfer success/failure. Read the contract below and the source header before use.

## Contract and usage

Observe document save/failure transitions and assert duplicate or failed
transfer behavior.

---

### Telegram SourceFiles Test Docs Helpers Test Via Window (`Telegram/SourceFiles/test/docs/helpers/test_via_window.md`)

# test_via_window

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness self-test.

Source contract: [test_via_window.h](../../test_via_window.h).

## Use when

Self-test for window-mapped captures. Read the contract below and the source header before use.

## Contract and usage

The window-mapped capture's own self-test: a real `Ui::Toast` whose bare
prepared grab is refused in the turn it is created, beside the window-cropped
frame of the same rect that the harness accepts, and a flat fixture region
proving a blank frame is a `Note` and never a FAIL; a sixth stage builds an
offscreen `Qt::WA_DontShowOnScreen` top level, reads it, destroys it inside
the same `.run`, and shows the retained reading answering `resolved()` false
with its identity, mapped rect and refusal intact.

---

### Telegram SourceFiles Test Docs Helpers Test Wallet Intercept (`Telegram/SourceFiles/test/docs/helpers/test_wallet_intercept.md`)

# test_wallet_intercept

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_wallet_intercept.h](../../test_wallet_intercept.h).

## Use when

Controlled wallet request interception. Read the contract below and the source header before use.

## Contract and usage

The controlled Gram wallet fixture's shared request intercept, so a wallet
campaign calls it instead of copying `GramIntercept` overlay code. The overlay
pastes one hook, quoted in the header, as the first statement of
`MTP::details::Session::sendPrepared`:
`Test::InterceptWalletRequest(_instance, request)`, returning when it answers
true. That is the single main-thread funnel into a session's send map, every
`Instance::Private` resend included, and the module itself adds no production
seam. `ControlledWalletIntercept()` is the process-global `WalletIntercept`:
`arm(&session->mtp())` suppresses and records every request of that instance;
`seal()` suppresses `wallet.sendTransfer` and a provider POST
`/api/v2/jsonRPC` whose JSON `method` is `sendBoc` (the product's
`IsSendBocRequest`, here `IsWalletTransferRequest`) from any instance for the
rest of the process; `disarm()` lets every other constructor pass. Held
`toncenter.performApiRequest` provider calls are released through their real
registered callbacks with a controlled `rpc_error 400 TIMEOUT`, which
`Wallet::Api::IsTimeoutError` classifies as the host's own expiry
(`kTimeout`), from the first drain through every box and press; a campaign
turns releasing off only for a deliberate hold and then answers those calls
itself, posting from `onRegistered`. `AppendWalletEngineDrain` releases
everything held, leaves the release on and waits for the pure `readiness()`:
the engine worker idle by the overlay's own counters (an absent worker is an
idle reading, a missing reading refuses), no provider call held, and none
registered, released or answered for 1500 ms, longer than the 1000 ms
comment-recipient retry delay. `CheckNoEarlierProviderCallHeld` gates the
press. A refusal of either is a fixture gate (`TEST_FLAW`), never a product
`FAIL`. `teardown(quiesce)` runs seal, stop releasing, release the held
provider calls, cancel the other suppressed requests, the caller's quiesce,
disarm - the seal before the disarm, because the source run disarmed first and
its queued fixture send's routed `wallet.sendTransfer` reached test DC 2 - and
is idempotent from `Runner::onFinish`. The overlay fixes the fixture route
when the send is queued (`Test::GramOwns(this)` captured before
`_engine->run`), so a fixture send that enters the engine after disarm never
takes the real engine path. Own self-test on private intercepts, with no
network, no account, no session and no `MTP::Instance`:
`AppendWalletInterceptSelfTest`.

---

### Telegram SourceFiles Test Docs Helpers Test Wallet Ready (`Telegram/SourceFiles/test/docs/helpers/test_wallet_ready.md`)

# test_wallet_ready

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_wallet_ready.h](../../test_wallet_ready.h).

## Use when

Judge served wallet freshness. Read the contract below and the source header before use.

## Contract and usage

Judge one reading of the wallet's freshness stamp — the one `applyState()`
writes when the server's wallet state reaches this client — refuse a stamp
that was never written, wait for a stamped state under a bounded deadline that
names what it observed, and re-run the instrument's own
positive/negative/recovery self-test.

---

### Telegram SourceFiles Test Docs Helpers Test Web Cache (`Telegram/SourceFiles/test/docs/helpers/test_web_cache.md`)

# test_web_cache

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_web_cache.h](../../test_web_cache.h).

## Use when

Clear the exact web-document cache entry. Read the contract below and the source header before use.

## Contract and usage

Remove a web document's persistent cache entry before a leg that must reach
the network. `Data::WebDocumentCacheKey` is the SHA-256 of the URL alone -
never the access hash - and the marked live portable folder is reused from run
to run with its caches, so a URL an earlier run loaded is a local hit that
`FileLoader` finishes with no `upload.getWebFile` at all.
`Test::RemoveWebDocumentCache(session, url)` removes that key from
`session->data().cache()` - where web documents land - and from
`cacheBigFile()`, which no current writer uses for them and which the removal
guards against a future one, and returns a `WebCacheRemoval` reading written
on main only: poll `finished()` (true only after both databases answered) from
a pure `until`, and judge `ok()` in `then` with `WebCacheRemovalText` as
details. It holds only a `base::weak_ptr` to the session: once the session is
gone nothing completes and the text says `session=gone`. Removing an absent
key completes `ok()`, so a campaign may remove unconditionally. It clears the
persistent caches only, not a media object that already holds the bytes in
this process. Own self-test over fabricated `.example.invalid` URLs with
synthetic bytes and no network, beside an unrelated key that must survive:
`AppendWebDocumentCacheSelfTest`.

---

### Telegram SourceFiles Test Docs Helpers Test Wheel (`Telegram/SourceFiles/test/docs/helpers/test_wheel.md`)

# test_wheel

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness self-test.

Source contract: [test_wheel.h](../../test_wheel.h).

## Use when

Self-test for wheel delivery. Read the contract below and the source header before use.

## Contract and usage

The wheel helper's own self-test: a covering `Ui::AbstractButton` inside a
`Ui::ScrollArea` whose subject wheel moves the area and names the viewport,
beside the same wheel aimed at the viewport (today's working target), a wheel
aimed at the `QAbstractScrollArea` itself that is not certified delivered, and
a parentless-window button that refuses by name.

---

### Telegram SourceFiles Test Docs Helpers Test Widgets (`Telegram/SourceFiles/test/docs/helpers/test_widgets.md`)

# test_widgets

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_widgets.h](../../test_widgets.h).

## Use when

Typed discovery, live publications and input events. Read the contract below and the source header before use.

## Contract and usage

Safe typed discovery, live object/action publication, input, postponed-call
settlement, QPA-injected window activation, wheel delivery along Qt's parent
ladder with a named `WheelDelivery` refusal, clipboard-free text drop into a
`Ui::InputField` with a named `TextDrop` refusal, and a press-point ripple
origin for a click (`ClickWithPressOrigin`, a `PressOrigin` reading with named
refusals, printed by `PressOriginDetails`).

## Exact objects and actions

Telegram custom widgets generally do not declare `Q_OBJECT`. Never call
`findChildren<Ui::CustomWidget*>()`; Qt may return unrelated widgets blindly
cast to that type. `FindAll<T>`, `FindFirst<T>`, and `FindVisible<T>` enumerate
real `QWidget` descendants and apply C++ RTTI safely.

`FindVisible` means only `QWidget::isVisible()`. It does not prove that the
widget belongs to the current layer, is unobscured, has non-empty mapped
geometry, or is the latest instance. Add those task-specific conditions, or
publish the exact object from its construction seam.

For layer-owned objects, repeated boxes, async replacements, and footer
callbacks with no returned pointer, use the live publication helpers:

```cpp
// In an inventoried Debug overlay hunk at the real construction seam.
Test::PublishLiveWidget(u"gift.composer"_q, composer);
Test::PublishLiveAction(
	u"gift.confirm"_q,
	box,
	[=] { confirm(); });

// In test_scenario.cpp.
runner->actOnWidget(
	u"open current composer"_q,
	[] { return Test::ReadLiveWidget(u"gift.composer"_q).widget; },
	[](QWidget *widget) { Test::Click(widget); },
	[](QWidget *widget) {
		return widget->isVisible() && widget->isEnabled();
	},
	Test::kDefaultStageTimeout,
	[](QWidget *widget) {
		return u"visible=%1 enabled=%2 size=%3x%4"_q
			.arg(widget->isVisible())
			.arg(widget->isEnabled())
			.arg(widget->width())
			.arg(widget->height());
	});
```

Each publication advances a generation. A destroyed context makes its widget
or action unavailable. Actions are one-shot by default; pass `true` only for
a production callback that is intentionally retryable. Use
`ReadLiveAction()` to wait for a specific generation and invocation count,
then `InvokeLiveAction()` in an action/assertion stage. This is preferable to
guessing which `RoundButton` belongs to a presentation wrapper.

Drive a top-level `Ui::RpWindow` through that static type, never through a
`QWidget*` or a `QPointer<QWidget>`. `Ui::RpWindow` (`rp_window.h`)
declares `setMinimumSize()`, `setFixedSize()`, `setGeometry()`,
`showFullScreen()`, `showNormal()` and `close()` without `virtual` and
forwards each to its `Ui::Platform::BasicWindowHelper` (`rp_window.cpp`).
The `QWidget` members of the same names are non-virtual too, so these six
only hide them and the static type picks the function: through a
`QWidget*` the call compiles, runs the `QWidget` member and silently skips
lib_ui's platform window helper. Keep the type the product hands out —
`Calls::Group::Panel::window()`, `Calls::Panel::window()` and
`Calls::Window::window()` return `not_null<Ui::RpWindow*>` — and hold it
across stages as `QPointer<Ui::RpWindow>`. A window found through
`QApplication::topLevelWidgets()` or `QWidget::window()` is a `QWidget*`:
`dynamic_cast<Ui::RpWindow*>` it before the call, and treat a null cast of
the window a stage means to drive as a fixture gate, never as a reason to
call through the `QWidget*`. Every other `Ui::RpWindow` forward, such as
`setTitle()` or `setStaysOnTop()`, has no `QWidget` namesake, so a
`QWidget*` call to it does not compile; a member `Ui::RpWindow` does not
declare, such as `showMaximized()`, `windowState()` or `geometry()`, is
the same `QWidget` member through either type. The full-screen row under
[failure diagnosis](../diagnostics.md) gives the skip's measured consequence on Windows.

## Input helpers

All input helpers synchronously dispatch real Qt events and drain
`Ui::PostponeCall` work after each event. They lifetime-guard the target and
stop if it is destroyed. `AppendClipboardRoundTrip` sits beside `DropText`
below because the two answer the clipboard question from opposite sides, but
it is a stage, not an event helper: it yields to the event loop between its
attempts.

| Helper | Use |
| --- | --- |
| `Click` | Left press and release at the center or a supplied local point, then a `QEvent::Leave` to that same widget, so `Ui::AbstractButton::isOver()` is false on it and a `RoundButton` repaints its normal `textBg`. Send it the widget that accepts the press — an ignored press propagates to the ancestor that takes it and the leave does not, so a click aimed at a non-accepting child leaves that ancestor hovered. |
| `ClickWithPressOrigin` | `Click` whose press starts the pressed button's own ripple at the press point, as a real pointer press there would - for press-feedback frames. A plain `Click` cannot: `Ui::RippleButton::onStateChanged` starts the press ripple at `prepareRippleStartPosition()`, which every class computes as `mapFromGlobal(QCursor::pos())` on itself plus its own offset and guard, so the ripple starts where the owner's real mouse is - usually far outside the button, so frames about 40 and 120 ms after the press see nothing inside it - or the class's guard (`Ui::IconButton`'s ripple area) refuses that point and starts none. Inside one `Settle` action the helper reads `QCursor::pos()`, moves the target alone with `QWidget::move` by its floating `mapFromGlobal(QPointF(cursor))` minus the point (each axis rounded, because a displacement taken from the already rounded `QPoint` mapping lands a pixel off on a half-pixel high-DPI window), verifies that the target's own `mapFromGlobal(QCursor::pos())` now reads the point, delivers `Click`'s own press (local the point, global the target's real `mapToGlobal(point)`, read before the move) and moves the target back - all before `Settle` drains the postponed calls; the release, at the restored position, and the closing `QEvent::Leave` are `Click`'s. A non-native child's own geometry is part of its global mapping up to the first `QWindow`, so this works whatever a platform sources `QCursor::pos()` from, and the real cursor is only read, never set. Only the origin is substituted: the ripple's mask, colour, timing and paint and the class's own offset and guard stay the button's, and nothing is recomputed per class, so an `IconButton` pressed inside the button but outside its ripple area starts no ripple, as a pointer press there would. It returns a `PressOrigin` and neither Notes nor Fails. A refusal is named and sends nothing; the first four move nothing: `top-level-target` (moving a window moves a real OS window), `native-target` (the target, or a descendant inside its window, has `internalWinId()` or `Qt::WA_NativeWindow` - a separate window parented under the target is skipped, since it does not move with it - and `reason` names that widget), `disabled-target` (its mouse events would propagate to an ancestor), `point-outside` (no pointer press there reaches this widget) and `origin-unreachable` (after the move the target still does not map the cursor to the point - a transformed ancestor, a cursor that moved between the two reads, or a point coordinate of 0 on an exact half pixel; the target is moved back first). Judge the reading `PressOriginDetails` prints on one line: `refusal=none`, `originSeen` (the target's `mapFromGlobal(QCursor::pos())` right before the press, the expression the classes evaluate) equal to `point`, `restored=1`, `cursorStable=1` (`cursorBefore` equals `cursorAfter`), `pressAccepted=1`, `delivered=1` (press, release and leave each reached a live target), `geometryChangedByPress=0` (1 when the press handler moved or resized the target: a moved target keeps the handler's position, its `Qt::WA_Moved` and `Qt::WA_PendingMoveEvent` included, and a resized one keeps its new size and still gets its position back), `moveEventsWithheld` (2 for a visible displaced target) and `paintsWhileDisplaced=0` (1 or more only when the press handler itself calls `repaint()` or `processEvents()`). For the press only, an event filter installed on the target last withholds the target's own two `QEvent::Move` - so `RpWidget::geometryValue()` owners and earlier object filters never see the transient move, while a move the press handler makes passes - `Qt::WA_Moved` and `Qt::WA_PendingMoveEvent` are restored, and `Qt::WA_NoMousePropagation` is set, so a press the target does not accept stops at the target instead of reaching its parent. Caveats: during the press the target's and its descendants' own `pos()`, `geometry()`, `mapToGlobal()` and `mapFromGlobal()` read the displaced frame - that is the substitution - so a handler that places a popup from the target's `mapToGlobal()` on press places it at the real cursor; application-level event filters still see the two moves, an object filter the press handler installs on the target during the press runs before the facility's and sees the move back, and accessibility gets two `LocationChanged` notifications while a client is connected. Hover caveat: a harness press shows the ripple but no hover highlight, because the helper's closing leave cancels it before it paints - `IconButton`'s hover animation restarts from 1 to 0 at its still-zero value and `RoundButton`'s `isOver()` is false again before any paint - so press frames measure the ripple alone. Not for drags: `Drag` and `AppendQueuedStartDrag` keep the cursor-derived origin, because a drag start can reach a nested `QDrag::exec()` loop that must never run while a target is displaced. Confirm with `AppendPressOriginSelfTest` (`test_press_origin.h`). |
| `TypeText` | Key press/release per Unicode grapheme; surrogate pairs and joined emoji stay intact. Send it to the widget that owns the key route: for a `Ui::InputField` that is `rawTextEdit()`, never the wrapper. |
| `CommitText` | One `QInputMethodEvent` commit when insertion, custom emoji, or IME semantics matter more than physical keys. Same target rule as `TypeText`, and the fallback when key events left the field empty. |
| `DropText` | Hand a text to a `Ui::InputField` the way a paste does, with no system clipboard: a `QMimeData` carried by a `QDragEnterEvent` and then a `QDropEvent`, both sent to `rawTextEdit()->viewport()`, which ends in `canInsertFromMimeDataInner` / `insertFromMimeDataInner` and so in the field's `setMimeDataHook` (or its own insertion when the hook leaves it), the insertion Ctrl+V ends in. The drop is sent only when the field's own editor accepted the enter. An ignored enter climbs to the first enabled ancestor that accepts drops, such as a chat's file-drop area, so for the pair an application filter withholds every drag event Qt would hand any other widget, and the returned `TextDrop` lists them in `shielded`. It settles once after the pair, not after each event. A miss is a named `refusal`: `viewport-refuses-drops`, `enter-not-accepted` or `drop-not-accepted`. The text goes in at the caret and does not replace a selection. It does not exercise the clipboard read of a Paste accessory or of Ctrl+V. `InputField::dropEventInner` raises and activates the field's window afterwards (on macOS this asks for the app to come forward, which the window server may grant or refuse after a default background `test-run` launch; a never-shown top level is unaffected). `TextDropDetails` never prints the text. Confirm with `test_text_drop.h`. |
| `AppendClipboardRoundTrip` | A check whose subject is the system clipboard itself - a copy entry's result, or a Paste accessory's or Ctrl+V's own read - first needs a host whose clipboard reads back. One stage (`test_clipboard.h`) writes a sentinel carrying the caller's label (one token, no spaces) and reads it back, up to 10 times 300 ms apart across event-loop turns, logging every attempt as a `CLIPBOARD_ROUND_TRIP_ATTEMPT` row; read the returned `ClipboardRoundTrip` from a later stage: `succeededAt`, or a named refusal saying what the last read held (`read-empty`, `read-stale-sentinel`, `read-foreign`). Judge a product copy afterwards with `ReadClipboard(expected)`, one classified reading (`expected` / `empty` / `harness-sentinel` / `foreign`) with its `owns` reading, never a wait. Text the harness did not write is never printed, measured or hashed, and the owner's clipboard is overwritten and never saved or restored. For what a field does with pasted text, `DropText` stays the route. Confirm with `AppendClipboardRoundTripSelfTest`. |
| `PressKey` | Escape, Return, arrows, shortcuts, and other key behavior. Send to the widget that owns the event route, often the raw editor (`Ui::InputField::rawTextEdit()`) or the top `Ui::BoxLayerWidget`, not a wrapper. |
| `Drag` | Left-button press, interpolated moves with `buttons()==LeftButton`, and release, then the same `QEvent::Leave` as `Click`. |
| `AppendQueuedStartDrag` | A drag whose start the target queues - both message lists, the shared-media list and the chats list's drag onto a folder queue `performDrag()` with `InvokeQueued`, `Ui::FlatLabel`'s link and selection drag queues `executeDrag()` the same way, and `Drag` releases before that call runs (Failure diagnosis). One stage (`test_queued_drag.h`) resolves the target and its two points when it begins, delivers the press and the interpolated moves exactly as `Drag` does, posts an `InvokeQueued` marker to the target and a `crl::on_main` marker, yields to the event loop until both ran - so every normal-priority queued call and every `crl::on_main` call posted on the main thread while the press and moves were handled has run - and only then delivers the release and the same `QEvent::Leave`. It is a stage, not an event helper. Its wait is bounded (`kQueuedStartDragBound`, 3000 ms, inside the stage's timeout), and a miss is a named refusal on the returned `QueuedStartDrag`, read from a later stage: `no-target`, `target-destroyed` (no release is sent) or `bound-expired` (the release and the leave are still sent, so nothing stays pressed). A call those calls post in turn, a timer and a network answer still need a condition wait. It intercepts no platform drag, and every queued start that reaches `QDrag::exec()` starts a real drag that drops at the real cursor: before launching a scenario, hook the function whose `QDrag::exec()` its queued start reaches - `Window::MainWindow::launchDrag()` or `Ui::FlatLabel::executeDrag()` - as its first statement, returning before `new QDrag`, as the Failure diagnosis row and the header say. `Drag` itself is unchanged. Confirm with `AppendQueuedStartDragSelfTest`. |
| `Wheel` | A real wheel event at the named widget's local point (`QPoint(0, -120)` is one conventional downward step). A stack-built `QWheelEvent` is not spontaneous, so Qt 6 does not propagate it; the helper re-sends up `parentWidget()` until a widget handles and accepts — the scroll area's viewport, never the `QAbstractScrollArea` itself — honouring `isWindow()` and `Qt::WA_NoMousePropagation`. Returns `WheelDelivery`; a miss is a named `refusal`, not a silent no-op. Confirm with `test_wheel.h`. |
| `OpenContextMenu` | A context menu (`test_menu.h`): deliver a `QContextMenuEvent` (Mouse by default, Keyboard allowed) at a point in the receiving widget's coordinates and, in the same call, read the `Ui::PopupMenu` it built - its ordered non-separator action texts and its lifecycle at build and after the postponed calls settled - as a `ContextMenuReading`, or a named refusal: `no-menu`, `several-menus`, `receiver-gone`. The snapshot spans the whole application, so a menu the handler parents to another widget is found, and the reading holds the menu as a `QPointer`, so it stays printable after the menu dies. The helper does not hover: hover first when the product's handler reads the hovered link. On a chat list - `HistoryInner` or `HistoryView::ListWidget` - read with `OpenChatListContextMenu` (the next row) instead: in an inactive window this helper alone may read a menu the list built for another message. `TriggerContextMenuAction(reading, text)` triggers the one entry with exactly that text and delivers its queued `Ui::Menu::CreateAction` callback before it returns, without hiding the menu; it refuses `menu-gone`, `no-such-action`, `several-actions`, `action-disabled` and `submenu-entry`. Neither grabs pixels: a frame stays with `CapturePopupMenu` while the menu is still open. Never poll for a menu on later turns, and count a `no-menu` reading only beside a positive control the same helper built in the same widget (Failure diagnosis). Confirm with `AppendContextMenuReaderSelfTest`. |
| `OpenChatListContextMenu` | A context menu on a chat list (`test_chat_menu.h`), attributed to its target message: `OpenChatListContextMenu(list, target, point)` takes a `HistoryInner` or `HistoryView::ListWidget`, the target `HistoryItem` and a point in the list's coordinates, and returns a `ChatListMenuReading` that is `built()` only when the menu was built while the list resolved exactly that message - otherwise a named refusal, never a reading whose texts merely look valid. Both lists resolve the point in `mouseActionUpdate()`, which returns at once while `!_mouseActive && !window()->isActiveWindow()` (on `HistoryInner` also while `hasPendingResizedItems()`), so in an inactive window `OpenContextMenu` alone reads a menu built for whatever message the list resolved last (Failure diagnosis). The reader activates no window - activation can mark the shown messages read - and opens the gate's other operand for its own call only: a `QEnterEvent` to the list inside `Settle` (`enterEventHook` sets `_mouseActive` and resolves the real `QCursor::pos()`, which is never attributed), then `OpenContextMenu` with an at-build observer that reads the list's resolution inside the delivery, after `showContextMenu`'s own `mouseActionUpdate(e->globalPos())` and before any postponed call drained, then always a `QEvent::Leave` while the list survives, after which `_mouseActive` must read false. Each `ChatListResolution` - before the enter, after it, at build and after the leave - holds the resolved view's message and identity (`Element::Moused()` / `_overElement`), `Element::Hovered()` and `Element::HoveredLink()` on `HistoryInner`, the exact item (`_dragStateItem` / `_overItemExact`), the message the menu takes its entries from, `mouseActive`, `activeWindow`, `pendingResize` and the gate, read through file-private read-only accessors. A reading is accepted only when the gate was open at delivery, at build the resolved view is the list's own view of the target and every one of those candidates names the target or nothing, and the fresh menu is the list's own `_menu`. Refused before anything is sent: `no-list`, `not-a-chat-list` (also a `ListWidget` with no window controller, the chat preview's), `no-target`, `foreign-target`, `outside-viewport`, `pending-resize` (in a window that gets no paints, the minimized one `KeepMainWindowNotMarkingRead` keeps, nothing recounts that layout until the next `Data::Session::sendHistoryChangeNotifications()`, and `History::addNewItem` leaves one waiting: process it first, outside any `until` and only while the window cannot mark read, with `notifyHistoryChangeDelayed(history)` and `sendHistoryChangeNotifications()`, which `HistoryWidget::handleHistoryChange` answers by recounting it, as the self-test does) and `ambiguous-target` (an album, or a selection already in the list); after the delivery: `list-gone`, `target-gone`, `menu-refused`, `unresolved`, `wrong-target` (the resolved message is in the details), `ambiguous-target`, `foreign-menu` and `mouse-still-active`. A refusal after the delivery hides the one menu this call built and clears `reading.menu.menu`, so `TriggerContextMenuAction(reading.menu, text)` refuses `menu-gone`; trigger only from a `built()` reading, in the same turn, where "Select" selects exactly the target. After the leave `Element::Moused()` and `_overElement` keep the last resolved view: that is not hover, and nothing claims to clear them. `ChatListMenuDetails` prints the reading; neither function Notes or Fails. Confirm with `AppendChatListMenuSelfTest`. |
| `Settle` | Wrap programmatic mutations such as `InputField::setText`; drains postponed fixups after the action. |
| `ForceWindowActive` | Inject window activation through the QPA seam before any focus-routed action, and re-assert it on every poll of a bounded wait. On a locked console, or on macOS after a default `test-run` launch until something activates the client, the window is never active in Qt's sense, so `setFocus()` never reaches `QApplication::focusWidget()` and every `hasFocus()`/`isActiveWindow()` branch silently reads false. |
| `KeepMainWindowNotMarkingRead` | Minimize the primary window and poll until `MainWindow::markingAsRead()` is false with the window minimized and not hidden, or until `kNotMarkingReadBound` returns a named refusal. Call it again after `Window::Controller::activate()` or any `showHistory` that is not `anim::activation::background`, which clear the minimized bit. A control that cannot read true within `kNotMarkingReadBound` - a still-unexposed window, or a locked console that keeps it false - must not be reported as success; the self-test exposes the primary window itself with `KeepWindowExposed` from its control until its restore stage. Confirm with `test_marking_read.h`. |
| `KeepWindowExposed` | Stack a covered top-level window above ordinary windows - on macOS `Qt::WindowStaysOnTopHint` on its `QWindow`, never `QWidget::setWindowFlag` - without activating the app, changing the focus window or taking focus, so a window another application covers is exposed and paints again. It never waits: wait for `ReadWindowExposure(...).exposed` (`isExposed()`) in a pure `until`. Undo with `RestoreWindowExposure` before teardown. It refuses a minimized window by name (stacking cannot expose it), so it cannot undo `KeepMainWindowNotMarkingRead`; off macOS it is a no-op that still returns its reading. Confirm with `test_window_exposure.h`. |
| `ClickBoxButton` | Click a box's shell footer button by label. `addButton` re-parents the button onto the `Ui::BoxLayerWidget`, so a content-rooted `FindAll<Ui::RoundButton>` never sees it; this roots the search at `Test::PaintingLayerRoot(box)`, keeps only its direct children, and refuses by name when nothing matches. |

The harness has no pointer. No helper leaves a hover behind, and every
measurement is taken as if no cursor exists — assert a hovered state by
setting it deliberately, not by relying on a click's residue. The one
helper that enters a widget, `OpenChatListContextMenu`, does so only for
its own call: its `QEnterEvent` sets the chat list's `_mouseActive` (the
list's `enterEventHook` also resolves the real `QCursor::pos()`, which the
reader never attributes), and its closing `QEvent::Leave` clears
`_mouseActive`, the active click handler and, on `HistoryInner`,
`Element::Hovered()` again, while `Element::Moused()` and `_overElement`
keep the last resolved view, which is not hover. The one helper that
lends a press a pointer's origin, `ClickWithPressOrigin`, does so without
a pointer: for that one synchronous press it moves only the pressed
widget, so the widget's own `mapFromGlobal(QCursor::pos())` reads the
press point, it never moves the real cursor, and it ends with `Click`'s
leave, so it leaves no hover behind either. A press starts a
`RippleButton` ripple at the origin `prepareRippleStartPosition()`
derives from the real `QCursor::pos()` - with `Click`, `Drag` and
`AppendQueuedStartDrag` where the owner's mouse is, usually outside the
widget, or none when the class's guard refuses that point; with
`ClickWithPressOrigin` at the press point - and the ripple decays over
its style's `showDuration + hideDuration`; finish it with
`finishAnimating()` or wait it out before measuring a fill.

Timers, queued invokes, animations, network callbacks, and reactive streams
still require condition waits. `Settle` is not a replacement for readiness.

## Related guidance

[Launch behavior](../launch.md); [chat-list resolution](../chat-lists.md); [failure diagnosis](../diagnostics.md).

---

### Telegram SourceFiles Test Docs Helpers Test Window Exposure (`Telegram/SourceFiles/test/docs/helpers/test_window_exposure.md`)

# test_window_exposure

[Harness guide](../../README.md) · [Helper index](README.md)

Type: Harness API (may include self-tests).

Source contract: [test_window_exposure.h](../../test_window_exposure.h).

## Use when

Keep covered windows painting without activation. Read the contract below and the source header before use.

## Contract and usage

Keep a top-level window that another application covers exposed, without
activating anything. `ReadWindowExposure(widget)` is the pure reading of
`widget->window()`'s `QWindow`, legal in an `until`: the target's identity and
frame, `exposed` (`isExposed()`), `active` (`isActive()`), `stayOnTop`
(`Qt::WindowStaysOnTopHint` in its flags), `applied`, the application state,
the focus window, and a refusal. `KeepWindowExposed(widget)` returns that
reading taken before its call - so `exposed`, `active` and `stayOnTop` are the
values before it - and on macOS then sets `Qt::WindowStaysOnTopHint` on the
`QWindow` (`NSModalPanelWindowLevel`; no ordering, key-window or activation
call, the same call `Calls::Window::setPinnedOnTop` makes), with `applied=1`
only when this call set the hint, so a window that already carries it gets
nothing applied. It never waits: call it, wait for `isExposed()` in a pure
`until`, then ask for the repaint you need - a re-exposed window with nothing
dirty is only flushed, not painted. Named refusals, returned and never logged,
in order: `null-widget`, `minimized` (stacking cannot expose a minimized
window, so the helper and `KeepMainWindowNotMarkingRead` exclude each other)
and `hidden` (no `QWindow` handle - never retargeted at another window -,
`isHidden()`, or a `QWindow` that is not visible, such as under
`Qt::WA_DontShowOnScreen`). `RestoreWindowExposure(reading)` clears the hint
only when that reading applied it, so it leaves a product's own hint alone; it
is idempotent and safe after the window is gone. `WindowExposureText` prints
one `window exposure: target=... exposed= active= stayOnTop= applied=
stacking= appState= focusWindow=` row, with ` - <refusal>` when refused. Off
macOS the helper is a documented no-op that still returns its reading
(`applied=0 stacking=none`): Windows and X11 never turn a covered window
unexposed and the hint would restyle or recreate the native frame there, and
Wayland gives clients no stacking control. Exposing the primary window can
make it mark messages read (see the failure row). Own self-test,
`AppendKeepWindowExposedSelfTest`, needing no session, network or account,
over two legs - an ordinary parentless top-level window and a
`Ui::SeparatePanel` with the hint cleared before show - each shown without
activation (`Qt::WA_ShowWithoutActivating` before the native window exists,
never `raise()` or `showAndActivate()`) under a harness-owned opaque titled
cover: showing them changes neither the focus window nor the application
state; a settle stage puts the cover up only once the target has been shown
for 2000 ms, because a freshly shown window gets one more layer display about
half a second after it is shown and Qt counts any layer display as an expose,
so a target covered sooner reads `isExposed()` true while the window server
still reports it occluded; the control reads the target unexposed under the
cover within 3000 ms or writes a named `N/A`, never a pass, and the leg's
deciding stages are `N/A` with it; the premise shows the covered target, which
nothing asks to repaint, staying unexposed with no paint for 500 ms; the
helper exposes it within 3000 ms with no repaint requested, `isActive()`, the
focus window and the application state unchanged, and the widget still shown
with its own flags unchanged; only then does the self-test ask for one
`update()`, which must paint within 3000 ms; the undo - a second call applies
nothing and its undo leaves the hint set, the first reading's undo clears it
and activates nothing; the teardown destroys the target and the cover
(`before=2 after=0`), and `Runner::onFinish` releases them on every path; and
a refusal stage that returns `null-widget`, `hidden` and `minimized` without a
logged failure. Off macOS, or on a console `Test::ReadConsoleLock()` reads
locked - on macOS the session's `CGSSessionScreenIsLocked`, so a console
locked before the launch too, where a window shown behind the lock screen
keeps a stale `isExposed()` - every leg stage is a named `N/A`; the lock's
`N/A` ends with the `CONSOLE_LOCK` row it read (`the console is locked, so
occlusion and exposure cannot be decided: CONSOLE_LOCK: state=locked ...`),
and the control's `screenLocked=` is the same reading. After a default
background `test-run` launch a target that another application's window covers
from the moment it is shown cannot be turned unexposed by the harness cover
(see the exposure failure row), so that leg is the named `N/A`, never a pass.

---

--- METRICS ---
- Files Extracted: 74
- Estimated Token Budget: ~121490 tokens
- Canonical Reference: https://codewiki.google/github.com/telegramdesktop/tdesktop
