mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/telegramdesktop/tdesktop
synced 2026-09-20 08:03:45 +08:00
[ai] Keep test-flaw recovery inside task runs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user