browserstack-node-sdk 1.65.6 → 1.66.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 (234) hide show
  1. package/ats/src/cloudproviders/AWS.js +1 -1
  2. package/ats/src/cloudproviders/Azure.js +1 -1
  3. package/ats/src/cloudproviders/CloudProvider.js +1 -1
  4. package/ats/src/cloudproviders/GoogleCloudProvider.js +1 -1
  5. package/ats/src/cloudproviders/utils.js +1 -1
  6. package/ats/src/commands/configure.js +1 -1
  7. package/ats/src/commands/connect.js +1 -1
  8. package/ats/src/commands/connect_commands/grid.js +1 -1
  9. package/ats/src/commands/create.js +1 -1
  10. package/ats/src/commands/create_commands/grid.js +1 -1
  11. package/ats/src/commands/delete.js +1 -1
  12. package/ats/src/commands/delete_commands/grid.js +1 -1
  13. package/ats/src/commands/init.js +1 -1
  14. package/ats/src/commands/list.js +1 -1
  15. package/ats/src/commands/list_commands/grid_profiles.js +1 -1
  16. package/ats/src/commands/list_commands/grids.js +1 -1
  17. package/ats/src/commands/precheck.js +1 -1
  18. package/ats/src/commands/precheck_commands/setup_precheck.js +1 -1
  19. package/ats/src/commands/start.js +1 -1
  20. package/ats/src/commands/start_commands/grid.js +1 -1
  21. package/ats/src/commands/stop.js +1 -1
  22. package/ats/src/commands/stop_commands/grid.js +1 -1
  23. package/ats/src/config/constants.js +1 -1
  24. package/ats/src/config/logger.js +1 -1
  25. package/ats/src/index.js +1 -1
  26. package/ats/src/utils/AWSUtils.js +1 -1
  27. package/ats/src/utils/createAutoscalerStack.js +1 -1
  28. package/ats/src/utils/createClusterStack.js +1 -1
  29. package/ats/src/utils/createHubHaStack.js +1 -1
  30. package/ats/src/utils/createk8sClient.js +1 -1
  31. package/ats/src/utils/deleteEFSMountTargets.js +1 -1
  32. package/ats/src/utils/deleteStack.js +1 -1
  33. package/ats/src/utils/describeStack.js +1 -1
  34. package/ats/src/utils/gridComponents.js +1 -1
  35. package/ats/src/utils/hstUtils.js +1 -1
  36. package/ats/src/utils/instrumentationUtils.js +1 -1
  37. package/ats/src/utils/userInput.js +1 -1
  38. package/ats/src/utils/utilityMethods.js +1 -1
  39. package/ats/src/utils/webSocketUtils.js +1 -1
  40. package/generated/sdk-messages_pb.d.ts +118 -0
  41. package/generated/sdk-messages_pb.js +908 -0
  42. package/generated/sdk_grpc_pb.d.ts +10 -0
  43. package/generated/sdk_grpc_pb.js +74 -0
  44. package/package.json +1 -1
  45. package/src/bin/codeceptjs/BrowserStackPlugin.js +1 -1
  46. package/src/bin/codeceptjs/command.js +1 -1
  47. package/src/bin/codeceptjs/helper.js +1 -1
  48. package/src/bin/codeceptjs/o11yHelper.js +1 -1
  49. package/src/bin/cucumber-js/command.js +1 -1
  50. package/src/bin/cucumber-js/formatter/custom_formatter.js +1 -1
  51. package/src/bin/cucumber-js/formatter/custom_formatter_old_versions.js +1 -1
  52. package/src/bin/cucumber-js/test-observability.js +1 -1
  53. package/src/bin/cucumber-js/test.setup.js +1 -1
  54. package/src/bin/cucumber-js/test_old.setup.js +1 -1
  55. package/src/bin/cucumber-js/timeout.setup.js +1 -1
  56. package/src/bin/detox/a11ySetup.js +1 -0
  57. package/src/bin/detox/command.js +2 -0
  58. package/src/bin/detox/modifyDetoxConf.js +1 -0
  59. package/src/bin/detox/traEmitter.js +1 -0
  60. package/src/bin/integrations/command.js +1 -1
  61. package/src/bin/integrations/constants.js +1 -1
  62. package/src/bin/jasmine/bstack-reporter/customReporter.js +1 -1
  63. package/src/bin/jasmine/command.js +1 -1
  64. package/src/bin/jasmine/test.setup.js +1 -1
  65. package/src/bin/jasmine/testRunMap.js +1 -1
  66. package/src/bin/jest/command.js +1 -1
  67. package/src/bin/jest/customImplements/customCircus.js +1 -1
  68. package/src/bin/jest/customImplements/customEnvironment.js +1 -1
  69. package/src/bin/jest/customImplements/customJestAdapterInit.js +1 -1
  70. package/src/bin/jest/customImplements/customSequencer.js +1 -1
  71. package/src/bin/jest/customImplements/customTestRunnerTemplate.js +1 -1
  72. package/src/bin/jest/jest.setup.js +1 -1
  73. package/src/bin/jest/test-observability/data-hooks.js +1 -1
  74. package/src/bin/jest/test-observability/helper.js +1 -1
  75. package/src/bin/jest/test-observability/obsReporter.js +1 -1
  76. package/src/bin/jest/test-observability/test-details.js +1 -1
  77. package/src/bin/jest/test.setup.js +1 -1
  78. package/src/bin/jest/test_before.setup.js +1 -1
  79. package/src/bin/launcher/launcher.js +1 -1
  80. package/src/bin/load/index.js +1 -1
  81. package/src/bin/load/loadErrors.js +1 -1
  82. package/src/bin/load/loadUtils.js +1 -1
  83. package/src/bin/mocha/accessibility-automation/helper.js +1 -1
  84. package/src/bin/mocha/bstack-reporter/customReporter.js +1 -1
  85. package/src/bin/mocha/command.js +1 -1
  86. package/src/bin/mocha/test-observability/data-hooks.js +1 -1
  87. package/src/bin/mocha/test-observability/mochaUtils.js +1 -1
  88. package/src/bin/mocha/test.setup.js +1 -1
  89. package/src/bin/newman/command.js +1 -1
  90. package/src/bin/newman/helper.js +1 -1
  91. package/src/bin/newman/reporter/index.js +1 -1
  92. package/src/bin/newman/scriptRunner.js +1 -1
  93. package/src/bin/nightwatch/command.js +1 -1
  94. package/src/bin/node-generic/client.js +1 -1
  95. package/src/bin/playwright/accessibility-helper.js +1 -1
  96. package/src/bin/playwright/command.js +1 -1
  97. package/src/bin/playwright/globalSetup.js +1 -1
  98. package/src/bin/playwright/mock-process.js +1 -1
  99. package/src/bin/playwright/modifyPlaywrightConf.js +1 -1
  100. package/src/bin/playwright/patches.js +1 -1
  101. package/src/bin/playwright/reporter/pwUtils.js +1 -1
  102. package/src/bin/playwright/reporter/reporter.js +1 -1
  103. package/src/bin/playwright/reporter/test-details.js +1 -1
  104. package/src/bin/playwright/reporterArgs.js +1 -1
  105. package/src/bin/playwright/sessionDetails.js +1 -1
  106. package/src/bin/puppeteer/patches.js +1 -1
  107. package/src/bin/puppeteer/sessionDetails.js +1 -1
  108. package/src/bin/puppeteer/test.setup.js +1 -1
  109. package/src/bin/runner.js +1 -1
  110. package/src/bin/setup.js +1 -1
  111. package/src/bin/test-runner/index.js +1 -1
  112. package/src/bin/test-runner/runTest.js +1 -1
  113. package/src/bin/test-runner/testWorker.js +1 -1
  114. package/src/bin/testcafe/a11y-runner.js +1 -1
  115. package/src/bin/testcafe/command.js +1 -1
  116. package/src/bin/testcafe/reporter/index.js +1 -1
  117. package/src/bin/testcafe/reporter/tcUtils.js +1 -1
  118. package/src/bin/testcafe/session-registry.js +1 -1
  119. package/src/bin/testcafe/stdout-interceptor.js +1 -1
  120. package/src/bin/testcafe/testcafe-action-patcher.js +1 -1
  121. package/src/bin/testcafe/w3c-driver.js +1 -1
  122. package/src/bin/utils/apiUtilityMethods.js +1 -1
  123. package/src/bin/utils/clientTroubleshootingService.js +1 -1
  124. package/src/bin/utils/constants.js +1 -1
  125. package/src/bin/utils/log4jsAppender.js +1 -1
  126. package/src/bin/utils/logPatcher.js +1 -1
  127. package/src/bin/utils/logReportingAPI.js +1 -1
  128. package/src/bin/utils/playwright-patcher.js +1 -1
  129. package/src/bin/utils/puppeteer-patcher.js +1 -1
  130. package/src/bin/utils/sharedPlaywrightSessionDetails.js +1 -1
  131. package/src/bin/utils/sharedUtils.js +1 -1
  132. package/src/bin/v2/TestInfo.js +1 -1
  133. package/src/bin/v2/browserstackCLI.js +1 -1
  134. package/src/bin/v2/cliUtils.js +1 -1
  135. package/src/bin/v2/customTagManager.js +1 -1
  136. package/src/bin/v2/eventDispatcher.js +1 -1
  137. package/src/bin/v2/framework/automationFramework.js +1 -1
  138. package/src/bin/v2/framework/constants/automationFrameworkConstants.js +1 -1
  139. package/src/bin/v2/framework/constants/testFrameworkConstants.js +1 -1
  140. package/src/bin/v2/framework/cucumberTestFramework.js +1 -1
  141. package/src/bin/v2/framework/jestTestFramework.js +1 -1
  142. package/src/bin/v2/framework/newmanTestFramework.js +1 -1
  143. package/src/bin/v2/framework/nodeGenericTestFramework.js +1 -1
  144. package/src/bin/v2/framework/playwrightAutomationFramework.js +1 -1
  145. package/src/bin/v2/framework/playwrightTestFramework.js +1 -1
  146. package/src/bin/v2/framework/puppeteerAutomationFramework.js +1 -1
  147. package/src/bin/v2/framework/testFramework.js +1 -1
  148. package/src/bin/v2/framework/testcafeAutomationFramework.js +1 -1
  149. package/src/bin/v2/framework/testcafeTestFramework.js +1 -1
  150. package/src/bin/v2/grpcClient.js +1 -1
  151. package/src/bin/v2/instances/automationFrameworkInstance.js +1 -1
  152. package/src/bin/v2/instances/testFrameworkInstance.js +1 -1
  153. package/src/bin/v2/instances/trackedContext.js +1 -1
  154. package/src/bin/v2/instances/trackedInstance.js +1 -1
  155. package/src/bin/v2/modules/accessibilityModule.js +1 -1
  156. package/src/bin/v2/modules/baseModule.js +1 -1
  157. package/src/bin/v2/modules/loadTestModule.js +1 -1
  158. package/src/bin/v2/modules/observabilityModule.js +1 -1
  159. package/src/bin/v2/modules/playwrightDriverModule.js +1 -1
  160. package/src/bin/v2/modules/puppeteerDriverModule.js +1 -1
  161. package/src/bin/v2/modules/testcafeDriverModule.js +1 -1
  162. package/src/bin/v2/modules/testhubModule.js +1 -1
  163. package/src/bin/v2/sdkErrorHandler.js +1 -1
  164. package/src/bin/v2/state/automationFrameworkState.js +1 -1
  165. package/src/bin/v2/state/hookState.js +1 -1
  166. package/src/bin/v2/state/testFrameworkState.js +1 -1
  167. package/src/bin/v2/uploadAttachmentsManager.js +1 -1
  168. package/src/bin/v2/utils/cucumber-utils.js +1 -1
  169. package/src/bin/v2/utils/jest-utils.js +1 -1
  170. package/src/bin/vanilla-js/command.js +1 -1
  171. package/src/browserStackSdk.js +1 -1
  172. package/src/detox-driver/a11y.js +574 -0
  173. package/src/detox-driver/allocationDriver.js +26 -0
  174. package/src/detox-driver/cloudPlatformAction.js +111 -0
  175. package/src/detox-driver/environment.js +253 -0
  176. package/src/detox-driver/expect.js +44 -0
  177. package/src/detox-driver/index.js +12 -0
  178. package/src/detox-driver/login-timeout-preload.js +25 -0
  179. package/src/detox-driver/runtimeDriver.js +351 -0
  180. package/src/detox-driver/session-reporter.js +41 -0
  181. package/src/detox-driver/tra-capture.js +81 -0
  182. package/src/helpers/BrowserStackSetup.js +1 -1
  183. package/src/helpers/accessibility-automation/accessibilityRetryHelper.js +1 -1
  184. package/src/helpers/accessibility-automation/constants.js +1 -1
  185. package/src/helpers/accessibility-automation/helper.js +1 -1
  186. package/src/helpers/accessibility-automation/scripts.js +1 -1
  187. package/src/helpers/ats/constants.js +1 -1
  188. package/src/helpers/ats/helper.js +1 -1
  189. package/src/helpers/caCertHelper.js +1 -1
  190. package/src/helpers/capsMapping.js +1 -1
  191. package/src/helpers/central-scanner/constants.js +1 -1
  192. package/src/helpers/central-scanner/helpers.js +1 -1
  193. package/src/helpers/classLogger.js +1 -1
  194. package/src/helpers/driverInitErrorReporter.js +1 -1
  195. package/src/helpers/globals.js +1 -1
  196. package/src/helpers/helper.js +1 -1
  197. package/src/helpers/logger.js +1 -1
  198. package/src/helpers/metadata.js +1 -1
  199. package/src/helpers/patchHelper.js +1 -1
  200. package/src/helpers/patchHelpers.js +1 -1
  201. package/src/helpers/percy/Percy.js +1 -1
  202. package/src/helpers/percy/PercyBinary.js +1 -1
  203. package/src/helpers/percy/PercyCaptureMap.js +1 -1
  204. package/src/helpers/percy/PercyCaptureModes.js +1 -1
  205. package/src/helpers/percy/PercySDK.js +1 -1
  206. package/src/helpers/percy/index.js +1 -1
  207. package/src/helpers/percy/utils.js +1 -1
  208. package/src/helpers/performance/constants.js +1 -1
  209. package/src/helpers/performance/performance-tester.js +1 -1
  210. package/src/helpers/playwright-helper.js +1 -1
  211. package/src/helpers/processHelper.js +1 -1
  212. package/src/helpers/puppeteer-helper.js +1 -1
  213. package/src/helpers/request-spy.js +1 -1
  214. package/src/helpers/setKeepAlive.js +1 -1
  215. package/src/helpers/stackFrameUtils.js +1 -1
  216. package/src/helpers/test-observability/constants.js +1 -1
  217. package/src/helpers/test-observability/error-handler.js +1 -1
  218. package/src/helpers/test-observability/requestQueueHandler.js +1 -1
  219. package/src/helpers/test-observability/sessionHandler.js +1 -1
  220. package/src/helpers/test-observability/utils.js +1 -1
  221. package/src/helpers/test-orchestration/constants.js +1 -1
  222. package/src/helpers/test-orchestration/failfastManager.js +1 -1
  223. package/src/helpers/test-orchestration/fileHelper.js +1 -1
  224. package/src/helpers/test-orchestration/orchestrationUtils.js +1 -1
  225. package/src/helpers/test-orchestration/requestUtils.js +1 -1
  226. package/src/helpers/test-orchestration/testOrchestrationHandler.js +1 -1
  227. package/src/helpers/test-orchestration/testOrderingServerHandler.js +1 -1
  228. package/src/helpers/testhub/constants.js +1 -1
  229. package/src/helpers/testhub/testhubHandler.js +1 -1
  230. package/src/helpers/testhub/utils.js +1 -1
  231. package/src/helpers/utilities.js +1 -1
  232. package/src/helpers/w3cMapping.js +1 -1
  233. package/src/hubAllocationMethods.js +1 -1
  234. package/src/index.js +1 -1
@@ -0,0 +1,574 @@
1
+ 'use strict';
2
+
3
+ // App Accessibility scan hook — runs inside the detox jest process (Node realm), NOT the
4
+ // SDK. Same realm rules as tra-capture/session-reporter: node builtins only, and every
5
+ // path is best-effort. A11y is instrumentation; a scan that cannot be armed, routed or
6
+ // answered must leave the customer's test result byte-identical to a run without it.
7
+ //
8
+ // The arming data (scan script, build uuid, tokens, command list) is resolved in the SDK
9
+ // main process and handed over as a JSON file named by BROWSERSTACK_DETOX_A11Y_CONFIG —
10
+ // the detox child inherits the parent env wholesale. A file rather than an env value
11
+ // because the scan script is an opaque server-supplied string of unbounded length.
12
+ //
13
+ // The scan itself is NOT ours to compose: the server ships a `scan` script carrying a
14
+ // printf-style `%s`, and every App-A11y SDK substitutes a JSON args object into it and
15
+ // hands the result to the framework's execute primitive (java `String.format`, csharp
16
+ // `.Replace("%s", …)`, ruby `format_string`). Detox has no execute primitive and no
17
+ // WebDriver session, so the substituted string is relayed over the one device-side
18
+ // channel that exists — the detox WS bridge, whose `CloudPlatform` action the Hub
19
+ // already intercepts. Which frame shape the Hub routes is discovered at runtime by the
20
+ // candidate ladder below, because it is not documented anywhere we can read.
21
+ const fs = require('fs');
22
+ const https = require('https');
23
+
24
+ const LOG_PREFIX = '[BrowserStack App A11y]';
25
+
26
+ // Deliberately shorter than CloudPlatformAction's 90s: an unrouted frame must not hold a
27
+ // test for a minute and a half, and a scan the far side never answers has to be written
28
+ // off while the run is still cheap to save.
29
+ const SCAN_TIMEOUT_MS = 30000;
30
+
31
+ const loggedOnce = new Set();
32
+ let config = null;
33
+ let disabled = '';
34
+ let routedCandidate = null;
35
+ let laddersWalked = false;
36
+ let scansAttempted = 0;
37
+ let scansRouted = 0;
38
+ // The WS client is reachable only through the runtime driver — the expect/matcher surfaces
39
+ // are handed an invocationManager, not a client. The driver registers it on construction,
40
+ // which always precedes any element action.
41
+ let registeredClient = null;
42
+
43
+ const registerClient = (client) => {
44
+ registeredClient = client || registeredClient;
45
+ };
46
+
47
+ const log = (message) => {
48
+ try {
49
+ console.log(`${LOG_PREFIX} ${message}`);
50
+ } catch (err) { /* a log must never break a run */ }
51
+ };
52
+
53
+ const logOnce = (key, message) => {
54
+ if (loggedOnce.has(key)) {
55
+ return;
56
+ }
57
+ loggedOnce.add(key);
58
+ log(message);
59
+ };
60
+
61
+ const brief = (value, limit = 600) => {
62
+ try {
63
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
64
+ if (text === undefined) {
65
+ return String(value);
66
+ }
67
+
68
+ return text.length > limit ? `${text.slice(0, limit)}…(${text.length} chars)` : text;
69
+ } catch (err) {
70
+ return String(value);
71
+ }
72
+ };
73
+
74
+ // Command entries arrive in the shape App Accessibility serves them:
75
+ // {library, method, module, name, class}. `class` and the trailing segment of `module`
76
+ // both name the surface; either is accepted because only one of them is guaranteed by
77
+ // the served payload. `name` is what the scan reports as its `method` — the served set
78
+ // carries BOTH bare and disambiguated names for the same method on different surfaces
79
+ // (`tap` on NativeElement vs `deviceTap` on RuntimeDevice), which is exactly why the
80
+ // match key is (surface, method) and never the name alone.
81
+ const normaliseCommands = (raw) => {
82
+ const commands = [];
83
+ if (!Array.isArray(raw)) {
84
+ return commands;
85
+ }
86
+ raw.forEach((entry) => {
87
+ if (!entry || !entry.method) {
88
+ return;
89
+ }
90
+ const moduleTail = String(entry.module || '').split('/').pop();
91
+ const surface = String(entry.class || moduleTail || '');
92
+ if (!surface) {
93
+ return;
94
+ }
95
+ commands.push({
96
+ surface: surface.toLowerCase(),
97
+ method: String(entry.method),
98
+ name: String(entry.name || entry.method),
99
+ source: entry.bstackLocal === true ? 'sdk-local' : 'served'
100
+ });
101
+ });
102
+
103
+ return commands;
104
+ };
105
+
106
+ const loadConfig = () => {
107
+ if (config !== null) {
108
+ return config;
109
+ }
110
+ config = false;
111
+ try {
112
+ const file = process.env.BROWSERSTACK_DETOX_A11Y_CONFIG;
113
+ if (!file || !fs.existsSync(file)) {
114
+ return config;
115
+ }
116
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
117
+ if (!raw || !raw.scanScript) {
118
+ log('armed but the build carried no scan script — no scans for this run.');
119
+
120
+ return config;
121
+ }
122
+ const commands = normaliseCommands(raw.commands);
123
+ if (!commands.length) {
124
+ log('armed but the build carried no commands to wrap — no scans for this run.');
125
+
126
+ return config;
127
+ }
128
+ config = {
129
+ scanScript: String(raw.scanScript),
130
+ thBuildUuid: String(raw.thBuildUuid || ''),
131
+ thJwtToken: String(raw.thJwtToken || ''),
132
+ authHeader: String(raw.authHeader || ''),
133
+ centralAuthHeader: String(raw.centralAuthHeader || ''),
134
+ commands,
135
+ hub: raw.hub && raw.hub.user && raw.hub.key ? raw.hub : null,
136
+ probe: raw.probe === true
137
+ };
138
+ const bySource = commands.reduce((acc, cmd) => {
139
+ acc[cmd.source] = (acc[cmd.source] || 0) + 1;
140
+
141
+ return acc;
142
+ }, {});
143
+ log(`armed — ${commands.length} command(s) to wrap (`
144
+ + `${Object.keys(bySource).map((k) => `${k}: ${bySource[k]}`).join(', ')}`
145
+ + `), build ${config.thBuildUuid || '<unknown>'}`
146
+ + `, hub relay ${config.hub ? config.hub.host : 'unavailable (no credentials)'}`
147
+ + `${config.probe ? ', transport probe ON' : ''}.`);
148
+ } catch (err) {
149
+ log(`could not read the arming data (${err && err.message}) — no scans for this run.`);
150
+ config = false;
151
+ }
152
+
153
+ return config;
154
+ };
155
+
156
+ const enabled = () => Boolean(loadConfig()) && !disabled;
157
+
158
+ // Which command name (if any) a call on `surface` maps to. Returns null for anything not
159
+ // in the list — matchers, expectations and every unmapped method stay untouched.
160
+ const commandNameFor = (surface, method) => {
161
+ const cfg = loadConfig();
162
+ if (!cfg) {
163
+ return null;
164
+ }
165
+ const key = String(surface || '').toLowerCase();
166
+ const hit = cfg.commands.find((cmd) => cmd.surface === key && cmd.method === method);
167
+
168
+ return hit ? hit : null;
169
+ };
170
+
171
+ // The scan args, exactly the set the reference SDKs put on the wire for the app branch
172
+ // (javaagent AccessibilityModule.getParamsForScanForCli). `method` is the matched command
173
+ // name; the backend does not validate it against a closed set, so the observed values
174
+ // differ across SDKs (a test-method name from TestNG, command names from WDIO).
175
+ const scanArgs = (cfg, testUuid, commandName) => {
176
+ const args = {
177
+ thTestRunUuid: testUuid,
178
+ thBuildUuid: cfg.thBuildUuid,
179
+ thJwtToken: cfg.thJwtToken,
180
+ authHeader: cfg.authHeader,
181
+ method: commandName,
182
+ scanTimestamp: String(Date.now())
183
+ };
184
+ // The reference SDKs send the CENTRAL SDK JWT here — verified byte-identical to
185
+ // `thJwtToken` in a working TestNG scan (both the 840-char `iss: www.browserstack.com`
186
+ // token; `authHeader` is the separate build-scoped a11y token). Omitting it is why a scan
187
+ // could be accepted (`scan_pending: true`) and still never produce a snapshot: the far
188
+ // side has the build token but not the central one.
189
+ const central = cfg.centralAuthHeader || cfg.thJwtToken;
190
+ if (central) {
191
+ args.centralAuthHeader = central;
192
+ }
193
+
194
+ return args;
195
+ };
196
+
197
+ // Candidate frame shapes for relaying the scan, most-likely first. Every one rides the
198
+ // `CloudPlatform` action the Hub already intercepts for launchApp/terminateApp, so an
199
+ // unrecognised shape is answered (or refused) by the Hub rather than forwarded to the
200
+ // stock upstream detox-server, which would reject an unknown action type. The
201
+ // executor-string variants carry the substituted script verbatim; the argument variants
202
+ // carry the parsed action + arguments, which is the shape app-automate's own spec names
203
+ // for a `device.browserstackExecutor({action, arguments})` helper.
204
+ // The Hub dispatches on `method` and answers an unknown one with a well-formed
205
+ // `{success:false, message:'method <x> not supported'}` — cheaply, in well under a second,
206
+ // with no effect on the socket. That makes a name sweep both safe and conclusive, so the
207
+ // probe list is deliberately broad: the entry name is app-automate's to choose and is not
208
+ // documented anywhere readable from here.
209
+ // The scan's other possible carrier, and the only one that needs nothing from the Hub: the
210
+ // `cloudSessionId` the bridge returns on every CloudPlatform response is a real Hub session
211
+ // key — a request against a FINISHED one is answered `{"status":13,"value":{"message":
212
+ // "Session not started or terminated"}}`, i.e. looked up and found absent, not refused as
213
+ // unroutable. If the record is live for the duration of the session, the standard WebDriver
214
+ // execute endpoint reaches the same `jsExecutor` → `appAllyHandler.appAllyScan` path every
215
+ // Appium SDK uses, with the executor string verbatim and no new Hub route.
216
+ //
217
+ // Never rejects; the outcome is the status line and the body, because a refusal's SHAPE is
218
+ // what distinguishes "no such session" from "no such command" from "not allowed here".
219
+ const httpPost = (hub, pathname, body, timeoutMs) => new Promise((resolve) => {
220
+ let settled = false;
221
+ const done = (outcome) => {
222
+ if (!settled) {
223
+ settled = true;
224
+ resolve(outcome);
225
+ }
226
+ };
227
+ try {
228
+ const request = https.request({
229
+ host: hub.host,
230
+ port: 443,
231
+ method: 'POST',
232
+ path: pathname,
233
+ headers: {
234
+ 'Content-Type': 'application/json',
235
+ 'Content-Length': Buffer.byteLength(body),
236
+ Authorization: `Basic ${Buffer.from(`${hub.user}:${hub.key}`).toString('base64')}`
237
+ },
238
+ timeout: timeoutMs
239
+ }, (response) => {
240
+ let raw = '';
241
+ response.setEncoding('utf8');
242
+ response.on('data', (chunk) => {
243
+ raw += chunk;
244
+ });
245
+ response.on('end', () => done({status: response.statusCode, body: raw}));
246
+ });
247
+ request.on('timeout', () => {
248
+ request.destroy(new Error(`no answer within ${timeoutMs}ms`));
249
+ });
250
+ request.on('error', (err) => done({error: (err && err.message) || String(err)}));
251
+ request.end(body);
252
+ } catch (err) {
253
+ done({error: (err && err.message) || String(err)});
254
+ }
255
+ });
256
+
257
+ const httpScan = async (cfg, candidate, script, timeoutMs) => {
258
+ if (!cfg.hub) {
259
+ return {routed: false, fatal: false, detail: 'no hub credentials in the arming data'};
260
+ }
261
+ const sessionId = require('./cloudPlatformAction').getCloudSessionId();
262
+ if (!sessionId) {
263
+ return {routed: false, fatal: false, detail: 'no cloud session id seen on the bridge yet'};
264
+ }
265
+
266
+ const result = await httpPost(
267
+ cfg.hub, `/wd/hub/session/${sessionId}${candidate.suffix}`, JSON.stringify({script, args: []}), timeoutMs
268
+ );
269
+ if (result.error) {
270
+ return {routed: false, fatal: false, detail: `transport error: ${result.error}`};
271
+ }
272
+
273
+ let parsed = null;
274
+ try {
275
+ parsed = JSON.parse(result.body);
276
+ } catch (err) { /* a non-JSON body is reported verbatim below */ }
277
+ const value = parsed && parsed.value;
278
+ // W3C reports failure as `value.error`; the JSONWire dialect this Hub also speaks reports
279
+ // it as a non-zero top-level `status`. A refusal is never a route.
280
+ const errored = result.status !== 200
281
+ || (value && typeof value === 'object' && value.error)
282
+ || (parsed && parsed.status);
283
+
284
+ return {
285
+ routed: !errored,
286
+ fatal: false,
287
+ detail: `HTTP ${result.status} session=${sessionId} body=${brief(result.body)}`
288
+ };
289
+ };
290
+
291
+ const CANDIDATES = [
292
+ // First because it is the only shape that could make this SDK-only, and because an HTTP
293
+ // call is off the WS socket entirely — it cannot disturb an in-flight detox action.
294
+ {id: 'http:wd-execute-sync', kind: 'http', suffix: '/execute/sync'},
295
+ {id: 'http:wd-execute', kind: 'http', suffix: '/execute'},
296
+
297
+ {id: 'cloudPlatform:appAllyScan', params: (script, args) => ({method: 'appAllyScan', arguments: args})},
298
+ {id: 'cloudPlatform:browserstackExecutor+action', params: (script, args) => ({method: 'browserstackExecutor', action: 'appAllyScan', arguments: args})},
299
+ {id: 'cloudPlatform:browserstackExecutor+script', params: (script) => ({method: 'browserstackExecutor', script})},
300
+ {id: 'cloudPlatform:executeScript', params: (script) => ({method: 'executeScript', script})},
301
+ {id: 'cloudPlatform:accessibilityScan', params: (script, args) => ({method: 'accessibilityScan', arguments: args})},
302
+
303
+ {id: 'cloudPlatform:appAllyScan+script', probeOnly: true, params: (script) => ({method: 'appAllyScan', script})},
304
+ {id: 'cloudPlatform:appAccessibilityScan', probeOnly: true, params: (script, args) => ({method: 'appAccessibilityScan', arguments: args})},
305
+ {id: 'cloudPlatform:performScan', probeOnly: true, params: (script, args) => ({method: 'performScan', arguments: args})},
306
+ {id: 'cloudPlatform:jsExecutor', probeOnly: true, params: (script) => ({method: 'jsExecutor', script})},
307
+ {id: 'cloudPlatform:customExecutor', probeOnly: true, params: (script) => ({method: 'customExecutor', script})},
308
+ {id: 'cloudPlatform:execute', probeOnly: true, params: (script) => ({method: 'execute', script})},
309
+ {id: 'cloudPlatform:sendRequestToPlatform', probeOnly: true, params: (script) => ({method: 'sendRequestToPlatform', script})},
310
+ {id: 'cloudPlatform:accessibility_info', probeOnly: true, params: (script, args) => ({method: 'accessibility_info', arguments: args})},
311
+
312
+ // Deliberately last: a frame whose ACTION TYPE the Hub does not intercept is relayed
313
+ // generically to the stock upstream detox-server, and an unknown type there answers with
314
+ // a frame detox's own _onMessage cannot place — which rejects every pending request and
315
+ // takes the run down. Worth measuring once under the probe flag; never on a customer run.
316
+ {id: 'action:browserstackExecutor', probeOnly: true, actionType: 'browserstackExecutor', params: (script, args) => ({action: 'appAllyScan', arguments: args})}
317
+ ];
318
+
319
+ // A response frame the Hub never answered leaves its entry in the WS client's in-flight
320
+ // map (detox's InflightRequest.reject does not remove it), and the NEXT send then sees two
321
+ // atomic requests in flight and rejects BOTH — which would fail the customer's next
322
+ // command. Clearing our own entry the way detox's own resetInFlightPromises does, and
323
+ // recording it as aborted so a late answer is logged rather than thrown as an unexpected
324
+ // message, is what keeps a dead scan from taking the run with it.
325
+ const releaseInflight = (client, messageId) => {
326
+ try {
327
+ const ws = client && client._asyncWebSocket;
328
+ if (!ws || messageId === undefined || messageId === null) {
329
+ return;
330
+ }
331
+ if (ws.inFlightPromises && Object.prototype.hasOwnProperty.call(ws.inFlightPromises, messageId)) {
332
+ delete ws.inFlightPromises[messageId];
333
+ }
334
+ if (ws._abortedMessageIds && typeof ws._abortedMessageIds.add === 'function') {
335
+ ws._abortedMessageIds.add(+messageId);
336
+ }
337
+ } catch (err) { /* best-effort */ }
338
+ };
339
+
340
+ // Outcome shape: {routed:boolean, fatal:boolean, detail:string}. `fatal` means the socket
341
+ // is no longer safe to send scans on, so the ladder stops and scanning is disabled for the
342
+ // rest of the session — one bad frame, never a cascade.
343
+ const attempt = async (client, actions, candidate, script, args) => {
344
+ if (candidate.kind === 'http') {
345
+ return httpScan(loadConfig(), candidate, script, SCAN_TIMEOUT_MS);
346
+ }
347
+ // Construction is INSIDE the try on purpose: `candidate.params()` and the Action constructor
348
+ // are the caller's own code running inside a real device action's promise chain, so a throw
349
+ // here would fail the customer's command — the exact outcome this file exists to prevent.
350
+ let action;
351
+ try {
352
+ action = new actions.ScanRelayAction(
353
+ candidate.params(script, args), SCAN_TIMEOUT_MS, candidate.actionType
354
+ );
355
+ const response = await client.sendAction(action);
356
+ const type = response && response.type;
357
+ const envelopeAccepted = type === 'CloudPlatform' || type === 'browserstackExecutor';
358
+ // The Hub answers an unmapped method with a well-formed frame of the RIGHT type and
359
+ // `success: false` ("method <x> not supported"). Reading the type alone would score
360
+ // that as a working transport and stop the search at the first refusal.
361
+ const inner = (response && response.response) || {};
362
+ const refused = inner.success === false;
363
+
364
+ if (envelopeAccepted && !refused) {
365
+ return {routed: true, fatal: false, detail: `type=${type} response=${brief(response)}`};
366
+ }
367
+ if (envelopeAccepted) {
368
+ return {routed: false, fatal: false, detail: `Hub refused: ${inner.message || brief(response)}`};
369
+ }
370
+
371
+ return {routed: false, fatal: false, detail: `unexpected response type=${type} response=${brief(response)}`};
372
+ } catch (err) {
373
+ const message = String((err && err.message) || err);
374
+ const timedOut = message.includes('has not received a response within');
375
+ // `action` is undefined if construction itself threw — there is no in-flight entry to
376
+ // release in that case, and dereferencing it here would turn a handled failure into an
377
+ // unhandled one.
378
+ if (timedOut && action) {
379
+ releaseInflight(client, action.messageId);
380
+ }
381
+
382
+ return {routed: false, fatal: timedOut, detail: `${timedOut ? 'timeout' : 'error'}: ${brief(message)}`};
383
+ }
384
+ };
385
+
386
+ // Fire one scan for a completed command. Awaited by the caller on purpose: detox rejects
387
+ // every in-flight atomic action the moment a second one is sent, so a fire-and-forget scan
388
+ // would fail the customer's next command. Never throws.
389
+ const scan = async (client, commandName, testUuid) => {
390
+ const cfg = loadConfig();
391
+ if (!cfg || disabled || !client) {
392
+ return;
393
+ }
394
+
395
+ // A scan with no test to attach to is an orphan the dashboard cannot place. Hook-scope
396
+ // uuids are not published, so a beforeAll/beforeEach action is suppressed rather than
397
+ // reported against an empty uuid.
398
+ if (!testUuid) {
399
+ logOnce('no-uuid', `no scan for "${commandName}" and any other command outside a test body`
400
+ + ' — a scan can only be attributed to a running test.');
401
+
402
+ return;
403
+ }
404
+
405
+ let args;
406
+ let script;
407
+ try {
408
+ // Built once: the script-carrying and argument-carrying relay shapes must describe the
409
+ // same scan, and a second scanArgs() call would give them different scanTimestamps.
410
+ args = scanArgs(cfg, testUuid, commandName);
411
+ // Function replacement: `$&`-style sequences inside a JWT would otherwise be read as
412
+ // replacement patterns.
413
+ const json = JSON.stringify(args);
414
+ script = cfg.scanScript.includes('%s') ? cfg.scanScript.replace('%s', () => json) : cfg.scanScript;
415
+ } catch (err) {
416
+ log(`could not build the scan payload for "${commandName}" (${err && err.message}); skipping.`);
417
+
418
+ return;
419
+ }
420
+
421
+ const actions = require('./cloudPlatformAction');
422
+ scansAttempted++;
423
+
424
+ // Once a candidate has routed, only that one is used. The full ladder is walked exactly
425
+ // once — a detox cloud session allocates rarely enough that one-shape-per-run would cost
426
+ // days, so the first scan of a probe run measures every shape and later scans use the
427
+ // winner. Outside probe mode the ladder stops at the first shape that answers.
428
+ const sweeping = cfg.probe && !laddersWalked;
429
+ const ladder = (routedCandidate && !sweeping)
430
+ ? [routedCandidate]
431
+ : CANDIDATES.filter((candidate) => sweeping || !candidate.probeOnly);
432
+ let routed = false;
433
+
434
+ for (const candidate of ladder) {
435
+ const outcome = await attempt(client, actions, candidate, script, args);
436
+ const line = `scan for "${commandName}" via ${candidate.id} → ${outcome.routed ? 'ROUTED' : 'no route'} — ${outcome.detail}`;
437
+ if (outcome.routed || sweeping) {
438
+ log(line);
439
+ } else {
440
+ logOnce(`nr:${candidate.id}`, line);
441
+ }
442
+
443
+ if (outcome.routed) {
444
+ routed = true;
445
+ if (!routedCandidate) {
446
+ routedCandidate = candidate;
447
+ log(`transport resolved: ${candidate.id}`);
448
+ }
449
+ if (!sweeping) {
450
+ break;
451
+ }
452
+ }
453
+
454
+ if (outcome.fatal) {
455
+ disabled = `transport timed out on ${candidate.id}`;
456
+ log(`scans DISABLED for the rest of this session (${disabled}). The test run is unaffected.`);
457
+ break;
458
+ }
459
+ }
460
+
461
+ if (sweeping) {
462
+ laddersWalked = true;
463
+ }
464
+
465
+ if (routed) {
466
+ scansRouted++;
467
+
468
+ return;
469
+ }
470
+
471
+ // A refused method is a static property of the Hub's route table, not a transient
472
+ // failure — retrying it on every subsequent command would add a round trip per command
473
+ // and change nothing. Stop after one exhausted pass, and say plainly that this run
474
+ // produced NO scans (as distinct from scans that were attempted and failed), because
475
+ // that distinction is the whole difference for whoever picks up the support ticket.
476
+ if (!disabled) {
477
+ disabled = 'the Hub has no route for a detox App Accessibility scan';
478
+ log(`NO SCANS for this run: ${ladder.length} relay shape(s) tried and every one was refused`
479
+ + ` by the Hub (${ladder.map((c) => c.id).join(', ')}). The App Accessibility build was created`
480
+ + ' and armed correctly; the scan relay is not routed. Test results are unaffected.');
481
+ }
482
+ };
483
+
484
+ // Recursive, path-accumulating proxy over one of detox's element objects. Applied ONLY
485
+ // when a11y is armed, so the disabled path returns detox's own objects untouched.
486
+ //
487
+ // Two source hazards it exists to absorb: `NativeElement.atIndex()` and
488
+ // `NativeWaitForElement`'s `get not` both `return this` — the raw target — so a chained
489
+ // call would escape the wrapper unless a result identical to the target is re-wrapped.
490
+ // Methods are applied to the TARGET, not the proxy, so detox's own internal `this.…`
491
+ // calls never re-enter the hook and no action is counted twice.
492
+ const wrap = (target, surface) => {
493
+ if (!target || (typeof target !== 'object' && typeof target !== 'function')) {
494
+ return target;
495
+ }
496
+
497
+ const proxy = new Proxy(target, {
498
+ get(obj, prop) {
499
+ const value = obj[prop];
500
+ if (typeof value !== 'function') {
501
+ return value;
502
+ }
503
+
504
+ const command = commandNameFor(surface, String(prop));
505
+
506
+ return function wrapped(...args) {
507
+ const result = value.apply(obj, args);
508
+
509
+ // Synchronous chaining helper (atIndex, WebViewElement.element): re-wrap so the
510
+ // chain stays instrumented all the way to the leaf action.
511
+ if (!(result && typeof result.then === 'function')) {
512
+ return result === obj ? proxy : wrapChainResult(result);
513
+ }
514
+
515
+ return result.then(async (resolved) => {
516
+ if (command) {
517
+ // Post-settle by construction: a native invocation resolves only after
518
+ // Espresso's idling resources are idle, so the screen the scan captures is the
519
+ // one the command produced. Web-view actions are detox's own documented
520
+ // exception — it adds a hardcoded 500ms and guarantees nothing.
521
+ await scan(registeredClient, command.name, currentTestUuid());
522
+ }
523
+
524
+ return resolved === obj ? proxy : wrapChainResult(resolved);
525
+ });
526
+ };
527
+ }
528
+ });
529
+
530
+ return proxy;
531
+ };
532
+
533
+ // A method that returns a *different* element object (WebViewElement.element() →
534
+ // WebElement) has to hand back a proxy over the new surface, or the leaf action is never
535
+ // seen. Anything that is not one of the wrapped surfaces is returned untouched.
536
+ const WRAPPED_SURFACES = ['NativeElement', 'WebElement', 'WebViewElement'];
537
+
538
+ const wrapChainResult = (result) => {
539
+ try {
540
+ if (!result || typeof result !== 'object') {
541
+ return result;
542
+ }
543
+ const name = result.constructor && result.constructor.name;
544
+ if (WRAPPED_SURFACES.includes(name)) {
545
+ return wrap(result, name);
546
+ }
547
+ } catch (err) { /* best-effort */ }
548
+
549
+ return result;
550
+ };
551
+
552
+ // The owning test's uuid, published per `it()` on the Node-realm global by the SDK's jest
553
+ // environment (the same channel __BSTACK_DETOX_SPEC__ uses) and cleared at test_done —
554
+ // so its absence is precisely the "outside a test body" case R50 requires be suppressed.
555
+ const currentTestUuid = () => {
556
+ try {
557
+ return global.__BSTACK_DETOX_TEST_UUID__ || '';
558
+ } catch (err) {
559
+ return '';
560
+ }
561
+ };
562
+
563
+ const summary = () => ({scansAttempted, scansRouted, disabled, transport: routedCandidate && routedCandidate.id});
564
+
565
+ module.exports = {
566
+ enabled,
567
+ commandNameFor,
568
+ scan,
569
+ wrap,
570
+ registerClient,
571
+ currentTestUuid,
572
+ summary,
573
+ SCAN_TIMEOUT_MS
574
+ };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ // Vanilla Detox's external-module contract (devices/allocation/factories/external.js)
4
+ // requires a `DeviceAllocationDriverClass`. BrowserStack cloud sessions don't allocate
5
+ // a local device — the real "allocation" happens server-side when the runtime driver's
6
+ // launchApp opens the cloud WebSocket session — so this driver is a no-op shell that
7
+ // satisfies the AllocationDriverBase interface (init/allocate/postAllocate/free).
8
+ class CloudAllocationDriver {
9
+ async init() {}
10
+
11
+ // Cookie shape must match detox's AndroidDeviceCookie ({id, adbName}) since
12
+ // RuntimeDevice/DeviceAllocator key off cookie.id — nulls are fine, there is no
13
+ // local adb device to name.
14
+ async allocate(_deviceConfig) {
15
+ return {id: null, adbName: null};
16
+ }
17
+
18
+ async postAllocate(deviceCookie) {
19
+ return deviceCookie;
20
+ }
21
+
22
+ // No real device to release.
23
+ async free(_cookie, _options) {}
24
+ }
25
+
26
+ module.exports = CloudAllocationDriver;