@xenon-device-management/xenon 1.2.0 → 1.3.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/README.md +74 -0
- package/lib/package.json +1 -1
- package/lib/public/assets/{Layouts-D0WSzKOh.js → Layouts-D6IPfwoe.js} +1 -1
- package/lib/public/assets/{ai-settings-DQWDdNd7.js → ai-settings-CflyFKan.js} +1 -1
- package/lib/public/assets/{apps-1sLWHOGO.js → apps-Da4dvQ1J.js} +1 -1
- package/lib/public/assets/{badge-BiR1gmMm.js → badge-BNR9umdu.js} +1 -1
- package/lib/public/assets/{button-BVazt4Z1.js → button-hZFV1ypT.js} +1 -1
- package/lib/public/assets/{calendar-yMyP2_Nc.js → calendar-fehdBtun.js} +1 -1
- package/lib/public/assets/{clock-CsVplnJ2.js → clock-DrpxSvCL.js} +1 -1
- package/lib/public/assets/{cpu-DNC8n7kK.js → cpu-tuyMVZ4I.js} +1 -1
- package/lib/public/assets/{device-explorer-DFu8Gxj4.js → device-explorer-DOfRH3zm.js} +1 -1
- package/lib/public/assets/{index-S71J2rWg.js → index-BaTiUCeH.js} +18 -18
- package/lib/public/assets/{lock-BstCxnX6.js → lock-C6CoqSr2.js} +1 -1
- package/lib/public/assets/{maintenance-settings-BwfG9cu2.js → maintenance-settings-CM2oC7-i.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-CSn_Wnc9.js → mouse-pointer-2-CXdnjXIg.js} +1 -1
- package/lib/public/assets/{plus-DfjM7G6e.js → plus-B4B1Hukt.js} +1 -1
- package/lib/public/assets/{session-dashboard-C6ek4z65.js → session-dashboard-B5OPMTz5.js} +1 -1
- package/lib/public/assets/{settings-BDYP8ULf.js → settings-BTHP7fj3.js} +1 -1
- package/lib/public/assets/{trash-2-CZWUMK5b.js → trash-2-NJMZJ2Ol.js} +1 -1
- package/lib/public/assets/{useSocket-CliVeWS3.js → useSocket-Ct2wo7P2.js} +2 -2
- package/lib/public/assets/{webhook-settings-tPiwWf8y.js → webhook-settings-Cz35-QJ7.js} +1 -1
- package/lib/public/assets/{zap-ZrK5B58i.js → zap-CssSMAN5.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/schema.json +85 -38
- package/lib/src/InternalHttpClient.js +69 -14
- package/lib/src/app/index.js +92 -24
- package/lib/src/app/routers/apikeys.js +33 -0
- package/lib/src/app/routers/apps.js +4 -0
- package/lib/src/app/routers/auth.js +36 -0
- package/lib/src/app/routers/config.js +4 -0
- package/lib/src/app/routers/control.js +61 -10
- package/lib/src/app/routers/dashboard.js +5 -6
- package/lib/src/app/routers/grid.js +30 -12
- package/lib/src/app/routers/processes.js +24 -0
- package/lib/src/app/routers/reservation.js +15 -0
- package/lib/src/app/routers/webhook.js +6 -3
- package/lib/src/auth/nodeSecret.js +33 -0
- package/lib/src/config.js +5 -0
- package/lib/src/data-service/prisma-store.js +17 -1
- package/lib/src/device-managers/AndroidDeviceManager.js +2 -2
- package/lib/src/device-managers/NodeDevices.js +8 -1
- package/lib/src/device-managers/ios/IOSDiscoveryService.js +7 -4
- package/lib/src/device-managers/ios/IOSStreamService.js +7 -0
- package/lib/src/device-managers/ios/WDAClient.js +2 -0
- package/lib/src/device-utils.js +29 -4
- package/lib/src/generated/client/edge.js +2 -2
- package/lib/src/generated/client/index.js +2 -2
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +3 -0
- package/lib/src/helpers/UniversalMjpegProxy.js +23 -0
- package/lib/src/index.js +10 -2
- package/lib/src/interceptors/CommandInterceptor.js +29 -0
- package/lib/src/interfaces/IPluginArgs.js +0 -1
- package/lib/src/logger.js +30 -2
- package/lib/src/logging/sessionContext.js +28 -0
- package/lib/src/middleware/apiKeyMiddleware.js +49 -0
- package/lib/src/middleware/csrfMiddleware.js +73 -0
- package/lib/src/middleware/nodeSecretMiddleware.js +38 -0
- package/lib/src/middleware/rateLimitMiddleware.js +68 -0
- package/lib/src/middleware/scopeGuard.js +41 -0
- package/lib/src/plugin.js +1 -1
- package/lib/src/services/AIService.js +43 -8
- package/lib/src/services/ApiKeyService.js +102 -0
- package/lib/src/services/CircuitBreaker.js +158 -0
- package/lib/src/services/CleanupService.js +137 -39
- package/lib/src/services/DeviceReconciler.js +102 -0
- package/lib/src/services/MetricsService.js +78 -0
- package/lib/src/services/PortAllocator.js +13 -0
- package/lib/src/services/ProcessMetricsService.js +99 -0
- package/lib/src/services/ProcessRegistry.js +123 -0
- package/lib/src/services/ServerManager.js +14 -2
- package/lib/src/services/SessionLifecycleService.js +80 -23
- package/lib/src/services/ShutdownCoordinator.js +89 -0
- package/lib/src/services/SocketClient.js +11 -0
- package/lib/src/services/SocketServer.js +109 -6
- package/lib/src/services/VideoPipelineService.js +2 -0
- package/lib/src/services/healing/HealingMetrics.js +63 -0
- package/lib/src/services/healing/HealingOrchestrator.js +32 -4
- package/lib/src/services/healing/OcrHealingProvider.js +7 -0
- package/lib/test/unit/ApiKeyService.test.js +101 -0
- package/lib/test/unit/PortAllocator.test.js +14 -0
- package/lib/test/unit/ProcessRegistry.test.js +70 -0
- package/lib/test/unit/apiKeyMiddleware.test.js +58 -0
- package/lib/test/unit/nodeSecretMiddleware.test.js +38 -0
- package/lib/test/unit/rateLimitMiddleware.test.js +37 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/prisma/migrations/20260423081701_add_session_indexes/migration.sql +8 -0
- package/prisma/schema.prisma +3 -0
- package/schema.json +85 -38
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
9
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
10
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
11
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
12
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
13
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
14
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
18
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
19
|
+
};
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.ShutdownCoordinator = void 0;
|
|
22
|
+
const typedi_1 = require("typedi");
|
|
23
|
+
const logger_1 = __importDefault(require("../logger"));
|
|
24
|
+
const SessionManager_1 = require("../sessions/SessionManager");
|
|
25
|
+
const SessionLifecycleService_1 = require("./SessionLifecycleService");
|
|
26
|
+
// Graceful shutdown phase that runs BEFORE the existing infrastructure
|
|
27
|
+
// teardown in index.ts:cleanup(). Active sessions get a bounded chance to
|
|
28
|
+
// archive their video + release ports + mark themselves failed, so we don't
|
|
29
|
+
// leak a half-recorded session and a permanently-busy device every time the
|
|
30
|
+
// hub is redeployed.
|
|
31
|
+
//
|
|
32
|
+
// Bounded by two timeouts:
|
|
33
|
+
// - per-session: a single hung driver can't block the rest from finishing
|
|
34
|
+
// - overall: if the fleet is very large, the shutdown still exits in time
|
|
35
|
+
// for init systems (systemd defaults to 90s before SIGKILL)
|
|
36
|
+
let ShutdownCoordinator = class ShutdownCoordinator {
|
|
37
|
+
constructor() {
|
|
38
|
+
this.draining = false;
|
|
39
|
+
this.logger = logger_1.default.scope('Shutdown');
|
|
40
|
+
}
|
|
41
|
+
get isDraining() {
|
|
42
|
+
return this.draining;
|
|
43
|
+
}
|
|
44
|
+
drain(timeoutMs_1) {
|
|
45
|
+
return __awaiter(this, arguments, void 0, function* (timeoutMs, perSessionTimeoutMs = 10000) {
|
|
46
|
+
if (this.draining) {
|
|
47
|
+
this.logger.warn('drain() called while already draining; ignoring');
|
|
48
|
+
return { attempted: 0, completed: 0 };
|
|
49
|
+
}
|
|
50
|
+
this.draining = true;
|
|
51
|
+
const sessions = SessionManager_1.SESSION_MANAGER.getAllSessions();
|
|
52
|
+
if (sessions.length === 0) {
|
|
53
|
+
this.logger.info('No active sessions to drain');
|
|
54
|
+
return { attempted: 0, completed: 0 };
|
|
55
|
+
}
|
|
56
|
+
this.logger.info(`Draining ${sessions.length} active session(s) (overall=${timeoutMs}ms, per-session=${perSessionTimeoutMs}ms)`);
|
|
57
|
+
const lifecycle = typedi_1.Container.get(SessionLifecycleService_1.SessionLifecycleService);
|
|
58
|
+
const deadline = Date.now() + timeoutMs;
|
|
59
|
+
const tasks = sessions.map((session) => __awaiter(this, void 0, void 0, function* () {
|
|
60
|
+
const id = session.getId();
|
|
61
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
62
|
+
const budget = Math.min(perSessionTimeoutMs, remaining);
|
|
63
|
+
if (budget <= 0) {
|
|
64
|
+
this.logger.warn(`[shutdown] No budget left for session ${id}; skipping`);
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
yield Promise.race([
|
|
69
|
+
lifecycle.stopSessionForShutdown(id, 'Hub shutdown'),
|
|
70
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('per-session drain timeout')), budget)),
|
|
71
|
+
]);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
this.logger.warn(`[shutdown] Failed to drain session ${id}: ${err.message}`);
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}));
|
|
79
|
+
const results = yield Promise.allSettled(tasks);
|
|
80
|
+
const completed = results.filter((r) => r.status === 'fulfilled' && r.value).length;
|
|
81
|
+
this.logger.info(`Drain complete: ${completed}/${sessions.length} session(s) finalized`);
|
|
82
|
+
return { attempted: sessions.length, completed };
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
exports.ShutdownCoordinator = ShutdownCoordinator;
|
|
87
|
+
exports.ShutdownCoordinator = ShutdownCoordinator = __decorate([
|
|
88
|
+
(0, typedi_1.Service)()
|
|
89
|
+
], ShutdownCoordinator);
|
|
@@ -13,6 +13,7 @@ exports.SocketClient = void 0;
|
|
|
13
13
|
const socket_io_client_1 = require("socket.io-client");
|
|
14
14
|
const typedi_1 = require("typedi");
|
|
15
15
|
const logger_1 = __importDefault(require("../logger"));
|
|
16
|
+
const config_1 = require("../config");
|
|
16
17
|
const SocketEvents_1 = require("../enums/SocketEvents");
|
|
17
18
|
let SocketClient = class SocketClient {
|
|
18
19
|
constructor() {
|
|
@@ -26,11 +27,21 @@ let SocketClient = class SocketClient {
|
|
|
26
27
|
// Remove wd/hub if present in hubUrl
|
|
27
28
|
const normalizedHubUrl = hubUrl.replace(/\/wd\/hub$/, '');
|
|
28
29
|
logger_1.default.info(`[SocketClient] Connecting to Hub WebSocket: ${normalizedHubUrl}`);
|
|
30
|
+
// Hub now authenticates every socket handshake. Nodes present the same
|
|
31
|
+
// shared secret they already use for REST calls; without it the hub will
|
|
32
|
+
// reject the handshake with "unauthorized". auth-disabled mode on the hub
|
|
33
|
+
// (XENON_AUTH_DISABLED=true) ignores this field, so it's safe to send
|
|
34
|
+
// unconditionally.
|
|
35
|
+
const nodeSecret = config_1.config.nodeSecret;
|
|
36
|
+
if (!nodeSecret) {
|
|
37
|
+
logger_1.default.warn('[SocketClient] XENON_NODE_SECRET not set; hub will reject the handshake unless it also has auth disabled.');
|
|
38
|
+
}
|
|
29
39
|
this.socket = (0, socket_io_client_1.io)(normalizedHubUrl, {
|
|
30
40
|
reconnection: true,
|
|
31
41
|
reconnectionAttempts: Infinity,
|
|
32
42
|
reconnectionDelay: 1000,
|
|
33
43
|
reconnectionDelayMax: 5000,
|
|
44
|
+
auth: nodeSecret ? { nodeSecret } : undefined,
|
|
34
45
|
});
|
|
35
46
|
this.socket.on('connect', () => {
|
|
36
47
|
var _a, _b, _c;
|
|
@@ -5,31 +5,74 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
5
5
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
6
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
7
|
};
|
|
8
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
9
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
10
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
11
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
12
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
13
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
14
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
15
|
+
});
|
|
16
|
+
};
|
|
8
17
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
9
18
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
10
19
|
};
|
|
20
|
+
var SocketServer_1;
|
|
11
21
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
22
|
exports.SocketServer = void 0;
|
|
13
23
|
const socket_io_1 = require("socket.io");
|
|
14
24
|
const typedi_1 = require("typedi");
|
|
15
25
|
const logger_1 = __importDefault(require("../logger"));
|
|
26
|
+
const config_1 = require("../config");
|
|
27
|
+
const ApiKeyService_1 = require("./ApiKeyService");
|
|
28
|
+
const nodeSecret_1 = require("../auth/nodeSecret");
|
|
16
29
|
const SocketEvents_1 = require("../enums/SocketEvents");
|
|
17
|
-
|
|
30
|
+
const SESSION_COOKIE = 'xenon_dashboard_session';
|
|
31
|
+
function readCookie(cookieHeader, name) {
|
|
32
|
+
if (!cookieHeader)
|
|
33
|
+
return undefined;
|
|
34
|
+
for (const part of cookieHeader.split(';')) {
|
|
35
|
+
const eq = part.indexOf('=');
|
|
36
|
+
if (eq < 0)
|
|
37
|
+
continue;
|
|
38
|
+
if (part.slice(0, eq).trim() === name) {
|
|
39
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
let SocketServer = SocketServer_1 = class SocketServer {
|
|
18
45
|
constructor() {
|
|
19
46
|
this.io = null;
|
|
20
47
|
this.nodes = new Map(); // socketId -> nodeHost
|
|
21
48
|
}
|
|
22
49
|
initialize(server) {
|
|
23
50
|
this.io = new socket_io_1.Server(server, {
|
|
51
|
+
// Same-origin only. Browsers on a different origin are blocked at the
|
|
52
|
+
// handshake; server-to-server node connections send no Origin header
|
|
53
|
+
// and are unaffected. Matches the apiRouter cors({origin:false}) policy.
|
|
24
54
|
cors: {
|
|
25
|
-
origin:
|
|
55
|
+
origin: false,
|
|
26
56
|
methods: ['GET', 'POST'],
|
|
27
57
|
},
|
|
28
58
|
});
|
|
59
|
+
this.io.use((socket, next) => __awaiter(this, void 0, void 0, function* () {
|
|
60
|
+
try {
|
|
61
|
+
const principal = yield this.authenticate(socket);
|
|
62
|
+
socket.data.principal = principal;
|
|
63
|
+
next();
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
logger_1.default.warn(`[SocketServer] Handshake rejected for ${socket.id}: ${err.message}`);
|
|
67
|
+
next(new Error('unauthorized'));
|
|
68
|
+
}
|
|
69
|
+
}));
|
|
29
70
|
this.io.on('connection', (socket) => {
|
|
30
71
|
const socketId = socket.id;
|
|
31
|
-
|
|
32
|
-
|
|
72
|
+
const principal = socket.data.principal;
|
|
73
|
+
logger_1.default.info(`[SocketServer] New connection: ${socketId} (principal=${principal})`);
|
|
74
|
+
// Protocol version handshake — still runs after auth so a logged-in
|
|
75
|
+
// client on the wrong protocol version still gets a clean disconnect.
|
|
33
76
|
socket.on(SocketEvents_1.SocketEvents.HANDSHAKE, (data) => {
|
|
34
77
|
const { version, host } = data;
|
|
35
78
|
if (version !== SocketEvents_1.XENON_PROTOCOL_VERSION) {
|
|
@@ -40,6 +83,11 @@ let SocketServer = class SocketServer {
|
|
|
40
83
|
logger_1.default.info(`[SocketServer] Handshake successful with client ${host || socketId} (v${version})`);
|
|
41
84
|
});
|
|
42
85
|
socket.on(SocketEvents_1.SocketEvents.REGISTER_NODE, (data) => {
|
|
86
|
+
if (!SocketServer_1.canRegisterAs(principal, 'node')) {
|
|
87
|
+
logger_1.default.warn(`[SocketServer] Rejected REGISTER_NODE from principal=${principal} on ${socketId}`);
|
|
88
|
+
socket.disconnect();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
43
91
|
const { host } = data;
|
|
44
92
|
this.nodes.set(socketId, host);
|
|
45
93
|
logger_1.default.info(`[SocketServer] Node registered: ${host} (Socket: ${socketId})`);
|
|
@@ -48,6 +96,11 @@ let SocketServer = class SocketServer {
|
|
|
48
96
|
this.emitToDashboard(SocketEvents_1.SocketEvents.NODE_CONNECTED, { host });
|
|
49
97
|
});
|
|
50
98
|
socket.on(SocketEvents_1.SocketEvents.REGISTER_DASHBOARD, () => {
|
|
99
|
+
if (!SocketServer_1.canRegisterAs(principal, 'dashboard')) {
|
|
100
|
+
logger_1.default.warn(`[SocketServer] Rejected REGISTER_DASHBOARD from principal=${principal} on ${socketId}`);
|
|
101
|
+
socket.disconnect();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
51
104
|
logger_1.default.info(`[SocketServer] Dashboard client registered (Socket: ${socketId})`);
|
|
52
105
|
socket.join('dashboard');
|
|
53
106
|
});
|
|
@@ -63,7 +116,57 @@ let SocketServer = class SocketServer {
|
|
|
63
116
|
}
|
|
64
117
|
});
|
|
65
118
|
});
|
|
66
|
-
logger_1.default.info('[SocketServer] WebSocket server initialized');
|
|
119
|
+
logger_1.default.info('[SocketServer] WebSocket server initialized (auth enabled)');
|
|
120
|
+
}
|
|
121
|
+
// Role-based registration guard. A dashboard-authed socket can't join the
|
|
122
|
+
// 'nodes' broadcast room and a node-authed socket can't subscribe to
|
|
123
|
+
// dashboard events — keeps a stolen credential of one class from acting
|
|
124
|
+
// as the other. auth-disabled mode trusts the caller for parity with REST.
|
|
125
|
+
static canRegisterAs(principal, role) {
|
|
126
|
+
if (principal === 'auth-disabled')
|
|
127
|
+
return true;
|
|
128
|
+
return principal === role;
|
|
129
|
+
}
|
|
130
|
+
authenticate(socket) {
|
|
131
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
132
|
+
var _a, _b;
|
|
133
|
+
if (config_1.config.authDisabled === true)
|
|
134
|
+
return 'auth-disabled';
|
|
135
|
+
const auth = (socket.handshake.auth || {});
|
|
136
|
+
const headers = socket.handshake.headers || {};
|
|
137
|
+
// Node path: shared secret. Checked first because nodes never have a
|
|
138
|
+
// dashboard cookie, so we avoid a pointless ApiKeyService lookup. Accepts
|
|
139
|
+
// either the current or (during rotation overlap) the previous secret —
|
|
140
|
+
// validateNodeSecret logs a warn for the previous-match case.
|
|
141
|
+
const nodeSecret = (typeof auth.nodeSecret === 'string' && auth.nodeSecret) ||
|
|
142
|
+
((_a = headers['x-xenon-node-secret']) !== null && _a !== void 0 ? _a : '');
|
|
143
|
+
if (nodeSecret) {
|
|
144
|
+
if (!config_1.config.nodeSecret && !config_1.config.nodeSecretPrevious) {
|
|
145
|
+
throw new Error('node secret presented but server has none configured');
|
|
146
|
+
}
|
|
147
|
+
const outcome = (0, nodeSecret_1.validateNodeSecret)(nodeSecret, {
|
|
148
|
+
current: config_1.config.nodeSecret,
|
|
149
|
+
previous: config_1.config.nodeSecretPrevious,
|
|
150
|
+
});
|
|
151
|
+
if (outcome === 'reject') {
|
|
152
|
+
throw new Error('invalid node secret');
|
|
153
|
+
}
|
|
154
|
+
return 'node';
|
|
155
|
+
}
|
|
156
|
+
// Dashboard path: API key header, or session cookie set by /auth/login.
|
|
157
|
+
const apiKeyRaw = (typeof auth.apiKey === 'string' && auth.apiKey) ||
|
|
158
|
+
((_b = headers['x-xenon-api-key']) !== null && _b !== void 0 ? _b : '') ||
|
|
159
|
+
readCookie(headers.cookie, SESSION_COOKIE) ||
|
|
160
|
+
'';
|
|
161
|
+
if (!apiKeyRaw) {
|
|
162
|
+
throw new Error('missing credentials (need nodeSecret, apiKey, or dashboard cookie)');
|
|
163
|
+
}
|
|
164
|
+
const row = yield typedi_1.Container.get(ApiKeyService_1.ApiKeyService).verify(apiKeyRaw);
|
|
165
|
+
if (!row) {
|
|
166
|
+
throw new Error('invalid or revoked API key');
|
|
167
|
+
}
|
|
168
|
+
return 'dashboard';
|
|
169
|
+
});
|
|
67
170
|
}
|
|
68
171
|
emitToDashboard(event, data) {
|
|
69
172
|
if (this.io) {
|
|
@@ -82,6 +185,6 @@ let SocketServer = class SocketServer {
|
|
|
82
185
|
}
|
|
83
186
|
};
|
|
84
187
|
exports.SocketServer = SocketServer;
|
|
85
|
-
exports.SocketServer = SocketServer = __decorate([
|
|
188
|
+
exports.SocketServer = SocketServer = SocketServer_1 = __decorate([
|
|
86
189
|
(0, typedi_1.Service)()
|
|
87
190
|
], SocketServer);
|
|
@@ -56,6 +56,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
56
56
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
57
57
|
exports.VideoPipelineService = void 0;
|
|
58
58
|
const typedi_1 = require("typedi");
|
|
59
|
+
const ProcessRegistry_1 = require("./ProcessRegistry");
|
|
59
60
|
const child_process_1 = require("child_process");
|
|
60
61
|
const logger_1 = __importDefault(require("../logger"));
|
|
61
62
|
const path_1 = __importDefault(require("path"));
|
|
@@ -154,6 +155,7 @@ let VideoPipelineService = class VideoPipelineService {
|
|
|
154
155
|
const ffmpegProc = (0, child_process_1.spawn)(command, wrappedArgs, {
|
|
155
156
|
stdio: ['ignore', 'ignore', 'pipe'], // Only capture stderr for errors
|
|
156
157
|
});
|
|
158
|
+
typedi_1.Container.get(ProcessRegistry_1.ProcessRegistry).track({ kind: 'ffmpeg', sessionId, udid, process: ffmpegProc });
|
|
157
159
|
(_a = ffmpegProc.stderr) === null || _a === void 0 ? void 0 : _a.on('data', (data) => {
|
|
158
160
|
const msg = data.toString();
|
|
159
161
|
if (msg.toLowerCase().includes('error')) {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// In-process counters for healing tier outcomes. Deliberately in-memory:
|
|
3
|
+
// existing DB-backed counters (MetricsService.incrementHealing*) cover
|
|
4
|
+
// session-lifetime totals, but tier-level data ticks on every findElement
|
|
5
|
+
// failure — writing that through SQLite would thrash the DB. Prom scrapes
|
|
6
|
+
// pull the current values; restarts reset counters, which matches Prom's
|
|
7
|
+
// standard reset-semantics anyway.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.HEALING_METRICS = void 0;
|
|
10
|
+
class HealingMetricsRegistry {
|
|
11
|
+
constructor() {
|
|
12
|
+
// Key: `${tier}:${name}` so the same tier number with a renamed provider
|
|
13
|
+
// lands in a separate bucket instead of silently merging.
|
|
14
|
+
this.buckets = new Map();
|
|
15
|
+
this.skipBuckets = new Map();
|
|
16
|
+
this.allTiersFailedCount = 0;
|
|
17
|
+
}
|
|
18
|
+
record(tier, name, outcome, durationMs) {
|
|
19
|
+
const key = `${tier}:${name}`;
|
|
20
|
+
let bucket = this.buckets.get(key);
|
|
21
|
+
if (!bucket) {
|
|
22
|
+
bucket = { attempts: 0, successes: 0, failures: 0, durationMsSum: 0 };
|
|
23
|
+
this.buckets.set(key, bucket);
|
|
24
|
+
}
|
|
25
|
+
bucket.attempts++;
|
|
26
|
+
bucket.durationMsSum += durationMs;
|
|
27
|
+
if (outcome === 'success')
|
|
28
|
+
bucket.successes++;
|
|
29
|
+
else
|
|
30
|
+
bucket.failures++;
|
|
31
|
+
}
|
|
32
|
+
recordAllTiersFailed() {
|
|
33
|
+
this.allTiersFailedCount++;
|
|
34
|
+
}
|
|
35
|
+
recordSkippedRemaining(tier, name) {
|
|
36
|
+
const key = `${tier}:${name}`;
|
|
37
|
+
let bucket = this.skipBuckets.get(key);
|
|
38
|
+
if (!bucket) {
|
|
39
|
+
bucket = { tier, name, count: 0 };
|
|
40
|
+
this.skipBuckets.set(key, bucket);
|
|
41
|
+
}
|
|
42
|
+
bucket.count++;
|
|
43
|
+
}
|
|
44
|
+
skipSnapshot() {
|
|
45
|
+
return Array.from(this.skipBuckets.values()).sort((a, b) => a.tier - b.tier || a.name.localeCompare(b.name));
|
|
46
|
+
}
|
|
47
|
+
snapshot() {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const [key, bucket] of this.buckets) {
|
|
50
|
+
const sep = key.indexOf(':');
|
|
51
|
+
const tier = Number(key.slice(0, sep));
|
|
52
|
+
const name = key.slice(sep + 1);
|
|
53
|
+
out.push(Object.assign({ tier, name }, bucket));
|
|
54
|
+
}
|
|
55
|
+
// Stable order by tier then name — makes the /metrics output deterministic
|
|
56
|
+
// so alert rules and grafana variables don't see label reshuffling.
|
|
57
|
+
return out.sort((a, b) => a.tier - b.tier || a.name.localeCompare(b.name));
|
|
58
|
+
}
|
|
59
|
+
getAllTiersFailedCount() {
|
|
60
|
+
return this.allTiersFailedCount;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
exports.HEALING_METRICS = new HealingMetricsRegistry();
|
|
@@ -63,6 +63,7 @@ const VisualAiHealingProvider_1 = require("./VisualAiHealingProvider");
|
|
|
63
63
|
const LlmHealingProvider_1 = require("./LlmHealingProvider");
|
|
64
64
|
const HealEtalonService_1 = require("./HealEtalonService");
|
|
65
65
|
const ResilioTreeHealingProvider_1 = require("./ResilioTreeHealingProvider");
|
|
66
|
+
const HealingMetrics_1 = require("./HealingMetrics");
|
|
66
67
|
let HealingOrchestrator = class HealingOrchestrator {
|
|
67
68
|
constructor(etalonService) {
|
|
68
69
|
this.etalonService = etalonService;
|
|
@@ -78,6 +79,7 @@ let HealingOrchestrator = class HealingOrchestrator {
|
|
|
78
79
|
}
|
|
79
80
|
attemptHealing(sessionId, driver, strategy, selector) {
|
|
80
81
|
return __awaiter(this, void 0, void 0, function* () {
|
|
82
|
+
var _a;
|
|
81
83
|
this.logger.info(`🚨 Self-Healing triggered for session ${sessionId}. Broken locator: ${strategy}=${selector}`);
|
|
82
84
|
// Preparation: Collect data required for healing
|
|
83
85
|
// Note: We do this once to avoid multiple expensive round-trips
|
|
@@ -94,14 +96,19 @@ let HealingOrchestrator = class HealingOrchestrator {
|
|
|
94
96
|
}
|
|
95
97
|
// Tiered Execution: Try providers in order of cost/complexity
|
|
96
98
|
for (const provider of this.providers) {
|
|
99
|
+
const tierStart = Date.now();
|
|
97
100
|
try {
|
|
98
101
|
this.logger.info(`Attempting Tier ${provider.tier}: ${provider.name}...`);
|
|
99
102
|
const result = yield provider.heal(context);
|
|
103
|
+
HealingMetrics_1.HEALING_METRICS.record(provider.tier, provider.name, result ? 'success' : 'failure', Date.now() - tierStart);
|
|
100
104
|
if (result) {
|
|
101
105
|
this.logger.info(`✨ Provider ${provider.name} found a match! Confidence: ${(result.confidence * 100).toFixed(0)}%`);
|
|
102
106
|
// Tier 1/2 Optimization: Stability Verification Loop
|
|
103
107
|
// We try all candidates to see which one is the most stable (semantic vs absolute)
|
|
108
|
+
let stabilityAttempted = false;
|
|
109
|
+
let stabilityVerified = false;
|
|
104
110
|
if (result.candidateSelectors && result.candidateSelectors.length > 0) {
|
|
111
|
+
stabilityAttempted = true;
|
|
105
112
|
this.logger.debug(`Verifying ${result.candidateSelectors.length} candidate locators for stability...`);
|
|
106
113
|
for (const candidate of result.candidateSelectors) {
|
|
107
114
|
try {
|
|
@@ -109,6 +116,7 @@ let HealingOrchestrator = class HealingOrchestrator {
|
|
|
109
116
|
if (elements.length === 1) {
|
|
110
117
|
this.logger.info(`🎯 Verified stable & unique locator: ${candidate}`);
|
|
111
118
|
result.recommendedSelector = candidate;
|
|
119
|
+
stabilityVerified = true;
|
|
112
120
|
break; // Found a unique stable locator
|
|
113
121
|
}
|
|
114
122
|
else if (elements.length > 1) {
|
|
@@ -120,9 +128,16 @@ let HealingOrchestrator = class HealingOrchestrator {
|
|
|
120
128
|
}
|
|
121
129
|
}
|
|
122
130
|
}
|
|
123
|
-
// Principal Learning: Autonomously update the etalon to prevent future failures
|
|
124
|
-
//
|
|
125
|
-
|
|
131
|
+
// Principal Learning: Autonomously update the etalon to prevent future failures.
|
|
132
|
+
// Gate on stability: if the provider emitted candidate selectors but none of them
|
|
133
|
+
// resolved to a unique element, the structural guess was wrong and we must not
|
|
134
|
+
// persist it — that's how lucky LLM/fuzzy-XML guesses poison future sessions.
|
|
135
|
+
// Providers that emit no candidates (OCR/Visual) can't be verified this way, so
|
|
136
|
+
// they still learn on confidence alone.
|
|
137
|
+
if (stabilityAttempted && !stabilityVerified) {
|
|
138
|
+
this.logger.warn(`Skipping etalon save for ${selector}: ${result.candidateSelectors.length} candidate locator(s) attempted, none verified unique (provider=${provider.name}, confidence=${result.confidence})`);
|
|
139
|
+
}
|
|
140
|
+
else if (result.confidence > 0.7 && result.node) {
|
|
126
141
|
try {
|
|
127
142
|
this.logger.info(`🧠 Learning from healing success: updating etalon for ${selector}`);
|
|
128
143
|
// Recalculate path for ResilioTree if node is available
|
|
@@ -143,16 +158,29 @@ let HealingOrchestrator = class HealingOrchestrator {
|
|
|
143
158
|
yield this.etalonService.saveSignature(strategy, selector, result.node, learnedPath);
|
|
144
159
|
}
|
|
145
160
|
catch (learnErr) {
|
|
146
|
-
|
|
161
|
+
// Warn (not debug) so the loss of learning shows up in default logs —
|
|
162
|
+
// a silent failure here means the same selector keeps needing the LLM tier.
|
|
163
|
+
this.logger.warn(`Etalon save failed after healing [strategy=${strategy}, selector=${selector}, confidence=${result.confidence}]: ${learnErr.message}`);
|
|
147
164
|
}
|
|
148
165
|
}
|
|
149
166
|
return result;
|
|
150
167
|
}
|
|
151
168
|
}
|
|
152
169
|
catch (err) {
|
|
170
|
+
HealingMetrics_1.HEALING_METRICS.record(provider.tier, provider.name, 'failure', Date.now() - tierStart);
|
|
153
171
|
this.logger.error(`Provider ${provider.name} failed: ${err.message}`);
|
|
154
172
|
}
|
|
173
|
+
// Provider-driven short-circuit: a tier can advise that no downstream
|
|
174
|
+
// tier can plausibly succeed (e.g. missing prerequisites that all
|
|
175
|
+
// share). Saves an expensive LLM round-trip when upstream context
|
|
176
|
+
// collection failed.
|
|
177
|
+
if ((_a = provider.shouldSkipRemaining) === null || _a === void 0 ? void 0 : _a.call(provider, context)) {
|
|
178
|
+
this.logger.warn(`Tier ${provider.tier} (${provider.name}) advised skipping remaining tiers for selector=${selector}`);
|
|
179
|
+
HealingMetrics_1.HEALING_METRICS.recordSkippedRemaining(provider.tier, provider.name);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
155
182
|
}
|
|
183
|
+
HealingMetrics_1.HEALING_METRICS.recordAllTiersFailed();
|
|
156
184
|
this.logger.warn(`❌ All healing tiers failed for selector: ${selector}`);
|
|
157
185
|
return null;
|
|
158
186
|
});
|
|
@@ -22,6 +22,13 @@ class OcrHealingProvider {
|
|
|
22
22
|
this.tier = types_1.HealingTier.TIER_3_LOCAL_OCR;
|
|
23
23
|
this.logger = logger_1.default.scope('OcrHealing');
|
|
24
24
|
}
|
|
25
|
+
// Visual AI (tier 4) also needs a screenshot, and LLM (tier 5) needs a
|
|
26
|
+
// pageSource. If the context has neither, the remaining tiers can't do
|
|
27
|
+
// better than we did, so tell the orchestrator to give up — saves an
|
|
28
|
+
// LLM round-trip when context collection actually failed upstream.
|
|
29
|
+
shouldSkipRemaining(context) {
|
|
30
|
+
return !context.screenshotBase64 && !context.pageSource;
|
|
31
|
+
}
|
|
25
32
|
heal(context) {
|
|
26
33
|
return __awaiter(this, void 0, void 0, function* () {
|
|
27
34
|
if (!context.screenshotBase64) {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
36
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
37
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
38
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
39
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
40
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
41
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
45
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
46
|
+
};
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
const chai_1 = require("chai");
|
|
49
|
+
const sinon_1 = __importDefault(require("sinon"));
|
|
50
|
+
const ApiKeyService_1 = require("../../src/services/ApiKeyService");
|
|
51
|
+
const prisma_1 = require("../../src/prisma");
|
|
52
|
+
const fs_1 = __importDefault(require("fs"));
|
|
53
|
+
describe('ApiKeyService', () => {
|
|
54
|
+
afterEach(() => sinon_1.default.restore());
|
|
55
|
+
it('creates a bootstrap key when the table is empty', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
56
|
+
sinon_1.default.stub(prisma_1.prisma.apiKey, 'count').resolves(0);
|
|
57
|
+
const create = sinon_1.default.stub(prisma_1.prisma.apiKey, 'create').resolves({});
|
|
58
|
+
const writeStub = sinon_1.default.stub(fs_1.default, 'writeFileSync');
|
|
59
|
+
sinon_1.default.stub(fs_1.default, 'mkdirSync');
|
|
60
|
+
const svc = new ApiKeyService_1.ApiKeyService();
|
|
61
|
+
const key = yield svc.bootstrapIfEmpty('/tmp/test-bootstrap.txt');
|
|
62
|
+
(0, chai_1.expect)(key).to.be.a('string').with.lengthOf(64);
|
|
63
|
+
(0, chai_1.expect)(create.calledOnce).to.be.true;
|
|
64
|
+
(0, chai_1.expect)(create.firstCall.args[0].data.scopes).to.equal('admin');
|
|
65
|
+
(0, chai_1.expect)(writeStub.calledOnce).to.be.true;
|
|
66
|
+
(0, chai_1.expect)(writeStub.firstCall.args[0]).to.equal('/tmp/test-bootstrap.txt');
|
|
67
|
+
}));
|
|
68
|
+
it('returns null when keys already exist', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
69
|
+
sinon_1.default.stub(prisma_1.prisma.apiKey, 'count').resolves(1);
|
|
70
|
+
const svc = new ApiKeyService_1.ApiKeyService();
|
|
71
|
+
const key = yield svc.bootstrapIfEmpty('/tmp/test-bootstrap.txt');
|
|
72
|
+
(0, chai_1.expect)(key).to.be.null;
|
|
73
|
+
}));
|
|
74
|
+
it('verify returns the key row for a valid raw key', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
75
|
+
const raw = 'a'.repeat(64);
|
|
76
|
+
const hash = (yield Promise.resolve().then(() => __importStar(require('crypto'))))
|
|
77
|
+
.createHash('sha256')
|
|
78
|
+
.update(raw)
|
|
79
|
+
.digest('hex');
|
|
80
|
+
sinon_1.default
|
|
81
|
+
.stub(prisma_1.prisma.apiKey, 'findUnique')
|
|
82
|
+
.resolves({ id: 'k1', keyHash: hash, scopes: 'read', rateLimit: 300, revokedAt: null });
|
|
83
|
+
sinon_1.default.stub(prisma_1.prisma.apiKey, 'update').resolves({});
|
|
84
|
+
const svc = new ApiKeyService_1.ApiKeyService();
|
|
85
|
+
const row = yield svc.verify(raw);
|
|
86
|
+
(0, chai_1.expect)(row === null || row === void 0 ? void 0 : row.id).to.equal('k1');
|
|
87
|
+
}));
|
|
88
|
+
it('verify rejects revoked keys', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
89
|
+
const raw = 'a'.repeat(64);
|
|
90
|
+
const hash = (yield Promise.resolve().then(() => __importStar(require('crypto'))))
|
|
91
|
+
.createHash('sha256')
|
|
92
|
+
.update(raw)
|
|
93
|
+
.digest('hex');
|
|
94
|
+
sinon_1.default
|
|
95
|
+
.stub(prisma_1.prisma.apiKey, 'findUnique')
|
|
96
|
+
.resolves({ id: 'k1', keyHash: hash, scopes: 'read', rateLimit: 300, revokedAt: new Date() });
|
|
97
|
+
const svc = new ApiKeyService_1.ApiKeyService();
|
|
98
|
+
const row = yield svc.verify(raw);
|
|
99
|
+
(0, chai_1.expect)(row).to.be.null;
|
|
100
|
+
}));
|
|
101
|
+
});
|
|
@@ -24,13 +24,17 @@ function makeAllocator(overrides) {
|
|
|
24
24
|
describe('PortAllocator', () => {
|
|
25
25
|
let createStub;
|
|
26
26
|
let findManyStub;
|
|
27
|
+
let findFirstStub;
|
|
27
28
|
let deleteManyStub;
|
|
28
29
|
let deleteStub;
|
|
30
|
+
let updateStub;
|
|
29
31
|
beforeEach(() => {
|
|
30
32
|
createStub = sinon_1.default.stub(prisma_1.prisma.portLease, 'create');
|
|
31
33
|
findManyStub = sinon_1.default.stub(prisma_1.prisma.portLease, 'findMany').resolves([]);
|
|
34
|
+
findFirstStub = sinon_1.default.stub(prisma_1.prisma.portLease, 'findFirst').resolves(null);
|
|
32
35
|
deleteManyStub = sinon_1.default.stub(prisma_1.prisma.portLease, 'deleteMany').resolves({ count: 0 });
|
|
33
36
|
deleteStub = sinon_1.default.stub(prisma_1.prisma.portLease, 'delete').resolves({});
|
|
37
|
+
updateStub = sinon_1.default.stub(prisma_1.prisma.portLease, 'update').resolves({});
|
|
34
38
|
});
|
|
35
39
|
afterEach(() => sinon_1.default.restore());
|
|
36
40
|
it('allocates first free port in the configured range', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
@@ -66,6 +70,16 @@ describe('PortAllocator', () => {
|
|
|
66
70
|
(0, chai_1.expect)(err.message).to.match(/wda/);
|
|
67
71
|
}
|
|
68
72
|
}));
|
|
73
|
+
it('reuses an existing lease when the same (purpose, udid) acquires again', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
74
|
+
findFirstStub.resolves({ port: 8101 });
|
|
75
|
+
const allocator = makeAllocator({ wda: [8100, 8102] });
|
|
76
|
+
allocator.isOsFree = () => __awaiter(void 0, void 0, void 0, function* () { return true; });
|
|
77
|
+
const port = yield allocator.acquire('wda', 'udid-1');
|
|
78
|
+
(0, chai_1.expect)(port).to.equal(8101);
|
|
79
|
+
(0, chai_1.expect)(createStub.called, 'should not create a new lease').to.be.false;
|
|
80
|
+
(0, chai_1.expect)(updateStub.calledOnce, 'should refresh the existing lease').to.be.true;
|
|
81
|
+
(0, chai_1.expect)(updateStub.firstCall.args[0].where.port).to.equal(8101);
|
|
82
|
+
}));
|
|
69
83
|
it('releaseForUdid deletes all leases for that UDID', () => __awaiter(void 0, void 0, void 0, function* () {
|
|
70
84
|
deleteManyStub.resolves({ count: 2 });
|
|
71
85
|
const allocator = makeAllocator({ wda: [8100, 8102] });
|