[ai] Keep test-flaw recovery inside task runs

This commit is contained in:
John Preston
2026-08-16 14:02:42 +04:00
parent f0e1873551
commit 0cd2b4fcae
11 changed files with 432 additions and 33 deletions

View File

@@ -34,7 +34,7 @@ adapter point; every other rule here still applies.
absence is never by itself a planning, implementation, or test blocker. The spec and its cited
repository/baseline sources are one side of test design; the implementation diff is the other.
- Config: `BUILD` (build command), `EXE` (built binary path), `MAX_ATTEMPTS` (default 4),
`MAX_TEST_RUNS` (default 12). The test account lives in `out/Debug/` as the portable-data folders
`MAX_TEST_RUNS` (default 12 per test campaign). The test account lives in `out/Debug/` as the portable-data folders
described under "Test account" below;
the wrapper has already confirmed the golden one exists (launch gate). All paths are relative to
the current checkout — no worktrees are created; the run happens in whatever repository slot it
@@ -58,9 +58,14 @@ TEST_AUTHOR -> RUN -> ASSESS (adversarial — see "Assessing"):
(--3way, else re-author); RUN. attempt++
UNRECOVERABLE -> delete the test binary; return BLOCKED up with the reason. Stop.
attempt > MAX -> delete the test binary; return BLOCKED up with test.md + "improve" notes. Stop.
campaign runs == MAX_TEST_RUNS with TEST_FLAW remaining
-> CAP ASSESS; preserve every pass, isolate only unmet checks, and either
begin a focused recovery campaign or prove recovery exhaustion. The cap
alone never returns BLOCKED.
On every TERMINAL exit (APPROVED / BLOCKED / UNRECOVERABLE / cap) "delete the test binary" means the
step in "Leave no test binary behind" below.
On every TERMINAL exit (APPROVED / BLOCKED / UNRECOVERABLE /
implementation-attempt cap) "delete the test binary" means the step in "Leave
no test binary behind" below. A test-campaign cap is not terminal.
```
Repeated-failure rule: a repeated **failure signature is a demand for a more direct test**, not a
@@ -97,9 +102,30 @@ After the same signature repeats, use a fresh test-recovery leaf and explicitly
approach in its prompt. Early `BLOCKED(test)` is allowed only when a fresh recovery assessment
records why every applicable unused strategy above is unsafe, unavailable, or would bypass the
changed code, and the performer confirms that record. Otherwise continue until approval,
implementation diagnosis, or `MAX_TEST_RUNS`. The macOS cached-language startup signature still
implementation diagnosis, or a campaign-cap assessment. The macOS cached-language startup signature still
gets the one-time clean-rebuild recovery under "Crashes & assertions" before entering this ladder.
`MAX_TEST_RUNS` limits one campaign, not the task's lifetime. At the cap, use a
fresh assessor over `test.md`, the saved overlay, the run artifacts, and the
current unmet-check list. It must choose exactly one:
- **FOCUSED_RECOVERY:** carry all prior PASS evidence forward, forbid every
failed technique, reduce the next scenario to only the unmet checks and the
controls needed to make them falsifiable, reset the campaign run counter,
and continue autonomously. Record the new campaign and its changed
directness in `test.md`; total `Test-Runs` never resets.
- **RECOVERY_EXHAUSTED:** add `## Recovery exhaustion` to `test.md`, with one
row for every directness strategy and concrete evidence that it was tried or
is unsafe, unavailable, or would bypass the task diff. A run cap, elapsed
time, overlay complexity, or a missing screenshot is not exhaustion.
A focused campaign must be monotonic: it never reruns checks already proved,
never re-enables a forbidden fixture technique, and must remove at least one
failed assumption. There is no fixed number of campaigns. The finite
directness ladder and the exhaustion record are the stop condition, so a
recoverable harness flaw stays inside the autonomous task instead of becoming
a user-facing block.
UNRECOVERABLE conditions: the app reaches a login screen / `AUTH_KEY_DUPLICATED` and re-copying the
test account does not recover it, or a crash has no usable diagnostic after one retry and the
macOS cached-language recovery below does not apply. Missing `test_TelegramForcePortable` is a
@@ -368,7 +394,9 @@ The repository carries a permanent test harness under
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), `Crop`/`Zoom`/`ContactSheet` for tight same-scale evidence.
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_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.
@@ -393,9 +421,17 @@ void SetupScenario(not_null<Runner*> runner) {
Core::App().activeWindow()->widget());
Test::LogGeometry(u"row"_q, row->geometry());
Test::CheckNear(row->height(), st::someRowHeight, 1, u"row height"_q);
Test::CaptureWidget(row, u"target_row"_q);
},
});
runner->captureWidget(
u"target_row"_q,
[] {
return Test::FindFirst<Ui::SomeWidget>(
Core::App().activeWindow()->widget());
},
[](QWidget *widget) {
return widget->height() == st::someRowHeight;
});
}
```
@@ -440,6 +476,14 @@ so it can never run against real account data. The overlay must:
isn't clearly captured, that is a TEST_FLAW (re-frame), never a pass. The helpers grab
in-process after layout and paint, so a locked desktop never blocks capture and a blank
grab fails loudly instead of passing silently.
- **For a full box, layer owner, animated root, or any surface whose children
appear asynchronously, use `Runner::captureWidget`.** Resolve the exact
target on each poll and put task-specific content/identity checks in its
optional readiness predicate. It waits for a visible, non-empty, nonblank,
valid paint root and saves the exact frame that satisfied readiness. Do not
hand-roll `GrabWidget` + `LooksBlank` + `SaveImage` for evidence and do not
assume that object construction, `isVisible()`, or one child paint event
means the owning presentation has painted current content.
- **Lay down the oracle's references.** Save every applicable independent reference beside the
crop (`SaveImage`, `ContactSheet` for same-scale comparison). Exact asset work saves OLD and
intended-NEW art as `<name>_{old,new}.png`. Without target artwork, save the

View File

@@ -246,7 +246,7 @@ ownership.
Otherwise select the first ready task in `own_blocked` whose id is in
`batch_task_ids` and not in `attempted_blocked`. Readiness means every
dependency is `approved`. Add its id to the set, then reopen it locally:
dependency is `approved`. Reopen it locally:
```bash
python3 .agents/skills/process-inbox/scripts/workspace.py retry \
@@ -258,9 +258,10 @@ result, and evidence while changing the slot worktree back to local
`in-progress`. It publishes no `Resume` commit. Spawn its performer at the
first incomplete validated boundary.
If it blocks again, leave the new canonical `Block` boundary and do not retry
it again in this invocation. Independent work may continue; the next
invocation gets a fresh retry set.
Add the id to `attempted_blocked` only if the performer later publishes a
genuine new `Block` boundary under the validation below. A test-campaign cap,
`TEST_FLAW`, blank/missing evidence, or another recoverable harness failure is
not genuine and does not consume this invocation's blocked retry.
### 3. Start recorded reserved work
@@ -346,6 +347,19 @@ After it returns, require one of:
- a clearly reported global hard stop, leaving the task `in-progress` and all
task-scoped local state recoverable for the next invocation.
Before accepting a canonical test block, read `work/result.md` and
`work/test.md`. It is genuine only when the verdict is not `TEST_FLAW`, does
not cite `MAX_TEST_RUNS` or a missing/blank capture as the blocker, and
`work/test.md` contains `## Recovery exhaustion`; the separately documented
Computer Use infrastructure-unavailable verdict is the only exception to the
section requirement. If an older or concurrently finishing performer
published a boundary that fails this check, immediately `retry` it in this
same invocation, keep it out of `attempted_blocked`, and spawn one fresh
performer at the focused test-recovery boundary. Preserve all positive
evidence and rerun only unmet checks. New performers cannot normally publish
such a boundary because `workspace.py finish` enforces the same rule; this is
defense for legacy state.
An interruption or environment stop never becomes a convenience `Block`.
After a genuine `Block`, add the task id to `attempted_blocked` and continue
with independent work. A source-lineage mismatch first proven after Phase 1 is

View File

@@ -144,8 +144,11 @@ local state intact for the next invocation.
A repeated test setup failure is not exhausted verification by itself. Follow
the shared directness ladder: forbid the failed fixture technique and make the
next run more manual and closer to the changed production seam. The configured
test-run cap is the safety boundary; the former two-identical-signature shortcut
must not be used.
test-run cap closes one campaign: preserve prior passes, isolate the unmet
checks, and start a focused recovery campaign unless a fresh assessment proves
every direct strategy exhausted. The cap and a `TEST_FLAW` can never by
themselves publish `BLOCKED`; the former two-identical-signature shortcut must
not be used.
A locked macOS session is not an environment stop or verification blocker.
Skip interactive Computer Use and complete the same coverage through the

View File

@@ -920,6 +920,10 @@ Write <WORK_DIR>/test-design.md:
- a fixture fallback plan naming at least two progressively more direct ways
to reach the changed production seam if the preferred fixture does not
materialize; do not make unrelated UI setup a single point of failure
- for each full box, layer owner, animated root, or asynchronously populated
surface, a `Runner::captureWidget` step that resolves the exact current
owner and uses a task-specific content predicate; never plan a hand-rolled
`GrabWidget` / `LooksBlank` / `SaveImage` chain
- a `## Reconcile` line reminding the test author to re-verify every check
against the final retained diff after the review loop
@@ -972,6 +976,10 @@ Then implement the recovery. Prefer fewer assumptions and more manual control:
Keep an independent falsifiable oracle. A direct seam may bypass setup outside
the task diff, but it must not reimplement or bypass the changed code itself.
For full boxes, layer owners, animated roots, and asynchronously populated
surfaces, use `Runner::captureWidget` so the scenario waits for and saves the
same nonblank painted frame. Do not hand-roll capture readiness from
`isVisible()`, a construction event, or one child paint event.
Fix every test flaw visible in the latest run together, build Debug, and return
the compact phase block.
@@ -982,6 +990,62 @@ unsafe, unavailable, or would bypass the task diff. Return `BLOCKED` for the
performer to confirm independently.
```
## Test-Campaign-Cap Assessment
Use a fresh leaf whenever the current campaign reaches `MAX_TEST_RUNS` with
one or more `TEST_FLAW` checks still open. The cap is an internal recovery
checkpoint, not a terminal verdict. Replace `<C>` with the campaign number.
```text
You are the independent campaign-cap assessor for one Telegram Desktop task.
You assess test recovery only. Do not edit product source, the overlay, or the
implementation, and do not delegate.
Read:
- the task spec and final retained task diff
- <WORK_DIR>/test-design.md
- <WORK_DIR>/test.md in full
- <WORK_DIR>/test-overlay.patch and test-overlay.paths
- every raw log and decisive screenshot from the current campaign
- .agents/shared/test-loop.md, especially the directness ladder and campaign-cap rules
Campaign: <C>
Write <WORK_DIR>/test-cap-assessment-<C>.md with:
## Prior proof
Every check already established, with its decisive run/evidence. Prior PASS
evidence is retained and must not be rerun.
## Unmet checks
Only acceptance checks still lacking decisive evidence. Distinguish a real
product failure from a capture/fixture/oracle failure. Do not invent new scope.
## Directness audit
For each unmet check, list every ladder strategy already attempted and
forbidden, then the next unused safe strategy. A different owner, production
registration seam, transport callback, or Runner::captureWidget content-ready
predicate counts as a changed strategy; renaming a wait does not.
## Verdict: FOCUSED_RECOVERY | RECOVERY_EXHAUSTED
Choose FOCUSED_RECOVERY whenever any safe strategy can still execute the
changed code and decide the oracle. Append `## Test campaign <C+1>` to test.md
with the carried prior proof, exact unmet checks, forbidden techniques, and
the focused scenario to author. It must run only those checks and their
falsifying controls. Total Test-Runs stays cumulative; only the campaign
counter resets.
Choose RECOVERY_EXHAUSTED only when no safe strategy remains. Append
`## Recovery exhaustion` to test.md with one row per directness strategy and
concrete attempted evidence or the exact reason it is unsafe, unavailable, or
would bypass the task diff. The run cap, time spent, overlay size, a blank or
missing screenshot, and repeated TEST_FLAW are never exhaustion.
Return the compact phase block. The performer independently confirms an
exhaustion verdict before it may publish a test block.
```
### Step 6s: Review synthesis
```text

View File

@@ -63,7 +63,7 @@ out/Debug/Telegram.app/Contents/MacOS/Telegram
TEST_ACCOUNT = out/Debug/test_TelegramForcePortable
MAX_ATTEMPTS = 4
MAX_TEST_RUNS = 12
MAX_TEST_RUNS = 12 per test campaign
COMPUTER_USE_POLICY = auto | overlay-only | required
```
@@ -148,6 +148,7 @@ work/review1-performance.md
work/review1.md # synthesized review for the iteration
work/test-design.md # check design drafted during review iteration 1
work/test.md
work/test-cap-assessment-*.md # independent focused-recovery decision at a campaign cap
work/result.md
work/owned-paths.txt
work/progress.md
@@ -335,7 +336,8 @@ Skip these phases outright rather than running them against an empty diff:
- **Phase 6, Normalize.** No task-owned source text changes.
- **Phase 7's `source-commit`.** The helper refuses it for this type. There is no
implementation attempt, no `GREEN_REF`, and no attempt counter to advance;
`MAX_ATTEMPTS` does not apply, only `MAX_TEST_RUNS`.
`MAX_ATTEMPTS` does not apply; the per-campaign `MAX_TEST_RUNS` recovery
rules still do.
### What it does run
@@ -614,8 +616,10 @@ rules, with these external-task safety adaptations:
re-copies golden — then retry once. That deletion takes the Crashpad
database under `tdata/dumps/completed/` with it, so copy out any dump
worth keeping first.
- Enforce the in-app watchdog too. Count test runs independently from
implementation attempts and stop at `MAX_TEST_RUNS`.
- Enforce the in-app watchdog too. Count total test runs independently from
implementation attempts, and also count runs in the current campaign. At
`MAX_TEST_RUNS`, run the shared cap assessment; never turn the number alone
into a terminal verdict.
- A repeated setup failure is not a reason to stop below that cap. Apply the
shared test loop's directness ladder: preserve what the run proved, forbid
the failed fixture technique, and make the next overlay more manual and
@@ -623,8 +627,9 @@ rules, with these external-task safety adaptations:
fails repeatedly, bypass that setup rather than continuing to test it.
- Plan the fewest possible runs: one complete programmed scenario per attempt
that proves every check in a single execution, splitting only for checks
that cannot share one process lifetime. `MAX_TEST_RUNS` is a safety cap,
never a budget to spend on fragmenting one scenario into several.
that cannot share one process lifetime. `MAX_TEST_RUNS` is a per-campaign
safety checkpoint, never a budget to spend on fragmenting one scenario into
several and never permission to publish a test block.
- **That rule governs how checks are packed, never how many are taken. A
coverage gap you find mid-task is closed by another run, not by a follow-up
task.** When you discover a check this task's acceptance needs and this
@@ -640,7 +645,7 @@ rules, with these external-task safety adaptations:
rebuilds all four from nothing before it can take the same measurement. One
more run costs minutes; the task that replaces it costs a full lifecycle and
lands days later. Runs added for coverage are not implementation attempts and
never advance `MAX_ATTEMPTS`; they count only against `MAX_TEST_RUNS`.
never advance `MAX_ATTEMPTS`; they count toward the current test campaign.
- Start the test author from `work/test-design.md` when the review-phase
draft exists; the author still reconciles every drafted check against the
final retained diff before writing overlay code, and owns `test.md`.
@@ -677,12 +682,15 @@ halves of a branch, more than one interface scale — the author iterates the
range rather than sampling it, because a hand-picked subset is exactly the shape
of gap that comes back later as its own task. Missing or ambiguous evidence is
`TEST_FLAW`; no expected task delta is `IMPL_BUG`. Repeated failure signatures
trigger the shared directness ladder, not an automatic block. Block before
`MAX_TEST_RUNS` only after a fresh recovery assessment proves that every
trigger the shared directness ladder, not an automatic block. Block before or
at a campaign cap only after a fresh recovery assessment proves that every
applicable more-direct strategy is unsafe, unavailable, or would bypass the
changed code. The macOS cached-language signature first gets the shared test
loop's one-time Xcode clean-rebuild recovery. A known implementation bug at the
attempt cap is implementation-blocked, not a successful retained commit.
changed code and writes `## Recovery exhaustion` to `work/test.md`. Otherwise
start a focused campaign that contains only the unmet checks and their controls,
preserves all prior positive evidence, and resets only the campaign counter.
The macOS cached-language signature first gets the shared test loop's one-time
Xcode clean-rebuild recovery. A known implementation bug at the attempt cap is
implementation-blocked, not a successful retained commit.
Skip runtime testing only for a task with no runnable behavior. Record
`NOT_APPLICABLE` and exact file-level validation. Configuration alone is not a
@@ -697,7 +705,10 @@ executable. The marked live test copy stays in place per the test-loop folder
rules. For implementation-blocked work with no
retained commit, restore only proven owned paths to `BASE_REF`. For test-blocked
work retain the latest implementation commit and state the exact unverified
behavior.
behavior. `Blocker-Type: test` additionally requires `work/test.md` to contain
`## Recovery exhaustion`, unless the verdict is the separately documented
Computer Use infrastructure-unavailable case. A `TEST_FLAW`, a run cap, or a
missing capture can never be the blocked verdict.
Write `work/result.md` with exactly one value for every field:
@@ -827,6 +838,11 @@ delays finishing the work actually in hand.
proceed with independent work, but the next invocation retries it once before
starting new shared work. A dirty/non-buildable checkout or global
environment problem stops the current invocation.
- A test campaign cap is not a clean blocked attempt. Preserve the overlay and
evidence, run the cap assessment, and continue with a focused campaign while
any safe direct strategy remains. The publication helper rejects a test block
whose report still says `TEST_FLAW`, cites the run cap, or lacks the required
recovery-exhaustion record.
- A Windows file-lock build error follows the shared bounded exact-checkout
recovery. Only exhaustion or an unsafe/non-owned holder stops the run and
asks the human; the task remains `in-progress`.

View File

@@ -2663,6 +2663,73 @@ def validate_verify_result(lines, result_path, approved):
)
def required_result_value(lines, result_path, field):
prefix = f"{field}:"
values = [
line.split(":", 1)[1].strip() for line in lines
if line.startswith(prefix)
]
if len(values) != 1 or not values[0]:
raise WorkspaceError(
f"Task result must record exactly one nonempty {field}: {result_path}"
)
return values[0]
def validate_blocked_result(lines, result_path):
blocker = required_result_value(lines, result_path, "Blocker-Type")
if blocker not in {"test", "impl", "unrecoverable"}:
raise WorkspaceError(
"A blocked task needs Blocker-Type: test | impl | unrecoverable: "
f"{result_path}"
)
if blocker != "test":
return
verdict = required_result_value(lines, result_path, "Verdict")
unverified = required_result_value(lines, result_path, "Unverified")
if unverified.lower() == "none":
raise WorkspaceError(
f"A test-blocked task must name exact unverified behavior: {result_path}"
)
lowered = verdict.lower().replace("-", "_")
for forbidden in (
"test_flaw",
"max_test_runs",
"max test runs",
"run_cap",
"run cap",
"blank_capture",
"blank capture",
"missing_capture",
"missing capture",
"missing_screenshot",
"missing screenshot",
):
if forbidden in lowered:
raise WorkspaceError(
"A recoverable harness or evidence failure cannot publish "
f"Blocker-Type: test ({forbidden}): {result_path}"
)
task_dir = result_path.parents[1]
if verdict.lower().startswith("computer-use-unavailable:"):
capability = task_dir / "computer-use-capability.md"
if not capability.is_file():
raise WorkspaceError(
"A computer-use-unavailable block needs its capability report: "
f"{capability}"
)
return
test_path = result_path.parent / "test.md"
if not test_path.is_file() or "## Recovery exhaustion" not in test_path.read_text(
encoding="utf-8-sig",
):
raise WorkspaceError(
"A test block requires work/test.md with ## Recovery exhaustion; "
"a run cap or recoverable TEST_FLAW must start a focused campaign: "
f"{test_path}"
)
def command_finish(args):
model = args.model.strip()
if not MODEL_PATTERN.fullmatch(model):
@@ -2690,6 +2757,8 @@ def command_finish(args):
raise WorkspaceError(f"Task result does not contain an approved verdict: {result_path}")
if "Checkout: clean-buildable" not in lines:
raise WorkspaceError(f"Task result does not confirm a clean checkout: {result_path}")
if not approved:
validate_blocked_result(lines, result_path)
if kind == "verify":
validate_verify_result(lines, result_path, approved)
ensure_no_persisted_commit_hashes(result_path.parents[1])

View File

@@ -1038,7 +1038,11 @@ Checkout: clean-buildable
contextlib.redirect_stdout(io.StringIO()),
):
workspace.command_finish(
SimpleNamespace(task=TASK_ID, status="approved")
SimpleNamespace(
task=TASK_ID,
status="approved",
model="gpt-5.6-sol",
)
)
self.assertEqual(subjects, [
@@ -2327,6 +2331,61 @@ class MechanicsTest(unittest.TestCase):
with self.assertRaisesRegex(workspace.WorkspaceError, "could not measure"):
check(["Touched: none", "Finding: deviation"], approved=False)
def test_test_block_requires_real_recovery_exhaustion(self):
with tempfile.TemporaryDirectory() as temporary:
task = Path(temporary) / "task"
work = task / "work"
work.mkdir(parents=True)
result = work / "result.md"
def check(verdict, unverified="full presentation"):
workspace.validate_blocked_result([
f"Verdict: {verdict}",
"Blocker-Type: test",
f"Unverified: {unverified}",
], result)
(work / "test.md").write_text(
"## Recovery exhaustion\n\n| Strategy | Evidence |\n",
encoding="utf-8",
)
check("recovery-exhausted: fixture unavailable")
for verdict in (
"TEST_FLAW at MAX_TEST_RUNS",
"blank-capture at run cap",
"missing screenshot",
):
with self.assertRaisesRegex(
workspace.WorkspaceError,
"recoverable harness or evidence failure",
):
check(verdict)
with self.assertRaisesRegex(
workspace.WorkspaceError,
"exact unverified behavior",
):
check("recovery-exhausted: fixture unavailable", "none")
(work / "test.md").unlink()
with self.assertRaisesRegex(
workspace.WorkspaceError,
"Recovery exhaustion",
):
check("recovery-exhausted: fixture unavailable")
with self.assertRaisesRegex(
workspace.WorkspaceError,
"capability report",
):
check("computer-use-unavailable: exact app identity")
(task / "computer-use-capability.md").write_text(
"unavailable\n",
encoding="utf-8",
)
check("computer-use-unavailable: exact app identity")
def test_task_type_defaults_to_implementation(self):
with tempfile.TemporaryDirectory() as temporary:
slot = Path(temporary)

View File

@@ -159,7 +159,8 @@ constexpr auto kBackgroundOwnerHops = 6;
[[nodiscard]] QString BlankRootDetails(
not_null<QWidget*> widget,
const QImage &image,
const QRect &logicalRect) {
const QRect &logicalRect,
bool logCoverage = true) {
if (image.isNull()
|| widget->testAttribute(Qt::WA_OpaquePaintEvent)
|| widget->testAttribute(Qt::WA_NoSystemBackground)) {
@@ -171,11 +172,13 @@ constexpr auto kBackgroundOwnerHops = 6;
return QString();
}
const auto unpainted = UnpaintedPermille(widget, logicalRect);
Note(u"capture coverage: harnessThemeBase=%1/1000 unpainted=%2/1000 "
u"(threshold %3/1000)"_q
.arg(baseMatched)
.arg(unpainted)
.arg(kUnpaintedMinPermille));
if (logCoverage) {
Note(u"capture coverage: harnessThemeBase=%1/1000 unpainted=%2/1000 "
u"(threshold %3/1000)"_q
.arg(baseMatched)
.arg(unpainted)
.arg(kUnpaintedMinPermille));
}
if (unpainted < kUnpaintedMinPermille) {
return QString();
}
@@ -228,6 +231,69 @@ bool LooksBlank(const QImage &image) {
return (maxLuma - minLuma) < kBlankSpreadThreshold;
}
bool PreparedWidgetCapture::prepare(QWidget *widget) {
_widget = nullptr;
_image = QImage();
_globalGeometry = QRect();
if (!widget) {
_pendingReason = u"target does not exist"_q;
return false;
} else if (!widget->isVisible()) {
_pendingReason = u"target is not visible: %1"_q.arg(
WidgetDescription(widget));
return false;
} else if (widget->size().isEmpty()) {
_pendingReason = u"target has empty geometry: %1"_q.arg(
WidgetDescription(widget));
return false;
}
const auto image = GrabWidget(widget);
if (LooksBlank(image)) {
_pendingReason = u"target grab still looks blank: %1"_q.arg(
WidgetDescription(widget));
return false;
}
const auto blankRoot = BlankRootDetails(widget, image, QRect(), false);
if (!blankRoot.isEmpty()) {
_pendingReason = blankRoot;
return false;
}
_widget = widget;
_image = image;
_globalGeometry = QRect(widget->mapToGlobal(QPoint()), widget->size());
_pendingReason = QString();
return true;
}
void PreparedWidgetCapture::invalidate(QString reason) {
_widget = nullptr;
_image = QImage();
_globalGeometry = QRect();
_pendingReason = std::move(reason);
}
bool PreparedWidgetCapture::save(const QString &name) {
if (!_widget || _image.isNull()) {
Fail(
u"prepared capture %1"_q.arg(name),
_pendingReason.isEmpty()
? u"no accepted frame"_q
: _pendingReason);
return false;
}
LogGeometry(name, _globalGeometry);
const auto path = SaveImage(_image, name);
if (path.isEmpty()) {
Fail(u"prepared capture %1"_q.arg(name), u"could not save image"_q);
return false;
}
return true;
}
QString PreparedWidgetCapture::pendingReason() const {
return _pendingReason;
}
QString SaveImage(const QImage &image, const QString &name) {
if (image.isNull()) {
return QString();

View File

@@ -7,6 +7,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
*/
#pragma once
#include <QtCore/QPointer>
#include <QtGui/QImage>
#include <QtWidgets/QWidget>
namespace Test {
@@ -27,6 +29,25 @@ namespace Test {
// Near-uniform images are capture failures, never evidence.
[[nodiscard]] bool LooksBlank(const QImage &image);
// Pollable capture state for animated/layer-owned surfaces. prepare() accepts
// a frame only after the exact target is visible, non-empty, nonblank, and a
// valid paint root. save() persists that same accepted frame, so the target
// cannot regress between a runner's readiness check and its evidence step.
class PreparedWidgetCapture final {
public:
[[nodiscard]] bool prepare(QWidget *widget);
void invalidate(QString reason);
[[nodiscard]] bool save(const QString &name);
[[nodiscard]] QString pendingReason() const;
private:
QPointer<QWidget> _widget;
QImage _image;
QRect _globalGeometry;
QString _pendingReason;
};
// Saves under ScreenshotsDir(), appends .png when missing, emits the
// SCREENSHOT marker, and returns the absolute path (empty on failure).
QString SaveImage(const QImage &image, const QString &name);

View File

@@ -10,6 +10,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "test/test_runner.h"
#include "test/test_agent.h"
#include "test/test_capture.h"
#include "test/test_log.h"
#include "base/call_delayed.h"
#include "core/application.h"
@@ -235,6 +236,31 @@ void Runner::waitForChatsLoadedStrict(crl::time timeout) {
});
}
void Runner::captureWidget(
const QString &name,
Fn<QWidget*()> resolve,
Fn<bool(QWidget*)> ready,
crl::time timeout) {
const auto capture = std::make_shared<PreparedWidgetCapture>();
add({
.name = u"capture painted widget: %1"_q.arg(name),
.until = [=] {
const auto widget = resolve();
if (!capture->prepare(widget)) {
return false;
} else if (ready && !ready(widget)) {
capture->invalidate(
u"task readiness predicate did not pass"_q);
return false;
}
return true;
},
.then = [=] { (void)capture->save(name); },
.timeout = timeout,
.timeoutDetails = [=] { return capture->pendingReason(); },
});
}
bool Runner::empty() const {
return _stages.empty();
}
@@ -262,9 +288,15 @@ void Runner::tick() {
if (!stage.until || stage.until()) {
completeStage();
} else if (crl::now() - _stageStarted > stage.timeout) {
const auto details = stage.timeoutDetails
? stage.timeoutDetails()
: QString();
Fail(
u"stage timed out: %1"_q.arg(stage.name),
u"waited %1 ms"_q.arg(stage.timeout));
details.isEmpty()
? u"waited %1 ms"_q.arg(stage.timeout)
: u"waited %1 ms; last state: %2"_q.arg(
stage.timeout).arg(details));
finish();
}
}

View File

@@ -24,6 +24,7 @@ struct Stage {
Fn<bool()> until;
Fn<void()> then;
crl::time timeout = kDefaultStageTimeout;
Fn<QString()> timeoutDetails;
};
class Runner final {
@@ -38,6 +39,16 @@ public:
void waitForChatsLoaded(crl::time timeout = kStartupStageTimeout);
void waitForChatsLoadedStrict(crl::time timeout = kStartupStageTimeout);
// Resolves an exact widget on every tick, waits until the harness can
// prepare a valid painted frame and the optional task predicate agrees,
// then saves that same frame. Use this for full boxes, layer owners, and
// animated surfaces instead of capture-from-then timing guesses.
void captureWidget(
const QString &name,
Fn<QWidget*()> resolve,
Fn<bool(QWidget*)> ready = {},
crl::time timeout = kDefaultStageTimeout);
[[nodiscard]] bool empty() const;
void start();