• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

prebid / Prebid.js / 20272535632

16 Dec 2025 03:04PM UTC coverage: 96.208% (+0.002%) from 96.206%
20272535632

push

github

web-flow
netads alias added (#14271)

Co-authored-by: Gabriel Chicoye <gabriel@macbookrogab24g.lan>

41388 of 50926 branches covered (81.27%)

207099 of 215261 relevant lines covered (96.21%)

71.43 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.72
/modules/intentIqAnalyticsAdapter.js
1
import { isPlainObject, logError, logInfo } from '../src/utils.js';
1✔
2
import adapter from '../libraries/analyticsAdapter/AnalyticsAdapter.js';
3
import adapterManager from '../src/adapterManager.js';
4
import { ajax } from '../src/ajax.js';
5
import { EVENTS } from '../src/constants.js';
6
import { detectBrowser } from '../libraries/intentIqUtils/detectBrowserUtils.js';
7
import { appendSPData } from '../libraries/intentIqUtils/urlUtils.js';
8
import { appendVrrefAndFui, getReferrer } from '../libraries/intentIqUtils/getRefferer.js';
9
import { getCmpData } from '../libraries/intentIqUtils/getCmpData.js';
10
import {
11
  VERSION,
12
  PREBID,
13
  WITH_IIQ
14
} from '../libraries/intentIqConstants/intentIqConstants.js';
15
import { reportingServerAddress } from '../libraries/intentIqUtils/intentIqConfig.js';
16
import { handleAdditionalParams } from '../libraries/intentIqUtils/handleAdditionalParams.js';
17
import { gamPredictionReport } from '../libraries/intentIqUtils/gamPredictionReport.js';
18
import { defineABTestingGroup } from '../libraries/intentIqUtils/defineABTestingGroupUtils.js';
19

20
const MODULE_NAME = 'iiqAnalytics';
1✔
21
const analyticsType = 'endpoint';
1✔
22
const prebidVersion = '$prebid.version$';
1✔
23
export const REPORTER_ID = Date.now() + '_' + getRandom(0, 1000);
1✔
24
let globalName;
25
let identityGlobalName;
26
let alreadySubscribedOnGAM = false;
1✔
27
let reportList = {};
1✔
28
let cleanReportsID;
29
let iiqConfig;
30

31
const PARAMS_NAMES = {
1✔
32
  abTestGroup: 'abGroup',
33
  pbPauseUntil: 'pbPauseUntil',
34
  pbMonitoringEnabled: 'pbMonitoringEnabled',
35
  isInTestGroup: 'isInTestGroup',
36
  enhanceRequests: 'enhanceRequests',
37
  wasSubscribedForPrebid: 'wasSubscribedForPrebid',
38
  hadEids: 'hadEids',
39
  ABTestingConfigurationSource: 'ABTestingConfigurationSource',
40
  lateConfiguration: 'lateConfiguration',
41
  jsversion: 'jsversion',
42
  eidsNames: 'eidsNames',
43
  requestRtt: 'rtt',
44
  clientType: 'clientType',
45
  adserverDeviceType: 'AdserverDeviceType',
46
  terminationCause: 'terminationCause',
47
  callCount: 'callCount',
48
  manualCallCount: 'mcc',
49
  pubprovidedidsFailedToregister: 'ppcc',
50
  noDataCount: 'noDataCount',
51
  profile: 'profile',
52
  isProfileDeterministic: 'pidDeterministic',
53
  siteId: 'sid',
54
  hadEidsInLocalStorage: 'idls',
55
  auctionStartTime: 'ast',
56
  eidsReadTime: 'eidt',
57
  agentId: 'aid',
58
  auctionEidsLength: 'aeidln',
59
  wasServerCalled: 'wsrvcll',
60
  referrer: 'vrref',
61
  isInBrowserBlacklist: 'inbbl',
62
  prebidVersion: 'pbjsver',
63
  partnerId: 'partnerId',
64
  firstPartyId: 'pcid',
65
  placementId: 'placementId',
66
  adType: 'adType',
67
  abTestUuid: 'abTestUuid',
68
};
69

70
const DEFAULT_URL = 'https://reports.intentiq.com/report';
1✔
71

72
const getDataForDefineURL = () => {
1✔
73
  const cmpData = getCmpData();
29✔
74
  const gdprDetected = cmpData.gdprString;
29✔
75

76
  return [iiqAnalyticsAnalyticsAdapter.initOptions.reportingServerAddress, gdprDetected];
29✔
77
};
78

79
const getDefaultInitOptions = () => {
1✔
80
  return {
61✔
81
    adapterConfigInitialized: false,
82
    partner: null,
83
    fpid: null,
84
    currentGroup: null,
85
    dataInLs: null,
86
    eidl: null,
87
    dataIdsInitialized: false,
88
    manualWinReportEnabled: false,
89
    domainName: null,
90
    siloEnabled: false,
91
    reportMethod: null,
92
    abPercentage: null,
93
    abTestUuid: null,
94
    additionalParams: null,
95
    reportingServerAddress: ''
96
  }
97
}
98

99
const iiqAnalyticsAnalyticsAdapter = Object.assign(adapter({ url: DEFAULT_URL, analyticsType }), {
1✔
100
  initOptions: getDefaultInitOptions(),
101
  track({ eventType, args }) {
62✔
102
    switch (eventType) {
62✔
103
      case BID_WON:
104
        bidWon(args);
29✔
105
        break;
29✔
106
      case BID_REQUESTED:
107
        if (!alreadySubscribedOnGAM && shouldSubscribeOnGAM()) {
4✔
108
          alreadySubscribedOnGAM = true;
1✔
109
          gamPredictionReport(iiqConfig?.gamObjectReference, bidWon);
1✔
110
        }
111
        break;
4✔
112
      default:
113
        break;
29✔
114
    }
115
  }
116
});
117

118
// Events needed
119
const { BID_WON, BID_REQUESTED } = EVENTS;
1✔
120

121
function initAdapterConfig(config) {
122
  if (iiqAnalyticsAnalyticsAdapter.initOptions.adapterConfigInitialized) return;
60!
123

124
  const options = config?.options || {}
60!
125
  iiqConfig = options
60✔
126
  const { manualWinReportEnabled, gamPredictReporting, reportMethod, reportingServerAddress, adUnitConfig, partner, ABTestingConfigurationSource, browserBlackList, domainName, additionalParams } = options
60✔
127
  iiqAnalyticsAnalyticsAdapter.initOptions.manualWinReportEnabled =
60✔
128
            manualWinReportEnabled || false;
119✔
129
  iiqAnalyticsAnalyticsAdapter.initOptions.reportMethod = parseReportingMethod(reportMethod);
60✔
130
  iiqAnalyticsAnalyticsAdapter.initOptions.gamPredictReporting = typeof gamPredictReporting === 'boolean' ? gamPredictReporting : false;
60!
131
  iiqAnalyticsAnalyticsAdapter.initOptions.reportingServerAddress = typeof reportingServerAddress === 'string' ? reportingServerAddress : '';
60✔
132
  iiqAnalyticsAnalyticsAdapter.initOptions.adUnitConfig = typeof adUnitConfig === 'number' ? adUnitConfig : 1;
60✔
133
  iiqAnalyticsAnalyticsAdapter.initOptions.configSource = ABTestingConfigurationSource;
60✔
134
  iiqAnalyticsAnalyticsAdapter.initOptions.currentGroup = defineABTestingGroup(options);
60✔
135
  iiqAnalyticsAnalyticsAdapter.initOptions.idModuleConfigInitialized = true;
60✔
136
  iiqAnalyticsAnalyticsAdapter.initOptions.browserBlackList =
60✔
137
        typeof browserBlackList === 'string'
60✔
138
          ? browserBlackList.toLowerCase()
139
          : '';
140
  iiqAnalyticsAnalyticsAdapter.initOptions.domainName = domainName || '';
60✔
141
  iiqAnalyticsAnalyticsAdapter.initOptions.additionalParams = additionalParams || null;
60✔
142
  if (!partner) {
60!
143
    logError('IIQ ANALYTICS -> partner ID is missing');
×
144
    iiqAnalyticsAnalyticsAdapter.initOptions.partner = -1
×
145
  } else iiqAnalyticsAnalyticsAdapter.initOptions.partner = partner
60✔
146
  defineGlobalVariableName();
60✔
147
  iiqAnalyticsAnalyticsAdapter.initOptions.adapterConfigInitialized = true
60✔
148
}
149

150
function receivePartnerData() {
151
  try {
29✔
152
    iiqAnalyticsAnalyticsAdapter.initOptions.dataInLs = null;
29✔
153
    const FPD = window[identityGlobalName]?.firstPartyData
29✔
154
    if (!window[identityGlobalName] || !FPD) {
29!
155
      return false
×
156
    }
157
    iiqAnalyticsAnalyticsAdapter.initOptions.fpid = FPD
29✔
158
    const { partnerData, clientsHints = '', actualABGroup } = window[identityGlobalName]
29✔
159

160
    if (partnerData) {
29✔
161
      iiqAnalyticsAnalyticsAdapter.initOptions.dataIdsInitialized = true;
29✔
162
      iiqAnalyticsAnalyticsAdapter.initOptions.terminationCause = partnerData.terminationCause;
29✔
163
      iiqAnalyticsAnalyticsAdapter.initOptions.abTestUuid = partnerData.abTestUuid;
29✔
164
      iiqAnalyticsAnalyticsAdapter.initOptions.dataInLs = partnerData.data;
29✔
165
      iiqAnalyticsAnalyticsAdapter.initOptions.eidl = partnerData.eidl || -1;
29✔
166
      iiqAnalyticsAnalyticsAdapter.initOptions.clientType = partnerData.clientType || null;
29!
167
      iiqAnalyticsAnalyticsAdapter.initOptions.siteId = partnerData.siteId || null;
29✔
168
      iiqAnalyticsAnalyticsAdapter.initOptions.wsrvcll = partnerData.wsrvcll || false;
29!
169
      iiqAnalyticsAnalyticsAdapter.initOptions.rrtt = partnerData.rrtt || null;
29✔
170
    }
171

172
    if (actualABGroup) {
29✔
173
      iiqAnalyticsAnalyticsAdapter.initOptions.currentGroup = actualABGroup;
18✔
174
    }
175
    iiqAnalyticsAnalyticsAdapter.initOptions.clientsHints = clientsHints;
29✔
176
  } catch (e) {
177
    logError(e);
×
178
    return false;
×
179
  }
180
}
181

182
function shouldSubscribeOnGAM() {
183
  if (!iiqConfig?.gamObjectReference || !isPlainObject(iiqConfig.gamObjectReference)) return false;
5✔
184
  const partnerData = window[identityGlobalName]?.partnerData
1✔
185

186
  if (partnerData) {
1✔
187
    return partnerData.gpr || (!('gpr' in partnerData) && iiqAnalyticsAnalyticsAdapter.initOptions.gamPredictReporting);
1!
188
  }
189
  return false;
×
190
}
191

192
function shouldSendReport(isReportExternal) {
193
  return (
31✔
194
    (isReportExternal &&
93✔
195
            iiqAnalyticsAnalyticsAdapter.initOptions.manualWinReportEnabled &&
196
            !shouldSubscribeOnGAM()) ||
197
        (!isReportExternal && !iiqAnalyticsAnalyticsAdapter.initOptions.manualWinReportEnabled)
198
  );
199
}
200

201
export function restoreReportList() {
202
  reportList = {};
35✔
203
}
204

205
function bidWon(args, isReportExternal) {
206
  if (
32!
207
    isNaN(iiqAnalyticsAnalyticsAdapter.initOptions.partner)
208
  ) {
209
    iiqAnalyticsAnalyticsAdapter.initOptions.partner = -1;
×
210
  }
211
  const currentBrowserLowerCase = detectBrowser();
32✔
212
  if (iiqAnalyticsAnalyticsAdapter.initOptions.browserBlackList?.includes(currentBrowserLowerCase)) {
32✔
213
    logError('IIQ ANALYTICS -> Browser is in blacklist!');
1✔
214
    return;
1✔
215
  }
216

217
  if (shouldSendReport(isReportExternal)) {
31✔
218
    const success = receivePartnerData();
29✔
219
    const preparedPayload = preparePayload(args);
29✔
220
    if (!preparedPayload) return false;
29!
221
    if (success === false) {
29!
222
      preparedPayload[PARAMS_NAMES.terminationCause] = -1
×
223
    }
224
    const { url, method, payload } = constructFullUrl(preparedPayload);
29✔
225
    if (method === 'POST') {
29✔
226
      ajax(url, undefined, payload, {
1✔
227
        method,
228
        contentType: 'application/x-www-form-urlencoded'
229
      });
230
    } else {
231
      ajax(url, undefined, null, { method });
28✔
232
    }
233
    logInfo('IIQ ANALYTICS -> BID WON');
29✔
234
    return true;
29✔
235
  }
236
  return false;
2✔
237
}
238

239
function parseReportingMethod(reportMethod) {
240
  if (typeof reportMethod === 'string') {
60✔
241
    switch (reportMethod.toUpperCase()) {
1!
242
      case 'GET':
243
        return 'GET';
×
244
      case 'POST':
245
        return 'POST';
1✔
246
      default:
247
        return 'GET';
×
248
    }
249
  }
250
  return 'GET';
59✔
251
}
252

253
function defineGlobalVariableName() {
254
  function reportExternalWin(args) {
255
    return bidWon(args, true);
2✔
256
  }
257

258
  const partnerId = iiqConfig?.partner || 0;
60!
259
  globalName = `intentIqAnalyticsAdapter_${partnerId}`;
60✔
260
  identityGlobalName = `iiq_identity_${partnerId}`
60✔
261

262
  window[globalName] = { reportExternalWin };
60✔
263
}
264

265
function getRandom(start, end) {
266
  return Math.floor(Math.random() * (end - start + 1) + start);
1✔
267
}
268

269
export function preparePayload(data) {
270
  const result = getDefaultDataObject();
32✔
271
  result[PARAMS_NAMES.partnerId] = iiqAnalyticsAnalyticsAdapter.initOptions.partner;
32✔
272
  result[PARAMS_NAMES.prebidVersion] = prebidVersion;
32✔
273
  result[PARAMS_NAMES.referrer] = getReferrer();
32✔
274
  result[PARAMS_NAMES.terminationCause] = iiqAnalyticsAnalyticsAdapter.initOptions.terminationCause;
32✔
275
  result[PARAMS_NAMES.clientType] = iiqAnalyticsAnalyticsAdapter.initOptions.clientType;
32✔
276
  result[PARAMS_NAMES.siteId] = iiqAnalyticsAnalyticsAdapter.initOptions.siteId;
32✔
277
  result[PARAMS_NAMES.wasServerCalled] = iiqAnalyticsAnalyticsAdapter.initOptions.wsrvcll;
32✔
278
  result[PARAMS_NAMES.requestRtt] = iiqAnalyticsAnalyticsAdapter.initOptions.rrtt;
32✔
279
  result[PARAMS_NAMES.isInTestGroup] = iiqAnalyticsAnalyticsAdapter.initOptions.currentGroup === WITH_IIQ;
32✔
280

281
  if (iiqAnalyticsAnalyticsAdapter.initOptions.currentGroup) {
32✔
282
    result[PARAMS_NAMES.abTestGroup] = iiqAnalyticsAnalyticsAdapter.initOptions.currentGroup;
32✔
283
  }
284
  result[PARAMS_NAMES.agentId] = REPORTER_ID;
32✔
285
  if (iiqAnalyticsAnalyticsAdapter.initOptions.abTestUuid) {
32✔
286
    result[PARAMS_NAMES.abTestUuid] = iiqAnalyticsAnalyticsAdapter.initOptions.abTestUuid;
32✔
287
  }
288
  if (iiqAnalyticsAnalyticsAdapter.initOptions.fpid?.pcid) {
32✔
289
    result[PARAMS_NAMES.firstPartyId] = encodeURIComponent(iiqAnalyticsAnalyticsAdapter.initOptions.fpid.pcid);
32✔
290
  }
291
  if (iiqAnalyticsAnalyticsAdapter.initOptions.fpid?.pid) {
32✔
292
    result[PARAMS_NAMES.profile] = encodeURIComponent(iiqAnalyticsAnalyticsAdapter.initOptions.fpid.pid);
32✔
293
  }
294
  if (iiqAnalyticsAnalyticsAdapter.initOptions.configSource) {
32✔
295
    result[PARAMS_NAMES.ABTestingConfigurationSource] = iiqAnalyticsAnalyticsAdapter.initOptions.configSource
2✔
296
  }
297
  prepareData(data, result);
32✔
298

299
  if (!reportList[result.placementId] || !reportList[result.placementId][result.prebidAuctionId]) {
32!
300
    reportList[result.placementId] = reportList[result.placementId]
32!
301
      ? { ...reportList[result.placementId], [result.prebidAuctionId]: 1 }
302
      : { [result.prebidAuctionId]: 1 };
303
    cleanReportsID = setTimeout(() => {
32✔
304
      if (cleanReportsID) clearTimeout(cleanReportsID);
32!
305
      restoreReportList();
32✔
306
    }, 1500); // clear object in 1.5 second after defining reporting list
307
  } else {
308
    logError('Duplication detected, report will be not sent');
×
309
    return;
×
310
  }
311

312
  fillEidsData(result);
32✔
313

314
  return result;
32✔
315
}
316

317
function fillEidsData(result) {
318
  if (iiqAnalyticsAnalyticsAdapter.initOptions.dataIdsInitialized) {
32✔
319
    result[PARAMS_NAMES.hadEidsInLocalStorage] =
32✔
320
            iiqAnalyticsAnalyticsAdapter.initOptions.eidl && iiqAnalyticsAnalyticsAdapter.initOptions.eidl > 0;
64✔
321
    result[PARAMS_NAMES.auctionEidsLength] = iiqAnalyticsAnalyticsAdapter.initOptions.eidl || -1;
32!
322
  }
323
}
324

325
function prepareData(data, result) {
326
  const adTypeValue = data.adType || data.mediaType;
32✔
327

328
  if (data.bidderCode) {
32✔
329
    result.bidderCode = data.bidderCode;
31✔
330
  }
331
  if (data.cpm) {
32✔
332
    result.cpm = data.cpm;
32✔
333
  }
334
  if (data.currency) {
32✔
335
    result.currency = data.currency;
32✔
336
  }
337
  if (data.originalCpm) {
32✔
338
    result.originalCpm = data.originalCpm;
31✔
339
  }
340
  if (data.originalCurrency) {
32✔
341
    result.originalCurrency = data.originalCurrency;
31✔
342
  }
343
  if (data.status) {
32✔
344
    result.status = data.status;
31✔
345
  }
346

347
  result.prebidAuctionId = data.auctionId || data.prebidAuctionId;
32✔
348

349
  if (adTypeValue) {
32✔
350
    result[PARAMS_NAMES.adType] = adTypeValue;
31✔
351
  }
352

353
  switch (iiqAnalyticsAnalyticsAdapter.initOptions.adUnitConfig) {
32!
354
    case 1:
355
      // adUnitCode or placementId
356
      result.placementId = data.adUnitCode || extractPlacementId(data) || '';
28✔
357
      break;
28✔
358
    case 2:
359
      // placementId or adUnitCode
360
      result.placementId = extractPlacementId(data) || data.adUnitCode || '';
2!
361
      break;
2✔
362
    case 3:
363
      // Only adUnitCode
364
      result.placementId = data.adUnitCode || '';
1!
365
      break;
1✔
366
    case 4:
367
      // Only placementId
368
      result.placementId = extractPlacementId(data) || '';
1!
369
      break;
1✔
370
    default:
371
      // Default (like in case #1)
372
      result.placementId = data.adUnitCode || extractPlacementId(data) || '';
×
373
  }
374

375
  result.biddingPlatformId = data.biddingPlatformId || 1;
32✔
376
  result.partnerAuctionId = 'BW';
32✔
377
}
378

379
function extractPlacementId(data) {
380
  if (data.placementId) {
30✔
381
    return data.placementId;
4✔
382
  }
383
  if (data.params && Array.isArray(data.params)) {
26✔
384
    for (let i = 0; i < data.params.length; i++) {
1✔
385
      if (data.params[i].placementId) {
2✔
386
        return data.params[i].placementId;
1✔
387
      }
388
    }
389
  }
390
  return null;
25✔
391
}
392

393
function getDefaultDataObject() {
394
  return {
32✔
395
    inbbl: false,
396
    pbjsver: prebidVersion,
397
    partnerAuctionId: 'BW',
398
    reportSource: 'pbjs',
399
    jsversion: VERSION,
400
    partnerId: -1,
401
    biddingPlatformId: 1,
402
    idls: false,
403
    ast: -1,
404
    aeidln: -1
405
  };
406
}
407

408
function constructFullUrl(data) {
409
  const report = [];
29✔
410
  const reportMethod = iiqAnalyticsAnalyticsAdapter.initOptions.reportMethod;
29✔
411
  const currentBrowserLowerCase = detectBrowser();
29✔
412
  data = btoa(JSON.stringify(data));
29✔
413
  report.push(data);
29✔
414

415
  const cmpData = getCmpData();
29✔
416
  const baseUrl = reportingServerAddress(...getDataForDefineURL());
29✔
417

418
  let url =
419
        baseUrl +
29✔
420
        '?pid=' +
421
        iiqAnalyticsAnalyticsAdapter.initOptions.partner +
422
        '&mct=1' +
423
        (iiqAnalyticsAnalyticsAdapter.initOptions?.fpid
29!
424
          ? '&iiqid=' + encodeURIComponent(iiqAnalyticsAnalyticsAdapter.initOptions.fpid.pcid)
425
          : '') +
426
        '&agid=' +
427
        REPORTER_ID +
428
        '&jsver=' +
429
        VERSION +
430
        '&source=' +
431
        PREBID +
432
        '&uh=' +
433
        encodeURIComponent(iiqAnalyticsAnalyticsAdapter.initOptions.clientsHints) +
434
        (cmpData.uspString ? '&us_privacy=' + encodeURIComponent(cmpData.uspString) : '') +
29✔
435
        (cmpData.gppString ? '&gpp=' + encodeURIComponent(cmpData.gppString) : '') +
29✔
436
        (cmpData.gdprString ? '&gdpr_consent=' + encodeURIComponent(cmpData.gdprString) + '&gdpr=1' : '&gdpr=0');
29✔
437
  url = appendSPData(url, iiqAnalyticsAnalyticsAdapter.initOptions.fpid);
29✔
438
  url = appendVrrefAndFui(url, iiqAnalyticsAnalyticsAdapter.initOptions.domainName);
29✔
439

440
  if (reportMethod === 'POST') {
29✔
441
    return { url, method: 'POST', payload: JSON.stringify(report) };
1✔
442
  }
443
  url += '&payload=' + encodeURIComponent(JSON.stringify(report));
28✔
444
  url = handleAdditionalParams(
28✔
445
    currentBrowserLowerCase,
446
    url,
447
    2,
448
    iiqAnalyticsAnalyticsAdapter.initOptions.additionalParams
449
  );
450
  return { url, method: 'GET' };
28✔
451
}
452

453
iiqAnalyticsAnalyticsAdapter.originEnableAnalytics = iiqAnalyticsAnalyticsAdapter.enableAnalytics;
1✔
454

455
iiqAnalyticsAnalyticsAdapter.enableAnalytics = function (myConfig) {
1✔
456
  iiqAnalyticsAnalyticsAdapter.originEnableAnalytics(myConfig); // call the base class function
60✔
457
  initAdapterConfig(myConfig)
60✔
458
};
459

460
iiqAnalyticsAnalyticsAdapter.originDisableAnalytics = iiqAnalyticsAnalyticsAdapter.disableAnalytics;
1✔
461
iiqAnalyticsAnalyticsAdapter.disableAnalytics = function() {
1✔
462
  globalName = undefined;
60✔
463
  identityGlobalName = undefined;
60✔
464
  alreadySubscribedOnGAM = false;
60✔
465
  reportList = {};
60✔
466
  cleanReportsID = undefined;
60✔
467
  iiqConfig = undefined;
60✔
468
  iiqAnalyticsAnalyticsAdapter.initOptions = getDefaultInitOptions()
60✔
469
  iiqAnalyticsAnalyticsAdapter.originDisableAnalytics()
60✔
470
};
471
adapterManager.registerAnalyticsAdapter({
1✔
472
  adapter: iiqAnalyticsAnalyticsAdapter,
473
  code: MODULE_NAME
474
});
475

476
export default iiqAnalyticsAnalyticsAdapter;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc