qa-scenarios
GitHub用于创建、编辑、调试和运行Strata项目的GUI自动化测试场景。支持操作JSONL格式的场景文件、修改头部配置、分析QA失败日志及产物,并管理底层QA运行器与协议交互。
Trigger Scenarios
Install
npx skills add lgse/strata --skill qa-scenarios -g -y
SKILL.md
Frontmatter
{
"name": "qa-scenarios",
"description": "Create, edit, debug, run, rename, split, or remove Strata's QA scenarios (tests\/qa\/scenarios\/*.jsonl, run by tests\/qa.rs through .\/scripts\/qa.sh), and work with the `strata --qa` JSON-lines protocol behind them. Use for any task that writes or changes a scenario file or its header (modes, variants, cost, quality, skip, known_bug), reads a QA failure or its artifacts in target\/qa-artifacts, or changes the QA runner."
}
QA scenarios
QA scenarios are Strata's GUI test suite. Each scenario is one .jsonl file
that drives the real app with real X input and checks what a user would see.
This file covers the common 80%; reference.md has every
header key, step, target, predicate, and fs kind, and is the only catalogue
of them. The "QA scenarios (--qa mode)" section of docs/testing.md covers
the run environment, keyboard layouts, scheduling, visual baselines, and
mutations; its "Skipped scenarios" and "Waits for the app's own timers" lists
are part of the suite.
How it fits together
- The app side. A build with the
qacargo feature (never released) acceptsstrata --qa <directory>: the normal window opens on the directory, thensrc/qa.rsreads one JSON command per line on stdin, runs it on the GTK main loop (src/qa/dispatch.rs), and writes one reply per line on stdout once the main context drains. Input is XTEST sent from inside the process; state is read from the widget tree by the accessible namessrc/ui/accessibility.rssets.strata --qa --portalruns the FileChooser portal backend instead.src/qa/protocol/parses commands;src/qa/targets.rsand itstargets/submodules resolve targets;state.rsbuilds the snapshot. - The reply.
{"ok","app","changed","criticals","result","failures","error"}.appis the state snapshot (view mode, directory, panes with selection and focus, focused entry, dialog, menu, preview, field, matches). Any GLib warning or critical incriticalsfails the run. - The runner.
tests/qa.rsand its modules intests/qa/(custom harnesstests/qa/harness.rs,harness = false) loads every file, checks every step with the process's own parser before launching anything, and makes each run a test named<scenario>/<run>. Each run gets its own Xvfb, HOME, XDG directories, and fixture tree fromtests/fixtures/harness.json. Runner-side steps (fs,restart,layout, peers intests/qa/peers.rs) execute in the runner; the rest go to the process. Costliest runs start first. - The container.
./scripts/qa.shrunscargo test --features qa,invariant-checks --test qainside the pinned image.tests/qa.rsrefuses to start without theSTRATA_QA_CONTAINER=1it sets. - The files.
tests/qa/scenarios/<folder>/<scenario>.jsonl, one scenario per file. Line 1 is the header; every later line is one step.
Run
./scripts/qa.sh # every scenario, every run
./scripts/qa.sh hidden-files-persist-across-restart # all runs of one scenario
./scripts/qa.sh hidden-files-persist-across-restart/list # one run
./scripts/qa.sh a b --test-threads 4 # several scenarios, 4 at once
./scripts/qa.sh --skip copy-leaves-source # everything but one scenario
STRATA_QA_KEEP_ARTIFACTS=1 ./scripts/qa.sh <name> # artifacts for passing runs too
- A name selects exactly that scenario (never another it prefixes);
<scenario>/<run>selects one run._in a name reads as-. --skip <scenario>or--skip <scenario>/<run>leaves exactly that out.--listprints the selected tests in start (cost) order.qa.shthen exits 1 with "The selection ran no QA scenario", because nothing ran.- A run prints
RUN <scenario>/<run>up front, thentest <name> ... ok <secs>andPASS <scenario>/<run>(orKNOWN BUG ...) as each finishes, failure details at the end, and libtest'stest result:line.
Container rules:
- Use Docker or Podman, whichever the scripts select (Podman whenever it is
installed);
STRATA_CONTAINER_ENGINEoverrides it. Run container scripts (scripts/qa.sh,scripts/qa-mutation.sh,scripts/quality.sh, and the like) one after another, never concurrently: some share a build cache, and all of them load the whole machine. - Never run scenarios on the host.
STRATA_QA_HOST=1needs the owner's explicit consent for that task, is labelled unsupported, is never validation evidence, and goes in the handoff with the container run it replaced.
Write a scenario
- Find the coverage owner.
grep -rlthe scenarios for the feature's keys, labels, and preferences. Extend an existing scenario (a step, a mode, a variant) rather than adding a near-duplicate. Add a new file only for behaviour nothing covers. - Choose the folder and name. A new scenario lives in a feature-area
folder named in snake_case without a
test_prefix (tests/qa/scenarios/hidden_files/); reuse an existing folder when one fits. The file stem is thescenarioname: lowercase words joined by hyphens, unique across all folders, saying the behaviour (hidden-files-persist-across-restart). The runner'severy_scenario_file_is_planned_and_well_formedtest enforces both. - Write the header. Minimum:
{"scenario":"<name>","modes":[...]}. Addmodesonly where the view changes the behaviour; addprefs,tree,setup,variants,session_bus,envonly as needed. Leave outcostuntil it is measured. - Build the fixture. The default tree (
canonical) is:archive/,documents/{notes.txt,report.md,spreadsheet.csv},pictures/{diagram.txt,photo.txt},readme.md,todo.txt,.hidden.txt. Prefer it. Add files with a headertree/images,setupfsactions, or anfsstep before the input that needs them. Paths are relative to the fixture;$fixture,$root,$home,$config,$data,$cache,$environment,$python,$view, and$modeare built in. - Drive input, then wait on observable state, never on time. After every
key, click, or drag,
waiton what the user would see change (entries, selection, focus, a dialog, a menu, a field, a preference on disk, a file on disk) before the next input. Never add asleepto make a step pass. - Give every wait a
message. Say what is awaited, in words, so a timed-out wait readstimed out after 15000 ms: <message> expected <predicate>, actual <value>:"Ctrl+. to show hidden files","the rename editor to take focus". Giveexpectandfschecks a message too, unless it asserts at once ("timeout_ms":0) right after a wait that already explains the state. - Assert behaviour, not layout. Check names, selection, focus, dialogs, menus, files, preferences, and text. Do not assert widget geometry, alignment, spacing, sizes, wrapping, or pixel placement; geometry predicates are for behaviour that is positional (a scroll reached its target, a drag landed, a repaint happened, a committed visual baseline).
- Target by accessibility. Copy roles and names from a
treequery. Entry rows are labelled with their name and describedFolderorFile; panes are labelled with their directory; menu items carry the accelerator in their description; dialogs carry the dialog role and a name; a button with a text label is named by it. Tooltips never name a target. If a control has no usable name, name it in the app (accessibility::set_labelfor an icon-only button); never reach around the accessibility layer. - Run it by name until it passes, then run each of its runs a few times
and once inside a full
./scripts/qa.sh, which runs it under load. - Prove it can fail. Break the behaviour temporarily (change a step's expected value, or revert the app change) and confirm the failure names the right step; restore it.
- Set
costandquality(see below).
wait stops the run on timeout; expect records a mismatch and continues
(use it for several independent assertions about one state). select
clicks until an entry is selected; select_with_keyboard walks the cursor to
it. settle waits for a target's box to stop moving before a click that must
land on it.
Examples
All three pass in the pinned container.
A QA-only scenario: a preference survives a restart
tests/qa/scenarios/hidden_files/hidden-files-persist-across-restart.jsonl:
{"scenario":"hidden-files-persist-across-restart","modes":["columns","list"],"cost":{"columns":0.5,"list":0.6}}
{"wait":{"entries":{"contains":["todo.txt"],"excludes":[".hidden.txt"]},"dir":"$root"},"message":"the fixture listed without its hidden file"}
{"key":"ctrl+period"}
{"wait":{"entries":{"contains":[".hidden.txt"]},"dir":"$root"},"message":"Ctrl+. to show hidden files"}
{"wait":{"pref":{"key":"show_hidden","value":true}},"message":"show_hidden to be saved as true"}
{"restart":true}
{"wait":{"entries":{"contains":["todo.txt",".hidden.txt"]},"dir":"$root"},"message":"hidden files to stay shown after a restart"}
{"key":"ctrl+period"}
{"wait":{"entries":{"contains":["todo.txt"],"excludes":[".hidden.txt"]},"dir":"$root"},"message":"Ctrl+. to hide hidden files again"}
{"wait":{"pref":{"key":"show_hidden","value":false}},"message":"show_hidden to be saved as false"}
It waits for the listing before the first key, waits on the saved preference before restarting (so the restart cannot race the write), and checks the listing after the restart.
Pointer, context menu, and an inline editor
The steps of tests/qa/scenarios/entry_management/rename-from-context-menu.jsonl:
{"click":{"entry":"readme.md"},"button":3}
{"wait":{"menu_items":{}},"message":"the context menu for 'readme.md'"}
{"wait":{"present":{"menu_item":"Rename"}},"message":"the menu item 'Rename'"}
{"settle":{"menu_item":"Rename"}}
{"click":{"menu_item":"Rename"}}
{"wait":{"menu_items":null},"message":"the menu to close"}
{"wait":{"field":{}},"message":"an editable field to take focus"}
{"remember":{"field":true},"as":"field"}
{"key":"ctrl+a"}
{"type":"guide.md"}
{"wait":{"field":{"of":"field","text":"guide.md"}},"message":"the new name to be typed"}
{"key":"Return"}
{"fs":{"path":"guide.md","exists":true},"message":"the rename to apply"}
{"fs":{"path":"readme.md","exists":false},"timeout_ms":0}
It settles the popover's item before clicking it, remembers the editor so later checks read that field, and checks the result on disk.
Every view, and expect versus wait
The steps of tests/qa/scenarios/selection/right-click-selects-entry.jsonl
(modes columns, icons, list; variants file and folder set $target
and $previous):
{"select":{"entry":"readme.md","dir":"$root"}}
{"wait":{"focused":"readme.md"},"message":"keyboard focus to reach 'readme.md'"}
{"click":{"entry":"$target","dir":"$root"},"button":3}
{"wait":{"menu_items":{}},"message":"the context menu for '$target'"}
{"wait":{"selected":["$target"],"dir":"$root"},"message":"the selection to become ['$target']"}
{"key":"Escape"}
{"wait":{"menu_items":null},"message":"the menu to close"}
{"wait":{"focused":"$target"},"message":"keyboard focus to reach '$target'"}
{"expect":{"panes":["$root"]},"message":"a folder context menu must not navigate"}
{"key":"Left","modes":["icons"]}
{"key":"Up","modes":["columns","list"]}
{"wait":{"focused":"$previous"},"message":"keyboard focus to reach '$previous'"}
{"wait":{"selected":["$previous"],"dir":"$root"},"message":"the selection to become ['$previous']"}
The expect asserts that the menu left the listing where it was without
stopping the run, so the steps after it still report; the modes modifiers
send the key that moves back one entry in each view.
Header fields you will use
| Key | Use |
|---|---|
modes |
columns, icons, list; one run each. The run's mode is written as browser_mode over every prefs. |
prefs |
settings.toml overrides, for example {"type_to_search":false}. |
variants |
[{"id":"ru","layout":"ru","vars":{"key":"value"},"prefs":{...},"modes":[...]}]: reruns, labelled <mode>-<id>; steps opt in with "variants":["ru"]. |
tree, images, setup, teardown |
Fixture contents, written before launch; teardown runs however the run ends. |
session_bus, env, portal, fixture_root |
A private D-Bus; extra app variables; the portal backend; a fixed /tmp/<name> fixture path. |
input_method |
A private fcitx5 with that input method (host runs only; see docs/testing.md). |
known_bug, known_bug_step |
An issue and the line expected to fail. |
mutation |
A patch in tests/qa/mutations/ the scenario must catch; check it with ./scripts/qa-mutation.sh <scenario>. |
skip |
A reason; keeps the scenario out of the default run. |
cost |
Measured start-order weights per run label. |
quality |
The scenario's value rating. |
Step modifiers: "modes":[...], "variants":[...], "when":PREDICATE,
"if"/"unless" (flags kept by expect/flag with "as"),
"message", "timeout_ms".
Keep cost and quality current
costorders the start of runs (costliest first; a run without one starts first). After adding scenarios or runs, refresh it:python3 scripts/qa_cost.py run(three full container runs, then rewrites only each header'scost), orpython3 scripts/qa_cost.py write target/qa-cost/run-*.logfrom saved logs. The runner rejects acostthat misses a run label or names another, so update it when you add, rename, or drop a mode or variant.qualityis{"impact","coverage","criticality","diagnostic","score","note"}; the runner checks the shape and recomputesscore. Use theqa-qualityskill to rate a scenario and compute the score, including its independent cheap-model grading; re-rate when the scenario's behaviour or coverage changes.
Manage scenarios
- Rename:
git mvthe file, change itsscenariokey to the new stem, and replace the old name everywhere it appears (grep -rn <old> docs tests .agents), including the "Skipped scenarios" and "Waits for the app's own timers" lists. Cost labels are run labels, so they stay. - Split: give each new file its own header and only the steps its
behaviour needs; keep every meaningful assertion; refresh
cost; rate each file'squality. - Remove: only when another scenario covers the same behaviour (name it in the commit message) or the behaviour is gone. Delete the file and every mention of its name.
skipis only for a scenario whose pass or fail the app leaves to chance, until the app is fixed: file the issue, put it in the reason, and add the scenario with its evidence to "Skipped scenarios". It still runs when named.known_bugis for a reproducible app bug that the scenario documents: set the issue and the exact failing line. The run then passes only while that step fails; once the bug is fixed the run fails with "remove the marker". Never use it for a flake.
Flakes and races
- Never add
sleepor a longer timeout to hide a race. Reproduce first: run the one run repeatedly (for i in $(seq 20); do ./scripts/qa.sh <scenario>/<run> || break; done) and inside full runs, which add load. - If the scenario raced the app (input before the state it depends on), add a wait on that observable state.
- If the app itself drops or misroutes input for a short window (a race in the app), file an issue; do not work around it silently.
- A wait that exists only because QA input is faster than a person can be (for example waiting for the column the cursor mirrors before a shortcut) is allowed only with evidence: add the scenario, run, what it waits for, the app race, and failure counts with and without the wait to "Waits for the app's own timers", and file an issue for the race.
Debug a failure
A failed run prints <scenario>/<run> failed: with each failure as
line N {step}: <error> and artifacts: .../target/qa-artifacts/<scenario>/<run>
(the path is the container's /workspace, which is the checkout).
could not run: means setup, launch, or a harness error, which no
known_bug excuses. A malformed step fails before launch with
line N: <parser error>.
The artifact directory holds failures.txt, transcript.jsonl (every
command with its reply and app snapshot), tree.txt, state.json,
screenshot.png, strata.log, input.log, fixture.txt, xvfb.log, and
result.json. Pass STRATA_QA_KEEP_ARTIFACTS=1 to keep them for a passing
run.
- A wait timed out. The line reads
timed out after 15000 ms: <message> expected <predicate>, actual <value>. Compareactualwith what you expected; then read theappsnapshot intranscript.jsonlfor the steps before it to find where the state diverged. Checkscreenshot.pngfor an unexpected dialog or menu, andstrata.logfor errors. If the state is right but named differently, fix the predicate fromtree.txt. - A target was not found. Search
tree.txtfor the role and name. Names come from labels andaccessibility::set_label, never tooltips; a widget scrolled away is notpresent(usereveal, or"rendered":falsewhere presence is enough). - Focus is not where expected.
tree.txtstarts withfocus: ..., marked when focus sits on an unmapped widget; such focus shows asnullin the snapshot. Wait for{"focused":...}or{"focused_widget":...}before sending keys, since keys go to the focus. - A click was lost. A listing that replaces a row mid-press cancels the
click. Wait for the listing to finish first: after a reload,
rememberthe row before the reload andwaitfor{"replaced":{"target":...,"since":...}}; otherwise wait for the row's own final state, orsettleit. If no observable state marks the end of the refresh, that is an app race: file an issue. - See what the snapshot holds at any point. Add a temporary
{"query":"state"}or{"query":"tree"}step, run withSTRATA_QA_KEEP_ARTIFACTS=1, and read theresultintranscript.jsonl; remove the step afterwards. To explore interactively, use theqa-driveskill. - A GLib warning failed the run. The failure names it; it is an app bug, not a scenario bug.
Change the runner
Runner unit tests live in tests/qa/scenario_tests.rs and
tests/qa/step_tests.rs and must be listed in the unit_tests! macro in
tests/qa/unit_tests.rs: the custom harness ignores #[test] and
#[cfg(test)], and no_test_attribute_hides_a_runner_test rejects them.
Unit tests run in every ./scripts/qa.sh run that names no scenario. Script
tests: python3 -m unittest discover -s scripts -p 'test_*.py'.
The main-thread I/O audit also runs through the runner: with
STRATA_QA_MAIN_IO set, tests/qa/main_io.rs starts every Strata launch
under strace and writes the meta.json that scripts/trace-main-io.py
classifies paths by. When you change how the runner launches or restarts the
app, starts peers, or places its own files, run
./scripts/trace-main-io.py <scenario> on an affected scenario and check its
traces still appear. See "Main-thread I/O audit" in docs/testing.md.
Before committing a scenario change
- The file is in the right folder, its stem equals
scenario, and the name says the behaviour. - Every input is followed by a wait on observable state; every wait and
polling check has a
message; nosleepwas added. - Assertions test behaviour, not layout or geometry.
- Targets use accessible names; any missing name was added in the app.
- Each run passes by name, repeatedly, and in a full
./scripts/qa.sh; the scenario was shown to fail when the behaviour breaks. -
costcovers exactly the run labels;quality, if present, was re-rated with theqa-qualityskill and checked by its independent cheap-model grader. -
skip,known_bug, and any wait for the app's own timers carry an issue and are listed in the guide. - A scenario with a
mutationstill catches it:./scripts/qa-mutation.sh <scenario>. - Renamed or removed names are gone from
docs/,tests/, and the skills. -
git diff --checkis clean.
Version History
- d5ae6aa Current 2026-11-02 10:17


