@percy/nightwatch 2.2.1-beta.0 → 2.2.1-beta.2

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.
@@ -46,8 +46,13 @@ module.exports = class PercySnapshotCommand {
46
46
  // Readiness gate — runs before serialize when CLI supports it.
47
47
  const readinessDiagnostics = await waitForReady(this.api, options, utils, log);
48
48
 
49
- // Serialize and capture the DOM
50
- let { domSnapshot, url } = await captureDOM(this.api, options, utils, log, domScript);
49
+ // Serialize and capture the DOM. Merge .percy.yml config with per-snapshot
50
+ // options (per-call wins) before handing them to serialize.
51
+ // domScript + log are threaded through so concurrent percySnapshot calls
52
+ // (parallel workers in the same Node process) don't race on shared
53
+ // module-level state.
54
+ const mergedOptions = utils.mergeSnapshotOptions(options);
55
+ let { domSnapshot, url } = await captureDOM(this.api, mergedOptions, utils, domScript, log);
51
56
 
52
57
  // Attach readiness diagnostics so the CLI can log timing and pass/fail
53
58
  if (readinessDiagnostics && domSnapshot && typeof domSnapshot === 'object' && !Array.isArray(domSnapshot)) {
@@ -61,8 +66,24 @@ module.exports = class PercySnapshotCommand {
61
66
  ...snapshotOptions
62
67
  } = options;
63
68
 
69
+ // Sanitize caller-supplied options before spreading them into the outbound
70
+ // POST body (CWE-284/CWE-1321 — PER-8722): drop prototype-pollution keys
71
+ // and code-bearing fields the SDK must never forward. clientInfo /
72
+ // environmentInfo are hard-coded AFTER the spread so callers can't override.
73
+ const BLOCKED_OPTION_KEYS = new Set([
74
+ '__proto__', 'constructor', 'prototype', 'execute', 'domTransformation'
75
+ ]);
76
+ const safeOptions = {};
77
+ for (const key of Object.keys(snapshotOptions)) {
78
+ if (BLOCKED_OPTION_KEYS.has(key)) {
79
+ log.debug?.(`Ignoring disallowed percySnapshot option: ${key}`);
80
+ continue;
81
+ }
82
+ safeOptions[key] = snapshotOptions[key];
83
+ }
84
+
64
85
  const postData = {
65
- ...snapshotOptions,
86
+ ...safeOptions,
66
87
  domSnapshot,
67
88
  environmentInfo: ENV_INFO,
68
89
  clientInfo: CLIENT_INFO,
package/lib/snapshot.js CHANGED
@@ -1,6 +1,31 @@
1
+ // Canonical iframe-capture helpers, single source of truth (percy/cli #2319).
2
+ // Aliased to the local _FRAME_ names this module already uses.
3
+ const {
4
+ DEFAULT_MAX_IFRAME_DEPTH: DEFAULT_MAX_FRAME_DEPTH,
5
+ isUnsupportedIframeSrc,
6
+ clampIframeDepth: clampFrameDepth,
7
+ normalizeIgnoreSelectors
8
+ } = require('@percy/sdk-utils');
9
+
1
10
  const CS_MAX_SCREENSHOT_LIMIT = 25000;
2
11
  const SCROLL_DEFAULT_SLEEP_TIME = 0.45; // 450ms
3
12
 
13
+ // Kept for backward compatibility with callers that previously pinned a
14
+ // module-scope domScript + log. The new code threads both through ctx /
15
+ // function arguments so two concurrent percySnapshot calls cannot race on
16
+ // shared module state. This function is a no-op.
17
+ function setSnapshotContext(/* script, logger */) {
18
+ // Intentionally empty — context now flows through ctx.
19
+ }
20
+
21
+ function getOrigin(url) {
22
+ try {
23
+ return new URL(url).origin;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
4
29
  function ignoreCanvasSerializationErrors(options = {}, utils) {
5
30
  return options?.ignoreCanvasSerializationErrors ??
6
31
  utils?.percy?.config?.snapshot?.ignoreCanvasSerializationErrors ??
@@ -102,7 +127,273 @@ function getCookies(browser) {
102
127
  }).catch(() => []);
103
128
  }
104
129
 
105
- async function captureSerializedDOM(browser, options = {}, utils) {
130
+ // Cookie names that commonly hold session/auth secrets. These are stripped from
131
+ // the snapshot payload by default so credentials aren't forwarded to the Percy
132
+ // daemon and on to the cloud (CWE-200). Set PERCY_FORWARD_ALL_COOKIES=true to
133
+ // restore forwarding the full cookie jar.
134
+ const SENSITIVE_COOKIE_PATTERN = /session|token|auth|sid|jwt|bearer|csrf|xsrf/i;
135
+
136
+ function filterSensitiveCookies(cookies) {
137
+ if (process.env.PERCY_FORWARD_ALL_COOKIES === 'true' || !Array.isArray(cookies)) return cookies;
138
+ return cookies.filter(cookie => !SENSITIVE_COOKIE_PATTERN.test(cookie?.name || ''));
139
+ }
140
+
141
+ function resolveMaxFrameDepth(options = {}, utils) {
142
+ return clampFrameDepth(
143
+ options.maxIframeDepth ??
144
+ utils?.percy?.config?.snapshot?.maxIframeDepth ??
145
+ DEFAULT_MAX_FRAME_DEPTH
146
+ );
147
+ }
148
+
149
+ function resolveIgnoreSelectors(options = {}, utils) {
150
+ return normalizeIgnoreSelectors(
151
+ options.ignoreIframeSelectors ??
152
+ utils?.percy?.config?.snapshot?.ignoreIframeSelectors ??
153
+ []
154
+ );
155
+ }
156
+
157
+ // In-browser script that walks document.querySelectorAll('iframe') and
158
+ // returns metadata for each. Defined as a function reference so the two
159
+ // call sites (top-level enumeration in captureCorsIframes and per-frame
160
+ // recursion in processFrameTree) share one body.
161
+ function enumerateIframesScript(selectors) {
162
+ let iframes = document.querySelectorAll('iframe');
163
+ let result = [];
164
+ for (let i = 0; i < iframes.length; i++) {
165
+ let frame = iframes[i];
166
+ let matchesIgnore = false;
167
+ if (selectors && selectors.length) {
168
+ for (let j = 0; j < selectors.length; j++) {
169
+ try { if (frame.matches(selectors[j])) { matchesIgnore = true; break; } } catch (e) { /* invalid selector — ignore */ }
170
+ }
171
+ }
172
+ result.push({
173
+ src: frame.src || '',
174
+ srcdoc: frame.getAttribute('srcdoc'),
175
+ percyElementId: frame.getAttribute('data-percy-element-id'),
176
+ dataPercyIgnore: frame.hasAttribute('data-percy-ignore'),
177
+ matchesIgnoreSelector: matchesIgnore,
178
+ index: i
179
+ });
180
+ }
181
+ return result;
182
+ }
183
+
184
+ function shouldSkipIframe(iframe, currentOrigin, log) {
185
+ if (iframe.dataPercyIgnore) {
186
+ log?.debug?.(`Skipping iframe marked with data-percy-ignore: ${iframe.src || '(no src)'}`);
187
+ return true;
188
+ }
189
+ if (iframe.matchesIgnoreSelector) {
190
+ log?.debug?.(`Skipping iframe matching ignoreIframeSelectors: ${iframe.src || '(no src)'}`);
191
+ return true;
192
+ }
193
+ if (!iframe.src || isUnsupportedIframeSrc(iframe.src)) {
194
+ if (iframe.src) log?.debug?.(`Skipping unsupported iframe src: ${iframe.src}`);
195
+ return true;
196
+ }
197
+ if (iframe.srcdoc) {
198
+ log?.debug?.(`Skipping srcdoc iframe at index ${iframe.index}`);
199
+ return true;
200
+ }
201
+ let frameOrigin = getOrigin(iframe.src);
202
+ if (!frameOrigin) {
203
+ log?.debug?.(`Skipping iframe with invalid URL: ${iframe.src}`);
204
+ return true;
205
+ }
206
+ if (frameOrigin === currentOrigin) {
207
+ log?.debug?.(`Skipping same-origin iframe: ${iframe.src}`);
208
+ return true;
209
+ }
210
+ if (!iframe.percyElementId) {
211
+ log?.debug?.(`Skipping cross-origin iframe without data-percy-element-id: ${iframe.src}`);
212
+ return true;
213
+ }
214
+ return false;
215
+ }
216
+
217
+ // Switch into the given iframe element from the current frame context, capture
218
+ // its DOM, then recurse into any cross-origin iframes nested inside it. Restores
219
+ // the parent context on exit. Bounded by maxFrameDepth to prevent runaway
220
+ // recursion when pages link to each other in cycles. `ancestorUrls` tracks the
221
+ // chain of frame URLs above this one — if the current frame's URL appears in
222
+ // the chain, we treat it as a cycle and stop descending (still capturing the
223
+ // current frame once).
224
+ async function processFrameTree(browser, iframe, depth, ancestorUrls, ctx) {
225
+ const { maxFrameDepth, ignoreSelectors, serializeOptions, domScript, log } = ctx;
226
+ if (depth > maxFrameDepth) {
227
+ log?.debug?.(`Reached max iframe nesting depth (${maxFrameDepth}); stopping at ${iframe.src}`);
228
+ return [];
229
+ }
230
+ if (ancestorUrls && ancestorUrls.has(iframe.src)) {
231
+ log?.debug?.(`Skipping cyclic iframe (${iframe.src} appears in ancestor chain)`);
232
+ return [];
233
+ }
234
+
235
+ const collected = [];
236
+ let switchedIn = false;
237
+ let capturedError = null;
238
+
239
+ try {
240
+ log?.debug?.(`Processing cross-origin iframe (depth ${depth}): ${iframe.src}`);
241
+
242
+ // Switch to the iframe by its data-percy-element-id attribute instead of numeric
243
+ // index, which avoids drift if the DOM changes between enumeration and switch.
244
+ // CSS.escape() neutralises any quote/backslash characters that could otherwise
245
+ // break out of the attribute selector — defence in depth, since the id is
246
+ // SDK-generated.
247
+ let iframeElement = await executeScript(browser, function(percyId) {
248
+ /* eslint-disable-next-line no-undef */
249
+ const cssApi = (typeof CSS !== 'undefined') ? CSS : null;
250
+ const safeId = (cssApi && cssApi.escape) ? cssApi.escape(percyId) : String(percyId).replace(/["\\]/g, '\\$&');
251
+ return document.querySelector('iframe[data-percy-element-id="' + safeId + '"]');
252
+ }, [iframe.percyElementId]);
253
+
254
+ if (!iframeElement) {
255
+ log?.warn?.(`Could not find iframe element with data-percy-element-id: ${iframe.percyElementId}`);
256
+ return [];
257
+ }
258
+
259
+ await promisifyBrowserCommand(browser, 'frame', iframeElement);
260
+ switchedIn = true;
261
+
262
+ // Inject PercyDOM into the frame
263
+ if (domScript) {
264
+ await executeScript(browser, domScript);
265
+ log?.debug?.(`Injected PercyDOM into frame: ${iframe.src}`);
266
+ }
267
+
268
+ // Serialize the frame's DOM; enableJavaScript is intentionally forced to true
269
+ // to prevent the standard iframe serialization logic from running, since we
270
+ // handle cross-origin iframe serialization manually here
271
+ let frameResult = await executeScript(browser, function(opts) {
272
+ return {
273
+ /* eslint-disable-next-line no-undef */
274
+ snapshot: PercyDOM.serialize(opts),
275
+ frameUrl: document.URL
276
+ };
277
+ }, [{ ...serializeOptions, enableJavaScript: true }]);
278
+
279
+ if (!frameResult || !frameResult.snapshot) {
280
+ log?.warn?.(`Serialization returned empty result for frame: ${iframe.src}`);
281
+ return [];
282
+ }
283
+
284
+ log?.debug?.(`Captured cross-origin iframe (depth ${depth}): ${frameResult.frameUrl}`);
285
+
286
+ collected.push({
287
+ frameUrl: frameResult.frameUrl,
288
+ iframeData: { percyElementId: iframe.percyElementId },
289
+ iframeSnapshot: frameResult.snapshot
290
+ });
291
+
292
+ // Look for cross-origin iframes nested inside this frame and recurse into them.
293
+ // Same-origin descendants are already inlined as srcdoc by PercyDOM.serialize above.
294
+ if (depth < maxFrameDepth) {
295
+ let currentOrigin = getOrigin(frameResult.frameUrl);
296
+ let childIframesRaw = await executeScript(browser, enumerateIframesScript, [ignoreSelectors]);
297
+ let childIframes = Array.isArray(childIframesRaw) ? childIframesRaw : [];
298
+ let nextAncestors = new Set(ancestorUrls || []);
299
+ nextAncestors.add(frameResult.frameUrl);
300
+ nextAncestors.add(iframe.src);
301
+ for (let child of childIframes) {
302
+ if (shouldSkipIframe(child, currentOrigin, log)) continue;
303
+ let nested = await processFrameTree(browser, child, depth + 1, nextAncestors, ctx);
304
+ if (nested.length) collected.push(...nested);
305
+ }
306
+ }
307
+
308
+ return collected;
309
+ } catch (error) {
310
+ if (error && error.percyContextLost) {
311
+ // Merge any partial capture from the inner level into ours before propagating
312
+ if (Array.isArray(error.partialCapture) && error.partialCapture.length) {
313
+ collected.push(...error.partialCapture);
314
+ error.partialCapture = collected;
315
+ } else {
316
+ error.partialCapture = collected;
317
+ }
318
+ throw error;
319
+ }
320
+ log?.warn?.(`Failed to process cross-origin iframe ${iframe.src}: ${error.message}`);
321
+ capturedError = error;
322
+ return collected;
323
+ } finally {
324
+ if (switchedIn) {
325
+ // Step up exactly one level so an outer recursion can continue from its
326
+ // own context. If parentFrame fails we have no reliable way to land in
327
+ // the correct parent — fall back to top and signal the caller to stop
328
+ // iterating siblings (whose enumeration was performed in a now-lost
329
+ // context). Without this signal, sibling lookups would silently match
330
+ // against the top document and produce wrong percyElementId resolutions.
331
+ try {
332
+ await promisifyBrowserCommand(browser, 'frameParent');
333
+ } catch (e) {
334
+ log?.warn?.(`Failed to switch back to parent frame: ${e.message}`);
335
+ try { await promisifyBrowserCommand(browser, 'frame', null); } catch (_) {}
336
+ // Always raise percyContextLost regardless of depth: even at depth 1
337
+ // the fallback `frame(null)` leaves us at the top document, so the
338
+ // caller's pending sibling enumeration was performed in a now-stale
339
+ // context and must be aborted. Carry partial capture so the outer
340
+ // caller can keep what was already serialized successfully.
341
+ const err = new Error(`Lost parent frame context: ${e.message}`);
342
+ err.percyContextLost = true;
343
+ err.partialCapture = collected;
344
+ // Preserve the original error so it isn't swallowed by this throw
345
+ if (capturedError) err.cause = capturedError;
346
+ // eslint-disable-next-line no-unsafe-finally
347
+ throw err;
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ async function captureCorsIframes(browser, pageUrl, ctx) {
354
+ const { ignoreSelectors, log } = ctx;
355
+ try {
356
+ let iframeInfoRaw = await executeScript(browser, enumerateIframesScript, [ignoreSelectors]);
357
+ let iframeInfo = Array.isArray(iframeInfoRaw) ? iframeInfoRaw : [];
358
+ if (!iframeInfo.length) return [];
359
+
360
+ log?.debug?.(`Found ${iframeInfo.length} top-level iframe(s)`);
361
+
362
+ let pageOrigin = getOrigin(pageUrl);
363
+ let corsIframes = [];
364
+ let skippedCount = 0;
365
+
366
+ for (let iframe of iframeInfo) {
367
+ if (shouldSkipIframe(iframe, pageOrigin, log)) {
368
+ skippedCount++;
369
+ continue;
370
+ }
371
+ let entries;
372
+ try {
373
+ entries = await processFrameTree(browser, iframe, 1, new Set([pageUrl]), ctx);
374
+ } catch (error) {
375
+ if (error && error.percyContextLost) {
376
+ log?.warn?.('Aborting further nested CORS capture due to lost frame context');
377
+ if (Array.isArray(error.partialCapture) && error.partialCapture.length) {
378
+ corsIframes.push(...error.partialCapture);
379
+ }
380
+ break;
381
+ }
382
+ throw error;
383
+ }
384
+ if (entries && entries.length) corsIframes.push(...entries);
385
+ }
386
+
387
+ log?.debug?.(`Captured ${corsIframes.length} cross-origin iframe(s) (top-level skipped: ${skippedCount})`);
388
+
389
+ return corsIframes;
390
+ } catch (error) {
391
+ log?.warn?.(`Error capturing CORS iframes: ${error.message}`);
392
+ return [];
393
+ }
394
+ }
395
+
396
+ async function captureSerializedDOM(browser, options = {}, utils, domScript = null, log = null) {
106
397
  const serializeOptions = {
107
398
  ...options,
108
399
  ignoreCanvasSerializationErrors: ignoreCanvasSerializationErrors(options, utils),
@@ -118,7 +409,21 @@ async function captureSerializedDOM(browser, options = {}, utils) {
118
409
  }, [serializeOptions]);
119
410
 
120
411
  if (!domSnapshot) domSnapshot = {};
121
- domSnapshot.cookies = await getCookies(browser);
412
+ domSnapshot.cookies = filterSensitiveCookies(await getCookies(browser));
413
+
414
+ // Capture cross-origin iframes. ctx carries the per-call domScript + log so
415
+ // concurrent percySnapshot invocations don't race on shared module state.
416
+ const ctx = {
417
+ maxFrameDepth: resolveMaxFrameDepth(options, utils),
418
+ ignoreSelectors: resolveIgnoreSelectors(options, utils),
419
+ serializeOptions,
420
+ domScript,
421
+ log
422
+ };
423
+ let corsIframes = await captureCorsIframes(browser, url, ctx);
424
+ if (corsIframes.length > 0) {
425
+ domSnapshot.corsIframes = corsIframes;
426
+ }
122
427
 
123
428
  return { domSnapshot, url };
124
429
  }
@@ -272,7 +577,12 @@ async function changeWindowDimensionAndWait(browser, width, height, resizeCount,
272
577
  async function maybeReloadPage(browser, domScript) {
273
578
  if (!process.env.PERCY_RESPONSIVE_CAPTURE_RELOAD_PAGE) return false;
274
579
  await promisifyBrowserCommand(browser, 'refresh');
275
- if (domScript) await executeScript(browser, domScript);
580
+ // Guard: if a consumer imported captureDOM directly (bypassing percySnapshot)
581
+ // domScript may be null. Skipping the re-injection is safe — the page reload
582
+ // will only matter when PercyDOM is also pinned.
583
+ if (domScript) {
584
+ await executeScript(browser, domScript);
585
+ }
276
586
  return true;
277
587
  }
278
588
 
@@ -296,7 +606,7 @@ async function getResponsiveHeight(browser, currentHeight, utils) {
296
606
  }, [minHeight]);
297
607
  }
298
608
 
299
- async function captureResponsiveDOM(browser, options, utils, log, domScript) {
609
+ async function captureResponsiveDOM(browser, options, utils, domScript = null, log = null) {
300
610
  const widths = getWidthsForMultiDOM(options.widths || [], utils?.percy?.widths || {});
301
611
  const domSnapshots = [];
302
612
  const { width: originalWidth, height: originalHeight } = await getWindowRect(browser);
@@ -331,7 +641,7 @@ async function captureResponsiveDOM(browser, options, utils, log, domScript) {
331
641
  await maybeSleepForResponsiveCapture();
332
642
  await maybeScrollForLazyLoad(browser);
333
643
 
334
- let { domSnapshot, url } = await captureSerializedDOM(browser, options, utils);
644
+ let { domSnapshot, url } = await captureSerializedDOM(browser, options, utils, domScript, log);
335
645
  domSnapshot.width = width;
336
646
  domSnapshots.push(domSnapshot);
337
647
  lastURL = url;
@@ -341,13 +651,36 @@ async function captureResponsiveDOM(browser, options, utils, log, domScript) {
341
651
  return { domSnapshot: domSnapshots, url: lastURL };
342
652
  }
343
653
 
344
- async function captureDOM(browser, options = {}, utils, log, domScript) {
654
+ // Backward-compatibility shim for the two trailing positional arguments.
655
+ //
656
+ // captureDOM is exported and was published (v2.2.1-beta.0) with the signature
657
+ // captureDOM(browser, options, utils, log, domScript)
658
+ // This branch reordered the trailing pair to (..., domScript, log) so the
659
+ // per-call domScript + logger could be threaded through ctx. Silently swapping
660
+ // two published positional args would break any external caller that imports
661
+ // captureDOM directly and still passes them in the old order.
662
+ //
663
+ // domScript is always the PercyDOM source (a string); log is always a logger
664
+ // object (or null/undefined). They are never the same type, so we can accept
665
+ // either order and route each value to its correct slot. This keeps both the
666
+ // old (log, domScript) and new (domScript, log) call shapes working.
667
+ function normalizeScriptAndLog(a, b) {
668
+ const isScript = v => typeof v === 'string';
669
+ if (isScript(a) && !isScript(b)) return { domScript: a, log: b ?? null };
670
+ if (isScript(b) && !isScript(a)) return { domScript: b, log: a ?? null };
671
+ // Neither is a string (e.g. both null, or domScript passed as non-string):
672
+ // fall back to the new positional order without reinterpreting.
673
+ return { domScript: a ?? null, log: b ?? null };
674
+ }
675
+
676
+ async function captureDOM(browser, options = {}, utils, arg4 = null, arg5 = null) {
677
+ const { domScript, log } = normalizeScriptAndLog(arg4, arg5);
345
678
  const responsiveEnabled = isResponsiveOptionEnabled(options, utils);
346
679
  if (responsiveEnabled) {
347
- return await captureResponsiveDOM(browser, options, utils, log, domScript);
680
+ return await captureResponsiveDOM(browser, options, utils, domScript, log);
348
681
  }
349
682
 
350
- return await captureSerializedDOM(browser, options, utils);
683
+ return await captureSerializedDOM(browser, options, utils, domScript, log);
351
684
  }
352
685
 
353
686
  async function slowScrollToBottom(browser, scrollSleep = SCROLL_DEFAULT_SLEEP_TIME) {
@@ -400,8 +733,13 @@ async function slowScrollToBottom(browser, scrollSleep = SCROLL_DEFAULT_SLEEP_TI
400
733
  module.exports = {
401
734
  captureSerializedDOM,
402
735
  captureDOM,
736
+ setSnapshotContext,
403
737
  slowScrollToBottom,
404
738
  ignoreCanvasSerializationErrors,
405
739
  ignoreStyleSheetSerializationErrors,
740
+ filterSensitiveCookies,
741
+ isUnsupportedIframeSrc,
742
+ getOrigin,
743
+ captureCorsIframes,
406
744
  waitForReady
407
745
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@percy/nightwatch",
3
3
  "description": "Nightwatch client library for visual testing with Percy",
4
- "version": "2.2.1-beta.0",
4
+ "version": "2.2.1-beta.2",
5
5
  "license": "MIT",
6
6
  "author": "Perceptual Inc.",
7
7
  "repository": "https://github.com/percy/percy-nightwatch",
@@ -29,14 +29,14 @@
29
29
  "test:types": "tsd"
30
30
  },
31
31
  "dependencies": {
32
- "@percy/sdk-utils": "^1.31.15-beta.0",
32
+ "@percy/sdk-utils": "1.32.3-beta.1",
33
33
  "@percy/selenium-webdriver": "^2.2.3"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "nightwatch": ">=1 || >=2 || >=3"
37
37
  },
38
38
  "devDependencies": {
39
- "@percy/cli": "^1.31.4",
39
+ "@percy/cli": "1.31.14",
40
40
  "cross-env": "^10.1.0",
41
41
  "eslint": "^7.11.0",
42
42
  "eslint-config-standard": "^16.0.1",
@@ -49,5 +49,6 @@
49
49
  "nightwatch": "^3.12.3",
50
50
  "nyc": "^17.1.0",
51
51
  "tsd": "^0.33.0"
52
- }
52
+ },
53
+ "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
53
54
  }