@andrian.yablonskyy/thub-common 1.0.0
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 +68 -0
- package/package.json +36 -0
- package/src/api-client.js +168 -0
- package/src/index.js +20 -0
- package/src/job-spec.schema.js +78 -0
- package/src/states.js +105 -0
- package/src/validate-job-spec.js +38 -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
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# @andrian.yablonskyy/thub-common
|
|
2
|
+
|
|
3
|
+
Shared code for [TestHub](https://github.com/andrianyablonskyy/thub) — a self-hosted job network that lets CI/CD pipelines and individual developers run firmware tests on real hardware or emulators in a private lab. This package has no server and no CLI of its own; it's the small, dependency-light core that the [Agent](https://github.com/andrianyablonskyy/thub-agent), [Coordinator](https://github.com/andrianyablonskyy/thub-coordinator) and [Client](https://github.com/andrianyablonskyy/thub-client) all depend on, so the three speak exactly the same protocol.
|
|
4
|
+
|
|
5
|
+
See the [main TestHub repo](https://github.com/andrianyablonskyy/thub) for the full system architecture, deployment diagrams and end-to-end flows.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @andrian.yablonskyy/thub-common
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## What's in it
|
|
14
|
+
|
|
15
|
+
### Job spec schema and validation
|
|
16
|
+
|
|
17
|
+
An [Ajv](https://ajv.js.org/) JSON schema for the job specification the Agent sends to the Coordinator, and a thin wrapper that validates a spec and fills in its defaults.
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
const { validateJobSpec } = require('@andrian.yablonskyy/thub-common');
|
|
21
|
+
|
|
22
|
+
const { valid, spec, errors } = validateJobSpec({
|
|
23
|
+
target: { type: 'hw', labels: ['board:nucleo-f401re'] },
|
|
24
|
+
firmware: { url: 'https://artifactory.example.com/app.bin' },
|
|
25
|
+
tests: { url: 'https://artifactory.example.com/tests.tar.gz' }
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The schema deliberately has no field for shell commands — a job only ever names a firmware image and a test package; the Client runs a fixed entry point (`run-tests.sh`) from that package, never arbitrary code from the spec itself. Top-level fields: `target` (`type`, `labels`, optional `group`), `firmware` (`url`, optional `sha256`/`flashAddress`), `tests` (`url`, `suite`, `args`), `timeoutSec`, `priority`, `source` (`ci`/`cli`), `user` (a free-text job-owner label), `meta` (arbitrary key/value metadata), and `dryRun`.
|
|
30
|
+
|
|
31
|
+
### State enums and exit codes
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
const { RESOURCE_STATES, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, BUSY_SOURCES, EXIT_CODES, exitCodeForJobState } = require('@andrian.yablonskyy/thub-common');
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- `RESOURCE_STATES`: `REGISTERED`, `IDLE`, `BUSY`, `OUT_OF_SERVICE`, `MAINTENANCE`.
|
|
38
|
+
- `JOB_STATES`: `QUEUED`, `ASSIGNED`, `PREPARING`, `RUNNING`, `PASSED`, `FAILED`, `ERROR`, `TIMEOUT`, `CANCELED`, `LOST`. `ACTIVE_JOB_STATES`/`TERMINAL_JOB_STATES` are the two natural partitions of that set.
|
|
39
|
+
- `BUSY_SOURCES`: `ci`, `cli`, `local` — why a resource is currently busy.
|
|
40
|
+
- `EXIT_CODES` / `exitCodeForJobState(state)`: the Agent CLI's process exit codes (`PASSED` → 0, `FAILED` → 1, `ERROR`/`TIMEOUT`/`LOST` → 2, `CANCELED` → 3, usage/auth error → 4, detached with Ctrl-C → 130), so a CI step can branch on `$?` without parsing output.
|
|
41
|
+
|
|
42
|
+
### API client
|
|
43
|
+
|
|
44
|
+
A minimal `fetch`-based HTTP client for the Coordinator's `/api/v1` surface, with a hand-rolled Server-Sent-Events parser (no external SSE dependency) — used by both the Agent and the Client so they speak the exact same protocol.
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
const { ApiClient } = require('@andrian.yablonskyy/thub-common');
|
|
48
|
+
|
|
49
|
+
const client = new ApiClient({ baseUrl: 'https://thub.example.com', token: 'agt_...' });
|
|
50
|
+
const job = await client.post('/jobs', spec);
|
|
51
|
+
await client.streamEvents(`/jobs/${job.jobId}/logs/stream`, {
|
|
52
|
+
onEvent: ({ event, id, data }) => console.log(event, data)
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`request()`/`get()`/`post()` handle JSON bodies, query strings and non-2xx errors (throwing with `.status`/`.body` set); `streamEvents()` reconnects the caller is expected to drive (it just parses one connection's worth of events and resolves when the stream ends) and honors `Last-Event-ID` for resuming after a drop.
|
|
57
|
+
|
|
58
|
+
## Development
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm install
|
|
62
|
+
npm test # node --test test/*.test.js
|
|
63
|
+
npm run lint
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
See [LICENSE.md](./LICENSE.md).
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@andrian.yablonskyy/thub-common",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Shared job-spec schema, state enums and a thin API client used by the Agent and Client",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"files": ["src"],
|
|
7
|
+
"license": "SEE LICENSE IN LICENSE.md",
|
|
8
|
+
"author": "Andrian Yablonskyy <andrian.yablonskyy@gmail.com>",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/andrianyablonskyy/thub-common.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/andrianyablonskyy/thub-common#readme",
|
|
14
|
+
"bugs": "https://github.com/andrianyablonskyy/thub-common/issues",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=24"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/*.test.js",
|
|
23
|
+
"lint": "eslint .",
|
|
24
|
+
"lint:fix": "eslint . --fix"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"ajv": "^8.17.1",
|
|
28
|
+
"ajv-formats": "^3.0.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@stylistic/eslint-plugin": "^5.10.0",
|
|
32
|
+
"eslint": "^10.11.0",
|
|
33
|
+
"eslint-plugin-promise": "^7.3.0",
|
|
34
|
+
"globals": "^17.12.0"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/shared/src/api-client.js
|
|
3
|
+
* @description Fetch-based HTTP client with SSE parsing, shared by the Agent and Client
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A thin wrapper around fetch() for talking to the Coordinator's
|
|
18
|
+
* /api/v1 surface. Shared by the Agent CLI and the Client daemon so
|
|
19
|
+
* both speak the exact same protocol (README.md §1: "This keeps CI
|
|
20
|
+
* and manual usage identical").
|
|
21
|
+
*/
|
|
22
|
+
class ApiClient{
|
|
23
|
+
constructor({ baseUrl, token }){
|
|
24
|
+
if (!baseUrl){
|
|
25
|
+
throw new Error('ApiClient requires baseUrl');
|
|
26
|
+
}
|
|
27
|
+
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
28
|
+
this.token = token;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_url(path){
|
|
32
|
+
return `${this.baseUrl}/api/v1${path}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_headers(extra = {}){
|
|
36
|
+
const headers = { ...extra };
|
|
37
|
+
if (this.token){
|
|
38
|
+
headers.Authorization = `Bearer ${this.token}`;
|
|
39
|
+
}
|
|
40
|
+
return headers;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async request(method, path, { body, headers, query, signal } = {}){
|
|
44
|
+
let url = this._url(path);
|
|
45
|
+
if (query){
|
|
46
|
+
const qs = new URLSearchParams(
|
|
47
|
+
Object.entries(query).filter(([, v]) => v !== undefined && v !== null)
|
|
48
|
+
).toString();
|
|
49
|
+
if (qs){
|
|
50
|
+
url += `?${qs}`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isJsonBody = body !== undefined && !(body instanceof FormData),
|
|
55
|
+
res = await fetch(url, {
|
|
56
|
+
method,
|
|
57
|
+
signal,
|
|
58
|
+
headers: this._headers({
|
|
59
|
+
...(isJsonBody ? { 'Content-Type': 'application/json' } : {}),
|
|
60
|
+
...headers
|
|
61
|
+
}),
|
|
62
|
+
body: isJsonBody ? JSON.stringify(body) : body
|
|
63
|
+
}),
|
|
64
|
+
|
|
65
|
+
text = await res.text(),
|
|
66
|
+
data = text ? safeJson(text) : undefined;
|
|
67
|
+
|
|
68
|
+
if (!res.ok){
|
|
69
|
+
const err = new Error((data && data.error) || `HTTP ${res.status}`);
|
|
70
|
+
err.status = res.status;
|
|
71
|
+
err.body = data;
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
get(path, opts){
|
|
78
|
+
return this.request('GET', path, opts);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
post(path, body, opts){
|
|
82
|
+
return this.request('POST', path, { ...opts, body });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Opens a Server-Sent-Events stream and invokes onEvent({event, id, data})
|
|
87
|
+
* for each event. Resolves when the stream ends (server closes it, e.g.
|
|
88
|
+
* on job.end) or rejects on network error / abort.
|
|
89
|
+
*/
|
|
90
|
+
async streamEvents(path, { lastEventId, signal, onEvent, query } = {}){
|
|
91
|
+
let url = this._url(path);
|
|
92
|
+
if (query){
|
|
93
|
+
const qs = new URLSearchParams(
|
|
94
|
+
Object.entries(query).filter(([, v]) => v !== undefined && v !== null)
|
|
95
|
+
).toString();
|
|
96
|
+
if (qs){
|
|
97
|
+
url += `?${qs}`;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const headers = this._headers({ Accept: 'text/event-stream' });
|
|
102
|
+
if (lastEventId){
|
|
103
|
+
headers['Last-Event-ID'] = String(lastEventId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const res = await fetch(url, { headers, signal });
|
|
107
|
+
if (!res.ok){
|
|
108
|
+
const err = new Error(`HTTP ${res.status}`);
|
|
109
|
+
err.status = res.status;
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const reader = res.body.getReader(),
|
|
114
|
+
decoder = new TextDecoder();
|
|
115
|
+
let buf = '';
|
|
116
|
+
|
|
117
|
+
while (true){
|
|
118
|
+
const { done, value } = await reader.read();
|
|
119
|
+
if (done){
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
buf += decoder.decode(value, { stream: true });
|
|
123
|
+
|
|
124
|
+
let sep;
|
|
125
|
+
while ((sep = buf.indexOf('\n\n')) !== -1){
|
|
126
|
+
const rawEvent = buf.slice(0, sep);
|
|
127
|
+
buf = buf.slice(sep + 2);
|
|
128
|
+
const parsed = parseSseEvent(rawEvent);
|
|
129
|
+
if (parsed){
|
|
130
|
+
onEvent(parsed);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseSseEvent(raw){
|
|
138
|
+
let event = 'message',
|
|
139
|
+
id;
|
|
140
|
+
const dataLines = [];
|
|
141
|
+
for (const line of raw.split('\n')){
|
|
142
|
+
if (line.startsWith('event:')){
|
|
143
|
+
event = line.slice(6).trim();
|
|
144
|
+
}
|
|
145
|
+
else if (line.startsWith('id:')){
|
|
146
|
+
id = line.slice(3).trim();
|
|
147
|
+
}
|
|
148
|
+
else if (line.startsWith('data:')){
|
|
149
|
+
dataLines.push(line.slice(5).trim());
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (dataLines.length === 0 && !id){
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const rawData = dataLines.join('\n');
|
|
156
|
+
return { event, id, data: safeJson(rawData) ?? rawData };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function safeJson(text){
|
|
160
|
+
try {
|
|
161
|
+
return JSON.parse(text);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { ApiClient };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/shared/src/index.js
|
|
3
|
+
* @description Public entry point re-exporting shared states, schema, and API client
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
module.exports = {
|
|
17
|
+
...require('./states'),
|
|
18
|
+
...require('./validate-job-spec'),
|
|
19
|
+
ApiClient: require('./api-client').ApiClient
|
|
20
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/shared/src/job-spec.schema.js
|
|
3
|
+
* @description Ajv JSON schema for the job specification (README §4.3)
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
// Matches the job spec shape documented in README.md §4.3.
|
|
17
|
+
// Deliberately has no field for shell commands: the Client only ever
|
|
18
|
+
// runs the fixed entry point from the downloaded test package.
|
|
19
|
+
const jobSpecSchema = {
|
|
20
|
+
$id: 'https://thub.example.com/schemas/job-spec.json',
|
|
21
|
+
type: 'object',
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
required: ['target', 'firmware', 'tests'],
|
|
24
|
+
properties: {
|
|
25
|
+
target: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
required: ['type'],
|
|
29
|
+
properties: {
|
|
30
|
+
type: { enum: ['hw', 'sw'] },
|
|
31
|
+
labels: {
|
|
32
|
+
type: 'array',
|
|
33
|
+
items: { type: 'string', minLength: 1 },
|
|
34
|
+
default: []
|
|
35
|
+
},
|
|
36
|
+
// Constrains scheduling to resources that are members of this
|
|
37
|
+
// group (§13.1, `thub run --group <id>`) — a third targeting
|
|
38
|
+
// dimension alongside type/labels. Omitted: any matching resource
|
|
39
|
+
// in any (or no) group is eligible, same as before groups existed.
|
|
40
|
+
group: { type: 'string', minLength: 1 }
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
firmware: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
required: ['url'],
|
|
47
|
+
properties: {
|
|
48
|
+
url: { type: 'string', format: 'uri' },
|
|
49
|
+
sha256: { type: 'string', pattern: '^[a-f0-9]{64}$' },
|
|
50
|
+
flashAddress: { type: 'string' }
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
tests: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
required: ['url'],
|
|
57
|
+
properties: {
|
|
58
|
+
url: { type: 'string', format: 'uri' },
|
|
59
|
+
suite: { type: 'string', default: 'default' },
|
|
60
|
+
args: { type: 'array', items: { type: 'string' }, default: [] }
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
timeoutSec: { type: 'integer', minimum: 1, default: 1800 },
|
|
64
|
+
priority: { type: 'integer', minimum: 0, maximum: 100, default: 50 },
|
|
65
|
+
source: { enum: ['ci', 'cli'] },
|
|
66
|
+
// Free-text job owner (`thub run --user <name>`, §7.1) — purely a
|
|
67
|
+
// label shown on the Client and dashboard to tell whose job is whose,
|
|
68
|
+
// not an identity: nothing authenticates or enforces it.
|
|
69
|
+
user: { type: 'string', minLength: 1 },
|
|
70
|
+
meta: { type: 'object' },
|
|
71
|
+
// Exercises the full pipeline (schedule, accept, state transitions,
|
|
72
|
+
// logs, artifact, result) without flashing/running anything for real —
|
|
73
|
+
// see README §7.1 "Dry-run the pipeline".
|
|
74
|
+
dryRun: { type: 'boolean', default: false }
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
module.exports = { jobSpecSchema };
|
package/src/states.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/shared/src/states.js
|
|
3
|
+
* @description Resource and job state enums, and CLI exit codes
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
// §4.1 Resource (Client) states
|
|
17
|
+
const RESOURCE_STATES = Object.freeze({
|
|
18
|
+
REGISTERED: 'REGISTERED',
|
|
19
|
+
IDLE: 'IDLE',
|
|
20
|
+
BUSY: 'BUSY',
|
|
21
|
+
OUT_OF_SERVICE: 'OUT_OF_SERVICE',
|
|
22
|
+
MAINTENANCE: 'MAINTENANCE'
|
|
23
|
+
}),
|
|
24
|
+
|
|
25
|
+
SCHEDULABLE_RESOURCE_STATES = new Set([RESOURCE_STATES.IDLE]),
|
|
26
|
+
|
|
27
|
+
BUSY_SOURCES = Object.freeze({
|
|
28
|
+
CI: 'ci',
|
|
29
|
+
CLI: 'cli',
|
|
30
|
+
LOCAL: 'local'
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
// §4.2 Job states
|
|
34
|
+
JOB_STATES = Object.freeze({
|
|
35
|
+
QUEUED: 'QUEUED',
|
|
36
|
+
ASSIGNED: 'ASSIGNED',
|
|
37
|
+
PREPARING: 'PREPARING',
|
|
38
|
+
RUNNING: 'RUNNING',
|
|
39
|
+
PASSED: 'PASSED',
|
|
40
|
+
FAILED: 'FAILED',
|
|
41
|
+
ERROR: 'ERROR',
|
|
42
|
+
TIMEOUT: 'TIMEOUT',
|
|
43
|
+
CANCELED: 'CANCELED',
|
|
44
|
+
LOST: 'LOST'
|
|
45
|
+
}),
|
|
46
|
+
|
|
47
|
+
TERMINAL_JOB_STATES = new Set([
|
|
48
|
+
JOB_STATES.PASSED,
|
|
49
|
+
JOB_STATES.FAILED,
|
|
50
|
+
JOB_STATES.ERROR,
|
|
51
|
+
JOB_STATES.TIMEOUT,
|
|
52
|
+
JOB_STATES.CANCELED,
|
|
53
|
+
JOB_STATES.LOST
|
|
54
|
+
]),
|
|
55
|
+
|
|
56
|
+
ACTIVE_JOB_STATES = new Set([
|
|
57
|
+
JOB_STATES.QUEUED,
|
|
58
|
+
JOB_STATES.ASSIGNED,
|
|
59
|
+
JOB_STATES.PREPARING,
|
|
60
|
+
JOB_STATES.RUNNING
|
|
61
|
+
]),
|
|
62
|
+
|
|
63
|
+
JOB_SOURCES = Object.freeze({
|
|
64
|
+
CI: 'ci',
|
|
65
|
+
CLI: 'cli'
|
|
66
|
+
}),
|
|
67
|
+
|
|
68
|
+
// §7 Agent CLI exit codes
|
|
69
|
+
EXIT_CODES = Object.freeze({
|
|
70
|
+
PASSED: 0,
|
|
71
|
+
FAILED: 1,
|
|
72
|
+
INFRA: 2, // ERROR, TIMEOUT, LOST
|
|
73
|
+
CANCELED: 3,
|
|
74
|
+
USAGE: 4,
|
|
75
|
+
DETACHED: 130
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function exitCodeForJobState(state){
|
|
79
|
+
switch (state){
|
|
80
|
+
case JOB_STATES.PASSED:
|
|
81
|
+
return EXIT_CODES.PASSED;
|
|
82
|
+
case JOB_STATES.FAILED:
|
|
83
|
+
return EXIT_CODES.FAILED;
|
|
84
|
+
case JOB_STATES.ERROR:
|
|
85
|
+
case JOB_STATES.TIMEOUT:
|
|
86
|
+
case JOB_STATES.LOST:
|
|
87
|
+
return EXIT_CODES.INFRA;
|
|
88
|
+
case JOB_STATES.CANCELED:
|
|
89
|
+
return EXIT_CODES.CANCELED;
|
|
90
|
+
default:
|
|
91
|
+
return EXIT_CODES.USAGE;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
RESOURCE_STATES,
|
|
97
|
+
SCHEDULABLE_RESOURCE_STATES,
|
|
98
|
+
BUSY_SOURCES,
|
|
99
|
+
JOB_STATES,
|
|
100
|
+
TERMINAL_JOB_STATES,
|
|
101
|
+
ACTIVE_JOB_STATES,
|
|
102
|
+
JOB_SOURCES,
|
|
103
|
+
EXIT_CODES,
|
|
104
|
+
exitCodeForJobState
|
|
105
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/shared/src/validate-job-spec.js
|
|
3
|
+
* @description Validates a job spec object against the Ajv schema
|
|
4
|
+
*
|
|
5
|
+
* @author Andrian Yablonskyy
|
|
6
|
+
* @copyright Copyright (c) 2026 Andrian Yablonskyy. All rights reserved.
|
|
7
|
+
*
|
|
8
|
+
* This file is part of TestHub and is proprietary and confidential.
|
|
9
|
+
* Unauthorized copying, modification, distribution, or use of this file,
|
|
10
|
+
* via any medium, is strictly prohibited without prior written permission
|
|
11
|
+
* from AdSystem.PRO.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const Ajv = require('ajv'),
|
|
17
|
+
addFormats = require('ajv-formats'),
|
|
18
|
+
{ jobSpecSchema } = require('./job-spec.schema');
|
|
19
|
+
|
|
20
|
+
const ajv = new Ajv({ useDefaults: true, allErrors: true, strict: false });
|
|
21
|
+
addFormats(ajv);
|
|
22
|
+
const validateFn = ajv.compile(jobSpecSchema);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validates and normalizes (defaults applied in place) a job spec.
|
|
26
|
+
* Returns { valid, spec, errors }.
|
|
27
|
+
*/
|
|
28
|
+
function validateJobSpec(spec){
|
|
29
|
+
const clone = JSON.parse(JSON.stringify(spec ?? {})),
|
|
30
|
+
valid = validateFn(clone);
|
|
31
|
+
return {
|
|
32
|
+
valid,
|
|
33
|
+
spec: clone,
|
|
34
|
+
errors: valid ? [] : (validateFn.errors || []).map((e) => `${e.instancePath || '/'} ${e.message}`)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { validateJobSpec };
|