PyClickerRPA 0.4.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PKG-INFO +47 -12
  2. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PyClickerRPA.egg-info/PKG-INFO +47 -12
  3. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PyClickerRPA.egg-info/SOURCES.txt +8 -0
  4. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PyClickerRPA.egg-info/entry_points.txt +1 -0
  5. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/README.md +45 -10
  6. pyclickerrpa-0.6.0/autoclicker/diagnose.py +179 -0
  7. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/flow_editor.py +76 -18
  8. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/flow_node.py +21 -2
  9. pyclickerrpa-0.6.0/autoclicker/gui/lint_dialog.py +53 -0
  10. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/main_window.py +114 -3
  11. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/qt_runner.py +6 -1
  12. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/task_widget.py +281 -36
  13. pyclickerrpa-0.6.0/autoclicker/lint.py +203 -0
  14. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/models.py +108 -9
  15. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/runner.py +272 -62
  16. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/visual.py +11 -4
  17. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/pyproject.toml +3 -2
  18. pyclickerrpa-0.6.0/tests/test_diagnose.py +198 -0
  19. pyclickerrpa-0.6.0/tests/test_diagnose_gui.py +97 -0
  20. pyclickerrpa-0.6.0/tests/test_lint.py +200 -0
  21. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_recorder_gui.py +7 -1
  22. pyclickerrpa-0.6.0/tests/test_structured_flow.py +377 -0
  23. pyclickerrpa-0.6.0/tests/test_structured_gui.py +223 -0
  24. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/LICENSE +0 -0
  25. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PyClickerRPA.egg-info/dependency_links.txt +0 -0
  26. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PyClickerRPA.egg-info/requires.txt +0 -0
  27. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/PyClickerRPA.egg-info/top_level.txt +0 -0
  28. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/__init__.py +0 -0
  29. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/adb.py +0 -0
  30. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/app.py +0 -0
  31. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/capture.py +0 -0
  32. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/clicker.py +0 -0
  33. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/dpi.py +0 -0
  34. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/expr.py +0 -0
  35. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/__init__.py +0 -0
  36. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/flow_connection.py +0 -0
  37. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/hotkeys.py +0 -0
  38. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/monitor.py +0 -0
  39. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/preview_label.py +0 -0
  40. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/recorder.py +0 -0
  41. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/scheduler_widget.py +0 -0
  42. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/snippet_library.py +0 -0
  43. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/gui/testkit_widget.py +0 -0
  44. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/matcher.py +0 -0
  45. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/ocr.py +0 -0
  46. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/run.py +0 -0
  47. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/templatize.py +0 -0
  48. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/__init__.py +0 -0
  49. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/__main__.py +0 -0
  50. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/assertions.py +0 -0
  51. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/case.py +0 -0
  52. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/loader.py +0 -0
  53. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/pytest_plugin.py +0 -0
  54. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/report.py +0 -0
  55. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/testkit/runner.py +0 -0
  56. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/__init__.py +0 -0
  57. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/__main__.py +0 -0
  58. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/run_manager.py +0 -0
  59. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/scheduler.py +0 -0
  60. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/serialize.py +0 -0
  61. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/server.py +0 -0
  62. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/static/app.js +0 -0
  63. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/static/index.html +0 -0
  64. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/web/static/style.css +0 -0
  65. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/autoclicker/windows.py +0 -0
  66. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/setup.cfg +0 -0
  67. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_adb.py +0 -0
  68. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_flow_branch.py +0 -0
  69. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_matcher.py +0 -0
  70. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_models.py +0 -0
  71. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_runner.py +0 -0
  72. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_templatize.py +0 -0
  73. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_testkit.py +0 -0
  74. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_testkit_gui.py +0 -0
  75. {pyclickerrpa-0.4.0 → pyclickerrpa-0.6.0}/tests/test_web.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyClickerRPA
3
- Version: 0.4.0
4
- Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor, a smart recorder that records images instead of coordinates, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
3
+ Version: 0.6.0
4
+ Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, structured conditions and loops with array iteration, node flow editor, smart recorder, task health checks and failure attribution, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
5
5
  Author: Equinox
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Equinox/PyClickerRPA
@@ -44,8 +44,8 @@ ADB) input — then turn those flows into a **visual test framework** with
44
44
  assertions, visual-regression baselines, HTML/JUnit reports, and a **web console**
45
45
  for remote runs.
46
46
 
47
- Python/PySide6, started as a port of a Qt/C++ AutoClicker (still reads/writes the
48
- same `.json` task format).
47
+ Python/PySide6, started as a port of a Qt/C++ AutoClicker. Legacy `.json` tasks
48
+ remain supported; structured control-flow blocks are a Python-only extension.
49
49
 
50
50
  ## Install
51
51
 
@@ -66,6 +66,7 @@ pip install "PyClickerRPA[web]" # FastAPI + uvicorn — web console
66
66
  autoclicker # desktop GUI (flow editor / list editor / test tab)
67
67
  autoclicker-run task.json --window "Notepad" # headless run against a window
68
68
  autoclicker-run task.json --adb # run against an Android device (adb)
69
+ autoclicker-lint task.json # health-check a task (exit code = error count)
69
70
  autoclicker-testkit suite.json --report out.html --junit out.xml # run a test suite
70
71
  autoclicker-web --suites-dir examples # web console at http://127.0.0.1:8000
71
72
  ```
@@ -90,14 +91,24 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
90
91
 
91
92
  ## Authoring (desktop GUI)
92
93
 
93
- Drag-and-drop **node flow graph** (condition nodes have **true/false branch
94
- ports** for visual if-else; a terminal **stop** node ends the flow) and an
95
- interoperable **step list** with a full
96
- per-step dialog; snippet library, task
97
- packages (`.zip` with templates), on-preview coordinate/color picking, template
98
- crop, single-step test run; global hotkeys (F6 run/stop, ESC stop), scheduler,
99
- and an image/color **monitor/trigger** with tray icon; a **Test** tab to load and
100
- run visual test suites.
94
+ Drag-and-drop **node flow graph** and an interoperable **step list**, with
95
+ nested **If/Else**, **While**, fixed-count loops, **ForEach** arrays, and
96
+ **Break/Continue** — no step numbers required. Double-click a block to edit its
97
+ body or branches. Legacy condition nodes retain their true/false ports.
98
+
99
+ Select consecutive steps and choose **包进… → 遍历数组** to reuse them for each
100
+ item. Enter a custom JSON array or generate a numeric range, then reference the
101
+ current item in text actions, assignments, and conditions. For example, iterate
102
+ `[1,2,3,4,5,6,7,8,9,10]` with variable `server`, click **`{server}服`**, perform
103
+ the actions, and return to the server-selection screen inside the same body.
104
+ Enable exact text matching so `1服` does not also match `11服`. A failed action
105
+ inside a block stops execution unless its failure policy explicitly skips it.
106
+
107
+ Also includes a snippet library, task packages (`.zip` with nested templates),
108
+ on-preview coordinate/color picking, template crop, single-step test run;
109
+ global hotkeys (F6 run/stop, ESC stop), scheduler, and an image/color
110
+ **monitor/trigger** with tray icon; a **Test** tab to load and run visual test
111
+ suites.
101
112
 
102
113
  ### Smart recorder
103
114
 
@@ -120,6 +131,30 @@ like* instead of where they *were*:
120
131
  Anything that can't be made robust falls back to the plain coordinate/fixed-wait
121
132
  step and says why in the log.
122
133
 
134
+ ## Diagnostics
135
+
136
+ **Task health check** (`autoclicker-lint task.json`, or 诊断 → 任务体检 in the GUI)
137
+ statically scans a task and separates *will definitely break* from *works but
138
+ fragile*:
139
+
140
+ - **errors** — missing template / sub-task file, image step with no template,
141
+ loop / goto / fail-jump target out of range, unbalanced groups. The GUI asks
142
+ for confirmation before running a task with errors; the CLI exit code is the
143
+ error count.
144
+ - **warnings** — steps that depend on absolute coordinates, long fixed waits,
145
+ a match threshold low enough to hit the wrong thing, templates too small to be
146
+ unique. These are exactly the things the smart recorder now avoids, so the
147
+ check doubles as a "what should I re-record?" list.
148
+
149
+ **Failure attribution.** A run captures a screenshot per step; the first
150
+ successful run becomes the baseline timeline (`.timelines/<task>/`). When a later
151
+ run fails, the frames are compared step by step and the report names the step
152
+ where the screen **first diverged** from the successful run — which is usually
153
+ *earlier* than the step that failed (step 7 can't find its button because a popup
154
+ appeared at step 4). You get the diverging step, the per-step changed-pixel
155
+ ratios, and a baseline|actual|heatmap image. If the screens match, it says so —
156
+ that points at timing/flakiness rather than a UI change.
157
+
123
158
  ## Visual test framework (`autoclicker.testkit`)
124
159
 
125
160
  Turn automation into tests:
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyClickerRPA
3
- Version: 0.4.0
4
- Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, node flow editor, a smart recorder that records images instead of coordinates, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
3
+ Version: 0.6.0
4
+ Summary: Visual UI automation & testing tool: Windows + Android(ADB) automation, robust/self-healing image matching, OCR, structured conditions and loops with array iteration, node flow editor, smart recorder, task health checks and failure attribution, a visual test framework (assertions, visual regression, HTML/JUnit reports) and a web console.
5
5
  Author: Equinox
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/Equinox/PyClickerRPA
@@ -44,8 +44,8 @@ ADB) input — then turn those flows into a **visual test framework** with
44
44
  assertions, visual-regression baselines, HTML/JUnit reports, and a **web console**
45
45
  for remote runs.
46
46
 
47
- Python/PySide6, started as a port of a Qt/C++ AutoClicker (still reads/writes the
48
- same `.json` task format).
47
+ Python/PySide6, started as a port of a Qt/C++ AutoClicker. Legacy `.json` tasks
48
+ remain supported; structured control-flow blocks are a Python-only extension.
49
49
 
50
50
  ## Install
51
51
 
@@ -66,6 +66,7 @@ pip install "PyClickerRPA[web]" # FastAPI + uvicorn — web console
66
66
  autoclicker # desktop GUI (flow editor / list editor / test tab)
67
67
  autoclicker-run task.json --window "Notepad" # headless run against a window
68
68
  autoclicker-run task.json --adb # run against an Android device (adb)
69
+ autoclicker-lint task.json # health-check a task (exit code = error count)
69
70
  autoclicker-testkit suite.json --report out.html --junit out.xml # run a test suite
70
71
  autoclicker-web --suites-dir examples # web console at http://127.0.0.1:8000
71
72
  ```
@@ -90,14 +91,24 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
90
91
 
91
92
  ## Authoring (desktop GUI)
92
93
 
93
- Drag-and-drop **node flow graph** (condition nodes have **true/false branch
94
- ports** for visual if-else; a terminal **stop** node ends the flow) and an
95
- interoperable **step list** with a full
96
- per-step dialog; snippet library, task
97
- packages (`.zip` with templates), on-preview coordinate/color picking, template
98
- crop, single-step test run; global hotkeys (F6 run/stop, ESC stop), scheduler,
99
- and an image/color **monitor/trigger** with tray icon; a **Test** tab to load and
100
- run visual test suites.
94
+ Drag-and-drop **node flow graph** and an interoperable **step list**, with
95
+ nested **If/Else**, **While**, fixed-count loops, **ForEach** arrays, and
96
+ **Break/Continue** — no step numbers required. Double-click a block to edit its
97
+ body or branches. Legacy condition nodes retain their true/false ports.
98
+
99
+ Select consecutive steps and choose **包进… → 遍历数组** to reuse them for each
100
+ item. Enter a custom JSON array or generate a numeric range, then reference the
101
+ current item in text actions, assignments, and conditions. For example, iterate
102
+ `[1,2,3,4,5,6,7,8,9,10]` with variable `server`, click **`{server}服`**, perform
103
+ the actions, and return to the server-selection screen inside the same body.
104
+ Enable exact text matching so `1服` does not also match `11服`. A failed action
105
+ inside a block stops execution unless its failure policy explicitly skips it.
106
+
107
+ Also includes a snippet library, task packages (`.zip` with nested templates),
108
+ on-preview coordinate/color picking, template crop, single-step test run;
109
+ global hotkeys (F6 run/stop, ESC stop), scheduler, and an image/color
110
+ **monitor/trigger** with tray icon; a **Test** tab to load and run visual test
111
+ suites.
101
112
 
102
113
  ### Smart recorder
103
114
 
@@ -120,6 +131,30 @@ like* instead of where they *were*:
120
131
  Anything that can't be made robust falls back to the plain coordinate/fixed-wait
121
132
  step and says why in the log.
122
133
 
134
+ ## Diagnostics
135
+
136
+ **Task health check** (`autoclicker-lint task.json`, or 诊断 → 任务体检 in the GUI)
137
+ statically scans a task and separates *will definitely break* from *works but
138
+ fragile*:
139
+
140
+ - **errors** — missing template / sub-task file, image step with no template,
141
+ loop / goto / fail-jump target out of range, unbalanced groups. The GUI asks
142
+ for confirmation before running a task with errors; the CLI exit code is the
143
+ error count.
144
+ - **warnings** — steps that depend on absolute coordinates, long fixed waits,
145
+ a match threshold low enough to hit the wrong thing, templates too small to be
146
+ unique. These are exactly the things the smart recorder now avoids, so the
147
+ check doubles as a "what should I re-record?" list.
148
+
149
+ **Failure attribution.** A run captures a screenshot per step; the first
150
+ successful run becomes the baseline timeline (`.timelines/<task>/`). When a later
151
+ run fails, the frames are compared step by step and the report names the step
152
+ where the screen **first diverged** from the successful run — which is usually
153
+ *earlier* than the step that failed (step 7 can't find its button because a popup
154
+ appeared at step 4). You get the diverging step, the per-step changed-pixel
155
+ ratios, and a baseline|actual|heatmap image. If the screens match, it says so —
156
+ that points at timing/flakiness rather than a UI change.
157
+
123
158
  ## Visual test framework (`autoclicker.testkit`)
124
159
 
125
160
  Turn automation into tests:
@@ -12,8 +12,10 @@ autoclicker/adb.py
12
12
  autoclicker/app.py
13
13
  autoclicker/capture.py
14
14
  autoclicker/clicker.py
15
+ autoclicker/diagnose.py
15
16
  autoclicker/dpi.py
16
17
  autoclicker/expr.py
18
+ autoclicker/lint.py
17
19
  autoclicker/matcher.py
18
20
  autoclicker/models.py
19
21
  autoclicker/ocr.py
@@ -26,6 +28,7 @@ autoclicker/gui/flow_connection.py
26
28
  autoclicker/gui/flow_editor.py
27
29
  autoclicker/gui/flow_node.py
28
30
  autoclicker/gui/hotkeys.py
31
+ autoclicker/gui/lint_dialog.py
29
32
  autoclicker/gui/main_window.py
30
33
  autoclicker/gui/monitor.py
31
34
  autoclicker/gui/preview_label.py
@@ -54,11 +57,16 @@ autoclicker/web/static/app.js
54
57
  autoclicker/web/static/index.html
55
58
  autoclicker/web/static/style.css
56
59
  tests/test_adb.py
60
+ tests/test_diagnose.py
61
+ tests/test_diagnose_gui.py
57
62
  tests/test_flow_branch.py
63
+ tests/test_lint.py
58
64
  tests/test_matcher.py
59
65
  tests/test_models.py
60
66
  tests/test_recorder_gui.py
61
67
  tests/test_runner.py
68
+ tests/test_structured_flow.py
69
+ tests/test_structured_gui.py
62
70
  tests/test_templatize.py
63
71
  tests/test_testkit.py
64
72
  tests/test_testkit_gui.py
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
+ autoclicker-lint = autoclicker.lint:main
2
3
  autoclicker-run = autoclicker.run:main
3
4
  autoclicker-testkit = autoclicker.testkit.__main__:main
4
5
  autoclicker-web = autoclicker.web.__main__:main
@@ -6,8 +6,8 @@ ADB) input — then turn those flows into a **visual test framework** with
6
6
  assertions, visual-regression baselines, HTML/JUnit reports, and a **web console**
7
7
  for remote runs.
8
8
 
9
- Python/PySide6, started as a port of a Qt/C++ AutoClicker (still reads/writes the
10
- same `.json` task format).
9
+ Python/PySide6, started as a port of a Qt/C++ AutoClicker. Legacy `.json` tasks
10
+ remain supported; structured control-flow blocks are a Python-only extension.
11
11
 
12
12
  ## Install
13
13
 
@@ -28,6 +28,7 @@ pip install "PyClickerRPA[web]" # FastAPI + uvicorn — web console
28
28
  autoclicker # desktop GUI (flow editor / list editor / test tab)
29
29
  autoclicker-run task.json --window "Notepad" # headless run against a window
30
30
  autoclicker-run task.json --adb # run against an Android device (adb)
31
+ autoclicker-lint task.json # health-check a task (exit code = error count)
31
32
  autoclicker-testkit suite.json --report out.html --junit out.xml # run a test suite
32
33
  autoclicker-web --suites-dir examples # web console at http://127.0.0.1:8000
33
34
  ```
@@ -52,14 +53,24 @@ autoclicker-web --suites-dir examples # web console at http://127.0.0.1
52
53
 
53
54
  ## Authoring (desktop GUI)
54
55
 
55
- Drag-and-drop **node flow graph** (condition nodes have **true/false branch
56
- ports** for visual if-else; a terminal **stop** node ends the flow) and an
57
- interoperable **step list** with a full
58
- per-step dialog; snippet library, task
59
- packages (`.zip` with templates), on-preview coordinate/color picking, template
60
- crop, single-step test run; global hotkeys (F6 run/stop, ESC stop), scheduler,
61
- and an image/color **monitor/trigger** with tray icon; a **Test** tab to load and
62
- run visual test suites.
56
+ Drag-and-drop **node flow graph** and an interoperable **step list**, with
57
+ nested **If/Else**, **While**, fixed-count loops, **ForEach** arrays, and
58
+ **Break/Continue** — no step numbers required. Double-click a block to edit its
59
+ body or branches. Legacy condition nodes retain their true/false ports.
60
+
61
+ Select consecutive steps and choose **包进… → 遍历数组** to reuse them for each
62
+ item. Enter a custom JSON array or generate a numeric range, then reference the
63
+ current item in text actions, assignments, and conditions. For example, iterate
64
+ `[1,2,3,4,5,6,7,8,9,10]` with variable `server`, click **`{server}服`**, perform
65
+ the actions, and return to the server-selection screen inside the same body.
66
+ Enable exact text matching so `1服` does not also match `11服`. A failed action
67
+ inside a block stops execution unless its failure policy explicitly skips it.
68
+
69
+ Also includes a snippet library, task packages (`.zip` with nested templates),
70
+ on-preview coordinate/color picking, template crop, single-step test run;
71
+ global hotkeys (F6 run/stop, ESC stop), scheduler, and an image/color
72
+ **monitor/trigger** with tray icon; a **Test** tab to load and run visual test
73
+ suites.
63
74
 
64
75
  ### Smart recorder
65
76
 
@@ -82,6 +93,30 @@ like* instead of where they *were*:
82
93
  Anything that can't be made robust falls back to the plain coordinate/fixed-wait
83
94
  step and says why in the log.
84
95
 
96
+ ## Diagnostics
97
+
98
+ **Task health check** (`autoclicker-lint task.json`, or 诊断 → 任务体检 in the GUI)
99
+ statically scans a task and separates *will definitely break* from *works but
100
+ fragile*:
101
+
102
+ - **errors** — missing template / sub-task file, image step with no template,
103
+ loop / goto / fail-jump target out of range, unbalanced groups. The GUI asks
104
+ for confirmation before running a task with errors; the CLI exit code is the
105
+ error count.
106
+ - **warnings** — steps that depend on absolute coordinates, long fixed waits,
107
+ a match threshold low enough to hit the wrong thing, templates too small to be
108
+ unique. These are exactly the things the smart recorder now avoids, so the
109
+ check doubles as a "what should I re-record?" list.
110
+
111
+ **Failure attribution.** A run captures a screenshot per step; the first
112
+ successful run becomes the baseline timeline (`.timelines/<task>/`). When a later
113
+ run fails, the frames are compared step by step and the report names the step
114
+ where the screen **first diverged** from the successful run — which is usually
115
+ *earlier* than the step that failed (step 7 can't find its button because a popup
116
+ appeared at step 4). You get the diverging step, the per-step changed-pixel
117
+ ratios, and a baseline|actual|heatmap image. If the screens match, it says so —
118
+ that points at timing/flakiness rather than a UI change.
119
+
85
120
  ## Visual test framework (`autoclicker.testkit`)
86
121
 
87
122
  Turn automation into tests:
@@ -0,0 +1,179 @@
1
+ """失败归因:拿上一次成功运行的逐步截图当基准,指出画面**第一次**偏离发生在哪一步。
2
+
3
+ 动机:失败的步骤往往不是出问题的步骤。第 7 步找不到图,真正的原因可能是第 4 步弹出了
4
+ 公告框。日志只会告诉你"第 7 步失败",于是排查全靠人肉翻截图。
5
+
6
+ timeline 帧结构沿用 TaskRunner 的 `{"index": int, "desc": str, "png": bytes}`。
7
+ Qt-free。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import re
14
+ from dataclasses import dataclass, field
15
+
16
+ import cv2
17
+ import numpy as np
18
+
19
+ from autoclicker import matcher
20
+
21
+ BASELINE_ROOT = ".timelines"
22
+ DEFAULT_TOL = 0.02 # 变化像素比例超过这个就算"画面不一样了"
23
+ INDEX_FILE = "index.json"
24
+
25
+
26
+ @dataclass
27
+ class StepDiff:
28
+ index: int
29
+ desc: str
30
+ ratio: float # 变化像素比例;1.0 也可能表示尺寸不一致
31
+ note: str = ""
32
+
33
+ @property
34
+ def diverged(self) -> bool:
35
+ return self.ratio > DEFAULT_TOL
36
+
37
+
38
+ @dataclass
39
+ class Attribution:
40
+ failed_index: int
41
+ diverged_index: int | None # 画面第一次偏离的步骤;None = 没查出偏离
42
+ diffs: list[StepDiff] = field(default_factory=list)
43
+ triptych_png: bytes | None = None # 偏离那一步的 基准|实际|热力图
44
+ message: str = ""
45
+
46
+
47
+ def safe_name(name: str) -> str:
48
+ s = re.sub(r'[<>:"/\\|?*\s]+', "_", (name or "task").strip())
49
+ return s[:60] or "task"
50
+
51
+
52
+ def baseline_dir(task_name: str, root: str = BASELINE_ROOT) -> str:
53
+ return os.path.join(root, safe_name(task_name))
54
+
55
+
56
+ # ─────────────────── 持久化 ───────────────────
57
+
58
+ def save_timeline(timeline: list[dict], dirname: str) -> int:
59
+ os.makedirs(dirname, exist_ok=True)
60
+ meta = []
61
+ for pos, f in enumerate(timeline):
62
+ fname = f"frame_{pos:03d}_step_{int(f['index']):03d}.png"
63
+ with open(os.path.join(dirname, fname), "wb") as fh:
64
+ fh.write(f["png"])
65
+ meta.append({"file": fname, "index": int(f["index"]), "desc": f.get("desc", "")})
66
+ with open(os.path.join(dirname, INDEX_FILE), "w", encoding="utf-8") as fh:
67
+ json.dump(meta, fh, ensure_ascii=False, indent=2)
68
+ return len(meta)
69
+
70
+
71
+ def load_timeline(dirname: str) -> list[dict]:
72
+ idx = os.path.join(dirname, INDEX_FILE)
73
+ if not os.path.isfile(idx):
74
+ return []
75
+ with open(idx, "r", encoding="utf-8") as fh:
76
+ meta = json.load(fh)
77
+ out = []
78
+ for m in meta:
79
+ p = os.path.join(dirname, m["file"])
80
+ if not os.path.isfile(p):
81
+ continue
82
+ with open(p, "rb") as fh:
83
+ out.append({"index": int(m["index"]), "desc": m.get("desc", ""), "png": fh.read()})
84
+ return out
85
+
86
+
87
+ def has_baseline(dirname: str) -> bool:
88
+ return os.path.isfile(os.path.join(dirname, INDEX_FILE))
89
+
90
+
91
+ def save_baseline_if_absent(timeline: list[dict], dirname: str) -> bool:
92
+ """只在还没有基准时写。沿用 visual.compare_region 的"无基准就创建"惯例。
93
+ 返回是否真的写了。"""
94
+ if not timeline or has_baseline(dirname):
95
+ return False
96
+ save_timeline(timeline, dirname)
97
+ return True
98
+
99
+
100
+ # ─────────────────── 比对 ───────────────────
101
+
102
+ def _decode(png: bytes):
103
+ img = cv2.imdecode(np.frombuffer(png, dtype=np.uint8), cv2.IMREAD_COLOR)
104
+ return None if img is None or img.size == 0 else img
105
+
106
+
107
+ def _by_step(timeline: list[dict]) -> dict[int, dict]:
108
+ """按步骤下标取**首帧**。循环会让同一步出现多次,比对时只认第一次进入那一帧。"""
109
+ out: dict[int, dict] = {}
110
+ for f in timeline:
111
+ out.setdefault(int(f["index"]), f)
112
+ return out
113
+
114
+
115
+ def compare_timelines(baseline: list[dict], actual: list[dict],
116
+ tol: float = DEFAULT_TOL) -> list[StepDiff]:
117
+ """按步骤下标对齐两条 timeline,逐步算变化像素比例。"""
118
+ base_map = _by_step(baseline)
119
+ diffs: list[StepDiff] = []
120
+ for f in _by_step(actual).values():
121
+ i = int(f["index"])
122
+ desc = f.get("desc", "")
123
+ b = base_map.get(i)
124
+ if b is None:
125
+ diffs.append(StepDiff(i, desc, 0.0, "基准里没有这一步(流程走了不同分支?)"))
126
+ continue
127
+ a_img, b_img = _decode(f["png"]), _decode(b["png"])
128
+ if a_img is None or b_img is None:
129
+ diffs.append(StepDiff(i, desc, 0.0, "截图无法解码"))
130
+ continue
131
+ if a_img.shape != b_img.shape:
132
+ diffs.append(StepDiff(i, desc, 1.0,
133
+ f"画面尺寸变了 基准{b_img.shape[1]}x{b_img.shape[0]} "
134
+ f"vs 实际{a_img.shape[1]}x{a_img.shape[0]}"))
135
+ continue
136
+ diffs.append(StepDiff(i, desc, matcher.diff_ratio(a_img, b_img)))
137
+ diffs.sort(key=lambda d: d.index)
138
+ return diffs
139
+
140
+
141
+ def _triptych_for(baseline: list[dict], actual: list[dict], index: int) -> bytes | None:
142
+ from autoclicker.testkit.visual import heatmap, triptych
143
+ b = _by_step(baseline).get(index)
144
+ a = _by_step(actual).get(index)
145
+ if b is None or a is None:
146
+ return None
147
+ b_img, a_img = _decode(b["png"]), _decode(a["png"])
148
+ if b_img is None or a_img is None:
149
+ return None
150
+ if b_img.shape != a_img.shape:
151
+ return triptych(b_img, a_img, np.zeros_like(a_img))
152
+ per_pixel_max = cv2.absdiff(b_img, a_img).max(axis=2)
153
+ return triptych(b_img, a_img, heatmap(per_pixel_max))
154
+
155
+
156
+ def attribute(baseline: list[dict], actual: list[dict], failed_index: int,
157
+ tol: float = DEFAULT_TOL) -> Attribution:
158
+ """归因。重点在 diverged_index:画面第一次和成功那次不一样是在哪一步。"""
159
+ if not baseline:
160
+ return Attribution(failed_index, None, [], None,
161
+ "没有基准时间线可比对(成功跑一次后会自动建立)")
162
+ diffs = compare_timelines(baseline, actual, tol)
163
+ diverged = next((d for d in diffs if d.ratio > tol), None)
164
+ if diverged is None:
165
+ return Attribution(failed_index, None, diffs, None,
166
+ f"第 {failed_index + 1} 步失败,但逐步画面和成功那次基本一致"
167
+ f"(最大偏离 {max((d.ratio for d in diffs), default=0.0):.4f});"
168
+ f"可能是时序/随机性问题,而不是界面变了")
169
+ png = _triptych_for(baseline, actual, diverged.index)
170
+ if diverged.index < failed_index:
171
+ msg = (f"第 {failed_index + 1} 步失败,但画面从**第 {diverged.index + 1} 步**"
172
+ f"就开始和成功那次不一样了(偏离 {diverged.ratio:.4f})"
173
+ f"{' — ' + diverged.note if diverged.note else ''}。"
174
+ f"先查这一步: {diverged.desc}")
175
+ else:
176
+ msg = (f"第 {failed_index + 1} 步失败,画面偏离也正是从这一步开始"
177
+ f"(偏离 {diverged.ratio:.4f})"
178
+ f"{' — ' + diverged.note if diverged.note else ''}")
179
+ return Attribution(failed_index, diverged.index, diffs, png, msg)
@@ -13,10 +13,10 @@ from collections import deque
13
13
  from PySide6.QtCore import QLineF, QPointF, Qt, Signal
14
14
  from PySide6.QtGui import QColor, QKeySequence, QPainter, QPen
15
15
  from PySide6.QtWidgets import (
16
- QDialog, QGraphicsScene, QGraphicsView, QInputDialog, QMenu,
16
+ QDialog, QGraphicsScene, QGraphicsView, QInputDialog, QMenu, QMessageBox,
17
17
  )
18
18
 
19
- from autoclicker.models import Step, StepType, Task
19
+ from autoclicker.models import Step, StepType, Task, BLOCK_TYPES, iter_steps
20
20
  from autoclicker.gui.flow_node import FlowNode, FlowPort
21
21
  from autoclicker.gui.flow_connection import FlowConnection
22
22
 
@@ -29,16 +29,13 @@ _ADD_MENU = {
29
29
  "手势": [("拖拽", StepType.Drag), ("多点拖拽", StepType.MultiDrag),
30
30
  ("捏合", StepType.Pinch), ("滚轮", StepType.Scroll)],
31
31
  "识别/变量": [("读取文字→变量", StepType.ReadText), ("设置变量", StepType.SetVar)],
32
- "条件": [("条件(图片)", StepType.IfImageFound), ("条件(文字)", StepType.IfTextFound),
33
- ("条件(颜色)", StepType.IfColorMatch), ("条件(图片数量)", StepType.IfImageCount),
34
- ("条件(变量)", StepType.IfVar), ("If/Else", StepType.IfElse)],
32
+ "条件/循环": [("如果 / 否则", StepType.IfBlock), ("遍历数组", StepType.ForEach),
33
+ ("重复指定次数", StepType.RepeatBlock), ("条件循环 (While)", StepType.WhileBlock),
34
+ ("退出当前循环", StepType.Break), ("进入下一轮", StepType.Continue)],
35
35
  "流程": [("等待", StepType.Wait), ("随机等待", StepType.RandomWait),
36
36
  ("等待画面稳定", StepType.WaitStable),
37
37
  ("等待图片出现", StepType.WaitImage), ("等待图片消失", StepType.WaitImageGone),
38
- ("循环直到", StepType.DoUntil), ("While 条件", StepType.WhileCondition),
39
- ("跳转", StepType.Goto), ("调用子任务", StepType.CallSub),
40
- ("分组开始", StepType.GroupBegin), ("分组结束", StepType.GroupEnd),
41
- ("结束任务", StepType.Stop)],
38
+ ("调用子任务", StepType.CallSub), ("结束任务", StepType.Stop)],
42
39
  "窗口/高级": [("刷新/激活窗口", StepType.ActivateWindow), ("合成拖拽", StepType.MergeImage),
43
40
  ("HTTP 请求", StepType.HttpRequest), ("运行脚本(Python)", StepType.RunScript)],
44
41
  }
@@ -143,14 +140,12 @@ class FlowEditor(QGraphicsView):
143
140
  self._drag_line = None
144
141
  item = self.itemAt(event.position().toPoint())
145
142
  if isinstance(item, FlowPort) and item.port_type == FlowPort.INPUT and item is not self._drag_start_port:
146
- conn = self.connect_nodes(self._drag_start_port, item)
147
- if conn:
148
- from_node = self._drag_start_port.parentItem()
149
- to_node = item.parentItem()
150
- if to_node.pos().y() < from_node.pos().y(): # back-edge
151
- count, ok = QInputDialog.getInt(self, "循环次数", "重复次数 (-1 = 无限):", -1, -1, 99999, 1)
152
- if ok:
153
- conn.set_repeat_count(count)
143
+ from_node = self._drag_start_port.parentItem()
144
+ to_node = item.parentItem()
145
+ if from_node in self._reachable(to_node, set()):
146
+ QMessageBox.information(self, "使用循环块", "无需连接回线:选中需要重复的步骤,右键选择「将选中步骤包进…」。")
147
+ else:
148
+ self.connect_nodes(self._drag_start_port, item)
154
149
  self._drag_start_port = None
155
150
  return
156
151
  super().mouseReleaseEvent(event)
@@ -237,6 +232,10 @@ class FlowEditor(QGraphicsView):
237
232
  menu.addAction("删除", self._delete_selected)
238
233
  if self.selected_nodes():
239
234
  menu.addAction("复制选中节点 (Ctrl+C)", self._copy_selected)
235
+ wrap_menu = menu.addMenu("将选中步骤包进…")
236
+ for label, st in [("遍历数组", StepType.ForEach), ("重复指定次数", StepType.RepeatBlock),
237
+ ("条件循环", StepType.WhileBlock), ("如果 / 否则", StepType.IfBlock)]:
238
+ wrap_menu.addAction(label, lambda st=st: self.wrap_selection(st))
240
239
  if self._clip and self._clip["steps"]:
241
240
  menu.addAction("粘贴 (Ctrl+V)", self._paste_clip)
242
241
  if node or conn or self.selected_nodes() or self._clip:
@@ -246,9 +245,68 @@ class FlowEditor(QGraphicsView):
246
245
  for group, items in _ADD_MENU.items():
247
246
  sub = add_menu.addMenu(group)
248
247
  for label, st in items:
249
- sub.addAction(label, lambda st=st, p=scene_pos: self.add_node(Step(type=st), p))
248
+ sub.addAction(label, lambda st=st, p=scene_pos: self._add_configured_node(st, p))
250
249
  menu.exec(event.globalPos())
251
250
 
251
+ def _add_configured_node(self, step_type, pos):
252
+ from autoclicker.gui.task_widget import StepEditorDialog, new_step
253
+ dlg = StepEditorDialog(new_step(step_type), self)
254
+ if dlg.exec() == QDialog.Accepted:
255
+ self.add_node(dlg.result_step(), pos)
256
+
257
+ def wrap_selection(self, step_type):
258
+ from autoclicker.gui.task_widget import StepEditorDialog, new_step
259
+ selected = set(self.selected_nodes())
260
+ if not selected or step_type not in BLOCK_TYPES:
261
+ return
262
+ starts = [n for n in selected if not any(
263
+ c.from_port.parentItem() in selected for c in n.input_port.connections)]
264
+ ordered = []
265
+ node = starts[0] if len(starts) == 1 else None
266
+ while node is not None and node not in ordered:
267
+ ordered.append(node)
268
+ edges = [c for p in node.output_ports() for c in p.connections
269
+ if c.to_port.parentItem() in selected]
270
+ node = edges[0].to_port.parentItem() if len(edges) == 1 else None
271
+ incoming = [c for n in selected for c in n.input_port.connections
272
+ if c.from_port.parentItem() not in selected]
273
+ outgoing = [c for n in selected for p in n.output_ports() for c in p.connections
274
+ if c.to_port.parentItem() not in selected]
275
+ internal = [c for n in selected for p in n.output_ports() for c in p.connections
276
+ if c.to_port.parentItem() in selected]
277
+ linear = (len(ordered) == len(selected) and len(internal) == len(selected) - 1
278
+ and all(c.to_port.parentItem() is ordered[0] for c in incoming)
279
+ and all(c.from_port.parentItem() is ordered[-1] for c in outgoing)
280
+ and all(c.repeat_count == 1 for c in internal + incoming + outgoing)
281
+ and not any(n.is_condition() for n in selected))
282
+ legacy = {StepType.Loop, StepType.Goto, StepType.DoUntil, StepType.WhileCondition,
283
+ StepType.IfImageFound, StepType.IfTextFound, StepType.IfColorMatch,
284
+ StepType.IfImageCount, StepType.IfVar, StepType.IfElse,
285
+ StepType.GroupBegin, StepType.GroupEnd}
286
+ if not linear or any(s.type in legacy or s.failGoto >= 0
287
+ for s in iter_steps([n.step() for n in self.nodes])):
288
+ QMessageBox.warning(self, "无法包进块", "请选择连续、无分叉的步骤;流程中的旧式步骤号跳转须先改为条件/循环块。")
289
+ return
290
+ block = new_step(step_type)
291
+ block.body = [copy.deepcopy(n.step()) for n in ordered]
292
+ dlg = StepEditorDialog(block, self)
293
+ if dlg.exec() != QDialog.Accepted:
294
+ return
295
+ block = dlg.result_step()
296
+ pos = ordered[0].pos()
297
+ sources = [c.from_port for c in incoming]
298
+ targets = [c.to_port for c in outgoing]
299
+ self._scene.clearSelection()
300
+ for n in selected:
301
+ n.setSelected(True)
302
+ self._delete_selected()
303
+ wrapped = self.add_node(block, pos)
304
+ for source in sources:
305
+ self.connect_nodes(source, wrapped.input_port)
306
+ for target in targets:
307
+ self.connect_nodes(wrapped.output_port, target)
308
+ wrapped.setSelected(True)
309
+
252
310
  # ---- helpers ----
253
311
  def _node_at(self, view_pos) -> FlowNode | None:
254
312
  item = self.itemAt(view_pos)