@andrian.yablonskyy/thub-agent 1.1.5 → 1.1.7
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.
- package/LICENSE.md +26 -0
- package/README.md +248 -3
- package/RELEASE.md +25 -0
- package/package.json +5 -2
- package/src/cli.js +106 -4
- package/src/report.js +306 -0
- package/src/streaming.js +7 -1
- package/test/report.test.js +150 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Custom License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrian Yablonskyy
|
|
4
|
+
|
|
5
|
+
Permission is granted to use this software as-is, subject to the following
|
|
6
|
+
conditions:
|
|
7
|
+
|
|
8
|
+
1. No Modification. This software may not be modified, adapted, or used to
|
|
9
|
+
create derivative works.
|
|
10
|
+
|
|
11
|
+
2. No Redistribution. This software may not be republished, redistributed,
|
|
12
|
+
or repackaged, in whole or in part, under this or any other name.
|
|
13
|
+
|
|
14
|
+
3. Public Use Notification. Any public use of this software (including in a
|
|
15
|
+
publicly accessible product, service, or project) requires prior
|
|
16
|
+
notification to the author at andrian.yablonskyy@gmail.com.
|
|
17
|
+
|
|
18
|
+
4. Commercial Use. Any commercial use of this software requires the
|
|
19
|
+
author's prior written agreement.
|
|
20
|
+
|
|
21
|
+
THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
22
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
23
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
24
|
+
AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN
|
|
25
|
+
ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN
|
|
26
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -32,6 +32,8 @@ thub key rotate # a new key; the old one stops at once (saved
|
|
|
32
32
|
thub run [options] Submit a test job and follow its log
|
|
33
33
|
thub status <jobId> Show status; follow log if running, verdict, test counts and artifacts if done
|
|
34
34
|
thub cancel <jobId> Cancel a job
|
|
35
|
+
thub power on|off|reset <jobId> [--delay <sec>] [--port <n>] Switch the USB power of the Client running your job (owner only)
|
|
36
|
+
thub report <jobId> [--post gitlab|bitbucket|github] [--commit-status] The job's report, or a sticky MR/PR comment
|
|
35
37
|
thub resources List resources and their status
|
|
36
38
|
thub jobs [--mine] [--state <s>] List recent jobs (a cli token: only its own; a ci token: all, or its own with --mine)
|
|
37
39
|
thub config set <key> <value> Save coordinator URL / key / default user locally
|
|
@@ -58,6 +60,10 @@ Key options for `thub run`. **On the Client** names the environment variable the
|
|
|
58
60
|
| `--priority <n>` | 0–100; CI defaults to 50, CLI to 60 so a developer is not starved by a busy pipeline. | `JOB_PRIORITY` |
|
|
59
61
|
| `--meta <key=value>` | Arbitrary metadata stored on the job (repeatable) — CI job ids, git coordinates, anything else worth attaching to the run. | `JOB_META_<KEY>` (also `THUB_META_<KEY>`) |
|
|
60
62
|
| `--dry-run` | Exercise the full pipeline without the Client executing anything for real. | — (the command doesn't run) |
|
|
63
|
+
| `--id-file <path>` | Write the job id to this file as soon as it's queued, for a later `thub report`. | — (Agent only) |
|
|
64
|
+
| `--power-on-start on\|off\|reset` | HW only: switch the Client's USB power ports (uhubctl) before the DUT is prepared; a failure ends the job in `ERROR`. | `JOB_POWER_ON_START` |
|
|
65
|
+
| `--power-on-end on\|off\|reset` | HW only: switch them when the job ends, whatever its verdict. | `JOB_POWER_ON_END` |
|
|
66
|
+
| `--power-reset-delay <sec>` | How long a `reset` keeps the power off, 0–60 s; default `1`. | `JOB_POWER_RESET_DELAY` |
|
|
61
67
|
| `--wait` | Do not detach on job end; exit with the job's verdict code (used in CI). | — (Agent only) |
|
|
62
68
|
| `--detach` | Print the job id and exit immediately. | — (Agent only) |
|
|
63
69
|
| `--json` | Machine-readable output. | — (Agent only) |
|
|
@@ -77,6 +83,45 @@ Exit codes make the Agent usable as a CI step:
|
|
|
77
83
|
| `5` | `thub status <jobId> --json`: the job is still queued or running |
|
|
78
84
|
| `130` | Detached with Ctrl-C (job still running) |
|
|
79
85
|
|
|
86
|
+
### USB port power (HW)
|
|
87
|
+
|
|
88
|
+
On a Client with USB power ports (uhubctl, see the main README §8.7), a job can cold-boot its board and switch it off at the end. Its owner can also power-cycle it while it runs, without canceling the job:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
thub run --type hw --label board:nucleo-f401re \
|
|
92
|
+
--download-file "$IMAGE_URL" --command './ci/flash-and-test.sh' \
|
|
93
|
+
--power-on-start reset --power-reset-delay 2 --power-on-end off --wait
|
|
94
|
+
|
|
95
|
+
thub power reset M-00131 # every port, 1 s off (the default delay)
|
|
96
|
+
thub power reset M-00131 --port 2 --delay 3 # the Client's second port, 3 s off
|
|
97
|
+
thub power off M-00131
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`thub power` works only for your own job, while it's `PREPARING` or `RUNNING`; otherwise the Coordinator says why it refused. The job's log shows each action:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
[runner] USB power reset (port 2) requested by alice (thub power)
|
|
104
|
+
[runner] USB power reset: 1-1.4:3 (off 3 s)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Smart sockets, PDUs and other lab devices
|
|
108
|
+
|
|
109
|
+
Boards on a smart socket (Shelly, Tasmota, Kasa, Home Assistant) or a PDU outlet are switched by the job itself. Its `--command`, or a script from the repository it clones, calls the device with `curl`, `snmpset` or the device's own CLI, with credentials passed as `--env`. Each Client instance names its bench's device in its environment (e.g. `BENCH_POWER=shelly:10.0.20.11`, set with a systemd drop-in):
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
# inline: Shelly Gen2 off, 2 s, on, then the tests
|
|
113
|
+
thub run --type hw --label board:nucleo-f401re --env SHELLY_PASSWORD \
|
|
114
|
+
--command 'S="http://$BENCH_IP/rpc/Switch.Set?id=0"
|
|
115
|
+
curl -fsS --digest -u "admin:$SHELLY_PASSWORD" "$S&on=false" && sleep 2 &&
|
|
116
|
+
curl -fsS --digest -u "admin:$SHELLY_PASSWORD" "$S&on=true" && ./ci/test.sh' --wait
|
|
117
|
+
|
|
118
|
+
# from the test repository: ci/run.sh resets the bench's socket or PDU outlet, tests, and always powers it off
|
|
119
|
+
thub run --type hw --env GH_TOKEN --env POWER_PASSWORD \
|
|
120
|
+
--command 'git clone --depth 1 "https://x-access-token:$GH_TOKEN@github.com/yourorg/firmware-tests.git" src && cd src && exec ci/run.sh' --wait
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Use `exec` so a canceled job's `SIGTERM` reaches the script's trap. The full `ci/power.sh` and `ci/run.sh` examples (Shelly, Tasmota, Home Assistant, Kasa, APC PDU over SNMP) are in the main README, §8.8, and in the dashboard's Help.
|
|
124
|
+
|
|
80
125
|
## Examples
|
|
81
126
|
|
|
82
127
|
**Run a task** — download the firmware, clone the tests at a tag, flash and test (HW):
|
|
@@ -237,7 +282,74 @@ while thub status "$JOB" --json > job.json; [ $? -eq 5 ]; do sleep 10; done
|
|
|
237
282
|
jq -r '"\(.state) exit=\(.exit_code) failed=\(.summary.failed // 0)"' job.json # PASSED exit=0 failed=0
|
|
238
283
|
```
|
|
239
284
|
|
|
240
|
-
## GitHub Actions
|
|
285
|
+
## CI/CD: GitHub Actions, GitLab, Bitbucket, Jenkins
|
|
286
|
+
|
|
287
|
+
Every CI system works the same way: store a CI token (dashboard → CI tokens) as the secret `THUB_KEY`, set `THUB_URL`, and run `thub run … --wait` on a runner with Node.js 24. The step's exit code is the verdict. Set `THUB_NO_SELF_UPDATE=1` on short-lived runners.
|
|
288
|
+
|
|
289
|
+
### GitHub Actions
|
|
290
|
+
|
|
291
|
+
The TestHub action does it in one step: the job's log in the step, a Job Summary with every test, a pull-request comment (verdict, board, time, log link; updated in place on re-runs) and an optional commit status:
|
|
292
|
+
|
|
293
|
+
```yaml
|
|
294
|
+
- uses: andrianyablonskyy/thub-action@v1
|
|
295
|
+
with:
|
|
296
|
+
url: https://thub.example.com
|
|
297
|
+
key: ${{ secrets.THUB_CI_TOKEN }}
|
|
298
|
+
type: hw
|
|
299
|
+
labels: board:nucleo-f401re
|
|
300
|
+
download-files: ${{ needs.build.outputs.image_url }}
|
|
301
|
+
command: st-flash --reset write "$THUB_DOWNLOAD_1" 0x08000000 && ./ci/hw-tests.sh
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
The PR comment it leaves:
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
### ❌ TestHub HW smoke: FAILED
|
|
308
|
+
| Job | A-00042 |
|
|
309
|
+
| Result | FAILED (exit code 1) |
|
|
310
|
+
| Board | nucleo-f401re on lab-hw-01 |
|
|
311
|
+
| Tests | 42 total · 40 passed · 2 failed · 0 skipped |
|
|
312
|
+
| Duration | 3m 12s (queued 20s) |
|
|
313
|
+
| Commit | abcdef1 |
|
|
314
|
+
| Links | Job page and full log · Workflow run |
|
|
315
|
+
▸ Failed tests (2)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Use its outputs in later steps:
|
|
319
|
+
|
|
320
|
+
```yaml
|
|
321
|
+
- id: thub
|
|
322
|
+
uses: andrianyablonskyy/thub-action@v1
|
|
323
|
+
with:
|
|
324
|
+
url: https://thub.example.com
|
|
325
|
+
key: ${{ secrets.THUB_CI_TOKEN }}
|
|
326
|
+
type: hw
|
|
327
|
+
labels: board:nucleo-f401re
|
|
328
|
+
command: ./ci/hw-tests.sh
|
|
329
|
+
- if: always() && steps.thub.outputs.job-id
|
|
330
|
+
run: |
|
|
331
|
+
echo "TestHub ${{ steps.thub.outputs.job-id }} on ${{ steps.thub.outputs.client }}: ${{ steps.thub.outputs.state }}"
|
|
332
|
+
echo "${{ steps.thub.outputs.passed }}/${{ steps.thub.outputs.total }} tests passed in ${{ steps.thub.outputs.duration-sec }} s"
|
|
333
|
+
echo "Log: ${{ steps.thub.outputs.job-url }}"
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Report only, quietly, with the verdict as a commit status:
|
|
337
|
+
|
|
338
|
+
```yaml
|
|
339
|
+
- uses: andrianyablonskyy/thub-action@v1
|
|
340
|
+
with:
|
|
341
|
+
url: https://thub.example.com
|
|
342
|
+
key: ${{ secrets.THUB_CI_TOKEN }}
|
|
343
|
+
type: sw
|
|
344
|
+
command: ./ci/sw-tests.sh
|
|
345
|
+
fail: false
|
|
346
|
+
comment: on-failure
|
|
347
|
+
commit-status: true
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Complete workflows, every input, and how the PR comment and test table work are in `packages/action/README.md` and the main README §11.1.
|
|
351
|
+
|
|
352
|
+
With the Agent directly:
|
|
241
353
|
|
|
242
354
|
```yaml
|
|
243
355
|
test-sw:
|
|
@@ -255,7 +367,140 @@ test-sw:
|
|
|
255
367
|
git checkout -q FETCH_HEAD && ./ci/sw-tests.sh' --suite full --wait
|
|
256
368
|
```
|
|
257
369
|
|
|
258
|
-
|
|
370
|
+
In `--wait` mode, `SIGINT` and `SIGTERM` (how GitHub, GitLab and Jenkins stop a canceled step) make the Agent cancel the job (`POST /jobs/:id/cancel`) before exiting, so abandoned CI jobs don't hold hardware. In interactive mode, Ctrl-C only detaches.
|
|
371
|
+
|
|
372
|
+
### GitLab CI/CD
|
|
373
|
+
|
|
374
|
+
```yaml
|
|
375
|
+
test-hw:
|
|
376
|
+
image: node:24
|
|
377
|
+
variables: { THUB_URL: https://thub.example.com, THUB_NO_SELF_UPDATE: "1" } # THUB_KEY: a masked CI/CD variable
|
|
378
|
+
script:
|
|
379
|
+
- |
|
|
380
|
+
npx -y @andrian.yablonskyy/thub-agent run --type hw --label board:nucleo-f401re --download-file "$IMAGE_URL" \
|
|
381
|
+
--env CI_JOB_TOKEN --env CI_SERVER_HOST --env CI_PROJECT_PATH --env CI_COMMIT_SHA \
|
|
382
|
+
--command 'git init -q src && cd src &&
|
|
383
|
+
git fetch -q --depth 1 "https://gitlab-ci-token:$CI_JOB_TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH.git" "$CI_COMMIT_SHA" &&
|
|
384
|
+
git checkout -q FETCH_HEAD && ./ci/hw-tests.sh' \
|
|
385
|
+
--wait --meta pipelineUrl="$CI_PIPELINE_URL"
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### Bitbucket Pipelines
|
|
389
|
+
|
|
390
|
+
```yaml
|
|
391
|
+
- step:
|
|
392
|
+
name: HW tests
|
|
393
|
+
image: node:24
|
|
394
|
+
script: # repository variables: THUB_URL, THUB_KEY (secured), TESTS_TOKEN (a repository access token)
|
|
395
|
+
- >-
|
|
396
|
+
npx -y @andrian.yablonskyy/thub-agent run --type hw --download-file "$IMAGE_URL"
|
|
397
|
+
--env TESTS_TOKEN --env BITBUCKET_REPO_FULL_NAME --env BITBUCKET_COMMIT
|
|
398
|
+
--command 'git init -q src && cd src &&
|
|
399
|
+
git fetch -q --depth 1 "https://x-token-auth:$TESTS_TOKEN@bitbucket.org/$BITBUCKET_REPO_FULL_NAME.git" "$BITBUCKET_COMMIT" &&
|
|
400
|
+
git checkout -q FETCH_HEAD && ./ci/hw-tests.sh' --wait
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Jenkins
|
|
404
|
+
|
|
405
|
+
```groovy
|
|
406
|
+
stage('HW tests') {
|
|
407
|
+
agent { docker { image 'node:24' } }
|
|
408
|
+
environment {
|
|
409
|
+
THUB_URL = 'https://thub.example.com'
|
|
410
|
+
THUB_KEY = credentials('thub-ci-token') // Secret text
|
|
411
|
+
NPM_CONFIG_CACHE = "${env.WORKSPACE}/.npm"
|
|
412
|
+
}
|
|
413
|
+
steps {
|
|
414
|
+
sh '''
|
|
415
|
+
npx -y @andrian.yablonskyy/thub-agent run --type hw --download-file "$IMAGE_URL" \
|
|
416
|
+
--command './ci/hw-tests.sh' --wait --meta buildUrl="$BUILD_URL"
|
|
417
|
+
'''
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Full pipelines — build, test, and bringing the JUnit report back into GitLab, Bitbucket or Jenkins — are in the main README, §11, and in the dashboard's Help.
|
|
423
|
+
|
|
424
|
+
### Merge/pull-request comments: GitLab, Bitbucket, Jenkins
|
|
425
|
+
|
|
426
|
+
`thub report <jobId>` renders the same report the GitHub Action leaves: verdict, board, Client, duration, tests, failed tests, and links to the job's log and the pipeline. `--post gitlab|bitbucket|github` keeps it as one comment on the merge/pull request, updated by every later run, and `--commit-status` sets the commit's status, linking to the job. Save the id with `thub run --id-file`, and report in the step that runs whatever the result.
|
|
427
|
+
|
|
428
|
+
GitLab, with a project access token (`api` scope, Reporter) in the masked variable `GITLAB_TOKEN`:
|
|
429
|
+
|
|
430
|
+
```yaml
|
|
431
|
+
# .gitlab-ci.yml
|
|
432
|
+
test-hw:
|
|
433
|
+
stage: test
|
|
434
|
+
image: node:24
|
|
435
|
+
rules:
|
|
436
|
+
- if: $CI_PIPELINE_SOURCE == "merge_request_event" # CI_MERGE_REQUEST_IID: the MR to comment on
|
|
437
|
+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # status only (a branch pipeline's open MR, if any, is found by commit)
|
|
438
|
+
variables:
|
|
439
|
+
THUB_URL: https://thub.example.com # THUB_KEY, GITLAB_TOKEN: masked CI/CD variables
|
|
440
|
+
THUB_NO_SELF_UPDATE: "1"
|
|
441
|
+
before_script:
|
|
442
|
+
- npm i -g @andrian.yablonskyy/thub-agent
|
|
443
|
+
script:
|
|
444
|
+
- thub run --type hw --label board:nucleo-f401re --download-file "$IMAGE_URL"
|
|
445
|
+
--command './ci/hw-tests.sh' --suite smoke --timeout 30m --wait --id-file .thub-job
|
|
446
|
+
--meta pipelineUrl="$CI_PIPELINE_URL"
|
|
447
|
+
after_script: # runs after failures and cancels too
|
|
448
|
+
- '[ -s .thub-job ] && thub report "$(cat .thub-job)" --post gitlab --commit-status --title "HW smoke"'
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Bitbucket Cloud, with a repository access token (Pull requests: Write) in the secured variable `BITBUCKET_TOKEN`:
|
|
452
|
+
|
|
453
|
+
```yaml
|
|
454
|
+
# bitbucket-pipelines.yml
|
|
455
|
+
image: node:24
|
|
456
|
+
pipelines:
|
|
457
|
+
pull-requests:
|
|
458
|
+
'**':
|
|
459
|
+
- step:
|
|
460
|
+
name: HW tests
|
|
461
|
+
max-time: 60
|
|
462
|
+
script: # THUB_URL, THUB_KEY, BITBUCKET_TOKEN: repository variables
|
|
463
|
+
- export THUB_NO_SELF_UPDATE=1
|
|
464
|
+
- npm i -g @andrian.yablonskyy/thub-agent
|
|
465
|
+
- thub run --type hw --label board:nucleo-f401re --command './ci/hw-tests.sh' --suite smoke --timeout 30m --wait --id-file .thub-job
|
|
466
|
+
after-script: # runs whether the step passed or failed
|
|
467
|
+
- '[ -s .thub-job ] && thub report "$(cat .thub-job)" --post bitbucket --commit-status --title "HW smoke"'
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Jenkins, Bitbucket Data Center, every `thub report` option, and which CI variables each code host reads: main README, §11.5.
|
|
471
|
+
|
|
472
|
+
## Test frameworks and the PR comment
|
|
473
|
+
|
|
474
|
+
The verdict is the command's exit code. Test counts come from JUnit XML the command writes to `results/` (or `artifacts/`, either one folder down too):
|
|
475
|
+
|
|
476
|
+
```bash
|
|
477
|
+
--command '… ./build/uart_tests --gtest_output=xml:results/ …' # GoogleTest
|
|
478
|
+
--command '… ctest --test-dir build --output-junit "$PWD/results/ctest.xml" …' # CTest (absolute path)
|
|
479
|
+
--command '… pytest tests/ --junitxml=results/pytest.xml -o junit_family=xunit2 …' # pytest
|
|
480
|
+
--command '… mvn -B test; rc=$?; mkdir -p results && cp target/surefire-reports/TEST-*.xml results/; exit $rc' # JUnit / Maven
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
To have **every test case** in the PR/MR comment, have the command publish the XML and list it as an artifact (`ci/publish-junit.sh`). The GitHub Action or `thub report --artifact-header …` then fetches it. Full examples, including hardware-in-the-loop with pytest: main README §7.6.
|
|
484
|
+
|
|
485
|
+
## Artifact storage
|
|
486
|
+
|
|
487
|
+
`--download-file` is a plain, anonymous GET. For private storage, either pass a short-lived signed URL (`aws s3 presign …`), or fetch the file in `--command` with credentials passed as `--env`. Outputs are uploaded by the command and listed in `$THUB_ARTIFACTS_FILE`:
|
|
488
|
+
|
|
489
|
+
```bash
|
|
490
|
+
thub run --type sw --env ART_TOKEN \
|
|
491
|
+
--command './ci/test.sh; rc=$?
|
|
492
|
+
curl -fsS -H "Authorization: Bearer $ART_TOKEN" -T results/junit.xml "https://artifactory.example.com/qa/$THUB_JOB_ID/junit.xml"
|
|
493
|
+
exit $rc' --wait
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
The main README, §7.5, and the dashboard's Help have examples for Artifactory, AWS S3, Google Drive (rclone), FTP/FTPS/SFTP, and custom HTTP authentication (bearer, API key, basic, `.netrc`, mutual TLS, OAuth2).
|
|
497
|
+
|
|
498
|
+
More authentication how-tos, in the main README and the dashboard's Help:
|
|
499
|
+
|
|
500
|
+
- **Downloads over HTTPS:** a private CA (`NODE_EXTRA_CA_CERTS` on the Client), and client certificates (`curl --cert` in `--command`; `--download-file` can't present one). See §7.5.
|
|
501
|
+
- **Artifactory:** scoped, short-lived access tokens minted per CI run, the JFrog CLI, and signed URLs. See §7.5.
|
|
502
|
+
- **git over SSH:** a deploy key plus a pinned `known_hosts`, both passed with `--env`. See §7.2.
|
|
503
|
+
- **`docker login`:** your own registry, Artifactory, ghcr, GitLab, Docker Hub, ECR, Google Artifact Registry and ACR. See §7.2.
|
|
259
504
|
|
|
260
505
|
## Development
|
|
261
506
|
|
|
@@ -266,4 +511,4 @@ npm run lint
|
|
|
266
511
|
|
|
267
512
|
## License
|
|
268
513
|
|
|
269
|
-
|
|
514
|
+
See [LICENSE.md](./LICENSE.md).
|
package/RELEASE.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Release notes — @andrian.yablonskyy/thub-agent
|
|
2
|
+
|
|
3
|
+
What changed in each release, newest first. `## Unreleased` collects the changes since the version on npm; `bin/publish` turns that heading into the version and date it releases.
|
|
4
|
+
|
|
5
|
+
## 1.1.7 — 2026-10-08
|
|
6
|
+
|
|
7
|
+
Changes since 1.1.6.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`thub report <jobId>`**: a job's report as Markdown — verdict, board and Client, duration, test counts, the failed tests, links to the job's log and the CI run — or, with `--post gitlab|bitbucket|github`, **one sticky comment on the merge/pull request** of the pipeline, updated in place by every later run of the same CI job. `--commit-status` also sets a GitLab commit status, Bitbucket build status or GitHub status that links to the TestHub job. The project, merge/pull request, commit and pipeline come from the CI's own variables; it needs only the code host's token (`GITLAB_TOKEN`, `BITBUCKET_TOKEN`, `GITHUB_TOKEN`). Every test case is listed when the job published its JUnit XML as an artifact (fetched with `--artifact-header`). Also `--title`, `--key`, `--pr`, `--commit`, `--run-url`, `--summary`, `--output`, `--exit-code`.
|
|
12
|
+
- **`thub run --id-file <path>`**: writes the job id to a file as soon as the job is queued, for a later `thub report` in the step that runs whatever the result.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- With `--wait`, **`SIGTERM` cancels the job** like `SIGINT` (Ctrl-C) always did. GitLab CI, Jenkins and most CI runners stop a canceled step with `SIGTERM`, which used to end the Agent and leave the job holding the bench until its timeout. Without `--wait`, `SIGTERM` still just ends the Agent.
|
|
17
|
+
|
|
18
|
+
### Requires
|
|
19
|
+
|
|
20
|
+
- `@andrian.yablonskyy/thub-common` with `report-markdown` (the release after 1.1.5).
|
|
21
|
+
|
|
22
|
+
### Docs
|
|
23
|
+
|
|
24
|
+
- README: CI/CD for GitHub Actions (with the TestHub action), GitLab, Bitbucket and Jenkins; merge/pull-request comments; test frameworks (GoogleTest, CTest, pytest, JUnit) and their results in the PR comment; artifact storage and authentication (Artifactory, S3, Google Drive, SFTP, certificates); the license is now `LICENSE.md` (`"license"` and `"author"` set in `package.json`).
|
|
25
|
+
- These release notes (`RELEASE.md`) are now part of the package.
|
package/package.json
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrian.yablonskyy/thub-agent",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"description": "TestHub Agent CLI — the single entry point CI/CD and developers use to submit and follow test jobs",
|
|
5
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
|
+
"author": "Andrian Yablonskyy <andrian.yablonskyy@gmail.com>",
|
|
5
7
|
"bin": {
|
|
6
8
|
"thub": "./src/cli.js"
|
|
7
9
|
},
|
|
8
10
|
"main": "src/cli.js",
|
|
9
11
|
"scripts": {
|
|
12
|
+
"test": "node --test test/*.test.js",
|
|
10
13
|
"postinstall": "node scripts/install-default-config.js",
|
|
11
14
|
"lint": "eslint .",
|
|
12
15
|
"lint:fix": "eslint . --fix"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@andrian.yablonskyy/thub-common": "^1.1.
|
|
18
|
+
"@andrian.yablonskyy/thub-common": "^1.1.6",
|
|
16
19
|
"commander": "^13.1.0"
|
|
17
20
|
},
|
|
18
21
|
"devDependencies": {
|
package/src/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file packages/agent/src/cli.js
|
|
5
|
-
* @description thub CLI entry point: run/status/cancel/resources/jobs/config commands (README §7)
|
|
5
|
+
* @description thub CLI entry point: run/status/cancel/power/report/resources/jobs/config commands (README §7)
|
|
6
6
|
*
|
|
7
7
|
* @author Andrian Yablonskyy
|
|
8
8
|
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
const { Command, Option } = require('commander'),
|
|
19
19
|
{
|
|
20
20
|
ApiClient, EXIT_CODES, ACTIVE_JOB_STATES, exitCodeForJobState, PACKAGES, fetchLatestVersion, isNewer, isValidVersion, formatDateTime,
|
|
21
|
-
parseEnvList
|
|
21
|
+
parseEnvList, POWER_ACTIONS, DEFAULT_RESET_DELAY_SEC, MAX_RESET_DELAY_SEC, powerRequestErrors
|
|
22
22
|
} = require('@andrian.yablonskyy/thub-common'),
|
|
23
23
|
{ resolveConnection, writeConfigFile, readConfigFile, CONFIG_PATH } = require('./config'),
|
|
24
24
|
{ parseDurationSec } = require('./duration'),
|
|
25
25
|
{ followJob } = require('./streaming'),
|
|
26
|
-
{ applyRequestedUpdate, installAgent, version } = require('./self-update')
|
|
26
|
+
{ applyRequestedUpdate, installAgent, version } = require('./self-update'),
|
|
27
|
+
{ report } = require('./report'),
|
|
28
|
+
fs = require('node:fs');
|
|
27
29
|
|
|
28
30
|
const program = new Command();
|
|
29
31
|
program
|
|
@@ -81,6 +83,34 @@ function taskFromOptions(opts){
|
|
|
81
83
|
return task;
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
// --power-on-start / --power-on-end / --power-reset-delay -> the job spec's
|
|
87
|
+
// `power` (README §8.7), checked here so a typo fails before it's sent.
|
|
88
|
+
function powerFromOptions(opts){
|
|
89
|
+
const power = {};
|
|
90
|
+
for (const [option, key]of [['powerOnStart', 'onStart'], ['powerOnEnd', 'onEnd']]){
|
|
91
|
+
if (opts[option] !== undefined){
|
|
92
|
+
if (!POWER_ACTIONS.includes(opts[option])){
|
|
93
|
+
throw usageError(`--${option.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)} ${opts[option]}: must be one of ${POWER_ACTIONS.join(', ')}`);
|
|
94
|
+
}
|
|
95
|
+
power[key] = opts[option];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (opts.powerResetDelay !== undefined){
|
|
99
|
+
const errors = powerRequestErrors({ action: 'reset', delaySec: opts.powerResetDelay });
|
|
100
|
+
if (errors.length){
|
|
101
|
+
throw usageError(`--power-reset-delay: ${errors.join('; ')}`);
|
|
102
|
+
}
|
|
103
|
+
if (power.onStart !== 'reset' && power.onEnd !== 'reset'){
|
|
104
|
+
throw usageError('--power-reset-delay needs --power-on-start reset or --power-on-end reset');
|
|
105
|
+
}
|
|
106
|
+
power.resetDelaySec = opts.powerResetDelay;
|
|
107
|
+
}
|
|
108
|
+
if (Object.keys(power).length && opts.type !== 'hw'){
|
|
109
|
+
throw usageError('--power-on-start / --power-on-end are for HW jobs only (--type hw)');
|
|
110
|
+
}
|
|
111
|
+
return Object.keys(power).length ? power : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
84
114
|
function usageError(message){
|
|
85
115
|
return Object.assign(new Error(message), { status: EXIT_CODES.USAGE });
|
|
86
116
|
}
|
|
@@ -170,9 +200,14 @@ program
|
|
|
170
200
|
'without the Client flashing/running anything for real',
|
|
171
201
|
false
|
|
172
202
|
)
|
|
203
|
+
.option('--power-on-start <action>', `USB power on the Client (uhubctl) before the DUT is prepared: ${POWER_ACTIONS.join(' | ')} (HW only)`)
|
|
204
|
+
.option('--power-on-end <action>', `USB power on the Client when the job ends, whatever its verdict: ${POWER_ACTIONS.join(' | ')} (HW only)`)
|
|
205
|
+
.option('--id-file <path>', 'Write the job id to this file as soon as it\'s queued (for a later `thub report`, §11.5)')
|
|
206
|
+
.option('--power-reset-delay <sec>', `Seconds a reset keeps the power off (default ${DEFAULT_RESET_DELAY_SEC}, max ${MAX_RESET_DELAY_SEC})`, (v) => Number(v))
|
|
173
207
|
.action(async (opts) => {
|
|
174
208
|
try {
|
|
175
209
|
const task = taskFromOptions(opts),
|
|
210
|
+
power = powerFromOptions(opts),
|
|
176
211
|
c = client(),
|
|
177
212
|
labels = requiredLabels(opts),
|
|
178
213
|
meta = Object.fromEntries(opts.meta.map((kv) => kv.split(/=(.*)/s).slice(0, 2))),
|
|
@@ -184,10 +219,14 @@ program
|
|
|
184
219
|
timeoutSec: parseDurationSec(opts.timeout),
|
|
185
220
|
...(opts.priority !== undefined ? { priority: opts.priority } : {}),
|
|
186
221
|
...(Object.keys(meta).length ? { meta } : {}),
|
|
187
|
-
...(opts.dryRun ? { dryRun: true } : {})
|
|
222
|
+
...(opts.dryRun ? { dryRun: true } : {}),
|
|
223
|
+
...(power ? { power } : {})
|
|
188
224
|
},
|
|
189
225
|
|
|
190
226
|
result = await c.post('/jobs', spec);
|
|
227
|
+
if (opts.idFile){
|
|
228
|
+
fs.writeFileSync(opts.idFile, `${result.jobId}\n`);
|
|
229
|
+
}
|
|
191
230
|
if (opts.json){
|
|
192
231
|
console.log(JSON.stringify(result));
|
|
193
232
|
}
|
|
@@ -267,6 +306,69 @@ program
|
|
|
267
306
|
}
|
|
268
307
|
});
|
|
269
308
|
|
|
309
|
+
// README §8.7: the owner of a running job switches its Client's USB power
|
|
310
|
+
// right away — no need to wait for the job to end or to cancel it.
|
|
311
|
+
program
|
|
312
|
+
.command('power')
|
|
313
|
+
.description('Switch the USB power of the Client running your job (uhubctl): on, off or reset — while it runs')
|
|
314
|
+
.argument('<action>', POWER_ACTIONS.join(' | '))
|
|
315
|
+
.argument('<jobId>')
|
|
316
|
+
.option('--delay <sec>', `reset: seconds the power stays off (default ${DEFAULT_RESET_DELAY_SEC}, max ${MAX_RESET_DELAY_SEC})`, (v) => Number(v))
|
|
317
|
+
.option('--port <n>', 'Only this port of the Client: its number in the Client\'s hw-devices.usbPower.ports (default: all of them)', (v) => Number(v))
|
|
318
|
+
.option('--json', 'Machine-readable output', false)
|
|
319
|
+
.action(async (action, jobId, opts) => {
|
|
320
|
+
try {
|
|
321
|
+
const errors = powerRequestErrors({ action, delaySec: opts.delay, port: opts.port });
|
|
322
|
+
if (errors.length){
|
|
323
|
+
throw usageError(errors.join('; ').replace('the reset delay (delaySec)', '--delay').replace(/^port/, '--port'));
|
|
324
|
+
}
|
|
325
|
+
const res = await client().post(`/jobs/${jobId}/power`, {
|
|
326
|
+
action, ...(opts.delay !== undefined ? { delaySec: opts.delay } : {}), ...(opts.port !== undefined ? { port: opts.port } : {})
|
|
327
|
+
});
|
|
328
|
+
if (opts.json){
|
|
329
|
+
return console.log(JSON.stringify(res));
|
|
330
|
+
}
|
|
331
|
+
const what = `USB power ${res.action}${res.port ? ` (port ${res.port})` : ''}${res.action === 'reset' ? `, ${res.delaySec} s off` : ''}`;
|
|
332
|
+
console.log(`${what} sent to ${res.resource.name} for job ${res.jobId} — the job's log shows when it's done.`);
|
|
333
|
+
}
|
|
334
|
+
catch (err){
|
|
335
|
+
fail(err);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// §11.5: the job as Markdown, or posted where its code is reviewed — the
|
|
340
|
+
// same report the GitHub Action leaves, from any CI.
|
|
341
|
+
program
|
|
342
|
+
.command('report')
|
|
343
|
+
.description('A job\'s report as Markdown, or a sticky comment on a GitLab merge request / Bitbucket or GitHub pull request')
|
|
344
|
+
.argument('<jobId>', 'The job, e.g. A-00042 (thub run --id-file saves it)')
|
|
345
|
+
.option('--post <platform>', 'Comment on the merge/pull request of this pipeline: gitlab, bitbucket or github (from the CI\'s own variables)')
|
|
346
|
+
.option('--commit-status', 'With --post: also set a commit status (GitLab) / build status (Bitbucket) / status (GitHub) linking to the job', false)
|
|
347
|
+
.option('--title <text>', 'A title in the heading, e.g. "HW smoke (nucleo-f401re)"')
|
|
348
|
+
.option('--key <text>', 'What identifies the comment, so the next run updates it (default: the CI job name and --title)')
|
|
349
|
+
.option('--pr <number>', 'The merge/pull request, when the pipeline doesn\'t say (a branch pipeline)')
|
|
350
|
+
.option('--commit <sha>', 'The commit to report on and set the status of (default: the pipeline\'s)')
|
|
351
|
+
.option('--run-url <url>', 'A link back to the CI run (default: the pipeline\'s)')
|
|
352
|
+
.option('--run-label <text>', 'Its label (default: Pipeline / Workflow run)')
|
|
353
|
+
.option('--flavor <platform>', 'Without --post: Markdown for github (default), gitlab or bitbucket (no HTML)')
|
|
354
|
+
.option('--summary', 'The full report (every test, artifacts) instead of the comment', false)
|
|
355
|
+
.option('--artifact-header <header>', 'A "Name: value" header for fetching the JUnit XML the job listed as artifacts (repeatable)', collectRepeatable, [])
|
|
356
|
+
.option('--no-junit', 'Don\'t fetch the JUnit XML artifacts (counts only)')
|
|
357
|
+
.option('--output <file>', 'Write the Markdown to this file (default: print it, unless --post)')
|
|
358
|
+
.option('--exit-code', 'Exit with the job\'s verdict code (0 PASSED, 1 FAILED, …) instead of 0', false)
|
|
359
|
+
.action(async (jobId, opts) => {
|
|
360
|
+
try {
|
|
361
|
+
const { url } = resolveConnection(program.opts()),
|
|
362
|
+
{ job } = await report(client(), url, jobId, opts);
|
|
363
|
+
if (opts.exitCode){
|
|
364
|
+
process.exit(ACTIVE_JOB_STATES.has(job.state) ? EXIT_CODES.ACTIVE : exitCodeForJobState(job.state));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (err){
|
|
368
|
+
fail(err);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
270
372
|
program
|
|
271
373
|
.command('resources')
|
|
272
374
|
.description('List resources and their status')
|
package/src/report.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/agent/src/report.js
|
|
3
|
+
* @description thub report: a finished job as Markdown, posted as a sticky comment on a GitLab merge request, a
|
|
4
|
+
* Bitbucket pull request or a GitHub pull request, with an optional commit/build status (README §11.5)
|
|
5
|
+
*
|
|
6
|
+
* @author Andrian Yablonskyy
|
|
7
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
8
|
+
*
|
|
9
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
10
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
11
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
12
|
+
* from AdSystem.PRO.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs'),
|
|
18
|
+
crypto = require('node:crypto'),
|
|
19
|
+
{ parseJUnit, renderComment, renderSummary, commentMarker, statusFor, countsFromCases } = require('@andrian.yablonskyy/thub-common');
|
|
20
|
+
|
|
21
|
+
const JUNIT_MAX_FILES = 10,
|
|
22
|
+
JUNIT_MAX_BYTES = 5 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
function fail(message, status = 1){
|
|
25
|
+
return Object.assign(new Error(message), { status });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function http(method, url, { headers = {}, body } = {}){
|
|
29
|
+
const res = await fetch(url, {
|
|
30
|
+
method,
|
|
31
|
+
headers: { Accept: 'application/json', 'User-Agent': 'thub-agent', ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers },
|
|
32
|
+
body: body ? JSON.stringify(body) : undefined
|
|
33
|
+
}),
|
|
34
|
+
data = res.status === 204 ? null : await res.json().catch(() => null);
|
|
35
|
+
if (!res.ok){
|
|
36
|
+
const why = data?.message || data?.error?.message || data?.error || '';
|
|
37
|
+
throw fail(`${method} ${url.replace(/\?.*/, '')}: HTTP ${res.status}${why ? ` ${typeof why === 'string' ? why : JSON.stringify(why)}` : ''}`);
|
|
38
|
+
}
|
|
39
|
+
return data;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- Where the report goes ------------------------------------------------
|
|
43
|
+
// Each platform: what it reads from its own CI's environment, how it finds
|
|
44
|
+
// and updates its comment, and how it sets a commit status.
|
|
45
|
+
|
|
46
|
+
const PLATFORMS = {
|
|
47
|
+
// GitLab merge requests. GITLAB_TOKEN: a project (or group) access token
|
|
48
|
+
// with the api scope, Reporter or above; CI_JOB_TOKEN can't write notes.
|
|
49
|
+
gitlab: {
|
|
50
|
+
detect(env, opts){
|
|
51
|
+
const api = (env.CI_API_V4_URL || '').replace(/\/+$/, ''),
|
|
52
|
+
project = env.CI_PROJECT_ID,
|
|
53
|
+
token = env.GITLAB_TOKEN;
|
|
54
|
+
if (!api || !project){
|
|
55
|
+
throw fail('--post gitlab: CI_API_V4_URL and CI_PROJECT_ID are not set — run it in a GitLab CI job', 4);
|
|
56
|
+
}
|
|
57
|
+
if (!token){
|
|
58
|
+
throw fail('--post gitlab: set GITLAB_TOKEN (a project access token with the api scope) as a masked CI/CD variable', 4);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
api,
|
|
62
|
+
base: `${api}/projects/${encodeURIComponent(project)}`,
|
|
63
|
+
headers: { 'PRIVATE-TOKEN': token },
|
|
64
|
+
target: opts.pr || env.CI_MERGE_REQUEST_IID || null,
|
|
65
|
+
sha: opts.commit || env.CI_MERGE_REQUEST_SOURCE_BRANCH_SHA || env.CI_COMMIT_SHA || null,
|
|
66
|
+
runUrl: env.CI_PIPELINE_URL || null,
|
|
67
|
+
runLabel: 'Pipeline',
|
|
68
|
+
key: env.CI_JOB_NAME || null
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
// A branch pipeline has no CI_MERGE_REQUEST_IID: the open MR for the commit.
|
|
72
|
+
async findTarget(p){
|
|
73
|
+
if (p.target || !p.sha){
|
|
74
|
+
return p.target;
|
|
75
|
+
}
|
|
76
|
+
const mrs = await http('GET', `${p.base}/repository/commits/${p.sha}/merge_requests`, { headers: p.headers });
|
|
77
|
+
return mrs.find((mr) => mr.state === 'opened')?.iid || null;
|
|
78
|
+
},
|
|
79
|
+
async upsert(p, mr, marker, body){
|
|
80
|
+
const notes = `${p.base}/merge_requests/${mr}/notes`;
|
|
81
|
+
for (let page = 1; page <= 20; page++){
|
|
82
|
+
const list = await http('GET', `${notes}?per_page=100&page=${page}&sort=asc`, { headers: p.headers }),
|
|
83
|
+
own = list.find((n) => n.body?.startsWith(marker));
|
|
84
|
+
if (own){
|
|
85
|
+
await http('PUT', `${notes}/${own.id}`, { headers: p.headers, body: { body } });
|
|
86
|
+
return 'updated';
|
|
87
|
+
}
|
|
88
|
+
if (list.length < 100){
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
await http('POST', notes, { headers: p.headers, body: { body } });
|
|
93
|
+
return 'created';
|
|
94
|
+
},
|
|
95
|
+
status(p, { state, context, description, url }){
|
|
96
|
+
return http('POST', `${p.base}/statuses/${p.sha}`, {
|
|
97
|
+
headers: p.headers, body: { state, name: context, description: description.slice(0, 255), target_url: url }
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
targetName: (mr) => `merge request !${mr}`
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
// Bitbucket Cloud pull requests. BITBUCKET_TOKEN: a repository access token
|
|
104
|
+
// with Pull requests: Write (and Repositories: Write for the build status);
|
|
105
|
+
// or BITBUCKET_AUTH=user:app-password / email:API-token, as basic auth.
|
|
106
|
+
bitbucket: {
|
|
107
|
+
detect(env, opts){
|
|
108
|
+
const ws = env.BITBUCKET_WORKSPACE,
|
|
109
|
+
repo = env.BITBUCKET_REPO_SLUG,
|
|
110
|
+
api = (env.BITBUCKET_API_URL || 'https://api.bitbucket.org/2.0').replace(/\/+$/, '');
|
|
111
|
+
if (!ws || !repo){
|
|
112
|
+
throw fail('--post bitbucket: BITBUCKET_WORKSPACE and BITBUCKET_REPO_SLUG are not set — run it in a Bitbucket Pipelines step', 4);
|
|
113
|
+
}
|
|
114
|
+
const auth = env.BITBUCKET_TOKEN
|
|
115
|
+
? `Bearer ${env.BITBUCKET_TOKEN}`
|
|
116
|
+
: env.BITBUCKET_AUTH ? `Basic ${Buffer.from(env.BITBUCKET_AUTH).toString('base64')}` : null;
|
|
117
|
+
if (!auth){
|
|
118
|
+
throw fail('--post bitbucket: set BITBUCKET_TOKEN (a repository access token) or BITBUCKET_AUTH=user:password as a secured variable', 4);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
base: `${api}/repositories/${ws}/${repo}`,
|
|
122
|
+
headers: { Authorization: auth },
|
|
123
|
+
target: opts.pr || env.BITBUCKET_PR_ID || null,
|
|
124
|
+
sha: opts.commit || env.BITBUCKET_COMMIT || null,
|
|
125
|
+
runUrl: env.BITBUCKET_GIT_HTTP_ORIGIN && env.BITBUCKET_BUILD_NUMBER
|
|
126
|
+
? `${env.BITBUCKET_GIT_HTTP_ORIGIN}/pipelines/results/${env.BITBUCKET_BUILD_NUMBER}` : null,
|
|
127
|
+
runLabel: 'Pipeline',
|
|
128
|
+
key: null
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
// Only pull-request pipelines (pull-requests:) carry BITBUCKET_PR_ID.
|
|
132
|
+
findTarget: async (p) => p.target,
|
|
133
|
+
async upsert(p, pr, marker, body){
|
|
134
|
+
let url = `${p.base}/pullrequests/${pr}/comments?pagelen=100`;
|
|
135
|
+
for (let page = 0; url && page < 20; page++){
|
|
136
|
+
const list = await http('GET', url, { headers: p.headers }),
|
|
137
|
+
own = (list.values || []).find((c) => !c.deleted && c.content?.raw?.startsWith(marker));
|
|
138
|
+
if (own){
|
|
139
|
+
await http('PUT', `${p.base}/pullrequests/${pr}/comments/${own.id}`, { headers: p.headers, body: { content: { raw: body } } });
|
|
140
|
+
return 'updated';
|
|
141
|
+
}
|
|
142
|
+
url = list.next;
|
|
143
|
+
}
|
|
144
|
+
await http('POST', `${p.base}/pullrequests/${pr}/comments`, { headers: p.headers, body: { content: { raw: body } } });
|
|
145
|
+
return 'created';
|
|
146
|
+
},
|
|
147
|
+
// A build status key is at most 40 characters: a hash of ours.
|
|
148
|
+
status(p, { state, context, description, url }){
|
|
149
|
+
return http('POST', `${p.base}/commit/${p.sha}/statuses/build`, {
|
|
150
|
+
headers: p.headers,
|
|
151
|
+
body: {
|
|
152
|
+
key: `thub-${crypto.createHash('sha1').update(context).digest('hex').slice(0, 34)}`,
|
|
153
|
+
state, name: context, description: description.slice(0, 255), url
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
targetName: (pr) => `pull request #${pr}`
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
// GitHub pull requests from any CI (Jenkins, …); in GitHub Actions the
|
|
161
|
+
// TestHub action does this too. GITHUB_TOKEN: pull-requests (and statuses) write.
|
|
162
|
+
github: {
|
|
163
|
+
detect(env, opts){
|
|
164
|
+
const repo = env.GITHUB_REPOSITORY,
|
|
165
|
+
token = env.GITHUB_TOKEN;
|
|
166
|
+
if (!repo){
|
|
167
|
+
throw fail('--post github: set GITHUB_REPOSITORY (owner/name)', 4);
|
|
168
|
+
}
|
|
169
|
+
if (!token){
|
|
170
|
+
throw fail('--post github: set GITHUB_TOKEN (pull-requests: write, and statuses: write for --commit-status)', 4);
|
|
171
|
+
}
|
|
172
|
+
let event = {};
|
|
173
|
+
try {
|
|
174
|
+
event = env.GITHUB_EVENT_PATH ? JSON.parse(fs.readFileSync(env.GITHUB_EVENT_PATH, 'utf8')) : {};
|
|
175
|
+
}
|
|
176
|
+
catch { /* not in Actions */ }
|
|
177
|
+
const server = env.GITHUB_SERVER_URL || 'https://github.com';
|
|
178
|
+
return {
|
|
179
|
+
base: `${(env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '')}/repos/${repo}`,
|
|
180
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' },
|
|
181
|
+
target: opts.pr || event.pull_request?.number || null,
|
|
182
|
+
sha: opts.commit || event.pull_request?.head?.sha || env.GITHUB_SHA || null,
|
|
183
|
+
runUrl: env.GITHUB_RUN_ID ? `${server}/${repo}/actions/runs/${env.GITHUB_RUN_ID}` : null,
|
|
184
|
+
runLabel: 'Workflow run',
|
|
185
|
+
key: [env.GITHUB_WORKFLOW, env.GITHUB_JOB].filter(Boolean).join(' / ') || null
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
findTarget: async (p) => p.target,
|
|
189
|
+
async upsert(p, pr, marker, body){
|
|
190
|
+
for (let page = 1; page <= 20; page++){
|
|
191
|
+
const list = await http('GET', `${p.base}/issues/${pr}/comments?per_page=100&page=${page}`, { headers: p.headers }),
|
|
192
|
+
own = list.find((c) => c.body?.startsWith(marker));
|
|
193
|
+
if (own){
|
|
194
|
+
await http('PATCH', `${p.base}/issues/comments/${own.id}`, { headers: p.headers, body: { body } });
|
|
195
|
+
return 'updated';
|
|
196
|
+
}
|
|
197
|
+
if (list.length < 100){
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
await http('POST', `${p.base}/issues/${pr}/comments`, { headers: p.headers, body: { body } });
|
|
202
|
+
return 'created';
|
|
203
|
+
},
|
|
204
|
+
status(p, { state, context, description, url }){
|
|
205
|
+
return http('POST', `${p.base}/statuses/${p.sha}`, {
|
|
206
|
+
headers: p.headers, body: { state, context, description: description.slice(0, 140), target_url: url }
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
targetName: (pr) => `pull request #${pr}`
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// Test cases from the JUnit XML the job listed in $THUB_ARTIFACTS_FILE.
|
|
214
|
+
async function junitCases(job, headerLines, warn){
|
|
215
|
+
const headers = Object.fromEntries(headerLines.map((h) => [h.slice(0, h.indexOf(':')).trim(), h.slice(h.indexOf(':') + 1).trim()])),
|
|
216
|
+
files = (job.artifacts || []).filter((a) => /\.xml(\?|$)/i.test(a.name || '') || /\.xml(\?|$)/i.test(a.link || '')).slice(0, JUNIT_MAX_FILES),
|
|
217
|
+
cases = [];
|
|
218
|
+
for (const a of files){
|
|
219
|
+
try {
|
|
220
|
+
const res = await fetch(a.link, { headers });
|
|
221
|
+
if (!res.ok){
|
|
222
|
+
throw new Error(`HTTP ${res.status}`);
|
|
223
|
+
}
|
|
224
|
+
const text = await res.text();
|
|
225
|
+
if (text.length > JUNIT_MAX_BYTES){
|
|
226
|
+
throw new Error('larger than 5 MB');
|
|
227
|
+
}
|
|
228
|
+
cases.push(...parseJUnit(text));
|
|
229
|
+
}
|
|
230
|
+
catch (err){
|
|
231
|
+
warn(`Couldn't read test results from ${a.name}: ${err.message} (--artifact-header, if the storage needs credentials)`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return cases;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// `thub report <jobId>`. `api`: the Agent's ApiClient; `baseUrl`: the
|
|
238
|
+
// Coordinator, for the job page's link. Returns { markdown, posted, status }.
|
|
239
|
+
async function report(api, baseUrl, jobId, opts, { env = process.env, out = console.log, warn = console.error } = {}){
|
|
240
|
+
for (const h of opts.artifactHeader || []){
|
|
241
|
+
if (!/^[A-Za-z0-9-]+:\s*\S/.test(h)){
|
|
242
|
+
throw fail(`--artifact-header ${h}: expected "Name: value"`, 4);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (opts.post && !PLATFORMS[opts.post]){
|
|
246
|
+
throw fail(`--post ${opts.post}: must be gitlab, bitbucket or github`, 4);
|
|
247
|
+
}
|
|
248
|
+
if (opts.commitStatus && !opts.post){
|
|
249
|
+
throw fail('--commit-status needs --post gitlab|bitbucket|github (where to set it)', 4);
|
|
250
|
+
}
|
|
251
|
+
const job = await api.get(`/jobs/${encodeURIComponent(jobId)}`),
|
|
252
|
+
cases = opts.junit === false ? [] : await junitCases(job, opts.artifactHeader || [], warn);
|
|
253
|
+
if (cases.length && !(job.summary?.total > 0)){
|
|
254
|
+
job.summary = { ...job.summary, ...countsFromCases(cases) };
|
|
255
|
+
}
|
|
256
|
+
const platform = opts.post ? PLATFORMS[opts.post] : null,
|
|
257
|
+
where = platform ? platform.detect(env, opts) : {},
|
|
258
|
+
flavor = opts.flavor || opts.post || 'github',
|
|
259
|
+
key = opts.key || [where.key, opts.title].filter(Boolean).join(' / ') || 'TestHub',
|
|
260
|
+
ctx = {
|
|
261
|
+
key,
|
|
262
|
+
title: opts.title,
|
|
263
|
+
flavor,
|
|
264
|
+
jobUrl: `${baseUrl.replace(/\/+$/, '')}/jobs/${job.id}`,
|
|
265
|
+
runUrl: opts.runUrl || where.runUrl || null,
|
|
266
|
+
runLabel: opts.runLabel || where.runLabel || 'CI run',
|
|
267
|
+
sha: opts.commit || where.sha || null
|
|
268
|
+
},
|
|
269
|
+
markdown = opts.summary ? renderSummary(job, ctx, cases) : renderComment(job, ctx, cases),
|
|
270
|
+
result = { job, markdown, posted: null, status: null };
|
|
271
|
+
|
|
272
|
+
if (opts.output){
|
|
273
|
+
fs.writeFileSync(opts.output, markdown + '\n');
|
|
274
|
+
}
|
|
275
|
+
if (!platform){
|
|
276
|
+
if (!opts.output){
|
|
277
|
+
out(markdown);
|
|
278
|
+
}
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const target = await platform.findTarget(where);
|
|
283
|
+
if (target){
|
|
284
|
+
const action = await platform.upsert(where, target, commentMarker(key, flavor), renderComment(job, ctx, cases));
|
|
285
|
+
result.posted = { target, action };
|
|
286
|
+
out(`${platform.targetName(target)}: TestHub comment ${action}`);
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
const what = opts.post === 'gitlab' ? 'merge request' : 'pull request';
|
|
290
|
+
warn(`No ${what} for this pipeline — nothing commented (use a ${what} pipeline, or --pr <number>)`);
|
|
291
|
+
}
|
|
292
|
+
if (opts.commitStatus){
|
|
293
|
+
if (!where.sha){
|
|
294
|
+
throw fail('--commit-status: no commit (set --commit <sha>)', 4);
|
|
295
|
+
}
|
|
296
|
+
const state = statusFor(job.state, opts.post),
|
|
297
|
+
description = `${job.state}${job.summary?.total ? ` — ${job.summary.passed}/${job.summary.total} tests passed` : ''}` +
|
|
298
|
+
`${job.resource?.name ? ` on ${job.resource.name}` : ''}`;
|
|
299
|
+
await platform.status(where, { state, context: `TestHub / ${key}`, description, url: ctx.jobUrl });
|
|
300
|
+
result.status = state;
|
|
301
|
+
out(`commit ${where.sha.slice(0, 7)}: status ${state}`);
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
module.exports = { report, PLATFORMS };
|
package/src/streaming.js
CHANGED
|
@@ -21,7 +21,9 @@ const { exitCodeForJobState, EXIT_CODES } = require('@andrian.yablonskyy/thub-co
|
|
|
21
21
|
* last seen `seq` on network drops (§7).
|
|
22
22
|
*
|
|
23
23
|
* In --wait mode (used by CI, §11) Ctrl-C/SIGINT is treated as a cancel
|
|
24
|
-
* request
|
|
24
|
+
* request — and so is SIGTERM, which is how GitLab CI, Jenkins and most CI
|
|
25
|
+
* runners stop a canceled step; otherwise it only detaches and the job
|
|
26
|
+
* keeps running (SIGTERM then just ends the Agent, as before).
|
|
25
27
|
*/
|
|
26
28
|
async function followJob(client, jobId, { fromSeq = 0, waitMode = false, print = console.log } = {}){
|
|
27
29
|
let lastEventId = fromSeq,
|
|
@@ -60,6 +62,9 @@ async function followJob(client, jobId, { fromSeq = 0, waitMode = false, print =
|
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
process.on('SIGINT', onSigint);
|
|
65
|
+
if (waitMode){
|
|
66
|
+
process.on('SIGTERM', onSigint);
|
|
67
|
+
}
|
|
63
68
|
|
|
64
69
|
(async () => {
|
|
65
70
|
while (!finished){
|
|
@@ -107,6 +112,7 @@ async function followJob(client, jobId, { fromSeq = 0, waitMode = false, print =
|
|
|
107
112
|
}
|
|
108
113
|
finally {
|
|
109
114
|
process.off('SIGINT', onSigint);
|
|
115
|
+
process.off('SIGTERM', onSigint);
|
|
110
116
|
controller.abort();
|
|
111
117
|
}
|
|
112
118
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/agent/test/report.test.js
|
|
3
|
+
* @description Tests: thub report — the Markdown, and the sticky comment and commit/build status on GitLab, Bitbucket
|
|
4
|
+
* and GitHub (fake APIs)
|
|
5
|
+
*
|
|
6
|
+
* @author Andrian Yablonskyy
|
|
7
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
8
|
+
*
|
|
9
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
10
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
11
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
12
|
+
* from AdSystem.PRO.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const test = require('node:test'),
|
|
18
|
+
assert = require('node:assert/strict'),
|
|
19
|
+
http = require('node:http'),
|
|
20
|
+
{ ApiClient } = require('@andrian.yablonskyy/thub-common'),
|
|
21
|
+
{ report } = require('../src/report');
|
|
22
|
+
|
|
23
|
+
const JUNIT = '<testsuite name="s"><testcase classname="uart" name="echo"/>' +
|
|
24
|
+
'<testcase classname="uart" name="flow"><failure message="no CTS"/></testcase></testsuite>';
|
|
25
|
+
|
|
26
|
+
// The Coordinator (/api/v1/jobs/…), the JUnit artifact (/art/…) and the code
|
|
27
|
+
// host's API, on one server. `comments`: what the code host already has.
|
|
28
|
+
async function fake(t, { comments = [], state = 'FAILED' } = {}){
|
|
29
|
+
const seen = [],
|
|
30
|
+
server = http.createServer((req, res) => {
|
|
31
|
+
let body = '';
|
|
32
|
+
req.on('data', (c) => (body += c));
|
|
33
|
+
req.on('end', () => {
|
|
34
|
+
seen.push({ method: req.method, url: req.url, headers: req.headers, body: body ? JSON.parse(body) : null });
|
|
35
|
+
const json = (code, data) => res.writeHead(code, { 'content-type': 'application/json' }).end(JSON.stringify(data)),
|
|
36
|
+
u = req.url.replace(/\?.*/, '');
|
|
37
|
+
if (u === '/api/v1/jobs/A-00042'){
|
|
38
|
+
return json(200, {
|
|
39
|
+
id: 'A-00042', state, exit_code: state === 'PASSED' ? 0 : 1, duration_sec: 75,
|
|
40
|
+
created_at: '2026-10-08T10:00:00Z', started_at: '2026-10-08T10:00:30Z',
|
|
41
|
+
resource: { name: 'lab-hw-01' }, spec: { suite: 'smoke', target: { labels: ['board:nucleo-f401re'] } },
|
|
42
|
+
summary: { total: 0, passed: 0, failed: 0, skipped: 0 }, artifacts: [{ name: 'junit.xml', link: `${base}/art/junit.xml` }]
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (u === '/art/junit.xml'){
|
|
46
|
+
return res.end(JUNIT);
|
|
47
|
+
}
|
|
48
|
+
if (req.method === 'GET' && u.endsWith('/merge_requests') && u.includes('/repository/commits/')){
|
|
49
|
+
return json(200, [{ iid: 3, state: 'merged' }, { iid: 7, state: 'opened' }]);
|
|
50
|
+
}
|
|
51
|
+
if (req.method === 'GET'){
|
|
52
|
+
return json(200, u.includes('/2.0/') ? { values: comments } : comments);
|
|
53
|
+
}
|
|
54
|
+
json(201, { id: 99 });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
58
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
59
|
+
t.after(() => server.close());
|
|
60
|
+
const api = new ApiClient({ baseUrl: base, token: 'agt_x' }),
|
|
61
|
+
out = [],
|
|
62
|
+
run = (opts, env) => report(api, 'https://thub.example.com', 'A-00042', opts, { env, out: (l) => out.push(l), warn: (l) => out.push(l) });
|
|
63
|
+
return { base, seen, api, run, out };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test('without --post: the comment as Markdown, test counts from the JUnit artifact', async (t) => {
|
|
67
|
+
const f = await fake(t),
|
|
68
|
+
{ markdown } = await f.run({ title: 'HW smoke' }, {});
|
|
69
|
+
assert.match(markdown, /^<!-- thub-report:HW smoke -->\n### ❌ TestHub HW smoke: \*\*FAILED\*\*/);
|
|
70
|
+
assert.match(markdown, /\| Board \| `nucleo-f401re` on lab-hw-01 \|/);
|
|
71
|
+
assert.match(markdown, /\| Tests \| 2 total · 1 passed · 1 failed · 0 skipped \|/);
|
|
72
|
+
assert.match(markdown, /\| Duration \| 1m 15s \(queued 30s\) \|/);
|
|
73
|
+
assert.match(markdown, /\[A-00042\]\(https:\/\/thub\.example\.com\/jobs\/A-00042\)/);
|
|
74
|
+
assert.match(markdown, /\| ❌ \| uart\.flow \| — \| no CTS \|/);
|
|
75
|
+
assert.equal(f.out[0], markdown);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('GitLab: finds the open MR of a branch pipeline, creates the note, sets the commit status', async (t) => {
|
|
79
|
+
const f = await fake(t),
|
|
80
|
+
env = {
|
|
81
|
+
CI_API_V4_URL: `${f.base}/api/v4`, CI_PROJECT_ID: '12', GITLAB_TOKEN: 'glpat-x',
|
|
82
|
+
CI_COMMIT_SHA: 'abc1234567', CI_PIPELINE_URL: 'https://gitlab.example.com/fw/-/pipelines/5', CI_JOB_NAME: 'test-hw'
|
|
83
|
+
},
|
|
84
|
+
{ posted, status } = await f.run({ post: 'gitlab', commitStatus: true }, env);
|
|
85
|
+
assert.deepEqual(posted, { target: 7, action: 'created' });
|
|
86
|
+
const note = f.seen.find((r) => r.method === 'POST' && r.url === '/api/v4/projects/12/merge_requests/7/notes');
|
|
87
|
+
assert.ok(note.body.body.startsWith('<!-- thub-report:test-hw -->'));
|
|
88
|
+
assert.match(note.body.body, /\[Pipeline\]\(https:\/\/gitlab\.example\.com\/fw\/-\/pipelines\/5\)/);
|
|
89
|
+
assert.match(note.body.body, /<details open><summary>Failed tests \(1\)<\/summary>/); // GitLab renders HTML
|
|
90
|
+
assert.equal(note.headers['private-token'], 'glpat-x');
|
|
91
|
+
const st = f.seen.find((r) => r.url === '/api/v4/projects/12/statuses/abc1234567');
|
|
92
|
+
assert.deepEqual([st.body.state, st.body.name, st.body.target_url], ['failed', 'TestHub / test-hw', 'https://thub.example.com/jobs/A-00042']);
|
|
93
|
+
assert.equal(status, 'failed');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('GitLab: a merge-request pipeline updates its own note, leaving others alone', async (t) => {
|
|
97
|
+
const f = await fake(t, { state: 'PASSED', comments: [{ id: 1, body: 'LGTM' }, { id: 2, body: '<!-- thub-report:test-hw -->\nold' }] }),
|
|
98
|
+
env = { CI_API_V4_URL: 'x', CI_PROJECT_ID: '12', GITLAB_TOKEN: 't', CI_MERGE_REQUEST_IID: '4', CI_JOB_NAME: 'test-hw' };
|
|
99
|
+
env.CI_API_V4_URL = `${f.base}/api/v4`;
|
|
100
|
+
const { posted } = await f.run({ post: 'gitlab' }, env);
|
|
101
|
+
assert.deepEqual(posted, { target: '4', action: 'updated' });
|
|
102
|
+
const put = f.seen.find((r) => r.method === 'PUT');
|
|
103
|
+
assert.equal(put.url, '/api/v4/projects/12/merge_requests/4/notes/2');
|
|
104
|
+
assert.match(put.body.body, /✅ TestHub \*\*PASSED\*\*/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('Bitbucket: no HTML, a link-definition marker, the build status keyed under 40 characters', async (t) => {
|
|
108
|
+
const f = await fake(t, { comments: [{ id: 5, content: { raw: '[//]: # (thub-report:HW smoke)\nold' } }] }),
|
|
109
|
+
env = {
|
|
110
|
+
BITBUCKET_API_URL: `${f.base}/2.0`, BITBUCKET_WORKSPACE: 'acme', BITBUCKET_REPO_SLUG: 'fw', BITBUCKET_PR_ID: '11',
|
|
111
|
+
BITBUCKET_COMMIT: 'def4567890', BITBUCKET_TOKEN: 'bbtok', BITBUCKET_GIT_HTTP_ORIGIN: 'https://bitbucket.org/acme/fw', BITBUCKET_BUILD_NUMBER: '88'
|
|
112
|
+
},
|
|
113
|
+
{ posted } = await f.run({ post: 'bitbucket', commitStatus: true, title: 'HW smoke' }, env);
|
|
114
|
+
assert.deepEqual(posted, { target: '11', action: 'updated' });
|
|
115
|
+
const put = f.seen.find((r) => r.method === 'PUT'),
|
|
116
|
+
raw = put.body.content.raw;
|
|
117
|
+
assert.equal(put.url, '/2.0/repositories/acme/fw/pullrequests/11/comments/5');
|
|
118
|
+
assert.equal(put.headers.authorization, 'Bearer bbtok');
|
|
119
|
+
assert.ok(raw.startsWith('[//]: # (thub-report:HW smoke)\n'));
|
|
120
|
+
assert.doesNotMatch(raw, /<details|<sub>|<!--/);
|
|
121
|
+
assert.match(raw, /\*\*Failed tests \(1\)\*\*/);
|
|
122
|
+
assert.match(raw, /\[Pipeline\]\(https:\/\/bitbucket\.org\/acme\/fw\/pipelines\/results\/88\)/);
|
|
123
|
+
const st = f.seen.find((r) => r.url === '/2.0/repositories/acme/fw/commit/def4567890/statuses/build');
|
|
124
|
+
assert.equal(st.body.state, 'FAILED');
|
|
125
|
+
assert.ok(st.body.key.length <= 40 && st.body.key.startsWith('thub-'));
|
|
126
|
+
assert.equal(st.body.url, 'https://thub.example.com/jobs/A-00042');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('GitHub from another CI (Jenkins): --pr and GITHUB_TOKEN', async (t) => {
|
|
130
|
+
const f = await fake(t),
|
|
131
|
+
env = { GITHUB_API_URL: f.base, GITHUB_REPOSITORY: 'o/r', GITHUB_TOKEN: 'ghp_x' },
|
|
132
|
+
opts = { post: 'github', pr: '9', commit: 'aaa1111', commitStatus: true, runUrl: 'https://ci.lab/job/fw/12/', runLabel: 'Jenkins build' },
|
|
133
|
+
{ posted } = await f.run(opts, env);
|
|
134
|
+
assert.deepEqual(posted, { target: '9', action: 'created' });
|
|
135
|
+
const post = f.seen.find((r) => r.method === 'POST' && r.url === '/repos/o/r/issues/9/comments');
|
|
136
|
+
assert.match(post.body.body, /\[Jenkins build\]\(https:\/\/ci\.lab\/job\/fw\/12\/\)/);
|
|
137
|
+
assert.equal(f.seen.find((r) => r.url === '/repos/o/r/statuses/aaa1111').body.state, 'failure');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('what\'s missing is said plainly', async (t) => {
|
|
141
|
+
const f = await fake(t);
|
|
142
|
+
await assert.rejects(f.run({ post: 'gitlab' }, { CI_API_V4_URL: 'x', CI_PROJECT_ID: '1' }), /set GITLAB_TOKEN/);
|
|
143
|
+
await assert.rejects(f.run({ post: 'bitbucket' }, {}), /BITBUCKET_WORKSPACE and BITBUCKET_REPO_SLUG are not set/);
|
|
144
|
+
await assert.rejects(f.run({ post: 'gerrit' }, {}), /must be gitlab, bitbucket or github/);
|
|
145
|
+
await assert.rejects(f.run({ commitStatus: true }, {}), /--commit-status needs --post/);
|
|
146
|
+
// A Bitbucket branch pipeline: no PR to comment on, said so, not an error.
|
|
147
|
+
f.out.length = 0;
|
|
148
|
+
await f.run({ post: 'bitbucket' }, { BITBUCKET_API_URL: `${f.base}/2.0`, BITBUCKET_WORKSPACE: 'a', BITBUCKET_REPO_SLUG: 'b', BITBUCKET_TOKEN: 't' });
|
|
149
|
+
assert.match(f.out.join('\n'), /No pull request for this pipeline/);
|
|
150
|
+
});
|