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.
- ignition_test-0.1.0/PKG-INFO +316 -0
- ignition_test-0.1.0/README.md +291 -0
- ignition_test-0.1.0/pyproject.toml +53 -0
- ignition_test-0.1.0/setup.cfg +4 -0
- ignition_test-0.1.0/src/ignition_test.egg-info/PKG-INFO +316 -0
- ignition_test-0.1.0/src/ignition_test.egg-info/SOURCES.txt +32 -0
- ignition_test-0.1.0/src/ignition_test.egg-info/dependency_links.txt +1 -0
- ignition_test-0.1.0/src/ignition_test.egg-info/entry_points.txt +2 -0
- ignition_test-0.1.0/src/ignition_test.egg-info/requires.txt +8 -0
- ignition_test-0.1.0/src/ignition_test.egg-info/top_level.txt +1 -0
- ignition_test-0.1.0/src/ignition_test_cli/__init__.py +7 -0
- ignition_test-0.1.0/src/ignition_test_cli/client.py +511 -0
- ignition_test-0.1.0/src/ignition_test_cli/exits.py +55 -0
- ignition_test-0.1.0/src/ignition_test_cli/main.py +1462 -0
- ignition_test-0.1.0/src/ignition_test_cli/offline/ignition-test-offline.jar +0 -0
- ignition_test-0.1.0/src/ignition_test_cli/offline/runtime-8.1.json +56 -0
- ignition_test-0.1.0/src/ignition_test_cli/offline/runtime-8.3.json +65 -0
- ignition_test-0.1.0/src/ignition_test_cli/offline.py +290 -0
- ignition_test-0.1.0/src/ignition_test_cli/progress.py +283 -0
- ignition_test-0.1.0/src/ignition_test_cli/provision.py +293 -0
- ignition_test-0.1.0/src/ignition_test_cli/push.py +110 -0
- ignition_test-0.1.0/src/ignition_test_cli/verdict.py +248 -0
- ignition_test-0.1.0/tests/test_action_report.py +225 -0
- ignition_test-0.1.0/tests/test_action_steps.py +345 -0
- ignition_test-0.1.0/tests/test_agents.py +423 -0
- ignition_test-0.1.0/tests/test_commands.py +202 -0
- ignition_test-0.1.0/tests/test_info.py +102 -0
- ignition_test-0.1.0/tests/test_offline.py +342 -0
- ignition_test-0.1.0/tests/test_provision.py +160 -0
- ignition_test-0.1.0/tests/test_push.py +91 -0
- ignition_test-0.1.0/tests/test_run.py +383 -0
- ignition_test-0.1.0/tests/test_target_81.py +140 -0
- ignition_test-0.1.0/tests/test_verdict.py +216 -0
- 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"
|