@xenon-device-management/xenon 2.13.1 → 2.14.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.
Files changed (223) hide show
  1. package/README.md +11 -5
  2. package/lib/package.json +1 -1
  3. package/lib/public/assets/{AnnotationOverlay-Cvpkk4r9.js → AnnotationOverlay-B_VNtKBH.js} +1 -1
  4. package/lib/public/assets/{ApiKeyGate-Bhn5ZtVM.js → ApiKeyGate-BwuaMjkX.js} +1 -1
  5. package/lib/public/assets/{BugReportButton-BA6aEjTC.js → BugReportButton-BAt0_pzq.js} +1 -1
  6. package/lib/public/assets/DeviceMosaicView-BYaQHhTf.js +2 -0
  7. package/lib/public/assets/{EmptyState-CgzbuUvO.js → EmptyState-BSjhk_IV.js} +1 -1
  8. package/lib/public/assets/{FieldGroup-BHc_5qNc.js → FieldGroup-Cb5QZsE1.js} +1 -1
  9. package/lib/public/assets/{FilterMenu-D7OMGuRc.js → FilterMenu-BAaSlWbW.js} +1 -1
  10. package/lib/public/assets/{Menu-C173JJ0l.js → Menu-JGGYrSQQ.js} +1 -1
  11. package/lib/public/assets/{Modal-ChvVUI3B.js → Modal-CFCp3n9d.js} +1 -1
  12. package/lib/public/assets/{RecordingPage-Dhw4d_O6.js → RecordingPage-CbJj_EBT.js} +1 -1
  13. package/lib/public/assets/{RecordingsPage-z97ftGK-.js → RecordingsPage-BJeYnygR.js} +2 -2
  14. package/lib/public/assets/{SegmentedControl-DbXhBGZC.js → SegmentedControl-Bg42Atjy.js} +1 -1
  15. package/lib/public/assets/{SettingCard-C908GgiI.js → SettingCard-Njz1pUIJ.js} +1 -1
  16. package/lib/public/assets/{Table-B1DB_ZN-.js → Table-BcTm5xV2.js} +1 -1
  17. package/lib/public/assets/{activity-Css0xNvp.js → activity-DOVdsfMQ.js} +1 -1
  18. package/lib/public/assets/{ai-settings-BMQK3-yN.js → ai-settings-CsuJroRf.js} +2 -2
  19. package/lib/public/assets/{api-keys-CbJ42eDQ.js → api-keys-BKnNSTw9.js} +1 -1
  20. package/lib/public/assets/{apps-DW1pw2re.js → apps-D0SC6uw6.js} +2 -2
  21. package/lib/public/assets/{arrow-left-Dpog9L0F.js → arrow-left-MMbTmgoE.js} +1 -1
  22. package/lib/public/assets/{arrow-right-CsBIlSIw.js → arrow-right-BMfnGmxW.js} +1 -1
  23. package/lib/public/assets/{arrow-up-right-z1tRQE4R.js → arrow-up-right-DuAftMWB.js} +1 -1
  24. package/lib/public/assets/{auth-shell-mXiX5vNk.js → auth-shell-nZC2JNDO.js} +2 -2
  25. package/lib/public/assets/{builds-page-BcFn23bE.js → builds-page-hDCgTwkS.js} +1 -1
  26. package/lib/public/assets/{button-Cq_zHETv.js → button-Dda4irn_.js} +1 -1
  27. package/lib/public/assets/{calendar-CKxABX9q.js → calendar-BMr0Q62v.js} +1 -1
  28. package/lib/public/assets/{check-C7xu-hGU.js → check-CZ9iw9-e.js} +1 -1
  29. package/lib/public/assets/{chevron-right-BiqfdCGA.js → chevron-right-BRYJJVC9.js} +1 -1
  30. package/lib/public/assets/{circle-check-CyBjjPxR.js → circle-check-CqAJjQ5O.js} +1 -1
  31. package/lib/public/assets/{circle-x-6gMRcx6z.js → circle-x-DlDJtnVz.js} +1 -1
  32. package/lib/public/assets/{clock-U1rDTT1h.js → clock-CpuDKysU.js} +1 -1
  33. package/lib/public/assets/{copy-BIM0ZX0L.js → copy-CAQydNFe.js} +1 -1
  34. package/lib/public/assets/device-explorer-CdO6_nrn.js +282 -0
  35. package/lib/public/assets/{download-DDBRXnAZ.js → download-C9XG6lLv.js} +1 -1
  36. package/lib/public/assets/{forgot-password-Dpg7yxM8.js → forgot-password-CGnkRgE9.js} +1 -1
  37. package/lib/public/assets/h264Stream-VQBphYhg.js +26 -0
  38. package/lib/public/assets/{index-BGBqohfQ.js → index-BbKEXUFH.js} +5 -5
  39. package/lib/public/assets/{index-BTYMAqNW.css → index-Bxoxt9sV.css} +1 -1
  40. package/lib/public/assets/{index-BQ2luT7L.js → index-DJGc1M2f.js} +1 -1
  41. package/lib/public/assets/{input-BvB1cjHE.js → input-Cm0tmSoc.js} +1 -1
  42. package/lib/public/assets/{line-chart-m78VggnW.js → line-chart-D2DyJCml.js} +1 -1
  43. package/lib/public/assets/{list-checks-cY7o1QTL.js → list-checks-UWf1GY99.js} +1 -1
  44. package/lib/public/assets/{lock-loz8txQm.js → lock-CCJv9m_I.js} +1 -1
  45. package/lib/public/assets/{login-BYOvz4lx.js → login-f8vOVW-U.js} +1 -1
  46. package/lib/public/assets/maintenance-settings-C1PrkuAH.js +6 -0
  47. package/lib/public/assets/{monitor-C4HPHb6s.js → monitor-B2jDgwm7.js} +1 -1
  48. package/lib/public/assets/{mouse-pointer-2-BkdKtCzi.js → mouse-pointer-2-DLI1RtzU.js} +1 -1
  49. package/lib/public/assets/{network-C5e0MIWo.js → network-ebT86qoi.js} +1 -1
  50. package/lib/public/assets/{overview-tlu8ijGd.js → overview-C0aykVrd.js} +1 -1
  51. package/lib/public/assets/{page-header-FXPlo6az.js → page-header-BjzgWz-T.js} +1 -1
  52. package/lib/public/assets/{play-CWdk0_Sy.js → play-DLG9t8FU.js} +1 -1
  53. package/lib/public/assets/{plus-B2xCnntV.js → plus-nBFSMu_X.js} +1 -1
  54. package/lib/public/assets/{profile-page-D080qzr4.js → profile-page-CSl0UYlv.js} +1 -1
  55. package/lib/public/assets/{reset-password-D3u4Mbvn.js → reset-password-LeOuHqmB.js} +1 -1
  56. package/lib/public/assets/{runbook-page-hPRZMy7s.js → runbook-page-CMMeQml9.js} +1 -1
  57. package/lib/public/assets/{select-12RThAXc.js → select-88jHcl-Z.js} +1 -1
  58. package/lib/public/assets/{selector-detail-redirect-uQwvV7EQ.js → selector-detail-redirect-BbXKJjZM.js} +1 -1
  59. package/lib/public/assets/{selector-health-page-CThoxDFO.js → selector-health-page-B4rA1Xkv.js} +2 -2
  60. package/lib/public/assets/session-detail-page-COL_RZxw.js +4 -0
  61. package/lib/public/assets/settings-GF6ZMSQ6.js +6 -0
  62. package/lib/public/assets/{stat-tile-B02WEte8.js → stat-tile-BV98e_Sv.js} +1 -1
  63. package/lib/public/assets/{tablet-Dj30VGZl.js → tablet-BiAsnFDj.js} +1 -1
  64. package/lib/public/assets/{teams-uL4fjTDR.js → teams-B4-jrr-1.js} +1 -1
  65. package/lib/public/assets/{trash-2-B54e-TfY.js → trash-2-C5RSuX0e.js} +1 -1
  66. package/lib/public/assets/{upload-B3k8WCSP.js → upload-BG7_rMXK.js} +1 -1
  67. package/lib/public/assets/{use-builds-data-BFn90NH3.js → use-builds-data-J-riQYyC.js} +1 -1
  68. package/lib/public/assets/{use-password-reset-mode-DL2TmWZ0.js → use-password-reset-mode-C8-eH-UG.js} +1 -1
  69. package/lib/public/assets/{users-CqBuxMlX.js → users-CFba5TSy.js} +1 -1
  70. package/lib/public/assets/{users-DjQSucKb.js → users-Cp0beJm3.js} +1 -1
  71. package/lib/public/assets/{video-off-T0rlt0GA.js → video-off-DDsEISjV.js} +1 -1
  72. package/lib/public/assets/webhook-settings-BGu32vSB.js +1 -0
  73. package/lib/public/assets/{zap-gBHqutZV.js → zap-l4VaXzq8.js} +1 -1
  74. package/lib/public/index.html +2 -2
  75. package/lib/schema.json +19 -46
  76. package/lib/src/XenonCapabilityManager.js +22 -5
  77. package/lib/src/app/openapi/control.yaml +71 -20
  78. package/lib/src/app/openapi/grid.yaml +23 -5
  79. package/lib/src/app/openapi/platform.yaml +136 -28
  80. package/lib/src/app/routers/control.js +30 -5
  81. package/lib/src/app/routers/dashboard.js +29 -1
  82. package/lib/src/app/routers/grid.js +32 -10
  83. package/lib/src/app/routers/streamStartConflict.js +3 -0
  84. package/lib/src/app/routers/streamType.js +7 -2
  85. package/lib/src/app/routers/webhook.js +8 -4
  86. package/lib/src/config.js +50 -5
  87. package/lib/src/dashboard/commands.js +185 -141
  88. package/lib/src/dashboard/event-manager.js +39 -6
  89. package/lib/src/data-service/appleFamily.js +43 -0
  90. package/lib/src/data-service/db.js +11 -0
  91. package/lib/src/data-service/device-service.js +46 -1
  92. package/lib/src/data-service/device-store.js +66 -3
  93. package/lib/src/data-service/deviceClaims.js +37 -0
  94. package/lib/src/data-service/deviceFieldOwners.js +25 -11
  95. package/lib/src/data-service/deviceSettings.js +79 -0
  96. package/lib/src/data-service/prisma-store.js +152 -9
  97. package/lib/src/data-service/web-config-service.js +43 -92
  98. package/lib/src/device-managers/AndroidDeviceManager.js +9 -31
  99. package/lib/src/device-managers/ios/WDAClient.js +44 -43
  100. package/lib/src/device-managers/shellCommands.js +96 -0
  101. package/lib/src/device-utils.js +143 -37
  102. package/lib/src/generated/client/edge.js +17 -3
  103. package/lib/src/generated/client/index-browser.js +14 -0
  104. package/lib/src/generated/client/index.d.ts +2054 -379
  105. package/lib/src/generated/client/index.js +17 -3
  106. package/lib/src/generated/client/package.json +1 -1
  107. package/lib/src/generated/client/schema.prisma +35 -7
  108. package/lib/src/generated/client/wasm.js +14 -0
  109. package/lib/src/interceptors/CommandInterceptor.js +29 -8
  110. package/lib/src/interceptors/xenonScripts.js +55 -0
  111. package/lib/src/interfaces/IPluginArgs.js +1 -2
  112. package/lib/src/middleware/deviceAccessGuard.js +63 -9
  113. package/lib/src/plugin.js +15 -1
  114. package/lib/src/prisma.js +1 -1
  115. package/lib/src/scripts/database-check.js +124 -0
  116. package/lib/src/services/AIService.js +99 -0
  117. package/lib/src/services/CleanupService.js +6 -1
  118. package/lib/src/services/InterceptorService.js +70 -13
  119. package/lib/src/services/NetworkConditioningService.js +85 -88
  120. package/lib/src/services/NotificationService.js +36 -29
  121. package/lib/src/services/OrphanSweeper.js +5 -0
  122. package/lib/src/services/PortAllocator.js +5 -0
  123. package/lib/src/services/ServerManager.js +97 -8
  124. package/lib/src/services/SessionLifecycleService.js +63 -53
  125. package/lib/src/services/ShutdownCoordinator.js +33 -0
  126. package/lib/src/services/TeamService.js +20 -3
  127. package/lib/src/services/device-access/SessionOwnerResolver.js +26 -0
  128. package/lib/src/services/device-access/deviceAccessPolicy.js +38 -6
  129. package/lib/src/services/device-access/leaseHold.js +26 -0
  130. package/lib/src/services/device-access/ticketActorAccess.js +6 -0
  131. package/lib/src/services/healing/HealingOrchestrator.js +32 -1
  132. package/lib/src/services/interceptor/AndroidProxyAdapter.js +18 -0
  133. package/lib/src/services/interceptor/interceptorOptions.js +44 -0
  134. package/lib/src/services/lease/activeLeases.js +37 -0
  135. package/lib/src/services/network/AndroidRadios.js +71 -0
  136. package/lib/src/services/network/PhoneNetworkLedger.js +184 -0
  137. package/lib/src/services/network/PhoneNetworkRestore.js +330 -0
  138. package/lib/src/services/network/adbForPhone.js +60 -0
  139. package/lib/src/services/network/phoneNetworkLock.js +20 -0
  140. package/lib/src/services/network/xenonProxy.js +107 -0
  141. package/lib/src/services/omni-vision/OmniVisionService.js +53 -43
  142. package/lib/src/services/omni-vision/VisionAssertionService.js +28 -25
  143. package/lib/src/services/omni-vision/ocrTextMatch.js +105 -0
  144. package/lib/src/services/omni-vision/screenScale.js +68 -0
  145. package/lib/src/services/recording/busy-precheck.js +24 -3
  146. package/lib/src/services/recording/concurrency-gate.js +12 -3
  147. package/lib/src/services/settings/SelfHealingSwitch.js +70 -0
  148. package/lib/src/services/settings/labSettings.js +149 -0
  149. package/lib/src/services/webhookEvents.js +173 -0
  150. package/lib/test/helpers/fake-adb.js +58 -0
  151. package/lib/test/helpers/fake-device-table.js +63 -24
  152. package/lib/test/helpers/scratch-database.js +1 -0
  153. package/lib/test/unit/TeamService.test.js +3 -0
  154. package/lib/test/unit/XenonCapabilityManager.spec.js +4 -2
  155. package/lib/test/unit/apple-family.spec.js +36 -0
  156. package/lib/test/unit/cleanup-settings-db.spec.js +103 -0
  157. package/lib/test/unit/cleanup-settings.spec.js +244 -0
  158. package/lib/test/unit/dashboard-api-paths.spec.js +101 -0
  159. package/lib/test/unit/dashboard-commands.spec.js +249 -0
  160. package/lib/test/unit/dashboard-events-team-scope.spec.js +9 -6
  161. package/lib/test/unit/database-check.spec.js +108 -0
  162. package/lib/test/unit/default-plugin-args.spec.js +33 -0
  163. package/lib/test/unit/device-access-lease.spec.js +270 -0
  164. package/lib/test/unit/device-offline-webhook.spec.js +81 -0
  165. package/lib/test/unit/device-setting-columns.spec.js +66 -0
  166. package/lib/test/unit/device-settings-loki.spec.js +87 -0
  167. package/lib/test/unit/device-settings-survive.spec.js +555 -0
  168. package/lib/test/unit/device-sync-lost-update.spec.js +5 -0
  169. package/lib/test/unit/emulator-boot.spec.js +91 -0
  170. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +48 -0
  171. package/lib/test/unit/hub-node-same-host.spec.js +5 -0
  172. package/lib/test/unit/iphone-ipad-only.spec.js +132 -0
  173. package/lib/test/unit/json-logging-option.spec.js +74 -0
  174. package/lib/test/unit/lab-settings.spec.js +218 -0
  175. package/lib/test/unit/lease/allocate-lease-branch.spec.js +22 -0
  176. package/lib/test/unit/lease/session-lease-proof.spec.js +59 -0
  177. package/lib/test/unit/lease-held-phone.spec.js +196 -0
  178. package/lib/test/unit/max-sessions.spec.js +240 -0
  179. package/lib/test/unit/ocr-text-match.spec.js +44 -0
  180. package/lib/test/unit/omni-vision-analyze-screen.spec.js +72 -0
  181. package/lib/test/unit/omni-vision-ios-points.spec.js +161 -0
  182. package/lib/test/unit/omni-vision-tap-text.spec.js +129 -0
  183. package/lib/test/unit/omni-vision.spec.js +1 -1
  184. package/lib/test/unit/partial-config-file.spec.js +114 -0
  185. package/lib/test/unit/phone-network/boot-clean-up.spec.js +64 -0
  186. package/lib/test/unit/phone-network/interceptor-options.spec.js +77 -0
  187. package/lib/test/unit/phone-network/interceptor-proxy-restore.spec.js +145 -0
  188. package/lib/test/unit/phone-network/network-conditioning.spec.js +145 -0
  189. package/lib/test/unit/phone-network/phone-network-restore.spec.js +246 -0
  190. package/lib/test/unit/phone-network/session-end-paths.spec.js +182 -0
  191. package/lib/test/unit/phone-network/session-start.spec.js +110 -0
  192. package/lib/test/unit/phone-network/xenon-proxy.spec.js +69 -0
  193. package/lib/test/unit/plugin.spec.js +3 -3
  194. package/lib/test/unit/recording-options.spec.js +181 -0
  195. package/lib/test/unit/schema-required-args.spec.js +21 -16
  196. package/lib/test/unit/self-healing-boot.spec.js +75 -0
  197. package/lib/test/unit/self-healing-setting.spec.js +272 -0
  198. package/lib/test/unit/session-failed-webhook.spec.js +170 -0
  199. package/lib/test/unit/shell-commands.spec.js +133 -0
  200. package/lib/test/unit/socketServer.scopedEmit.spec.js +2 -0
  201. package/lib/test/unit/stream-start-pipelines.spec.js +210 -0
  202. package/lib/test/unit/stream-status-type.spec.js +4 -0
  203. package/lib/test/unit/vision-assertion.spec.js +118 -0
  204. package/lib/test/unit/wda-client-shell.spec.js +133 -0
  205. package/lib/test/unit/web-config-service.spec.js +79 -0
  206. package/lib/test/unit/webhook-delivery.spec.js +24 -0
  207. package/lib/test/unit/webhook-payloads.spec.js +273 -0
  208. package/lib/test/unit/xenon-execute-scripts.spec.js +138 -0
  209. package/lib/tsconfig.tsbuildinfo +1 -1
  210. package/lib/web/src/components/webhook-settings/webhookEventVariables.json +30 -0
  211. package/package.json +1 -1
  212. package/prisma/migrations/20261004140000_keep_device_settings/migration.sql +19 -0
  213. package/prisma/schema.prisma +35 -7
  214. package/schema.json +19 -46
  215. package/scripts/generate-types-from-schema.js +7 -2
  216. package/scripts/lib/always-present-plugin-args.js +42 -0
  217. package/lib/public/assets/DeviceMosaicView-ChhHnpFQ.js +0 -2
  218. package/lib/public/assets/device-explorer-DSpAA_t9.js +0 -279
  219. package/lib/public/assets/maintenance-settings-BxbA7SkB.js +0 -6
  220. package/lib/public/assets/recording-group-store-B75435xR.js +0 -26
  221. package/lib/public/assets/session-detail-page-Bc02YjMm.js +0 -4
  222. package/lib/public/assets/settings-C1VIluuT.js +0 -6
  223. package/lib/public/assets/webhook-settings-DAHgnJyZ.js +0 -1
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.settingsDefaults = settingsDefaults;
16
+ exports.isValidCron = isValidCron;
17
+ exports.selfHealingEnabled = selfHealingEnabled;
18
+ exports.effectiveSettings = effectiveSettings;
19
+ exports.loadEffectiveSettings = loadEffectiveSettings;
20
+ exports.validateSettingsUpdate = validateSettingsUpdate;
21
+ const typedi_1 = require("typedi");
22
+ const node_schedule_1 = __importDefault(require("node-schedule"));
23
+ const schema_json_1 = __importDefault(require("../../../schema.json"));
24
+ const logger_1 = __importDefault(require("../../logger"));
25
+ const PluginContext_1 = require("../../PluginContext");
26
+ const web_config_service_1 = require("../../data-service/web-config-service");
27
+ const schemaProperties = schema_json_1.default.properties;
28
+ function schemaDefault(key) {
29
+ return schemaProperties[key].default;
30
+ }
31
+ /** The defaults schema.json declares, which Appium fills in: the server's own. */
32
+ function settingsDefaults() {
33
+ return {
34
+ enableSelfHealing: schemaDefault('enableSelfHealing'),
35
+ healthCheckIntervalMs: schemaDefault('healthCheckIntervalMs'),
36
+ buildCleanupDays: schemaDefault('buildCleanupDays'),
37
+ buildCleanupMaxCount: schemaDefault('buildCleanupMaxCount'),
38
+ buildCleanupSchedule: schemaDefault('buildCleanupSchedule'),
39
+ deleteBuildAssets: schemaDefault('deleteBuildAssets'),
40
+ };
41
+ }
42
+ /**
43
+ * Whether a string is a cron expression the scheduler will run: five or six
44
+ * fields. node-schedule alone also takes a date ("2030-01-01") as a one-off
45
+ * job, which is not a schedule that repeats.
46
+ */
47
+ function isValidCron(value) {
48
+ if (typeof value !== 'string')
49
+ return false;
50
+ const fields = value.trim().split(/\s+/);
51
+ if (fields.length < 5 || fields.length > 6)
52
+ return false;
53
+ if (!fields.every((field) => /^[0-9A-Za-z*/,\-?#]+$/.test(field)))
54
+ return false;
55
+ const job = new node_schedule_1.default.Job(() => undefined);
56
+ const ok = job.schedule(value.trim());
57
+ job.cancel();
58
+ return ok;
59
+ }
60
+ const wholeNumberAtLeastOne = (v) => typeof v === 'number' && Number.isInteger(v) && v >= 1;
61
+ const positiveNumber = (v) => typeof v === 'number' && Number.isFinite(v) && v > 0;
62
+ const isBoolean = (v) => typeof v === 'boolean';
63
+ /** The first value that is usable, in the order given. */
64
+ function firstValid(valid, ...candidates) {
65
+ return candidates.find(valid);
66
+ }
67
+ /**
68
+ * Whether self-healing runs: the value saved in the dashboard, else the plugin
69
+ * option, else the default (on). The same rule `effectiveSettings` applies,
70
+ * on its own because the command interceptor asks at every command and
71
+ * `effectiveSettings` also checks cron expressions. A value that is not a
72
+ * boolean is no choice, so `"false"` as an option does not turn healing off.
73
+ */
74
+ function selfHealingEnabled(startupOption, saved) {
75
+ var _a;
76
+ const fallback = schemaDefault('enableSelfHealing');
77
+ return (_a = firstValid(isBoolean, saved, startupOption, fallback)) !== null && _a !== void 0 ? _a : fallback;
78
+ }
79
+ /**
80
+ * The settings in effect: saved in the dashboard, else the plugin option the
81
+ * server started with, else the default. A saved or started-with value that
82
+ * could not work (a retention window of 0 would purge everything) is skipped,
83
+ * so a bad row never reaches the cleanup job.
84
+ */
85
+ function effectiveSettings(startup, saved) {
86
+ var _a, _b, _c, _d, _e;
87
+ const defaults = settingsDefaults();
88
+ const out = {
89
+ enableSelfHealing: selfHealingEnabled(startup.enableSelfHealing, saved.enableSelfHealing),
90
+ healthCheckIntervalMs: (_a = firstValid(positiveNumber, saved.healthCheckIntervalMs, startup.healthCheckIntervalMs, defaults.healthCheckIntervalMs)) !== null && _a !== void 0 ? _a : defaults.healthCheckIntervalMs,
91
+ buildCleanupDays: (_b = firstValid(wholeNumberAtLeastOne, saved.buildCleanupDays, startup.buildCleanupDays, defaults.buildCleanupDays)) !== null && _b !== void 0 ? _b : defaults.buildCleanupDays,
92
+ buildCleanupMaxCount: (_c = firstValid(wholeNumberAtLeastOne, saved.buildCleanupMaxCount, startup.buildCleanupMaxCount, defaults.buildCleanupMaxCount)) !== null && _c !== void 0 ? _c : defaults.buildCleanupMaxCount,
93
+ buildCleanupSchedule: (_d = firstValid(isValidCron, saved.buildCleanupSchedule, startup.buildCleanupSchedule, defaults.buildCleanupSchedule)) !== null && _d !== void 0 ? _d : defaults.buildCleanupSchedule,
94
+ deleteBuildAssets: (_e = firstValid(isBoolean, saved.deleteBuildAssets, startup.deleteBuildAssets, defaults.deleteBuildAssets)) !== null && _e !== void 0 ? _e : defaults.deleteBuildAssets,
95
+ };
96
+ // A saved schedule of '' is the person clearing it, to go back to the
97
+ // interval; only an unsaved one falls through to the startup option.
98
+ const schedule = typeof saved.healthCheckSchedule === 'string'
99
+ ? saved.healthCheckSchedule
100
+ : startup.healthCheckSchedule;
101
+ if (typeof schedule === 'string' && schedule.trim() !== '') {
102
+ out.healthCheckSchedule = schedule;
103
+ }
104
+ return out;
105
+ }
106
+ /** The settings in effect now: what is saved, over what the server started with. */
107
+ function loadEffectiveSettings() {
108
+ return __awaiter(this, arguments, void 0, function* (startup = typedi_1.Container.get(PluginContext_1.PluginContext).pluginArgs) {
109
+ let saved = {};
110
+ try {
111
+ saved = yield typedi_1.Container.get(web_config_service_1.WebConfigService).getConfig();
112
+ }
113
+ catch (err) {
114
+ // A cleanup that can't read the dashboard's values still runs, on the
115
+ // startup options: skipping a night's cleanup helps nobody.
116
+ logger_1.default.warn(`Could not read the saved settings, using the startup options: ${err === null || err === void 0 ? void 0 : err.message}`);
117
+ }
118
+ return effectiveSettings(startup, saved);
119
+ });
120
+ }
121
+ /**
122
+ * What is wrong with a settings update, or null. The cleanup fields are held to
123
+ * a rule because a retention window of 0, or a cap of 0, would purge every
124
+ * build, and these values used to be inert; the self-healing switch because
125
+ * anything but true or false is no answer to "on or off". The other fields are
126
+ * not checked. Fields not sent are not checked either.
127
+ */
128
+ function validateSettingsUpdate(body) {
129
+ const has = (key) => body[key] !== undefined;
130
+ for (const field of ['buildCleanupDays', 'buildCleanupMaxCount']) {
131
+ if (has(field) && !wholeNumberAtLeastOne(body[field])) {
132
+ return { field, message: `${field} must be a whole number of at least 1.` };
133
+ }
134
+ }
135
+ if (has('buildCleanupSchedule') && !isValidCron(body.buildCleanupSchedule)) {
136
+ return {
137
+ field: 'buildCleanupSchedule',
138
+ message: 'buildCleanupSchedule must be a cron expression with five or six fields, ' +
139
+ "such as '0 0 * * *' for midnight.",
140
+ };
141
+ }
142
+ if (has('deleteBuildAssets') && typeof body.deleteBuildAssets !== 'boolean') {
143
+ return { field: 'deleteBuildAssets', message: 'deleteBuildAssets must be true or false.' };
144
+ }
145
+ if (has('enableSelfHealing') && typeof body.enableSelfHealing !== 'boolean') {
146
+ return { field: 'enableSelfHealing', message: 'enableSelfHealing must be true or false.' };
147
+ }
148
+ return null;
149
+ }
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ /**
3
+ * What each webhook event carries.
4
+ *
5
+ * One shape per event, documented here and nowhere else. A webhook turns it
6
+ * into a message in three ways, and all three read these names:
7
+ *
8
+ * - the built-in Slack message (`type: slack`, `NotificationService`);
9
+ * - the generic JSON body, `{ "event": "<event>", "payload": { ... } }`;
10
+ * - a custom template, where `{{name}}` stands for a name below, `{{eventType}}`
11
+ * for the event itself, and `{{a.b}}` or `{{list.0.b}}` reaches inside.
12
+ *
13
+ * The dashboard offers the names below as template chips
14
+ * (`web/src/components/webhook-settings/webhookEvents.ts`; a spec keeps the two
15
+ * lists equal). A device event also carries the device's other fields, which
16
+ * may change: only the names listed are promised.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.EVENT_TYPES = exports.WEBHOOK_EVENTS = void 0;
20
+ exports.isEventType = isEventType;
21
+ exports.sessionFailedPayload = sessionFailedPayload;
22
+ exports.renderTemplate = renderTemplate;
23
+ const DEVICE_VARIABLES = [
24
+ { name: 'udid', description: "The device's id" },
25
+ { name: 'name', description: "The device's name" },
26
+ { name: 'host', description: 'The machine it is plugged into' },
27
+ { name: 'platform', description: 'android or ios' },
28
+ ];
29
+ const sampleDevice = () => ({
30
+ udid: 'test-device-udid',
31
+ name: 'Test Device',
32
+ host: '127.0.0.1',
33
+ platform: 'android',
34
+ });
35
+ exports.WEBHOOK_EVENTS = {
36
+ device_offline: {
37
+ when: 'A device is no longer reported by its machine (unplugged, or its machine stopped).',
38
+ variables: DEVICE_VARIABLES,
39
+ sample: sampleDevice,
40
+ },
41
+ device_new: {
42
+ when: 'A device is detected for the first time.',
43
+ variables: DEVICE_VARIABLES,
44
+ sample: sampleDevice,
45
+ },
46
+ session_failed: {
47
+ when: 'A session ends as failed, once per session: the test marked it failed, a command failed, ' +
48
+ 'it timed out for inactivity, its driver crashed, or its heartbeat stopped. Not when the ' +
49
+ 'server itself shuts down, and not for a session that was already failed when the server ' +
50
+ 'started.',
51
+ variables: [
52
+ { name: 'sessionId', description: "The session's id" },
53
+ { name: 'sessionName', description: "The session's name, if the test gave it one" },
54
+ { name: 'failureReason', description: 'Why it failed' },
55
+ { name: 'udid', description: "The device's id" },
56
+ { name: 'deviceName', description: "The device's name" },
57
+ { name: 'platform', description: 'android or ios' },
58
+ { name: 'osVersion', description: "The device's OS version" },
59
+ { name: 'startTime', description: 'When it started (ISO 8601)' },
60
+ { name: 'endTime', description: 'When it ended (ISO 8601)' },
61
+ ],
62
+ sample: () => ({
63
+ sessionId: 'test-session-id',
64
+ sessionName: 'Checkout flow',
65
+ failureReason: 'Test message: element not found',
66
+ udid: 'test-device-udid',
67
+ deviceName: 'Test Device',
68
+ platform: 'android',
69
+ osVersion: '14',
70
+ startTime: new Date(Date.now() - 150000).toISOString(),
71
+ endTime: new Date().toISOString(),
72
+ }),
73
+ },
74
+ selector_health_digest: {
75
+ when: 'The Selector Health digest is sent. `hotspots` lists the most healed selectors, each ' +
76
+ 'with `healCount`, `originalSelector` and, when there is one, `suggestedRewrite`; reach ' +
77
+ 'into it with `{{hotspots.0.originalSelector}}`.',
78
+ variables: [
79
+ { name: 'windowDays', description: 'How many days the digest covers' },
80
+ { name: 'totalHeals', description: 'Heals in that time' },
81
+ { name: 'distinctSelectors', description: 'Selectors that were healed' },
82
+ ],
83
+ sample: () => ({
84
+ windowDays: 30,
85
+ totalHeals: 12,
86
+ distinctSelectors: 3,
87
+ hotspots: [
88
+ {
89
+ healCount: 7,
90
+ originalSelector: "//*[@text='Log in']",
91
+ suggestedRewrite: '~login-button',
92
+ },
93
+ ],
94
+ }),
95
+ },
96
+ };
97
+ exports.EVENT_TYPES = Object.keys(exports.WEBHOOK_EVENTS);
98
+ function isEventType(value) {
99
+ return typeof value === 'string' && exports.EVENT_TYPES.includes(value);
100
+ }
101
+ /**
102
+ * `session_failed`'s payload. Not the row: a row has the session's
103
+ * capabilities, the keys that created it and its AI analysis, and its names
104
+ * are the database's (`failure_reason`). Unknown values are empty text, never
105
+ * missing, so a template never shows "undefined" or "null".
106
+ */
107
+ function sessionFailedPayload(session) {
108
+ var _a, _b, _c;
109
+ const iso = (value) => value ? new Date(value).toISOString() : '';
110
+ return {
111
+ sessionId: session.id,
112
+ sessionName: (_a = session.name) !== null && _a !== void 0 ? _a : '',
113
+ failureReason: (_b = session.failure_reason) !== null && _b !== void 0 ? _b : '',
114
+ udid: session.device_udid,
115
+ deviceName: (_c = session.device_name) !== null && _c !== void 0 ? _c : '',
116
+ platform: session.device_platform,
117
+ osVersion: session.device_version,
118
+ startTime: iso(session.startTime),
119
+ endTime: iso(session.endTime),
120
+ };
121
+ }
122
+ function lookup(data, path) {
123
+ let value = data;
124
+ for (const key of path.trim().split('.')) {
125
+ value = value === null || value === undefined ? undefined : value[key];
126
+ }
127
+ return value;
128
+ }
129
+ const asText = (value) => typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value);
130
+ /** `{{name}}` filled in from `data` as text; a name `data` doesn't have stays as written. */
131
+ function fill(text, data) {
132
+ return text.replace(/\{\{([^}]+)\}\}/g, (match, path) => {
133
+ const value = lookup(data, path);
134
+ return value === undefined ? match : asText(value);
135
+ });
136
+ }
137
+ function fillStrings(node, data) {
138
+ if (typeof node === 'string')
139
+ return fill(node, data);
140
+ if (Array.isArray(node))
141
+ return node.map((item) => fillStrings(item, data));
142
+ if (node && typeof node === 'object') {
143
+ return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, fillStrings(value, data)]));
144
+ }
145
+ return node;
146
+ }
147
+ /**
148
+ * The body a custom template sends for an event.
149
+ *
150
+ * A template that is JSON as written is filled in string by string, so a
151
+ * value with a quote, a backslash or a line break (an error message, say)
152
+ * cannot break it. Any other template, such as `{"n": {{totalHeals}}}` or
153
+ * plain text, is filled in as text, then sent as JSON if the result parses,
154
+ * and as `{ "text": "<result>" }` if not.
155
+ */
156
+ function renderTemplate(template, data) {
157
+ let written;
158
+ try {
159
+ written = JSON.parse(template);
160
+ }
161
+ catch (_a) {
162
+ written = undefined;
163
+ }
164
+ if (written !== null && typeof written === 'object')
165
+ return fillStrings(written, data);
166
+ const text = fill(template, data);
167
+ try {
168
+ return JSON.parse(text);
169
+ }
170
+ catch (_b) {
171
+ return { text };
172
+ }
173
+ }
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.fakeAdb = fakeAdb;
16
+ exports.useFakeAdb = useFakeAdb;
17
+ const typedi_1 = require("typedi");
18
+ const AndroidDeviceManager_1 = __importDefault(require("../../src/device-managers/AndroidDeviceManager"));
19
+ const container_registration_1 = require("./container-registration");
20
+ function fakeAdb(answers = {}) {
21
+ const fake = {
22
+ calls: [],
23
+ answers,
24
+ commands(udid) {
25
+ return fake.calls
26
+ .filter((args) => udid === undefined || args[1] === udid)
27
+ .map((args) => args.slice(2).join(' '));
28
+ },
29
+ };
30
+ return fake;
31
+ }
32
+ /**
33
+ * Registers `fake` as every phone's adb for the enclosing `describe`. Returns
34
+ * nothing: the registration is put back after each test.
35
+ */
36
+ function useFakeAdb(get) {
37
+ let restore;
38
+ beforeEach(() => {
39
+ restore = (0, container_registration_1.saveRegistrations)(AndroidDeviceManager_1.default);
40
+ typedi_1.Container.set(AndroidDeviceManager_1.default, {
41
+ getAdbForDevice: (udid) => __awaiter(this, void 0, void 0, function* () {
42
+ return ({
43
+ adbExec: (args) => __awaiter(this, void 0, void 0, function* () {
44
+ var _a;
45
+ const fake = get();
46
+ fake.calls.push(args);
47
+ const key = args.slice(args[0] === '-s' ? 2 : 0).join(' ');
48
+ const answer = (_a = fake.answers[`${udid} ${key}`]) !== null && _a !== void 0 ? _a : fake.answers[key];
49
+ if (answer instanceof Error)
50
+ throw answer;
51
+ return answer !== null && answer !== void 0 ? answer : '';
52
+ }),
53
+ });
54
+ }),
55
+ });
56
+ });
57
+ afterEach(() => restore());
58
+ }
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  /**
3
- * An in-memory device table to stub `prisma.device` with, so a spec runs the
4
- * real PrismaDeviceStore. It follows Prisma's rules: `undefined` leaves a
3
+ * In-memory tables to stub `prisma.device` (fakeDeviceTable) and
4
+ * `prisma.deviceSetting` (fakeDeviceSettingTable) with, so a spec runs the
5
+ * real PrismaDeviceStore. They follow Prisma's rules: `undefined` leaves a
5
6
  * column alone, `null` clears it, an upsert takes `create` for a new row and
6
7
  * `update` for a known one, and `{ contains }` matches a substring (the store
7
8
  * uses it for a host that isn't a URL).
@@ -16,31 +17,69 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
16
17
  });
17
18
  };
18
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.fakeDeviceSettingTable = fakeDeviceSettingTable;
19
21
  exports.fakeDeviceTable = fakeDeviceTable;
20
- function fakeDeviceTable(seed) {
22
+ const key = (udid, host) => `${udid}|${host}`;
23
+ const matches = (row, where) => {
24
+ if (!where)
25
+ return true;
26
+ return Object.entries(where).every(([field, cond]) => {
27
+ var _a;
28
+ if (field === 'OR')
29
+ return cond.some((w) => matches(row, w));
30
+ if (field === 'AND')
31
+ return cond.every((w) => matches(row, w));
32
+ if (cond && typeof cond === 'object' && 'in' in cond)
33
+ return cond.in.includes(row[field]);
34
+ if (cond && typeof cond === 'object' && 'contains' in cond)
35
+ return String((_a = row[field]) !== null && _a !== void 0 ? _a : '').includes(cond.contains);
36
+ return row[field] === cond;
37
+ });
38
+ };
39
+ const write = (row, data) => {
40
+ for (const [field, value] of Object.entries(data))
41
+ if (value !== undefined)
42
+ row[field] = value;
43
+ };
44
+ /**
45
+ * The saved settings of phones (DeviceSetting), to stub `prisma.deviceSetting`
46
+ * with beside fakeDeviceTable, so the store's settings don't reach a database.
47
+ */
48
+ function fakeDeviceSettingTable(seed = []) {
21
49
  const rows = new Map();
22
- const key = (udid, host) => `${udid}|${host}`;
23
- const matches = (row, where) => {
24
- if (!where)
25
- return true;
26
- return Object.entries(where).every(([field, cond]) => {
27
- var _a;
28
- if (field === 'OR')
29
- return cond.some((w) => matches(row, w));
30
- if (field === 'AND')
31
- return cond.every((w) => matches(row, w));
32
- if (cond && typeof cond === 'object' && 'in' in cond)
33
- return cond.in.includes(row[field]);
34
- if (cond && typeof cond === 'object' && 'contains' in cond)
35
- return String((_a = row[field]) !== null && _a !== void 0 ? _a : '').includes(cond.contains);
36
- return row[field] === cond;
37
- });
38
- };
39
- const write = (row, data) => {
40
- for (const [field, value] of Object.entries(data))
41
- if (value !== undefined)
42
- row[field] = value;
50
+ for (const r of seed)
51
+ rows.set(key(r.udid, r.host), Object.assign({ userBlocked: false }, r));
52
+ return {
53
+ row: (udid, host) => [...rows.values()].find((r) => r.udid === udid && (host === undefined || r.host === host)),
54
+ delegate: {
55
+ findMany: (...args_1) => __awaiter(this, [...args_1], void 0, function* (args = {}) { return [...rows.values()].filter((r) => matches(r, args.where)).map((r) => (Object.assign({}, r))); }),
56
+ upsert: (_a) => __awaiter(this, [_a], void 0, function* ({ where, create, update, }) {
57
+ const k = key(where.udid_host.udid, where.udid_host.host);
58
+ const found = rows.get(k);
59
+ if (found) {
60
+ write(found, update);
61
+ return Object.assign({}, found);
62
+ }
63
+ const created = { userBlocked: false };
64
+ write(created, create);
65
+ rows.set(k, created);
66
+ return Object.assign({}, created);
67
+ }),
68
+ deleteMany: (...args_1) => __awaiter(this, [...args_1], void 0, function* (args = {}) {
69
+ let count = 0;
70
+ for (const [k, r] of rows) {
71
+ if (matches(r, args.where)) {
72
+ rows.delete(k);
73
+ count++;
74
+ }
75
+ }
76
+ return { count };
77
+ }),
78
+ },
43
79
  };
80
+ }
81
+ function fakeDeviceTable(seed) {
82
+ const rows = new Map();
44
83
  const defaults = () => ({
45
84
  busy: false,
46
85
  userBlocked: false,
@@ -29,6 +29,7 @@ const MODELS = [
29
29
  'profiling',
30
30
  'app',
31
31
  'device',
32
+ 'deviceSetting',
32
33
  'pendingSession',
33
34
  'cLIArgs',
34
35
  'webhookConfig',
@@ -56,6 +56,7 @@ describe('TeamService (User-keyed)', () => {
56
56
  }));
57
57
  it('delete() blocks when team has devices OR active members', () => __awaiter(void 0, void 0, void 0, function* () {
58
58
  sinon_1.default.stub(prisma_1.prisma.device, 'count').resolves(1);
59
+ sinon_1.default.stub(prisma_1.prisma.deviceSetting, 'findMany').resolves([]);
59
60
  sinon_1.default.stub(prisma_1.prisma.teamMember, 'count').resolves(0);
60
61
  sinon_1.default.stub(prisma_1.prisma.app, 'count').resolves(0);
61
62
  let err;
@@ -72,6 +73,7 @@ describe('TeamService (User-keyed)', () => {
72
73
  // as for its phones: the admin moves them first.
73
74
  it('delete() blocks while the team still owns apps', () => __awaiter(void 0, void 0, void 0, function* () {
74
75
  sinon_1.default.stub(prisma_1.prisma.device, 'count').resolves(0);
76
+ sinon_1.default.stub(prisma_1.prisma.deviceSetting, 'findMany').resolves([]);
75
77
  sinon_1.default.stub(prisma_1.prisma.teamMember, 'count').resolves(0);
76
78
  const apps = sinon_1.default.stub(prisma_1.prisma.app, 'count').resolves(2);
77
79
  const teamDel = sinon_1.default.stub(prisma_1.prisma.team, 'delete').resolves({});
@@ -89,6 +91,7 @@ describe('TeamService (User-keyed)', () => {
89
91
  }));
90
92
  it('delete() drops FK on revoked apiKeys then deletes the team', () => __awaiter(void 0, void 0, void 0, function* () {
91
93
  sinon_1.default.stub(prisma_1.prisma.device, 'count').resolves(0);
94
+ sinon_1.default.stub(prisma_1.prisma.deviceSetting, 'findMany').resolves([]);
92
95
  sinon_1.default.stub(prisma_1.prisma.teamMember, 'count').resolves(0);
93
96
  sinon_1.default.stub(prisma_1.prisma.app, 'count').resolves(0);
94
97
  const apiKeyUpdate = sinon_1.default.stub(prisma_1.prisma.apiKey, 'updateMany').resolves({ count: 0 });
@@ -47,9 +47,11 @@ describe('XenonCapabilityManager.getXenonCapabilities — interceptor activation
47
47
  expect(out[XenonCapabilityManager_1.XENON_CAPABILITIES.SESSION_NAME]).to.equal('new-name');
48
48
  expect(out[XenonCapabilityManager_1.XENON_CAPABILITIES.BUILD_NAME]).to.equal('nightly');
49
49
  });
50
- it('treats no interceptor cap as disabled', () => {
50
+ it("leaves no interceptor cap unset, for the server's `interceptor` option to decide", () => {
51
+ // Off unless the server's option turns it on: resolveInterceptorOptions,
52
+ // test/unit/phone-network/interceptor-options.spec.ts.
51
53
  const out = (0, XenonCapabilityManager_1.getXenonCapabilities)(caps({}));
52
- expect(out[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_ENABLED]).to.equal(false);
54
+ expect(out[XenonCapabilityManager_1.XENON_CAPABILITIES.INTERCEPTOR_ENABLED]).to.equal(undefined);
53
55
  });
54
56
  it('does not throw when firstMatch is missing entirely', () => {
55
57
  const malformed = { alwaysMatch: { interceptor: { enabled: true } } };
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const chai_1 = require("chai");
4
+ const appleFamily_1 = require("../../src/data-service/appleFamily");
5
+ /**
6
+ * Whether a phone is an iPhone or an iPad, for `appium:iPhoneOnly` and
7
+ * `appium:iPadOnly`. Its model and form factor are read before its name: a
8
+ * real phone is named by its owner.
9
+ */
10
+ describe('appleFamilyOf', () => {
11
+ const ios = (over) => (Object.assign({ platform: 'ios', name: 'unknown' }, over));
12
+ it('reads the family from the model first', () => {
13
+ (0, chai_1.expect)((0, appleFamily_1.appleFamilyOf)(ios({ model: 'iPhone17,3', name: 'My iPad' }))).to.equal('iphone');
14
+ (0, chai_1.expect)((0, appleFamily_1.appleFamilyOf)(ios({ model: 'iPad13,4', name: 'iPhone spare' }))).to.equal('ipad');
15
+ });
16
+ it('reads it from the form factor when there is no model (a simulator)', () => {
17
+ (0, chai_1.expect)((0, appleFamily_1.appleFamilyOf)(ios({ formFactor: 'phone', name: 'Test device 1' }))).to.equal('iphone');
18
+ (0, chai_1.expect)((0, appleFamily_1.appleFamilyOf)(ios({ formFactor: 'tablet', name: 'Test device 2' }))).to.equal('ipad');
19
+ });
20
+ it('puts an Apple TV in neither family', () => {
21
+ (0, chai_1.expect)((0, appleFamily_1.appleFamilyOf)(ios({ formFactor: 'tv', name: 'iPad-like TV' }))).to.equal(undefined);
22
+ });
23
+ it('falls back to the marketing name, then the name, for an old row', () => {
24
+ (0, chai_1.expect)((0, appleFamily_1.appleFamilyOf)(ios({ marketingName: 'iPad Air (5th generation)', name: 'x' }))).to.equal('ipad');
25
+ (0, chai_1.expect)((0, appleFamily_1.appleFamilyOf)(ios({ name: 'iPhone 13' }))).to.equal('iphone');
26
+ (0, chai_1.expect)((0, appleFamily_1.appleFamilyOf)(ios({ name: 'iPad (9th generation)' }))).to.equal('ipad');
27
+ (0, chai_1.expect)((0, appleFamily_1.appleFamilyOf)(ios({ name: 'unknown' }))).to.equal(undefined);
28
+ });
29
+ it('is case-insensitive about a name', () => {
30
+ (0, chai_1.expect)((0, appleFamily_1.appleFamilyOf)(ios({ name: 'IPHONE 12' }))).to.equal('iphone');
31
+ });
32
+ it('is nothing for a device that is not iOS', () => {
33
+ (0, chai_1.expect)((0, appleFamily_1.appleFamilyOf)({ platform: 'android', name: 'Pixel Tablet', formFactor: 'tablet' })).to.equal(undefined);
34
+ (0, chai_1.expect)((0, appleFamily_1.appleFamilyOf)({ platform: 'android', name: 'iPhone clone', model: 'iPhone1,1' })).to.equal(undefined);
35
+ });
36
+ });