@applitools/nml-client 1.11.12 → 1.11.14

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/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.11.14](https://github.com/Applitools-Dev/sdk/compare/js/nml-client@1.11.13...js/nml-client@1.11.14) (2026-01-11)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * nml broker retry mechanism | FLD-3968 FLD-3963 FLD-3950 ([#3430](https://github.com/Applitools-Dev/sdk/issues/3430)) ([42617e0](https://github.com/Applitools-Dev/sdk/commit/42617e021f43a89f8a8f2cb914f489ac8d215714))
9
+
10
+
11
+ ### Dependencies
12
+
13
+ * @applitools/driver bumped to 1.24.4
14
+ #### Bug Fixes
15
+
16
+ * scrolling element fallback logic | FLD-3959 ([#3442](https://github.com/Applitools-Dev/sdk/issues/3442)) ([36348b4](https://github.com/Applitools-Dev/sdk/commit/36348b46e6a127c99d4ccfa58bf386a8e414fb40))
17
+ * @applitools/spec-driver-webdriver bumped to 1.5.4
18
+
19
+ * @applitools/core-base bumped to 1.31.1
20
+ #### Bug Fixes
21
+
22
+ * per-API key heartbeat management | FLD-3889 ([#3406](https://github.com/Applitools-Dev/sdk/issues/3406)) ([5d7f380](https://github.com/Applitools-Dev/sdk/commit/5d7f38037f17006dcc923c4a3dc925e8dded25d8))
23
+
24
+ ## [1.11.13](https://github.com/Applitools-Dev/sdk/compare/js/nml-client@1.11.12...js/nml-client@1.11.13) (2025-12-14)
25
+
26
+
27
+ ### Dependencies
28
+
29
+ * @applitools/core-base bumped to 1.31.0
30
+ #### Features
31
+
32
+ * Baseline branch fallback list | FLD-3837 ([#3373](https://github.com/Applitools-Dev/sdk/issues/3373)) ([e94bb10](https://github.com/Applitools-Dev/sdk/commit/e94bb10ad6b49322a56e4ce6dfde560b237e9ac0))
33
+
3
34
  ## [1.11.12](https://github.com/Applitools-Dev/sdk/compare/js/nml-client@1.11.11...js/nml-client@1.11.12) (2025-12-01)
4
35
 
5
36
 
@@ -26,6 +26,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
26
26
  exports.makeReqBroker = void 0;
27
27
  const req_1 = __importStar(require("@applitools/req"));
28
28
  const utils = __importStar(require("@applitools/utils"));
29
+ function isInvalidBrokerMessage(result) {
30
+ try {
31
+ const parsedResult = JSON.parse(result);
32
+ // Bare empty arrays indicate result is not ready yet
33
+ if (Array.isArray(parsedResult) && parsedResult.length === 0)
34
+ return true;
35
+ // Check objects with empty payload arrays
36
+ if (parsedResult && typeof parsedResult === 'object' && !Array.isArray(parsedResult)) {
37
+ const { payload } = parsedResult;
38
+ if (Array.isArray(payload) && payload.length === 0)
39
+ return true;
40
+ }
41
+ return false;
42
+ }
43
+ catch {
44
+ return true; // unparsable response is equivalent to empty one
45
+ }
46
+ }
29
47
  function makeReqBroker({ settings, logger }) {
30
48
  return (0, req_1.makeReq)({
31
49
  method: 'POST',
@@ -63,34 +81,106 @@ function handleLogs({ logger: defaultLogger } = {}) {
63
81
  },
64
82
  };
65
83
  }
84
+ /**
85
+ * Creates a custom fetch function that buffers polling responses to work around node-fetch clone bug.
86
+ *
87
+ * node-fetch 3.x has a bug where cloning a response after reading its body results in
88
+ * empty clones. Since polling requests use retry validation that reads the body AND
89
+ * have afterResponse hooks that also read the body, we need to buffer BEFORE retry
90
+ * validation runs. We do this by wrapping the fetch function itself.
91
+ *
92
+ * See: js/packages/nml-client/docs/NODE_FETCH_CLONE_BUG.md for full investigation details
93
+ */
94
+ function makeBufferingFetch() {
95
+ return async (input, init) => {
96
+ // Use globalReq to fetch, forwarding all options (proxy, headers, etc.)
97
+ // Pass init directly to preserve proxy settings and other options
98
+ const response = await (0, req_1.default)(input, init);
99
+ // Buffer the body once
100
+ const bufferedBody = await response.text();
101
+ // Create fresh Response with buffered string (not stream)
102
+ const bufferedResponse = new req_1.Response(bufferedBody, {
103
+ status: response.status,
104
+ statusText: response.statusText,
105
+ headers: response.headers,
106
+ });
107
+ // Monkey-patch .text() and .json() to return from buffered string
108
+ // WITHOUT consuming the Response body. This is critical because:
109
+ // 1. Retry validation calls .text() to check for empty payloads
110
+ // 2. Then hooks/caller call .json() to parse the result
111
+ // 3. Calling .text() normally consumes the body, breaking .json()
112
+ bufferedResponse.text = async () => bufferedBody;
113
+ bufferedResponse.json = async () => JSON.parse(bufferedBody);
114
+ // Also patch .clone() to return responses with the same monkey-patched methods
115
+ const originalClone = bufferedResponse.clone.bind(bufferedResponse);
116
+ bufferedResponse.clone = () => {
117
+ const cloned = originalClone();
118
+ cloned.text = async () => bufferedBody;
119
+ cloned.json = async () => JSON.parse(bufferedBody);
120
+ return cloned;
121
+ };
122
+ return bufferedResponse;
123
+ };
124
+ }
66
125
  function handleLongRequests({ req, logger: defaultLogger }) {
67
126
  return {
68
127
  async afterResponse({ request, response, options }) {
128
+ var _a;
69
129
  if (response.status === 200) {
130
+ let attemptCount = 0;
131
+ const MAX_ATTEMPTS = 1500;
132
+ const logger = (_a = options === null || options === void 0 ? void 0 : options.logger) !== null && _a !== void 0 ? _a : defaultLogger;
133
+ // Use custom fetch that buffers responses to work around node-fetch clone bug
134
+ const bufferingFetch = makeBufferingFetch();
70
135
  return req(request.url + '-response', {
71
136
  proxy: options === null || options === void 0 ? void 0 : options.proxy,
72
- retry: {
73
- // 1500 attempts x 200 ms = 5 minutes
74
- limit: 1500,
75
- timeout: 200,
76
- statuses: [404],
77
- },
137
+ fetch: bufferingFetch,
138
+ retry: [
139
+ {
140
+ // 1500 attempts x 200 ms = 5 minutes
141
+ limit: MAX_ATTEMPTS,
142
+ timeout: 200,
143
+ statuses: [404],
144
+ },
145
+ {
146
+ limit: MAX_ATTEMPTS,
147
+ timeout: 200,
148
+ validate: async ({ response }) => {
149
+ if (!response || response.status !== 200)
150
+ return false;
151
+ try {
152
+ const body = await response.clone().text();
153
+ const isInvalid = isInvalidBrokerMessage(body);
154
+ if (isInvalid) {
155
+ logger === null || logger === void 0 ? void 0 : logger.log(`Broker polling request "${options === null || options === void 0 ? void 0 : options.name}" received empty response, retrying (attempt ${attemptCount + 1}/${MAX_ATTEMPTS})`);
156
+ }
157
+ return isInvalid;
158
+ }
159
+ catch (error) {
160
+ return false;
161
+ }
162
+ },
163
+ },
164
+ ],
78
165
  hooks: [
79
166
  {
80
167
  beforeRequest({ request, options: beforeOptions }) {
81
- var _a;
82
- const logger = (_a = options === null || options === void 0 ? void 0 : options.logger) !== null && _a !== void 0 ? _a : defaultLogger;
83
- logger === null || logger === void 0 ? void 0 : logger.log(`Broker polling request "${options === null || options === void 0 ? void 0 : options.name}" will be sent to the address "[${request.method}]${request.url}" with body`, beforeOptions === null || beforeOptions === void 0 ? void 0 : beforeOptions.body);
168
+ attemptCount++;
169
+ logger === null || logger === void 0 ? void 0 : logger.log(`Broker polling request "${options === null || options === void 0 ? void 0 : options.name}" attempt #${attemptCount}/${MAX_ATTEMPTS} will be sent to the address "[${request.method}]${request.url}" with body`, beforeOptions === null || beforeOptions === void 0 ? void 0 : beforeOptions.body);
84
170
  },
85
171
  async afterResponse({ request, response }) {
86
- var _a;
87
- const logger = (_a = options === null || options === void 0 ? void 0 : options.logger) !== null && _a !== void 0 ? _a : defaultLogger;
88
- logger === null || logger === void 0 ? void 0 : logger.log(`Broker polling request "${options === null || options === void 0 ? void 0 : options.name}" that was sent to the address "[${request.method}]${request.url}" respond with ${response.statusText}(${response.status})`, response.status !== 200 ? `and body ${JSON.stringify(await response.clone().text())}` : '');
172
+ try {
173
+ const body = await response.clone().text();
174
+ logger === null || logger === void 0 ? void 0 : logger.log(`Broker polling request "${options === null || options === void 0 ? void 0 : options.name}" attempt #${attemptCount}/${MAX_ATTEMPTS} that was sent to the address "[${request.method}]${request.url}" respond with ${response.statusText}(${response.status}) with body ${JSON.stringify(body)}`);
175
+ }
176
+ catch (error) {
177
+ logger === null || logger === void 0 ? void 0 : logger.log(`Broker polling request "${options === null || options === void 0 ? void 0 : options.name}" attempt #${attemptCount}/${MAX_ATTEMPTS} that was sent to the address "[${request.method}]${request.url}" respond with ${response.statusText}(${response.status})`);
178
+ }
89
179
  },
90
180
  afterError({ request, error }) {
91
181
  var _a;
92
182
  const logger = (_a = options === null || options === void 0 ? void 0 : options.logger) !== null && _a !== void 0 ? _a : defaultLogger;
93
- logger === null || logger === void 0 ? void 0 : logger.error(`Broker polling request "${options === null || options === void 0 ? void 0 : options.name}" that was sent to the address "[${request.method}]${request.url}" failed with error`, error);
183
+ logger === null || logger === void 0 ? void 0 : logger.error(`Broker polling request "${options === null || options === void 0 ? void 0 : options.name}" attempt #${attemptCount}/${MAX_ATTEMPTS} that was sent to the address "[${request.method}]${request.url}" failed with error`, error);
94
184
  },
95
185
  },
96
186
  ],
@@ -105,21 +195,22 @@ function handleUnexpectedResponse() {
105
195
  if (response.status !== 200) {
106
196
  throw new Error(`Something went wrong when communicating with the mobile application, please try running your test again (error code: ${response.status})`);
107
197
  }
108
- else {
109
- const body = await response.text();
110
- const result = JSON.parse(body);
111
- if (result === null || result === void 0 ? void 0 : result.payload) {
112
- const error = utils.types.isArray(result.payload)
113
- ? result.payload.find(payload => payload.error)
114
- : result.payload.error;
115
- if (error) {
116
- const nmlError = new Error(`There was a problem when interacting with the mobile application. The provided error message was "${error.message}" and had a stack trace of "${error.stack}"`);
117
- nmlError.nextPath = result.nextPath;
118
- throw nmlError;
119
- }
198
+ const body = await response.clone().text();
199
+ if (!body) {
200
+ return response;
201
+ }
202
+ const result = JSON.parse(body);
203
+ if (result === null || result === void 0 ? void 0 : result.payload) {
204
+ const error = utils.types.isArray(result.payload)
205
+ ? result.payload.find(payload => payload.error)
206
+ : result.payload.error;
207
+ if (error) {
208
+ const nmlError = new Error(`There was a problem when interacting with the mobile application. The provided error message was "${error.message}" and had a stack trace of "${error.stack}"`);
209
+ nmlError.nextPath = result.nextPath;
210
+ throw nmlError;
120
211
  }
121
- return { response, body };
122
212
  }
213
+ return response;
123
214
  },
124
215
  };
125
216
  }
@@ -81,7 +81,7 @@ function makeNMLRequests({ settings, logger: mainLogger, }) {
81
81
  brokerUrl = result.nextPath;
82
82
  }
83
83
  async function takeScreenshots({ settings, logger = mainLogger, }) {
84
- var _a, _b;
84
+ var _a;
85
85
  logger = logger.extend(mainLogger, { tags: [`nml-request-${utils.general.shortid()}`] });
86
86
  logger.log('Request "takeScreenshots" called with settings', settings);
87
87
  const { localEnvironment, renderEnvironments, environmentSettings } = await (0, get_environments_info_1.getNMLEnvironmentsInfo)({
@@ -114,32 +114,35 @@ function makeNMLRequests({ settings, logger: mainLogger, }) {
114
114
  let screenshots;
115
115
  if (Number(result.protocolVersion) >= 2) {
116
116
  logger.log(`Request "takeScreenshots" was performed on applitools lib v${result.nmlVersion} through protocol v${result.protocolVersion} on device`, result.payload.debugInfo);
117
- if ((_b = result.payload.result) === null || _b === void 0 ? void 0 : _b.some((res) => res.error)) {
118
- const errors = result.payload.result.filter((res) => res.error);
119
- let errMessage = 'There were problems in taking screenshots for';
120
- if (localEnvironment) {
121
- errMessage += ` local environment ${JSON.stringify(localEnvironment)}:\n`;
122
- errMessage += `\t ${JSON.stringify(errors)}`;
123
- }
124
- else {
125
- errMessage += ' environments:';
126
- errMessage += ` \n\t ${errors
127
- .map((err, index) => `Environment ${JSON.stringify(renderEnvironments[index])}: ${JSON.stringify(err)}`)
128
- .join('\n\t')}`;
117
+ // Note: Payload-level errors (payload.error) are already handled by req-broker.ts afterResponse hook
118
+ if (localEnvironment) {
119
+ if (result.payload.result[0].error) {
120
+ throw new Error(`There was a problem in taking screenshot for local environment ${JSON.stringify(localEnvironment)}. The provided error message was "${result.payload.result[0].error.message}" and had a stack trace of "${result.payload.result[0].error.stack}"`);
129
121
  }
130
- throw new Error(errMessage);
122
+ screenshots = [
123
+ {
124
+ image: result.payload.result[0].result.screenshotUrl,
125
+ calculateRegions: result.payload.result[0].result.selectorRegions,
126
+ dom: result.payload.result[0].result.dom,
127
+ environment: localEnvironment,
128
+ },
129
+ ];
130
+ }
131
+ else {
132
+ screenshots = renderEnvironments.map((environment, index) => {
133
+ if (result.payload.result[index].error) {
134
+ throw new Error(`There was a problem in taking screenshot for environment ${JSON.stringify(environment)}. The provided error message was "${result.payload.result[index].error.message}" and had a stack trace of "${result.payload.result[index].error.stack}"`);
135
+ }
136
+ return {
137
+ image: result.payload.result[index].result.screenshotUrl,
138
+ calculateRegions: result.payload.result[index].result.selectorRegions,
139
+ dom: result.payload.result[index].result.dom,
140
+ environment,
141
+ };
142
+ });
131
143
  }
132
- screenshots = renderEnvironments.map((environment, index) => {
133
- return {
134
- image: result.payload.result[index].result.screenshotUrl,
135
- calculateRegions: result.payload.result[index].result.selectorRegions,
136
- dom: result.payload.result[index].result.dom,
137
- environment: localEnvironment || environment,
138
- };
139
- });
140
- if (localEnvironment && renderEnvironments.length > 1) {
141
- logger.warn(`Local environment detected with ${renderEnvironments.length} rendered environment(s). Using local environment ${JSON.stringify(localEnvironment)} and ignoring rendered environments: ${JSON.stringify(renderEnvironments)}. Note - this warning shouldn't appear in normal usage.`);
142
- screenshots = [screenshots[0]];
144
+ if (localEnvironment && renderEnvironments.length > 0) {
145
+ logger.warn(`Local environment detected with ${renderEnvironments.length} rendered environment(s). Using local environment and ignoring rendered environments: ${JSON.stringify(renderEnvironments)}. Note - this warning shouldn't appear in normal usage.`);
143
146
  }
144
147
  }
145
148
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@applitools/nml-client",
3
- "version": "1.11.12",
3
+ "version": "1.11.14",
4
4
  "description": "Client to integrate the SDKs to the Native Mobile Library (NML)",
5
5
  "homepage": "https://applitools.com",
6
6
  "bugs": {
@@ -41,8 +41,8 @@
41
41
  "@applitools/utils": "1.14.1"
42
42
  },
43
43
  "devDependencies": {
44
- "@applitools/core-base": "1.30.1",
45
- "@applitools/spec-driver-webdriver": "^1.5.3",
44
+ "@applitools/core-base": "1.31.1",
45
+ "@applitools/spec-driver-webdriver": "^1.5.4",
46
46
  "@applitools/test-server": "^1.3.5",
47
47
  "@applitools/test-utils": "^1.5.17",
48
48
  "@types/node": "^12.20.55",
@@ -50,7 +50,7 @@
50
50
  "webdriver": "^7.31.1"
51
51
  },
52
52
  "peerDependencies": {
53
- "@applitools/core-base": "1.30.1"
53
+ "@applitools/core-base": "1.31.1"
54
54
  },
55
55
  "engines": {
56
56
  "node": ">=12.13.0"
@@ -1,6 +1,6 @@
1
1
  import type { BrokerServerSettings } from '../types';
2
- import { type Logger } from '@applitools/logger';
3
- import { type Req, type Options } from '@applitools/req';
2
+ import type { Logger } from '@applitools/logger';
3
+ import type { Req, Options } from '@applitools/req';
4
4
  export type ReqBrokerOptions = Options & {
5
5
  name: string;
6
6
  body: {