ignition-test 0.1.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 (34) hide show
  1. ignition_test-0.1.0/PKG-INFO +316 -0
  2. ignition_test-0.1.0/README.md +291 -0
  3. ignition_test-0.1.0/pyproject.toml +53 -0
  4. ignition_test-0.1.0/setup.cfg +4 -0
  5. ignition_test-0.1.0/src/ignition_test.egg-info/PKG-INFO +316 -0
  6. ignition_test-0.1.0/src/ignition_test.egg-info/SOURCES.txt +32 -0
  7. ignition_test-0.1.0/src/ignition_test.egg-info/dependency_links.txt +1 -0
  8. ignition_test-0.1.0/src/ignition_test.egg-info/entry_points.txt +2 -0
  9. ignition_test-0.1.0/src/ignition_test.egg-info/requires.txt +8 -0
  10. ignition_test-0.1.0/src/ignition_test.egg-info/top_level.txt +1 -0
  11. ignition_test-0.1.0/src/ignition_test_cli/__init__.py +7 -0
  12. ignition_test-0.1.0/src/ignition_test_cli/client.py +511 -0
  13. ignition_test-0.1.0/src/ignition_test_cli/exits.py +55 -0
  14. ignition_test-0.1.0/src/ignition_test_cli/main.py +1462 -0
  15. ignition_test-0.1.0/src/ignition_test_cli/offline/ignition-test-offline.jar +0 -0
  16. ignition_test-0.1.0/src/ignition_test_cli/offline/runtime-8.1.json +56 -0
  17. ignition_test-0.1.0/src/ignition_test_cli/offline/runtime-8.3.json +65 -0
  18. ignition_test-0.1.0/src/ignition_test_cli/offline.py +290 -0
  19. ignition_test-0.1.0/src/ignition_test_cli/progress.py +283 -0
  20. ignition_test-0.1.0/src/ignition_test_cli/provision.py +293 -0
  21. ignition_test-0.1.0/src/ignition_test_cli/push.py +110 -0
  22. ignition_test-0.1.0/src/ignition_test_cli/verdict.py +248 -0
  23. ignition_test-0.1.0/tests/test_action_report.py +225 -0
  24. ignition_test-0.1.0/tests/test_action_steps.py +345 -0
  25. ignition_test-0.1.0/tests/test_agents.py +423 -0
  26. ignition_test-0.1.0/tests/test_commands.py +202 -0
  27. ignition_test-0.1.0/tests/test_info.py +102 -0
  28. ignition_test-0.1.0/tests/test_offline.py +342 -0
  29. ignition_test-0.1.0/tests/test_provision.py +160 -0
  30. ignition_test-0.1.0/tests/test_push.py +91 -0
  31. ignition_test-0.1.0/tests/test_run.py +383 -0
  32. ignition_test-0.1.0/tests/test_target_81.py +140 -0
  33. ignition_test-0.1.0/tests/test_verdict.py +216 -0
  34. ignition_test-0.1.0/tests/test_wait_ready.py +120 -0
@@ -0,0 +1,316 @@
1
+ Metadata-Version: 2.4
2
+ Name: ignition-test
3
+ Version: 0.1.0
4
+ Summary: CI client for the Ignition Test Framework module: provision a token, wait for the gateway, run suites over REST, download JUnit/lcov, exit like pytest.
5
+ Author: BW Design Group
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/bw-design-group/ignition.modules.test-framework
8
+ Project-URL: Documentation, https://github.com/bw-design-group/ignition.modules.test-framework/tree/main/cli
9
+ Keywords: ignition,testing,ci,pytest,scada
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: requests>=2.28
20
+ Requires-Dist: click>=8.1
21
+ Provides-Extra: ws
22
+ Requires-Dist: websocket-client>=1.6; extra == "ws"
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=7; extra == "test"
25
+
26
+ # ignition-test
27
+
28
+ Command-line client for the [Ignition Test Framework](../README.md) module. It
29
+ is what a CI job (or a developer without a Designer open) uses to provision a
30
+ gateway API token, wait for the gateway, register the Playwright browser agent,
31
+ run test suites over the module's REST API with pytest-like output, download
32
+ JUnit / lcov reports and exit with pytest-like codes.
33
+
34
+ - Python 3.9+, depends on `requests` and `click` only.
35
+ - Importable package: `ignition_test_cli` (`ignition_test` is the Jython
36
+ framework that runs inside the gateway; the two never meet).
37
+ - Talks to `/data/test-framework` on an Ignition 8.3 or 8.1 gateway running the module;
38
+ plain polling of `GET /runs/:id/events?since=` is the contract, no WebSocket
39
+ is needed.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pip install ignition-test # from PyPI; bundles the offline runner (run --offline)
45
+ pip install -e cli/ # from a checkout of this repository
46
+ # source install from GitHub, pinned by ref (branch, tag or commit); the repository is
47
+ # private, so pip needs credentials that can read it
48
+ pip install "git+https://github.com/bw-design-group/ignition.modules.test-framework@main#subdirectory=cli"
49
+ ```
50
+
51
+ `ignition-test` is published on PyPI from `cli-v*` tags; pin it (`ignition-test==0.1.0` or
52
+ `"ignition-test>=0.1,<1"`) in CI. Only the PyPI wheel carries the offline runtime: a source
53
+ install runs every gateway command, and `run --offline` after `./gradlew :offline:cliRuntime`.
54
+ The GitHub Action's `cli-source: auto` and the sample workflow still default to the source install
55
+ beside the action; `cli-source: pypi` and `IGNITION_TEST_CLI_FROM_PYPI: "true"` switch to PyPI.
56
+
57
+ ## Five-minute tour
58
+
59
+ ```bash
60
+ export IGT_GATEWAY=http://localhost:8088
61
+ export IGT_API_TOKEN=ci:... # the value X-Ignition-API-Token expects
62
+
63
+ ignition-test info # module/gateway versions, caller, schemaVersion
64
+ ignition-test discover --project DemoPlant # the static test tree, no code executed
65
+ ignition-test run --project DemoPlant --suite smoke
66
+ ignition-test run --project DemoPlant unit --coverage --fail-under 70 \
67
+ --junit out/junit.xml --lcov out/lcov.info --results out/results.json --run-json out/run.json
68
+ ignition-test runs # recent runs
69
+ ignition-test results r-20260926-121922-c011ee --exit-code # exit with the run's verdict
70
+ ignition-test agents add default --url http://browser-agent:7311 --secret-env AGENT_TOKEN --gateway-base-url http://gateway:8088
71
+ ```
72
+
73
+ What a run looks like:
74
+
75
+ ```
76
+ gateway http://localhost:8088 Ignition 8.3.9 module 0.1.0 (b26092604) transport=polling
77
+ run r-20260926-121918-09526e project=DemoPlant running
78
+ collected 21 items
79
+ unit/test_recipes ...................xs skipped: designer scope
80
+ wrote out/junit.xml
81
+ wrote out/lcov.info
82
+ coverage 46.3% (19/41 lines)
83
+ 19 passed, 0 failed, 0 error, 1 skipped, 1 xfailed in 2.38s exit 0
84
+ ```
85
+
86
+ `--verbose` prints one line per test (`unit/test_recipes::test_scale_total[50.0-51.0] PASSED 1 ms`).
87
+ Failures are printed at the end with the assertion explanation and the
88
+ resource-mapped traceback, then collection errors, the coverage line and the
89
+ summary line.
90
+
91
+ ## Commands
92
+
93
+ | Command | What it does | Routes |
94
+ |---|---|---|
95
+ | `info` | Module, gateway, caller (`kind actor canRead canWrite`) and schema facts; refuses an `/info` `schemaVersion` the CLI does not know | `GET /info` |
96
+ | `provision` | 8.3: writes a security level, its gateway grant and an API token into a `data/config` directory (offline; nothing is restarted). 8.1 (`--target 8.1`): prints the `IGT_BOOTSTRAP_*` environment the module seeds its token from | filesystem (8.3), none (8.1) |
97
+ | `wait-ready` | `/StatusPing` RUNNING, then `/health`, then `/info` with the token (`--module`, `--project` add checks); exit 3 on timeout with the last state, 4 at once on a refused token or for `--project` without a token (the project rung needs one) | `/StatusPing`, `/health`, `/info`, `/projects/:p/tests` |
98
+ | `run` | `POST /runs`, follow the events, download reports, exit like pytest | `POST /runs`, `/runs/:id/events`, `/runs/:id`, `/runs/:id/results`, `junit.xml`, `lcov.info` |
99
+ | `discover` | Static tree of a project (`--json`, `--ids`) | `GET /projects/:p/tests` |
100
+ | `runs` | Table of recent runs (`--project`, `--limit`, `--json`) | `GET /runs` |
101
+ | `results RUN_ID` | Summary and failures of a run (`--all`, `--json`); exits 0 and names the verdict, or exits with it under `--exit-code` | `GET /runs/:id`, `/runs/:id/results` |
102
+ | `cancel RUN_ID` | Cancel a queued or running run (already finished is not an error) | `POST /runs/:id/cancel` |
103
+ | `scan` | Rescan `data/projects` for files written after boot | `POST /scan?timeout=N` |
104
+ | `push SRC` | Write a folder of `.py` files as `test-python` resources into a mounted project, then scan | filesystem, `POST /scan` |
105
+ | `agents list` | Table of the configured browser agents (`--json`); a `gateway url` column appears when a record carries `gatewayBaseUrl`; secrets are never returned | `GET /agents` |
106
+ | `agents add NAME --url URL` | Create (or `--replace`) a `browser-agents` record; the secret is encrypted by the gateway first; `--gateway-base-url`, `--default`, `--disabled`, `--description`, `--ping` | `POST /data/api/v1/encryption/encrypt`, `/data/api/v1/resources/...` |
107
+ | `agents ping NAME` | The gateway probes `<url>/health` with the stored secret; exit 0 reachable, 3 not, 4 unknown | `POST /agents/:name/ping` |
108
+ | `agents remove NAME` | Delete the record by name and signature (`--if-exists` makes a missing name a no-op) | `/data/api/v1/resources/...` |
109
+
110
+ Every command accepts `--gateway URL` / `--token NAME:SECRET` (or `IGT_GATEWAY` /
111
+ `IGT_API_TOKEN`), `--insecure` for self-signed TLS and `--http-timeout`. `run`,
112
+ `discover` and `push` also read `IGT_PROJECT`.
113
+
114
+ ### `run` options
115
+
116
+ | Option | Maps to |
117
+ |---|---|
118
+ | `--suite S` | `RunRequest.suite`; the suite's selection and options are the base, flags win field by field |
119
+ | `PATHS...`, `--node-id`, `-k`, `-m` | `select.paths`, `select.nodeIds`, `select.keywords`, `select.markers` |
120
+ | `--coverage/--no-coverage`, `--coverage-include M`, `--fail-under N` | `options.coverage.{enabled,include,thresholdLines}`; `--fail-under` and `--coverage-include` imply coverage |
121
+ | `--timeout-per-test S`, `--timeout-run S`, `--fail-fast` | `options.timeoutPerTestMs`, `options.timeoutRunMs` (also the client wall clock: on expiry the run is cancelled and the CLI exits 2), `options.failFast` |
122
+ | `--policy development\|production` | `options.policy` (a suite that says `production` cannot be loosened by the request) |
123
+ | `--label K=V` (repeatable) | `RunRequest.label`, joined with commas |
124
+ | `--junit`, `--lcov`, `--results`, `--run-json PATH` | files downloaded after the terminal event |
125
+ | `--scan` | `POST /scan` before the run (files dropped into `data/projects` after boot) |
126
+ | `--wait/--no-wait`, `--poll-interval S` | follow the run (default) or print `{"runId": ...}` as the only stdout line (the banner goes to stderr) and exit 0 |
127
+ | `--require-browser` | exit 7 when a `browser`-marked test was skipped or errored for want of an agent, even with `--allow-degraded` |
128
+ | `--allow-degraded` | exit 0 instead of 7 for a degraded run; the summary still says `degraded` |
129
+ | `--collect-only` | `kind: collect`: collect without running; exit 1 on collection errors, else 0 |
130
+ | `--offline` | run from the project folder without a gateway (see [Offline runs](#offline-runs)) |
131
+
132
+ ### Exit codes
133
+
134
+ | Code | Meaning |
135
+ |---|---|
136
+ | 0 | passed |
137
+ | 1 | failures, errors, or any collection error (a syntax error never yields a green run with fewer tests); `aborted` with `collection-error` |
138
+ | 2 | cancelled or interrupted; `aborted` with `run-timeout`, `pytest-exit`, `library-changed`; the client `--timeout-run` expired |
139
+ | 3 | cannot reach the gateway, HTTP 5xx, `wait-ready` timeout, `queue-full`/`indexing` still refused after the retry window, `runner-unavailable`, `redundancy-inactive`; `aborted` with `run-error` or `leaked-threads` |
140
+ | 4 | usage: bad flags, 401/403, every 400/404/413/422 refusal, 409 `policy-refused`, an unknown `/info` schema |
141
+ | 5 | no tests collected |
142
+ | 6 | coverage below `--fail-under`, everything else green |
143
+ | 7 | degraded: `run.json.limitations[]` non-empty (unless `--allow-degraded`), or `--require-browser` and a browser item skipped for the agent |
144
+
145
+ Precedence when several conditions hold: 3, 4, 2, 1, 6, 7, 5, 0. The 409
146
+ `queue-full` and 503 `indexing` answers to `POST /runs` are retried every 5 s for
147
+ up to a minute before they count as 3.
148
+
149
+ ## Provisioning a token
150
+
151
+ `provision` is the one implementation of the API-token recipe (a port of
152
+ Flint's verified `provision-api-token.mjs`) as plain file writes:
153
+
154
+ ```bash
155
+ ignition-test provision --config-dir ci-stage/config --token-name ci --format env
156
+ # IGT_API_TOKEN=ci:<secret>
157
+ ```
158
+
159
+ It writes, under `<config-dir>/resources/core/ignition/`:
160
+
161
+ - `security-levels/config.json`: a custom grantable level (`--security-level`,
162
+ default `Test Framework`; `A/B` creates a nested level) appended to whatever
163
+ the file already holds;
164
+ - `security-properties/config.json`: that level added to the gateway
165
+ Access/Read/Write permissions (`AnyOf`). The module's routes check the platform
166
+ READ/WRITE permission today, so this grant is what lets the token start runs;
167
+ `--no-grant-gateway-permissions` skips it for the least-privilege design of the
168
+ security document;
169
+ - `api-token/<name>/config.json`: a `basic-token` profile holding `Authenticated`
170
+ plus the level, with `tokenHash = base64url(sha256(base64url_decode(secret)))`.
171
+ The secret is printed once; the gateway stores only its hash.
172
+
173
+ Each folder gets a `resource.json` (`scope A`, `files: [config.json]`,
174
+ `attributes.uuid`). `--settings '{"policy": "development"}'` (or `@file.json`)
175
+ additionally writes the module's `test-framework-settings` config resource.
176
+ `--token-file docker/.api-token` reuses the stored secret for the same token
177
+ name, so re-provisioning after a container recreate keeps callers working.
178
+
179
+ Two ways to get the files into a gateway:
180
+
181
+ 1. **Before first boot**: mount the directory as the container's
182
+ `data/config` (`examples/ci/docker-compose.yaml` does this; `chmod -R 0777`
183
+ the staged tree because the stock image runs as a non-root user).
184
+ 2. **Into a running container**: copy the container's existing
185
+ `security-levels` and `security-properties` folders out, run `provision`
186
+ against them (existing entries are merged, never replaced), copy the three
187
+ folders back and restart the gateway. `docker/provision-token.sh` does exactly
188
+ this for the compose stack (dev and the `integration-test.yaml` lane), calling
189
+ `provision` for the file writes; the CLI never restarts anything.
190
+
191
+ ### Ignition 8.1
192
+
193
+ 8.1 has no platform API tokens, so the module owns its tokens: it stores
194
+ `NAME` with `base64url(sha256(base64url_decode(SECRET)))` (the same hash as above)
195
+ and accepts the same `X-Ignition-API-Token: NAME:SECRET` header. The token is
196
+ seeded from the gateway container's environment at startup, so nothing is
197
+ written to disk:
198
+
199
+ ```bash
200
+ set -a # the printed NAME=value lines carry no export; compose must see IGT_BOOTSTRAP_*
201
+ eval "$(ignition-test provision --target 8.1 --format env --token-file docker/.api-token)"
202
+ set +a
203
+ # IGT_BOOTSTRAP_TOKEN=ci:<secret> -> gateway container (hashed and stored at startup)
204
+ # IGT_BOOTSTRAP_TOKEN_SCOPE=admin -> gateway container (read | run | admin)
205
+ # IGT_API_TOKEN=ci:<secret> -> this shell, for the CLI and tests/ci
206
+ docker compose -f docker/docker-compose.yaml -f docker/docker-compose.ci.yaml up -d --wait
207
+ ```
208
+
209
+ Scopes: `read` allows the GET routes; `run` adds `POST /runs`, `/scan` and
210
+ cancel; `admin` adds browser-agent and settings management (what `agents add`
211
+ needs). `--settings JSON|@file` adds `IGT_BOOTSTRAP_SETTINGS`, applied only while
212
+ the gateway has no settings row. On 8.1 the `agents` commands use the module's
213
+ own `POST /agents`, `PUT`/`DELETE /agents/:name` and `PUT /config/settings`
214
+ routes instead of the platform config API; `info` shows which platform answered.
215
+
216
+ ## Registering the browser agent
217
+
218
+ Browser tests need a `browser-agents` config record that tells the gateway where
219
+ the Playwright agent listens and the shared secret it was started with. The
220
+ gateway web page can create one by hand; in CI use the CLI after `wait-ready`:
221
+
222
+ ```bash
223
+ export AGENT_TOKEN="$(openssl rand -hex 32)" # the value the agent container gets as IGT_AGENT_SECRET
224
+ docker compose -f examples/ci/docker-compose.yaml --profile browser up -d --wait
225
+ ignition-test wait-ready --module dev.bwdesigngroup.testing.TestFramework --project DemoPlant
226
+ ignition-test agents add default --url http://browser-agent:7311 --secret-env AGENT_TOKEN --gateway-base-url http://gateway:8088 --description "CI agent" --ping
227
+ ignition-test agents list
228
+ ```
229
+
230
+ `add` sends the secret once to the gateway's `POST /data/api/v1/encryption/encrypt`
231
+ and writes only the encrypted `{"type": "Embedded", "data": ...}` form into the
232
+ record, exactly as the module's web page does; the plaintext is never printed.
233
+ The secret comes from one of `--secret VALUE`, `--secret-env VAR`,
234
+ `--secret-file PATH` or `--secret-stdin` (prefer the last three in CI so the
235
+ value stays out of the process list). An existing name is an error unless
236
+ `--replace` is given; `--replace` without a new secret keeps the stored one.
237
+ `--gateway-base-url URL` writes the record's `gatewayBaseUrl`: what the browser
238
+ inside the agent container calls the gateway (`http://gateway:8088`, the compose
239
+ network alias, in the example lane; it must match the agent's `ALLOWED_GATEWAYS`).
240
+ The key travels only when the flag is given, so a gateway build without the field
241
+ keeps accepting the record and the gateway's own base URL applies; `--replace`
242
+ without the flag keeps the stored value. `agents list` adds a `gateway url`
243
+ column when any record carries one.
244
+ `--default` also writes `defaultBrowserAgent=NAME` into the
245
+ `test-framework-settings` resource; without it the gateway picks the only
246
+ configured agent, so a second record without `--default` leaves no default.
247
+ `--disabled` stores the record but keeps it out of runs. `agents ping NAME` (or
248
+ `add --ping`) asks the gateway to `GET <url>/health` with the stored secret and
249
+ exits 0 when the agent answered 2xx, 3 when it did not, 4 when no such agent
250
+ exists. The agent's `/health` is an unauthenticated liveness probe, so a
251
+ reachable answer proves the URL, not the secret; a wrong secret surfaces when a
252
+ browser run opens its session. HTTP 401/403 exit 4 with the usual token hint; a 422 validation answer
253
+ (for example a URL without a scheme) exits 4 with the platform's field messages.
254
+ `agents remove NAME` looks the signature up and deletes the record;
255
+ `--if-exists` makes a missing name a no-op for idempotent teardown.
256
+
257
+ ## Offline runs
258
+
259
+ ```bash
260
+ ignition-test run --offline --project-dir examples/project/DemoPlant --suite smoke
261
+ ignition-test run --offline --projects-dir examples/project --project DemoPlant --target 8.1 --collect-only
262
+ ```
263
+
264
+ `--offline` runs the project's suites in a local runtime instead of on a gateway, with
265
+ the same request flags, reports, output and exit codes. It uses `jython-ia`, IA's
266
+ interpreter, and IA's own `system.dataset`, `system.date`, `system.math` and
267
+ `system.util` JSON and logger functions. Tests marked `requires_gateway`, `requires_db`,
268
+ `requires_tag_provider`, `requires_perspective`, `requires_module` or `browser` skip. Any
269
+ other `system.*` call that needs a gateway fails with `OfflineUnavailable`, unless the test
270
+ patches it or a conftest registers a stand-in with `ignition_test.offline.stub`.
271
+
272
+ | Option | |
273
+ |---|---|
274
+ | `--project-dir DIR`, or `--projects-dir DIR --project NAME` | the project folder; parents come from the projects folder (`IGT_PROJECTS_DIR`) |
275
+ | `--target 8.1\|8.3` | the Ignition the runtime stands for (default 8.3, `IGT_TARGET`) |
276
+ | `--pylib DIR` | extra `sys.path` folder, like `user-lib/pylib` (repeatable) |
277
+ | `--java PATH` | Java 17+ (default `JAVA_HOME`, then `PATH`) |
278
+ | `--runtime DIR` | a local `./gradlew :offline:installDist` tree instead of the bundled runtime (`IGT_OFFLINE_RUNTIME`) |
279
+
280
+ The wheel bundles the runner jar and a manifest per target. The first offline run
281
+ downloads the jars that manifest lists (IA's `common` and `jython-ia`, upstream
282
+ `jython-standalone`; about 150 MB) from Maven Central and IA's public Nexus into
283
+ `~/.cache/ignition-test` (`IGT_CACHE_DIR`), and every later run checks their sha256.
284
+ A source install has no bundled runtime until `./gradlew :offline:cliRuntime` has
285
+ run. The user guide page is `docs/guide/ci/offline-runs.md`.
286
+
287
+ ## Pushing tests from a folder
288
+
289
+ ```bash
290
+ ignition-test push ./tests --project DemoPlant --projects-dir docker/data/projects
291
+ ignition-test push ./tests --project DemoPlant --projects-dir ci-stage/projects --no-scan # before boot
292
+ ```
293
+
294
+ `tests/unit/test_a.py` becomes
295
+ `DemoPlant/dev.bwdesigngroup.testing.TestFramework/test-python/unit/test_a/{code.py,resource.json}`,
296
+ `tests/conftest.py` becomes the `conftest` resource, sub-folders are plain
297
+ folders. `--dest generated` nests everything under one folder and `--prune`
298
+ removes resources there that the source no longer has. The written
299
+ `resource.json` carries `enabled`, `lastModification.actor/timestamp` and no
300
+ `lastModificationSignature` (a Designer-side hash the CLI cannot reproduce);
301
+ Designer-authored trees committed with the project do not need `push` at all.
302
+
303
+ ## Development
304
+
305
+ ```bash
306
+ python -m venv .venv && . .venv/bin/activate
307
+ pip install -e "cli[test]"
308
+ pytest cli/tests # 140+ tests against a scripted fake of the REST surface; no gateway needed
309
+ ```
310
+
311
+ The fake gateway lives in `cli/tests/conftest.py` and speaks the wire shapes the
312
+ module serves today (`{error, code}` error bodies, `EventPage` with
313
+ `lastSeq`/`terminal`, the platform's config resource CRUD, encrypt endpoint and
314
+ `{messages, fieldMessages}` 422 bodies for the `agents` commands). When the
315
+ module's REST contract changes, change the fake and the CLI in the same pull
316
+ request.
@@ -0,0 +1,291 @@
1
+ # ignition-test
2
+
3
+ Command-line client for the [Ignition Test Framework](../README.md) module. It
4
+ is what a CI job (or a developer without a Designer open) uses to provision a
5
+ gateway API token, wait for the gateway, register the Playwright browser agent,
6
+ run test suites over the module's REST API with pytest-like output, download
7
+ JUnit / lcov reports and exit with pytest-like codes.
8
+
9
+ - Python 3.9+, depends on `requests` and `click` only.
10
+ - Importable package: `ignition_test_cli` (`ignition_test` is the Jython
11
+ framework that runs inside the gateway; the two never meet).
12
+ - Talks to `/data/test-framework` on an Ignition 8.3 or 8.1 gateway running the module;
13
+ plain polling of `GET /runs/:id/events?since=` is the contract, no WebSocket
14
+ is needed.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install ignition-test # from PyPI; bundles the offline runner (run --offline)
20
+ pip install -e cli/ # from a checkout of this repository
21
+ # source install from GitHub, pinned by ref (branch, tag or commit); the repository is
22
+ # private, so pip needs credentials that can read it
23
+ pip install "git+https://github.com/bw-design-group/ignition.modules.test-framework@main#subdirectory=cli"
24
+ ```
25
+
26
+ `ignition-test` is published on PyPI from `cli-v*` tags; pin it (`ignition-test==0.1.0` or
27
+ `"ignition-test>=0.1,<1"`) in CI. Only the PyPI wheel carries the offline runtime: a source
28
+ install runs every gateway command, and `run --offline` after `./gradlew :offline:cliRuntime`.
29
+ The GitHub Action's `cli-source: auto` and the sample workflow still default to the source install
30
+ beside the action; `cli-source: pypi` and `IGNITION_TEST_CLI_FROM_PYPI: "true"` switch to PyPI.
31
+
32
+ ## Five-minute tour
33
+
34
+ ```bash
35
+ export IGT_GATEWAY=http://localhost:8088
36
+ export IGT_API_TOKEN=ci:... # the value X-Ignition-API-Token expects
37
+
38
+ ignition-test info # module/gateway versions, caller, schemaVersion
39
+ ignition-test discover --project DemoPlant # the static test tree, no code executed
40
+ ignition-test run --project DemoPlant --suite smoke
41
+ ignition-test run --project DemoPlant unit --coverage --fail-under 70 \
42
+ --junit out/junit.xml --lcov out/lcov.info --results out/results.json --run-json out/run.json
43
+ ignition-test runs # recent runs
44
+ ignition-test results r-20260926-121922-c011ee --exit-code # exit with the run's verdict
45
+ ignition-test agents add default --url http://browser-agent:7311 --secret-env AGENT_TOKEN --gateway-base-url http://gateway:8088
46
+ ```
47
+
48
+ What a run looks like:
49
+
50
+ ```
51
+ gateway http://localhost:8088 Ignition 8.3.9 module 0.1.0 (b26092604) transport=polling
52
+ run r-20260926-121918-09526e project=DemoPlant running
53
+ collected 21 items
54
+ unit/test_recipes ...................xs skipped: designer scope
55
+ wrote out/junit.xml
56
+ wrote out/lcov.info
57
+ coverage 46.3% (19/41 lines)
58
+ 19 passed, 0 failed, 0 error, 1 skipped, 1 xfailed in 2.38s exit 0
59
+ ```
60
+
61
+ `--verbose` prints one line per test (`unit/test_recipes::test_scale_total[50.0-51.0] PASSED 1 ms`).
62
+ Failures are printed at the end with the assertion explanation and the
63
+ resource-mapped traceback, then collection errors, the coverage line and the
64
+ summary line.
65
+
66
+ ## Commands
67
+
68
+ | Command | What it does | Routes |
69
+ |---|---|---|
70
+ | `info` | Module, gateway, caller (`kind actor canRead canWrite`) and schema facts; refuses an `/info` `schemaVersion` the CLI does not know | `GET /info` |
71
+ | `provision` | 8.3: writes a security level, its gateway grant and an API token into a `data/config` directory (offline; nothing is restarted). 8.1 (`--target 8.1`): prints the `IGT_BOOTSTRAP_*` environment the module seeds its token from | filesystem (8.3), none (8.1) |
72
+ | `wait-ready` | `/StatusPing` RUNNING, then `/health`, then `/info` with the token (`--module`, `--project` add checks); exit 3 on timeout with the last state, 4 at once on a refused token or for `--project` without a token (the project rung needs one) | `/StatusPing`, `/health`, `/info`, `/projects/:p/tests` |
73
+ | `run` | `POST /runs`, follow the events, download reports, exit like pytest | `POST /runs`, `/runs/:id/events`, `/runs/:id`, `/runs/:id/results`, `junit.xml`, `lcov.info` |
74
+ | `discover` | Static tree of a project (`--json`, `--ids`) | `GET /projects/:p/tests` |
75
+ | `runs` | Table of recent runs (`--project`, `--limit`, `--json`) | `GET /runs` |
76
+ | `results RUN_ID` | Summary and failures of a run (`--all`, `--json`); exits 0 and names the verdict, or exits with it under `--exit-code` | `GET /runs/:id`, `/runs/:id/results` |
77
+ | `cancel RUN_ID` | Cancel a queued or running run (already finished is not an error) | `POST /runs/:id/cancel` |
78
+ | `scan` | Rescan `data/projects` for files written after boot | `POST /scan?timeout=N` |
79
+ | `push SRC` | Write a folder of `.py` files as `test-python` resources into a mounted project, then scan | filesystem, `POST /scan` |
80
+ | `agents list` | Table of the configured browser agents (`--json`); a `gateway url` column appears when a record carries `gatewayBaseUrl`; secrets are never returned | `GET /agents` |
81
+ | `agents add NAME --url URL` | Create (or `--replace`) a `browser-agents` record; the secret is encrypted by the gateway first; `--gateway-base-url`, `--default`, `--disabled`, `--description`, `--ping` | `POST /data/api/v1/encryption/encrypt`, `/data/api/v1/resources/...` |
82
+ | `agents ping NAME` | The gateway probes `<url>/health` with the stored secret; exit 0 reachable, 3 not, 4 unknown | `POST /agents/:name/ping` |
83
+ | `agents remove NAME` | Delete the record by name and signature (`--if-exists` makes a missing name a no-op) | `/data/api/v1/resources/...` |
84
+
85
+ Every command accepts `--gateway URL` / `--token NAME:SECRET` (or `IGT_GATEWAY` /
86
+ `IGT_API_TOKEN`), `--insecure` for self-signed TLS and `--http-timeout`. `run`,
87
+ `discover` and `push` also read `IGT_PROJECT`.
88
+
89
+ ### `run` options
90
+
91
+ | Option | Maps to |
92
+ |---|---|
93
+ | `--suite S` | `RunRequest.suite`; the suite's selection and options are the base, flags win field by field |
94
+ | `PATHS...`, `--node-id`, `-k`, `-m` | `select.paths`, `select.nodeIds`, `select.keywords`, `select.markers` |
95
+ | `--coverage/--no-coverage`, `--coverage-include M`, `--fail-under N` | `options.coverage.{enabled,include,thresholdLines}`; `--fail-under` and `--coverage-include` imply coverage |
96
+ | `--timeout-per-test S`, `--timeout-run S`, `--fail-fast` | `options.timeoutPerTestMs`, `options.timeoutRunMs` (also the client wall clock: on expiry the run is cancelled and the CLI exits 2), `options.failFast` |
97
+ | `--policy development\|production` | `options.policy` (a suite that says `production` cannot be loosened by the request) |
98
+ | `--label K=V` (repeatable) | `RunRequest.label`, joined with commas |
99
+ | `--junit`, `--lcov`, `--results`, `--run-json PATH` | files downloaded after the terminal event |
100
+ | `--scan` | `POST /scan` before the run (files dropped into `data/projects` after boot) |
101
+ | `--wait/--no-wait`, `--poll-interval S` | follow the run (default) or print `{"runId": ...}` as the only stdout line (the banner goes to stderr) and exit 0 |
102
+ | `--require-browser` | exit 7 when a `browser`-marked test was skipped or errored for want of an agent, even with `--allow-degraded` |
103
+ | `--allow-degraded` | exit 0 instead of 7 for a degraded run; the summary still says `degraded` |
104
+ | `--collect-only` | `kind: collect`: collect without running; exit 1 on collection errors, else 0 |
105
+ | `--offline` | run from the project folder without a gateway (see [Offline runs](#offline-runs)) |
106
+
107
+ ### Exit codes
108
+
109
+ | Code | Meaning |
110
+ |---|---|
111
+ | 0 | passed |
112
+ | 1 | failures, errors, or any collection error (a syntax error never yields a green run with fewer tests); `aborted` with `collection-error` |
113
+ | 2 | cancelled or interrupted; `aborted` with `run-timeout`, `pytest-exit`, `library-changed`; the client `--timeout-run` expired |
114
+ | 3 | cannot reach the gateway, HTTP 5xx, `wait-ready` timeout, `queue-full`/`indexing` still refused after the retry window, `runner-unavailable`, `redundancy-inactive`; `aborted` with `run-error` or `leaked-threads` |
115
+ | 4 | usage: bad flags, 401/403, every 400/404/413/422 refusal, 409 `policy-refused`, an unknown `/info` schema |
116
+ | 5 | no tests collected |
117
+ | 6 | coverage below `--fail-under`, everything else green |
118
+ | 7 | degraded: `run.json.limitations[]` non-empty (unless `--allow-degraded`), or `--require-browser` and a browser item skipped for the agent |
119
+
120
+ Precedence when several conditions hold: 3, 4, 2, 1, 6, 7, 5, 0. The 409
121
+ `queue-full` and 503 `indexing` answers to `POST /runs` are retried every 5 s for
122
+ up to a minute before they count as 3.
123
+
124
+ ## Provisioning a token
125
+
126
+ `provision` is the one implementation of the API-token recipe (a port of
127
+ Flint's verified `provision-api-token.mjs`) as plain file writes:
128
+
129
+ ```bash
130
+ ignition-test provision --config-dir ci-stage/config --token-name ci --format env
131
+ # IGT_API_TOKEN=ci:<secret>
132
+ ```
133
+
134
+ It writes, under `<config-dir>/resources/core/ignition/`:
135
+
136
+ - `security-levels/config.json`: a custom grantable level (`--security-level`,
137
+ default `Test Framework`; `A/B` creates a nested level) appended to whatever
138
+ the file already holds;
139
+ - `security-properties/config.json`: that level added to the gateway
140
+ Access/Read/Write permissions (`AnyOf`). The module's routes check the platform
141
+ READ/WRITE permission today, so this grant is what lets the token start runs;
142
+ `--no-grant-gateway-permissions` skips it for the least-privilege design of the
143
+ security document;
144
+ - `api-token/<name>/config.json`: a `basic-token` profile holding `Authenticated`
145
+ plus the level, with `tokenHash = base64url(sha256(base64url_decode(secret)))`.
146
+ The secret is printed once; the gateway stores only its hash.
147
+
148
+ Each folder gets a `resource.json` (`scope A`, `files: [config.json]`,
149
+ `attributes.uuid`). `--settings '{"policy": "development"}'` (or `@file.json`)
150
+ additionally writes the module's `test-framework-settings` config resource.
151
+ `--token-file docker/.api-token` reuses the stored secret for the same token
152
+ name, so re-provisioning after a container recreate keeps callers working.
153
+
154
+ Two ways to get the files into a gateway:
155
+
156
+ 1. **Before first boot**: mount the directory as the container's
157
+ `data/config` (`examples/ci/docker-compose.yaml` does this; `chmod -R 0777`
158
+ the staged tree because the stock image runs as a non-root user).
159
+ 2. **Into a running container**: copy the container's existing
160
+ `security-levels` and `security-properties` folders out, run `provision`
161
+ against them (existing entries are merged, never replaced), copy the three
162
+ folders back and restart the gateway. `docker/provision-token.sh` does exactly
163
+ this for the compose stack (dev and the `integration-test.yaml` lane), calling
164
+ `provision` for the file writes; the CLI never restarts anything.
165
+
166
+ ### Ignition 8.1
167
+
168
+ 8.1 has no platform API tokens, so the module owns its tokens: it stores
169
+ `NAME` with `base64url(sha256(base64url_decode(SECRET)))` (the same hash as above)
170
+ and accepts the same `X-Ignition-API-Token: NAME:SECRET` header. The token is
171
+ seeded from the gateway container's environment at startup, so nothing is
172
+ written to disk:
173
+
174
+ ```bash
175
+ set -a # the printed NAME=value lines carry no export; compose must see IGT_BOOTSTRAP_*
176
+ eval "$(ignition-test provision --target 8.1 --format env --token-file docker/.api-token)"
177
+ set +a
178
+ # IGT_BOOTSTRAP_TOKEN=ci:<secret> -> gateway container (hashed and stored at startup)
179
+ # IGT_BOOTSTRAP_TOKEN_SCOPE=admin -> gateway container (read | run | admin)
180
+ # IGT_API_TOKEN=ci:<secret> -> this shell, for the CLI and tests/ci
181
+ docker compose -f docker/docker-compose.yaml -f docker/docker-compose.ci.yaml up -d --wait
182
+ ```
183
+
184
+ Scopes: `read` allows the GET routes; `run` adds `POST /runs`, `/scan` and
185
+ cancel; `admin` adds browser-agent and settings management (what `agents add`
186
+ needs). `--settings JSON|@file` adds `IGT_BOOTSTRAP_SETTINGS`, applied only while
187
+ the gateway has no settings row. On 8.1 the `agents` commands use the module's
188
+ own `POST /agents`, `PUT`/`DELETE /agents/:name` and `PUT /config/settings`
189
+ routes instead of the platform config API; `info` shows which platform answered.
190
+
191
+ ## Registering the browser agent
192
+
193
+ Browser tests need a `browser-agents` config record that tells the gateway where
194
+ the Playwright agent listens and the shared secret it was started with. The
195
+ gateway web page can create one by hand; in CI use the CLI after `wait-ready`:
196
+
197
+ ```bash
198
+ export AGENT_TOKEN="$(openssl rand -hex 32)" # the value the agent container gets as IGT_AGENT_SECRET
199
+ docker compose -f examples/ci/docker-compose.yaml --profile browser up -d --wait
200
+ ignition-test wait-ready --module dev.bwdesigngroup.testing.TestFramework --project DemoPlant
201
+ ignition-test agents add default --url http://browser-agent:7311 --secret-env AGENT_TOKEN --gateway-base-url http://gateway:8088 --description "CI agent" --ping
202
+ ignition-test agents list
203
+ ```
204
+
205
+ `add` sends the secret once to the gateway's `POST /data/api/v1/encryption/encrypt`
206
+ and writes only the encrypted `{"type": "Embedded", "data": ...}` form into the
207
+ record, exactly as the module's web page does; the plaintext is never printed.
208
+ The secret comes from one of `--secret VALUE`, `--secret-env VAR`,
209
+ `--secret-file PATH` or `--secret-stdin` (prefer the last three in CI so the
210
+ value stays out of the process list). An existing name is an error unless
211
+ `--replace` is given; `--replace` without a new secret keeps the stored one.
212
+ `--gateway-base-url URL` writes the record's `gatewayBaseUrl`: what the browser
213
+ inside the agent container calls the gateway (`http://gateway:8088`, the compose
214
+ network alias, in the example lane; it must match the agent's `ALLOWED_GATEWAYS`).
215
+ The key travels only when the flag is given, so a gateway build without the field
216
+ keeps accepting the record and the gateway's own base URL applies; `--replace`
217
+ without the flag keeps the stored value. `agents list` adds a `gateway url`
218
+ column when any record carries one.
219
+ `--default` also writes `defaultBrowserAgent=NAME` into the
220
+ `test-framework-settings` resource; without it the gateway picks the only
221
+ configured agent, so a second record without `--default` leaves no default.
222
+ `--disabled` stores the record but keeps it out of runs. `agents ping NAME` (or
223
+ `add --ping`) asks the gateway to `GET <url>/health` with the stored secret and
224
+ exits 0 when the agent answered 2xx, 3 when it did not, 4 when no such agent
225
+ exists. The agent's `/health` is an unauthenticated liveness probe, so a
226
+ reachable answer proves the URL, not the secret; a wrong secret surfaces when a
227
+ browser run opens its session. HTTP 401/403 exit 4 with the usual token hint; a 422 validation answer
228
+ (for example a URL without a scheme) exits 4 with the platform's field messages.
229
+ `agents remove NAME` looks the signature up and deletes the record;
230
+ `--if-exists` makes a missing name a no-op for idempotent teardown.
231
+
232
+ ## Offline runs
233
+
234
+ ```bash
235
+ ignition-test run --offline --project-dir examples/project/DemoPlant --suite smoke
236
+ ignition-test run --offline --projects-dir examples/project --project DemoPlant --target 8.1 --collect-only
237
+ ```
238
+
239
+ `--offline` runs the project's suites in a local runtime instead of on a gateway, with
240
+ the same request flags, reports, output and exit codes. It uses `jython-ia`, IA's
241
+ interpreter, and IA's own `system.dataset`, `system.date`, `system.math` and
242
+ `system.util` JSON and logger functions. Tests marked `requires_gateway`, `requires_db`,
243
+ `requires_tag_provider`, `requires_perspective`, `requires_module` or `browser` skip. Any
244
+ other `system.*` call that needs a gateway fails with `OfflineUnavailable`, unless the test
245
+ patches it or a conftest registers a stand-in with `ignition_test.offline.stub`.
246
+
247
+ | Option | |
248
+ |---|---|
249
+ | `--project-dir DIR`, or `--projects-dir DIR --project NAME` | the project folder; parents come from the projects folder (`IGT_PROJECTS_DIR`) |
250
+ | `--target 8.1\|8.3` | the Ignition the runtime stands for (default 8.3, `IGT_TARGET`) |
251
+ | `--pylib DIR` | extra `sys.path` folder, like `user-lib/pylib` (repeatable) |
252
+ | `--java PATH` | Java 17+ (default `JAVA_HOME`, then `PATH`) |
253
+ | `--runtime DIR` | a local `./gradlew :offline:installDist` tree instead of the bundled runtime (`IGT_OFFLINE_RUNTIME`) |
254
+
255
+ The wheel bundles the runner jar and a manifest per target. The first offline run
256
+ downloads the jars that manifest lists (IA's `common` and `jython-ia`, upstream
257
+ `jython-standalone`; about 150 MB) from Maven Central and IA's public Nexus into
258
+ `~/.cache/ignition-test` (`IGT_CACHE_DIR`), and every later run checks their sha256.
259
+ A source install has no bundled runtime until `./gradlew :offline:cliRuntime` has
260
+ run. The user guide page is `docs/guide/ci/offline-runs.md`.
261
+
262
+ ## Pushing tests from a folder
263
+
264
+ ```bash
265
+ ignition-test push ./tests --project DemoPlant --projects-dir docker/data/projects
266
+ ignition-test push ./tests --project DemoPlant --projects-dir ci-stage/projects --no-scan # before boot
267
+ ```
268
+
269
+ `tests/unit/test_a.py` becomes
270
+ `DemoPlant/dev.bwdesigngroup.testing.TestFramework/test-python/unit/test_a/{code.py,resource.json}`,
271
+ `tests/conftest.py` becomes the `conftest` resource, sub-folders are plain
272
+ folders. `--dest generated` nests everything under one folder and `--prune`
273
+ removes resources there that the source no longer has. The written
274
+ `resource.json` carries `enabled`, `lastModification.actor/timestamp` and no
275
+ `lastModificationSignature` (a Designer-side hash the CLI cannot reproduce);
276
+ Designer-authored trees committed with the project do not need `push` at all.
277
+
278
+ ## Development
279
+
280
+ ```bash
281
+ python -m venv .venv && . .venv/bin/activate
282
+ pip install -e "cli[test]"
283
+ pytest cli/tests # 140+ tests against a scripted fake of the REST surface; no gateway needed
284
+ ```
285
+
286
+ The fake gateway lives in `cli/tests/conftest.py` and speaks the wire shapes the
287
+ module serves today (`{error, code}` error bodies, `EventPage` with
288
+ `lastSeq`/`terminal`, the platform's config resource CRUD, encrypt endpoint and
289
+ `{messages, fieldMessages}` 422 bodies for the `agents` commands). When the
290
+ module's REST contract changes, change the fake and the CLI in the same pull
291
+ request.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ignition-test"
7
+ description = "CI client for the Ignition Test Framework module: provision a token, wait for the gateway, run suites over REST, download JUnit/lcov, exit like pytest."
8
+ readme = "README.md"
9
+ license = { text = "MIT" }
10
+ requires-python = ">=3.9"
11
+ dynamic = ["version"]
12
+ authors = [{ name = "BW Design Group" }]
13
+ keywords = ["ignition", "testing", "ci", "pytest", "scada"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Topic :: Software Development :: Testing",
22
+ ]
23
+ dependencies = [
24
+ "requests>=2.28",
25
+ "click>=8.1",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ # WebSocket streaming is an optional fast path; polling GET /runs/:id/events is the contract.
30
+ ws = ["websocket-client>=1.6"]
31
+ test = ["pytest>=7"]
32
+
33
+ [project.scripts]
34
+ ignition-test = "ignition_test_cli.main:cli"
35
+
36
+ [project.urls]
37
+ Homepage = "https://github.com/bw-design-group/ignition.modules.test-framework"
38
+ Documentation = "https://github.com/bw-design-group/ignition.modules.test-framework/tree/main/cli"
39
+
40
+ [tool.setuptools.dynamic]
41
+ version = { attr = "ignition_test_cli.__version__" }
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ # The offline runner (run --offline): ./gradlew :offline:cliRuntime copies the jar and the
47
+ # per-target runtime manifests here before the wheel is built.
48
+ [tool.setuptools.package-data]
49
+ ignition_test_cli = ["offline/*.jar", "offline/*.json"]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+ addopts = "-q"