@percy/core 1.31.0-alpha.3 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +21 -4
- package/dist/browser.js +9 -3
- package/dist/config.js +149 -0
- package/dist/discovery.js +23 -4
- package/dist/install.js +6 -6
- package/dist/network.js +10 -0
- package/dist/percy.js +96 -1
- package/dist/queue.js +8 -0
- package/dist/session.js +6 -0
- package/dist/snapshot.js +8 -6
- package/dist/utils.js +50 -0
- package/package.json +9 -8
- package/test/helpers/server.js +10 -3
- package/types/index.d.ts +1 -0
package/dist/api.js
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { createRequire } from 'module';
|
|
2
|
+
import path, { dirname, resolve } from 'path';
|
|
4
3
|
import logger from '@percy/logger';
|
|
5
4
|
import { normalize } from '@percy/config/utils';
|
|
6
5
|
import { getPackageJSON, Server, percyAutomateRequestHandler, percyBuildEventHandler } from './utils.js';
|
|
7
6
|
import WebdriverUtils from '@percy/webdriver-utils';
|
|
8
7
|
import { handleSyncJob } from './snapshot.js';
|
|
9
|
-
//
|
|
10
|
-
|
|
8
|
+
// Previously, we used `createRequire(import.meta.url).resolve` to resolve the path to the module.
|
|
9
|
+
// This approach relied on `createRequire`, which is Node.js-specific and less compatible with modern ESM (ECMAScript Module) standards.
|
|
10
|
+
// This was leading to hard coded paths when CLI is used as a dependency in another project.
|
|
11
|
+
// Now, we use `fileURLToPath` and `path.resolve` to determine the absolute path in a way that's more aligned with ESM conventions.
|
|
12
|
+
// This change ensures better compatibility and avoids relying on Node.js-specific APIs that might cause issues in ESM environments.
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
import { createRequire } from 'module';
|
|
15
|
+
export const getPercyDomPath = url => {
|
|
16
|
+
try {
|
|
17
|
+
return createRequire(url).resolve('@percy/dom');
|
|
18
|
+
} catch (error) {
|
|
19
|
+
logger('core:server').warn(['Failed to resolve @percy/dom path using createRequire.', 'Falling back to using fileURLToPath and path.resolve.'].join(' '));
|
|
20
|
+
}
|
|
21
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
22
|
+
const __dirname = dirname(__filename);
|
|
23
|
+
return resolve(__dirname, 'node_modules/@percy/dom');
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Resolved path for PERCY_DOM
|
|
27
|
+
export const PERCY_DOM = getPercyDomPath(import.meta.url);
|
|
11
28
|
|
|
12
29
|
// Returns a URL encoded string of nested query params
|
|
13
30
|
function encodeURLSearchParams(subj, prefix) {
|
package/dist/browser.js
CHANGED
|
@@ -72,7 +72,7 @@ export class Browser extends EventEmitter {
|
|
|
72
72
|
this.readyState = 0;
|
|
73
73
|
let {
|
|
74
74
|
cookies = [],
|
|
75
|
-
launchOptions
|
|
75
|
+
launchOptions
|
|
76
76
|
} = this.percy.config.discovery;
|
|
77
77
|
let {
|
|
78
78
|
executable,
|
|
@@ -123,8 +123,14 @@ export class Browser extends EventEmitter {
|
|
|
123
123
|
var _this$ws;
|
|
124
124
|
return ((_this$ws = this.ws) === null || _this$ws === void 0 ? void 0 : _this$ws.readyState) === WebSocket.OPEN;
|
|
125
125
|
}
|
|
126
|
-
async close() {
|
|
127
|
-
var _this$process, _this$ws2;
|
|
126
|
+
async close(force = false) {
|
|
127
|
+
var _this$percy$config$di, _this$process, _this$ws2;
|
|
128
|
+
// Check for the new closeBrowser option
|
|
129
|
+
if (!force && ((_this$percy$config$di = this.percy.config.discovery) === null || _this$percy$config$di === void 0 || (_this$percy$config$di = _this$percy$config$di.launchOptions) === null || _this$percy$config$di === void 0 ? void 0 : _this$percy$config$di.closeBrowser) === false) {
|
|
130
|
+
this.log.debug('Skipping browser close due to closeBrowser:false option');
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
128
134
|
// not running, already closed, or closing
|
|
129
135
|
if (this._closed) return this._closed;
|
|
130
136
|
this.readyState = 2;
|
package/dist/config.js
CHANGED
|
@@ -26,6 +26,33 @@ export const configSchema = {
|
|
|
26
26
|
snapshot: {
|
|
27
27
|
type: 'object',
|
|
28
28
|
additionalProperties: false,
|
|
29
|
+
definitions: {
|
|
30
|
+
configurationProperties: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
additionalProperties: false,
|
|
33
|
+
properties: {
|
|
34
|
+
diffSensitivity: {
|
|
35
|
+
type: 'integer',
|
|
36
|
+
minimum: 0,
|
|
37
|
+
maximum: 4
|
|
38
|
+
},
|
|
39
|
+
imageIgnoreThreshold: {
|
|
40
|
+
type: 'number',
|
|
41
|
+
minimum: 0,
|
|
42
|
+
maximum: 1
|
|
43
|
+
},
|
|
44
|
+
carouselsEnabled: {
|
|
45
|
+
type: 'boolean'
|
|
46
|
+
},
|
|
47
|
+
bannersEnabled: {
|
|
48
|
+
type: 'boolean'
|
|
49
|
+
},
|
|
50
|
+
adsEnabled: {
|
|
51
|
+
type: 'boolean'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
29
56
|
properties: {
|
|
30
57
|
widths: {
|
|
31
58
|
type: 'array',
|
|
@@ -95,6 +122,14 @@ export const configSchema = {
|
|
|
95
122
|
thTestCaseExecutionId: {
|
|
96
123
|
type: 'string'
|
|
97
124
|
},
|
|
125
|
+
browsers: {
|
|
126
|
+
type: 'array',
|
|
127
|
+
items: {
|
|
128
|
+
type: 'string',
|
|
129
|
+
minLength: 1
|
|
130
|
+
},
|
|
131
|
+
onlyWeb: true
|
|
132
|
+
},
|
|
98
133
|
fullPage: {
|
|
99
134
|
type: 'boolean',
|
|
100
135
|
onlyAutomate: true
|
|
@@ -164,6 +199,88 @@ export const configSchema = {
|
|
|
164
199
|
}
|
|
165
200
|
}
|
|
166
201
|
}
|
|
202
|
+
},
|
|
203
|
+
regions: {
|
|
204
|
+
type: 'array',
|
|
205
|
+
items: {
|
|
206
|
+
type: 'object',
|
|
207
|
+
properties: {
|
|
208
|
+
elementSelector: {
|
|
209
|
+
type: 'object',
|
|
210
|
+
additionalProperties: false,
|
|
211
|
+
properties: {
|
|
212
|
+
boundingBox: {
|
|
213
|
+
type: 'object',
|
|
214
|
+
additionalProperties: false,
|
|
215
|
+
properties: {
|
|
216
|
+
x: {
|
|
217
|
+
type: 'integer'
|
|
218
|
+
},
|
|
219
|
+
y: {
|
|
220
|
+
type: 'integer'
|
|
221
|
+
},
|
|
222
|
+
width: {
|
|
223
|
+
type: 'integer'
|
|
224
|
+
},
|
|
225
|
+
height: {
|
|
226
|
+
type: 'integer'
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
elementXpath: {
|
|
231
|
+
type: 'string'
|
|
232
|
+
},
|
|
233
|
+
elementCSS: {
|
|
234
|
+
type: 'string'
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
padding: {
|
|
239
|
+
type: 'object',
|
|
240
|
+
additionalProperties: false,
|
|
241
|
+
properties: {
|
|
242
|
+
top: {
|
|
243
|
+
type: 'integer'
|
|
244
|
+
},
|
|
245
|
+
bottom: {
|
|
246
|
+
type: 'integer'
|
|
247
|
+
},
|
|
248
|
+
left: {
|
|
249
|
+
type: 'integer'
|
|
250
|
+
},
|
|
251
|
+
right: {
|
|
252
|
+
type: 'integer'
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
algorithm: {
|
|
257
|
+
type: 'string',
|
|
258
|
+
enum: ['standard', 'layout', 'ignore', 'intelliignore']
|
|
259
|
+
},
|
|
260
|
+
configuration: {
|
|
261
|
+
$ref: '#/definitions/configurationProperties'
|
|
262
|
+
},
|
|
263
|
+
assertion: {
|
|
264
|
+
type: 'object',
|
|
265
|
+
additionalProperties: false,
|
|
266
|
+
properties: {
|
|
267
|
+
diffIgnoreThreshold: {
|
|
268
|
+
type: 'number',
|
|
269
|
+
minimum: 0,
|
|
270
|
+
maximum: 1
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
required: ['algorithm']
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
algorithm: {
|
|
279
|
+
type: 'string',
|
|
280
|
+
enum: ['standard', 'layout', 'intelliignore']
|
|
281
|
+
},
|
|
282
|
+
algorithmConfiguration: {
|
|
283
|
+
$ref: '#/definitions/configurationProperties'
|
|
167
284
|
}
|
|
168
285
|
}
|
|
169
286
|
},
|
|
@@ -221,6 +338,10 @@ export const configSchema = {
|
|
|
221
338
|
minimum: 1,
|
|
222
339
|
maximum: 30000
|
|
223
340
|
},
|
|
341
|
+
scrollToBottom: {
|
|
342
|
+
type: 'boolean',
|
|
343
|
+
default: false
|
|
344
|
+
},
|
|
224
345
|
disableCache: {
|
|
225
346
|
type: 'boolean'
|
|
226
347
|
},
|
|
@@ -310,6 +431,10 @@ export const configSchema = {
|
|
|
310
431
|
},
|
|
311
432
|
headless: {
|
|
312
433
|
type: 'boolean'
|
|
434
|
+
},
|
|
435
|
+
closeBrowser: {
|
|
436
|
+
type: 'boolean',
|
|
437
|
+
default: true
|
|
313
438
|
}
|
|
314
439
|
}
|
|
315
440
|
}
|
|
@@ -367,9 +492,21 @@ export const snapshotSchema = {
|
|
|
367
492
|
thTestCaseExecutionId: {
|
|
368
493
|
$ref: '/config/snapshot#/properties/thTestCaseExecutionId'
|
|
369
494
|
},
|
|
495
|
+
browsers: {
|
|
496
|
+
$ref: '/config/snapshot#/properties/browsers'
|
|
497
|
+
},
|
|
370
498
|
reshuffleInvalidTags: {
|
|
371
499
|
$ref: '/config/snapshot#/properties/reshuffleInvalidTags'
|
|
372
500
|
},
|
|
501
|
+
regions: {
|
|
502
|
+
$ref: '/config/snapshot#/properties/regions'
|
|
503
|
+
},
|
|
504
|
+
algorithm: {
|
|
505
|
+
$ref: '/config/snapshot#/properties/algorithm'
|
|
506
|
+
},
|
|
507
|
+
algorithmConfiguration: {
|
|
508
|
+
$ref: '/config/snapshot#/properties/algorithmConfiguration'
|
|
509
|
+
},
|
|
373
510
|
scopeOptions: {
|
|
374
511
|
$ref: '/config/snapshot#/properties/scopeOptions'
|
|
375
512
|
},
|
|
@@ -412,6 +549,9 @@ export const snapshotSchema = {
|
|
|
412
549
|
},
|
|
413
550
|
retry: {
|
|
414
551
|
$ref: '/config/discovery#/properties/retry'
|
|
552
|
+
},
|
|
553
|
+
scrollToBottom: {
|
|
554
|
+
$ref: '/config/discovery#/properties/scrollToBottom'
|
|
415
555
|
}
|
|
416
556
|
}
|
|
417
557
|
}
|
|
@@ -913,6 +1053,15 @@ export const comparisonSchema = {
|
|
|
913
1053
|
ignoreElementsData: regionsSchema
|
|
914
1054
|
}
|
|
915
1055
|
},
|
|
1056
|
+
regions: {
|
|
1057
|
+
$ref: '/config/snapshot#/properties/regions'
|
|
1058
|
+
},
|
|
1059
|
+
algorithm: {
|
|
1060
|
+
$ref: '/config/snapshot#/properties/algorithm'
|
|
1061
|
+
},
|
|
1062
|
+
algorithmConfiguration: {
|
|
1063
|
+
$ref: '/config/snapshot#/properties/algorithmConfiguration'
|
|
1064
|
+
},
|
|
916
1065
|
consideredElementsData: {
|
|
917
1066
|
type: 'object',
|
|
918
1067
|
additionalProperties: false,
|
package/dist/discovery.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import logger from '@percy/logger';
|
|
2
2
|
import Queue from './queue.js';
|
|
3
3
|
import Page from './page.js';
|
|
4
|
-
import { normalizeURL, hostnameMatches, createResource, createRootResource, createPercyCSSResource, createLogResource, yieldAll, snapshotLogName, waitForTimeout, withRetries, waitForSelectorInsideBrowser } from './utils.js';
|
|
4
|
+
import { normalizeURL, hostnameMatches, createResource, createRootResource, createPercyCSSResource, createLogResource, yieldAll, snapshotLogName, waitForTimeout, withRetries, waitForSelectorInsideBrowser, isGzipped, maybeScrollToBottom } from './utils.js';
|
|
5
5
|
import { sha256hash } from '@percy/client/utils';
|
|
6
6
|
import Pako from 'pako';
|
|
7
7
|
|
|
@@ -36,6 +36,7 @@ function debugSnapshotOptions(snapshot) {
|
|
|
36
36
|
debugProp(snapshot, 'waitForTimeout');
|
|
37
37
|
debugProp(snapshot, 'waitForSelector');
|
|
38
38
|
debugProp(snapshot, 'scopeOptions.scroll');
|
|
39
|
+
debugProp(snapshot, 'browsers');
|
|
39
40
|
debugProp(snapshot, 'execute.afterNavigation');
|
|
40
41
|
debugProp(snapshot, 'execute.beforeResize');
|
|
41
42
|
debugProp(snapshot, 'execute.afterResize');
|
|
@@ -52,6 +53,7 @@ function debugSnapshotOptions(snapshot) {
|
|
|
52
53
|
debugProp(snapshot, 'clientInfo');
|
|
53
54
|
debugProp(snapshot, 'environmentInfo');
|
|
54
55
|
debugProp(snapshot, 'domSnapshot', Boolean);
|
|
56
|
+
debugProp(snapshot, 'discovery.scrollToBottom');
|
|
55
57
|
if (Array.isArray(snapshot.domSnapshot)) {
|
|
56
58
|
debugProp(snapshot, 'domSnapshot.0.userAgent');
|
|
57
59
|
} else {
|
|
@@ -222,8 +224,12 @@ function processSnapshotResources({
|
|
|
222
224
|
})));
|
|
223
225
|
if (process.env.PERCY_GZIP) {
|
|
224
226
|
for (let index = 0; index < resources.length; index++) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
+
const alreadyZipped = isGzipped(resources[index].content);
|
|
228
|
+
/* istanbul ignore next: very hard to mock true */
|
|
229
|
+
if (!alreadyZipped) {
|
|
230
|
+
resources[index].content = Pako.gzip(resources[index].content);
|
|
231
|
+
resources[index].sha = sha256hash(resources[index].content);
|
|
232
|
+
}
|
|
227
233
|
}
|
|
228
234
|
}
|
|
229
235
|
return {
|
|
@@ -323,6 +329,7 @@ async function* captureSnapshotResources(page, snapshot, options) {
|
|
|
323
329
|
yield page.eval((_, s) => window.__PERCY__.snapshot = s, snapshot);
|
|
324
330
|
yield page.evaluate(snapshot.execute.afterNavigation);
|
|
325
331
|
}
|
|
332
|
+
yield* maybeScrollToBottom(page, discovery);
|
|
326
333
|
|
|
327
334
|
// Running before page idle since this will trigger many network calls
|
|
328
335
|
// so need to run as early as possible. plus it is just reading urls from dom srcset
|
|
@@ -361,6 +368,7 @@ async function* captureSnapshotResources(page, snapshot, options) {
|
|
|
361
368
|
});
|
|
362
369
|
}
|
|
363
370
|
yield page.evaluate(execute === null || execute === void 0 ? void 0 : execute.afterResize);
|
|
371
|
+
yield* maybeScrollToBottom(page, discovery);
|
|
364
372
|
}
|
|
365
373
|
}
|
|
366
374
|
if (capture && !snapshot.domSnapshot) {
|
|
@@ -397,7 +405,8 @@ export async function* discoverSnapshotResources(queue, options, callback) {
|
|
|
397
405
|
let {
|
|
398
406
|
snapshots,
|
|
399
407
|
skipDiscovery,
|
|
400
|
-
dryRun
|
|
408
|
+
dryRun,
|
|
409
|
+
checkAndUpdateConcurrency
|
|
401
410
|
} = options;
|
|
402
411
|
yield* yieldAll(snapshots.reduce((all, snapshot) => {
|
|
403
412
|
debugSnapshotOptions(snapshot);
|
|
@@ -411,6 +420,10 @@ export async function* discoverSnapshotResources(queue, options, callback) {
|
|
|
411
420
|
callback(dryRun ? snap : processSnapshotResources(snap));
|
|
412
421
|
}
|
|
413
422
|
} else {
|
|
423
|
+
// update concurrency before pushing new job in discovery queue
|
|
424
|
+
// if case of monitoring is stopped due to in-activity,
|
|
425
|
+
// it can take upto 1 sec to execute this fun
|
|
426
|
+
checkAndUpdateConcurrency();
|
|
414
427
|
all.push(queue.push(snapshot, callback));
|
|
415
428
|
}
|
|
416
429
|
return all;
|
|
@@ -498,6 +511,12 @@ export function createDiscoveryQueue(percy) {
|
|
|
498
511
|
return resource;
|
|
499
512
|
},
|
|
500
513
|
saveResource: r => {
|
|
514
|
+
const limitResources = process.env.LIMIT_SNAPSHOT_RESOURCES || false;
|
|
515
|
+
const MAX_RESOURCES = Number(process.env.MAX_SNAPSHOT_RESOURCES) || 749;
|
|
516
|
+
if (limitResources && snapshot.resources.size >= MAX_RESOURCES) {
|
|
517
|
+
percy.log.debug(`Skipping resource ${r.url} — resource limit reached`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
501
520
|
snapshot.resources.set(r.url, r);
|
|
502
521
|
if (!snapshot.discovery.disableCache) {
|
|
503
522
|
cache.set(r.url, r);
|
package/dist/install.js
CHANGED
|
@@ -162,13 +162,13 @@ export function chromium({
|
|
|
162
162
|
});
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
// default chromium revisions corresponds to
|
|
165
|
+
// default chromium revisions corresponds to v126.0.6478.184
|
|
166
166
|
chromium.revisions = {
|
|
167
|
-
linux: '
|
|
168
|
-
win64: '
|
|
169
|
-
win32: '
|
|
170
|
-
darwin: '
|
|
171
|
-
darwinArm: '
|
|
167
|
+
linux: '1300309',
|
|
168
|
+
win64: '1300297',
|
|
169
|
+
win32: '1300295',
|
|
170
|
+
darwin: '1300293',
|
|
171
|
+
darwinArm: '1300314'
|
|
172
172
|
};
|
|
173
173
|
|
|
174
174
|
// export the namespace by default
|
package/dist/network.js
CHANGED
|
@@ -491,6 +491,7 @@ async function makeDirectRequest(network, request, session) {
|
|
|
491
491
|
|
|
492
492
|
// Save a resource from a request, skipping it if specific paramters are not met
|
|
493
493
|
async function saveResponseResource(network, request, session) {
|
|
494
|
+
var _response$headers;
|
|
494
495
|
let {
|
|
495
496
|
disableCache,
|
|
496
497
|
allowedHostnames,
|
|
@@ -504,6 +505,14 @@ async function saveResponseResource(network, request, session) {
|
|
|
504
505
|
url,
|
|
505
506
|
responseStatus: response === null || response === void 0 ? void 0 : response.status
|
|
506
507
|
};
|
|
508
|
+
// Checing for content length more than 100MB, to prevent websocket error which is governed by
|
|
509
|
+
// maxPayload option of websocket defaulted to 100MB.
|
|
510
|
+
// If content-length is more than our allowed 25MB, no need to process that resouce we can return log.
|
|
511
|
+
let contentLength = (_response$headers = response.headers) === null || _response$headers === void 0 ? void 0 : _response$headers[Object.keys(response.headers).find(key => key.toLowerCase() === 'content-length')];
|
|
512
|
+
contentLength = parseInt(contentLength);
|
|
513
|
+
if (contentLength > MAX_RESOURCE_SIZE) {
|
|
514
|
+
return log.debug('- Skipping resource larger than 25MB', meta);
|
|
515
|
+
}
|
|
507
516
|
let resource = network.intercept.getResource(url);
|
|
508
517
|
if (!resource || !resource.root && !resource.provided && disableCache) {
|
|
509
518
|
try {
|
|
@@ -521,6 +530,7 @@ async function saveResponseResource(network, request, session) {
|
|
|
521
530
|
} else if (!body.length) {
|
|
522
531
|
return log.debug('- Skipping empty response', meta);
|
|
523
532
|
} else if (body.length > MAX_RESOURCE_SIZE) {
|
|
533
|
+
log.debug('- Missing headers for the requested resource.', meta);
|
|
524
534
|
return log.debug('- Skipping resource larger than 25MB', meta);
|
|
525
535
|
} else if (!ALLOWED_STATUSES.includes(response.status)) {
|
|
526
536
|
return log.debug(`- Skipping disallowed status [${response.status}]`, meta);
|
package/dist/percy.js
CHANGED
|
@@ -17,9 +17,15 @@ import { base64encode, generatePromise, yieldAll, yieldTo, redactSecrets, detect
|
|
|
17
17
|
import { createPercyServer, createStaticServer } from './api.js';
|
|
18
18
|
import { gatherSnapshots, createSnapshotsQueue, validateSnapshotOptions } from './snapshot.js';
|
|
19
19
|
import { discoverSnapshotResources, createDiscoveryQueue } from './discovery.js';
|
|
20
|
+
import Monitoring from '@percy/monitoring';
|
|
20
21
|
import { WaitForJob } from './wait-for-job.js';
|
|
21
22
|
const MAX_SUGGESTION_CALLS = 10;
|
|
22
23
|
|
|
24
|
+
// If no activity is done for 5 mins, we will stop monitoring
|
|
25
|
+
// system metric eg: (cpu load && memory usage)
|
|
26
|
+
const MONITOR_ACTIVITY_TIMEOUT = 300000;
|
|
27
|
+
const MONITORING_INTERVAL_MS = 5000; // 5 sec
|
|
28
|
+
|
|
23
29
|
// A Percy instance will create a new build when started, handle snapshot creation, asset discovery,
|
|
24
30
|
// and resource uploads, and will finalize the build when stopped. Snapshots are processed
|
|
25
31
|
// concurrently and the build is not finalized until all snapshots have been handled.
|
|
@@ -100,7 +106,13 @@ export class Percy {
|
|
|
100
106
|
if (server) this.server = createPercyServer(this, port);
|
|
101
107
|
this.browser = new Browser(this);
|
|
102
108
|
_classPrivateFieldSet(_discovery, this, createDiscoveryQueue(this));
|
|
109
|
+
this.discoveryMaxConcurrency = _classPrivateFieldGet(_discovery, this).concurrency;
|
|
103
110
|
_classPrivateFieldSet(_snapshots, this, createSnapshotsQueue(this));
|
|
111
|
+
this.monitoring = new Monitoring();
|
|
112
|
+
// used continue monitoring if there is activity going on
|
|
113
|
+
// if there is none, stop it
|
|
114
|
+
this.resetMonitoringId = null;
|
|
115
|
+
this.monitoringCheckLastExecutedAt = null;
|
|
104
116
|
|
|
105
117
|
// generator methods are wrapped to autorun and return promises
|
|
106
118
|
for (let m of ['start', 'stop', 'flush', 'idle', 'snapshot', 'upload']) {
|
|
@@ -109,6 +121,28 @@ export class Percy {
|
|
|
109
121
|
this[m] = (...args) => generatePromise(method(...args));
|
|
110
122
|
}
|
|
111
123
|
}
|
|
124
|
+
systemMonitoringEnabled() {
|
|
125
|
+
return process.env.PERCY_DISABLE_SYSTEM_MONITORING !== 'true';
|
|
126
|
+
}
|
|
127
|
+
async configureSystemMonitor() {
|
|
128
|
+
await this.monitoring.startMonitoring({
|
|
129
|
+
interval: MONITORING_INTERVAL_MS
|
|
130
|
+
});
|
|
131
|
+
this.resetSystemMonitor();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Debouncing logic to only stop Monitoring system
|
|
135
|
+
// if there is no any activity for 5 mins
|
|
136
|
+
// means, no job is pushed in queue from 5 mins
|
|
137
|
+
resetSystemMonitor() {
|
|
138
|
+
if (this.resetMonitoringId) {
|
|
139
|
+
clearTimeout(this.resetMonitoringId);
|
|
140
|
+
this.resetMonitoringId = null;
|
|
141
|
+
}
|
|
142
|
+
this.resetMonitoringId = setTimeout(() => {
|
|
143
|
+
this.monitoring.stopMonitoring();
|
|
144
|
+
}, MONITOR_ACTIVITY_TIMEOUT);
|
|
145
|
+
}
|
|
112
146
|
|
|
113
147
|
// Shortcut for controlling the global logger's log level.
|
|
114
148
|
loglevel(level) {
|
|
@@ -157,7 +191,7 @@ export class Percy {
|
|
|
157
191
|
concurrency
|
|
158
192
|
});
|
|
159
193
|
_classPrivateFieldGet(_snapshots, this).set({
|
|
160
|
-
snapshotConcurrency
|
|
194
|
+
concurrency: snapshotConcurrency
|
|
161
195
|
});
|
|
162
196
|
return this.config;
|
|
163
197
|
}
|
|
@@ -169,6 +203,12 @@ export class Percy {
|
|
|
169
203
|
this.readyState = 0;
|
|
170
204
|
this.cliStartTime = new Date().toISOString();
|
|
171
205
|
try {
|
|
206
|
+
// started monitoring system metrics
|
|
207
|
+
|
|
208
|
+
if (this.systemMonitoringEnabled()) {
|
|
209
|
+
await this.configureSystemMonitor();
|
|
210
|
+
await this.monitoring.logSystemInfo();
|
|
211
|
+
}
|
|
172
212
|
if (process.env.PERCY_CLIENT_ERROR_LOGS !== 'false') {
|
|
173
213
|
this.log.warn('Notice: Percy collects CI logs to improve service and enhance your experience. These logs help us debug issues and provide insights on your dashboards, making it easier to optimize the product experience. Logs are stored securely for 30 days. You can opt out anytime with export PERCY_CLIENT_ERROR_LOGS=false, but keeping this enabled helps us offer the best support and features.');
|
|
174
214
|
}
|
|
@@ -296,12 +336,66 @@ export class Percy {
|
|
|
296
336
|
this.log.error(err);
|
|
297
337
|
throw err;
|
|
298
338
|
} finally {
|
|
339
|
+
// stop monitoring system metric, if not already stopped
|
|
340
|
+
this.monitoring.stopMonitoring();
|
|
341
|
+
clearTimeout(this.resetMonitoringId);
|
|
342
|
+
|
|
299
343
|
// This issue doesn't comes under regular error logs,
|
|
300
344
|
// it's detected if we just and stop percy server
|
|
301
345
|
await this.checkForNoSnapshotCommandError();
|
|
302
346
|
await this.sendBuildLogs();
|
|
303
347
|
}
|
|
304
348
|
}
|
|
349
|
+
checkAndUpdateConcurrency() {
|
|
350
|
+
// early exit if monitoring is disabled
|
|
351
|
+
if (!this.systemMonitoringEnabled()) return;
|
|
352
|
+
|
|
353
|
+
// early exit if asset discovery concurrency change is disabled
|
|
354
|
+
// NOTE: system monitoring will still be running as only concurrency
|
|
355
|
+
// change is disabled
|
|
356
|
+
if (process.env.PERCY_DISABLE_CONCURRENCY_CHANGE === 'true') return;
|
|
357
|
+
|
|
358
|
+
// start system monitoring if not already doing...
|
|
359
|
+
// this doesn't handle cases where there is suggest cpu spikes
|
|
360
|
+
// in less 1 sec range and if monitoring is not in running state
|
|
361
|
+
if (this.monitoringCheckLastExecutedAt && Date.now() - this.monitoringCheckLastExecutedAt < MONITORING_INTERVAL_MS) return;
|
|
362
|
+
if (!this.monitoring.running) this.configureSystemMonitor();else this.resetSystemMonitor();
|
|
363
|
+
|
|
364
|
+
// early return if last executed was less than 5 seconds
|
|
365
|
+
// as we will get the same cpu/mem info under 5 sec interval
|
|
366
|
+
const {
|
|
367
|
+
cpuInfo,
|
|
368
|
+
memoryUsageInfo
|
|
369
|
+
} = this.monitoring.getMonitoringInfo();
|
|
370
|
+
this.log.debug(`cpuInfo: ${JSON.stringify(cpuInfo)}`);
|
|
371
|
+
this.log.debug(`memoryInfo: ${JSON.stringify(memoryUsageInfo)}`);
|
|
372
|
+
if (cpuInfo.currentUsagePercent >= 80 || memoryUsageInfo.currentUsagePercent >= 80) {
|
|
373
|
+
let currentConcurrent = _classPrivateFieldGet(_discovery, this).concurrency;
|
|
374
|
+
|
|
375
|
+
// concurrency must be betweeen [1, (default/user defined value)]
|
|
376
|
+
let newConcurrency = Math.max(1, parseInt(currentConcurrent / 2));
|
|
377
|
+
newConcurrency = Math.min(this.discoveryMaxConcurrency, newConcurrency);
|
|
378
|
+
this.log.debug(`Downscaling discovery browser concurrency from ${_classPrivateFieldGet(_discovery, this).concurrency} to ${newConcurrency}`);
|
|
379
|
+
_classPrivateFieldGet(_discovery, this).set({
|
|
380
|
+
concurrency: newConcurrency
|
|
381
|
+
});
|
|
382
|
+
} else if (cpuInfo.currentUsagePercent <= 50 && memoryUsageInfo.currentUsagePercent <= 50) {
|
|
383
|
+
let currentConcurrent = _classPrivateFieldGet(_discovery, this).concurrency;
|
|
384
|
+
let newConcurrency = currentConcurrent + 2;
|
|
385
|
+
|
|
386
|
+
// concurrency must be betweeen [1, (default/user-defined value)]
|
|
387
|
+
newConcurrency = Math.min(this.discoveryMaxConcurrency, newConcurrency);
|
|
388
|
+
newConcurrency = Math.max(1, newConcurrency);
|
|
389
|
+
this.log.debug(`Upscaling discovery browser concurrency from ${_classPrivateFieldGet(_discovery, this).concurrency} to ${newConcurrency}`);
|
|
390
|
+
_classPrivateFieldGet(_discovery, this).set({
|
|
391
|
+
concurrency: newConcurrency
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// reset timeout to stop monitoring after no-activity of 5 mins
|
|
396
|
+
this.resetSystemMonitor();
|
|
397
|
+
this.monitoringCheckLastExecutedAt = Date.now();
|
|
398
|
+
}
|
|
305
399
|
|
|
306
400
|
// Takes one or more snapshots of a page while discovering resources to upload with the resulting
|
|
307
401
|
// snapshots. Once asset discovery has completed for the provided snapshots, the queued task will
|
|
@@ -354,6 +448,7 @@ export class Percy {
|
|
|
354
448
|
yield* discoverSnapshotResources(_classPrivateFieldGet(_discovery, this), {
|
|
355
449
|
skipDiscovery: this.skipDiscovery,
|
|
356
450
|
dryRun: this.dryRun,
|
|
451
|
+
checkAndUpdateConcurrency: this.checkAndUpdateConcurrency.bind(this),
|
|
357
452
|
snapshots: yield* gatherSnapshots(options, {
|
|
358
453
|
meta: {
|
|
359
454
|
build: this.build
|
package/dist/queue.js
CHANGED
|
@@ -115,6 +115,13 @@ export class Queue {
|
|
|
115
115
|
// return the deferred task promise
|
|
116
116
|
return task.deferred;
|
|
117
117
|
}
|
|
118
|
+
logQueueSize() {
|
|
119
|
+
this.log.debug(`${this.name} queueInfo: ${JSON.stringify({
|
|
120
|
+
queued: _classPrivateFieldGet(_queued, this).size,
|
|
121
|
+
pending: _classPrivateFieldGet(_pending, this).size,
|
|
122
|
+
total: _classPrivateFieldGet(_pending, this).size + _classPrivateFieldGet(_queued, this).size
|
|
123
|
+
})}`);
|
|
124
|
+
}
|
|
118
125
|
// Cancels and aborts a specific item task.
|
|
119
126
|
cancel(item) {
|
|
120
127
|
let task = _assertClassBrand(_Queue_brand, this, _find).call(this, item);
|
|
@@ -243,6 +250,7 @@ export class Queue {
|
|
|
243
250
|
// Repeatedly yields, calling the callback with the position of the task within the queue
|
|
244
251
|
}
|
|
245
252
|
function _dequeue() {
|
|
253
|
+
this.logQueueSize();
|
|
246
254
|
if (!_classPrivateFieldGet(_queued, this).size || this.readyState < 2) return;
|
|
247
255
|
if (_classPrivateFieldGet(_pending, this).size >= this.concurrency) return;
|
|
248
256
|
let [task] = _classPrivateFieldGet(_queued, this);
|
package/dist/session.js
CHANGED
|
@@ -20,6 +20,12 @@ export class Session extends EventEmitter {
|
|
|
20
20
|
this.on('Inspector.targetCrashed', this._handleTargetCrashed);
|
|
21
21
|
}
|
|
22
22
|
async close() {
|
|
23
|
+
var _this$browser;
|
|
24
|
+
// Check for the new closeBrowser option
|
|
25
|
+
if (((_this$browser = this.browser) === null || _this$browser === void 0 || (_this$browser = _this$browser.percy.config.discovery) === null || _this$browser === void 0 || (_this$browser = _this$browser.launchOptions) === null || _this$browser === void 0 ? void 0 : _this$browser.closeBrowser) === false) {
|
|
26
|
+
this.log.debug('Skipping session close due to closeBrowser:false option');
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
23
29
|
if (!this.browser || this.closing) return;
|
|
24
30
|
this.closing = true;
|
|
25
31
|
await this.browser.send('Target.closeTarget', {
|
package/dist/snapshot.js
CHANGED
|
@@ -3,7 +3,7 @@ import PercyConfig from '@percy/config';
|
|
|
3
3
|
import micromatch from 'micromatch';
|
|
4
4
|
import { configSchema } from './config.js';
|
|
5
5
|
import Queue from './queue.js';
|
|
6
|
-
import { request, hostnameMatches, yieldTo, snapshotLogName, decodeAndEncodeURLWithLogging, compareObjectTypes } from './utils.js';
|
|
6
|
+
import { request, hostnameMatches, yieldTo, snapshotLogName, decodeAndEncodeURLWithLogging, compareObjectTypes, normalizeOptions } from './utils.js';
|
|
7
7
|
import { JobData } from './wait-for-job.js';
|
|
8
8
|
|
|
9
9
|
// Throw a better error message for missing or invalid urls
|
|
@@ -151,14 +151,17 @@ function getSnapshotOptions(options, {
|
|
|
151
151
|
captureMockedServiceWorker: config.discovery.captureMockedServiceWorker,
|
|
152
152
|
captureSrcset: config.discovery.captureSrcset,
|
|
153
153
|
userAgent: config.discovery.userAgent,
|
|
154
|
-
retry: config.discovery.retry
|
|
154
|
+
retry: config.discovery.retry,
|
|
155
|
+
scrollToBottom: config.discovery.scrollToBottom
|
|
155
156
|
}
|
|
156
157
|
}, options], (path, prev, next) => {
|
|
157
|
-
var _next, _next2;
|
|
158
|
+
var _next, _next2, _next3;
|
|
158
159
|
switch (path.map(k => k.toString()).join('.')) {
|
|
159
160
|
case 'widths':
|
|
160
161
|
// dedup, sort, and override widths when not empty
|
|
161
162
|
return [path, !((_next = next) !== null && _next !== void 0 && _next.length) ? prev : [...new Set(next)].sort((a, b) => a - b)];
|
|
163
|
+
case 'browsers':
|
|
164
|
+
return [path, !((_next2 = next) !== null && _next2 !== void 0 && _next2.length) ? prev : [...new Set(next)]];
|
|
162
165
|
case 'percyCSS':
|
|
163
166
|
// concatenate percy css
|
|
164
167
|
return [path, [prev, next].filter(Boolean).join('\n')];
|
|
@@ -167,7 +170,7 @@ function getSnapshotOptions(options, {
|
|
|
167
170
|
return Array.isArray(next) || typeof next !== 'object' ? [path.concat('beforeSnapshot'), next] : [path];
|
|
168
171
|
case 'discovery.disallowedHostnames':
|
|
169
172
|
// prevent disallowing the root hostname
|
|
170
|
-
return [path, !((
|
|
173
|
+
return [path, !((_next3 = next) !== null && _next3 !== void 0 && _next3.length) ? prev : (prev ?? []).concat(next).filter(h => !hostnameMatches(h, options.url))];
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
// ensure additional snapshots have complete names
|
|
@@ -192,9 +195,9 @@ function getSnapshotOptions(options, {
|
|
|
192
195
|
export function validateSnapshotOptions(options) {
|
|
193
196
|
var _migrated$baseUrl, _migrated$domSnapshot;
|
|
194
197
|
let log = logger('core:snapshot');
|
|
195
|
-
|
|
196
198
|
// decide which schema to validate against
|
|
197
199
|
let schema = ['domSnapshot', 'dom-snapshot', 'dom_snapshot'].some(k => k in options) && '/snapshot/dom' || 'url' in options && '/snapshot' || 'sitemap' in options && '/snapshot/sitemap' || 'serve' in options && '/snapshot/server' || 'snapshots' in options && '/snapshot/list' || '/snapshot';
|
|
200
|
+
options = normalizeOptions(options);
|
|
198
201
|
let {
|
|
199
202
|
// normalize, migrate, and remove certain properties from validating
|
|
200
203
|
clientInfo,
|
|
@@ -226,7 +229,6 @@ export function validateSnapshotOptions(options) {
|
|
|
226
229
|
log.warn('Encountered snapshot serialization warnings:');
|
|
227
230
|
for (let w of domWarnings) log.warn(`- ${w}`);
|
|
228
231
|
}
|
|
229
|
-
|
|
230
232
|
// warn on validation errors
|
|
231
233
|
let errors = PercyConfig.validate(migrated, schema);
|
|
232
234
|
if ((errors === null || errors === void 0 ? void 0 : errors.length) > 0) {
|
package/dist/utils.js
CHANGED
|
@@ -52,6 +52,9 @@ export function percyAutomateRequestHandler(req, percy) {
|
|
|
52
52
|
ignoreRegionXpaths: (_percy$config$snapsho4 = percy.config.snapshot.ignoreRegions) === null || _percy$config$snapsho4 === void 0 ? void 0 : _percy$config$snapsho4.ignoreRegionXpaths,
|
|
53
53
|
considerRegionSelectors: (_percy$config$snapsho5 = percy.config.snapshot.considerRegions) === null || _percy$config$snapsho5 === void 0 ? void 0 : _percy$config$snapsho5.considerRegionSelectors,
|
|
54
54
|
considerRegionXpaths: (_percy$config$snapsho6 = percy.config.snapshot.considerRegions) === null || _percy$config$snapsho6 === void 0 ? void 0 : _percy$config$snapsho6.considerRegionXpaths,
|
|
55
|
+
regions: percy.config.snapshot.regions,
|
|
56
|
+
algorithm: percy.config.snapshot.algorithm,
|
|
57
|
+
algorithmConfiguration: percy.config.snapshot.algorithmConfiguration,
|
|
55
58
|
sync: percy.config.snapshot.sync,
|
|
56
59
|
version: 'v2'
|
|
57
60
|
}, camelCasedOptions], (path, prev, next) => {
|
|
@@ -389,6 +392,20 @@ export function redactSecrets(data) {
|
|
|
389
392
|
export function base64encode(content) {
|
|
390
393
|
return Buffer.from(content).toString('base64');
|
|
391
394
|
}
|
|
395
|
+
|
|
396
|
+
// It checks if content is already gzipped or not.
|
|
397
|
+
// We don't want to gzip already gzipped content.
|
|
398
|
+
export function isGzipped(content) {
|
|
399
|
+
if (!(content instanceof Uint8Array || content instanceof ArrayBuffer)) {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Ensure content is a Uint8Array
|
|
404
|
+
const data = content instanceof ArrayBuffer ? new Uint8Array(content) : content;
|
|
405
|
+
|
|
406
|
+
// Gzip magic number: 0x1f8b
|
|
407
|
+
return data.length > 2 && data[0] === 0x1f && data[1] === 0x8b;
|
|
408
|
+
}
|
|
392
409
|
const RESERVED_CHARACTERS = {
|
|
393
410
|
'%3A': ':',
|
|
394
411
|
'%23': '#',
|
|
@@ -526,4 +543,37 @@ export function compareObjectTypes(obj1, obj2) {
|
|
|
526
543
|
if (!keys2.includes(key) || !compareObjectTypes(obj1[key], obj2[key])) return false;
|
|
527
544
|
}
|
|
528
545
|
return true;
|
|
546
|
+
}
|
|
547
|
+
const OPTION_MAPPINGS = {
|
|
548
|
+
name: 'name',
|
|
549
|
+
widths: 'widths',
|
|
550
|
+
scope: 'scope',
|
|
551
|
+
scopeoptions: 'scopeOptions',
|
|
552
|
+
minheight: 'minHeight',
|
|
553
|
+
enablejavascript: 'enableJavaScript',
|
|
554
|
+
enablelayout: 'enableLayout',
|
|
555
|
+
clientinfo: 'clientInfo',
|
|
556
|
+
environmentinfo: 'environmentInfo',
|
|
557
|
+
sync: 'sync',
|
|
558
|
+
testcase: 'testCase',
|
|
559
|
+
labels: 'labels',
|
|
560
|
+
thtestcaseexecutionid: 'thTestCaseExecutionId',
|
|
561
|
+
browsers: 'browsers',
|
|
562
|
+
resources: 'resources',
|
|
563
|
+
meta: 'meta',
|
|
564
|
+
snapshot: 'snapshot'
|
|
565
|
+
};
|
|
566
|
+
export function normalizeOptions(options) {
|
|
567
|
+
const normalizedOptions = {};
|
|
568
|
+
for (const key in options) {
|
|
569
|
+
const lowerCaseKey = key.toLowerCase().replace(/[-_]/g, '');
|
|
570
|
+
const normalizedKey = OPTION_MAPPINGS[lowerCaseKey] ? OPTION_MAPPINGS[lowerCaseKey] : key;
|
|
571
|
+
normalizedOptions[normalizedKey] = options[key];
|
|
572
|
+
}
|
|
573
|
+
return normalizedOptions;
|
|
574
|
+
}
|
|
575
|
+
export async function* maybeScrollToBottom(page, discovery) {
|
|
576
|
+
if (discovery.scrollToBottom && page.enableJavaScript) {
|
|
577
|
+
yield page.eval('await scrollToBottom()');
|
|
578
|
+
}
|
|
529
579
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percy/core",
|
|
3
|
-
"version": "1.31.0
|
|
3
|
+
"version": "1.31.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"publishConfig": {
|
|
11
11
|
"access": "public",
|
|
12
|
-
"tag": "
|
|
12
|
+
"tag": "latest"
|
|
13
13
|
},
|
|
14
14
|
"engines": {
|
|
15
15
|
"node": ">=14"
|
|
@@ -43,11 +43,12 @@
|
|
|
43
43
|
"test:types": "tsd"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@percy/client": "1.31.0
|
|
47
|
-
"@percy/config": "1.31.0
|
|
48
|
-
"@percy/dom": "1.31.0
|
|
49
|
-
"@percy/logger": "1.31.0
|
|
50
|
-
"@percy/
|
|
46
|
+
"@percy/client": "1.31.0",
|
|
47
|
+
"@percy/config": "1.31.0",
|
|
48
|
+
"@percy/dom": "1.31.0",
|
|
49
|
+
"@percy/logger": "1.31.0",
|
|
50
|
+
"@percy/monitoring": "1.31.0",
|
|
51
|
+
"@percy/webdriver-utils": "1.31.0",
|
|
51
52
|
"content-disposition": "^0.5.4",
|
|
52
53
|
"cross-spawn": "^7.0.3",
|
|
53
54
|
"extract-zip": "^2.0.1",
|
|
@@ -60,5 +61,5 @@
|
|
|
60
61
|
"ws": "^8.17.1",
|
|
61
62
|
"yaml": "^2.4.1"
|
|
62
63
|
},
|
|
63
|
-
"gitHead": "
|
|
64
|
+
"gitHead": "49895470c0dfa7242881db43e293317d1fb8f8b6"
|
|
64
65
|
}
|
package/test/helpers/server.js
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
// aliased to src during tests
|
|
2
2
|
import Server from '../../dist/server.js';
|
|
3
3
|
|
|
4
|
-
export function createTestServer({ default: defaultReply, ...replies }, port = 8000) {
|
|
4
|
+
export function createTestServer({ default: defaultReply, ...replies }, port = 8000, options = {}) {
|
|
5
5
|
let server = new Server();
|
|
6
6
|
|
|
7
7
|
// alternate route handling
|
|
8
|
-
let handleReply = reply => async (req, res) => {
|
|
8
|
+
let handleReply = (reply, options = {}) => async (req, res) => {
|
|
9
9
|
let [status, headers, body] = typeof reply === 'function' ? await reply(req) : reply;
|
|
10
10
|
if (!Buffer.isBuffer(body) && typeof body !== 'string') body = JSON.stringify(body);
|
|
11
|
+
|
|
12
|
+
if (options.noHeaders) {
|
|
13
|
+
return res.writeHead(status).end(body);
|
|
14
|
+
}
|
|
15
|
+
if (options.headersOverride) {
|
|
16
|
+
headers = { ...headers, ...options.headersOverride };
|
|
17
|
+
}
|
|
11
18
|
return res.send(status, headers, body);
|
|
12
19
|
};
|
|
13
20
|
|
|
14
21
|
// map replies to alternate route handlers
|
|
15
|
-
server.reply = (p, reply) => (replies[p] = handleReply(reply), null);
|
|
22
|
+
server.reply = (p, reply, options = {}) => (replies[p] = handleReply(reply, options), null);
|
|
16
23
|
for (let [p, reply] of Object.entries(replies)) server.reply(p, reply);
|
|
17
24
|
if (defaultReply) defaultReply = handleReply(defaultReply);
|
|
18
25
|
|
package/types/index.d.ts
CHANGED