@andrian.yablonskyy/thub-coordinator 1.0.29 → 1.0.31

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.
@@ -0,0 +1,8 @@
1
+ -- "Connected USB devices" (resource card tab, README §10): the Client runs
2
+ -- `lsusb` only when an admin presses Refresh — a scan-usb command with its
3
+ -- next heartbeat — and posts the output back. `usb_scan` is the last result
4
+ -- (JSON {output, error, at}); `usb_scan_requested_at`/`_request_id` the
5
+ -- pending request, so the tab can tell a fresh result from an old one.
6
+ ALTER TABLE resources ADD COLUMN usb_scan TEXT;
7
+ ALTER TABLE resources ADD COLUMN usb_scan_requested_at TEXT;
8
+ ALTER TABLE resources ADD COLUMN usb_scan_request_id TEXT;
@@ -66,6 +66,27 @@ const DEV_AGENT_NAME = 'virtual-agent',
66
66
  [0.4, 'ci', 'sw', 'PASSED', 201, null],
67
67
  [0.1, 'cli', 'sw', 'PASSED', 73, 'alice']
68
68
  ],
69
+ // What `lsusb -tvv` prints on a lab host with a DUT slot plugged in.
70
+ VIRTUAL_LSUSB = [
71
+ '/: Bus 001.Port 001: Dev 001, Class=root_hub, Driver=xhci_hcd/12p, 480M',
72
+ ' ID 1d6b:0002 Linux Foundation 2.0 root hub',
73
+ ' /sys/bus/usb/devices/usb1 /dev/bus/usb/001/001',
74
+ ' |__ Port 003: Dev 002, If 0, Class=Hub, Driver=hub/4p, 480M',
75
+ ' ID 05e3:0610 Genesys Logic, Inc. Hub',
76
+ ' /sys/bus/usb/devices/1-3 /dev/bus/usb/001/002',
77
+ ' |__ Port 001: Dev 004, If 0, Class=Vendor Specific Class, Driver=, 12M',
78
+ ' ID 0483:3748 STMicroelectronics ST-LINK/V2',
79
+ ' /sys/bus/usb/devices/1-3.1 /dev/bus/usb/001/004',
80
+ ' |__ Port 002: Dev 005, If 0, Class=Vendor Specific Class, Driver=ftdi_sio, 12M',
81
+ ' ID 0403:6001 Future Technology Devices International, Ltd FT232 Serial (UART) IC',
82
+ ' /sys/bus/usb/devices/1-3.2 /dev/bus/usb/001/005',
83
+ ' |__ Port 003: Dev 006, If 0, Class=Communications, Driver=cdc_acm, 12M',
84
+ ' ID 0483:5740 STMicroelectronics Virtual COM Port',
85
+ ' /sys/bus/usb/devices/1-3.3 /dev/bus/usb/001/006',
86
+ '/: Bus 002.Port 001: Dev 001, Class=root_hub, Driver=xhci_hcd/4p, 5000M',
87
+ ' ID 1d6b:0003 Linux Foundation 3.0 root hub',
88
+ ' /sys/bus/usb/devices/usb2 /dev/bus/usb/002/001'
89
+ ].join('\n'),
69
90
  LOG_SCRIPT = [
70
91
  ['runner', 'downloading app.bin (virtual)'],
71
92
  ['runner', 'cloning yourorg/firmware-tests at main (virtual)'],
@@ -167,6 +188,22 @@ function startVirtualClients(services, config){
167
188
  labels: def.labels,
168
189
  hostInfo: { hostname: `${def.name}.virtual`, platform: 'linux', addresses: [{ iface: 'eth0', address: def.address, family: 'IPv4' }], timeZone: 'UTC' },
169
190
  capabilities: { ...def.capabilities, rebootSupported: false },
191
+ // Its "config file": the hw/sw section, as a real Client reports it.
192
+ config: def.type === 'sw'
193
+ ? {
194
+ image: def.capabilities.sw.image,
195
+ allowDockerHub: def.capabilities.sw.allowDockerHub,
196
+ allowJobImages: def.capabilities.sw.allowJobImages,
197
+ cpus: def.capabilities.sw.cpus,
198
+ memory: def.capabilities.sw.memory
199
+ }
200
+ : {
201
+ stlinks: [{ index: 1, serial: '066DFF485457725187092834', devpath: '3.3.4.3.1' }],
202
+ uarts: [{ index: 1, baudRate: 115200, devpath: '3.3.3.2' }],
203
+ usbs: [{ index: 1 }],
204
+ relays: [{ channel: 0, baseUrl: 'http://localhost:3000' }],
205
+ power: { method: 'relay' }
206
+ },
170
207
  remoteAddr: '127.0.0.1',
171
208
  clientVersion: version
172
209
  });
@@ -186,9 +223,14 @@ function startVirtualClients(services, config){
186
223
  clientVersion: version,
187
224
  hostUptimeSec: Math.round((Date.now() - bootedAt) / 1000),
188
225
  activity: { state: c.activity.state, jobId: c.activity.jobId, durationSec: Math.round((Date.now() - c.activity.since) / 1000) },
189
- rebootSchedule: registry.get(resourceId)?.reboot_schedule || null // "applies" whatever is saved
226
+ rebootSchedule: registry.get(resourceId)?.reboot_schedule || null, // "applies" whatever is saved
227
+ configRevision: registry.get(resourceId)?.config_revision || 0 // ...and any saved config
190
228
  });
191
- services.commands.drain(resourceId); // nothing real to act on
229
+ for (const command of services.commands.drain(resourceId)){
230
+ if (command.command === 'scan-usb'){ // a plausible lsusb for the dashboard
231
+ registry.storeUsbScan(resourceId, { requestId: command.requestId, output: VIRTUAL_LSUSB, error: null });
232
+ }
233
+ }
192
234
  }
193
235
 
194
236
  const beat = () => {
package/src/server.js CHANGED
@@ -90,6 +90,14 @@ function buildServices(config){
90
90
  };
91
91
  }
92
92
 
93
+ function commonPath(){
94
+ return path.dirname(require.resolve('@andrian.yablonskyy/thub-common/package.json'));
95
+ }
96
+
97
+ function commonVersion(){
98
+ return require('@andrian.yablonskyy/thub-common/package.json').version;
99
+ }
100
+
93
101
  function createApp(config, services){
94
102
  const app = express();
95
103
  app.set('view engine', 'pug');
@@ -98,6 +106,10 @@ function createApp(config, services){
98
106
  app.locals.services = services;
99
107
  // Shown in every dashboard page's footer (layout.pug).
100
108
  app.locals.coordinatorVersion = require('../package.json').version;
109
+ // The thub-common this process actually loaded (it validates job specs):
110
+ // installs keep the one they got, so it can lag behind — shown on the
111
+ // dashboard and logged at startup to make that visible.
112
+ app.locals.commonVersion = commonVersion();
101
113
 
102
114
  app.use(express.json({ limit: '2mb' }));
103
115
  app.use(express.urlencoded({ extended: true }));
@@ -162,6 +174,7 @@ function start(configPath){
162
174
  const server = app.listen(config.port, config.host, () => {
163
175
  console.log(`Config: ${config.configPath || '(built-in defaults)'}; data: ${config.dataDir}`);
164
176
  console.log(`TestHub Coordinator listening on http://${config.host}:${config.port}`);
177
+ console.log(`Coordinator v${require('../package.json').version}, thub-common v${commonVersion()} (${commonPath()})`);
165
178
  });
166
179
 
167
180
  return { app, server, services, config };
@@ -14,7 +14,11 @@
14
14
  'use strict';
15
15
 
16
16
  const { validateJobSpec, JOB_STATES, ACTIVE_JOB_STATES, TERMINAL_JOB_STATES, RESOURCE_STATES } = require('@andrian.yablonskyy/thub-common'),
17
- { paginate } = require('./list-prefs');
17
+ { paginate } = require('./list-prefs'),
18
+ // Named in spec errors: a field the Agent sends but this thub-common
19
+ // doesn't know means the Coordinator needs an update.
20
+ VERSIONS = `Coordinator v${require('../../package.json').version}, ` +
21
+ `thub-common v${require('@andrian.yablonskyy/thub-common/package.json').version}`;
18
22
 
19
23
  function rowToJob(row){
20
24
  if (!row){
@@ -131,7 +135,7 @@ function createJobsService(db, { bus, events, registry, artifacts, config }){
131
135
  rawSpec?.priority === undefined ? { ...rawSpec, priority: DEFAULT_PRIORITY[source] } : rawSpec
132
136
  );
133
137
  if (!valid){
134
- throw Object.assign(new Error(`Invalid job spec: ${errors.join('; ')}`), { status: 400 });
138
+ throw Object.assign(new Error(`Invalid job spec (${VERSIONS}): ${errors.join('; ')}`), { status: 400 });
135
139
  }
136
140
  spec.source = source;
137
141
 
@@ -14,7 +14,7 @@
14
14
  'use strict';
15
15
 
16
16
  const { v4: uuid } = require('uuid'),
17
- { RESOURCE_STATES, BUSY_SOURCES, ACTIVE_JOB_STATES, compareVersions, parseCron } = require('@andrian.yablonskyy/thub-common'),
17
+ { RESOURCE_STATES, BUSY_SOURCES, ACTIVE_JOB_STATES, compareVersions, parseCron, validateClientConfig } = require('@andrian.yablonskyy/thub-common'),
18
18
  { generateToken, hashToken } = require('./tokens');
19
19
 
20
20
  function rowToResource(row){
@@ -27,6 +27,9 @@ function rowToResource(row){
27
27
  group_ids: JSON.parse(row.group_ids || '[]'),
28
28
  host_info: row.host_info ? JSON.parse(row.host_info) : null,
29
29
  capabilities: row.capabilities ? JSON.parse(row.capabilities) : null,
30
+ client_config: row.client_config ? JSON.parse(row.client_config) : null,
31
+ config_desired: row.config_desired ? JSON.parse(row.config_desired) : null,
32
+ usb_scan: row.usb_scan ? JSON.parse(row.usb_scan) : null,
30
33
  activity: row.activity ? JSON.parse(row.activity) : null
31
34
  };
32
35
  }
@@ -187,7 +190,25 @@ function createRegistryService(db, { bus, events }){
187
190
  // this registration is allowed to reclaim it. Anything else (IDLE, BUSY,
188
191
  // MAINTENANCE, REGISTERED-but-just-created) means a process might still
189
192
  // be actively using that identity, so it stays a hard conflict.
190
- function registerAuto({ clientId, name, type, labels = [], groups = [], hostInfo, capabilities, remoteAddr, clientVersion = null }){
193
+ // Plus `config`: the editable part of the Client's config (its hw or sw
194
+ // section, secrets excluded) — the starting point for the resource card's
195
+ // Config tab. Stored as reported, if it's a plain object of sane size.
196
+ // A dashboard rename (name_override) wins over the name the Client
197
+ // presents; that one is kept as reported_name.
198
+ function registerAuto({ config, ...registration }){
199
+ const known = registration.clientId ? getByClientId(registration.clientId) : null,
200
+ result = registerAutoInner({ ...registration, name: known?.name_override || registration.name });
201
+ db.prepare('UPDATE resources SET reported_name = ? WHERE id = ?').run(registration.name, result.resourceId);
202
+ if (config && typeof config === 'object' && !Array.isArray(config)){
203
+ const json = JSON.stringify(config);
204
+ if (json.length <= 64 * 1024){
205
+ db.prepare('UPDATE resources SET client_config = ? WHERE id = ?').run(json, result.resourceId);
206
+ }
207
+ }
208
+ return result;
209
+ }
210
+
211
+ function registerAutoInner({ clientId, name, type, labels = [], groups = [], hostInfo, capabilities, remoteAddr, clientVersion = null }){
191
212
  const resourceToken = generateToken('res'),
192
213
  remote = normalizeRemoteAddr(remoteAddr);
193
214
  hostInfo = {
@@ -307,7 +328,10 @@ function createRegistryService(db, { bus, events }){
307
328
  // Reconcile status from a heartbeat's self-reported state (§5.1).
308
329
  function heartbeat(
309
330
  resourceId,
310
- { state, activeJobId, localLock, metrics, addresses, remoteAddr, clientVersion = null, hostUptimeSec, activity, rebootSchedule } = {}
331
+ {
332
+ state, activeJobId, localLock, metrics, addresses, remoteAddr, clientVersion = null, hostUptimeSec, activity, rebootSchedule,
333
+ configRevision, configError
334
+ } = {}
311
335
  ){
312
336
  const resource = get(resourceId);
313
337
  if (!resource){
@@ -340,6 +364,16 @@ function createRegistryService(db, { bus, events }){
340
364
  busyReason = null;
341
365
  }
342
366
 
367
+ // The dashboard config revision the Client has applied, and why it last
368
+ // refused one — older Clients report neither.
369
+ if (Number.isInteger(configRevision)){
370
+ db.prepare('UPDATE resources SET config_applied_revision = ?, config_error = ? WHERE id = ?').run(
371
+ configRevision,
372
+ typeof configError === 'string' && configError ? configError.slice(0, 1000) : null,
373
+ resourceId
374
+ );
375
+ }
376
+
343
377
  // The reboot schedule the Client applies (null: none); older Clients
344
378
  // don't report one at all — then leave what's stored.
345
379
  if (rebootSchedule !== undefined){
@@ -539,6 +573,95 @@ function createRegistryService(db, { bus, events }){
539
573
  return db.prepare('SELECT * FROM resources WHERE remove_requested_at IS NOT NULL').all().map(rowToResource);
540
574
  }
541
575
 
576
+ // "Connected USB devices" tab: Refresh asks the Client to run `lsusb` on
577
+ // its next heartbeat (a scan-usb command, delivered by the caller's bus
578
+ // via commands). Only while it's connected — it would otherwise run
579
+ // whenever it next reconnects.
580
+ function requestUsbScan(resourceId, { by } = {}){
581
+ const r = get(resourceId);
582
+ if (!r){
583
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
584
+ }
585
+ if (r.status === RESOURCE_STATES.OUT_OF_SERVICE || !r.last_heartbeat_at){
586
+ throw Object.assign(new Error(`${r.name} is offline — it can only be scanned while it's connected`), { status: 409 });
587
+ }
588
+ const requestId = uuid(),
589
+ requestedAt = new Date().toISOString();
590
+ db.prepare('UPDATE resources SET usb_scan_requested_at = ?, usb_scan_request_id = ? WHERE id = ?').run(requestedAt, requestId, resourceId);
591
+ bus.emit('command', { resourceId, command: 'scan-usb', requestId });
592
+ events.record('resource', resourceId, 'resource.usb_scan_requested', { by });
593
+ return { requestId, requestedAt };
594
+ }
595
+
596
+ // The Client's answer (POST /resources/:id/usb-scan): kept only for the
597
+ // request still pending, capped in size.
598
+ function storeUsbScan(resourceId, { requestId, output, error }){
599
+ const r = get(resourceId);
600
+ if (!r || !requestId || requestId !== r.usb_scan_request_id){
601
+ return false;
602
+ }
603
+ const scan = {
604
+ output: typeof output === 'string' ? output.slice(0, 64 * 1024) : '',
605
+ error: typeof error === 'string' && error ? error.slice(0, 1000) : null,
606
+ at: new Date().toISOString()
607
+ };
608
+ db.prepare('UPDATE resources SET usb_scan = ?, usb_scan_request_id = NULL WHERE id = ?').run(JSON.stringify(scan), resourceId);
609
+ return true;
610
+ }
611
+
612
+ // Rename from the dashboard (resource card). The new name must be free;
613
+ // it's kept across the Client's re-registrations (name_override). An
614
+ // empty name — or the Client's own — goes back to the Client's own name.
615
+ // Jobs reference the resource id, so their history follows the rename.
616
+ function rename(resourceId, newName, { by } = {}){
617
+ const r = get(resourceId);
618
+ if (!r){
619
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
620
+ }
621
+ const wanted = typeof newName === 'string' ? newName.trim() : '',
622
+ name = wanted || r.reported_name || r.name;
623
+ if (!/^[A-Za-z0-9._@+-]{1,64}$/.test(name)){
624
+ throw Object.assign(new Error('A Client name is 1-64 letters, digits or . _ @ + - (no spaces)'), { status: 400 });
625
+ }
626
+ const owner = getByName(name);
627
+ if (owner && owner.id !== resourceId){
628
+ throw Object.assign(new Error(`The name "${name}" is already used by another Client (${owner.status})`), { status: 409 });
629
+ }
630
+ const override = name === r.reported_name ? null : name;
631
+ db.prepare('UPDATE resources SET name = ?, name_override = ? WHERE id = ?').run(name, override, resourceId);
632
+ if (name !== r.name){
633
+ events.record('resource', resourceId, 'resource.renamed', { from: r.name, to: name, by });
634
+ }
635
+ return get(resourceId);
636
+ }
637
+
638
+ // Client capabilities edited on the resource card's Config tab: validated
639
+ // like the Client itself will, stored at the next revision, and delivered
640
+ // with its next heartbeat (pendingConfig).
641
+ function setClientConfig(resourceId, config, { by } = {}){
642
+ const r = get(resourceId);
643
+ if (!r){
644
+ throw Object.assign(new Error('Unknown resource'), { status: 404 });
645
+ }
646
+ const { valid, errors } = validateClientConfig(r.type, config);
647
+ if (!valid){
648
+ throw Object.assign(new Error(`Invalid ${r.type.toUpperCase()} config: ${errors.join('; ')}`), { status: 400 });
649
+ }
650
+ db.prepare('UPDATE resources SET config_desired = ?, config_revision = config_revision + 1, config_error = NULL WHERE id = ?')
651
+ .run(JSON.stringify(config), resourceId);
652
+ events.record('resource', resourceId, 'resource.config_saved', { by, revision: get(resourceId).config_revision });
653
+ return get(resourceId);
654
+ }
655
+
656
+ // { revision, type, config } while the Client hasn't applied the saved one.
657
+ function pendingConfig(resourceId){
658
+ const r = get(resourceId);
659
+ if (!r || !r.config_desired || r.config_applied_revision >= r.config_revision){
660
+ return null;
661
+ }
662
+ return { revision: r.config_revision, type: r.type, config: r.config_desired };
663
+ }
664
+
542
665
  // Scheduled host reboot (resource card): a cron expression, validated
543
666
  // here; '' or null clears it. Delivered on the Client's next heartbeat.
544
667
  function setRebootSchedule(resourceId, cron, { by } = {}){
@@ -603,6 +726,11 @@ function createRegistryService(db, { bus, events }){
603
726
  remove,
604
727
  setRebootSchedule,
605
728
  pendingRebootSchedule,
729
+ setClientConfig,
730
+ pendingConfig,
731
+ rename,
732
+ requestUsbScan,
733
+ storeUsbScan,
606
734
  requestRemoval,
607
735
  pendingRemovals,
608
736
  findIdleCandidates,
package/src/web/routes.js CHANGED
@@ -425,6 +425,74 @@ function createWebRouter({ services, config }){
425
425
  res.redirect(returnTo(req, '/resources'));
426
426
  });
427
427
 
428
+ // "Connected USB devices" tab (public/js/usb-scan.js): Refresh queues a
429
+ // scan-usb for the Client's next heartbeat; the tab then polls the GET
430
+ // until the answer is in. JSON both ways — the modal stays open.
431
+ function usbScanView(req, r){
432
+ const tz = req.session.user.timezone || 'UTC',
433
+ scan = r.usb_scan;
434
+ return {
435
+ pending: Boolean(r.usb_scan_request_id),
436
+ requestedAt: r.usb_scan_requested_at,
437
+ scan: scan ? { ...scan, atText: formatDateTime(scan.at, { timeZone: tz }) } : null
438
+ };
439
+ }
440
+
441
+ router.post('/resources/:id/usb-scan', requireAdminRole, (req, res) => {
442
+ try {
443
+ services.registry.requestUsbScan(req.params.id, { by: req.session.user.username });
444
+ res.json(usbScanView(req, services.registry.get(req.params.id)));
445
+ }
446
+ catch (err){
447
+ res.status(err.status || 500).json({ error: err.message });
448
+ }
449
+ });
450
+
451
+ router.get('/resources/:id/usb-scan', (req, res) => {
452
+ const r = services.registry.get(req.params.id);
453
+ if (!r){
454
+ return res.status(404).json({ error: 'Unknown resource' });
455
+ }
456
+ res.set('Cache-Control', 'no-store').json(usbScanView(req, r));
457
+ });
458
+
459
+ // Rename (resource card, Name row); empty = back to the Client's own name.
460
+ router.post('/resources/:id/rename', requireAdminRole, (req, res) => {
461
+ try {
462
+ const before = services.registry.get(req.params.id)?.name,
463
+ r = services.registry.rename(req.params.id, req.body.name, { by: req.session.user.username });
464
+ if (r.name !== before){
465
+ flash(req, 'success', `Renamed "${before}" to "${r.name}".` + (r.name_override ? '' : '\nThat\'s the Client\'s own name again.'));
466
+ }
467
+ }
468
+ catch (err){
469
+ flash(req, 'danger', err.message);
470
+ }
471
+ res.redirect(returnTo(req, '/resources'));
472
+ });
473
+
474
+ // Capabilities (resource card, Config tab): public/js/client-config.js
475
+ // sends the edited hw/sw section as JSON in `config`. Applied by the Client
476
+ // on its next heartbeat, then it restarts once idle.
477
+ router.post('/resources/:id/config', requireAdminRole, (req, res) => {
478
+ try {
479
+ let config;
480
+ try {
481
+ config = JSON.parse(req.body.config || '');
482
+ }
483
+ catch {
484
+ throw Object.assign(new Error('The config form sent no valid JSON — reload the page and try again'), { status: 400 });
485
+ }
486
+ const r = services.registry.setClientConfig(req.params.id, config, { by: req.session.user.username });
487
+ flash(req, 'success', `Capabilities of ${r.name} saved (revision ${r.config_revision}).\n` +
488
+ 'The Client applies them on its next heartbeat, then restarts once it has no job running.');
489
+ }
490
+ catch (err){
491
+ flash(req, 'danger', err.message);
492
+ }
493
+ res.redirect(returnTo(req, '/resources'));
494
+ });
495
+
428
496
  // Scheduled host reboot (resource card): save a cron expression, or clear
429
497
  // it; the Client applies it on its next heartbeat.
430
498
  router.post('/resources/:id/reboot-schedule', requireAdminRole, (req, res) => {
@@ -618,6 +618,70 @@ test('reboot now: refused offline or without a helper; cancels a running job fir
618
618
  assert.equal(after.message, 'Canceled: user reboot request');
619
619
  });
620
620
 
621
+ test('capabilities from the dashboard: reported config stored, saved at a new revision, resent until applied', () => {
622
+ const { registry } = buildTestServices(),
623
+ { resourceId } = registry.registerAuto({
624
+ clientId: 'c-cfg', name: 'lab-cfg', type: 'sw', labels: [], capabilities: { sw: { image: 'emu' } },
625
+ config: { image: 'emu:1', cpus: 2, memory: '2g' }
626
+ });
627
+ assert.deepEqual(registry.get(resourceId).client_config, { image: 'emu:1', cpus: 2, memory: '2g' });
628
+ assert.equal(registry.pendingConfig(resourceId), null);
629
+
630
+ assert.throws(() => registry.setClientConfig(resourceId, { memory: 'lots' }), /Invalid SW config: sw\.memory/);
631
+ const saved = registry.setClientConfig(resourceId, { image: 'emu:2', cpus: 1, memory: '1g', allowJobImages: true });
632
+ assert.equal(saved.config_revision, 1);
633
+ assert.deepEqual(registry.pendingConfig(resourceId), { revision: 1, type: 'sw', config: { image: 'emu:2', cpus: 1, memory: '1g', allowJobImages: true } });
634
+
635
+ registry.heartbeat(resourceId, { state: 'idle' }); // an older Client: reports nothing
636
+ assert.equal(registry.pendingConfig(resourceId).revision, 1);
637
+ registry.heartbeat(resourceId, { state: 'idle', configRevision: 1 });
638
+ assert.equal(registry.pendingConfig(resourceId), null);
639
+
640
+ registry.setClientConfig(resourceId, { image: 'emu:3' });
641
+ registry.heartbeat(resourceId, { state: 'idle', configRevision: 2, configError: 'Config revision 2 refused: bad' });
642
+ assert.equal(registry.pendingConfig(resourceId), null); // refused ones aren't resent
643
+ assert.match(registry.get(resourceId).config_error, /refused: bad/);
644
+ });
645
+
646
+ test('rename from the dashboard: kept across re-registration, unique, and resettable to the Client\'s own name', () => {
647
+ const { registry } = buildTestServices(),
648
+ reg = (name) => registry.get(registry.registerAuto({ clientId: 'c-ren', name, type: 'sw', labels: [] }).resourceId),
649
+ r = reg('dut1');
650
+ registerResource(registry, { name: 'taken', type: 'sw' });
651
+
652
+ assert.equal(registry.rename(r.id, ' bench-7 ').name, 'bench-7');
653
+ assert.equal(reg('dut1').name, 'bench-7'); // the Client restarts, still presenting "dut1"
654
+ assert.equal(registry.get(r.id).reported_name, 'dut1');
655
+
656
+ assert.throws(() => registry.rename(r.id, 'taken'), /already used by another Client/);
657
+ assert.throws(() => registry.rename(r.id, 'has space'), /1-64 letters/);
658
+
659
+ const reset = registry.rename(r.id, '');
660
+ assert.deepEqual([reset.name, reset.name_override], ['dut1', null]);
661
+ assert.equal(reg('dut2').name, 'dut2'); // no override: the Client's own name again
662
+ });
663
+
664
+ test('USB scan: only while connected; the answer is kept for the pending request only', () => {
665
+ const { bus, registry } = buildTestServices(),
666
+ commands = [],
667
+ offline = registerResource(registry, { name: 'usb-off', type: 'hw' }),
668
+ r = registerResource(registry, { name: 'usb-on', type: 'hw' });
669
+ registry.heartbeat(r.id, { state: 'idle' });
670
+ bus.on('command', (c) => commands.push(c));
671
+
672
+ assert.throws(() => registry.requestUsbScan(offline.id), /offline/);
673
+ const { requestId } = registry.requestUsbScan(r.id, { by: 'admin' });
674
+ assert.deepEqual(commands, [{ resourceId: r.id, command: 'scan-usb', requestId }]);
675
+
676
+ assert.equal(registry.storeUsbScan(r.id, { requestId: 'stale', output: 'x' }), false);
677
+ assert.equal(registry.get(r.id).usb_scan, null);
678
+ assert.equal(registry.storeUsbScan(r.id, { requestId, output: 'Bus 001 Device 004: ID 0483:3748 ST-LINK/V2', error: null }), true);
679
+ const stored = registry.get(r.id);
680
+ assert.match(stored.usb_scan.output, /ST-LINK/);
681
+ assert.equal(stored.usb_scan_request_id, null); // no longer pending
682
+ assert.equal(registry.storeUsbScan(r.id, { requestId, output: 'again' }), false); // answered once
683
+ });
684
+
621
685
  test('a resource can belong to several groups at once', () => {
622
686
  const { registry, groups } = buildTestServices(),
623
687
  g1 = groups.create({ name: 'g1' }),
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @file packages/coordinator/test/usb-parse.test.js
3
+ * @description Tests: parsing `lsusb -tvv` and sorting devices into a HW Client's config lists ("Import to config")
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
+ { parseLsusbTree, classifyDevices } = require('../public/js/usb-parse');
19
+
20
+ const LSUSB = `/: Bus 001.Port 001: Dev 001, Class=root_hub, Driver=xhci_hcd/12p, 480M
21
+ ID 1d6b:0002 Linux Foundation 2.0 root hub
22
+ /sys/bus/usb/devices/usb1 /dev/bus/usb/001/001
23
+ |__ Port 003: Dev 002, If 0, Class=Hub, Driver=hub/4p, 480M
24
+ ID 05e3:0610 Genesys Logic, Inc. Hub
25
+ /sys/bus/usb/devices/1-3 /dev/bus/usb/001/002
26
+ |__ Port 010: Dev 009, If 0, Class=Vendor Specific Class, Driver=, 12M
27
+ ID 0483:3748 STMicroelectronics ST-LINK/V2
28
+ /sys/bus/usb/devices/1-3.10 /dev/bus/usb/001/009
29
+ |__ Port 002: Dev 005, If 0, Class=Vendor Specific Class, Driver=ftdi_sio, 12M
30
+ ID 0403:6001 Future Technology Devices International, Ltd FT232 Serial (UART) IC
31
+ /sys/bus/usb/devices/1-3.2 /dev/bus/usb/001/005
32
+ |__ Port 001: Dev 004, If 0, Class=Vendor Specific Class, Driver=, 12M
33
+ ID 0483:374b STMicroelectronics ST-LINK/V2.1
34
+ /sys/bus/usb/devices/1-3.1 /dev/bus/usb/001/004
35
+ |__ Port 001: Dev 004, If 1, Class=Mass Storage, Driver=usb-storage, 12M
36
+ ID 0483:374b STMicroelectronics ST-LINK/V2.1
37
+ /sys/bus/usb/devices/1-3.1 /dev/bus/usb/001/004
38
+ |__ Port 004: Dev 007, If 0, Class=Vendor Specific Class, Driver=ch341, 12M
39
+ ID 1a86:7523 QinHeng Electronics CH340 serial converter
40
+ /sys/bus/usb/devices/1-3.4 /dev/bus/usb/001/007
41
+ |__ Port 005: Dev 003, If 0, Class=Communications, Driver=cdc_acm, 12M
42
+ ID 0483:5740 STMicroelectronics Virtual COM Port
43
+ /sys/bus/usb/devices/1-5 /dev/bus/usb/001/003
44
+ |__ Port 006: Dev 008, If 0, Class=Human Interface Device, Driver=usbhid, 1.5M
45
+ ID 046d:c077 Logitech, Inc. Mouse
46
+ /sys/bus/usb/devices/1-6 /dev/bus/usb/001/008`;
47
+
48
+ test('parses every device once, with its ids, name, driver and udev devpath', () => {
49
+ const devices = parseLsusbTree(LSUSB);
50
+ assert.equal(devices.length, 8); // the ST-Link/V2-1's two interfaces are one device
51
+ const ftdi = devices.find((d) => d.id === '0403:6001');
52
+ assert.deepEqual([ftdi.devpath, ftdi.driver, ftdi.sysPath], ['3.2', 'ftdi_sio', '1-3.2']);
53
+ assert.equal(devices.find((d) => d.sysPath === 'usb1').devpath, null);
54
+ assert.deepEqual(parseLsusbTree(''), []);
55
+ });
56
+
57
+ test('sorts devices into the Client\'s lists in port order, with ids only when not the default', () => {
58
+ const { lists, skipped } = classifyDevices(parseLsusbTree(LSUSB));
59
+ assert.deepEqual(lists.stlinks.map((d) => [d.devpath, d.vendorId, d.productId]), [['3.1', '0483', '374b'], ['3.10', undefined, undefined]]);
60
+ assert.deepEqual(lists.uarts.map((d) => [d.devpath, d.id, d.vendorId]), [['3.2', '0403:6001', undefined], ['3.4', '1a86:7523', '1a86']]);
61
+ assert.deepEqual(lists.usbs.map((d) => d.devpath), ['5']);
62
+ assert.deepEqual(skipped.map((d) => [d.id, d.reason]), [
63
+ ['1d6b:0002', 'root hub'], ['05e3:0610', 'hub'], ['046d:c077', 'not a device the Client uses']
64
+ ]);
65
+ });
66
+
67
+ test('at most 8 per list; the rest are skipped', () => {
68
+ const ftdi = { id: '0403:6001', vendorId: '0403', productId: '6001', name: 'FT232', driver: 'ftdi_sio' },
69
+ many = Array.from({ length: 10 }, (_, i) => ({ ...ftdi, devpath: `2.${i + 1}` })),
70
+ { lists, skipped } = classifyDevices(many);
71
+ assert.equal(lists.uarts.length, 8);
72
+ assert.deepEqual(skipped.map((d) => d.reason), ['more than 8 uarts', 'more than 8 uarts']);
73
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @file packages/coordinator/test/views.test.js
3
+ * @description Tests: every dashboard Pug template compiles (catches syntax the Pug parser doesn't support, e.g. ??)
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
+ path = require('node:path'),
20
+ pug = require('pug');
21
+
22
+ const VIEWS = path.join(__dirname, '..', 'views');
23
+
24
+ function pugFiles(dir){
25
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
26
+ e.isDirectory() ? pugFiles(path.join(dir, e.name)) : e.name.endsWith('.pug') ? [path.join(dir, e.name)] : []);
27
+ }
28
+
29
+ test('every view compiles', () => {
30
+ const files = pugFiles(VIEWS);
31
+ assert.ok(files.length > 10);
32
+ for (const file of files){
33
+ assert.doesNotThrow(() => pug.compileFile(file), `${path.relative(VIEWS, file)}`);
34
+ }
35
+ });
package/views/index.pug CHANGED
@@ -47,3 +47,7 @@ block content
47
47
  block scripts
48
48
  script(src="/js/resource-card.js")
49
49
  script(src="/js/remove-resource.js")
50
+ script(src="/js/client-config.js")
51
+ script(src="/js/usb-scan.js")
52
+ script(src="/js/usb-parse.js")
53
+ script(src="/js/usb-import.js")
package/views/layout.pug CHANGED
@@ -12,7 +12,12 @@ html(lang="en" data-bs-theme=(user && user.theme) || "auto")
12
12
  .container-fluid
13
13
  a.navbar-brand.d-flex.flex-column.lh-1.py-1(href="/")
14
14
  span TestHub
15
- small.text-body-secondary.fw-normal.mt-1(style="font-size: .7rem;")
15
+ small.text-body-secondary.fw-normal.mt-1(
16
+ style="font-size: .7rem;"
17
+ data-bs-toggle="tooltip"
18
+ data-bs-placement="bottom"
19
+ data-bs-title=`Coordinator v${coordinatorVersion} · thub-common v${commonVersion} (validates job specs)`
20
+ )
16
21
  | ver. #{coordinatorVersion}
17
22
  if user && user.role !== 'admin' && updates.coordinatorUpdate
18
23
  span.badge.text-bg-warning.ms-1(