@xenon-device-management/xenon 2.12.0 → 2.13.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/lib/package.json +2 -2
- package/lib/public/assets/{AnnotationOverlay-Crkn72_3.js → AnnotationOverlay-CzbFYtPI.js} +1 -1
- package/lib/public/assets/{ApiKeyGate-BThVlj_j.js → ApiKeyGate-CA5KFHHJ.js} +1 -1
- package/lib/public/assets/{BugReportButton-Dwa0GnBh.js → BugReportButton-D-DSGg8C.js} +1 -1
- package/lib/public/assets/{DeviceMosaicView-Ccc-pOls.js → DeviceMosaicView-paDEFU4B.js} +1 -1
- package/lib/public/assets/{EmptyState-BeuTrlN_.js → EmptyState-BE_5f8Vw.js} +1 -1
- package/lib/public/assets/{FieldGroup-DBIaNxl1.js → FieldGroup-DqMf7Rf3.js} +1 -1
- package/lib/public/assets/{FilterMenu-BgSAS6iT.js → FilterMenu-BQY75Su3.js} +1 -1
- package/lib/public/assets/{Menu-GCbP6gYN.js → Menu-BiW3os9u.js} +1 -1
- package/lib/public/assets/{Modal-ByESeBRC.js → Modal-DfawTxih.js} +1 -1
- package/lib/public/assets/{RecordingPage-Bwyf3WMl.js → RecordingPage-BvemyRiy.js} +1 -1
- package/lib/public/assets/{RecordingsPage-7kM1rB3i.js → RecordingsPage-CXfw7wpd.js} +1 -1
- package/lib/public/assets/{SegmentedControl-B8tTU8kD.js → SegmentedControl-B7m9kpom.js} +1 -1
- package/lib/public/assets/{SettingCard-Clg_Ijbc.js → SettingCard-Cw6lDD0l.js} +1 -1
- package/lib/public/assets/{Table-CIaR0QWV.js → Table-DlEuFgv-.js} +1 -1
- package/lib/public/assets/{activity-Bengh9SD.js → activity-CnLvNwae.js} +1 -1
- package/lib/public/assets/{ai-settings-G9SymiPH.js → ai-settings-D7Te8o-a.js} +1 -1
- package/lib/public/assets/{api-keys-CrGlbfn-.js → api-keys-DphSVzBr.js} +1 -1
- package/lib/public/assets/{apps-DIbcGVlU.js → apps-vUfu15lX.js} +1 -1
- package/lib/public/assets/{arrow-left-BtJIXXMY.js → arrow-left-CLQWAiCY.js} +1 -1
- package/lib/public/assets/{arrow-right-zm-NagQh.js → arrow-right-K6A5e_Ee.js} +1 -1
- package/lib/public/assets/{arrow-up-right-DdU8YEYp.js → arrow-up-right-DUA_lq6f.js} +1 -1
- package/lib/public/assets/{auth-shell-CHmBFmOz.js → auth-shell-BYRHcZd6.js} +2 -2
- package/lib/public/assets/{builds-page-JkzWKRpU.js → builds-page-C0fTvIG8.js} +1 -1
- package/lib/public/assets/{button-CqhPBGRj.js → button-BxnHCCsX.js} +1 -1
- package/lib/public/assets/{calendar-C7eYYJTo.js → calendar-CqVsn28A.js} +1 -1
- package/lib/public/assets/{check-DpbIM4E0.js → check-R6V3bot0.js} +1 -1
- package/lib/public/assets/{chevron-right-Dl4X1PRz.js → chevron-right-BA2Fntu-.js} +1 -1
- package/lib/public/assets/{circle-check-Du3sjbfV.js → circle-check-D4EzyGzs.js} +1 -1
- package/lib/public/assets/{circle-x-BSSqhpXi.js → circle-x-Cf6AF2B2.js} +1 -1
- package/lib/public/assets/{clock-DMT60v1C.js → clock-EZfet0sS.js} +1 -1
- package/lib/public/assets/{copy-BKNyOehd.js → copy-Bc6P8v9V.js} +1 -1
- package/lib/public/assets/{device-explorer-CoPR8DW3.js → device-explorer-BFXpRq6d.js} +1 -1
- package/lib/public/assets/{download-BM6Xn22t.js → download-Bd8OGLzi.js} +1 -1
- package/lib/public/assets/{forgot-password-B8WqMqBT.js → forgot-password-CoE7Ugtx.js} +1 -1
- package/lib/public/assets/{index-DauQh6ie.js → index-BiL0Enl_.js} +1 -1
- package/lib/public/assets/{index-ClrpAMAT.js → index-CHOr4JCs.js} +2 -2
- package/lib/public/assets/{input-CyKdLnEx.js → input-CJ8z3IO5.js} +1 -1
- package/lib/public/assets/{line-chart-EIBXwYGo.js → line-chart-CPK0ObjV.js} +1 -1
- package/lib/public/assets/{list-checks-fWsgD9bI.js → list-checks-mXGHUVbB.js} +1 -1
- package/lib/public/assets/{lock-CVCe56TH.js → lock-BSXM1xvq.js} +1 -1
- package/lib/public/assets/{login-BJ8a7yVD.js → login-Ct2iW066.js} +1 -1
- package/lib/public/assets/{maintenance-settings-DcRmTLS6.js → maintenance-settings-DaOs00P6.js} +1 -1
- package/lib/public/assets/{monitor-Bw1YQZnL.js → monitor-D8WQK8md.js} +1 -1
- package/lib/public/assets/{mouse-pointer-2-C01jbqkO.js → mouse-pointer-2-Cw2tWzDX.js} +1 -1
- package/lib/public/assets/{network-CBGUjJDJ.js → network-Ba_uFxWo.js} +1 -1
- package/lib/public/assets/{overview-Cxe8aQ7C.js → overview-CxrZ7_6k.js} +1 -1
- package/lib/public/assets/{page-header-B92DKLiq.js → page-header-Cm1XL49f.js} +1 -1
- package/lib/public/assets/{play-Ck0L-0_m.js → play-C0CuKLXj.js} +1 -1
- package/lib/public/assets/{plus-Dq3tCy2N.js → plus-CIIvR_6Z.js} +1 -1
- package/lib/public/assets/{profile-page--dkDiKbr.js → profile-page-BZ_e-Bgw.js} +1 -1
- package/lib/public/assets/{recording-group-store-5BYIFFN9.js → recording-group-store-BWeeNs2_.js} +1 -1
- package/lib/public/assets/{reset-password-KYWlfjia.js → reset-password-lfNz8Rm1.js} +1 -1
- package/lib/public/assets/{runbook-page-mnsxgS1d.js → runbook-page-Cnec3VSx.js} +1 -1
- package/lib/public/assets/{select-BBOIZTYm.js → select-CZuCnold.js} +1 -1
- package/lib/public/assets/{selector-detail-redirect-CcI2j_Su.js → selector-detail-redirect-BG3pxFLw.js} +1 -1
- package/lib/public/assets/{selector-health-page-BdJb_x5L.js → selector-health-page-DsyL58tb.js} +1 -1
- package/lib/public/assets/{session-detail-page-BjLaJy8z.js → session-detail-page-ZZyGM413.js} +1 -1
- package/lib/public/assets/{settings-B6cpMhsx.js → settings-DGZ7B_AR.js} +1 -1
- package/lib/public/assets/{stat-tile-BU9e4s36.js → stat-tile-DFhv8JoI.js} +1 -1
- package/lib/public/assets/{tablet-hgbEwrWq.js → tablet-68J5f6zh.js} +1 -1
- package/lib/public/assets/{teams-uiiG4hZM.js → teams-eHSyEpbD.js} +1 -1
- package/lib/public/assets/{trash-2-NK_Iazmg.js → trash-2-CcSsv4fS.js} +1 -1
- package/lib/public/assets/{upload-BUX8TNFi.js → upload-BYf26K71.js} +1 -1
- package/lib/public/assets/{use-builds-data-DZszmFyl.js → use-builds-data-CHW-MhaP.js} +1 -1
- package/lib/public/assets/{use-password-reset-mode-BK4B9kRC.js → use-password-reset-mode-DozShXkk.js} +1 -1
- package/lib/public/assets/{users-BU4XBbMT.js → users-BpYnpOl2.js} +1 -1
- package/lib/public/assets/{users-BDh1xjad.js → users-CcSQRoK9.js} +1 -1
- package/lib/public/assets/{video-off-BOjNwT4R.js → video-off-dFKgps73.js} +1 -1
- package/lib/public/assets/{webhook-settings-BMzyUcx5.js → webhook-settings-DD1XjiOD.js} +1 -1
- package/lib/public/assets/{zap-DTWwVMUg.js → zap-COC2tZaH.js} +1 -1
- package/lib/public/index.html +1 -1
- package/lib/src/app/apiErrors.js +118 -0
- package/lib/src/app/index.js +6 -1
- package/lib/src/app/openapi/control.yaml +3129 -0
- package/lib/src/app/openapi/grid.yaml +2295 -0
- package/lib/src/app/openapi/identity.yaml +2168 -0
- package/lib/src/app/openapi/platform.yaml +2885 -0
- package/lib/src/app/openapi/sessions.yaml +3784 -0
- package/lib/src/app/routers/bug-report.js +4 -1
- package/lib/src/app/routers/config.js +6 -107
- package/lib/src/app/routers/control.js +117 -59
- package/lib/src/app/routers/dashboard.js +13 -7
- package/lib/src/app/routers/grid.js +54 -14
- package/lib/src/app/routers/profile.js +27 -13
- package/lib/src/app/routers/recordings.js +11 -5
- package/lib/src/app/routers/reservation.js +63 -15
- package/lib/src/app/routers/users.js +4 -0
- package/lib/src/app/routers/webhook.js +17 -8
- package/lib/src/app/swagger.js +259 -177
- package/lib/src/data-service/device-service.js +4 -1
- package/lib/src/data-service/deviceFieldOwners.js +1 -0
- package/lib/src/device-managers/AndroidDeviceManager.js +5 -2
- package/lib/src/device-managers/ios/WDAClient.js +32 -32
- package/lib/src/generated/client/edge.js +4 -3
- package/lib/src/generated/client/index-browser.js +1 -0
- package/lib/src/generated/client/index.d.ts +38 -0
- package/lib/src/generated/client/index.js +4 -3
- package/lib/src/generated/client/package.json +1 -1
- package/lib/src/generated/client/schema.prisma +2 -0
- package/lib/src/generated/client/wasm.js +1 -0
- package/lib/src/middleware/csrfMiddleware.js +13 -5
- package/lib/src/middleware/rateLimitMiddleware.js +19 -5
- package/lib/src/middleware/roleGuard.js +20 -0
- package/lib/src/services/AIService.js +13 -3
- package/lib/src/services/NotificationService.js +28 -28
- package/lib/src/services/bug-report/BugReportService.js +8 -2
- package/lib/src/services/lease/LeaseService.js +71 -20
- package/lib/src/services/omni-vision/OmniVisionService.js +14 -5
- package/lib/src/services/recording/RecordingOrchestrator.js +16 -2
- package/lib/test/helpers/expressRoutes.js +41 -0
- package/lib/test/integration/team-visibility-control.spec.js +2 -2
- package/lib/test/unit/access-scopes.spec.js +227 -0
- package/lib/test/unit/api-error-handling.spec.js +179 -0
- package/lib/test/unit/bug-report/route.spec.js +29 -0
- package/lib/test/unit/bug-report/service.spec.js +30 -0
- package/lib/test/unit/control-honest-answers.spec.js +134 -0
- package/lib/test/unit/device-allocation-routes.spec.js +232 -0
- package/lib/test/unit/healing-state-endpoints.spec.js +8 -5
- package/lib/test/unit/install-repository-app-team.spec.js +4 -1
- package/lib/test/unit/lease/LeaseService.spec.js +5 -4
- package/lib/test/unit/lease/lease-device-match.spec.js +161 -0
- package/lib/test/unit/lease/lease-session-ownership.spec.js +10 -9
- package/lib/test/unit/omni-vision-failures.spec.js +81 -0
- package/lib/test/unit/openapi-coverage.spec.js +114 -0
- package/lib/test/unit/profile-router.test.js +42 -0
- package/lib/test/unit/rateLimitMiddleware.test.js +49 -0
- package/lib/test/unit/recording-orchestrator.spec.js +80 -0
- package/lib/test/unit/recordings-library-routes.spec.js +37 -0
- package/lib/test/unit/reservation-team-visibility.spec.js +4 -3
- package/lib/test/unit/reset-link.test.js +2 -1
- package/lib/test/unit/stream-ticket-identity.spec.js +1 -1
- package/lib/test/unit/users-router.test.js +14 -1
- package/lib/test/unit/wda-client-failures.spec.js +90 -0
- package/lib/test/unit/webhook-delivery.spec.js +142 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/prisma/migrations/20261004120000_reservation_holder/migration.sql +2 -0
- package/prisma/schema.prisma +2 -0
- package/lib/src/app/swagger-docs.js +0 -1701
|
@@ -205,6 +205,8 @@ model Device {
|
|
|
205
205
|
batteryLevel Int?
|
|
206
206
|
reservationReason String?
|
|
207
207
|
reservedBy String?
|
|
208
|
+
/// The user who took the reservation; only they or an admin may release or extend it.
|
|
209
|
+
reservedByUserId String?
|
|
208
210
|
reservedUntil Float?
|
|
209
211
|
storageFree String?
|
|
210
212
|
tags String?
|
|
@@ -280,6 +280,7 @@ exports.Prisma.DeviceScalarFieldEnum = {
|
|
|
280
280
|
batteryLevel: 'batteryLevel',
|
|
281
281
|
reservationReason: 'reservationReason',
|
|
282
282
|
reservedBy: 'reservedBy',
|
|
283
|
+
reservedByUserId: 'reservedByUserId',
|
|
283
284
|
reservedUntil: 'reservedUntil',
|
|
284
285
|
storageFree: 'storageFree',
|
|
285
286
|
tags: 'tags',
|
|
@@ -14,11 +14,15 @@ const config_1 = require("../config");
|
|
|
14
14
|
// - reverse-proxy misconfigurations that strip SameSite enforcement
|
|
15
15
|
// - older browsers whose SameSite implementation has known gaps
|
|
16
16
|
//
|
|
17
|
-
// Header-authed callers — the (x-xenon-access-key, x-xenon-token) pair
|
|
18
|
-
// are immune to CSRF by construction: a
|
|
19
|
-
// header without an explicit CORS
|
|
20
|
-
// cors({origin:false}) already refuses
|
|
21
|
-
// through unchanged.
|
|
17
|
+
// Header-authed callers — the (x-xenon-access-key, x-xenon-token) pair, or
|
|
18
|
+
// an `Authorization: Bearer` token — are immune to CSRF by construction: a
|
|
19
|
+
// browser will not attach either header cross-origin without an explicit CORS
|
|
20
|
+
// preflight, and the apiRouter's cors({origin:false}) already refuses
|
|
21
|
+
// preflights. Those requests pass through unchanged. A bearer request is
|
|
22
|
+
// authenticated by its token alone: authMiddleware answers a bad one 401 and
|
|
23
|
+
// never falls back to the cookie. Through 2.12 bearer callers (SDK, MCP
|
|
24
|
+
// tools) were held to the Origin check, so every state change they sent
|
|
25
|
+
// without a browser's Origin header was refused.
|
|
22
26
|
const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
|
|
23
27
|
// Comma-separated list of allowed origins, intended for ops running the
|
|
24
28
|
// dashboard behind a reverse proxy on a different host than the Xenon API.
|
|
@@ -51,6 +55,10 @@ function csrfMiddleware(req, res, next) {
|
|
|
51
55
|
if (req.headers['x-xenon-access-key'] || req.headers['x-xenon-hub-token']) {
|
|
52
56
|
return next();
|
|
53
57
|
}
|
|
58
|
+
const authorization = req.headers.authorization;
|
|
59
|
+
if (typeof authorization === 'string' && authorization.startsWith('Bearer ')) {
|
|
60
|
+
return next();
|
|
61
|
+
}
|
|
54
62
|
// Cookie-authed (or unauthenticated) state-changer. Require Origin or
|
|
55
63
|
// Referer to match the Host we're being served from.
|
|
56
64
|
const host = req.headers.host;
|
|
@@ -15,11 +15,25 @@ function categorize(req) {
|
|
|
15
15
|
return 'heavy';
|
|
16
16
|
return 'control';
|
|
17
17
|
}
|
|
18
|
-
|
|
18
|
+
const USER_READ_MULTIPLIER = 10;
|
|
19
|
+
function capacityFor(keyLimit, cat, perUser) {
|
|
19
20
|
if (cat === 'heavy')
|
|
20
21
|
return Math.max(10, Math.floor(keyLimit / 4));
|
|
22
|
+
if (cat === 'read' && perUser)
|
|
23
|
+
return keyLimit * USER_READ_MULTIPLIER;
|
|
21
24
|
return keyLimit;
|
|
22
25
|
}
|
|
26
|
+
/** Whose buckets a request draws from, and their per-minute budget; undefined is unlimited. */
|
|
27
|
+
function limitedCaller(req) {
|
|
28
|
+
const key = req.apiKey;
|
|
29
|
+
if (key)
|
|
30
|
+
return { id: key.id, rateLimit: key.rateLimit, perUser: false };
|
|
31
|
+
const auth = req.auth;
|
|
32
|
+
if (auth && (auth.kind === 'user-session' || auth.kind === 'bearer') && auth.userId) {
|
|
33
|
+
return { id: `user:${auth.userId}`, rateLimit: auth.rateLimit, perUser: true };
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
23
37
|
function refill(b) {
|
|
24
38
|
const now = Date.now();
|
|
25
39
|
const elapsed = (now - b.lastRefill) / 1000;
|
|
@@ -28,14 +42,14 @@ function refill(b) {
|
|
|
28
42
|
}
|
|
29
43
|
function rateLimitMiddleware() {
|
|
30
44
|
return function (req, res, next) {
|
|
31
|
-
const
|
|
32
|
-
if (!
|
|
45
|
+
const caller = limitedCaller(req);
|
|
46
|
+
if (!caller)
|
|
33
47
|
return next();
|
|
34
48
|
const category = categorize(req);
|
|
35
|
-
const bucketKey = `${
|
|
49
|
+
const bucketKey = `${caller.id}:${category}`;
|
|
36
50
|
let bucket = buckets.get(bucketKey);
|
|
37
51
|
if (!bucket) {
|
|
38
|
-
const capacity = capacityFor(
|
|
52
|
+
const capacity = capacityFor(caller.rateLimit, category, caller.perUser);
|
|
39
53
|
bucket = {
|
|
40
54
|
tokens: capacity,
|
|
41
55
|
lastRefill: Date.now(),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.roleGuard = roleGuard;
|
|
4
|
+
exports.superAdminGuard = superAdminGuard;
|
|
4
5
|
const RANK = {
|
|
5
6
|
SUPER_ADMIN: 3,
|
|
6
7
|
ADMIN: 2,
|
|
@@ -25,3 +26,22 @@ function roleGuard(min) {
|
|
|
25
26
|
next();
|
|
26
27
|
};
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* SUPER_ADMIN only, refused in words a tester can act on: the dashboard shows
|
|
31
|
+
* the `error` of a refused change as it is.
|
|
32
|
+
*/
|
|
33
|
+
function superAdminGuard(message) {
|
|
34
|
+
return function (req, res, next) {
|
|
35
|
+
const auth = req.auth;
|
|
36
|
+
if (!auth) {
|
|
37
|
+
res.status(401).json({ error: 'unauthenticated' });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (auth.role !== 'SUPER_ADMIN') {
|
|
41
|
+
// `message` too: the AI Engine page shows a refused test's message.
|
|
42
|
+
res.status(403).json({ error: message, message });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
next();
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -323,12 +323,20 @@ Fix: Add a pre-emptive check for the location permission dialog or use the \`aut
|
|
|
323
323
|
/**
|
|
324
324
|
* Finds coordinates for an element based on a visual description (Tier 4)
|
|
325
325
|
*/
|
|
326
|
-
|
|
327
|
-
|
|
326
|
+
/**
|
|
327
|
+
* `throwOnError`: fail, rather than answer "not found", when no provider is
|
|
328
|
+
* configured or the call fails. Device control's "Test locator" asks for
|
|
329
|
+
* it; Appium's ai-icon findElement doesn't.
|
|
330
|
+
*/
|
|
331
|
+
visualFind(screenshotBase64_1, description_1) {
|
|
332
|
+
return __awaiter(this, arguments, void 0, function* (screenshotBase64, description, opts = {}) {
|
|
328
333
|
var _a, _b;
|
|
329
334
|
this.initializeProvider();
|
|
330
|
-
if (!this.isEnabled())
|
|
335
|
+
if (!this.isEnabled()) {
|
|
336
|
+
if (opts.throwOnError)
|
|
337
|
+
throw new Error('No AI provider is configured');
|
|
331
338
|
return null;
|
|
339
|
+
}
|
|
332
340
|
const prompt = `
|
|
333
341
|
Task: Find the center coordinates (X, Y) of the element described as: "${description}"
|
|
334
342
|
Response Format: JSON only, strictly { "x": number, "y": number }.
|
|
@@ -351,6 +359,8 @@ Fix: Add a pre-emptive check for the location permission dialog or use the \`aut
|
|
|
351
359
|
else {
|
|
352
360
|
logger_1.default.warn(`[AIService] visualFind failed: ${err.message}`);
|
|
353
361
|
}
|
|
362
|
+
if (opts.throwOnError)
|
|
363
|
+
throw err;
|
|
354
364
|
return null;
|
|
355
365
|
}
|
|
356
366
|
});
|
|
@@ -66,38 +66,46 @@ let NotificationService = class NotificationService {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
catch (err) {
|
|
69
|
-
logger_1.default.error(`
|
|
69
|
+
logger_1.default.error(`Webhook ${config.id} (${config.url}) failed for ${eventType}: ${err}`);
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
});
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Sends a sample event to a webhook the way a real one would be sent, and
|
|
76
|
+
* fails if the delivery does: "Send test" used to say it worked whatever
|
|
77
|
+
* happened, and ignored the webhook's type.
|
|
78
|
+
*/
|
|
79
|
+
sendTest(url_1) {
|
|
80
|
+
return __awaiter(this, arguments, void 0, function* (url, type = 'slack', payloadTemplate) {
|
|
81
|
+
yield this.sendToWebhook({ url, type, payloadTemplate: payloadTemplate !== null && payloadTemplate !== void 0 ? payloadTemplate : null }, 'device_new', { udid: 'test-device-udid', name: 'Test Device', host: '127.0.0.1' });
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/** Delivers one event. Throws when the webhook refuses or can't be reached. */
|
|
74
85
|
sendToWebhook(config, eventType, payload) {
|
|
75
86
|
return __awaiter(this, void 0, void 0, function* () {
|
|
76
87
|
// Principal Logic: Use custom template if defined
|
|
77
88
|
if (config.payloadTemplate) {
|
|
78
89
|
const substitutedBody = this.substituteTemplate(config.payloadTemplate, Object.assign({ eventType }, payload));
|
|
90
|
+
// JSON if the template renders to JSON, otherwise sent as text. Only
|
|
91
|
+
// the parse may fall back: one catch around both used to post again,
|
|
92
|
+
// as text, when the JSON delivery itself failed.
|
|
93
|
+
let body;
|
|
79
94
|
try {
|
|
80
|
-
|
|
81
|
-
const jsonBody = JSON.parse(substitutedBody);
|
|
82
|
-
yield axios_1.default.post(config.url, jsonBody);
|
|
95
|
+
body = JSON.parse(substitutedBody);
|
|
83
96
|
}
|
|
84
|
-
catch (
|
|
85
|
-
|
|
86
|
-
yield axios_1.default.post(config.url, { text: substitutedBody });
|
|
97
|
+
catch (_a) {
|
|
98
|
+
body = { text: substitutedBody };
|
|
87
99
|
}
|
|
100
|
+
yield axios_1.default.post(config.url, body);
|
|
88
101
|
return;
|
|
89
102
|
}
|
|
90
103
|
if (config.type === 'slack') {
|
|
91
104
|
yield this.sendSlackMessage(config.url, eventType, payload);
|
|
92
105
|
}
|
|
93
106
|
else {
|
|
94
|
-
// Generic webhook
|
|
95
|
-
|
|
96
|
-
yield axios_1.default.post(config.url, { event: eventType, payload });
|
|
97
|
-
}
|
|
98
|
-
catch (err) {
|
|
99
|
-
logger_1.default.error(`Webhook failed for ${config.url}: ${err}`);
|
|
100
|
-
}
|
|
107
|
+
// Generic webhook
|
|
108
|
+
yield axios_1.default.post(config.url, { event: eventType, payload });
|
|
101
109
|
}
|
|
102
110
|
});
|
|
103
111
|
}
|
|
@@ -154,13 +162,8 @@ let NotificationService = class NotificationService {
|
|
|
154
162
|
},
|
|
155
163
|
],
|
|
156
164
|
};
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
logger_1.default.info(`Selector Health digest sent to ${url}`);
|
|
160
|
-
}
|
|
161
|
-
catch (err) {
|
|
162
|
-
logger_1.default.error(`Failed to send digest to ${url}: ${err}`);
|
|
163
|
-
}
|
|
165
|
+
yield axios_1.default.post(url, body);
|
|
166
|
+
logger_1.default.info(`Selector Health digest sent to ${url}`);
|
|
164
167
|
return;
|
|
165
168
|
}
|
|
166
169
|
default:
|
|
@@ -181,13 +184,10 @@ let NotificationService = class NotificationService {
|
|
|
181
184
|
},
|
|
182
185
|
],
|
|
183
186
|
};
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
catch (err) {
|
|
189
|
-
logger_1.default.error(`Failed to send Slack notification: ${err}`);
|
|
190
|
-
}
|
|
187
|
+
// A failure is the caller's: dispatchEvent logs it and goes on to the
|
|
188
|
+
// next webhook; sendTest reports it.
|
|
189
|
+
yield axios_1.default.post(url, body);
|
|
190
|
+
logger_1.default.info(`Slack notification sent to ${url}`);
|
|
191
191
|
});
|
|
192
192
|
}
|
|
193
193
|
};
|
|
@@ -79,9 +79,15 @@ let BugReportService = class BugReportService {
|
|
|
79
79
|
const warnings = [];
|
|
80
80
|
const window = (0, window_1.resolveWindow)(session, opts.mode, (_a = opts.windowSec) !== null && _a !== void 0 ? _a : types_1.SLICE_DEFAULT_SEC);
|
|
81
81
|
const logs = yield this.collectLogs(opts.sessionId, window.startedAt, window.endedAt);
|
|
82
|
-
const harText =
|
|
83
|
-
|
|
82
|
+
const harText = opts.includeNetwork
|
|
83
|
+
? (0, har_collector_1.collectHar)(opts.sessionId, config_1.config.sessionAssetsPath)
|
|
84
|
+
: null;
|
|
85
|
+
if (!opts.includeNetwork) {
|
|
86
|
+
warnings.push('network capture left out: only admins can download it');
|
|
87
|
+
}
|
|
88
|
+
else if (!harText) {
|
|
84
89
|
warnings.push('no network capture for this session');
|
|
90
|
+
}
|
|
85
91
|
const aiSummary = (_b = session.ai_analysis) !== null && _b !== void 0 ? _b : null;
|
|
86
92
|
const tmpDir = yield fs.promises.mkdtemp(path.join(os.tmpdir(), 'xenon-bugreport-'));
|
|
87
93
|
const cleanups = [
|
|
@@ -104,12 +104,45 @@ function defaultNodePairAuthProvider() {
|
|
|
104
104
|
function teamScope(callerTeamIds) {
|
|
105
105
|
return callerTeamIds === undefined ? {} : { callerTeamIds };
|
|
106
106
|
}
|
|
107
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* The client's filters as a device-store filter, without a team list, which
|
|
109
|
+
* only the credential may set. `sdk` is the OS version (`platformVersion`,
|
|
110
|
+
* which wins when both are sent). `deviceName` isn't a store filter: see
|
|
111
|
+
* nameMatches.
|
|
112
|
+
*/
|
|
108
113
|
function clientFilters(filters) {
|
|
109
114
|
const out = Object.assign({}, filters);
|
|
110
115
|
delete out.callerTeamIds;
|
|
116
|
+
delete out.deviceName;
|
|
117
|
+
delete out.sdk;
|
|
118
|
+
// The availability fields are the lease's to decide, never the client's.
|
|
119
|
+
delete out.busy;
|
|
120
|
+
delete out.userBlocked;
|
|
121
|
+
delete out.offline;
|
|
122
|
+
if (typeof filters.sdk === 'string' && filters.sdk !== '' && out.platformVersion === undefined) {
|
|
123
|
+
out.platformVersion = filters.sdk;
|
|
124
|
+
}
|
|
111
125
|
return out;
|
|
112
126
|
}
|
|
127
|
+
/** `filters.deviceName`: the device's name, ignoring case. */
|
|
128
|
+
function nameMatches(deviceName) {
|
|
129
|
+
if (typeof deviceName !== 'string' || deviceName === '')
|
|
130
|
+
return () => true;
|
|
131
|
+
const wanted = deviceName.toLowerCase();
|
|
132
|
+
return (device) => typeof device.name === 'string' && device.name.toLowerCase() === wanted;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Whether a lease may take a matching device now. Session allocation skips
|
|
136
|
+
* the same devices: blocked by an admin, reserved, or failing its health
|
|
137
|
+
* check. Busy and already-leased devices are skipped by the lock itself.
|
|
138
|
+
*/
|
|
139
|
+
function leasable(device, now) {
|
|
140
|
+
if (device.busy || device.userBlocked || device.offline)
|
|
141
|
+
return false;
|
|
142
|
+
if (device.reservedUntil && now < device.reservedUntil)
|
|
143
|
+
return false;
|
|
144
|
+
return device.healthStatus !== 'Unhealthy';
|
|
145
|
+
}
|
|
113
146
|
// What a token for an unknown lease id is compared against: a real hash's
|
|
114
147
|
// length, of a random token nobody holds.
|
|
115
148
|
const UNKNOWN_LEASE_TOKEN_HASH = (0, leaseToken_1.hashToken)((0, leaseToken_1.generateToken)());
|
|
@@ -146,23 +179,41 @@ let LeaseService = class LeaseService {
|
|
|
146
179
|
}
|
|
147
180
|
create(req) {
|
|
148
181
|
return __awaiter(this, void 0, void 0, function* () {
|
|
182
|
+
var _a;
|
|
149
183
|
const durationMs = Math.max(MIN_DURATION_MS, Math.min(req.durationMs, MAX_LEASE_MS));
|
|
150
184
|
const heartbeatSeconds = Math.max(MIN_HEARTBEAT_SECONDS, Math.min(req.heartbeatSeconds, MAX_HEARTBEAT_SECONDS));
|
|
151
|
-
// Step 1:
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
185
|
+
// Step 1: the devices matching every filter, among the phones the caller
|
|
186
|
+
// can see. None is "no device matches" (404); some, but none free, is
|
|
187
|
+
// "all busy" (409). Spec §4.2. Counted within the caller's teams, so the
|
|
188
|
+
// answer can't reveal that another team has such a phone. Through 2.12 only
|
|
189
|
+
// the platform was counted, so an unknown udid answered 409, and `sdk`
|
|
190
|
+
// and `deviceName` were dropped.
|
|
191
|
+
const filters = Object.assign(Object.assign({}, clientFilters(req.filters)), teamScope(req.callerTeamIds));
|
|
192
|
+
const matching = ((_a = (yield this.store.getDevices(filters))) !== null && _a !== void 0 ? _a : []).filter(nameMatches(req.filters.deviceName));
|
|
193
|
+
if (matching.length === 0) {
|
|
194
|
+
// A fixed sentence, so a hidden phone and an unknown udid answer alike.
|
|
195
|
+
throw new NoMatchingDevice('No device matches the filters');
|
|
196
|
+
}
|
|
197
|
+
// Step 2: lock the first free one atomically. Through 2.12 the lock took
|
|
198
|
+
// a blocked, reserved or unhealthy device as readily as any other.
|
|
199
|
+
const lockedAt = Date.now();
|
|
200
|
+
let device = null;
|
|
201
|
+
for (const candidate of matching.filter((d) => leasable(d, lockedAt))) {
|
|
202
|
+
const locked = yield this.store.findAndLockDevice(Object.assign(Object.assign({}, filters), { udid: [candidate.udid], filterByHost: candidate.host }));
|
|
203
|
+
if (!locked)
|
|
204
|
+
continue;
|
|
205
|
+
if (locked.udid === candidate.udid && locked.host === candidate.host) {
|
|
206
|
+
device = locked;
|
|
207
|
+
break;
|
|
162
208
|
}
|
|
163
|
-
|
|
209
|
+
// The host filter is a substring match: another row of that udid was
|
|
210
|
+
// locked. Give it back.
|
|
211
|
+
yield this.store.updateDevice(locked.udid, locked.host, { busy: false });
|
|
212
|
+
}
|
|
213
|
+
if (!device) {
|
|
214
|
+
throw new AllMatchingBusy(`all devices matching ${JSON.stringify(req.filters)} are busy`);
|
|
164
215
|
}
|
|
165
|
-
// Step
|
|
216
|
+
// Step 3: port RPC (or local-call if device.host == this host)
|
|
166
217
|
let ports;
|
|
167
218
|
try {
|
|
168
219
|
const purposes = String(device.platform).toLowerCase() === 'android'
|
|
@@ -182,7 +233,7 @@ let LeaseService = class LeaseService {
|
|
|
182
233
|
yield this.store.updateDevice(device.udid, device.host, { busy: false });
|
|
183
234
|
throw new DeviceUnhealthy(`port allocation failed: ${err.message}`, err);
|
|
184
235
|
}
|
|
185
|
-
// Step
|
|
236
|
+
// Step 4: build token + insert lease row
|
|
186
237
|
const token = (0, leaseToken_1.generateToken)();
|
|
187
238
|
const tokenHash = (0, leaseToken_1.hashToken)(token);
|
|
188
239
|
const now = Date.now();
|
|
@@ -212,7 +263,7 @@ let LeaseService = class LeaseService {
|
|
|
212
263
|
yield this.db.portLease.deleteMany({ where: { port: { in: Object.values(ports) } } });
|
|
213
264
|
throw err;
|
|
214
265
|
}
|
|
215
|
-
// Step
|
|
266
|
+
// Step 5: build the cap bag now that we have lease.id, persist + return.
|
|
216
267
|
// If this update fails (rare, transient DB error), roll the lease back so
|
|
217
268
|
// we don't leave a zombie row with capabilityBag='' that would crash on
|
|
218
269
|
// any later JSON.parse in authorizeSessionUse().
|
|
@@ -222,7 +273,7 @@ let LeaseService = class LeaseService {
|
|
|
222
273
|
where: { id: lease.id },
|
|
223
274
|
data: { capabilityBag: JSON.stringify(bag) },
|
|
224
275
|
});
|
|
225
|
-
// Step
|
|
276
|
+
// Step 6: backfill leaseId on the PortLease rows
|
|
226
277
|
yield this.db.portLease.updateMany({
|
|
227
278
|
where: { port: { in: Object.values(ports) } },
|
|
228
279
|
data: { leaseId: lease.id },
|
|
@@ -232,15 +283,15 @@ let LeaseService = class LeaseService {
|
|
|
232
283
|
try {
|
|
233
284
|
yield this.db.lease.delete({ where: { id: lease.id } });
|
|
234
285
|
}
|
|
235
|
-
catch (
|
|
286
|
+
catch (_b) { }
|
|
236
287
|
try {
|
|
237
288
|
yield this.db.portLease.deleteMany({ where: { port: { in: Object.values(ports) } } });
|
|
238
289
|
}
|
|
239
|
-
catch (
|
|
290
|
+
catch (_c) { }
|
|
240
291
|
try {
|
|
241
292
|
yield this.store.updateDevice(device.udid, device.host, { busy: false });
|
|
242
293
|
}
|
|
243
|
-
catch (
|
|
294
|
+
catch (_d) { }
|
|
244
295
|
throw err;
|
|
245
296
|
}
|
|
246
297
|
return {
|
|
@@ -382,8 +382,13 @@ let OmniVisionService = OmniVisionService_1 = class OmniVisionService {
|
|
|
382
382
|
/**
|
|
383
383
|
* Proactive OCR Search: Finds elements matching text even if not in XML
|
|
384
384
|
*/
|
|
385
|
-
|
|
386
|
-
|
|
385
|
+
/**
|
|
386
|
+
* `throwOnError`: fail instead of answering "no match" when the screenshot
|
|
387
|
+
* or OCR fails. Device control's "Test locator" asks for it; an Appium
|
|
388
|
+
* findElement doesn't, and turns the empty list into NoSuchElement.
|
|
389
|
+
*/
|
|
390
|
+
findByText(driver_1, text_1) {
|
|
391
|
+
return __awaiter(this, arguments, void 0, function* (driver, text, opts = {}) {
|
|
387
392
|
this.logger.info(`Searching for text: "${text}" via proactive OCR...`);
|
|
388
393
|
try {
|
|
389
394
|
const screenshot = yield driver.getScreenshot();
|
|
@@ -412,6 +417,8 @@ let OmniVisionService = OmniVisionService_1 = class OmniVisionService {
|
|
|
412
417
|
}
|
|
413
418
|
catch (err) {
|
|
414
419
|
this.logger.error(`OCR proactive search failed: ${err.message}`);
|
|
420
|
+
if (opts.throwOnError)
|
|
421
|
+
throw err;
|
|
415
422
|
return [];
|
|
416
423
|
}
|
|
417
424
|
});
|
|
@@ -419,12 +426,12 @@ let OmniVisionService = OmniVisionService_1 = class OmniVisionService {
|
|
|
419
426
|
/**
|
|
420
427
|
* AI-based visual find for icons or specific descriptions
|
|
421
428
|
*/
|
|
422
|
-
findByIcon(
|
|
423
|
-
return __awaiter(this,
|
|
429
|
+
findByIcon(driver_1, iconDescription_1) {
|
|
430
|
+
return __awaiter(this, arguments, void 0, function* (driver, iconDescription, opts = {}) {
|
|
424
431
|
this.logger.info(`Searching for: "${iconDescription}" via AI Vision...`);
|
|
425
432
|
try {
|
|
426
433
|
const screenshot = yield driver.getScreenshot();
|
|
427
|
-
const coordinates = yield AIService_1.AI_SERVICE.visualFind(screenshot, iconDescription);
|
|
434
|
+
const coordinates = yield AIService_1.AI_SERVICE.visualFind(screenshot, iconDescription, opts);
|
|
428
435
|
if (coordinates) {
|
|
429
436
|
const el = {
|
|
430
437
|
id: `omni_ai_${Date.now()}`,
|
|
@@ -442,6 +449,8 @@ let OmniVisionService = OmniVisionService_1 = class OmniVisionService {
|
|
|
442
449
|
}
|
|
443
450
|
catch (err) {
|
|
444
451
|
this.logger.error(`AI proactive find failed: ${err.message}`);
|
|
452
|
+
if (opts.throwOnError)
|
|
453
|
+
throw err;
|
|
445
454
|
}
|
|
446
455
|
return null;
|
|
447
456
|
});
|
|
@@ -519,7 +519,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
519
519
|
*/
|
|
520
520
|
stop(groupId) {
|
|
521
521
|
return __awaiter(this, void 0, void 0, function* () {
|
|
522
|
-
var _a, _b;
|
|
522
|
+
var _a, _b, _c, _d;
|
|
523
523
|
ensureOtelInstruments();
|
|
524
524
|
const span = api_1.trace.getTracer('xenon.recording').startSpan('xenon.recording.stop', {
|
|
525
525
|
attributes: { [attributes_1.ATTR.RECORDING_GROUP_ID]: groupId },
|
|
@@ -538,6 +538,20 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
538
538
|
const recordings = yield this.store.listGroup(groupId);
|
|
539
539
|
const out = [];
|
|
540
540
|
for (const r of recordings) {
|
|
541
|
+
// A phone whose recording already ended (its source ended, or this
|
|
542
|
+
// is a second stop), or that handleSourceEnded is finalizing now, is
|
|
543
|
+
// reported as it stands. Finalizing it again overwrote its end,
|
|
544
|
+
// duration and fail_reason, and let go of the phone a second time.
|
|
545
|
+
if (r.status === 'STOPPED' || r.status === 'FAILED' || this.finalizing.has(r.id)) {
|
|
546
|
+
out.push({
|
|
547
|
+
id: r.id,
|
|
548
|
+
udid: r.device_udid,
|
|
549
|
+
status: r.status,
|
|
550
|
+
durationMs: (_b = r.duration_ms) !== null && _b !== void 0 ? _b : undefined,
|
|
551
|
+
sizeBytes: (_c = r.size_bytes) !== null && _c !== void 0 ? _c : undefined,
|
|
552
|
+
});
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
541
555
|
// Claim it so an ffmpeg exit landing mid-stop doesn't finalize the same
|
|
542
556
|
// row from handleSourceEnded at the same time.
|
|
543
557
|
this.finalizing.add(r.id);
|
|
@@ -555,7 +569,7 @@ let RecordingOrchestrator = class RecordingOrchestrator {
|
|
|
555
569
|
}
|
|
556
570
|
catch (err) {
|
|
557
571
|
span.recordException(err);
|
|
558
|
-
span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: (
|
|
572
|
+
span.setStatus({ code: api_1.SpanStatusCode.ERROR, message: (_d = err === null || err === void 0 ? void 0 : err.message) !== null && _d !== void 0 ? _d : 'unknown' });
|
|
559
573
|
throw err;
|
|
560
574
|
}
|
|
561
575
|
finally {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mountPath = mountPath;
|
|
4
|
+
exports.routesOf = routesOf;
|
|
5
|
+
/** '/api', '/control/:udid' or '' from a `use()` layer's regexp. */
|
|
6
|
+
function mountPath(layer) {
|
|
7
|
+
if (layer.regexp.fast_slash)
|
|
8
|
+
return '';
|
|
9
|
+
let n = 0;
|
|
10
|
+
return layer.regexp.source
|
|
11
|
+
.replace(/^\^/, '')
|
|
12
|
+
.replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '')
|
|
13
|
+
.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => { var _a, _b; return `:${(_b = (_a = layer.keys[n++]) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : 'param'}`; })
|
|
14
|
+
.replace(/\\\//g, '/')
|
|
15
|
+
.replace(/\\\./g, '.')
|
|
16
|
+
.replace(/\\-/g, '-');
|
|
17
|
+
}
|
|
18
|
+
function join(a, b) {
|
|
19
|
+
const joined = `${a}/${b}`.replace(/\/+/g, '/');
|
|
20
|
+
return joined.length > 1 ? joined.replace(/\/$/, '') : joined;
|
|
21
|
+
}
|
|
22
|
+
function routesOf(router, prefix = '') {
|
|
23
|
+
const out = new Set();
|
|
24
|
+
const walk = (stack, at) => {
|
|
25
|
+
var _a;
|
|
26
|
+
for (const layer of stack) {
|
|
27
|
+
if (layer.route) {
|
|
28
|
+
const path = join(at, String(layer.route.path));
|
|
29
|
+
for (const [method, on] of Object.entries(layer.route.methods)) {
|
|
30
|
+
if (on && method !== '_all')
|
|
31
|
+
out.add(`${method.toUpperCase()} ${path}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else if ((_a = layer.handle) === null || _a === void 0 ? void 0 : _a.stack) {
|
|
35
|
+
walk(layer.handle.stack, join(at, mountPath(layer)));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
walk(router.stack, prefix);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
@@ -214,12 +214,12 @@ describe('team boundary on /control (integration)', function () {
|
|
|
214
214
|
it('gets 404 on screenshot', () => __awaiter(this, void 0, void 0, function* () {
|
|
215
215
|
const res = yield screenshot(alice, TEAM_B_UDID);
|
|
216
216
|
(0, chai_1.expect)(res.status).to.equal(404);
|
|
217
|
-
(0, chai_1.expect)(res.
|
|
217
|
+
(0, chai_1.expect)(res.body).to.deep.equal({ error: 'not_found', message: 'Device not found' });
|
|
218
218
|
}));
|
|
219
219
|
it('gets 404 on tap', () => __awaiter(this, void 0, void 0, function* () {
|
|
220
220
|
const res = yield tap(alice, TEAM_B_UDID);
|
|
221
221
|
(0, chai_1.expect)(res.status).to.equal(404);
|
|
222
|
-
(0, chai_1.expect)(res.
|
|
222
|
+
(0, chai_1.expect)(res.body).to.deep.equal({ error: 'not_found', message: 'Device not found' });
|
|
223
223
|
}));
|
|
224
224
|
it('gets 404 on stream/ticket, and no ticket', () => __awaiter(this, void 0, void 0, function* () {
|
|
225
225
|
const res = yield ticket(alice, TEAM_B_UDID);
|