@andrian.yablonskyy/thub-coordinator 1.0.37 → 1.0.39
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/bin/thub-admin.js +3 -5
- package/config.json +1 -6
- package/package.json +2 -2
- package/public/css/thub.css +129 -0
- package/public/js/config-import.js +37 -33
- package/public/js/copy-to-clipboard.js +21 -18
- package/public/js/help.js +93 -0
- package/public/js/list-search.js +51 -0
- package/public/js/live.js +287 -0
- package/public/js/log-viewer.js +289 -45
- package/public/js/remove-resource.js +5 -3
- package/public/js/resource-card.js +14 -13
- package/public/js/tooltips.js +3 -1
- package/scripts/install-default-config.js +1 -1
- package/scripts/install-target.js +2 -2
- package/src/api/admin.js +1 -1
- package/src/api/agent.js +4 -10
- package/src/api/resource.js +6 -11
- package/src/api/sse.js +13 -10
- package/src/config.js +16 -9
- package/src/db/migrations/022_add_sessions.sql +10 -0
- package/src/db/migrations/023_drop_artifacts.sql +4 -0
- package/src/dev/virtual.js +1 -1
- package/src/server.js +55 -21
- package/src/services/cleanup.js +2 -2
- package/src/services/jobs.js +33 -19
- package/src/services/list-prefs.js +4 -0
- package/src/services/live.js +124 -0
- package/src/services/logs.js +12 -14
- package/src/services/retention.js +5 -14
- package/src/services/search.js +59 -0
- package/src/services/session-store.js +127 -0
- package/src/web/routes.js +205 -34
- package/test/cleanup.test.js +1 -5
- package/test/list-prefs.test.js +6 -0
- package/test/live.test.js +153 -0
- package/test/logs.test.js +150 -0
- package/test/scheduler.test.js +10 -12
- package/test/search.test.js +97 -0
- package/test/session.test.js +132 -0
- package/test/views.test.js +30 -0
- package/views/admin/agents.pug +28 -10
- package/views/groups/list.pug +14 -1
- package/views/help/_agent-cli.pug +103 -0
- package/views/help/_agent-setup.pug +72 -0
- package/views/help/_ci.pug +44 -0
- package/views/help/_client-machines.pug +87 -0
- package/views/help/_client-setup.pug +122 -0
- package/views/help/_coordinator.pug +137 -0
- package/views/help/_docker.pug +124 -0
- package/views/help/_env.pug +127 -0
- package/views/help/_git.pug +130 -0
- package/views/help/_mixins.pug +24 -0
- package/views/help/_overview.pug +118 -0
- package/views/help/_quick-start.pug +37 -0
- package/views/help/_troubleshooting.pug +44 -0
- package/views/help/index.pug +67 -0
- package/views/index.pug +3 -2
- package/views/jobs/list.pug +8 -5
- package/views/jobs/show.pug +0 -10
- package/views/layout.pug +24 -1
- package/views/mixins/list-controls.pug +36 -0
- package/views/mixins/log-viewer.pug +39 -3
- package/views/mixins/resource.pug +1 -1
- package/views/resources/list.pug +7 -4
- package/src/services/artifacts.js +0 -149
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/logs.test.js
|
|
3
|
+
* @description Tests: job log paging for the dashboard viewer, the full SSE replay, and the raw log (RAW button)
|
|
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 test = require('node:test'),
|
|
17
|
+
assert = require('node:assert/strict'),
|
|
18
|
+
fs = require('node:fs'),
|
|
19
|
+
os = require('node:os'),
|
|
20
|
+
path = require('node:path'),
|
|
21
|
+
{ loadConfig } = require('../src/config'),
|
|
22
|
+
{ buildServices, createApp } = require('../src/server');
|
|
23
|
+
|
|
24
|
+
// A Coordinator on a random port, an admin session, and one job with `n`
|
|
25
|
+
// log lines ("line 1".."line n").
|
|
26
|
+
async function setup(t, n){
|
|
27
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-logs-')),
|
|
28
|
+
file = path.join(dir, 'coordinator.json');
|
|
29
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
30
|
+
dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 }
|
|
31
|
+
}));
|
|
32
|
+
const config = loadConfig(file),
|
|
33
|
+
services = buildServices(config),
|
|
34
|
+
server = createApp(config, services).listen(0, '127.0.0.1');
|
|
35
|
+
t.after(() => new Promise((r) => {
|
|
36
|
+
server.closeAllConnections?.();
|
|
37
|
+
server.close(r);
|
|
38
|
+
}));
|
|
39
|
+
await new Promise((r) => server.on('listening', r));
|
|
40
|
+
const base = `http://127.0.0.1:${server.address().port}`;
|
|
41
|
+
|
|
42
|
+
services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
|
|
43
|
+
const login = await fetch(`${base}/login`, {
|
|
44
|
+
method: 'POST', body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
|
|
45
|
+
}),
|
|
46
|
+
cookie = login.headers.get('set-cookie').split(';')[0],
|
|
47
|
+
{ agent } = services.agents.create({ name: 'ci', kind: 'ci' });
|
|
48
|
+
services.registry.registerAuto({ clientId: 'c1', name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
49
|
+
const job = services.jobs.create({ agentId: agent.id, source: 'ci', spec: { target: { type: 'sw' }, command: './t.sh' } });
|
|
50
|
+
for (let i = 0; i < n; i += 500){
|
|
51
|
+
services.logs.appendBatch(job.id, Array.from({ length: Math.min(500, n - i) }, (_, k) => ({ stream: 'runner', line: `line ${i + k + 1}` })));
|
|
52
|
+
}
|
|
53
|
+
const finish = () => services.db.prepare('UPDATE jobs SET state = ?, finished_at = ? WHERE id = ?').run('PASSED', new Date().toISOString(), job.id);
|
|
54
|
+
return { base, cookie, services, job, finish, get: (p) => fetch(`${base}${p}`, { headers: { cookie } }) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
test('log pages: the end first, then earlier pages until the start', async (t) => {
|
|
58
|
+
const { get, job } = await setup(t, 2500),
|
|
59
|
+
last = await (await get(`/jobs/${job.id}/logs?limit=1000`)).json();
|
|
60
|
+
assert.equal(last.total, 2500);
|
|
61
|
+
assert.equal(last.hasMore, true);
|
|
62
|
+
assert.equal(last.lines.length, 1000);
|
|
63
|
+
assert.equal(last.lines[0].line, 'line 1501');
|
|
64
|
+
assert.equal(last.lines.at(-1).line, 'line 2500');
|
|
65
|
+
|
|
66
|
+
const earlier = await (await get(`/jobs/${job.id}/logs?limit=1000&before=${last.lines[0].seq}`)).json();
|
|
67
|
+
assert.deepEqual([earlier.lines[0].line, earlier.lines.at(-1).line, earlier.hasMore], ['line 501', 'line 1500', true]);
|
|
68
|
+
|
|
69
|
+
const first = await (await get(`/jobs/${job.id}/logs?limit=1000&before=${earlier.lines[0].seq}`)).json();
|
|
70
|
+
assert.deepEqual([first.lines.length, first.lines[0].line, first.hasMore], [500, 'line 1', false]);
|
|
71
|
+
|
|
72
|
+
// Limits are clamped: never more than 5000 a request.
|
|
73
|
+
assert.equal((await (await get(`/jobs/${job.id}/logs?limit=999999`)).json()).lines.length, 2500);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('log stream replays every line, not just the first 500', async (t) => {
|
|
77
|
+
const { get, job, finish } = await setup(t, 1234);
|
|
78
|
+
finish();
|
|
79
|
+
const text = await (await get(`/jobs/${job.id}/stream`)).text(),
|
|
80
|
+
lines = [...text.matchAll(/^event: log$/gm)].length;
|
|
81
|
+
assert.equal(lines, 1234);
|
|
82
|
+
assert.match(text, /"line":"line 1234"/);
|
|
83
|
+
assert.match(text, /event: end/);
|
|
84
|
+
|
|
85
|
+
// Resuming after a seq replays only what follows it.
|
|
86
|
+
const resumed = await (await get(`/jobs/${job.id}/stream?after=1200`)).text();
|
|
87
|
+
assert.equal([...resumed.matchAll(/^event: log$/gm)].length, 34);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('RAW: the whole log as plain text, for as long as the job exists', async (t) => {
|
|
91
|
+
const { get, job, finish, services } = await setup(t, 6001),
|
|
92
|
+
res = await get(`/jobs/${job.id}/log.txt`),
|
|
93
|
+
text = await res.text();
|
|
94
|
+
assert.equal(res.status, 200);
|
|
95
|
+
assert.match(res.headers.get('content-type'), /^text\/plain; charset=utf-8/);
|
|
96
|
+
assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
|
|
97
|
+
assert.match(res.headers.get('content-disposition'), new RegExp(`inline; filename="${job.id}.log"`));
|
|
98
|
+
const rows = text.trimEnd().split('\n');
|
|
99
|
+
assert.equal(rows.length, 6001); // more than one 5000-line page
|
|
100
|
+
assert.match(rows[0], /^\[\d{4}-\d\d-\d\dT[^\]]+\] \[runner\] line 1$/);
|
|
101
|
+
assert.match(rows.at(-1), /\] \[runner\] line 6001$/);
|
|
102
|
+
|
|
103
|
+
// Still complete once the job has finished — no retention of its own
|
|
104
|
+
// (log lines go with the job), and no artifact to fall back to.
|
|
105
|
+
finish();
|
|
106
|
+
assert.equal((await (await get(`/jobs/${job.id}/log.txt`)).text()).trimEnd().split('\n').length, 6001);
|
|
107
|
+
services.db.prepare('DELETE FROM job_logs WHERE job_id = ?').run(job.id);
|
|
108
|
+
assert.equal(await (await get(`/jobs/${job.id}/log.txt`)).text(), '');
|
|
109
|
+
|
|
110
|
+
assert.equal((await get('/jobs/M-99999/log.txt')).status, 404);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('artifacts are gone: old Clients\' uploads are discarded, old Agents get none, the store is removed', async (t) => {
|
|
114
|
+
const { base, services, job } = await setup(t, 3),
|
|
115
|
+
{ resourceId, resourceToken } = services.registry.registerAuto({ clientId: 'c9', name: 'lab-hw-09', type: 'hw', labels: [] }),
|
|
116
|
+
{ token: agentToken } = services.agents.create({ name: 'old-agent', kind: 'cli' });
|
|
117
|
+
services.db.prepare('UPDATE jobs SET resource_id = ? WHERE id = ?').run(resourceId, job.id);
|
|
118
|
+
|
|
119
|
+
// An old Client still uploads its results after the job: accepted (so the
|
|
120
|
+
// job doesn't end in ERROR), stored nowhere.
|
|
121
|
+
const form = new FormData();
|
|
122
|
+
form.append('files', new Blob(['<testsuite tests="1"/>']), 'results.xml');
|
|
123
|
+
const upload = await fetch(`${base}/api/v1/jobs/${job.id}/artifacts`, {
|
|
124
|
+
method: 'POST', headers: { authorization: `Bearer ${resourceToken}` }, body: form
|
|
125
|
+
});
|
|
126
|
+
assert.equal(upload.status, 201);
|
|
127
|
+
assert.deepEqual(await upload.json(), { artifacts: [] });
|
|
128
|
+
assert.equal(fs.existsSync(path.join(path.dirname(services.db.name), 'artifacts')), false);
|
|
129
|
+
|
|
130
|
+
// An old Agent's `thub status` still asks: none.
|
|
131
|
+
const listed = await fetch(`${base}/api/v1/jobs/${job.id}/artifacts`, { headers: { authorization: `Bearer ${agentToken}` } });
|
|
132
|
+
assert.deepEqual(await listed.json(), { artifacts: [] });
|
|
133
|
+
|
|
134
|
+
// No table, no download route.
|
|
135
|
+
assert.equal(services.db.prepare('SELECT COUNT(*) AS n FROM sqlite_master WHERE name = ?').get('artifacts').n, 0);
|
|
136
|
+
assert.equal((await fetch(`${base}/artifacts/download/x.y.z`, { redirect: 'manual' })).status, 302); // just the login redirect
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('upgrade: a leftover <dataDir>/artifacts is deleted on startup', (t) => {
|
|
140
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-legacy-')),
|
|
141
|
+
file = path.join(dir, 'coordinator.json'),
|
|
142
|
+
legacy = path.join(dir, 'data', 'artifacts', 'M-00001');
|
|
143
|
+
fs.mkdirSync(legacy, { recursive: true });
|
|
144
|
+
fs.writeFileSync(path.join(legacy, 'console.log'), 'old');
|
|
145
|
+
fs.writeFileSync(file, JSON.stringify({ dataDir: path.join(dir, 'data'), updates: { checkIntervalMin: 0 } }));
|
|
146
|
+
const log = t.mock.method(console, 'log', () => {});
|
|
147
|
+
buildServices(loadConfig(file));
|
|
148
|
+
assert.equal(fs.existsSync(path.join(dir, 'data', 'artifacts')), false);
|
|
149
|
+
assert.match(log.mock.calls[0].arguments[0], /no longer stores job artifacts/);
|
|
150
|
+
});
|
package/test/scheduler.test.js
CHANGED
|
@@ -25,8 +25,8 @@ const test = require('node:test'),
|
|
|
25
25
|
{ createRegistryService } = require('../src/services/registry'),
|
|
26
26
|
{ createAgentsService } = require('../src/services/agents'),
|
|
27
27
|
{ createGroupsService } = require('../src/services/groups'),
|
|
28
|
-
{ createArtifactsService } = require('../src/services/artifacts'),
|
|
29
28
|
{ createJobsService } = require('../src/services/jobs'),
|
|
29
|
+
{ createLogsService } = require('../src/services/logs'),
|
|
30
30
|
{ createScheduler } = require('../src/services/scheduler'),
|
|
31
31
|
{ createHeartbeatMonitor } = require('../src/services/heartbeat'),
|
|
32
32
|
{ JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common');
|
|
@@ -42,22 +42,19 @@ function buildTestServices(overrides = {}){
|
|
|
42
42
|
registry = createRegistryService(db, { bus, events }),
|
|
43
43
|
agents = createAgentsService(db, { events }),
|
|
44
44
|
groups = createGroupsService(db, { events, registry }),
|
|
45
|
-
artifactsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-test-artifacts-')),
|
|
46
45
|
config = {
|
|
47
46
|
scheduler: { assignAckTimeoutSec: 15, requeueOnLost: true, maxQueuedPerAgent: 20, tickIntervalSec: 3600 },
|
|
48
47
|
jobs: { defaultTimeoutSec: 1800, maxTimeoutSec: 14400 },
|
|
49
48
|
heartbeat: { intervalSec: 10, missedLimit: 3, sweepIntervalSec: 3600 },
|
|
50
|
-
artifactsDir,
|
|
51
49
|
sessionSecret: 'test-secret',
|
|
52
50
|
publicUrl: 'http://localhost:8080',
|
|
53
|
-
artifacts: { linkTtlHours: 1 },
|
|
54
51
|
...overrides
|
|
55
52
|
},
|
|
56
|
-
|
|
57
|
-
jobs = createJobsService(db, { bus, events, registry,
|
|
53
|
+
logs = createLogsService(db, { bus }),
|
|
54
|
+
jobs = createJobsService(db, { bus, events, registry, config }),
|
|
58
55
|
scheduler = createScheduler(db, { bus, events, registry, config }),
|
|
59
56
|
heartbeatMonitor = createHeartbeatMonitor(db, { bus, events, registry, jobs, config });
|
|
60
|
-
return { db, bus, registry, agents, groups,
|
|
57
|
+
return { db, bus, registry, agents, groups, logs, jobs, scheduler, heartbeatMonitor };
|
|
61
58
|
}
|
|
62
59
|
|
|
63
60
|
function registerResource(registry, { name, type, labels = [], groups = [], clientId } = {}){
|
|
@@ -206,15 +203,16 @@ test('admin resetQueue cancels every active job and leaves finished ones alone',
|
|
|
206
203
|
assert.equal(jobs.get(finished.id).state, JOB_STATES.PASSED); // untouched
|
|
207
204
|
});
|
|
208
205
|
|
|
209
|
-
test('admin cleanHistory deletes finished jobs
|
|
210
|
-
const { registry, agents,
|
|
206
|
+
test('admin cleanHistory deletes finished jobs with their log lines, and leaves active jobs alone', () => {
|
|
207
|
+
const { db, registry, agents, logs, jobs } = buildTestServices(),
|
|
211
208
|
{ agent } = agents.create({ name: 'ci', kind: 'ci' });
|
|
212
209
|
registerResource(registry, { name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
213
210
|
|
|
214
211
|
const finished = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() });
|
|
215
212
|
jobs.setState(finished.id, JOB_STATES.PASSED);
|
|
216
|
-
|
|
217
|
-
|
|
213
|
+
logs.appendBatch(finished.id, [{ stream: 'runner', line: 'hello' }]);
|
|
214
|
+
const logLines = (id) => db.prepare('SELECT COUNT(*) AS n FROM job_logs WHERE job_id = ?').get(id).n;
|
|
215
|
+
assert.equal(logLines(finished.id), 1);
|
|
218
216
|
|
|
219
217
|
const active = jobs.create({ agentId: agent.id, source: 'cli', spec: makeSpec() }),
|
|
220
218
|
|
|
@@ -222,7 +220,7 @@ test('admin cleanHistory deletes finished jobs, their artifacts on disk, and lea
|
|
|
222
220
|
|
|
223
221
|
assert.equal(deleted, 1);
|
|
224
222
|
assert.equal(jobs.get(finished.id), undefined);
|
|
225
|
-
assert.equal(
|
|
223
|
+
assert.equal(logLines(finished.id), 0);
|
|
226
224
|
assert.ok(jobs.get(active.id)); // active job untouched
|
|
227
225
|
});
|
|
228
226
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/search.test.js
|
|
3
|
+
* @description Tests: dashboard list search (?q=) — query parsing, in-memory matching and the Jobs page's SQL search
|
|
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 test = require('node:test'),
|
|
17
|
+
assert = require('node:assert/strict'),
|
|
18
|
+
{ EventEmitter } = require('node:events'),
|
|
19
|
+
{ openDb } = require('../src/db'),
|
|
20
|
+
{ createEventsService } = require('../src/services/events'),
|
|
21
|
+
{ createRegistryService } = require('../src/services/registry'),
|
|
22
|
+
{ createAgentsService } = require('../src/services/agents'),
|
|
23
|
+
{ createJobsService } = require('../src/services/jobs'),
|
|
24
|
+
{ parseTerms, normalizeQuery, matches, likeClause, MAX_QUERY } = require('../src/services/search');
|
|
25
|
+
|
|
26
|
+
test('parseTerms splits into lower-case, unique words, capped', () => {
|
|
27
|
+
assert.deepEqual(parseTerms(' Lab-HW nucleo lab-hw '), ['lab-hw', 'nucleo']);
|
|
28
|
+
assert.deepEqual(parseTerms(''), []);
|
|
29
|
+
assert.deepEqual(parseTerms(undefined), []);
|
|
30
|
+
assert.deepEqual(parseTerms(['a']), []); // ?q=a&q=b arrives as an array: ignored
|
|
31
|
+
assert.equal(parseTerms(Array.from({ length: 30 }, (_, i) => `w${i}`).join(' ')).length, 10);
|
|
32
|
+
assert.equal(normalizeQuery(` ${'x'.repeat(500)}`).length, MAX_QUERY - 2);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('matches needs every word somewhere in the fields', () => {
|
|
36
|
+
const fields = ['lab-hw-01', 'IDLE', ['board:nucleo-f401re', 'uart'], null, [['10.0.0.7']]];
|
|
37
|
+
assert.ok(matches(fields, []));
|
|
38
|
+
assert.ok(matches(fields, parseTerms('NUCLEO idle')));
|
|
39
|
+
assert.ok(matches(fields, parseTerms('10.0.0')));
|
|
40
|
+
assert.ok(!matches(fields, parseTerms('nucleo busy')));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('likeClause ANDs the words, ORs the columns and matches % and _ literally', () => {
|
|
44
|
+
const { sql, params } = likeClause(['a', 'b'], ['x', '50%_off']);
|
|
45
|
+
assert.equal(sql, '(COALESCE(a, \'\') LIKE ? ESCAPE \'\\\' OR COALESCE(b, \'\') LIKE ? ESCAPE \'\\\') AND ' +
|
|
46
|
+
'(COALESCE(a, \'\') LIKE ? ESCAPE \'\\\' OR COALESCE(b, \'\') LIKE ? ESCAPE \'\\\')');
|
|
47
|
+
assert.deepEqual(params, ['%x%', '%x%', '%50\\%\\_off%', '%50\\%\\_off%']);
|
|
48
|
+
assert.deepEqual(likeClause(['a'], []), { sql: '', params: [] });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('jobs.page searches ids, users, resources, agents and spec fields — never --env values', () => {
|
|
52
|
+
const db = openDb(':memory:'),
|
|
53
|
+
bus = new EventEmitter(),
|
|
54
|
+
events = createEventsService(db),
|
|
55
|
+
registry = createRegistryService(db, { bus, events }),
|
|
56
|
+
agents = createAgentsService(db, { events }),
|
|
57
|
+
ci = agents.create({ name: 'ci-firmware', kind: 'ci' }).agent,
|
|
58
|
+
dev = agents.create({ name: 'alice-laptop', kind: 'cli' }).agent,
|
|
59
|
+
config = { scheduler: { maxQueuedPerAgent: 100 }, jobs: { defaultTimeoutSec: 60, maxTimeoutSec: 60 } },
|
|
60
|
+
jobs = createJobsService(db, { bus, events, registry, artifacts: {}, config });
|
|
61
|
+
registry.registerAuto({ clientId: 'c', name: 'lab-sw-01', type: 'sw', labels: [] });
|
|
62
|
+
|
|
63
|
+
const a = jobs.create({
|
|
64
|
+
agentId: ci.id,
|
|
65
|
+
source: 'ci',
|
|
66
|
+
spec: {
|
|
67
|
+
target: { type: 'sw' },
|
|
68
|
+
command: './ci/smoke.sh',
|
|
69
|
+
git: { url: 'git@github.com:yourorg/firmware-tests.git', ref: 'release/1.4' },
|
|
70
|
+
meta: { ciJobId: '9165432107' },
|
|
71
|
+
env: { API_TOKEN: 'hunter2-secret' }
|
|
72
|
+
}
|
|
73
|
+
}).id,
|
|
74
|
+
m = jobs.create({
|
|
75
|
+
agentId: dev.id,
|
|
76
|
+
source: 'cli',
|
|
77
|
+
spec: { target: { type: 'sw' }, command: 'make test', user: 'Alice Smith', suite: 'regression_50%' }
|
|
78
|
+
}).id,
|
|
79
|
+
ids = (q, extra = {}) => jobs.page({ q, size: 'all', ...extra }).rows.map((j) => j.id).sort();
|
|
80
|
+
|
|
81
|
+
assert.deepEqual(ids(''), [a, m].sort());
|
|
82
|
+
assert.deepEqual(ids(a.toLowerCase()), [a]); // job id, any case
|
|
83
|
+
assert.deepEqual(ids('alice'), [m]); // user and agent name
|
|
84
|
+
assert.deepEqual(ids('ci-firmware'), [a]); // agent name
|
|
85
|
+
assert.deepEqual(ids('firmware-tests release/1.4'), [a]); // git url + ref, both words
|
|
86
|
+
assert.deepEqual(ids('9165432107'), [a]); // meta
|
|
87
|
+
assert.deepEqual(ids('smoke alice'), []); // words must all match the same job
|
|
88
|
+
assert.deepEqual(ids('regression_50%'), [m]); // % and _ are literal…
|
|
89
|
+
assert.deepEqual(ids('regression_5%'), []); // …not wildcards
|
|
90
|
+
assert.deepEqual(ids('queued', { source: 'cli' }), [m]); // combines with filters
|
|
91
|
+
assert.equal(jobs.page({ q: 'make', size: 'all' }).pagination.total, 1);
|
|
92
|
+
|
|
93
|
+
// --env values are secrets (README §7.2): searching them must find nothing,
|
|
94
|
+
// or the search box would be an oracle for them.
|
|
95
|
+
assert.deepEqual(ids('hunter2'), []);
|
|
96
|
+
assert.deepEqual(ids('API_TOKEN'), []);
|
|
97
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/coordinator/test/session.test.js
|
|
3
|
+
* @description Tests: dashboard sessions — the SQLite session store, sessions surviving a restart, and the Secure cookie flag
|
|
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 test = require('node:test'),
|
|
17
|
+
assert = require('node:assert/strict'),
|
|
18
|
+
fs = require('node:fs'),
|
|
19
|
+
os = require('node:os'),
|
|
20
|
+
path = require('node:path'),
|
|
21
|
+
{ promisify } = require('node:util'),
|
|
22
|
+
{ openDb } = require('../src/db'),
|
|
23
|
+
{ SqliteSessionStore } = require('../src/services/session-store'),
|
|
24
|
+
{ loadConfig } = require('../src/config'),
|
|
25
|
+
{ buildServices, createApp, cookieSecure } = require('../src/server');
|
|
26
|
+
|
|
27
|
+
test('session store: get/set/touch/destroy, expired sessions dropped and pruned', async () => {
|
|
28
|
+
let now = Date.parse('2026-09-30T12:00:00Z');
|
|
29
|
+
const store = new SqliteSessionStore(openDb(':memory:'), { pruneIntervalMs: 0, now: () => now }),
|
|
30
|
+
get = promisify(store.get.bind(store)),
|
|
31
|
+
set = promisify(store.set.bind(store)),
|
|
32
|
+
touch = promisify(store.touch.bind(store)),
|
|
33
|
+
destroy = promisify(store.destroy.bind(store)),
|
|
34
|
+
length = promisify(store.length.bind(store)),
|
|
35
|
+
sess = (minutes) => ({ cookie: { expires: new Date(now + minutes * 60_000).toISOString() }, user: { id: 'u1' } });
|
|
36
|
+
|
|
37
|
+
await set('a', sess(60));
|
|
38
|
+
await set('b', sess(5));
|
|
39
|
+
assert.deepEqual((await get('a')).user, { id: 'u1' });
|
|
40
|
+
assert.equal(await get('nope'), null);
|
|
41
|
+
assert.equal(await length(), 2);
|
|
42
|
+
|
|
43
|
+
now += 10 * 60_000; // b's 5 minutes are up
|
|
44
|
+
assert.equal(await get('b'), null); // expired: gone on read
|
|
45
|
+
await touch('a', sess(60)); // activity moves a's expiry out
|
|
46
|
+
now += 55 * 60_000;
|
|
47
|
+
assert.ok(await get('a'));
|
|
48
|
+
|
|
49
|
+
await set('c', sess(1));
|
|
50
|
+
now += 2 * 60_000;
|
|
51
|
+
assert.equal(store.prune(), 1); // c, never read again, swept
|
|
52
|
+
assert.equal(await length(), 1);
|
|
53
|
+
|
|
54
|
+
await destroy('a'); // logout
|
|
55
|
+
assert.equal(await get('a'), null);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('cookieSecure: Secure for an https publicUrl, per-request otherwise, or as configured', () => {
|
|
59
|
+
assert.equal(cookieSecure({ publicUrl: 'https://thub.example.com', session: { secureCookie: 'auto' } }), true);
|
|
60
|
+
assert.equal(cookieSecure({ publicUrl: 'http://localhost:8080', session: { secureCookie: 'auto' } }), 'auto');
|
|
61
|
+
assert.equal(cookieSecure({ publicUrl: 'https://thub.example.com', session: { secureCookie: false } }), false);
|
|
62
|
+
assert.equal(cookieSecure({ publicUrl: 'http://lab', session: { secureCookie: true } }), true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// --- over HTTP ------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function writeConfig(dir, extra = {}){
|
|
68
|
+
const file = path.join(dir, 'coordinator.json');
|
|
69
|
+
fs.writeFileSync(file, JSON.stringify({
|
|
70
|
+
dataDir: path.join(dir, 'data'), sessionSecret: 'test-secret-test-secret', updates: { checkIntervalMin: 0 }, ...extra
|
|
71
|
+
}));
|
|
72
|
+
return file;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function start(t, file){
|
|
76
|
+
const config = loadConfig(file),
|
|
77
|
+
services = buildServices(config),
|
|
78
|
+
server = createApp(config, services).listen(0, '127.0.0.1');
|
|
79
|
+
await new Promise((r) => server.on('listening', r));
|
|
80
|
+
const stop = () => new Promise((r) => {
|
|
81
|
+
server.closeAllConnections?.();
|
|
82
|
+
server.close(r);
|
|
83
|
+
});
|
|
84
|
+
t.after(stop);
|
|
85
|
+
return { base: `http://127.0.0.1:${server.address().port}`, services, stop };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const login = (base, headers = {}) => fetch(`${base}/login`, {
|
|
89
|
+
method: 'POST', headers, body: new URLSearchParams({ username: 'admin', password: 'pw' }), redirect: 'manual'
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a signed-in session survives a Coordinator restart', async (t) => {
|
|
93
|
+
const file = writeConfig(fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sess-'))),
|
|
94
|
+
first = await start(t, file);
|
|
95
|
+
first.services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
|
|
96
|
+
const cookie = (await login(first.base)).headers.get('set-cookie').split(';')[0];
|
|
97
|
+
assert.equal((await fetch(`${first.base}/jobs`, { headers: { cookie }, redirect: 'manual' })).status, 200);
|
|
98
|
+
await first.stop();
|
|
99
|
+
|
|
100
|
+
const second = await start(t, file); // same database, new process state
|
|
101
|
+
assert.equal((await fetch(`${second.base}/jobs`, { headers: { cookie }, redirect: 'manual' })).status, 200);
|
|
102
|
+
|
|
103
|
+
await fetch(`${second.base}/logout`, { method: 'POST', headers: { cookie }, redirect: 'manual' });
|
|
104
|
+
assert.equal((await fetch(`${second.base}/jobs`, { headers: { cookie }, redirect: 'manual' })).status, 302);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('https publicUrl: the session cookie is Secure; plain HTTP gets an explanation, not a login loop', async (t) => {
|
|
108
|
+
const file = writeConfig(fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sess-')), { publicUrl: 'https://thub.example.com' }),
|
|
109
|
+
{ base, services } = await start(t, file);
|
|
110
|
+
services.adminUsers.create({ username: 'admin', password: 'pw', role: 'admin' });
|
|
111
|
+
|
|
112
|
+
// Behind the (loopback, trusted) reverse proxy terminating TLS.
|
|
113
|
+
const viaProxy = await login(base, { 'X-Forwarded-Proto': 'https' }),
|
|
114
|
+
setCookie = viaProxy.headers.get('set-cookie');
|
|
115
|
+
assert.equal(viaProxy.status, 302);
|
|
116
|
+
assert.match(setCookie, /; Secure/);
|
|
117
|
+
assert.match(setCookie, /; HttpOnly/);
|
|
118
|
+
assert.match(setCookie, /; SameSite=Lax/);
|
|
119
|
+
|
|
120
|
+
// The proxy forgot X-Forwarded-Proto: no cookie could come back — say so.
|
|
121
|
+
const plain = await login(base);
|
|
122
|
+
assert.equal(plain.status, 400);
|
|
123
|
+
assert.equal(plain.headers.get('set-cookie'), null);
|
|
124
|
+
assert.match(await plain.text(), /Signing in needs HTTPS/);
|
|
125
|
+
assert.match(await (await fetch(`${base}/login`)).text(), /X-Forwarded-Proto: https/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('config: session.secureCookie must be "auto", true or false', () => {
|
|
129
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'thub-sess-'));
|
|
130
|
+
assert.equal(loadConfig(writeConfig(dir)).session.secureCookie, 'auto');
|
|
131
|
+
assert.throws(() => loadConfig(writeConfig(dir, { session: { secureCookie: 'yes' } })), /session\.secureCookie must be/);
|
|
132
|
+
});
|
package/test/views.test.js
CHANGED
|
@@ -33,3 +33,33 @@ test('every view compiles', () => {
|
|
|
33
33
|
assert.doesNotThrow(() => pug.compileFile(file), `${path.relative(VIEWS, file)}`);
|
|
34
34
|
}
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
// The help page is mostly static text split over partials sharing mixins
|
|
38
|
+
// (views/help/_mixins.pug): compiling each file alone doesn't catch a missing
|
|
39
|
+
// mixin or a broken include, so render the whole page.
|
|
40
|
+
test('help page renders every section with this Coordinator\'s URL', () => {
|
|
41
|
+
const html = pug.renderFile(path.join(VIEWS, 'help', 'index.pug'), {
|
|
42
|
+
user: { username: 'viewer', role: 'viewer', theme: 'auto' },
|
|
43
|
+
updates: {},
|
|
44
|
+
messages: [],
|
|
45
|
+
currentPath: '/help',
|
|
46
|
+
active: 'help',
|
|
47
|
+
title: 'Help',
|
|
48
|
+
coordinatorVersion: '1.2.3',
|
|
49
|
+
commonVersion: '1.0.0',
|
|
50
|
+
fmtDate: () => '',
|
|
51
|
+
coordinatorUrl: 'https://thub.example.test'
|
|
52
|
+
});
|
|
53
|
+
for (const id of ['overview', 'use-cases', 'quick-start', 'coordinator', 'agent-setup', 'client-setup',
|
|
54
|
+
'client-machines', 'docker', 'git', 'agent-cli', 'env', 'ci', 'troubleshooting']){
|
|
55
|
+
assert.match(html, new RegExp(`<section[^>]* id="${id}"`), `section #${id}`);
|
|
56
|
+
assert.match(html, new RegExp(`href="#${id}"`), `TOC entry for #${id}`);
|
|
57
|
+
}
|
|
58
|
+
assert.match(html, /thub config set url {3}https:\/\/thub\.example\.test/);
|
|
59
|
+
assert.match(html, /<a class="nav-link d-flex align-items-center active" href="\/help" aria-current="page"/);
|
|
60
|
+
// Troubleshooting: commands and parameters are <code>, placeholders escaped.
|
|
61
|
+
assert.match(html, /<code>journalctl -u thub-client@<instance> -f<\/code>/);
|
|
62
|
+
assert.match(html, /<span>The job is rejected with <code>422<\/code><\/span>/);
|
|
63
|
+
// Code placeholders are escaped, never parsed as tags.
|
|
64
|
+
assert.doesNotMatch(html, /<(url|jobId|groupId|resourceId|work|ref)>/);
|
|
65
|
+
});
|
package/views/admin/agents.pug
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
extends ../layout
|
|
2
2
|
include ../mixins/updates
|
|
3
|
+
include ../mixins/list-controls
|
|
3
4
|
|
|
4
5
|
block content
|
|
5
6
|
.d-flex.justify-content-between.align-items-center.mb-3
|
|
6
7
|
h1.h4.mb-0 Agents (CI / developers)
|
|
7
8
|
button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addAgentModal") Register agent
|
|
8
|
-
.mb-3
|
|
9
|
-
+updatesToolbar('agent', 'agents',
|
|
9
|
+
.d-flex.flex-wrap.align-items-center.gap-2.mb-3
|
|
10
|
+
+updatesToolbar('agent', 'agents', list.pageUrl())
|
|
11
|
+
.d-flex.flex-wrap.align-items-center.gap-2.mb-3
|
|
12
|
+
+searchForm(q, 'Search agents: name, kind, version, status', list.urlFor({ q: '' }))
|
|
13
|
+
.ms-auto
|
|
14
|
+
+pageSizePicker(list)
|
|
10
15
|
|
|
11
16
|
if newToken
|
|
12
17
|
.alert.alert-warning
|
|
@@ -14,23 +19,26 @@ block content
|
|
|
14
19
|
|
|
|
15
20
|
code= newToken
|
|
16
21
|
|
|
22
|
+
div(data-live="list-toolbar")
|
|
23
|
+
+listToolbar(list, 'agents')
|
|
24
|
+
|
|
17
25
|
table.table.table-hover.align-middle
|
|
18
26
|
thead
|
|
19
27
|
tr
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
+sortHeader(list, 'name', 'Name')
|
|
29
|
+
+sortHeader(list, 'kind', 'Kind')
|
|
30
|
+
+sortHeader(list, 'version', 'Version', 'desc')
|
|
31
|
+
+sortHeader(list, 'created', 'Created', 'desc')
|
|
32
|
+
+sortHeader(list, 'used', 'Last used', 'desc')
|
|
33
|
+
+sortHeader(list, 'status', 'Status')
|
|
26
34
|
th
|
|
27
|
-
tbody
|
|
35
|
+
tbody(data-live="rows")
|
|
28
36
|
each a in agents
|
|
29
37
|
tr(class=a.revoked_at ? 'opacity-50' : '')
|
|
30
38
|
td= a.name
|
|
31
39
|
td.text-uppercase= a.kind
|
|
32
40
|
//- A revoked token can't pick an update up: no button.
|
|
33
|
-
td: +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`,
|
|
41
|
+
td: +versionWithUpdate(a.version, updates.latest.agent, a.update_to, `/admin/agents/${a.id}/update`, list.pageUrl(), !a.revoked_at)
|
|
34
42
|
td= fmtDate(a.created_at)
|
|
35
43
|
td= fmtDate(a.last_used_at, 'never')
|
|
36
44
|
td= a.revoked_at ? 'revoked' : 'active'
|
|
@@ -50,12 +58,14 @@ block content
|
|
|
50
58
|
data-confirm-title="Revoke agent token?"
|
|
51
59
|
data-confirm-ok="Revoke"
|
|
52
60
|
)
|
|
61
|
+
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
53
62
|
button.btn.btn-sm.btn-outline-danger(type="submit") Revoke
|
|
54
63
|
|
|
55
64
|
.modal(tabindex="-1" id=`editAgentModal-${a.id}` aria-labelledby=`editAgentTitle-${a.id}`)
|
|
56
65
|
.modal-dialog
|
|
57
66
|
.modal-content
|
|
58
67
|
form(method="post" action=`/admin/agents/${a.id}/rename`)
|
|
68
|
+
input(type="hidden" name="returnTo" value=list.pageUrl())
|
|
59
69
|
.modal-header
|
|
60
70
|
h5.modal-title(id=`editAgentTitle-${a.id}`) Edit agent
|
|
61
71
|
button.btn-close(type="button" data-bs-dismiss="modal" aria-label="Close")
|
|
@@ -66,6 +76,14 @@ block content
|
|
|
66
76
|
.modal-footer
|
|
67
77
|
button.btn.btn-secondary(type="button" data-bs-dismiss="modal") Cancel
|
|
68
78
|
button.btn.btn-primary(type="submit") Save
|
|
79
|
+
else
|
|
80
|
+
tr
|
|
81
|
+
td.text-body-secondary(colspan="7")
|
|
82
|
+
if q
|
|
83
|
+
| No agent matches “#{q}”.
|
|
84
|
+
a(href=list.urlFor({ q: '' })) Show all agents
|
|
85
|
+
else
|
|
86
|
+
| No agents yet.
|
|
69
87
|
|
|
70
88
|
.modal(tabindex="-1" id="addAgentModal")
|
|
71
89
|
.modal-dialog
|
package/views/groups/list.pug
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
extends ../layout
|
|
2
|
+
include ../mixins/list-controls
|
|
2
3
|
|
|
3
4
|
block content
|
|
4
5
|
.d-flex.justify-content-between.align-items-center.mb-3
|
|
@@ -6,10 +7,18 @@ block content
|
|
|
6
7
|
if canManage
|
|
7
8
|
button.btn.btn-primary.btn-sm(type="button" data-bs-toggle="modal" data-bs-target="#addGroupModal") Add group
|
|
8
9
|
|
|
9
|
-
p.text-body-secondary.small.
|
|
10
|
+
p.text-body-secondary.small: i.
|
|
10
11
|
A job can be constrained to run only on resources in a group with `thub run --group <id>` (§13.1).
|
|
11
12
|
A resource joins a group by listing its id under `groups` in the Client's own config — membership isn't set here.
|
|
12
13
|
|
|
14
|
+
if totalCount
|
|
15
|
+
.d-flex.flex-wrap.align-items-center.gap-2.mb-2
|
|
16
|
+
+searchForm(q, 'Search groups: name, id, comment', '/groups')
|
|
17
|
+
if q
|
|
18
|
+
span.small.text-body-secondary
|
|
19
|
+
| #{groups.length} of #{totalCount} groups
|
|
20
|
+
+searchNote(q)
|
|
21
|
+
|
|
13
22
|
if groups.length
|
|
14
23
|
table.table.table-hover.align-middle
|
|
15
24
|
thead
|
|
@@ -77,6 +86,10 @@ block content
|
|
|
77
86
|
i.bi.bi-clipboard
|
|
78
87
|
.modal-footer
|
|
79
88
|
button.btn.btn-primary(type="submit") Save
|
|
89
|
+
else if q
|
|
90
|
+
p.text-body-secondary
|
|
91
|
+
| No group matches “#{q}”.
|
|
92
|
+
a(href="/groups") Show all groups
|
|
80
93
|
else
|
|
81
94
|
p.text-body-secondary No groups yet.
|
|
82
95
|
|