[ai] Have semi-prepared testing run.

This commit is contained in:
John Preston
2026-07-28 10:45:48 +04:00
parent 7c86e5ddcd
commit 87a022ad1d
20 changed files with 2482 additions and 255 deletions

View File

@@ -110,6 +110,9 @@ The debug build runs in portable mode out of `out/Debug/`. Three sibling folders
**SETUP — run at the START of every test run, with NO app instance alive. Idempotent, and a pure **SETUP — run at the START of every test run, with NO app instance alive. Idempotent, and a pure
no-op between runs and between consecutive tasks (the marked test copy is simply reused).** no-op between runs and between consecutive tasks (the marked test copy is simply reused).**
The workspace helper's `test-run` command performs exactly these steps before every launch, and
`test-account-reset` performs the broken-account recovery below; the manual steps remain the
contract those commands implement.
1. Require `test_TelegramForcePortable`. Its absence is the only portable-account setup blocker. 1. Require `test_TelegramForcePortable`. Its absence is the only portable-account setup blocker.
2. If `TelegramForcePortable/testing` exists, the live folder is already the reusable test copy: 2. If `TelegramForcePortable/testing` exists, the live folder is already the reusable test copy:
touch none of the three folders and proceed straight to testing. touch none of the three folders and proceed straight to testing.
@@ -151,7 +154,8 @@ account, and MUST be left alive. On Windows, scope the kill by path:
`taskkill /IM Telegram.exe /F` is forbidden here and anywhere else in this loop — it is image-name-wide `taskkill /IM Telegram.exe /F` is forbidden here and anywhere else in this loop — it is image-name-wide
and takes down the user's unrelated clients. Every "kill stragglers" / "taskkill" step below means and takes down the user's unrelated clients. Every "kill stragglers" / "taskkill" step below means
this path-scoped kill. this path-scoped kill. The workspace helper's `test-run` and `test-cleanup` commands implement it
on every platform; prefer them over hand-written kill shell.
**Avoid account-fatal calls; cloud data is otherwise fair game.** The overlay must never trigger **Avoid account-fatal calls; cloud data is otherwise fair game.** The overlay must never trigger
logout / session-termination / account-deletion, and must not wipe the account wholesale. Tests that logout / session-termination / account-deletion, and must not wipe the account wholesale. Tests that
@@ -263,48 +267,101 @@ How TEST verifies it (numbers over eyes):
## Overlay mechanics ## Overlay mechanics
The overlay is ad-hoc, authored fresh against the CURRENT implementation, injected at the The repository carries a permanent test harness under
highest level that still exercises the change (often a direct data-layer call like `Telegram/SourceFiles/test/` — always compiled, runtime-gated on `-testagent`
`item->applyEdition(...)` rather than a faked MTP response). It is also the complete runtime driver (`Test::Active()`), with all of its `#ifdef`s inside the harness itself:
of first resort: prefer programmatically triggering every required action and judging the saved logs
and captures afterwards over any external desktop driver, whether or not one is available. Drive the
whole task-specific flow inside the Debug binary by invoking application actions or posting Qt input events on the event loop, waiting for
observable state, logging assertions, capturing the rendered target in-process, and quitting. A
locked macOS session does not reduce required coverage and is never a testing blocker. The overlay
must:
- Live entirely inside `#ifdef _DEBUG` blocks. - `test_runner.h` — the staged scenario engine: `Stage{name, run, until, then, timeout}`,
`waitEvent`, `waitForSessionReady`, `waitForChatsLoaded`; built-in per-stage timeouts, a
wall-clock watchdog (default 120s, `TDESKTOP_TEST_WATCHDOG` override), and guaranteed
`TEST_COMPLETE` + quit on every exit path including timeout.
- `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`,
`PressKey` via real Qt events.
- `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.
- `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.
- `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->waitForChatsLoaded();
runner->add({
.name = u"open the target and verify the row"_q,
.run = [] { /* trigger the flow under test */ },
.until = [] {
return Test::FindFirst<Ui::SomeWidget>(
Core::App().activeWindow()->widget()) != nullptr;
},
.then = [] {
const auto row = Test::FindFirst<Ui::SomeWidget>(
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);
},
});
}
```
**The overlay = replacing `test_scenario.cpp` with the task's scenario.** Author the scenario
fresh against the CURRENT implementation from the task's check design; never re-implement
logging, finding, capturing, watchdogs, or quit handling that the harness already provides —
re-derived scaffolding is where capture flaws come from. Only two kinds of edits may touch
other files, and both stay in the inventory: a one-line `Test::Fire("task_waitpoint")` in code
this task already owns, and a true in-situ injection at the highest level that still exercises
the change (often a direct data-layer call like `item->applyEdition(...)` rather than a faked
MTP response). The scenario is the complete runtime driver of first resort: prefer
programmatically triggering every required action and judging the saved logs and captures
afterwards over any external desktop driver, whether or not one is available. Drive the whole
task-specific flow inside the Debug binary on the event loop, waiting for observable state,
logging assertions, capturing the rendered target in-process, and quitting. A locked macOS
session does not reduce required coverage and is never a testing blocker. The scenario runs
only when `-testagent` was passed AND the live portable folder carries the `testing` marker,
so it can never run against real account data. The overlay must:
- Keep any code added outside `test/` inside `#ifdef _DEBUG` blocks only when it would
change release behavior; harness calls like `Test::Fire` are runtime no-ops and need no
guard.
- Pick a **test strategy** and record it in the spec: - Pick a **test strategy** and record it in the spec:
`live-data` (use real account data) · `live-mutate` (really create an entity — prefer a `live-data` (use real account data) · `live-mutate` (really create an entity — prefer a
throwaway target, clean up after) · `inject` (build fake local state without the network) · throwaway target, clean up after) · `inject` (build fake local state without the network) ·
`mock-api` (intercept specific requests, return canned responses — for payments/destructive). `mock-api` (intercept specific requests, return canned responses — for payments/destructive).
Prefer `inject` over `live-mutate` to avoid account/server accumulation and flake. Prefer `inject` over `live-mutate` to avoid account/server accumulation and flake.
- Drive the scenario on the Qt event loop, preferring **condition-waits over fixed timers** - Express the flow as `Runner` stages with **condition-waits over fixed timers** (an `until`
(wait until the target widget/data actually exists, with a timeout fallback). Fixed sleeps are predicate on the target widget/data actually existing, with the stage timeout as fallback).
the main source of screenshot flake. Fixed sleeps are the main source of screenshot flake.
- Write a flushed log to `<EVIDENCE_DIR>/test_log.txt` (open Append|Text, flush after each write) and - Log through `test_log.h` (`Step`/`Pass`/`Fail`/`Check`/`Note`/`CheckNear`/`LogGeometry`) —
save screenshots to `<EVIDENCE_DIR>/screenshots/`. Delete the old log at the first step. it already writes the flushed absolute-path log and the exact `TEST_STEP` / `TEST_RESULT` /
- **Capture the target tightly.** Grab the specific widget / row / glyph (or crop the saved PNG to `SCREENSHOT` / `TEST_COMPLETE` markers the external runner parses. Never hand-roll marker
it) so the target is unambiguously in frame at usable resolution. A full-window grab that leaves strings or log files.
the target clipped, off-screen, or thumbnail-sized is NOT acceptable evidence — if the target - **Capture the target tightly** with `CaptureWidget`/`CaptureRect` — the specific widget /
isn't clearly captured, that is a TEST_FLAW (re-frame), never a pass. row / glyph, unambiguously in frame at usable resolution. A full-window grab that leaves the
- When the desktop is locked or an OS screenshot is unavailable, capture from inside the process target clipped, off-screen, or thumbnail-sized is NOT acceptable evidence — if the target
with `QWidget::grab()` or a renderer-owned image after layout and paint have completed. A widget or isn't clearly captured, that is a TEST_FLAW (re-frame), never a pass. The helpers grab
test-window grab plus logged geometry is primary visual evidence; never wait for unlock merely to in-process after layout and paint, so a locked desktop never blocks capture and a blank
obtain a desktop screenshot. grab fails loudly instead of passing silently.
- **Lay down the oracle's references.** Save every applicable independent reference beside the - **Lay down the oracle's references.** Save every applicable independent reference beside the
crop. Exact asset work saves OLD and intended-NEW art as `<name>_{old,new}.png`. Without target crop (`SaveImage`, `ContactSheet` for same-scale comparison). Exact asset work saves OLD and
artwork, save the baseline/reference-component crop when available and log the contract anchors, intended-NEW art as `<name>_{old,new}.png`. Without target artwork, save the
baseline/reference-component crop when available and log the contract anchors,
style/resource identities, and measurements. Never fabricate an `_new` image. style/resource identities, and measurements. Never fabricate an `_new` image.
- Emit these markers, one per line:
`TEST_STEP: <desc>` · `TEST_RESULT: PASS: <what>` / `TEST_RESULT: FAIL: <what> - <details>` ·
`SCREENSHOT: <full path>` · `TEST_COMPLETE` (immediately before quit).
- Prefer asserting on **logged state** (log the actual value, assert on text — deterministic); - Prefer asserting on **logged state** (log the actual value, assert on text — deterministic);
reserve screenshots for genuinely visual checks where an eye is the right judge. reserve screenshots for genuinely visual checks where an eye is the right judge.
- **Watchdog:** install a `QTimer` at scenario start that force-quits (`Core::Quit()`, and if - Rely on the Runner's built-in watchdog and termination: it force-quits at the wall-clock cap
needed `std::abort` after a flush) at a hard wall-clock cap (default 120s). This guarantees the and ends every path — success, assertion failure, or stage timeout — with `TEST_COMPLETE`
app never hangs holding a lock on the exe — independent of the runner's own timeout. then quit, so the app never hangs holding a lock on the exe. Do not install a second
- End every path (success or assertion failure) by logging `TEST_COMPLETE` then `Core::Quit()`. watchdog and do not call `Core::Quit()` from scenario code.
### Finding widgets in an overlay (CRITICAL — avoids a guaranteed crash) ### Finding widgets in an overlay (CRITICAL — avoids a guaranteed crash)
@@ -316,40 +373,34 @@ do **NOT** declare `Q_OBJECT` — they have no own meta-object. So `QObject::fin
get a raw SIGSEGV — the debugger shows `this` with the *wrong* dynamic type. A clean rebuild does NOT get a raw SIGSEGV — the debugger shows `this` with the *wrong* dynamic type. A clean rebuild does NOT
fix it; it is a real bug in the overlay, not a stale build. fix it; it is a real bug in the overlay, not a stale build.
- **Never** `findChildren<Ui::SomeCustomWidget*>()`. Instead enumerate `findChildren<QWidget*>()` - **Never** `findChildren<Ui::SomeCustomWidget*>()`. Use the harness finders
(`QWidget` *is* `Q_OBJECT`, so that call is sound and returns all descendants) and `Test::FindAll<T>` / `Test::FindFirst<T>` / `Test::FindVisible<T>` from `test_widgets.h`,
`dynamic_cast<Ui::SomeCustomWidget*>()` each, keeping the non-null results — C++ RTTI identifies the which enumerate `findChildren<QWidget*>()` (`QWidget` *is* `Q_OBJECT`, so that call is sound)
real type regardless of `Q_OBJECT`. A reusable helper: and `dynamic_cast` each result — C++ RTTI identifies the real type regardless of `Q_OBJECT`.
```cpp
template <typename T>
[[nodiscard]] std::vector<T*> FindWidgets(QWidget *root) {
auto out = std::vector<T*>();
for (const auto w : root->findChildren<QWidget*>()) {
if (const auto t = dynamic_cast<T*>(w)) out.push_back(t);
}
return out;
}
```
- Only genuine Qt `Q_OBJECT` types (`QWidget`, `QLabel`, `QLineEdit`, …) are safe to pass directly to - Only genuine Qt `Q_OBJECT` types (`QWidget`, `QLabel`, `QLineEdit`, …) are safe to pass directly to
`findChildren<T*>()`. `findChildren<T*>()`.
### Log to an ABSOLUTE path (the launcher chdir's) ### Log to an ABSOLUTE path (the launcher chdir's)
The Windows launcher changes the working directory to the exe folder before the app runs, so a The Windows launcher changes the working directory to the exe folder before the app runs, so a
**relative** overlay log path (`<EVIDENCE_DIR>/test_log.txt`) silently fails to write (`QFile` won't **relative** log path silently fails to write (`QFile` won't create missing parents) — the run
create missing parents) — the run looks "clean" but produces no evidence. Create and resolve looks "clean" but produces no evidence. `Test::EvidenceDir()` resolves
`EVIDENCE_DIR` to an absolute path up front (or bake its absolute path into the overlay) so flushes `TDESKTOP_TEST_EVIDENCE_DIR` (which the workspace `test-run` helper always exports as an
actually land; likewise for screenshots. absolute path) and creates it up front, so harness logging and captures are immune; never
bypass it with hand-built relative paths.
### Git mechanics for the overlay (no stash) ### Git mechanics for the overlay (no stash)
- Before authoring, inventory every tracked overlay path in `<WORK_DIR>/test-overlay.paths`; no - The inventory in `<WORK_DIR>/test-overlay.paths` is normally exactly
unrelated or untracked source path may be used. After building, save the overlay with `Telegram/SourceFiles/test/test_scenario.cpp`, plus any in-situ injection or `Test::Fire`
`git diff --binary HEAD > <WORK_DIR>/test-overlay.patch` and verify the patch is nonempty and paths; no unrelated or untracked source path may be used. After building, save the overlay
reapplicable. Restore only the inventoried overlay paths to `GREEN_REF`; never hard-reset the with the workspace helper's `overlay-save` command: it verifies every dirty path against the
repository. The overlay never enters an impl commit. inventory, writes a nonempty verified `<WORK_DIR>/test-overlay.patch`, and restores only the
- Next round, re-apply on top of the new implementation: `git apply --3way inventoried overlay paths to the wrapper's restore ref; never hard-reset the repository. The
<WORK_DIR>/test-overlay.patch`. This succeeds ~90% of the time when the tail change was small. overlay never enters an impl commit.
- Next round, re-apply on top of the new implementation with `overlay-apply` (a `--3way`
application that reports conflicted paths). This succeeds ~90% of the time when the tail change
was small.
- On conflict, **re-author the conflicting hunk from the latest Attempt/Run in `<WORK_DIR>/test.md`** (which - On conflict, **re-author the conflicting hunk from the latest Attempt/Run in `<WORK_DIR>/test.md`** (which
records injection point, fake values, and assertions) rather than fighting conflict markers. records injection point, fake values, and assertions) rather than fighting conflict markers.
Scenario steps that only call public APIs should live in their own block so they never conflict; Scenario steps that only call public APIs should live in their own block so they never conflict;
@@ -369,22 +420,18 @@ actually land; likewise for screenshots.
it and the binary keeps the OLD asset. Before building such a task force regeneration — touch the it and the binary keeps the OLD asset. Before building such a task force regeneration — touch the
referencing `.style` (or clean the codegen output) — so the change actually ships. A render that referencing `.style` (or clean the codegen output) — so the change actually ships. A render that
shows no difference from before is the symptom of skipping this. shows no difference from before is the symptom of skipping this.
- Run: run the SETUP steps (Test account) -> create `EVIDENCE_DIR` -> launch `EXE` **with - Run: execute the workspace helper's `test-run` command with `EXE` and `EVIDENCE_DIR`. One call
`-testagent`** in the background, redirecting stdout to `<EVIDENCE_DIR>/app_stdout.txt` and stderr performs the SETUP steps (Test account), creates `EVIDENCE_DIR`, path-scope-kills stragglers,
to `<EVIDENCE_DIR>/app_stderr.txt` (this flag prevents modal crash hangs, and stderr captures launches `EXE` **with `-testagent -noupdate`** (so a shipped update can never replace the
assertion text) -> **start a hard wall-clock deadline (~90s) from launch** -> poll binary under test mid-run) capturing stdout to `<EVIDENCE_DIR>/app_stdout.txt` and
`<EVIDENCE_DIR>/test_log.txt` every ~5s -> on each `SCREENSHOT:` read the image and judge it -> detect stderr to `<EVIDENCE_DIR>/app_stderr.txt` (the flag prevents modal crash hangs, and stderr
`TEST_COMPLETE` (success) or process death (crash) or no new output for the watchdog cap, or the captures assertion text), enforces **a hard wall-clock deadline from launch** and a quiet-log
hard deadline elapsing (hang) -> path-scoped kill of any straggler (Test account → "Serialize app watchdog while polling `<EVIDENCE_DIR>/test_log.txt`, detects `TEST_COMPLETE` (success) versus
runs") -> save the binary overlay patch -> restore only inventoried overlay process death (crash) versus the caps elapsing (hang), kills any straggler, and returns one JSON
paths to `GREEN_REF` (the patch must be saved before this restore). report with the parsed markers, stderr tail, and fresh crash diagnostics. Then read each
`SCREENSHOT:` image and judge it, save the binary overlay patch, and restore only inventoried
On Windows, launch and capture both streams like: overlay paths (`overlay-save` — the patch must be saved before that restore). The runner only
gathers evidence; ASSESS below stays the agent's own adversarial judgement.
$exe = (Resolve-Path "$EXE").Path
Start-Process -FilePath $exe -ArgumentList '-testagent' `
-RedirectStandardError "$EVIDENCE_DIR/app_stderr.txt" `
-RedirectStandardOutput "$EVIDENCE_DIR/app_stdout.txt" -PassThru
### Crashes & assertions (always launch the test binary with `-testagent`) ### Crashes & assertions (always launch the test binary with `-testagent`)
@@ -477,9 +524,8 @@ The on-disk `EXE` (`out/Debug/Telegram.exe`) always contains the compiled overla
Restoring source does not rewrite the binary. When the loop reaches a TERMINAL verdict (APPROVED, Restoring source does not rewrite the binary. When the loop reaches a TERMINAL verdict (APPROVED,
BLOCKED, UNRECOVERABLE, or attempt cap), after the final path-scoped kill and exact-path source BLOCKED, UNRECOVERABLE, or attempt cap), after the final path-scoped kill and exact-path source
restore, **delete the built `EXE`** so no overlay-laden test binary is left for restore, **delete the built `EXE`** so no overlay-laden test binary is left for
the user to launch by mistake: the user to launch by mistake — the workspace helper's `test-cleanup --exe EXE --delete-exe` does
the final path-scoped kill and the deletion in one call.
Remove-Item -Force "$EXE"
A clean, feature-ready binary is one `BUILD` away on demand. (Delete only on terminal exit — between A clean, feature-ready binary is one `BUILD` away on demand. (Delete only on terminal exit — between
attempts the next round rebuilds the overlay, so the binary is reused there.) attempts the next round rebuilds the overlay, so the binary is reused there.)

View File

@@ -4,42 +4,60 @@
- [Orchestration rules](#orchestration-rules) - [Orchestration rules](#orchestration-rules)
- [Completion checks](#artifact-based-completion-checks) - [Completion checks](#artifact-based-completion-checks)
- [Context](#phase-1-context) - [Context and plan](#phase-1-context-and-plan)
- [Plan and assessment](#phase-2-plan) - [Assessment](#phase-3-plan-assessment)
- [Implementation and build](#phase-4-implementation) - [Implementation and build](#phase-4-implementation)
- [Review](#phase-6-code-review-loop) - [Review](#phase-6-code-review-loop)
- [Windows normalization](#phase-7-native-windows-text-normalization) - [Windows normalization](#phase-7-native-windows-text-normalization)
- [Prompt delivery](#prompt-delivery-and-logs) - [Prompt delivery](#prompt-delivery-and-logs)
Use these templates as Codex subagent messages. Use them as same-session Use these templates as subagent messages on any host. Use them as same-session
checklists only for intentional current-session build work, Phase 7, or when checklists only for intentional current-session build work, Phase 7, the
delegation is unavailable from the start at the current agent depth. Replace small-task fast path, or when delegation is unavailable from the start at the
current agent depth. Replace
every applicable placeholder: `<TASK>`, `<TASK_ID>`, `<WORK_DIR>`, every applicable placeholder: `<TASK>`, `<TASK_ID>`, `<WORK_DIR>`,
`<PROJECT_FILE>`, `<PREVIOUS_CONTEXT>`, `<BUILD>`, `<N>`, `<PROJECT_FILE>`, `<PREVIOUS_CONTEXT>`, `<BUILD>`, `<N>`,
`<OWNED_WRITE_SET>`, `<R>`, `<R-1>`, and `<phase-name>`. `<OWNED_WRITE_SET>`, `<R>`, `<R-1>`, and `<phase-name>`.
## Orchestration Rules ## Orchestration Rules
- When delegation is available, use a fresh subagent for Phase 1, Phase 2, Phase 3, each Phase 4 implementation unit, each Phase 6a lens, each Phase 6s synthesis, and each Phase 6b fix. Do not switch those phases to same-session midstream because of a timeout or missing artifact. - When delegation is available, use a fresh subagent for Phase 1 (context and plan), Phase 3, each Phase 4 implementation unit, each Phase 6a lens, the Phase 6d test-design leaf, each Phase 6s synthesis, and each Phase 6b fix. Do not switch those phases to same-session midstream because of a timeout or missing artifact.
- The Phase 6a lenses of one iteration are independent and write disjoint report files, so spawn them together when capacity allows. Never let one lens read another's report, and never collapse them into a single combined reviewer: their independence is the point of the phase. - The Phase 6a lenses of one iteration are independent and write disjoint report files, so spawn them together when capacity allows. The Phase 6d test-design leaf writes its own disjoint artifact and joins the iteration-1 fan-out. Never let one lens read another's report, and never collapse them into a single combined reviewer: their independence is the point of the phase.
- Treat delegation as selected only after the first real phase spawn succeeds; tool presence is insufficient. An immediate depth/capacity/policy rejection before phase work selects same-session checklists and is not a delegated retry. - Treat delegation as selected only after the first real phase spawn succeeds; tool presence is insufficient. An immediate depth/capacity/policy rejection before phase work selects same-session checklists and is not a delegated retry.
- Phase 7 runs in the current session on native, non-WSL Windows because it depends on the final local diff and touched-file set. Skip it on WSL and keep files LF/no-BOM there. - Phase 7 runs in the current session on native, non-WSL Windows because it depends on the final local diff and touched-file set. Skip it on WSL and keep files LF/no-BOM there.
- Write each phase prompt to `<WORK_DIR>/logs/phase-<phase-name>.prompt.md` before execution. - Write each phase prompt to `<WORK_DIR>/logs/phase-<phase-name>.prompt.md` before execution.
- If you delegate a phase, send the prompt file contents as the initial `spawn_agent` message. - If you delegate a phase, send the prompt file contents as the initial subagent message.
- When writing the phase prompt file, append the standard progress file contract and the standard compact reply block below so the subagent knows how to surface progress before the final artifact. - When writing the phase prompt file, append the standard progress file contract and the standard compact reply block below so the subagent knows how to surface progress before the final artifact.
- After each phase completes, write `<WORK_DIR>/logs/phase-<phase-name>.result.md` with exact - After each phase completes, write `<WORK_DIR>/logs/phase-<phase-name>.result.md` with exact
`STATUS:`, `ARTIFACTS:`, `TOUCHED:`, `BLOCKER:`, and `NOTES:` fields. `STATUS:`, `ARTIFACTS:`, `TOUCHED:`, `BLOCKER:`, and `NOTES:` fields.
- Use `fork_turns: "none"` by default. If the phase depends on thread-only context or UI attachments, pass it explicitly or use the smallest positive turn fork needed. - Use `fork_turns: "none"` by default. If the phase depends on thread-only context or UI attachments, pass it explicitly or use the smallest positive turn fork needed.
- Use only fields the current `spawn_agent` schema exposes; do not invent role, model, or reasoning arguments. Inherit the parent model/reasoning selection, or match it if the host explicitly supports overrides. - Use only fields the current spawn schema exposes; do not invent role, model, or reasoning arguments. Inherit the parent model/reasoning selection, or match it if the host explicitly supports overrides.
- Give each phase a unique lowercase/digit/underscore task name, store the canonical target returned by `spawn_agent`, and tell the phase it is a leaf that must not delegate. - Give each phase a unique lowercase/digit/underscore task name and tell the phase it is a leaf that must not delegate.
- For Phase 1, Phase 3, Phase 4, and Phase 6, if delegated retries still fail, stop and ask the user rather than rerunning the phase locally.
- Never use `codex exec`, background shell child processes, or JSONL child-session logging from this skill.
### Claude Code: synchronous delegation
- Run each leaf as one synchronous foreground Agent call. The call returning
is the completion signal; there is no polling, no heartbeat-mtime ladder,
and no stall windows. On return, validate the artifact-based completion
checks below before treating the phase as done.
- Spawn the independent leaves of one step — the Phase 6a lenses plus the
iteration-1 Phase 6d test-design leaf, or assessed-disjoint Phase 4 units —
as parallel Agent calls in a single message so they run concurrently.
- If a returned leaf fails its completion check, retry that disposable phase
once in a fresh Agent with more specific instructions before stopping to
ask the user.
### Codex: asynchronous spawn and wait
- Store the canonical target returned by `spawn_agent`.
- Poll with `wait_agent` for at most 60 seconds per call; use elapsed wall-clock windows for stall decisions. Use 30-60 second polls when a phase appears close to landing. - Poll with `wait_agent` for at most 60 seconds per call; use elapsed wall-clock windows for stall decisions. Use 30-60 second polls when a phase appears close to landing.
- `wait_agent` is mailbox-wide and may wake for another agent or user input. A timeout is not failure. After every wake, handle new user input if any, inspect the saved target with `list_agents`, and check the expected artifact and matching progress file. - `wait_agent` is mailbox-wide and may wake for another agent or user input. A timeout is not failure. After every wake, handle new user input if any, inspect the saved target with `list_agents`, and check the expected artifact and matching progress file.
- If the expected artifact exists and shows progress, wait again. - If the expected artifact exists and shows progress, wait again.
- If the expected artifact is not ready but the progress file mtime moved or its heartbeat counter increased since the previous check, wait again. Prefer mtime checks first and avoid rereading the file unless you need detail. Do not count that as a failed wait. - If the expected artifact is not ready but the progress file mtime moved or its heartbeat counter increased since the previous check, wait again. Prefer mtime checks first and avoid rereading the file unless you need detail. Do not count that as a failed wait.
- If neither the expected artifact nor progress file moved for a full five-minute blocked-check window, use `send_message` while the target is running or `followup_task` when it is idle, asking it to refresh progress, finish the artifact, and return the compact block. - If neither the expected artifact nor progress file moved for a full five-minute blocked-check window, use `send_message` while the target is running or `followup_task` when it is idle, asking it to refresh progress, finish the artifact, and return the compact block.
- If a second five-minute window after that follow-up still produces no usable artifact or movement, use `interrupt_agent` if needed, confirm the turn stopped, and retry the disposable phase once with a new unique name. There is no close-agent operation. - If a second five-minute window after that follow-up still produces no usable artifact or movement, use `interrupt_agent` if needed, confirm the turn stopped, and retry the disposable phase once with a new unique name. There is no close-agent operation.
- For Phase 1, Phase 2, Phase 3, Phase 4, and Phase 6, if delegated retries still fail, stop and ask the user rather than rerunning the phase locally.
- Never use `codex exec`, background shell child processes, or JSONL child-session logging from this skill.
## Standard Progress File Contract ## Standard Progress File Contract
@@ -80,28 +98,51 @@ Do not restate the full context, plan, diff, or long reasoning in the chat reply
## Artifact-Based Completion Checks ## Artifact-Based Completion Checks
- Phase 1 is complete only when `context.md` exists and is non-empty. For a - Phase 1 is complete only when `context.md` exists and is non-empty, `plan.md`
project task, `project.proposed.md` must also exist and be non-empty. exists and contains a `## Status` section, and no unintended source edits
- Phase 2 is complete only when `plan.md` exists, contains a `## Status` section, and no unintended source edits were made. were made. For a project task, `project.proposed.md` must also exist and be
non-empty. For a `Visual: layout` task, `visual.md` must also satisfy the
visual design completion check below.
- Phase 3 is complete only when `plan.md` contains both `Phases:` in the Status section and `Assessed: yes`. - Phase 3 is complete only when `plan.md` contains both `Phases:` in the Status section and `Assessed: yes`.
- Phase 4 is complete only when the target phase checkbox changed to checked and the touched-file list matches the owned write set, or the blocker explains any mismatch. - Phase 4 is complete only when the target phase checkbox changed to checked and the touched-file list matches the owned write set, or the blocker explains any mismatch.
- Phase 5 is complete only when the build outcome is known and the build checkbox is updated on success. - Phase 5 is complete only when the build outcome is known and the build checkbox is updated on success.
- Phase 6a is complete only when every lens scheduled for iteration `R` wrote `review<R>-<lens>.md` with a `## Verdict:` line and a non-empty `## Checked` section. A lens report that records no checked surfaces is incomplete work: rerun that lens rather than accepting it. - Phase 6a is complete only when every lens scheduled for iteration `R` wrote `review<R>-<lens>.md` with a `## Verdict:` line and a non-empty `## Checked` section. A lens report that records no checked surfaces is incomplete work: rerun that lens rather than accepting it.
- Phase 6s is complete only when `review<R>.md` exists with a `## Verdict:` line, a non-empty `## Coverage` section, and a `## Dropped` section. - Phase 6s is complete only when `review<R>.md` exists with a `## Verdict:` line, a non-empty `## Coverage` section, and a `## Dropped` section.
- Phase 6b is complete only when the requested fixes were applied and the post-fix build outcome is known. - Phase 6b is complete only when the requested fixes were applied and the post-fix build outcome is known.
- Phase 6d is complete only when `test-design.md` exists, covers every surface
the task's Observable result names (or marks one N/A with a reason), states
a falsifiable oracle with its source for each check, and compresses the run
plan to the fewest possible runs. It must not contain overlay code or filled
Actual/Result fields.
- A perform-task visual design phase is complete only when `visual.md` cites its available - A perform-task visual design phase is complete only when `visual.md` cites its available
design sources (images when supplied; otherwise request facts and repository/baseline anchors), design sources (images when supplied; otherwise request facts and repository/baseline anchors),
records assumptions, and contains desktop anchors, an ordered derivation, tolerances, and records assumptions, and contains desktop anchors, an ordered derivation, tolerances, and
falsifiable geometry checks. Missing mockups alone never make the phase incomplete. falsifiable geometry checks. Missing mockups alone never make the phase incomplete.
## Phase 1: Context ## Phase 1: Context and Plan
One leaf gathers context and writes the implementation plan in the same
session: the agent that just read every relevant file is the best-informed
planner, and Phase 3 still verifies both artifacts independently. For a
`Visual: layout` task, insert the pipeline's visual design instructions
between the context and plan steps so the leaf writes `visual.md` before
`plan.md` and the plan consumes the derived contract.
Small-task fast path: the performer may run this phase as a same-session
checklist instead of a leaf, but only when the task spec itself names every
file to touch and the change is mechanical — roughly two source files or
fewer, no new APIs, strings, or style tokens, no layout derivation. When in
doubt, delegate. Phase 3 always runs as a fresh leaf and must reject the fast
path (`Fast-Path: rejected` under Status, no `Assessed: yes`) when the task
turns out larger than those criteria; the performer then reruns Phase 1 as a
proper leaf.
```text ```text
You are a context-gathering agent for a large C++ codebase (Telegram Desktop). You are a context-gathering and planning agent for a large C++ codebase (Telegram Desktop).
TASK: <TASK> TASK: <TASK>
YOUR JOB: Read AGENTS.md, inspect the codebase, find all files and code relevant to this task, and write self-contained implementation context. YOUR JOB: Read AGENTS.md, inspect the codebase, find all files and code relevant to this task, write self-contained implementation context, and then write a detailed implementation plan.
Steps: Steps:
1. Read AGENTS.md for project conventions and build instructions. 1. Read AGENTS.md for project conventions and build instructions.
@@ -145,74 +186,7 @@ This is the primary task-specific implementation context. All downstream phases
Be extremely thorough. Another agent with no prior context will rely on this file. Be extremely thorough. Another agent with no prior context will rely on this file.
Do not implement code in this phase. After context.md is written, create a detailed plan in: <WORK_DIR>/plan.md
```
## Phase 1F: Context for an existing project
```text
You are a context-gathering agent for a follow-up task on an existing project in a large C++ codebase (Telegram Desktop).
NEW TASK: <TASK>
YOUR JOB: Read the existing project state, gather any additional context needed, and produce fresh documents for the new task.
Steps:
1. Read AGENTS.md for project conventions and build instructions.
2. Read <PROJECT_FILE>. This is the project-level blueprint describing everything done so far.
3. Read <PREVIOUS_CONTEXT>. This is the previous task's gathered context.
4. Understand what has already been implemented by reading the actual source files referenced in the project file and previous context.
5. Based on the new task description, search the codebase for any additional files, classes, functions, and patterns that are relevant to the new task but not already covered.
6. Read all newly relevant files thoroughly.
Write two files.
File 1: `<WORK_DIR>/project.proposed.md`
Write a single coherent proposed project document that describes everything,
including this task's changes, as fully implemented and working. Do not modify
`<PROJECT_FILE>` during this phase.
It should incorporate:
- everything from the existing project document that is still accurate and relevant
- the new task's functionality described as part of the project, not as a pending change
- any changed design decisions or architectural updates from the new task requirements
It should not contain:
- temporal state such as "Current State", "Pending Changes", or "TODO"
- history of how requirements changed between tasks
- references to "the old approach" versus "the new approach"
- task-by-task changelog or timeline
- information that contradicts the new task requirements
File 2: `<WORK_DIR>/context.md`
This is the primary document for the new task. It must be self-contained and should include:
- Task Description: The new task restated clearly, with enough project background that an implementation agent can understand it without reading other AI task files
- Relevant Files: Every file path with line ranges relevant to this task
- Key Code Patterns: How similar things are done in the codebase
- Data Structures: Relevant types, structs, classes
- API Methods: Any TL schema methods involved
- UI Styles: Any relevant style definitions
- Localization: Any relevant string keys
- Build Info: Build command and any special notes
- Reference Implementations: Similar features that can serve as templates
Be extremely thorough. Another agent with no prior context should be able to work from this file alone.
Do not implement code in this phase.
```
## Phase 2: Plan
```text
You are a planning agent. You must create a detailed implementation plan.
Read these files:
- <WORK_DIR>/context.md
- Then read the specific source files referenced in context.md to understand the code deeply.
Create a detailed plan in: <WORK_DIR>/plan.md
The plan.md should contain: The plan.md should contain:
@@ -258,6 +232,68 @@ Number every step. Group steps into phases if there are more than about eight st
Do not implement code in this phase. Do not implement code in this phase.
``` ```
## Phase 1F: Context and plan for an existing project
```text
You are a context-gathering and planning agent for a follow-up task on an existing project in a large C++ codebase (Telegram Desktop).
NEW TASK: <TASK>
YOUR JOB: Read the existing project state, gather any additional context needed, produce fresh documents for the new task, and then write a detailed implementation plan.
Steps:
1. Read AGENTS.md for project conventions and build instructions.
2. Read <PROJECT_FILE>. This is the project-level blueprint describing everything done so far.
3. Read <PREVIOUS_CONTEXT>. This is the previous task's gathered context.
4. Understand what has already been implemented by reading the actual source files referenced in the project file and previous context.
5. Based on the new task description, search the codebase for any additional files, classes, functions, and patterns that are relevant to the new task but not already covered.
6. Read all newly relevant files thoroughly.
Write two files.
File 1: `<WORK_DIR>/project.proposed.md`
Write a single coherent proposed project document that describes everything,
including this task's changes, as fully implemented and working. Do not modify
`<PROJECT_FILE>` during this phase.
It should incorporate:
- everything from the existing project document that is still accurate and relevant
- the new task's functionality described as part of the project, not as a pending change
- any changed design decisions or architectural updates from the new task requirements
It should not contain:
- temporal state such as "Current State", "Pending Changes", or "TODO"
- history of how requirements changed between tasks
- references to "the old approach" versus "the new approach"
- task-by-task changelog or timeline
- information that contradicts the new task requirements
File 2: `<WORK_DIR>/context.md`
This is the primary document for the new task. It must be self-contained and should include:
- Task Description: The new task restated clearly, with enough project background that an implementation agent can understand it without reading other AI task files
- Relevant Files: Every file path with line ranges relevant to this task
- Key Code Patterns: How similar things are done in the codebase
- Data Structures: Relevant types, structs, classes
- API Methods: Any TL schema methods involved
- UI Styles: Any relevant style definitions
- Localization: Any relevant string keys
- Build Info: Build command and any special notes
- Reference Implementations: Similar features that can serve as templates
Be extremely thorough. Another agent with no prior context should be able to work from this file alone.
File 3: `<WORK_DIR>/plan.md`
After the two documents are written, create a detailed plan with the same
structure required by Phase 1: Task, Approach, Files to Modify, Files to
Create, numbered Implementation Steps grouped into phases when there are more
than about eight steps, Build Verification, and the Status checkbox section.
Do not implement code in this phase.
```
## Phase 3: Plan Assessment ## Phase 3: Plan Assessment
```text ```text
@@ -266,6 +302,7 @@ You are a plan assessment agent. Review and refine an implementation plan.
Read these files: Read these files:
- <WORK_DIR>/context.md - <WORK_DIR>/context.md
- <WORK_DIR>/plan.md - <WORK_DIR>/plan.md
- <WORK_DIR>/visual.md when it exists
- Then read the actual source files referenced to verify the plan makes sense. - Then read the actual source files referenced to verify the plan makes sense.
Assess the plan: Assess the plan:
@@ -275,6 +312,17 @@ Assess the plan:
3. Code quality: Will the plan minimize code duplication? Does it follow existing codebase patterns from AGENTS.md? 3. Code quality: Will the plan minimize code duplication? Does it follow existing codebase patterns from AGENTS.md?
4. Design: Could the approach be improved? Are there better patterns already used in the codebase? 4. Design: Could the approach be improved? Are there better patterns already used in the codebase?
5. Phase sizing: Each phase should be implementable by a single agent in one session. If a phase has more than about 8-10 substantive code changes, split it further. 5. Phase sizing: Each phase should be implementable by a single agent in one session. If a phase has more than about 8-10 substantive code changes, split it further.
6. Visual contract (layout tasks): when visual.md exists, verify its anchors
are real (the cited style tokens, fonts, and reference widgets exist),
the ordered derivation is arithmetically consistent, and every quantity the
plan uses comes from the contract rather than an invented number.
7. Fast-path sizing (when the performer wrote context.md and plan.md itself):
confirm the task really matches the fast-path criteria — the spec names
every file to touch, roughly two source files or fewer, no new APIs,
strings, or style tokens, no layout derivation. If it does not, add
`Fast-Path: rejected` to the Status section, do NOT add `Assessed: yes`,
and state what was underestimated; the performer must rerun Phase 1 as a
fresh leaf.
Update plan.md with your refinements. Keep the same structure but: Update plan.md with your refinements. Keep the same structure but:
- fix any inaccuracies - fix any inaccuracies
@@ -388,7 +436,9 @@ Review loop:
```text ```text
LOOP: LOOP:
1. Run the scheduled Phase 6a lenses for iteration R. 1. Run the scheduled Phase 6a lenses for iteration R.
R = 1 -> all four lenses. R = 1 -> all four lenses, plus the Phase 6d test-design leaf in the
same fan-out (it writes test-design.md and takes no part in
the review verdict).
R > 1 -> every lens whose finding survived synthesis in iteration R-1, R > 1 -> every lens whose finding survived synthesis in iteration R-1,
plus `lifetime` unconditionally. A fix pass is the most likely plus `lifetime` unconditionally. A fix pass is the most likely
moment for a new ownership or lifetime error to be introduced, moment for a new ownership or lifetime error to be introduced,
@@ -557,6 +607,53 @@ cleared alongside them. For the reuse lens, include the searches you ran.>
Reply in the compact block. Do not restate the diff or the report body in chat. Reply in the compact block. Do not restate the diff or the report body in chat.
``` ```
### Step 6d: Test-check design (iteration 1 only)
The checks a test must make derive from the task spec and the plan, not from
the last review fix — so their design does not have to wait for the review
loop to finish. Spawn this leaf together with the iteration-1 lenses. It
writes `test-design.md` only; the later test author owns `test.md` and the
overlay, and MUST reconcile every drafted check against the final retained
diff before authoring code — a review fix can change what a check must
observe, and an unreconciled draft is a TEST_FLAW waiting to happen.
```text
You are the test-check designer for one Telegram Desktop task. You design
falsifiable checks. You do not write overlay code, do not run anything, and
do not modify source files.
Read these files:
- the task spec at <TASK_DIR>/task.md and every referenced input image
- <WORK_DIR>/context.md
- <WORK_DIR>/plan.md
- <WORK_DIR>/visual.md when it exists
- .agents/shared/test-loop.md — the sections "Design the tests from THIS
task", "Visual contract", and "Test report"
- the harness headers under Telegram/SourceFiles/test/ (test_runner.h,
test_widgets.h, test_capture.h, test_log.h) — design checks that the
harness's stage waits, typed finders, tight captures, and geometry logs can
observe directly
Then run `git diff` to see the current uncommitted task changes.
Write <WORK_DIR>/test-design.md:
- the chosen test strategy (live-data / live-mutate / inject / mock-api) with
one line of justification
- one `#### Test N — <aspect of THIS change>` block per concrete thing the
diff changed and per surface the task's Observable result names, each with
Expected / Oracle / Oracle source / Observed via fields in the test.md
format, leaving Actual, Screenshots, and Result unfilled
- a surface explicitly marked N/A with a reason when it genuinely cannot be
observed
- a run plan compressed to the fewest possible runs — normally exactly one —
splitting only for checks that cannot share one process lifetime
- a `## Reconcile` line reminding the test author to re-verify every check
against the final retained diff after the review loop
Every check needs an oracle that can come out FAIL. "The screen opened" is
not a check. Do not reuse a generic navigate-and-screenshot scenario.
```
### Step 6s: Review synthesis ### Step 6s: Review synthesis
```text ```text
@@ -696,7 +793,7 @@ When all phases, including build verification, code review, and Windows line end
## Error Handling ## Error Handling
- If any phase fails or gets stuck, follow the timeout and retry rules above. Do not close an agent solely because the final artifact is missing while its progress file is still advancing. For Phase 1, Phase 2, Phase 3, Phase 4, and Phase 6, do not rerun locally after delegated retries fail; ask the user instead. - If any phase fails or gets stuck, follow the host-specific retry rules above. On Codex, do not close an agent solely because the final artifact is missing while its progress file is still advancing. For Phase 1, Phase 3, Phase 4, and Phase 6, do not rerun locally after delegated retries fail; ask the user instead.
- If `context.md` or `plan.md` is not written properly by a phase, rerun that phase in a fresh subagent with more specific instructions. - If `context.md` or `plan.md` is not written properly by a phase, rerun that phase in a fresh subagent with more specific instructions.
- If build errors persist after the build phase's attempts, report the remaining errors to the user. - If build errors persist after the build phase's attempts, report the remaining errors to the user.
- If a review-fix phase introduces new build errors that it cannot resolve, report to the user. - If a review-fix phase introduces new build errors that it cannot resolve, report to the user.
@@ -711,17 +808,30 @@ For each phase:
`TOUCHED:`, `BLOCKER:`, and `NOTES:` fields. `TOUCHED:`, `BLOCKER:`, and `NOTES:` fields.
For review iterations, include the iteration and the lens in the file name, for example: For review iterations, include the iteration and the lens in the file name, for example:
- `phase-1-context-plan.prompt.md`
- `phase-6a-review-1-correctness.prompt.md` - `phase-6a-review-1-correctness.prompt.md`
- `phase-6a-review-1-correctness.result.md` - `phase-6a-review-1-correctness.result.md`
- `phase-6a-review-1-lifetime.prompt.md` - `phase-6a-review-1-lifetime.prompt.md`
- `phase-6a-review-1-reuse.prompt.md` - `phase-6a-review-1-reuse.prompt.md`
- `phase-6a-review-1-structure.prompt.md` - `phase-6a-review-1-structure.prompt.md`
- `phase-6d-test-design-1.prompt.md`
- `phase-6d-test-design-1.result.md`
- `phase-6s-synthesis-1.prompt.md` - `phase-6s-synthesis-1.prompt.md`
- `phase-6s-synthesis-1.result.md` - `phase-6s-synthesis-1.result.md`
- `phase-6b-fix-1.prompt.md` - `phase-6b-fix-1.prompt.md`
- `phase-6b-fix-1.result.md` - `phase-6b-fix-1.result.md`
## Subagent Pattern ## Subagent Pattern (Claude Code)
1. Write the phase prompt file(s).
2. Make one synchronous foreground Agent call per leaf — parallel calls in a
single message for independent leaves of the same step — with self-contained
prompts.
3. When the calls return, validate the expected artifacts or code changes with
small shell summaries and the completion checks above.
4. Write the result log from the validated outcome and the compact reply block.
## Subagent Pattern (Codex)
Use this pattern conceptually for delegated phases: Use this pattern conceptually for delegated phases:

View File

@@ -77,14 +77,24 @@ Before planning or editing:
1. Read `SOURCE_ROOT/AGENTS.md`, `REVIEW.md`, `AI_SLOT/AGENTS.md`, `TASK_SPEC`, 1. Read `SOURCE_ROOT/AGENTS.md`, `REVIEW.md`, `AI_SLOT/AGENTS.md`, `TASK_SPEC`,
every referenced input, and relevant project context. every referenced input, and relevant project context.
2. Verify `state.yaml` is `in-progress` and owned by this checkout tag. 2. Verify `state.yaml` is `in-progress` and owned by this checkout tag.
3. Require the prepared portable test account. Its absence is a global hard 3. Run the scripted preflight report and act on its JSON instead of composing
stop before implementation. the equivalent shell checks by hand:
4. Verify a usable Debug executable/build tree, safe path-scoped process
```bash
python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \
source-preflight --source-root SOURCE_ROOT --task TASK_ID --exe EXE
```
It reports source/submodule cleanliness, dirty paths outside the owned
write set, and the golden test account and live marker state for `EXE`.
4. Require the prepared portable test account (`golden_account_present`). Its
absence is a global hard stop before implementation.
5. Verify a usable Debug executable/build tree, safe path-scoped process
control, safe portable-folder operations, and the ability to launch and control, safe portable-folder operations, and the ability to launch and
render the in-binary test flow. A locked macOS session disables Computer Use render the in-binary test flow. A locked macOS session disables Computer Use
only; it does not fail this preflight or block testing, even when policy was only; it does not fail this preflight or block testing, even when policy was
`required`. `required`.
5. For a new run require a clean tracked Telegram worktree, clean submodules, 6. For a new run require a clean tracked Telegram worktree, clean submodules,
and no unrelated untracked files, then initialize local recovery state: and no unrelated untracked files, then initialize local recovery state:
```bash ```bash
@@ -97,9 +107,10 @@ Before planning or editing:
and records current `HEAD` in `RUN_REF`. Later task commits may remain above and records current `HEAD` in `RUN_REF`. Later task commits may remain above
the retained implementation. Never resolve or record a ref's object name in the retained implementation. Never resolve or record a ref's object name in
an artifact. an artifact.
6. For an interrupted run, allow dirty Telegram paths only when every one is 7. For an interrupted run, allow dirty Telegram paths only when every one is
listed in `work/owned-paths.txt` and completed phase artifacts prove this listed in `work/owned-paths.txt` and completed phase artifacts prove this
task owns them. Otherwise hard-stop without cleaning them. task owns them (`dirty_outside_owned` empty in the preflight report).
Otherwise hard-stop without cleaning them.
Do not stash. Do not reset, restore, stage, commit, or delete an unexpected Do not stash. Do not reset, restore, stage, commit, or delete an unexpected
path. Invocation authorizes recovery only for paths proven to belong to this path. Invocation authorizes recovery only for paths proven to belong to this
@@ -119,6 +130,7 @@ work/review1-lifetime.md
work/review1-reuse.md work/review1-reuse.md
work/review1-structure.md work/review1-structure.md
work/review1.md # synthesized review for the iteration work/review1.md # synthesized review for the iteration
work/test-design.md # check design drafted during review iteration 1
work/test.md work/test.md
work/result.md work/result.md
work/owned-paths.txt work/owned-paths.txt
@@ -165,16 +177,24 @@ exceptional `Block` commit captures the whole task record.
## Delegation ## Delegation
Use `references/phase-prompts.md` for the exact context, plan, assessment, Use `references/phase-prompts.md` for the exact context-and-plan, assessment,
implementation, build, review, and native-Windows normalization prompts. implementation, build, review, test-design, and native-Windows normalization
prompts, plus the host-specific orchestration rules.
- The performer is the only stateful task owner. - The performer is the only stateful task owner.
- Probe nested mode with the first real leaf phase. If depth, capacity, or - Probe nested mode with the first real leaf phase. If depth, capacity, or
policy rejects that spawn before work begins, execute the same prompt policy rejects that spawn before work begins, execute the same prompt
checklists in the performer. This is a supported mode, not degraded failure. checklists in the performer. This is a supported mode, not degraded failure.
- In nested mode, use a fresh leaf for context, planning, assessment, each - In nested mode, use a fresh leaf for context-and-plan, assessment, each
implementation unit, review, review-fix, and test authoring. Every leaf must implementation unit, review lenses, test design, review synthesis,
be told not to delegate and never to commit. review-fix, and test authoring. Every leaf must be told not to delegate and
never to commit.
- Small-task fast path: the performer may run the context-and-plan checklist
itself, without a leaf, only when the task spec itself names every file to
touch and the change is mechanical — roughly two source files or fewer, no
new APIs, strings, or style tokens, no layout derivation. When in doubt,
delegate. Assessment always runs as a fresh leaf and has the authority to
reject the fast-path sizing, which forces a proper Phase 1 leaf rerun.
- Use `fork_turns: "none"` with explicit paths. Fork the smallest turn window - Use `fork_turns: "none"` with explicit paths. Fork the smallest turn window
only for genuinely unavailable chat-only visual context. only for genuinely unavailable chat-only visual context.
- Inherit the parent's model and reasoning level. Do not invent tool fields. - Inherit the parent's model and reasoning level. Do not invent tool fields.
@@ -183,44 +203,51 @@ implementation, build, review, and native-Windows normalization prompts.
- Never duplicate the performer or an implementation unit with uncertain - Never duplicate the performer or an implementation unit with uncertain
writes. writes.
Write the delegated prompt first. Require an early small heartbeat and a final Write the delegated prompt first. Require a final reply containing only
reply containing only status, artifact paths, touched paths, and blocker. status, artifact paths, touched paths, and blocker. On Claude Code, run each
Poll no longer than 60 seconds. A timeout is not failure. Use artifact mtimes leaf as a synchronous foreground call and validate its artifacts when the call
and heartbeat counters; after five minutes without movement, message the same returns; run independent leaves of one step as parallel calls in a single
target, and after a second unchanged five-minute window interrupt and retry message. On Codex, use the asynchronous wait ladder from the phase prompts:
that disposable phase once. Never replace a live stateful performer. poll no longer than 60 seconds, treat a timeout as not-failure, use artifact
mtimes and heartbeat counters, message the target after five minutes without
movement, and interrupt and retry that disposable phase once after a second
unchanged window. On either host, never replace a live stateful performer.
## Implementation phases ## Implementation phases
Run sequentially: Run sequentially:
1. **Context.** Write a self-contained `work/context.md`. For project work, 1. **Context, visual design, and plan.** One leaf writes a self-contained
read the current project file and nearest approved task context, then write `work/context.md`, then — for `Visual: layout` tasks — `work/visual.md`,
`work/project.proposed.md` as a coherent finished-state blueprint. Use the then `work/plan.md` with exact files, functions, ordered steps, bounded
Phase 1F prompt when prior task context exists; otherwise use Phase 1 with phases, owned write sets, Debug build verification, and status checkboxes.
the project file. Do not promote the proposal yet; blocked work must not For project work it also writes `work/project.proposed.md` as a coherent
become project truth. finished-state blueprint; use the Phase 1F prompt when prior task context
2. **Visual design.** For `Visual: layout`, write `work/visual.md`. Derive every exists, otherwise Phase 1 with the project file. Do not promote the
dimension from request relationships, supplied images, font metrics, style proposal yet; blocked work must not become project truth.
tokens, sibling geometry, or a cited desktop analogue. Use ordered The visual contract derives every dimension from request relationships,
calculations, tolerances, relationship checks, same-scale comparison, and supplied images, font metrics, style tokens, sibling geometry, or a cited
an adversarial rejection pass. For `Visual: appearance`, keep the lighter desktop analogue, with ordered calculations, tolerances, relationship
exact color/text/glyph oracle. Skip for non-visual work. checks, same-scale comparison, and an adversarial rejection pass. For
3. **Plan.** Write `work/plan.md` with exact files, functions, ordered steps, `Visual: appearance`, keep the lighter exact color/text/glyph oracle. Skip
bounded phases, owned write sets, Debug build verification, and status the visual step for non-visual work.
checkboxes. Small-task fast path: under the strict criteria in the Delegation section,
4. **Assess.** Independently verify paths and APIs, completeness, design, the performer may run this phase as a same-session checklist producing the
duplication, edge cases, repository conventions, and phase sizing. Require same artifacts.
`Phases: <N>` and `Assessed: yes`. 2. **Assess.** Independently verify paths and APIs, completeness, design,
5. **Implement.** Run one leaf per assessed plan phase. Before each edit, duplication, edge cases, repository conventions, and phase sizing; on
layout tasks verify the visual contract's anchors and derivation; on a
fast-path plan verify the sizing itself. Require `Phases: <N>` and
`Assessed: yes`.
3. **Implement.** Run one leaf per assessed plan phase. Before each edit,
update `work/owned-paths.txt`. A leaf edits only its owned paths and its update `work/owned-paths.txt`. A leaf edits only its owned paths and its
phase status; it does not commit. phase status; it does not commit.
6. **Build.** Run the resolved Debug build in the performer. Fix only build 4. **Build.** Run the resolved Debug build in the performer. Fix only build
errors belonging to the task. If the task changed only a resource consumed errors belonging to the task. If the task changed only a resource consumed
by codegen, force its documented regeneration so the Debug binary contains by codegen, force its documented regeneration so the Debug binary contains
the new resource. A file-lock/access-denied build error is an immediate the new resource. A file-lock/access-denied build error is an immediate
global hard stop with no retry or workaround. global hard stop with no retry or workaround.
7. **Review.** Run the multi-lens review/fix loop from the phase prompts for up 5. **Review.** Run the multi-lens review/fix loop from the phase prompts for up
to three review iterations. Each iteration runs four independent lenses over to three review iterations. Each iteration runs four independent lenses over
the task diff — correctness, lifetime and ownership, reuse, structure — and the task diff — correctness, lifetime and ownership, reuse, structure — and
then one synthesis pass that confirms every finding against the code itself then one synthesis pass that confirms every finding against the code itself
@@ -228,28 +255,40 @@ Run sequentially:
defaults to not clean and must record the surfaces it checked; an approved defaults to not clean and must record the surfaces it checked; an approved
review carries that merged coverage as the evidence for approval. Rebuild review carries that merged coverage as the evidence for approval. Rebuild
after every fix pass. Give the correctness and structure lenses the visual after every fix pass. Give the correctness and structure lenses the visual
contract on layout tasks. contract on layout tasks. Alongside the iteration-1 lenses, spawn the
8. **Normalize.** On native non-WSL Windows, normalize only task-owned source, Phase 6d test-design leaf; it drafts `work/test-design.md` from the spec,
plan, and current diff so the test loop does not start from scratch.
6. **Normalize.** On native non-WSL Windows, normalize only task-owned source,
header, style, localization, and build/config text to CRLF without BOM, header, style, localization, and build/config text to CRLF without BOM,
preserving content and trailing-newline state, then rebuild. On macOS, preserving content and trailing-newline state, then rebuild. On macOS,
Linux, and WSL preserve LF/no-BOM. Linux, and WSL preserve LF/no-BOM.
9. **Commit and test.** Create the Telegram implementation commit, then run the 7. **Commit and test.** Create the Telegram implementation commit with the
test loop below. An implementation bug creates the next committed attempt; scripted helper, then run the test loop below:
keep the same `Task:` locator on every attempt. After each clean buildable
attempt, move the local retained-implementation ref with:
```bash ```bash
python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \ python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \
source-mark-green --source-root SOURCE_ROOT --task TASK_ID source-commit --source-root SOURCE_ROOT --task TASK_ID \
--subject "<one concise plain-language subject>" --mark-green
``` ```
It verifies every dirty path against `work/owned-paths.txt` (plus the
optional `tasks/TASK_ID.md` source note), stages exactly those paths,
writes and validates the exact three-line message, and with `--mark-green`
moves the retained-implementation refs — replacing manual staging and the
separate `source-mark-green` call. An implementation bug creates the next
committed attempt through the same helper; keep the same `Task:` locator on
every attempt.
## Telegram commits ## Telegram commits
The performer owns commit boundaries. Inspect every dirty path, verify it is in The performer owns commit boundaries. The workspace helper's `source-commit`
the union of owned write sets, and stage only explicit paths. Never use command is the standard mechanism: it enforces this section's contract —
`git add -A`. Commit an intended submodule first only when its preflight was every dirty path verified against the union of owned write sets, only explicit
clean and all of its changes belong to this task, then stage the superproject paths staged, never `git add -A`, the exact three-line message — in one
pointer. deterministic call. Commit an intended submodule first, manually, only when
its preflight was clean and all of its changes belong to this task, then stage
the superproject pointer; the helper refuses dirty submodule pointers so an
unintended one can never slip into an attempt.
Every implementation or implementation-fix commit message is exactly: Every implementation or implementation-fix commit message is exactly:
@@ -279,16 +318,38 @@ or operating a UI driver. Retain all task-derived oracle, layout measurement,
overlay, watchdog, crash/assertion, hang, account, attempt, report, and evidence overlay, watchdog, crash/assertion, hang, account, attempt, report, and evidence
rules, with these external-task safety adaptations: rules, with these external-task safety adaptations:
- The performer, not leaves, stages and commits every attempt. - The performer, not leaves, stages and commits every attempt (through
- Overlay code may modify only tracked task-owned source paths. Inventory them `source-commit`).
in `work/test-overlay.paths`; never introduce an untracked source file. - The overlay is authored against the permanent harness in
- Save the overlay with `git diff --binary HEAD > work/test-overlay.patch`, `Telegram/SourceFiles/test/` and normally consists of replacing
verify it is nonempty and reapplicable, then restore only inventoried overlay `Telegram/SourceFiles/test/test_scenario.cpp` alone — that slot file is
paths to `RUN_REF`. Do not run a repository-wide hard reset. After an always a permitted overlay path. Beyond it, overlay code may modify only
implementation-fix commit, move both `GREEN_REF` and `RUN_REF` to the new tracked task-owned source paths (one-line `Test::Fire` waitpoints or true
clean tip before reapplying the overlay. in-situ injections). Inventory every overlay path in
- Reapply with `git apply --3way`; re-author a conflicting hunk from `test.md` `work/test-overlay.paths`; never introduce an untracked source file, and
rather than leaving conflict markers. never re-implement logging, widget-finding, capture, watchdog, or quit
mechanics the harness already provides.
- Save and restore the overlay with the scripted helper instead of manual git
mechanics:
```bash
python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \
overlay-save --source-root SOURCE_ROOT --task TASK_ID --restore run
```
It verifies every dirty path against the inventory, refuses untracked
files, writes a nonempty verified `work/test-overlay.patch`, and restores
only inventoried paths to `RUN_REF` — never a repository-wide hard reset.
After an implementation-fix commit (`source-commit --mark-green` moves both
`GREEN_REF` and `RUN_REF`), reapply with:
```bash
python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \
overlay-apply --source-root SOURCE_ROOT --task TASK_ID
```
It applies with `--3way` and reports conflicted paths; re-author a
conflicting hunk from `test.md` rather than leaving conflict markers.
- On locked macOS, force overlay-only testing without waiting or blocking. - On locked macOS, force overlay-only testing without waiting or blocking.
Encode the complete interaction inside the Debug binary using application Encode the complete interaction inside the Debug binary using application
actions or Qt events, log assertions and geometry, capture widgets/windows actions or Qt events, log assertions and geometry, capture widgets/windows
@@ -305,20 +366,43 @@ rules, with these external-task safety adaptations:
- Set `RUN_DIR` and `EVIDENCE_DIR` to - Set `RUN_DIR` and `EVIDENCE_DIR` to
`TASK_DIR/.local/runs/attempt-<n>/run-<m>/`. Promote only decisive compact `TASK_DIR/.local/runs/attempt-<n>/run-<m>/`. Promote only decisive compact
logs/screenshots into tracked `evidence/`. logs/screenshots into tracked `evidence/`.
- Launch every test binary with `-testagent`. Detect crashes from process death - Execute every app run through the scripted runner instead of hand-composed
without `TEST_COMPLETE` plus a new `tdata/working`, not exit code. Read launch/poll/kill shell:
captured stderr first, then `tdata/working`, then note the minidump.
- Before each app run and build, stop only a process whose resolved executable ```bash
path equals `EXE`. Never use image-name-wide termination. python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \
- Enforce both the in-app watchdog and an external wall-clock deadline. Count test-run --exe EXE --run-dir RUN_DIR [--env NAME=VALUE ...] \
test runs independently from implementation attempts and stop at [--deadline 120] [--quiet 60]
`MAX_TEST_RUNS`. ```
One call performs the idempotent portable-account SETUP, the path-scoped
straggler kill, the `-testagent -noupdate` launch (never auto-update a
test binary) with stdout/stderr capture and
`TDESKTOP_TEST_EVIDENCE_DIR` set to `RUN_DIR`, the external wall-clock
deadline and quiet-log watchdog, and returns one JSON report: outcome,
`TEST_COMPLETE` state, parsed `TEST_STEP`/`TEST_RESULT`/`SCREENSHOT`
markers, stderr tail, fresh `tdata/working` crash excerpt, and minidump
paths. The performer then judges the evidence itself — the runner gathers,
it never assesses. Crash detection keys on process death without
`TEST_COMPLETE` plus a fresh `tdata/working`, not exit code.
- If the account breaks mid-loop (login screen, `AUTH_KEY_DUPLICATED`), run
`test-account-reset --exe EXE` — it deletes only a marked live copy and
re-copies golden — then retry once.
- Enforce the in-app watchdog too. Count test runs independently from
implementation attempts and stop at `MAX_TEST_RUNS`.
- Plan the fewest possible runs: one complete programmed scenario per attempt - Plan the fewest possible runs: one complete programmed scenario per attempt
that proves every check in a single execution, splitting only for checks 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, that cannot share one process lifetime. `MAX_TEST_RUNS` is a safety cap,
never a budget to spend. never a budget to spend.
- Delete the overlay-bearing Debug executable on every terminal test exit so - Start the test author from `work/test-design.md` when the review-phase
the user cannot launch it accidentally. draft exists; the author still reconciles every drafted check against the
final retained diff before writing overlay code, and owns `test.md`.
- On every terminal test exit, run `test-cleanup --exe EXE --delete-exe` so no
straggler survives and no overlay-bearing Debug executable is left for the
user to launch accidentally.
- When a task needs an out-of-scope fence, snapshot it with
`fence-create --file <baseline> --root SOURCE_ROOT <paths...>` and verify it
before publication with `fence-check`.
The test author must read the full task specification and every current-branch The test author must read the full task specification and every current-branch
commit whose message has this task's exact `Task:` line. For an uninterrupted commit whose message has this task's exact `Task:` line. For an uninterrupted

View File

@@ -8,8 +8,10 @@ import os
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
import re import re
import shutil import shutil
import signal
import subprocess import subprocess
import sys import sys
import time
TAG_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*") TAG_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*")
@@ -36,6 +38,14 @@ STATE_FIELD_ORDER = [
"phase", "phase",
"inbox_receipt", "inbox_receipt",
] ]
PORTABLE_GOLDEN = "test_TelegramForcePortable"
PORTABLE_LIVE = "TelegramForcePortable"
PORTABLE_REAL = "real_TelegramForcePortable"
PORTABLE_MARKER = "testing"
OVERLAY_PATHS_FILE = "test-overlay.paths"
OVERLAY_PATCH_FILE = "test-overlay.patch"
TEST_LOG_FILE = "test_log.txt"
TEST_COMPLETE_MARKER = "TEST_COMPLETE"
class WorkspaceError(RuntimeError): class WorkspaceError(RuntimeError):
@@ -1004,24 +1014,648 @@ def command_source_begin(args):
}, indent=2, sort_keys=True)) }, indent=2, sort_keys=True))
def command_source_mark_green(args): def mark_source_green(config, task_id):
config, _ = task_action_config(args)
source = Path(config["source_root"]) source = Path(config["source_root"])
ensure_clean(source, "Telegram source checkout") ensure_clean(source, "Telegram source checkout")
base = source_task_ref(args.task, "base") base = source_task_ref(task_id, "base")
if resolved_ref(source, base) is None: if resolved_ref(source, base) is None:
raise WorkspaceError("The local task baseline ref is missing") raise WorkspaceError("The local task baseline ref is missing")
if run_git(source, "merge-base", "--is-ancestor", base, "HEAD", check=False).returncode: if run_git(source, "merge-base", "--is-ancestor", base, "HEAD", check=False).returncode:
raise WorkspaceError("The retained implementation does not descend from the task baseline") raise WorkspaceError("The retained implementation does not descend from the task baseline")
validate_task_commit(source, "HEAD", args.task) validate_task_commit(source, "HEAD", task_id)
run_git(source, "update-ref", source_task_ref(args.task, "green"), "HEAD") run_git(source, "update-ref", source_task_ref(task_id, "green"), "HEAD")
run_git(source, "update-ref", source_task_ref(args.task, "run"), "HEAD") run_git(source, "update-ref", source_task_ref(task_id, "run"), "HEAD")
def command_source_mark_green(args):
config, _ = task_action_config(args)
mark_source_green(config, args.task)
print(json.dumps({ print(json.dumps({
"task": args.task, "task": args.task,
"source_state": "retained", "source_state": "retained",
}, indent=2, sort_keys=True)) }, indent=2, sort_keys=True))
def run_git_binary(path, *args):
result = subprocess.run(
["git", "-C", str(path), *args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode:
raise WorkspaceError(
result.stderr.decode("utf-8", "replace").strip()
or "git failed"
)
return result.stdout
def resolved_exe(value):
path = Path(value).expanduser().resolve()
if not path.is_file():
raise WorkspaceError(f"Test executable does not exist: {path}")
return path
def portable_root_for(exe, override):
if override:
root = Path(override).expanduser().resolve()
if not root.is_dir():
raise WorkspaceError(f"Portable root does not exist: {root}")
return root
for parent in exe.parents:
if parent.suffix == ".app":
return parent.parent
return exe.parent
def setup_test_account(root):
golden = root / PORTABLE_GOLDEN
live = root / PORTABLE_LIVE
real = root / PORTABLE_REAL
if not golden.is_dir():
raise WorkspaceError(f"Missing golden test account: {golden}")
if (live / PORTABLE_MARKER).exists():
return "reused-marked-live"
if live.exists():
if real.exists():
shutil.rmtree(live)
state = "replaced-manual-live"
else:
live.rename(real)
state = "preserved-real"
else:
state = "fresh-copy"
shutil.copytree(golden, live)
(live / PORTABLE_MARKER).write_text("1\n", encoding="utf-8")
return state
def reset_broken_test_account(root):
live = root / PORTABLE_LIVE
if not (live / PORTABLE_MARKER).exists():
raise WorkspaceError(
f"Refusing to reset an unmarked live folder: {live}"
)
shutil.rmtree(live)
return setup_test_account(root)
def processes_with_executable(exe):
value = str(exe)
pids = []
if sys.platform == "win32":
escaped = value.replace("'", "''")
script = (
"Get-CimInstance Win32_Process | "
f"Where-Object {{ $_.ExecutablePath -eq '{escaped}' }} | "
"ForEach-Object { $_.ProcessId }"
)
result = subprocess.run(
["powershell", "-NoProfile", "-Command", script],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
for line in result.stdout.splitlines():
line = line.strip()
if line.isdigit():
pids.append(int(line))
return pids
result = subprocess.run(
["ps", "-axo", "pid=,comm="],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
for line in result.stdout.splitlines():
parts = line.strip().split(None, 1)
if len(parts) != 2 or not parts[0].isdigit():
continue
pid, comm = int(parts[0]), parts[1]
if comm == value:
pids.append(pid)
continue
if sys.platform.startswith("linux"):
try:
if os.readlink(f"/proc/{pid}/exe") == value:
pids.append(pid)
except OSError:
continue
return pids
def kill_processes_with_executable(exe):
killed = []
for pid in processes_with_executable(exe):
try:
if sys.platform == "win32":
subprocess.run(
["taskkill", "/PID", str(pid), "/F"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
else:
os.kill(pid, signal.SIGKILL)
killed.append(pid)
except (OSError, subprocess.SubprocessError):
continue
return killed
def parse_test_log(text):
steps = []
passed = []
failed = []
screenshots = []
for line in text.splitlines():
if line.startswith("TEST_STEP: "):
steps.append(line[len("TEST_STEP: "):])
elif line.startswith("TEST_RESULT: PASS: "):
passed.append(line[len("TEST_RESULT: PASS: "):])
elif line.startswith("TEST_RESULT: FAIL: "):
failed.append(line[len("TEST_RESULT: FAIL: "):])
elif line.startswith("SCREENSHOT: "):
screenshots.append(line[len("SCREENSHOT: "):])
return {
"steps": steps,
"pass": passed,
"fail": failed,
"screenshots": screenshots,
}
def tail_of_file(path, lines=60):
if not path.is_file():
return None
text = path.read_text(encoding="utf-8", errors="replace")
return "\n".join(text.splitlines()[-lines:]) if text.strip() else None
def parse_env_values(values):
environment = {}
for value in values or ():
if "=" not in value:
raise WorkspaceError(f"Invalid --env value (want NAME=VALUE): {value!r}")
name, content = value.split("=", 1)
if not name:
raise WorkspaceError(f"Invalid --env value (empty name): {value!r}")
environment[name] = content
return environment
def command_test_run(args):
exe = resolved_exe(args.exe)
run_dir = Path(args.run_dir).expanduser().resolve()
run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "screenshots").mkdir(exist_ok=True)
portable = portable_root_for(exe, args.portable_root)
account = setup_test_account(portable)
stragglers = kill_processes_with_executable(exe)
log_path = run_dir / TEST_LOG_FILE
if log_path.exists():
log_path.unlink()
stdout_path = run_dir / "app_stdout.txt"
stderr_path = run_dir / "app_stderr.txt"
working = portable / PORTABLE_LIVE / "tdata" / "working"
dumps_dir = portable / PORTABLE_LIVE / "tdata" / "dumps"
environment = os.environ.copy()
environment["TDESKTOP_TEST_EVIDENCE_DIR"] = str(run_dir)
environment.update(parse_env_values(args.env))
launched_at = time.time()
with stdout_path.open("wb") as out, stderr_path.open("wb") as err:
process = subprocess.Popen(
[str(exe), "-testagent", "-noupdate"],
stdout=out,
stderr=err,
env=environment,
cwd=str(portable),
)
outcome = None
exit_code = None
complete_seen_at = None
last_size = -1
last_change = launched_at
while True:
time.sleep(0.5)
now = time.time()
size = log_path.stat().st_size if log_path.is_file() else -1
if size != last_size:
last_size = size
last_change = now
complete = False
if size > 0:
complete = TEST_COMPLETE_MARKER in log_path.read_text(
encoding="utf-8", errors="replace"
)
if complete and complete_seen_at is None:
complete_seen_at = now
exit_code = process.poll()
if exit_code is not None:
outcome = "exited"
break
if complete_seen_at is not None and now - complete_seen_at > args.grace:
process.kill()
outcome = "killed-after-complete"
break
if now - launched_at > args.deadline:
process.kill()
outcome = "deadline-killed"
break
if now - last_change > args.quiet and complete_seen_at is None:
process.kill()
outcome = "quiet-killed"
break
process.wait()
ended_at = time.time()
kill_processes_with_executable(exe)
log_text = (
log_path.read_text(encoding="utf-8", errors="replace")
if log_path.is_file()
else ""
)
test_complete = TEST_COMPLETE_MARKER in log_text
crash_report_fresh = (
working.is_file()
and working.stat().st_mtime >= launched_at
and working.stat().st_size > 0
)
dumps = sorted(
str(path) for path in dumps_dir.glob("*.dmp")
if path.stat().st_mtime >= launched_at
) if dumps_dir.is_dir() else []
if outcome == "exited":
if test_complete:
verdict_hint = "complete"
elif crash_report_fresh or dumps:
verdict_hint = "crash"
else:
verdict_hint = "died-without-complete"
elif outcome == "killed-after-complete":
verdict_hint = "complete"
else:
verdict_hint = "hang"
print(json.dumps({
"account": account,
"crash_report": str(working) if working.is_file() else None,
"crash_report_excerpt": (
working.read_text(encoding="utf-8", errors="replace")[:4000]
if crash_report_fresh
else None
),
"crash_report_fresh": crash_report_fresh,
"dumps": dumps,
"duration_seconds": round(ended_at - launched_at, 1),
"exe": str(exe),
"exit_code": exit_code,
"log_path": str(log_path) if log_path.is_file() else None,
"markers": parse_test_log(log_text),
"outcome": outcome,
"portable_root": str(portable),
"run_dir": str(run_dir),
"stderr_tail": tail_of_file(stderr_path),
"stragglers_killed": stragglers,
"test_complete": test_complete,
"verdict_hint": verdict_hint,
}, indent=2, sort_keys=True))
def command_test_cleanup(args):
exe = Path(args.exe).expanduser().resolve()
killed = kill_processes_with_executable(exe)
deleted = False
if args.delete_exe and exe.is_file():
exe.unlink()
deleted = True
print(json.dumps({
"deleted_exe": deleted,
"exe": str(exe),
"killed": killed,
}, indent=2, sort_keys=True))
def command_test_account_reset(args):
exe = resolved_exe(args.exe)
kill_processes_with_executable(exe)
portable = portable_root_for(exe, args.portable_root)
account = reset_broken_test_account(portable)
print(json.dumps({
"account": account,
"portable_root": str(portable),
}, indent=2, sort_keys=True))
def overlay_work_dir(config, slot, task_id):
work = slot / task_relative_dir(task_id) / "work"
if not work.is_dir():
raise WorkspaceError(f"Task work directory does not exist: {work}")
return work
def read_overlay_paths(work):
paths_file = work / OVERLAY_PATHS_FILE
if not paths_file.is_file():
raise WorkspaceError(f"Missing overlay inventory: {paths_file}")
paths = [
line.strip() for line in
paths_file.read_text(encoding="utf-8-sig").splitlines()
if line.strip()
]
if not paths:
raise WorkspaceError(f"Empty overlay inventory: {paths_file}")
return paths
def command_overlay_save(args):
config, slot = task_action_config(args)
source = Path(config["source_root"])
work = overlay_work_dir(config, slot, args.task)
inventory = read_overlay_paths(work)
untracked = run_git(
source, "ls-files", "--others", "--exclude-standard"
).stdout.splitlines()
if untracked:
raise WorkspaceError(
"The overlay may not use untracked source files: "
+ ", ".join(untracked)
)
dirty = changed_paths(source)
outside = [
path for path in dirty
if not path_is_covered(path, inventory)
]
if outside:
raise WorkspaceError(
"Dirty source paths are outside the overlay inventory: "
+ ", ".join(outside)
)
patch = run_git_binary(source, "diff", "--binary", "HEAD")
if not patch.strip():
raise WorkspaceError("The overlay diff is empty; nothing to save")
patch_path = work / OVERLAY_PATCH_FILE
patch_path.write_bytes(patch)
check = subprocess.run(
["git", "-C", str(source), "apply", "--check", "--reverse", str(patch_path)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if check.returncode:
raise WorkspaceError(
"The saved overlay patch does not verify: "
+ check.stderr.strip()
)
restored = []
if args.restore != "none":
ref = source_task_ref(args.task, args.restore)
if resolved_ref(source, ref) is None:
raise WorkspaceError(f"Missing task ref for restore: {ref}")
run_git(source, "checkout", ref, "--", *inventory)
restored = inventory
remaining = [
path for path in changed_paths(source)
if path_is_covered(path, inventory)
]
if remaining:
raise WorkspaceError(
"Overlay paths remain dirty after restore: "
+ ", ".join(remaining)
)
print(json.dumps({
"patch": str(patch_path),
"patch_bytes": len(patch),
"restored": restored,
"task": args.task,
}, indent=2, sort_keys=True))
def command_overlay_apply(args):
config, slot = task_action_config(args)
source = Path(config["source_root"])
work = overlay_work_dir(config, slot, args.task)
patch_path = work / OVERLAY_PATCH_FILE
if not patch_path.is_file() or not patch_path.stat().st_size:
raise WorkspaceError(f"Missing overlay patch: {patch_path}")
inventory = read_overlay_paths(work)
result = subprocess.run(
["git", "-C", str(source), "apply", "--3way", str(patch_path)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
conflicts = run_git(
source, "diff", "--name-only", "--diff-filter=U"
).stdout.splitlines()
applied = not result.returncode and not conflicts
outside = [
path for path in changed_paths(source)
if not path_is_covered(path, inventory)
]
print(json.dumps({
"applied": applied,
"conflicts": conflicts,
"error": result.stderr.strip() if result.returncode else None,
"outside_inventory": outside,
"task": args.task,
}, indent=2, sort_keys=True))
def gitlink_paths(source, paths):
result = []
for path in paths:
entry = run_git(source, "ls-files", "-s", "--", path).stdout
if entry.startswith("160000 "):
result.append(path)
return result
def command_source_commit(args):
config, slot = task_action_config(args)
source = Path(config["source_root"])
subject = args.subject.strip()
if not subject or "\n" in subject:
raise WorkspaceError("The commit subject must be a single non-empty line")
if len(subject) > 72:
raise WorkspaceError(
f"The commit subject is too long ({len(subject)} > 72 characters)"
)
work = slot / task_relative_dir(args.task) / "work"
owned_file = work / "owned-paths.txt"
if not owned_file.is_file():
raise WorkspaceError(f"Missing owned-paths inventory: {owned_file}")
owned = [
line.strip() for line in
owned_file.read_text(encoding="utf-8-sig").splitlines()
if line.strip()
]
if not owned:
raise WorkspaceError(f"Empty owned-paths inventory: {owned_file}")
source_note = f"tasks/{args.task}.md"
allowed = owned + [source_note]
dirty = changed_paths(source)
if not dirty:
raise WorkspaceError("The source checkout has no changes to commit")
outside = [
path for path in dirty
if not path_is_covered(path, allowed)
]
if outside:
raise WorkspaceError(
"Dirty source paths are outside the owned write set: "
+ ", ".join(outside)
)
submodules = gitlink_paths(source, dirty)
if submodules:
raise WorkspaceError(
"Submodule pointers must be committed explicitly first: "
+ ", ".join(submodules)
)
for path in dirty:
run_git(source, "add", "--", path)
run_git(
source,
"commit",
"-m",
f"{subject}\n\nTask: {args.task}",
)
validate_task_commit(source, "HEAD", args.task)
if args.mark_green:
mark_source_green(config, args.task)
print(json.dumps({
"committed": dirty,
"marked_green": bool(args.mark_green),
"subject": subject,
"task": args.task,
}, indent=2, sort_keys=True))
def command_source_verify_commit(args):
source = source_root(args.source_root)
validate_task_commit(source, args.ref, args.task)
subject = run_git(
source, "show", "-s", "--format=%s", args.ref
).stdout.strip()
print(json.dumps({
"ref": args.ref,
"subject": subject,
"task": args.task,
"valid": True,
}, indent=2, sort_keys=True))
def file_sha256(path):
digest = hashlib.sha256()
with path.open("rb") as stream:
while True:
chunk = stream.read(1024 * 1024)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
def command_fence_create(args):
root = Path(args.root).expanduser().resolve()
if not root.is_dir():
raise WorkspaceError(f"Fence root does not exist: {root}")
if not args.paths:
raise WorkspaceError("No fence paths were provided")
lines = []
for value in args.paths:
path = root / value
if not path.is_file():
raise WorkspaceError(f"Fence path does not exist: {path}")
lines.append(f"{file_sha256(path)} {value}")
target = Path(args.file).expanduser().resolve()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(json.dumps({
"file": str(target),
"paths": len(lines),
}, indent=2, sort_keys=True))
def command_fence_check(args):
root = Path(args.root).expanduser().resolve()
target = Path(args.file).expanduser().resolve()
if not target.is_file():
raise WorkspaceError(f"Fence baseline does not exist: {target}")
mismatched = []
missing = []
checked = 0
for line in target.read_text(encoding="utf-8-sig").splitlines():
line = line.strip()
if not line:
continue
if " " not in line:
raise WorkspaceError(f"Invalid fence line: {line!r}")
expected, value = line.split(" ", 1)
path = root / value
checked += 1
if not path.is_file():
missing.append(value)
elif file_sha256(path) != expected:
mismatched.append(value)
ok = not mismatched and not missing
print(json.dumps({
"checked": checked,
"mismatched": mismatched,
"missing": missing,
"ok": ok,
}, indent=2, sort_keys=True))
if not ok:
sys.exit(2)
def command_source_preflight(args):
config, slot = task_action_config(args)
source = Path(config["source_root"])
dirty = changed_paths(source)
submodule_lines = run_git(
source, "submodule", "status", "--recursive"
).stdout.splitlines()
submodules_dirty = [
line.strip() for line in submodule_lines
if line and line[0] in "+-U"
]
work = slot / task_relative_dir(args.task) / "work"
owned_file = work / "owned-paths.txt"
owned = [
line.strip() for line in
owned_file.read_text(encoding="utf-8-sig").splitlines()
if line.strip()
] if owned_file.is_file() else []
dirty_outside_owned = [
path for path in dirty
if not path_is_covered(path, owned + [f"tasks/{args.task}.md"])
]
result = {
"dirty": dirty,
"dirty_outside_owned": dirty_outside_owned,
"owned_paths_present": owned_file.is_file(),
"source_clean": not dirty,
"submodules_dirty": submodules_dirty,
"task": args.task,
}
if args.exe:
exe = Path(args.exe).expanduser().resolve()
result["exe_present"] = exe.is_file()
if exe.is_file():
portable = portable_root_for(exe, None)
result["golden_account_present"] = (
portable / PORTABLE_GOLDEN
).is_dir()
result["live_marker_present"] = (
portable / PORTABLE_LIVE / PORTABLE_MARKER
).exists()
print(json.dumps(result, indent=2, sort_keys=True))
def command_finish(args): def command_finish(args):
config, slot = task_action_config(args, allow_project=True) config, slot = task_action_config(args, allow_project=True)
ensure_clean(Path(config["source_root"]), "Telegram source checkout") ensure_clean(Path(config["source_root"]), "Telegram source checkout")
@@ -1474,7 +2108,7 @@ def clear_payload(inbox):
def command_finalize(args): def command_finalize(args):
transaction, metadata = load_transaction(args.transaction) transaction, metadata = load_transaction(args.transaction)
inbox = Path(metadata["inbox"]) inbox = Path(metadata["inbox"])
main = Path(metadata["ai_main"]) main = Path(metadata["ai_main"]).resolve()
receipt_value = normalized_publish_path(args.receipt) receipt_value = normalized_publish_path(args.receipt)
if not receipt_value.startswith("receipts/"): if not receipt_value.startswith("receipts/"):
raise WorkspaceError("The tracked receipt must be below receipts/") raise WorkspaceError("The tracked receipt must be below receipts/")
@@ -1591,6 +2225,71 @@ def parse_args():
source_mark_green.add_argument("--task", required=True) source_mark_green.add_argument("--task", required=True)
source_mark_green.set_defaults(handler=command_source_mark_green) source_mark_green.set_defaults(handler=command_source_mark_green)
source_commit = subparsers.add_parser("source-commit")
add_common_arguments(source_commit)
source_commit.add_argument("--task", required=True)
source_commit.add_argument("--subject", required=True)
source_commit.add_argument("--mark-green", action="store_true")
source_commit.set_defaults(handler=command_source_commit)
source_verify_commit = subparsers.add_parser("source-verify-commit")
add_common_arguments(source_verify_commit)
source_verify_commit.add_argument("--task", required=True)
source_verify_commit.add_argument("--ref", default="HEAD")
source_verify_commit.set_defaults(handler=command_source_verify_commit)
source_preflight = subparsers.add_parser("source-preflight")
add_common_arguments(source_preflight)
source_preflight.add_argument("--task", required=True)
source_preflight.add_argument("--exe")
source_preflight.set_defaults(handler=command_source_preflight)
overlay_save = subparsers.add_parser("overlay-save")
add_common_arguments(overlay_save)
overlay_save.add_argument("--task", required=True)
overlay_save.add_argument(
"--restore",
choices=("run", "green", "none"),
default="run",
)
overlay_save.set_defaults(handler=command_overlay_save)
overlay_apply = subparsers.add_parser("overlay-apply")
add_common_arguments(overlay_apply)
overlay_apply.add_argument("--task", required=True)
overlay_apply.set_defaults(handler=command_overlay_apply)
test_run = subparsers.add_parser("test-run")
test_run.add_argument("--exe", required=True)
test_run.add_argument("--run-dir", required=True)
test_run.add_argument("--portable-root")
test_run.add_argument("--deadline", type=float, default=120.0)
test_run.add_argument("--quiet", type=float, default=60.0)
test_run.add_argument("--grace", type=float, default=15.0)
test_run.add_argument("--env", action="append")
test_run.set_defaults(handler=command_test_run)
test_cleanup = subparsers.add_parser("test-cleanup")
test_cleanup.add_argument("--exe", required=True)
test_cleanup.add_argument("--delete-exe", action="store_true")
test_cleanup.set_defaults(handler=command_test_cleanup)
test_account_reset = subparsers.add_parser("test-account-reset")
test_account_reset.add_argument("--exe", required=True)
test_account_reset.add_argument("--portable-root")
test_account_reset.set_defaults(handler=command_test_account_reset)
fence_create = subparsers.add_parser("fence-create")
fence_create.add_argument("--file", required=True)
fence_create.add_argument("--root", default=".")
fence_create.add_argument("paths", nargs="*")
fence_create.set_defaults(handler=command_fence_create)
fence_check = subparsers.add_parser("fence-check")
fence_check.add_argument("--file", required=True)
fence_check.add_argument("--root", default=".")
fence_check.set_defaults(handler=command_fence_check)
finish = subparsers.add_parser("finish") finish = subparsers.add_parser("finish")
add_common_arguments(finish) add_common_arguments(finish)
finish.add_argument("--task", required=True) finish.add_argument("--task", required=True)

View File

@@ -4,6 +4,7 @@ import contextlib
import datetime import datetime
import io import io
import json import json
import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import tempfile import tempfile
@@ -875,5 +876,419 @@ inbox_receipt: receipts/2026/07/19/test.md
self.assertIn("projects/old-project/tasks.md", staged) self.assertIn("projects/old-project/tasks.md", staged)
def write_fake_exe(path, script):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#!/bin/sh\n" + script, encoding="utf-8")
path.chmod(0o755)
return path
def make_portable_root(root):
debug = root / "out" / "Debug"
golden = debug / workspace.PORTABLE_GOLDEN
(golden / "tdata").mkdir(parents=True)
(golden / "tdata" / "key_data").write_text("golden\n", encoding="utf-8")
return debug
def source_repo_with_task(root):
source = root / "source"
git_repo(source)
(source / "Telegram" / "build").mkdir(parents=True)
tracked = source / "tracked.txt"
tracked.write_text("base\n", encoding="utf-8")
git(source, "add", "tracked.txt")
git(source, "commit", "-m", "Create baseline")
slot = root / "slot"
work = slot / "tasks" / TASK_ID / "work"
work.mkdir(parents=True)
config = {"source_root": str(source)}
return source, slot, work, config
def run_command(handler, **kwargs):
out = io.StringIO()
with contextlib.redirect_stdout(out):
handler(SimpleNamespace(**kwargs))
return json.loads(out.getvalue())
class MechanicsTest(unittest.TestCase):
def test_parse_env_values_requires_name_value_pairs(self):
self.assertEqual(
workspace.parse_env_values(["A=1", "B=x=y"]),
{"A": "1", "B": "x=y"},
)
with self.assertRaises(workspace.WorkspaceError):
workspace.parse_env_values(["NOVALUE"])
with self.assertRaises(workspace.WorkspaceError):
workspace.parse_env_values(["=x"])
def test_setup_test_account_lifecycle(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
live = root / workspace.PORTABLE_LIVE
real = root / workspace.PORTABLE_REAL
golden = root / workspace.PORTABLE_GOLDEN
with self.assertRaisesRegex(workspace.WorkspaceError, "golden"):
workspace.setup_test_account(root)
(golden / "tdata").mkdir(parents=True)
(golden / "tdata" / "key_data").write_text("golden\n", encoding="utf-8")
self.assertEqual(workspace.setup_test_account(root), "fresh-copy")
self.assertTrue((live / workspace.PORTABLE_MARKER).is_file())
self.assertTrue((golden / "tdata" / "key_data").is_file())
self.assertEqual(
workspace.setup_test_account(root),
"reused-marked-live",
)
(live / workspace.PORTABLE_MARKER).unlink()
(live / "tdata" / "user_file").write_text("mine\n", encoding="utf-8")
self.assertEqual(workspace.setup_test_account(root), "preserved-real")
self.assertTrue((real / "tdata" / "user_file").is_file())
self.assertTrue((live / workspace.PORTABLE_MARKER).is_file())
self.assertFalse((live / "tdata" / "user_file").exists())
(live / workspace.PORTABLE_MARKER).unlink()
self.assertEqual(
workspace.setup_test_account(root),
"replaced-manual-live",
)
self.assertTrue((real / "tdata" / "user_file").is_file())
self.assertEqual(
workspace.reset_broken_test_account(root),
"fresh-copy",
)
(live / workspace.PORTABLE_MARKER).unlink()
with self.assertRaisesRegex(workspace.WorkspaceError, "unmarked"):
workspace.reset_broken_test_account(root)
@unittest.skipUnless(os.name == "posix", "posix launch mechanics")
def test_test_run_reports_complete_markers(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
debug = make_portable_root(root)
exe = write_fake_exe(debug / "Telegram", (
'LOG="$TDESKTOP_TEST_EVIDENCE_DIR/test_log.txt"\n'
'echo "TEST_STEP: open settings" >> "$LOG"\n'
'echo "TEST_RESULT: PASS: row painted" >> "$LOG"\n'
'echo "SCREENSHOT: /tmp/fake.png" >> "$LOG"\n'
'echo "TEST_COMPLETE" >> "$LOG"\n'
"exit 0\n"
))
result = run_command(
workspace.command_test_run,
exe=str(exe),
run_dir=str(root / "run1"),
portable_root=None,
deadline=20.0,
quiet=10.0,
grace=5.0,
env=["EXTRA_FLAG=1"],
)
self.assertEqual(result["outcome"], "exited")
self.assertEqual(result["verdict_hint"], "complete")
self.assertTrue(result["test_complete"])
self.assertEqual(result["exit_code"], 0)
self.assertEqual(result["account"], "fresh-copy")
self.assertEqual(result["markers"]["pass"], ["row painted"])
self.assertEqual(result["markers"]["screenshots"], ["/tmp/fake.png"])
self.assertFalse(result["crash_report_fresh"])
@unittest.skipUnless(os.name == "posix", "posix launch mechanics")
def test_test_run_reports_crash_diagnostics(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
debug = make_portable_root(root)
exe = write_fake_exe(debug / "Telegram", (
'LOG="$TDESKTOP_TEST_EVIDENCE_DIR/test_log.txt"\n'
'echo "TEST_STEP: about to crash" >> "$LOG"\n'
f'mkdir -p "{debug}/{workspace.PORTABLE_LIVE}/tdata"\n'
f'echo "Assertion: boom" > "{debug}/{workspace.PORTABLE_LIVE}/tdata/working"\n'
"exit 0\n"
))
result = run_command(
workspace.command_test_run,
exe=str(exe),
run_dir=str(root / "run1"),
portable_root=None,
deadline=20.0,
quiet=10.0,
grace=5.0,
env=None,
)
self.assertEqual(result["outcome"], "exited")
self.assertEqual(result["verdict_hint"], "crash")
self.assertFalse(result["test_complete"])
self.assertTrue(result["crash_report_fresh"])
self.assertIn("Assertion: boom", result["crash_report_excerpt"])
@unittest.skipUnless(os.name == "posix", "posix launch mechanics")
def test_test_run_kills_on_deadline(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
debug = make_portable_root(root)
exe = write_fake_exe(debug / "Telegram", "sleep 30\n")
result = run_command(
workspace.command_test_run,
exe=str(exe),
run_dir=str(root / "run1"),
portable_root=None,
deadline=2.0,
quiet=30.0,
grace=5.0,
env=None,
)
self.assertEqual(result["outcome"], "deadline-killed")
self.assertEqual(result["verdict_hint"], "hang")
self.assertFalse(result["test_complete"])
def test_portable_root_for_prefers_app_bundle_parent(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
binary = (
root / "out" / "Debug" / "Telegram.app"
/ "Contents" / "MacOS" / "Telegram"
)
binary.parent.mkdir(parents=True)
binary.write_text("", encoding="utf-8")
self.assertEqual(
workspace.portable_root_for(binary, None),
root / "out" / "Debug",
)
plain = root / "out" / "Debug" / "Telegram.exe"
plain.write_text("", encoding="utf-8")
self.assertEqual(
workspace.portable_root_for(plain, None),
root / "out" / "Debug",
)
def test_overlay_save_and_apply_roundtrip(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source, slot, work, config = source_repo_with_task(root)
git(
source, "update-ref",
workspace.source_task_ref(TASK_ID, "run"), "HEAD",
)
tracked = source / "tracked.txt"
tracked.write_text("base\noverlay\n", encoding="utf-8")
(work / workspace.OVERLAY_PATHS_FILE).write_text(
"tracked.txt\n", encoding="utf-8",
)
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
saved = run_command(
workspace.command_overlay_save,
task=TASK_ID,
restore="run",
)
self.assertGreater(saved["patch_bytes"], 0)
self.assertEqual(saved["restored"], ["tracked.txt"])
self.assertEqual(
tracked.read_text(encoding="utf-8"),
"base\n",
)
self.assertEqual(workspace.changed_paths(source), [])
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
applied = run_command(
workspace.command_overlay_apply,
task=TASK_ID,
)
self.assertTrue(applied["applied"])
self.assertEqual(applied["conflicts"], [])
self.assertEqual(
tracked.read_text(encoding="utf-8"),
"base\noverlay\n",
)
def test_overlay_save_rejects_uninventoried_and_untracked_paths(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source, slot, work, config = source_repo_with_task(root)
(work / workspace.OVERLAY_PATHS_FILE).write_text(
"tracked.txt\n", encoding="utf-8",
)
(source / "tracked.txt").write_text("base\noverlay\n", encoding="utf-8")
(source / "stray.txt").write_text("stray\n", encoding="utf-8")
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
with self.assertRaisesRegex(workspace.WorkspaceError, "untracked"):
run_command(
workspace.command_overlay_save,
task=TASK_ID,
restore="run",
)
(source / "stray.txt").unlink()
other = source / "other.txt"
other.write_text("tracked other\n", encoding="utf-8")
git(source, "add", "other.txt")
git(source, "commit", "-m", "Add other")
other.write_text("dirty\n", encoding="utf-8")
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
with self.assertRaisesRegex(workspace.WorkspaceError, "outside"):
run_command(
workspace.command_overlay_save,
task=TASK_ID,
restore="run",
)
def test_source_commit_stages_owned_paths_and_marks_green(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source, slot, work, config = source_repo_with_task(root)
git(
source, "update-ref",
workspace.source_task_ref(TASK_ID, "base"), "HEAD",
)
(work / "owned-paths.txt").write_text(
"tracked.txt\n", encoding="utf-8",
)
(source / "tracked.txt").write_text("task\n", encoding="utf-8")
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
result = run_command(
workspace.command_source_commit,
task=TASK_ID,
subject="Correct peer actions",
mark_green=True,
)
self.assertEqual(result["committed"], ["tracked.txt"])
self.assertTrue(result["marked_green"])
message = git(source, "show", "-s", "--format=%B", "HEAD")
self.assertEqual(
message,
f"Correct peer actions\n\nTask: {TASK_ID}",
)
head = git(source, "rev-parse", "HEAD")
self.assertEqual(
workspace.resolved_ref(
source, workspace.source_task_ref(TASK_ID, "green"),
),
head,
)
self.assertEqual(
workspace.resolved_ref(
source, workspace.source_task_ref(TASK_ID, "run"),
),
head,
)
verified = run_command(
workspace.command_source_verify_commit,
source_root=str(source),
task=TASK_ID,
ref="HEAD",
)
self.assertTrue(verified["valid"])
self.assertEqual(verified["subject"], "Correct peer actions")
def test_source_commit_rejects_paths_outside_owned_set(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source, slot, work, config = source_repo_with_task(root)
(work / "owned-paths.txt").write_text(
"tracked.txt\n", encoding="utf-8",
)
(source / "tracked.txt").write_text("task\n", encoding="utf-8")
(source / "extra.txt").write_text("extra\n", encoding="utf-8")
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
with self.assertRaisesRegex(workspace.WorkspaceError, "extra.txt"):
run_command(
workspace.command_source_commit,
task=TASK_ID,
subject="Correct peer actions",
mark_green=False,
)
with self.assertRaisesRegex(workspace.WorkspaceError, "single"):
run_command(
workspace.command_source_commit,
task=TASK_ID,
subject="Bad\nsubject",
mark_green=False,
)
with self.assertRaisesRegex(workspace.WorkspaceError, "too long"):
run_command(
workspace.command_source_commit,
task=TASK_ID,
subject="x" * 80,
mark_green=False,
)
def test_fence_create_and_check_detects_changes(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "a.png").write_bytes(b"aaa")
(root / "sub").mkdir()
(root / "sub" / "b.png").write_bytes(b"bbb")
baseline = root / "fence.txt"
created = run_command(
workspace.command_fence_create,
file=str(baseline),
root=str(root),
paths=["a.png", "sub/b.png"],
)
self.assertEqual(created["paths"], 2)
checked = run_command(
workspace.command_fence_check,
file=str(baseline),
root=str(root),
)
self.assertTrue(checked["ok"])
(root / "a.png").write_bytes(b"changed")
(root / "sub" / "b.png").unlink()
out = io.StringIO()
with contextlib.redirect_stdout(out):
with self.assertRaises(SystemExit):
workspace.command_fence_check(SimpleNamespace(
file=str(baseline),
root=str(root),
))
result = json.loads(out.getvalue())
self.assertFalse(result["ok"])
self.assertEqual(result["mismatched"], ["a.png"])
self.assertEqual(result["missing"], ["sub/b.png"])
def test_source_preflight_reports_dirty_and_account_state(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source, slot, work, config = source_repo_with_task(root)
(work / "owned-paths.txt").write_text(
"tracked.txt\n", encoding="utf-8",
)
(source / "tracked.txt").write_text("dirty\n", encoding="utf-8")
(source / "unrelated.txt").write_text("stray\n", encoding="utf-8")
debug = make_portable_root(root)
exe = write_fake_exe(debug / "Telegram", "exit 0\n")
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
result = run_command(
workspace.command_source_preflight,
task=TASK_ID,
exe=str(exe),
)
self.assertFalse(result["source_clean"])
self.assertIn("tracked.txt", result["dirty"])
self.assertEqual(result["dirty_outside_owned"], ["unrelated.txt"])
self.assertTrue(result["owned_paths_present"])
self.assertTrue(result["exe_present"])
self.assertTrue(result["golden_account_present"])
self.assertFalse(result["live_marker_present"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -20,21 +20,37 @@ This file adapts harness mechanics and removes unnecessary text normalization.
override can only pin a leaf below the parent. Do not pass a reasoning field override can only pin a leaf below the parent. Do not pass a reasoning field
either — the Agent tool has none, and effort is inherited unchanged, so every either — the Agent tool has none, and effort is inherited unchanged, so every
leaf keeps the parent's reasoning level. leaf keeps the parent's reasoning level.
- A foreground Agent call may replace Codex-specific polling. Treat its short - Run every phase leaf as a synchronous foreground Agent call. The call
reply as notification and validate the required files and repository state. returning is the completion signal: validate the required files and
repository state right there, treating the short reply as notification
only. Do not use background Agent calls plus shell `sleep`/`until` polling
loops for phase leaves — the Codex wait ladder, heartbeat-mtime checks, and
five-minute stall windows in the shared references are Codex-only mechanics
and do not apply in Claude Code. Leaves still write their progress files
(they are cheap resumability evidence), but the performer never polls them.
- Run the independent leaves of one step — the review lenses plus the
iteration-1 test-design leaf, or assessed-disjoint implementation units —
as parallel Agent calls in a single message so they run concurrently and
all return together.
- A long Debug build may run as background Bash; the harness re-invokes the
session when a background command exits, so do not poll its log with sleep
loops either.
- When a synchronous leaf call returns without its required artifact, retry
that disposable phase once in a fresh Agent with more specific instructions.
When Claude exposes a resumable agent id and more work is needed from that When Claude exposes a resumable agent id and more work is needed from that
same stateful worker, resume that id; never create a duplicate performer or same stateful worker, resume that id; never create a duplicate performer or
duplicate an agent whose writes may still be in flight. duplicate an agent whose writes may still be in flight. Never launch a
- Translate Codex-specific wait, message, follow-up, list, and interrupt calls
to the closest available Agent operation. Preserve the artifact heartbeat,
stall windows, one-retry limit, and terminal-state rules. Never launch a
nested `claude` process from Bash. nested `claude` process from Bash.
- If the first real leaf Agent is rejected before work begins because nested - If the first real leaf Agent is rejected before work begins because nested
delegation is unavailable, use the shared same-session fallback. Do not delegation is unavailable, use the shared same-session fallback. Do not
treat mere presence of the Agent tool as a successful delegation probe. treat mere presence of the Agent tool as a successful delegation probe.
- Whenever an Agent is asked to run `process-inbox`, `perform-task`, a phase - Whenever an Agent is asked to run `process-inbox`, `perform-task`, or
prompt, or discovered-task routing, explicitly tell it to read this adapter discovered-task routing — the orchestrating roles — explicitly tell it to
completely before the applicable shared skill or reference. read this adapter completely before the applicable shared skill or
reference. Do NOT tell leaf phase agents to read this adapter: their phase
prompts are self-contained and already carry the leaf rules (no delegation,
no commits, progress and reply contracts); an adapter read there is wasted
context.
## Text handling ## Text handling

View File

@@ -11,9 +11,10 @@ the Claude adapter's delegation and text-handling substitutions. Resolve, start
or resume, implement, verify, and publish only the named task. Do not or resume, implement, verify, and publish only the named task. Do not
continue with other queue work afterward. continue with other queue work afterward.
Tell every phase Agent to read `.claude/ai-workflow-adapter.md` before its Delegate phases with synchronous foreground Agent calls per the adapter; leaf
shared phase prompt. Use the Agent tool for delegation; do not start Claude phase agents receive self-contained prompts and are not told to read the
subprocesses through Bash. adapter. Use the Agent tool for delegation; do not start Claude subprocesses
through Bash.
Task short name or full id: Task short name or full id:

View File

@@ -1882,6 +1882,17 @@ PRIVATE
support/support_templates.h support/support_templates.h
tde2e/tde2e_integration.cpp tde2e/tde2e_integration.cpp
tde2e/tde2e_integration.h tde2e/tde2e_integration.h
test/test_agent.cpp
test/test_agent.h
test/test_capture.cpp
test/test_capture.h
test/test_log.cpp
test/test_log.h
test/test_runner.cpp
test/test_runner.h
test/test_scenario.cpp
test/test_widgets.cpp
test/test_widgets.h
ui/boxes/edit_invite_link_session.cpp ui/boxes/edit_invite_link_session.cpp
ui/boxes/edit_invite_link_session.h ui/boxes/edit_invite_link_session.h
ui/boxes/emoji_stake_box.cpp ui/boxes/emoji_stake_box.cpp

View File

@@ -94,6 +94,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
#include "ui/accessible/ui_accessible_factory.h" #include "ui/accessible/ui_accessible_factory.h"
#include "ui/boxes/confirm_box.h" #include "ui/boxes/confirm_box.h"
#include "core/cached_webview_availability.h" #include "core/cached_webview_availability.h"
#include "test/test_agent.h"
#include <QtCore/QStandardPaths> #include <QtCore/QStandardPaths>
#include <QtCore/QMimeDatabase> #include <QtCore/QMimeDatabase>
@@ -272,6 +273,8 @@ void Application::run() {
style::SetCustomFont(settings().customFontFamily()); style::SetCustomFont(settings().customFontFamily());
style::internal::StartFonts(); style::internal::StartFonts();
Test::ApplyStartupOverrides();
ValidateScale(); ValidateScale();
refreshGlobalProxy(); // Depends on app settings being read. refreshGlobalProxy(); // Depends on app settings being read.
@@ -423,6 +426,9 @@ void Application::run() {
} }
processCreatedWindow(_lastActivePrimaryWindow); processCreatedWindow(_lastActivePrimaryWindow);
Test::Fire(u"launch_finished"_q);
Test::Start();
} }
void Application::autoRegisterUrlScheme() { void Application::autoRegisterUrlScheme() {

View File

@@ -0,0 +1,61 @@
/*
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
*/
#include "test/test_agent.h"
#include "test/test_log.h"
#include "settings.h"
#include "ui/style/style_core_scale.h"
namespace Test {
namespace {
[[nodiscard]] base::flat_set<QString> &FiredEvents() {
static auto result = base::flat_set<QString>();
return result;
}
} // namespace
bool Active() {
#ifdef _DEBUG
return cTestAgent();
#else // _DEBUG
return false;
#endif // _DEBUG
}
void ApplyStartupOverrides() {
if (!Active()) {
return;
}
const auto value = qEnvironmentVariable("TDESKTOP_TEST_SCALE");
if (value.isEmpty()) {
return;
}
auto ok = false;
const auto scale = value.toInt(&ok);
if (ok && scale >= style::kScaleMin && scale <= style::kScaleMax) {
cSetConfigScale(scale);
Note(u"TDESKTOP_TEST_SCALE applied: %1"_q.arg(scale));
} else {
Note(u"TDESKTOP_TEST_SCALE rejected: %1"_q.arg(value));
}
}
void Fire(const QString &event) {
if (!Active() || !FiredEvents().emplace(event).second) {
return;
}
Note(u"event fired: %1"_q.arg(event));
}
bool HasFired(const QString &event) {
return Active() && FiredEvents().contains(event);
}
} // namespace Test

View File

@@ -0,0 +1,31 @@
/*
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
namespace Test {
// True only in a Debug build launched with -testagent.
[[nodiscard]] bool Active();
// Applies TDESKTOP_TEST_* environment overrides (interface scale).
// Runs before ValidateScale() in Application::run(). No-op unless Active().
void ApplyStartupOverrides();
// Marks a named waitpoint as reached. Fire-once, sticky. No-op unless
// Active(), so call sites in application code need no condition around them.
void Fire(const QString &event);
[[nodiscard]] bool HasFired(const QString &event);
// Builds the scenario registered by test/test_scenario.cpp and starts it on
// the event loop. Runs at the end of Application::run(). No-op unless
// Active(), a scenario is registered, and the portable data folder carries
// the "testing" marker of a disposable test account copy.
void Start();
} // namespace Test

View File

@@ -0,0 +1,157 @@
/*
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
*/
#include "test/test_capture.h"
#include "test/test_log.h"
#include <QtGui/QPainter>
namespace Test {
namespace {
constexpr auto kBlankSpreadThreshold = 6;
constexpr auto kContactSheetGap = 8;
[[nodiscard]] QString WithPngExtension(const QString &name) {
return name.endsWith(u".png"_q, Qt::CaseInsensitive)
? name
: (name + u".png"_q);
}
} // namespace
QImage GrabWidget(not_null<QWidget*> widget) {
return widget->grab().toImage();
}
QImage GrabRect(
not_null<QWidget*> widget,
const QRect &logicalRect) {
const auto image = GrabWidget(widget);
const auto ratio = image.devicePixelRatio();
return Crop(image, QRect(
int(std::floor(logicalRect.x() * ratio)),
int(std::floor(logicalRect.y() * ratio)),
int(std::ceil(logicalRect.width() * ratio)),
int(std::ceil(logicalRect.height() * ratio))));
}
bool LooksBlank(const QImage &image) {
if (image.isNull() || image.width() < 2 || image.height() < 2) {
return true;
}
auto minLuma = 255;
auto maxLuma = 0;
const auto columns = std::min(image.width(), 32);
const auto rows = std::min(image.height(), 32);
for (auto y = 0; y != rows; ++y) {
for (auto x = 0; x != columns; ++x) {
const auto pixel = image.pixelColor(
(x * (image.width() - 1)) / std::max(columns - 1, 1),
(y * (image.height() - 1)) / std::max(rows - 1, 1));
const auto luma = int(std::round(255 * pixel.lightnessF()));
minLuma = std::min(minLuma, luma);
maxLuma = std::max(maxLuma, luma);
}
}
return (maxLuma - minLuma) < kBlankSpreadThreshold;
}
QString SaveImage(const QImage &image, const QString &name) {
if (image.isNull()) {
return QString();
}
const auto path = ScreenshotsDir() + WithPngExtension(name);
if (!image.save(path, "PNG")) {
return QString();
}
LogRaw(u"SCREENSHOT: %1"_q.arg(path));
return path;
}
bool CaptureWidget(not_null<QWidget*> widget, const QString &name) {
if (!widget->isVisible()) {
Fail(u"capture %1"_q.arg(name), u"widget is not visible"_q);
return false;
}
const auto image = GrabWidget(widget);
if (LooksBlank(image)) {
Fail(u"capture %1"_q.arg(name), u"grabbed image looks blank"_q);
return false;
}
LogGeometry(name, QRect(widget->mapToGlobal(QPoint()), widget->size()));
return !SaveImage(image, name).isEmpty();
}
bool CaptureRect(
not_null<QWidget*> widget,
const QRect &logicalRect,
const QString &name) {
if (!widget->isVisible()) {
Fail(u"capture %1"_q.arg(name), u"widget is not visible"_q);
return false;
} else if (!QRect(QPoint(), widget->size()).intersects(logicalRect)) {
Fail(
u"capture %1"_q.arg(name),
u"rect is outside the widget bounds"_q);
return false;
}
const auto image = GrabRect(widget, logicalRect);
if (LooksBlank(image)) {
Fail(u"capture %1"_q.arg(name), u"grabbed image looks blank"_q);
return false;
}
LogGeometry(name, logicalRect);
return !SaveImage(image, name).isEmpty();
}
QImage Crop(const QImage &image, const QRect &pixelRect) {
const auto bounded = pixelRect.intersected(image.rect());
return bounded.isEmpty() ? QImage() : image.copy(bounded);
}
QImage Zoom(const QImage &image, int factor) {
return (image.isNull() || factor <= 1)
? image
: image.scaled(
image.size() * factor,
Qt::KeepAspectRatio,
Qt::FastTransformation);
}
QImage ContactSheet(const std::vector<QImage> &images) {
auto width = 0;
auto height = 0;
for (const auto &image : images) {
if (image.isNull()) {
continue;
}
width += image.width() + (width ? kContactSheetGap : 0);
height = std::max(height, image.height());
}
if (!width) {
return QImage();
}
auto result = QImage(width, height, QImage::Format_ARGB32_Premultiplied);
result.fill(Qt::white);
auto painter = QPainter(&result);
auto x = 0;
for (const auto &image : images) {
if (image.isNull()) {
continue;
}
auto copy = image;
copy.setDevicePixelRatio(1.);
painter.drawImage(x, 0, copy);
x += copy.width() + kContactSheetGap;
}
painter.end();
return result;
}
} // namespace Test

View File

@@ -0,0 +1,48 @@
/*
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 <QtWidgets/QWidget>
namespace Test {
// In-process render of the widget itself — immune to occlusion by floating
// elements, other windows, or a locked desktop session.
[[nodiscard]] QImage GrabWidget(not_null<QWidget*> widget);
// Grabs the widget and crops to |logicalRect| (widget-local logical
// coordinates), handling device pixel ratio.
[[nodiscard]] QImage GrabRect(
not_null<QWidget*> widget,
const QRect &logicalRect);
// Near-uniform images are capture failures, never evidence.
[[nodiscard]] bool LooksBlank(const QImage &image);
// 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);
// Grab + blank-check + save as one evidence-grade capture: a hidden widget,
// an empty grab, or a blank image is a logged FAIL, never a silent pass.
bool CaptureWidget(not_null<QWidget*> widget, const QString &name);
bool CaptureRect(
not_null<QWidget*> widget,
const QRect &logicalRect,
const QString &name);
[[nodiscard]] QImage Crop(const QImage &image, const QRect &pixelRect);
// Nearest-neighbor upscale for readable small-target evidence.
[[nodiscard]] QImage Zoom(const QImage &image, int factor);
// Lays the images out side by side at their native pixel sizes (no
// rescaling), for same-scale comparisons.
[[nodiscard]] QImage ContactSheet(const std::vector<QImage> &images);
} // namespace Test

View File

@@ -0,0 +1,108 @@
/*
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
*/
#include "test/test_log.h"
#include "settings.h"
namespace Test {
namespace {
auto FailuresCount = 0;
[[nodiscard]] QString EnsuredDir(const QString &path) {
QDir().mkpath(path);
return path.endsWith('/') ? path : (path + '/');
}
} // namespace
QString EvidenceDir() {
static const auto result = [] {
const auto value = qEnvironmentVariable("TDESKTOP_TEST_EVIDENCE_DIR");
const auto path = value.isEmpty()
? (cWorkingDir() + u"test_evidence"_q)
: value;
return EnsuredDir(QFileInfo(path).absoluteFilePath());
}();
return result;
}
QString ScreenshotsDir() {
static const auto result = EnsuredDir(EvidenceDir() + u"screenshots"_q);
return result;
}
void LogRaw(const QString &line) {
auto file = QFile(EvidenceDir() + u"test_log.txt"_q);
if (!file.open(QIODevice::Append | QIODevice::Text)) {
return;
}
file.write((line + u"\n"_q).toUtf8());
file.flush();
}
void Step(const QString &text) {
LogRaw(u"TEST_STEP: %1"_q.arg(text));
}
void Pass(const QString &text) {
LogRaw(u"TEST_RESULT: PASS: %1"_q.arg(text));
}
void Fail(const QString &text, const QString &details) {
++FailuresCount;
LogRaw(details.isEmpty()
? u"TEST_RESULT: FAIL: %1"_q.arg(text)
: u"TEST_RESULT: FAIL: %1 - %2"_q.arg(text, details));
}
void Check(bool ok, const QString &what, const QString &details) {
if (ok) {
Pass(what);
} else {
Fail(what, details);
}
}
void Note(const QString &text) {
LogRaw(u"NOTE: %1"_q.arg(text));
}
void CheckNear(
int actual,
int expected,
int tolerance,
const QString &what) {
Check(
std::abs(actual - expected) <= tolerance,
u"%1 (actual %2, expected %3 ±%4)"_q.arg(
what,
QString::number(actual),
QString::number(expected),
QString::number(tolerance)),
u"out of tolerance"_q);
}
void LogGeometry(const QString &name, const QRect &rect) {
LogRaw(u"GEOMETRY: %1: x=%2 y=%3 w=%4 h=%5"_q.arg(
name,
QString::number(rect.x()),
QString::number(rect.y()),
QString::number(rect.width()),
QString::number(rect.height())));
}
int FailureCount() {
return FailuresCount;
}
void Complete() {
LogRaw(u"TEST_COMPLETE"_q);
}
} // namespace Test

View File

@@ -0,0 +1,43 @@
/*
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
namespace Test {
// Absolute evidence directory: TDESKTOP_TEST_EVIDENCE_DIR when set (the
// workspace test-run helper always sets it), otherwise a test_evidence
// folder in the portable working directory. Created on first use.
[[nodiscard]] QString EvidenceDir();
[[nodiscard]] QString ScreenshotsDir();
// Appends one line to <EvidenceDir()>/test_log.txt and flushes immediately,
// so evidence survives any crash or kill.
void LogRaw(const QString &line);
void Step(const QString &text);
void Pass(const QString &text);
void Fail(const QString &text, const QString &details = QString());
void Check(bool ok, const QString &what, const QString &details = QString());
void Note(const QString &text);
// PASS/FAIL on |actual| being within |tolerance| of |expected|, logging the
// measured values either way.
void CheckNear(
int actual,
int expected,
int tolerance,
const QString &what);
void LogGeometry(const QString &name, const QRect &rect);
[[nodiscard]] int FailureCount();
// Writes the TEST_COMPLETE marker the external runner waits for.
void Complete();
} // namespace Test

View File

@@ -0,0 +1,175 @@
/*
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
*/
#include "test/test_runner.h"
#include "test/test_agent.h"
#include "test/test_log.h"
#include "core/application.h"
#include "data/data_session.h"
#include "main/main_account.h"
#include "main/main_domain.h"
#include "main/main_session.h"
#include "settings.h"
#include <QtCore/QTimer>
namespace Test {
namespace {
constexpr auto kTickInterval = crl::time(50);
constexpr auto kDefaultWatchdogSeconds = 120;
constexpr auto kAbortAfterQuitSeconds = 10;
[[nodiscard]] crl::time WatchdogTimeout() {
const auto value = qEnvironmentVariable("TDESKTOP_TEST_WATCHDOG");
auto ok = false;
const auto seconds = value.toInt(&ok);
return crl::time(1000) * ((ok && seconds > 0)
? seconds
: kDefaultWatchdogSeconds);
}
[[nodiscard]] bool SessionReady() {
const auto &domain = Core::App().domain();
return domain.started() && domain.active().sessionExists();
}
} // namespace
void Runner::add(Stage stage) {
Expects(!_started);
_stages.push_back(std::move(stage));
}
void Runner::waitEvent(const QString &event, crl::time timeout) {
add({
.name = u"wait for event: %1"_q.arg(event),
.until = [=] { return HasFired(event); },
.timeout = timeout,
});
}
void Runner::waitForSessionReady(crl::time timeout) {
add({
.name = u"wait for session ready"_q,
.until = SessionReady,
.timeout = timeout,
});
}
void Runner::waitForChatsLoaded(crl::time timeout) {
add({
.name = u"wait for chats loaded"_q,
.until = [] {
return SessionReady()
&& Core::App().domain().active().session().data(
).chatsListLoaded();
},
.timeout = timeout,
});
}
bool Runner::empty() const {
return _stages.empty();
}
void Runner::start() {
Expects(!_started && !_stages.empty());
_started = true;
LogRaw(u"SCENARIO_START: %1 stage(s)"_q.arg(_stages.size()));
_watchdog.setCallback([=] {
Fail(u"scenario watchdog"_q, u"hard wall-clock cap reached"_q);
finish();
});
_watchdog.callOnce(WatchdogTimeout());
beginStage();
_ticker.setCallback([=] { tick(); });
_ticker.callEach(kTickInterval);
}
void Runner::tick() {
if (_finished) {
return;
}
const auto &stage = _stages[_index];
if (!stage.until || stage.until()) {
completeStage();
} else if (crl::now() - _stageStarted > stage.timeout) {
Fail(
u"stage timed out: %1"_q.arg(stage.name),
u"waited %1 ms"_q.arg(stage.timeout));
finish();
}
}
void Runner::beginStage() {
const auto &stage = _stages[_index];
Step(stage.name);
_stageStarted = crl::now();
if (stage.run) {
stage.run();
}
}
void Runner::completeStage() {
const auto &stage = _stages[_index];
if (stage.then) {
stage.then();
}
if (++_index == int(_stages.size())) {
finish();
} else {
beginStage();
}
}
void Runner::finish() {
if (_finished) {
return;
}
_finished = true;
_ticker.cancel();
_watchdog.cancel();
const auto failures = FailureCount();
LogRaw(u"SCENARIO_RESULT: %1 (failures: %2)"_q.arg(
failures ? u"FAIL"_q : u"PASS"_q,
QString::number(failures)));
Complete();
QTimer::singleShot(kAbortAfterQuitSeconds * 1000, [] {
std::abort();
});
Core::Quit();
}
void Start() {
if (!Active()) {
return;
}
static auto Started = false;
if (Started) {
return;
}
Started = true;
static auto runner = Runner();
SetupScenario(&runner);
if (runner.empty()) {
Note(u"no scenario registered"_q);
return;
}
const auto marker = cWorkingDir() + u"testing"_q;
if (!QFile::exists(marker)) {
LogRaw(u"SCENARIO_REFUSED: missing disposable-copy marker %1"_q.arg(
marker));
return;
}
runner.start();
}
} // namespace Test

View File

@@ -0,0 +1,65 @@
/*
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 "base/timer.h"
namespace Test {
inline constexpr auto kDefaultStageTimeout = crl::time(10000);
inline constexpr auto kStartupStageTimeout = crl::time(30000);
// One scenario step. Runs |run| once, polls |until| on the event loop till it
// returns true (immediately ready when null), then runs |then| assertions.
// A stage past its |timeout| fails the scenario and finishes early — the
// scenario always ends in TEST_COMPLETE and quit, never a hang.
struct Stage {
QString name;
Fn<void()> run;
Fn<bool()> until;
Fn<void()> then;
crl::time timeout = kDefaultStageTimeout;
};
class Runner final {
public:
void add(Stage stage);
// Sugar stages over the common waits.
void waitEvent(
const QString &event,
crl::time timeout = kStartupStageTimeout);
void waitForSessionReady(crl::time timeout = kStartupStageTimeout);
void waitForChatsLoaded(crl::time timeout = kStartupStageTimeout);
[[nodiscard]] bool empty() const;
void start();
private:
void tick();
void beginStage();
void completeStage();
void finish();
std::vector<Stage> _stages;
int _index = 0;
bool _started = false;
bool _finished = false;
crl::time _stageStarted = 0;
base::Timer _ticker;
base::Timer _watchdog;
};
// Defined by test/test_scenario.cpp. The per-task test overlay replaces that
// file with a scenario built from the task's test design; the repository
// copy registers nothing.
void SetupScenario(not_null<Runner*> runner);
} // namespace Test

View File

@@ -0,0 +1,18 @@
/*
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
*/
#include "test/test_runner.h"
namespace Test {
// Per-task overlay slot: an automated test overlay replaces this whole file
// with a scenario built on test_runner.h / test_widgets.h / test_capture.h /
// test_log.h. The repository copy must stay a no-op.
void SetupScenario(not_null<Runner*> runner) {
}
} // namespace Test

View File

@@ -0,0 +1,70 @@
/*
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
*/
#include "test/test_widgets.h"
#include <QtGui/QMouseEvent>
#include <QtGui/QKeyEvent>
#include <QtWidgets/QApplication>
namespace Test {
QWidget *FindByObjectName(
not_null<QWidget*> root,
const QString &name) {
return root->findChild<QWidget*>(name);
}
void Click(not_null<QWidget*> widget, std::optional<QPoint> point) {
const auto local = QPointF(point.value_or(widget->rect().center()));
const auto global = widget->mapToGlobal(local);
auto press = QMouseEvent(
QEvent::MouseButtonPress,
local,
global,
Qt::LeftButton,
Qt::LeftButton,
Qt::NoModifier);
QApplication::sendEvent(widget, &press);
auto release = QMouseEvent(
QEvent::MouseButtonRelease,
local,
global,
Qt::LeftButton,
Qt::NoButton,
Qt::NoModifier);
QApplication::sendEvent(widget, &release);
}
void TypeText(not_null<QWidget*> widget, const QString &text) {
for (const auto &character : text) {
auto press = QKeyEvent(
QEvent::KeyPress,
0,
Qt::NoModifier,
QString(character));
QApplication::sendEvent(widget, &press);
auto release = QKeyEvent(
QEvent::KeyRelease,
0,
Qt::NoModifier,
QString(character));
QApplication::sendEvent(widget, &release);
}
}
void PressKey(
not_null<QWidget*> widget,
int key,
Qt::KeyboardModifiers modifiers) {
auto press = QKeyEvent(QEvent::KeyPress, key, modifiers);
QApplication::sendEvent(widget, &press);
auto release = QKeyEvent(QEvent::KeyRelease, key, modifiers);
QApplication::sendEvent(widget, &release);
}
} // namespace Test

View File

@@ -0,0 +1,63 @@
/*
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 <QtWidgets/QWidget>
namespace Test {
// Telegram's custom widgets do not declare Q_OBJECT, so
// QObject::findChildren<T*>() cannot filter by their type and returns every
// child blindly cast to T* — using such a result crashes. Enumerate QWidget
// descendants (QWidget is a real Q_OBJECT) and dynamic_cast each instead.
template <typename T>
[[nodiscard]] std::vector<T*> FindAll(not_null<QWidget*> root) {
auto result = std::vector<T*>();
for (const auto widget : root->findChildren<QWidget*>()) {
if (const auto typed = dynamic_cast<T*>(widget)) {
result.push_back(typed);
}
}
return result;
}
template <typename T>
[[nodiscard]] T *FindFirst(not_null<QWidget*> root) {
const auto all = FindAll<T>(root);
return all.empty() ? nullptr : all.front();
}
template <typename T>
[[nodiscard]] std::vector<T*> FindVisible(not_null<QWidget*> root) {
auto result = FindAll<T>(root);
result.erase(
ranges::remove_if(result, [](T *widget) {
return !static_cast<QWidget*>(widget)->isVisible();
}),
end(result));
return result;
}
[[nodiscard]] QWidget *FindByObjectName(
not_null<QWidget*> root,
const QString &name);
// Synthesizes a full mouse press + release on the widget, at its center by
// default. Drives the same event path as a real click.
void Click(not_null<QWidget*> widget, std::optional<QPoint> point = {});
// Synthesizes key press + release pairs carrying the text, one character at
// a time, into the widget.
void TypeText(not_null<QWidget*> widget, const QString &text);
void PressKey(
not_null<QWidget*> widget,
int key,
Qt::KeyboardModifiers modifiers = Qt::NoModifier);
} // namespace Test