mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/telegramdesktop/tdesktop
synced 2026-09-20 08:03:45 +08:00
[ai] Have semi-prepared testing run.
This commit is contained in:
@@ -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
|
||||
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.
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
The overlay is ad-hoc, authored fresh against the CURRENT implementation, injected at the
|
||||
highest level that still exercises the change (often a direct data-layer call like
|
||||
`item->applyEdition(...)` rather than a faked MTP response). It is also 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 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:
|
||||
The repository carries a permanent test harness under
|
||||
`Telegram/SourceFiles/test/` — always compiled, runtime-gated on `-testagent`
|
||||
(`Test::Active()`), with all of its `#ifdef`s inside the harness itself:
|
||||
|
||||
- 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:
|
||||
`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) ·
|
||||
`mock-api` (intercept specific requests, return canned responses — for payments/destructive).
|
||||
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**
|
||||
(wait until the target widget/data actually exists, with a timeout fallback). 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
|
||||
save screenshots to `<EVIDENCE_DIR>/screenshots/`. Delete the old log at the first step.
|
||||
- **Capture the target tightly.** Grab the specific widget / row / glyph (or crop the saved PNG to
|
||||
it) so the target is unambiguously in frame at usable resolution. A full-window grab that leaves
|
||||
the target clipped, off-screen, or thumbnail-sized is NOT acceptable evidence — if the target
|
||||
isn't clearly captured, that is a TEST_FLAW (re-frame), never a pass.
|
||||
- When the desktop is locked or an OS screenshot is unavailable, capture from inside the process
|
||||
with `QWidget::grab()` or a renderer-owned image after layout and paint have completed. A widget or
|
||||
test-window grab plus logged geometry is primary visual evidence; never wait for unlock merely to
|
||||
obtain a desktop screenshot.
|
||||
- Express the flow as `Runner` stages with **condition-waits over fixed timers** (an `until`
|
||||
predicate on the target widget/data actually existing, with the stage timeout as fallback).
|
||||
Fixed sleeps are the main source of screenshot flake.
|
||||
- Log through `test_log.h` (`Step`/`Pass`/`Fail`/`Check`/`Note`/`CheckNear`/`LogGeometry`) —
|
||||
it already writes the flushed absolute-path log and the exact `TEST_STEP` / `TEST_RESULT` /
|
||||
`SCREENSHOT` / `TEST_COMPLETE` markers the external runner parses. Never hand-roll marker
|
||||
strings or log files.
|
||||
- **Capture the target tightly** with `CaptureWidget`/`CaptureRect` — the specific widget /
|
||||
row / glyph, unambiguously in frame at usable resolution. A full-window grab that leaves the
|
||||
target clipped, off-screen, or thumbnail-sized is NOT acceptable evidence — if the target
|
||||
isn't clearly captured, that is a TEST_FLAW (re-frame), never a pass. The helpers grab
|
||||
in-process after layout and paint, so a locked desktop never blocks capture and a blank
|
||||
grab fails loudly instead of passing silently.
|
||||
- **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
|
||||
artwork, save the baseline/reference-component crop when available and log the contract anchors,
|
||||
crop (`SaveImage`, `ContactSheet` for same-scale comparison). Exact asset work saves OLD and
|
||||
intended-NEW art as `<name>_{old,new}.png`. Without target artwork, save the
|
||||
baseline/reference-component crop when available and log the contract anchors,
|
||||
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);
|
||||
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
|
||||
needed `std::abort` after a flush) at a hard wall-clock cap (default 120s). This guarantees the
|
||||
app never hangs holding a lock on the exe — independent of the runner's own timeout.
|
||||
- End every path (success or assertion failure) by logging `TEST_COMPLETE` then `Core::Quit()`.
|
||||
- Rely on the Runner's built-in watchdog and termination: it force-quits at the wall-clock cap
|
||||
and ends every path — success, assertion failure, or stage timeout — with `TEST_COMPLETE`
|
||||
then quit, so the app never hangs holding a lock on the exe. Do not install a second
|
||||
watchdog and do not call `Core::Quit()` from scenario code.
|
||||
|
||||
### 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
|
||||
fix it; it is a real bug in the overlay, not a stale build.
|
||||
|
||||
- **Never** `findChildren<Ui::SomeCustomWidget*>()`. Instead enumerate `findChildren<QWidget*>()`
|
||||
(`QWidget` *is* `Q_OBJECT`, so that call is sound and returns all descendants) and
|
||||
`dynamic_cast<Ui::SomeCustomWidget*>()` each, keeping the non-null results — C++ RTTI identifies the
|
||||
real type regardless of `Q_OBJECT`. A reusable helper:
|
||||
```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;
|
||||
}
|
||||
```
|
||||
- **Never** `findChildren<Ui::SomeCustomWidget*>()`. Use the harness finders
|
||||
`Test::FindAll<T>` / `Test::FindFirst<T>` / `Test::FindVisible<T>` from `test_widgets.h`,
|
||||
which enumerate `findChildren<QWidget*>()` (`QWidget` *is* `Q_OBJECT`, so that call is sound)
|
||||
and `dynamic_cast` each result — C++ RTTI identifies the real type regardless of `Q_OBJECT`.
|
||||
- Only genuine Qt `Q_OBJECT` types (`QWidget`, `QLabel`, `QLineEdit`, …) are safe to pass directly to
|
||||
`findChildren<T*>()`.
|
||||
|
||||
### 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
|
||||
**relative** overlay log path (`<EVIDENCE_DIR>/test_log.txt`) silently fails to write (`QFile` won't
|
||||
create missing parents) — the run looks "clean" but produces no evidence. Create and resolve
|
||||
`EVIDENCE_DIR` to an absolute path up front (or bake its absolute path into the overlay) so flushes
|
||||
actually land; likewise for screenshots.
|
||||
**relative** log path silently fails to write (`QFile` won't create missing parents) — the run
|
||||
looks "clean" but produces no evidence. `Test::EvidenceDir()` resolves
|
||||
`TDESKTOP_TEST_EVIDENCE_DIR` (which the workspace `test-run` helper always exports as an
|
||||
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)
|
||||
|
||||
- Before authoring, inventory every tracked overlay path in `<WORK_DIR>/test-overlay.paths`; no
|
||||
unrelated or untracked source path may be used. After building, save the overlay with
|
||||
`git diff --binary HEAD > <WORK_DIR>/test-overlay.patch` and verify the patch is nonempty and
|
||||
reapplicable. Restore only the inventoried overlay paths to `GREEN_REF`; never hard-reset the
|
||||
repository. The overlay never enters an impl commit.
|
||||
- Next round, re-apply on top of the new implementation: `git apply --3way
|
||||
<WORK_DIR>/test-overlay.patch`. This succeeds ~90% of the time when the tail change was small.
|
||||
- The inventory in `<WORK_DIR>/test-overlay.paths` is normally exactly
|
||||
`Telegram/SourceFiles/test/test_scenario.cpp`, plus any in-situ injection or `Test::Fire`
|
||||
paths; no unrelated or untracked source path may be used. After building, save the overlay
|
||||
with the workspace helper's `overlay-save` command: it verifies every dirty path against the
|
||||
inventory, writes a nonempty verified `<WORK_DIR>/test-overlay.patch`, and restores only the
|
||||
inventoried overlay paths to the wrapper's restore ref; never hard-reset the repository. The
|
||||
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
|
||||
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;
|
||||
@@ -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
|
||||
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.
|
||||
- Run: run the SETUP steps (Test account) -> create `EVIDENCE_DIR` -> launch `EXE` **with
|
||||
`-testagent`** in the background, redirecting stdout to `<EVIDENCE_DIR>/app_stdout.txt` and stderr
|
||||
to `<EVIDENCE_DIR>/app_stderr.txt` (this flag prevents modal crash hangs, and stderr captures
|
||||
assertion text) -> **start a hard wall-clock deadline (~90s) from launch** -> poll
|
||||
`<EVIDENCE_DIR>/test_log.txt` every ~5s -> on each `SCREENSHOT:` read the image and judge it -> detect
|
||||
`TEST_COMPLETE` (success) or process death (crash) or no new output for the watchdog cap, or the
|
||||
hard deadline elapsing (hang) -> path-scoped kill of any straggler (Test account → "Serialize app
|
||||
runs") -> save the binary overlay patch -> restore only inventoried overlay
|
||||
paths to `GREEN_REF` (the patch must be saved before this restore).
|
||||
|
||||
On Windows, launch and capture both streams like:
|
||||
|
||||
$exe = (Resolve-Path "$EXE").Path
|
||||
Start-Process -FilePath $exe -ArgumentList '-testagent' `
|
||||
-RedirectStandardError "$EVIDENCE_DIR/app_stderr.txt" `
|
||||
-RedirectStandardOutput "$EVIDENCE_DIR/app_stdout.txt" -PassThru
|
||||
- Run: execute the workspace helper's `test-run` command with `EXE` and `EVIDENCE_DIR`. One call
|
||||
performs the SETUP steps (Test account), creates `EVIDENCE_DIR`, path-scope-kills stragglers,
|
||||
launches `EXE` **with `-testagent -noupdate`** (so a shipped update can never replace the
|
||||
binary under test mid-run) capturing stdout to `<EVIDENCE_DIR>/app_stdout.txt` and
|
||||
stderr to `<EVIDENCE_DIR>/app_stderr.txt` (the flag prevents modal crash hangs, and stderr
|
||||
captures assertion text), enforces **a hard wall-clock deadline from launch** and a quiet-log
|
||||
watchdog while polling `<EVIDENCE_DIR>/test_log.txt`, detects `TEST_COMPLETE` (success) versus
|
||||
process death (crash) versus the caps elapsing (hang), kills any straggler, and returns one JSON
|
||||
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
|
||||
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.
|
||||
|
||||
### 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,
|
||||
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
|
||||
the user to launch by mistake:
|
||||
|
||||
Remove-Item -Force "$EXE"
|
||||
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.
|
||||
|
||||
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.)
|
||||
|
||||
@@ -4,42 +4,60 @@
|
||||
|
||||
- [Orchestration rules](#orchestration-rules)
|
||||
- [Completion checks](#artifact-based-completion-checks)
|
||||
- [Context](#phase-1-context)
|
||||
- [Plan and assessment](#phase-2-plan)
|
||||
- [Context and plan](#phase-1-context-and-plan)
|
||||
- [Assessment](#phase-3-plan-assessment)
|
||||
- [Implementation and build](#phase-4-implementation)
|
||||
- [Review](#phase-6-code-review-loop)
|
||||
- [Windows normalization](#phase-7-native-windows-text-normalization)
|
||||
- [Prompt delivery](#prompt-delivery-and-logs)
|
||||
|
||||
Use these templates as Codex subagent messages. Use them as same-session
|
||||
checklists only for intentional current-session build work, Phase 7, or when
|
||||
delegation is unavailable from the start at the current agent depth. Replace
|
||||
Use these templates as subagent messages on any host. Use them as same-session
|
||||
checklists only for intentional current-session build work, Phase 7, the
|
||||
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>`,
|
||||
`<PROJECT_FILE>`, `<PREVIOUS_CONTEXT>`, `<BUILD>`, `<N>`,
|
||||
`<OWNED_WRITE_SET>`, `<R>`, `<R-1>`, and `<phase-name>`.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
- 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. 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- After each phase completes, write `<WORK_DIR>/logs/phase-<phase-name>.result.md` with exact
|
||||
`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 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.
|
||||
- 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.
|
||||
- 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 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.
|
||||
- `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 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 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
|
||||
|
||||
@@ -80,28 +98,51 @@ Do not restate the full context, plan, diff, or long reasoning in the chat reply
|
||||
|
||||
## Artifact-Based Completion Checks
|
||||
|
||||
- Phase 1 is complete only when `context.md` exists and is non-empty. For a
|
||||
project task, `project.proposed.md` must also exist and be non-empty.
|
||||
- Phase 2 is complete only when `plan.md` exists, contains a `## Status` section, and no unintended source edits were made.
|
||||
- Phase 1 is complete only when `context.md` exists and is non-empty, `plan.md`
|
||||
exists and contains a `## Status` section, and no unintended source edits
|
||||
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 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 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 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
|
||||
design sources (images when supplied; otherwise request facts and repository/baseline anchors),
|
||||
records assumptions, and contains desktop anchors, an ordered derivation, tolerances, and
|
||||
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
|
||||
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>
|
||||
|
||||
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:
|
||||
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.
|
||||
|
||||
Do not implement code in this phase.
|
||||
```
|
||||
|
||||
## 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
|
||||
After context.md is written, create a detailed plan in: <WORK_DIR>/plan.md
|
||||
|
||||
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.
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
```text
|
||||
@@ -266,6 +302,7 @@ You are a plan assessment agent. Review and refine an implementation plan.
|
||||
Read these files:
|
||||
- <WORK_DIR>/context.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.
|
||||
|
||||
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?
|
||||
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.
|
||||
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:
|
||||
- fix any inaccuracies
|
||||
@@ -388,7 +436,9 @@ Review loop:
|
||||
```text
|
||||
LOOP:
|
||||
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,
|
||||
plus `lifetime` unconditionally. A fix pass is the most likely
|
||||
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.
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
```text
|
||||
@@ -696,7 +793,7 @@ When all phases, including build verification, code review, and Windows line end
|
||||
|
||||
## 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 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.
|
||||
@@ -711,17 +808,30 @@ For each phase:
|
||||
`TOUCHED:`, `BLOCKER:`, and `NOTES:` fields.
|
||||
|
||||
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.result.md`
|
||||
- `phase-6a-review-1-lifetime.prompt.md`
|
||||
- `phase-6a-review-1-reuse.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.result.md`
|
||||
- `phase-6b-fix-1.prompt.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:
|
||||
|
||||
|
||||
@@ -77,14 +77,24 @@ Before planning or editing:
|
||||
1. Read `SOURCE_ROOT/AGENTS.md`, `REVIEW.md`, `AI_SLOT/AGENTS.md`, `TASK_SPEC`,
|
||||
every referenced input, and relevant project context.
|
||||
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
|
||||
stop before implementation.
|
||||
4. Verify a usable Debug executable/build tree, safe path-scoped process
|
||||
3. Run the scripted preflight report and act on its JSON instead of composing
|
||||
the equivalent shell checks by hand:
|
||||
|
||||
```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
|
||||
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
|
||||
`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:
|
||||
|
||||
```bash
|
||||
@@ -97,9 +107,10 @@ Before planning or editing:
|
||||
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
|
||||
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
|
||||
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
|
||||
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-structure.md
|
||||
work/review1.md # synthesized review for the iteration
|
||||
work/test-design.md # check design drafted during review iteration 1
|
||||
work/test.md
|
||||
work/result.md
|
||||
work/owned-paths.txt
|
||||
@@ -165,16 +177,24 @@ exceptional `Block` commit captures the whole task record.
|
||||
|
||||
## Delegation
|
||||
|
||||
Use `references/phase-prompts.md` for the exact context, plan, assessment,
|
||||
implementation, build, review, and native-Windows normalization prompts.
|
||||
Use `references/phase-prompts.md` for the exact context-and-plan, assessment,
|
||||
implementation, build, review, test-design, and native-Windows normalization
|
||||
prompts, plus the host-specific orchestration rules.
|
||||
|
||||
- The performer is the only stateful task owner.
|
||||
- Probe nested mode with the first real leaf phase. If depth, capacity, or
|
||||
policy rejects that spawn before work begins, execute the same prompt
|
||||
checklists in the performer. This is a supported mode, not degraded failure.
|
||||
- In nested mode, use a fresh leaf for context, planning, assessment, each
|
||||
implementation unit, review, review-fix, and test authoring. Every leaf must
|
||||
be told not to delegate and never to commit.
|
||||
- In nested mode, use a fresh leaf for context-and-plan, assessment, each
|
||||
implementation unit, review lenses, test design, review synthesis,
|
||||
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
|
||||
only for genuinely unavailable chat-only visual context.
|
||||
- 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
|
||||
writes.
|
||||
|
||||
Write the delegated prompt first. Require an early small heartbeat and a final
|
||||
reply containing only status, artifact paths, touched paths, and blocker.
|
||||
Poll no longer than 60 seconds. A timeout is not failure. Use artifact mtimes
|
||||
and heartbeat counters; after five minutes without movement, message the same
|
||||
target, and after a second unchanged five-minute window interrupt and retry
|
||||
that disposable phase once. Never replace a live stateful performer.
|
||||
Write the delegated prompt first. Require a final reply containing only
|
||||
status, artifact paths, touched paths, and blocker. On Claude Code, run each
|
||||
leaf as a synchronous foreground call and validate its artifacts when the call
|
||||
returns; run independent leaves of one step as parallel calls in a single
|
||||
message. On Codex, use the asynchronous wait ladder from the phase prompts:
|
||||
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
|
||||
|
||||
Run sequentially:
|
||||
|
||||
1. **Context.** Write a self-contained `work/context.md`. For project work,
|
||||
read the current project file and nearest approved task context, then write
|
||||
`work/project.proposed.md` as a coherent finished-state blueprint. Use the
|
||||
Phase 1F prompt when prior task context exists; otherwise use Phase 1 with
|
||||
the project file. Do not promote the proposal yet; blocked work must not
|
||||
become project truth.
|
||||
2. **Visual design.** For `Visual: layout`, write `work/visual.md`. Derive every
|
||||
dimension from request relationships, supplied images, font metrics, style
|
||||
tokens, sibling geometry, or a cited desktop analogue. Use ordered
|
||||
calculations, tolerances, relationship checks, same-scale comparison, and
|
||||
an adversarial rejection pass. For `Visual: appearance`, keep the lighter
|
||||
exact color/text/glyph oracle. Skip for non-visual work.
|
||||
3. **Plan.** Write `work/plan.md` with exact files, functions, ordered steps,
|
||||
bounded phases, owned write sets, Debug build verification, and status
|
||||
checkboxes.
|
||||
4. **Assess.** Independently verify paths and APIs, completeness, design,
|
||||
duplication, edge cases, repository conventions, and phase sizing. Require
|
||||
`Phases: <N>` and `Assessed: yes`.
|
||||
5. **Implement.** Run one leaf per assessed plan phase. Before each edit,
|
||||
1. **Context, visual design, and plan.** One leaf writes a self-contained
|
||||
`work/context.md`, then — for `Visual: layout` tasks — `work/visual.md`,
|
||||
then `work/plan.md` with exact files, functions, ordered steps, bounded
|
||||
phases, owned write sets, Debug build verification, and status checkboxes.
|
||||
For project work it also writes `work/project.proposed.md` as a coherent
|
||||
finished-state blueprint; use the Phase 1F prompt when prior task context
|
||||
exists, otherwise Phase 1 with the project file. Do not promote the
|
||||
proposal yet; blocked work must not become project truth.
|
||||
The visual contract derives every dimension from request relationships,
|
||||
supplied images, font metrics, style tokens, sibling geometry, or a cited
|
||||
desktop analogue, with ordered calculations, tolerances, relationship
|
||||
checks, same-scale comparison, and an adversarial rejection pass. For
|
||||
`Visual: appearance`, keep the lighter exact color/text/glyph oracle. Skip
|
||||
the visual step for non-visual work.
|
||||
Small-task fast path: under the strict criteria in the Delegation section,
|
||||
the performer may run this phase as a same-session checklist producing the
|
||||
same artifacts.
|
||||
2. **Assess.** Independently verify paths and APIs, completeness, design,
|
||||
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
|
||||
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
|
||||
by codegen, force its documented regeneration so the Debug binary contains
|
||||
the new resource. A file-lock/access-denied build error is an immediate
|
||||
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
|
||||
the task diff — correctness, lifetime and ownership, reuse, structure — and
|
||||
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
|
||||
review carries that merged coverage as the evidence for approval. Rebuild
|
||||
after every fix pass. Give the correctness and structure lenses the visual
|
||||
contract on layout tasks.
|
||||
8. **Normalize.** On native non-WSL Windows, normalize only task-owned source,
|
||||
contract on layout tasks. Alongside the iteration-1 lenses, spawn the
|
||||
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,
|
||||
preserving content and trailing-newline state, then rebuild. On macOS,
|
||||
Linux, and WSL preserve LF/no-BOM.
|
||||
9. **Commit and test.** Create the Telegram implementation commit, then run the
|
||||
test loop below. An implementation bug creates the next committed attempt;
|
||||
keep the same `Task:` locator on every attempt. After each clean buildable
|
||||
attempt, move the local retained-implementation ref with:
|
||||
7. **Commit and test.** Create the Telegram implementation commit with the
|
||||
scripted helper, then run the test loop below:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
The performer owns commit boundaries. Inspect every dirty path, verify it is in
|
||||
the union of owned write sets, and stage only explicit paths. Never use
|
||||
`git add -A`. Commit an intended submodule first only when its preflight was
|
||||
clean and all of its changes belong to this task, then stage the superproject
|
||||
pointer.
|
||||
The performer owns commit boundaries. The workspace helper's `source-commit`
|
||||
command is the standard mechanism: it enforces this section's contract —
|
||||
every dirty path verified against the union of owned write sets, only explicit
|
||||
paths staged, never `git add -A`, the exact three-line message — in one
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
rules, with these external-task safety adaptations:
|
||||
|
||||
- The performer, not leaves, stages and commits every attempt.
|
||||
- Overlay code may modify only tracked task-owned source paths. Inventory them
|
||||
in `work/test-overlay.paths`; never introduce an untracked source file.
|
||||
- Save the overlay with `git diff --binary HEAD > work/test-overlay.patch`,
|
||||
verify it is nonempty and reapplicable, then restore only inventoried overlay
|
||||
paths to `RUN_REF`. Do not run a repository-wide hard reset. After an
|
||||
implementation-fix commit, move both `GREEN_REF` and `RUN_REF` to the new
|
||||
clean tip before reapplying the overlay.
|
||||
- Reapply with `git apply --3way`; re-author a conflicting hunk from `test.md`
|
||||
rather than leaving conflict markers.
|
||||
- The performer, not leaves, stages and commits every attempt (through
|
||||
`source-commit`).
|
||||
- The overlay is authored against the permanent harness in
|
||||
`Telegram/SourceFiles/test/` and normally consists of replacing
|
||||
`Telegram/SourceFiles/test/test_scenario.cpp` alone — that slot file is
|
||||
always a permitted overlay path. Beyond it, overlay code may modify only
|
||||
tracked task-owned source paths (one-line `Test::Fire` waitpoints or true
|
||||
in-situ injections). Inventory every overlay path in
|
||||
`work/test-overlay.paths`; never introduce an untracked source file, and
|
||||
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.
|
||||
Encode the complete interaction inside the Debug binary using application
|
||||
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
|
||||
`TASK_DIR/.local/runs/attempt-<n>/run-<m>/`. Promote only decisive compact
|
||||
logs/screenshots into tracked `evidence/`.
|
||||
- Launch every test binary with `-testagent`. Detect crashes from process death
|
||||
without `TEST_COMPLETE` plus a new `tdata/working`, not exit code. Read
|
||||
captured stderr first, then `tdata/working`, then note the minidump.
|
||||
- Before each app run and build, stop only a process whose resolved executable
|
||||
path equals `EXE`. Never use image-name-wide termination.
|
||||
- Enforce both the in-app watchdog and an external wall-clock deadline. Count
|
||||
test runs independently from implementation attempts and stop at
|
||||
`MAX_TEST_RUNS`.
|
||||
- Execute every app run through the scripted runner instead of hand-composed
|
||||
launch/poll/kill shell:
|
||||
|
||||
```bash
|
||||
python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \
|
||||
test-run --exe EXE --run-dir RUN_DIR [--env NAME=VALUE ...] \
|
||||
[--deadline 120] [--quiet 60]
|
||||
```
|
||||
|
||||
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
|
||||
that proves every check in a single execution, splitting only for checks
|
||||
that cannot share one process lifetime. `MAX_TEST_RUNS` is a safety cap,
|
||||
never a budget to spend.
|
||||
- Delete the overlay-bearing Debug executable on every terminal test exit so
|
||||
the user cannot launch it accidentally.
|
||||
- Start the test author from `work/test-design.md` when the review-phase
|
||||
draft exists; the author still reconciles every drafted check against the
|
||||
final retained diff before writing overlay code, and owns `test.md`.
|
||||
- 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
|
||||
commit whose message has this task's exact `Task:` line. For an uninterrupted
|
||||
|
||||
@@ -8,8 +8,10 @@ import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
TAG_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*")
|
||||
@@ -36,6 +38,14 @@ STATE_FIELD_ORDER = [
|
||||
"phase",
|
||||
"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):
|
||||
@@ -1004,24 +1014,648 @@ def command_source_begin(args):
|
||||
}, indent=2, sort_keys=True))
|
||||
|
||||
|
||||
def command_source_mark_green(args):
|
||||
config, _ = task_action_config(args)
|
||||
def mark_source_green(config, task_id):
|
||||
source = Path(config["source_root"])
|
||||
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:
|
||||
raise WorkspaceError("The local task baseline ref is missing")
|
||||
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")
|
||||
validate_task_commit(source, "HEAD", args.task)
|
||||
run_git(source, "update-ref", source_task_ref(args.task, "green"), "HEAD")
|
||||
run_git(source, "update-ref", source_task_ref(args.task, "run"), "HEAD")
|
||||
validate_task_commit(source, "HEAD", task_id)
|
||||
run_git(source, "update-ref", source_task_ref(task_id, "green"), "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({
|
||||
"task": args.task,
|
||||
"source_state": "retained",
|
||||
}, 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):
|
||||
config, slot = task_action_config(args, allow_project=True)
|
||||
ensure_clean(Path(config["source_root"]), "Telegram source checkout")
|
||||
@@ -1474,7 +2108,7 @@ def clear_payload(inbox):
|
||||
def command_finalize(args):
|
||||
transaction, metadata = load_transaction(args.transaction)
|
||||
inbox = Path(metadata["inbox"])
|
||||
main = Path(metadata["ai_main"])
|
||||
main = Path(metadata["ai_main"]).resolve()
|
||||
receipt_value = normalized_publish_path(args.receipt)
|
||||
if not receipt_value.startswith("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.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")
|
||||
add_common_arguments(finish)
|
||||
finish.add_argument("--task", required=True)
|
||||
|
||||
@@ -4,6 +4,7 @@ import contextlib
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -875,5 +876,419 @@ inbox_receipt: receipts/2026/07/19/test.md
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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
|
||||
either — the Agent tool has none, and effort is inherited unchanged, so every
|
||||
leaf keeps the parent's reasoning level.
|
||||
- A foreground Agent call may replace Codex-specific polling. Treat its short
|
||||
reply as notification and validate the required files and repository state.
|
||||
- Run every phase leaf as a synchronous foreground Agent call. The call
|
||||
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
|
||||
same stateful worker, resume that id; never create a duplicate performer or
|
||||
duplicate an agent whose writes may still be in flight.
|
||||
- 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
|
||||
duplicate an agent whose writes may still be in flight. Never launch a
|
||||
nested `claude` process from Bash.
|
||||
- If the first real leaf Agent is rejected before work begins because nested
|
||||
delegation is unavailable, use the shared same-session fallback. Do not
|
||||
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
|
||||
prompt, or discovered-task routing, explicitly tell it to read this adapter
|
||||
completely before the applicable shared skill or reference.
|
||||
- Whenever an Agent is asked to run `process-inbox`, `perform-task`, or
|
||||
discovered-task routing — the orchestrating roles — explicitly tell it to
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
continue with other queue work afterward.
|
||||
|
||||
Tell every phase Agent to read `.claude/ai-workflow-adapter.md` before its
|
||||
shared phase prompt. Use the Agent tool for delegation; do not start Claude
|
||||
subprocesses through Bash.
|
||||
Delegate phases with synchronous foreground Agent calls per the adapter; leaf
|
||||
phase agents receive self-contained prompts and are not told to read the
|
||||
adapter. Use the Agent tool for delegation; do not start Claude subprocesses
|
||||
through Bash.
|
||||
|
||||
Task short name or full id:
|
||||
|
||||
|
||||
@@ -1882,6 +1882,17 @@ PRIVATE
|
||||
support/support_templates.h
|
||||
tde2e/tde2e_integration.cpp
|
||||
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.h
|
||||
ui/boxes/emoji_stake_box.cpp
|
||||
|
||||
@@ -94,6 +94,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
||||
#include "ui/accessible/ui_accessible_factory.h"
|
||||
#include "ui/boxes/confirm_box.h"
|
||||
#include "core/cached_webview_availability.h"
|
||||
#include "test/test_agent.h"
|
||||
|
||||
#include <QtCore/QStandardPaths>
|
||||
#include <QtCore/QMimeDatabase>
|
||||
@@ -272,6 +273,8 @@ void Application::run() {
|
||||
style::SetCustomFont(settings().customFontFamily());
|
||||
style::internal::StartFonts();
|
||||
|
||||
Test::ApplyStartupOverrides();
|
||||
|
||||
ValidateScale();
|
||||
|
||||
refreshGlobalProxy(); // Depends on app settings being read.
|
||||
@@ -423,6 +426,9 @@ void Application::run() {
|
||||
}
|
||||
|
||||
processCreatedWindow(_lastActivePrimaryWindow);
|
||||
|
||||
Test::Fire(u"launch_finished"_q);
|
||||
Test::Start();
|
||||
}
|
||||
|
||||
void Application::autoRegisterUrlScheme() {
|
||||
|
||||
61
Telegram/SourceFiles/test/test_agent.cpp
Normal file
61
Telegram/SourceFiles/test/test_agent.cpp
Normal 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
|
||||
31
Telegram/SourceFiles/test/test_agent.h
Normal file
31
Telegram/SourceFiles/test/test_agent.h
Normal 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
|
||||
157
Telegram/SourceFiles/test/test_capture.cpp
Normal file
157
Telegram/SourceFiles/test/test_capture.cpp
Normal 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
|
||||
48
Telegram/SourceFiles/test/test_capture.h
Normal file
48
Telegram/SourceFiles/test/test_capture.h
Normal 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
|
||||
108
Telegram/SourceFiles/test/test_log.cpp
Normal file
108
Telegram/SourceFiles/test/test_log.cpp
Normal 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
|
||||
43
Telegram/SourceFiles/test/test_log.h
Normal file
43
Telegram/SourceFiles/test/test_log.h
Normal 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
|
||||
175
Telegram/SourceFiles/test/test_runner.cpp
Normal file
175
Telegram/SourceFiles/test/test_runner.cpp
Normal 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
|
||||
65
Telegram/SourceFiles/test/test_runner.h
Normal file
65
Telegram/SourceFiles/test/test_runner.h
Normal 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
|
||||
18
Telegram/SourceFiles/test/test_scenario.cpp
Normal file
18
Telegram/SourceFiles/test/test_scenario.cpp
Normal 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
|
||||
70
Telegram/SourceFiles/test/test_widgets.cpp
Normal file
70
Telegram/SourceFiles/test/test_widgets.cpp
Normal 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
|
||||
63
Telegram/SourceFiles/test/test_widgets.h
Normal file
63
Telegram/SourceFiles/test/test_widgets.h
Normal 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
|
||||
Reference in New Issue
Block a user