ZaStoGram_desktop/Telegram/SourceFiles/test/test_menu.h
John Preston e3d790a877 [ai] Share the harness console-lock reading with macOS
Task: 2026/10/08/share-a-console-lock-reading-that-sees-a-lock-taken-before-launch
2026-10-09 23:29:29 +04:00

424 lines
21 KiB
C++

/*
This file is part of Telegram Desktop,
the official desktop application for the Telegram messaging service.
For license and copyright information please follow this link:
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#pragma once
#include "test/test_runner.h"
#include <QtCore/QPointer>
#include <QtCore/QRect>
#include <QtCore/QString>
#include <QtCore/QStringList>
#include <QtGui/QContextMenuEvent>
namespace Test {
// A Ui::PopupMenu cannot be waited on through the visibility of the
// Ui::Menu it wraps. PopupMenu::startShowAnimation() ends with
// hideChildren() (ui/widgets/popup_menu.cpp), and the only path that shows
// them again during a normal open is the Ui::PostponeCall the final
// paintEvent queues once the show animation has drawn its last frame.
// showAnimationCallback() merely calls update(), and the other two
// showChildren() calls sit inside prepareCache()'s grab-and-restore and the
// opacity path that re-shows a menu after a hide
// (opacityAnimationCallback()). So the inner menu is hidden for the whole
// show animation and comes back only from a side effect no -testagent run
// is guaranteed to reach. A readiness over menu()->isVisible() then waits
// until focusOutEvent -> hideMenu() -> hideEvent() -> deleteLater() takes
// the popup away underneath it, which is how runs 4 and 5 of
// 2026/08/28/complete-server-history-details-hash-and-paging ended.
//
// The opposite mistake costs a run just as surely: a one-shot grab taken in
// the turn that called popup() lands on a show-animation frame with almost
// nothing painted on it, which is run 2's blank p1e_menu_peer_nohash.
//
// So the readiness here carries content identity only - the widget is a
// Ui::PopupMenu, it is visible, its geometry is non-empty and it carries
// actions - and the shared PreparedWidgetCapture decides when the frame is
// good, because refusing a blank frame is exactly the check a menu capture
// needs and it already exists. showingContent is read and printed in every
// details line so a log reader sees it, and it is never part of the gate.
//
// Two more facts a caller gets wrong otherwise. Test::PaintingLayerRoot()
// must not be used on a popup: PopupMenu::init() sets
// Qt::WA_NoSystemBackground, so the harness's blank-root refusal never
// applies to it, and the popup is its own top-level window
// (Qt::FramelessWindowHint | Qt::BypassWindowManagerHint | Qt::Popup |
// Qt::NoDropShadowWindowHint), so that walk refuses on its first hop. And
// the hidden-children premise above is platform-dependent rather than
// universal: init() takes _useTransparency from
// Platform::TranslucentWindowsSupported() and startShowAnimation() returns
// before hideChildren() when it is false. useTransparency() is public for
// exactly that reason, every reading below reports it, and the self-test
// asserts it as a named fixture gate instead of passing vacuously on a host
// without translucent windows - on Windows it is an inline return true
// (ui/platform/win/ui_utility_win.h).
//
// This is its own module rather than part of test_capture.h because
// CapturePopupMenu appends Runner stages, and test_runner.cpp already
// includes test_capture.h: the reverse include would invert the harness's
// layering and make its most-included module runner-aware. The three pure
// readings below would fit test_capture.h's QWidget-only vocabulary
// unchanged; the stage appender is the whole of what decides this.
//
// The context-menu reader at the end of this header lives here too: it
// composes this module's ReadPopupMenu and PopupMenuReady with
// test_widgets.h's Settle, and its self-test with FindAll, and
// test_widgets.h does not include this header, so no cycle forms.
struct PopupMenuReading {
bool isMenu = false;
bool visible = false;
bool transparent = false;
bool showingContent = false;
int width = 0;
int height = 0;
int actions = 0;
QString identity;
};
// One reading, taken once, so a snapshot can be asserted stages after the
// turn it was taken in and so a pass and a refusal print the same fields.
[[nodiscard]] PopupMenuReading ReadPopupMenu(QWidget *widget);
// Content identity only, and never menu()->isVisible(): the popup exists,
// is visible, has non-empty geometry and carries actions.
[[nodiscard]] bool PopupMenuReady(QWidget *widget);
// The same reading as text, for a stage's timeoutDetails and for a Check's
// details. Names a null and a widget that is not a Ui::PopupMenu, and
// otherwise carries showingContent together with the reason it is reported
// and never required.
[[nodiscard]] QString PopupMenuDetails(QWidget *widget);
// Captures an open Ui::PopupMenu, or one that |open| opens. |open| runs in
// its own stage, and only when the resolver does not already answer a ready
// menu; the helper never calls popup() itself, because popup() on an empty
// menu hides and deleteLater()s it (popup_menu.cpp) and only the caller
// knows the position and the way the product opens its menu.
// |skipReason|, when given, becomes the Stage::skipReason of both stages
// this appends - the opener and the capture - read when each begins; left
// empty the helper behaves exactly as before. A campaign gates a
// popup-capture leg on a locked Windows console with
// [] { return Test::ConsoleLockGate(Test::ReadConsoleLock()); }
// (test_console_lock.h).
void CapturePopupMenu(
not_null<Runner*> runner,
const QString &name,
Fn<QWidget*()> resolve,
Fn<void()> open = {},
Fn<void(QWidget*, const QImage &)> inspect = {},
crl::time timeout = kDefaultStageTimeout,
Fn<QString()> skipReason = {});
// This helper measuring itself, in six stages. It opens a real
// Ui::PopupMenu of its own over the primary window and, in the turn that
// opened it, shows the two predicates disagreeing: the content-identity
// readiness accepts while the inner menu is still hidden and a one-shot
// prepared grab still refuses. It then captures through both branches of
// CapturePopupMenu - the already-open one and, after closing the menu, the
// one that opens it - and asserts the refusal texts of a null, a widget
// that is no menu, an empty menu and a popup handed to the painting-layer
// root resolver.
//
// It needs no session, no chats list, no network and no account fixture.
// The only thing it asks of the process is a primary window to parent the
// menus to, and a missing one is reported as a named fixture gate instead
// of crashing. It appends its own teardown last, and it emits no deliberate
// failure: every refusal it demonstrates is observed through a value that
// logs nothing - PopupMenuReady returning false, PopupMenuDetails's text,
// PreparedWidgetCapture::prepare()'s pendingReason() - and is asserted as a
// passing Check whose details carry the refused reading verbatim. Its
// before-leg, the wedged stage this helper removes, is produced by gating
// on menu()->isVisible() and re-running the identical scenario, never by a
// stage that fails on purpose.
//
// A locked Windows console. The application is deactivated there and Qt
// closes the shown menu before the next turn: Attempt 1 Runs 1 and 2 of
// 2026/10/07/add-a-same-turn-context-menu-reader-and-a-retried-clipboard-round-trip-to-the-test-harness
// (work/test.md) timed out on "target is not visible" right after the open
// stage passed. So the four stages that need the menu open across turns -
// the popup_menu_open capture, close, and the popup_menu_reopened opener
// and capture - are TEST_RESULT: N/A by a skipReason that names the lock
// and prints the Test::ReadConsoleLock() reading (test_console_lock.h). It
// is read when the open stage pops the menu and again when each of those
// stages begins, and kept once it reads locked: a menu popped or held while
// locked is already closed. The open stage (decided from the opening turn),
// refusal text and the teardown still run and decide, the scenario
// continues past the self-test, and the open stage Notes the CONSOLE_LOCK
// row it read. The translucency premise stays a named hard fixture gate:
// Check, while the lock is a skip: a locked console is a scheduling
// condition - rerun on an unlocked console, where the same host certifies
// the capture (Run 3) - and a host without translucent windows can never
// certify that premise at all. Nothing is gated off Windows: on macOS a
// packed launch with CGSSessionScreenIsLocked true ran this self-test with
// 0 FAIL (2026/10/06/add-a-post-paint-capture-sampler-to-the-harness,
// work/test.md, Test 2), so Test::ConsoleLockGate answers only in a
// Windows build: on macOS the probe reads the session's lock and the open
// stage prints it, and nothing is gated.
void AppendPopupMenuCaptureSelfTest(not_null<Runner*> runner);
// A context menu, read in the turn that built it.
//
// Why it exists. Run 1 of
// 2026/10/07/show-url-tooltips-and-copy-link-on-rich-message-url-buttons
// (work/test.md, Tests 2-4) delivered a QContextMenuEvent, then polled on
// later event-loop turns for a visible, ready Ui::PopupMenu and read
// popup=0 for every target - the controls included, an ordinary keyboard
// URL button and a plain paragraph. Yet every product handler it drove
// 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, ...) and
// MarkdownDocumentWidget::contextMenuEvent. 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. Run 2 decided every leg by reading in
// the building turn instead (built=1 fresh=1, and ready=1 at build), and
// OpenContextMenu is that reading, shared.
//
// Delivery. The reader snapshots every live Ui::PopupMenu in the whole
// application (QApplication::allWidgets(), not only the receiver's
// subtree), so a handler that parents its menu to another widget is still
// read, and so is an ancestor that takes the propagated event -
// QApplication::notify re-sends an unaccepted ContextMenu up the parent
// chain until a window or Qt::WA_NoMousePropagation stops it. It then
// sends the QContextMenuEvent inside Test::Settle, takes the at-build
// reading inside the settle action right after sendEvent returns, before
// any postponed call runs, and the after-settle reading right after Settle
// drained them. No event-loop turn runs in between: no processEvents, no
// nested loop. One Windows caveat: PopupMenu::showPrepared calls
// ForceFullRepaintSync, which sends a synchronous QEvent::UpdateRequest,
// and Core::Sandbox::notify drains the crl::on_main queue on every
// UpdateRequest, so crl::on_main work may run inside the delivery. Qt
// posted events and timers cannot.
//
// What is read. "New" means not alive in the snapshot; the snapshot holds
// QPointers, so a menu destroyed and another allocated at its address
// never reads as the old one. A new menu whose parent is a Ui::PopupMenu
// is a submenu (PopupMenu::ensureSubmenu and addAction(text, submenu) both
// parent it to its menu) and is not counted. Exactly one new menu is the
// reading; none is the no-menu refusal, and more than one is the
// several-menus refusal, which names every candidate and takes none. A
// menu that exists after the event but is hidden or empty is reported,
// not refused - its atBuild says so; the products destroy an empty menu
// synchronously (_menu = nullptr), which correctly reads no-menu. A
// handler that re-pops a menu it kept from an earlier event builds nothing
// new, so it reads no-menu with reshown above zero. The texts are every
// non-separator entry in order, an entry whose QAction has no text kept in
// place as an empty string so positions match, and |actions| counts the
// separators too. A trailing separator is already gone from a popped menu:
// popup() removes it (prepareGeometryFor -> Menu::clearLastSeparator).
//
// Lifetime. The receiver, the menu and the menu's parent are held as
// QPointer<QWidget>, and everything else is a value, so a reading stays
// safe to keep, copy and print after its menu dies: the pointers read null
// and the identities, texts, counts and lifecycles stay as recorded. Print
// the recorded identity, never WidgetDescription of a pointer that may be
// gone.
//
// Reason. Mouse by default; Keyboard passes through, and what it means is
// the product's business - MarkdownDocumentWidget positions a keyboard
// menu at QCursor::pos(), for instance. The helper does not hover: a
// caller whose handler reads the hovered link hovers first.
//
// No pixels. A grab in the building turn lands on a blank show-animation
// frame (the comment at the top of this file), so the reader takes none.
// A frame stays with CapturePopupMenu, as a supplementary leg taken while
// the menu is still open.
//
// Positive control. A no-menu reading counts as a product result only
// beside a positive control that the same helper built in the same
// widget; on its own it says nothing about the product.
//
// Trigger. Every Ui::Menu entry a user activates ends in QAction::trigger()
// (PopupMenu::handleTriggered, ui/widgets/popup_menu.cpp), and
// Ui::Menu::CreateAction connects QAction::triggered with
// Qt::QueuedConnection and the action itself as the context
// (ui/widgets/menu/menu_common.cpp), so an entry's callback is a
// QMetaCallEvent posted to its action. TriggerContextMenuAction finds the
// one non-separator entry whose text equals |text| exactly on the
// still-live menu and, inside Test::Settle, calls trigger() and then
// QCoreApplication::sendPostedEvents(action, QEvent::MetaCall), which
// delivers exactly the calls posted to that action and leaves every other
// receiver's queued call pending. The callback has therefore run when it
// returns, before any event-loop turn. It does not hide the menu - the
// click path's hideMenu() is handleTriggered's own - and |after| says
// whether the menu is still open. Work the callback itself queues through
// crl::on_main, InvokeQueued or a timer still needs a bounded wait.
//
// Trigger refusals, each with nothing triggered: menu-gone (the reading
// holds no live menu), no-such-action (its reason lists the texts the
// menu holds), several-actions (an ambiguous text is refused, never
// resolved by taking the first match), action-disabled (a user cannot
// trigger a disabled entry, and Qt 5's QAction::activate emits triggered
// for one while Qt 6's ignores it, so the helper refuses it on both) and
// submenu-entry (a user's click opens the submenu instead of running the
// entry's callback). The last is decided by action->menu(), which covers
// both lib_ui routes that give an entry a submenu through a QMenu:
// MenuCallback's .fillSubmenu, which sets a dummy child QMenu, and
// PopupMenu::addAction(text, submenu). An entry given a submenu by a bare
// ensureSubmenu with no QMenu (ui/controls/who_reacted_context_action.cpp,
// for example) cannot be told apart through public API and is triggered
// like an ordinary entry.
//
// Neither function Notes nor Fails; the caller judges the reading, as
// with DropText.
enum class ContextMenuRefusal {
None,
ReceiverGone,
NoMenu,
SeveralMenus,
};
// One lifecycle reading of the menu a ContextMenuReading holds - at build,
// after the postponed calls settled, after a trigger or on a caller's own
// later poll - so every one of them prints alike. |activePopup| is "self",
// "other" or "none" (QApplication::activePopupWidget()); |appState| is
// "active", "inactive", "hidden" or "suspended"; |activeWindow| is
// QApplication::activeWindow()'s identity or "none"; |focusWindow| is
// "menu", "receiver-window", "other" or "none"
// (QGuiApplication::focusWindow() against the menu's own window handle and
// the receiver's window's). The application fields are read even when the
// menu is gone, and the menu fields then stay false and empty.
struct ContextMenuLifecycle {
QRect geometry;
QString activePopup;
QString appState;
QString activeWindow;
QString focusWindow;
int actions = 0;
bool alive = false;
bool visible = false;
bool hidden = false;
bool ready = false;
};
// |receiver|, |identity| and |parentIdentity| are WidgetDescription values
// recorded while their widgets lived ("none" is printed for an empty one);
// |freshMenus| names every new candidate, so a several-menus refusal shows
// them all; |reason| is the refusal's text and stays empty when built().
struct ContextMenuReading {
QPointer<QWidget> receiverWidget;
QPointer<QWidget> menu;
QPointer<QWidget> parent;
QString receiver;
QString identity;
QString parentIdentity;
QStringList freshMenus;
QStringList texts;
ContextMenuLifecycle atBuild;
ContextMenuLifecycle afterSettle;
QString reason;
QPoint local;
QPoint global;
QContextMenuEvent::Reason eventReason = QContextMenuEvent::Mouse;
ContextMenuRefusal refusal = ContextMenuRefusal::None;
int menusBefore = 0;
int menusAfter = 0;
int fresh = 0;
int reshown = 0;
int actions = 0;
int separators = 0;
bool receiverEnabled = false;
bool receiverVisible = false;
bool delivered = false;
bool accepted = false;
bool receiverAlive = false;
bool parentIsReceiver = false;
[[nodiscard]] bool built() const {
return (refusal == ContextMenuRefusal::None);
}
};
[[nodiscard]] QString ContextMenuRefusalName(ContextMenuRefusal refusal);
// |point| is in |receiver|'s coordinates. A null |receiver| - a destroyed
// QPointer reads as one - is the receiver-gone refusal and nothing is
// sent; a receiver destroyed by its own handler is the same refusal, with
// the counts it reached kept.
[[nodiscard]] ContextMenuReading OpenContextMenu(
QWidget *receiver,
QPoint point,
QContextMenuEvent::Reason reason = QContextMenuEvent::Mouse);
// Pure: no grab, no mutation, safe on a reading whose menu is gone. A
// caller polls it on later turns to watch the menu close, if it must.
[[nodiscard]] ContextMenuLifecycle ReadContextMenuLifecycle(
const ContextMenuReading &reading);
[[nodiscard]] QString ContextMenuLifecycleText(
const ContextMenuLifecycle &lifecycle);
// The one formatter for a reading, for a Check's details and a Note. It
// prints recorded values only.
[[nodiscard]] QString ContextMenuDetails(const ContextMenuReading &reading);
enum class MenuTriggerRefusal {
None,
MenuGone,
NoSuchAction,
SeveralActions,
ActionDisabled,
SubmenuEntry,
};
// |matches| is recorded on every path that looked at the menu; |after| is
// read on every path before the helper returns.
struct MenuTrigger {
QString text;
ContextMenuLifecycle after;
QString reason;
MenuTriggerRefusal refusal = MenuTriggerRefusal::None;
int matches = 0;
bool triggered = false;
bool actionAlive = false;
};
[[nodiscard]] QString MenuTriggerRefusalName(MenuTriggerRefusal refusal);
[[nodiscard]] MenuTrigger TriggerContextMenuAction(
const ContextMenuReading &reading,
const QString &text);
[[nodiscard]] QString MenuTriggerDetails(const MenuTrigger &trigger);
// The reader measuring itself, in seven stages, over hidden harness widgets
// parented to the primary window whose handlers build their Ui::PopupMenus
// the way the products do - so every popup is its own window with a live
// parent, and none is ever popped without one.
// 1. A handler that builds and pops a menu in the receiving widget:
// exactly one new menu, its texts in order without the separator, the
// receiver as its parent, and at build a shown, ready popup that is
// the application's active popup.
// 2. The same handler for a Keyboard-reason event: the reason reaches the
// handler, and the first reading, whose menu that handler destroyed,
// still prints its recorded identity and texts.
// 3. A handler that parents its menu to another widget: the
// application-wide snapshot finds it, while a search rooted at the
// receiving widget finds nothing.
// 4. A handler that builds and discards an empty menu: no-menu, beside
// the positive controls of stages 1 and 2 in the same widget.
// 5. Refusals: several-menus for a handler that builds two, receiver-gone
// for a null receiver, a submenu built the product way not counted as
// a second menu, and several-actions and submenu-entry from the
// trigger.
// 6. Trigger: First's Ui::Menu::CreateAction callback has run when
// TriggerContextMenuAction returns while a marker queued just before
// it has not, the marker runs on a later turn, and a raw trigger() of
// a standalone CreateAction action stays queued until then and is not
// delivered by the helper (the premise); then no-such-action,
// action-disabled and, once the fixture dropped the menu, menu-gone.
// 7. Teardown.
//
// It needs no session, no chats list, no network and no account fixture.
// The only thing it asks of the process is a primary window to parent its
// widgets to: a missing one fails stage 1's named fixture-gate Check, and
// every later stage but the teardown is N/A by that gate. It releases its
// fixture from the last stage and again from Runner::onFinish (idempotent),
// so a timed-out stage leaves no popup or widget behind. It emits no
// deliberate failure: every refusal it demonstrates is a returned value,
// asserted as a passing Check whose details print the reading.
void AppendContextMenuReaderSelfTest(not_null<Runner*> runner);
} // namespace Test