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

mendersoftware / gui / 1350829378

27 Jun 2024 01:46PM UTC coverage: 83.494% (-16.5%) from 99.965%
1350829378

Pull #4465

gitlab-ci

mzedel
chore: test fixes

Signed-off-by: Manuel Zedel <manuel.zedel@northern.tech>
Pull Request #4465: MEN-7169 - feat: added multi sorting capabilities to devices view

4506 of 6430 branches covered (70.08%)

81 of 100 new or added lines in 14 files covered. (81.0%)

1661 existing lines in 163 files now uncovered.

8574 of 10269 relevant lines covered (83.49%)

160.6 hits per line

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

94.96
/src/js/components/settings/roledefinition.js
1
// Copyright 2020 Northern.tech AS
2
//
3
//    Licensed under the Apache License, Version 2.0 (the "License");
4
//    you may not use this file except in compliance with the License.
5
//    You may obtain a copy of the License at
6
//
7
//        http://www.apache.org/licenses/LICENSE-2.0
8
//
9
//    Unless required by applicable law or agreed to in writing, software
10
//    distributed under the License is distributed on an "AS IS" BASIS,
11
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
//    See the License for the specific language governing permissions and
13
//    limitations under the License.
14
import React, { useEffect, useMemo, useState } from 'react';
15

16
// material ui
17
import { Close as CloseIcon, InfoOutlined as InfoOutlinedIcon, WarningAmber as WarningIcon } from '@mui/icons-material';
18
import {
19
  Box,
20
  Button,
21
  Checkbox,
22
  Dialog,
23
  DialogActions,
24
  DialogContent,
25
  DialogTitle,
26
  Divider,
27
  Drawer,
28
  FormControl,
29
  FormHelperText,
30
  IconButton,
31
  InputLabel,
32
  MenuItem,
33
  Select,
34
  TextField,
35
  Tooltip
36
} from '@mui/material';
37
import { makeStyles } from 'tss-react/mui';
38

39
import validator from 'validator';
40

41
import { ALL_DEVICES } from '../../constants/deviceConstants';
42
import { ALL_RELEASES } from '../../constants/releaseConstants';
43
import { emptyRole, emptyUiPermissions, itemUiPermissionsReducer, rolesById, uiPermissionsByArea, uiPermissionsById } from '../../constants/userConstants';
44
import { deepCompare, isEmpty, toggle } from '../../helpers';
45

46
const menuProps = {
5✔
47
  anchorOrigin: {
48
    vertical: 'bottom',
49
    horizontal: 'left'
50
  },
51
  transformOrigin: {
52
    vertical: 'top',
53
    horizontal: 'left'
54
  }
55
};
56

57
const permissionEnabledDisabled = (uiPermission, values, permissionsArea, unscoped) => {
5✔
58
  const { permissionLevel, value: permissionValue, unscopedOnly = {} } = uiPermission;
137✔
59
  const disabled = values.some(permission => uiPermissionsById[permission].permissionLevel > permissionLevel);
137✔
60
  const enabled = values.some(permission => permission === permissionValue) || disabled;
137✔
61
  const skip = unscopedOnly[permissionsArea] && !unscoped;
137✔
62
  return { enabled, disabled, skip };
137✔
63
};
64

65
const useStyles = makeStyles()(theme => ({
29✔
66
  buttons: { '&.flexbox.centered': { justifyContent: 'flex-end' } },
67
  roleDeletion: { marginRight: theme.spacing(2) },
68
  permissionSelect: { marginLeft: theme.spacing(-1.5) },
69
  permissionsTitle: { marginBottom: theme.spacing(-1), minHeight: theme.spacing(3) }
70
}));
71

72
const PermissionsSelect = ({ disabled, label, onChange, options, permissionsArea, unscoped, values }) => {
5✔
73
  const { classes } = useStyles();
160✔
74

75
  const onInputChange = ({ target: { value } }) => {
160✔
76
    if (value.includes('')) {
2!
UNCOV
77
      return onChange([]);
×
78
    }
79
    return onChange(value);
2✔
80
  };
81

82
  const { editablePermissions, selectedValues } = useMemo(
160✔
83
    () =>
84
      options.reduce(
45✔
85
        (accu, uiPermission) => {
86
          const { enabled, disabled, skip } = permissionEnabledDisabled(uiPermission, values, permissionsArea, unscoped);
137✔
87
          if (skip) {
137✔
88
            return accu;
8✔
89
          }
90
          accu.editablePermissions.push({ enabled, disabled, ...uiPermission });
129✔
91
          if (enabled) {
129✔
92
            accu.selectedValues.push(uiPermission.value);
15✔
93
          }
94
          return accu;
129✔
95
        },
96
        { editablePermissions: [], selectedValues: [] }
97
      ),
98
    [options, permissionsArea, unscoped, values]
99
  );
100

101
  return (
160✔
102
    <FormControl>
103
      <InputLabel id="permission-selection-label">{label && !values.length ? label : ''}</InputLabel>
422✔
104
      <Select
105
        labelId="permission-selection-label"
106
        disabled={disabled}
107
        displayEmpty={!label}
108
        fullWidth
109
        MenuProps={menuProps}
110
        multiple
111
        onChange={onInputChange}
112
        renderValue={() => (selectedValues.length ? selectedValues.map(value => uiPermissionsById[value].title).join(', ') : 'None')}
123✔
113
        value={values}
114
      >
115
        {editablePermissions.map(uiPermission => (
116
          <MenuItem disabled={uiPermission.disabled} key={uiPermission.value} value={uiPermission.value}>
454✔
117
            <Checkbox className={classes.permissionSelect} checked={uiPermission.enabled} disabled={uiPermission.disabled} />
118
            <div className={uiPermission.disabled ? 'text-muted' : ''}>{uiPermission.title}</div>
454✔
119
          </MenuItem>
120
        ))}
121
        <MenuItem value="">None</MenuItem>
122
      </Select>
123
    </FormControl>
124
  );
125
};
126

127
const PermissionsAreaTitle = ({ className = '', explanation, title }) => (
5✔
128
  <div className={`flexbox center-aligned margin-top ${className}`}>
116✔
129
    {title}
130
    <Tooltip arrow placement="bottom" title={explanation}>
131
      <InfoOutlinedIcon className="margin-left-small muted" fontSize="small" />
132
    </Tooltip>
133
  </div>
134
);
135

136
const PermissionsItem = ({ area, ...remainder }) => (
5✔
137
  <>
58✔
138
    <PermissionsAreaTitle title={area.title} explanation={area.explanation} />
139
    <PermissionsSelect options={area.uiPermissions} {...remainder} />
140
  </>
141
);
142

143
const groupsFilter = stateGroups =>
5✔
144
  Object.entries(stateGroups).reduce(
9✔
145
    (accu, [name, groupInfo]) => {
146
      if (!groupInfo.filters.length) {
18✔
147
        accu.push(name);
9✔
148
      }
149
      return accu;
18✔
150
    },
151
    [ALL_DEVICES]
152
  );
153

154
const releasesFilter = stateReleaseTags => [ALL_RELEASES, ...Object.keys(stateReleaseTags)];
9✔
155

156
const scopedPermissionAreas = {
5✔
157
  groups: {
158
    key: 'groups',
159
    filter: groupsFilter,
160
    placeholder: 'Search groups',
161
    excessiveAccessConfig: {
162
      selector: ALL_DEVICES,
163
      warning: `For 'All devices', users with the Manage permission may also create, edit and delete devices groups.`
164
    }
165
  },
166
  releases: {
167
    key: 'releases',
168
    filter: releasesFilter,
169
    placeholder: 'Search release tags',
170
    excessiveAccessConfig: {
171
      selector: ALL_RELEASES,
172
      warning: `For 'All releases', users with the Manage permission may also upload and delete releases.`
173
    }
174
  }
175
};
176

177
const maybeExtendPermissionSelection = (changedGroupSelection, currentGroup, items) => {
5✔
178
  if (items.every(item => changedGroupSelection.some(selectionItem => selectionItem.item === item))) {
11!
UNCOV
179
    return changedGroupSelection;
×
180
  }
181
  if (changedGroupSelection.every(selection => selection.item && selection.uiPermissions.length)) {
6✔
182
    changedGroupSelection.push(emptyItemSelection);
1✔
183
    return changedGroupSelection;
1✔
184
  }
185
  // the following is horrible, but I couldn't come up with a better solution that ensures only a single partly defined definition exists
186
  const filtered = changedGroupSelection.filter(selection => !((!selection.item || !selection.uiPermissions.length) && selection !== currentGroup));
6✔
187
  if (!filtered.some(selection => !selection.item || !selection.uiPermissions.length)) {
3✔
188
    filtered.push(emptyItemSelection);
2✔
189
  }
190
  return filtered;
3✔
191
};
192

193
const ItemSelection = ({
5✔
194
  disableEdit,
195
  items,
196
  itemsSelection,
197
  setter,
198
  permissionsArea,
199
  placeholder,
200
  excessiveAccessConfig: { selector: excessiveAccessSelector, warning: excessiveAccessWarning }
201
}) => {
202
  const onItemSelect = (index, { target: { value } }, currentSelection) => {
58✔
203
    let changedSelection = [...currentSelection];
2✔
204
    changedSelection[index] = { ...changedSelection[index], item: value };
2✔
205
    changedSelection = maybeExtendPermissionSelection(changedSelection, changedSelection[index], items);
2✔
206
    setter(changedSelection);
2✔
207
  };
208

209
  // find missing items in selected items
210
  itemsSelection.forEach((selection, key) => {
58✔
211
    if (selection.item && !items.includes(selection.item)) {
102✔
212
      items.push(selection.item);
1✔
213
      itemsSelection[key].notFound = true;
1✔
214
    }
215
  });
216

217
  const onItemPermissionSelect = (index, selectedPermissions, currentSelection) => {
58✔
218
    let changedSelection = [...currentSelection];
2✔
219
    changedSelection[index] = { ...changedSelection[index], uiPermissions: selectedPermissions };
2✔
220
    changedSelection = maybeExtendPermissionSelection(changedSelection, changedSelection[index], items);
2✔
221
    setter(changedSelection);
2✔
222
  };
223

224
  const isItemNotFound = item => !!itemsSelection.filter(selection => selection.item === item && selection.notFound === true).length;
1,521✔
225

226
  const { title, uiPermissions, explanation } = uiPermissionsByArea[permissionsArea];
58✔
227
  return (
58✔
228
    <>
229
      <PermissionsAreaTitle className="margin-left-small" explanation={explanation} title={title} />
230
      {itemsSelection.map((itemSelection, index) => {
231
        const disabled = disableEdit || itemSelection.disableEdit;
102✔
232
        return (
102✔
233
          <div className="flexbox center-aligned margin-left" key={`${itemSelection.item}-${index}`}>
234
            <div className="two-columns center-aligned" style={{ maxWidth: 500 }}>
235
              {disabled ? (
102!
236
                // empty label as a shortcut to align the layout with the select path
237
                <TextField disabled defaultValue={itemSelection.item} label=" " />
238
              ) : (
239
                <FormControl>
240
                  <InputLabel id="permissions-group-selection-label">{!itemSelection.item ? placeholder : ''}</InputLabel>
102✔
241
                  <Select labelId="permissions-group-selection-label" onChange={e => onItemSelect(index, e, itemsSelection)} value={itemSelection.item}>
2✔
242
                    {items.map(item => (
243
                      <MenuItem disabled={isItemNotFound(item)} key={item} value={item}>
251✔
244
                        <Box title={isItemNotFound(item) ? 'This item was removed' : ''} className="flexbox center-aligned">
251✔
245
                          {isItemNotFound(item) && <WarningIcon style={{ marginRight: 4 }} />}
257✔
246
                          {item}
247
                        </Box>
248
                      </MenuItem>
249
                    ))}
250
                  </Select>
251
                </FormControl>
252
              )}
253
              <PermissionsSelect
254
                disabled={disabled}
255
                label="Select"
256
                onChange={selectedPermissions => onItemPermissionSelect(index, selectedPermissions, itemsSelection)}
2✔
257
                options={uiPermissions}
258
                permissionsArea={permissionsArea}
259
                unscoped={itemSelection.item === excessiveAccessSelector}
260
                values={itemSelection.uiPermissions}
261
              />
262
            </div>
263
            {itemSelection.item === excessiveAccessSelector && (
114✔
264
              <div className="margin-left text-muted" style={{ alignSelf: 'flex-end' }}>
265
                {excessiveAccessWarning}
266
              </div>
267
            )}
268
          </div>
269
        );
270
      })}
271
    </>
272
  );
273
};
274

275
const emptyItemSelection = { item: '', uiPermissions: [], disableEdit: false };
5✔
276

277
const permissionMapper = uiPermission => uiPermissionsById[uiPermission].value;
11✔
278

279
const uiPermissionCompare = (existingPermissions, changedPermissions) => deepCompare(existingPermissions, changedPermissions);
5✔
280

281
const deriveItemsAndPermissions = (stateItems, roleItems, options = {}) => {
5!
282
  const { adding, disableEdit, filter } = options;
18✔
283
  let filteredStateItems = filter(stateItems);
18✔
284
  let itemSelections = Object.entries(roleItems).map(([group, permissions]) => ({
18✔
285
    ...emptyItemSelection,
286
    item: group,
287
    uiPermissions: permissions.map(permissionMapper)
288
  }));
289
  if (!adding && !itemSelections.length && filteredStateItems.length !== Object.keys(stateItems).length && !isEmpty(roleItems)) {
18!
UNCOV
290
    filteredStateItems = Object.keys(roleItems);
×
UNCOV
291
    itemSelections = Object.keys(roleItems).map(group => ({
×
292
      item: group,
293
      uiPermissions: [uiPermissionsById.read.value, uiPermissionsById.manage.value],
294
      disableEdit: true
295
    }));
296
  } else if (!disableEdit) {
18!
297
    itemSelections.push(emptyItemSelection);
18✔
298
  }
299
  return { filtered: filteredStateItems, selections: itemSelections };
18✔
300
};
301

302
const DeleteRoleDialog = ({ dismiss, open, submit, name }) => (
5✔
303
  <Dialog open={open}>
29✔
304
    <DialogTitle>Delete role?</DialogTitle>
305
    <DialogContent style={{ overflow: 'hidden' }}>
306
      Are you sure you want to delete the role{' '}
307
      <b>
308
        <i>{name}</i>
309
      </b>
310
      ?
311
    </DialogContent>
312
    <DialogActions>
313
      <Button style={{ marginRight: 10 }} onClick={dismiss}>
314
        Cancel
315
      </Button>
316
      <Button variant="contained" color="primary" onClick={submit}>
317
        Delete role
318
      </Button>
319
    </DialogActions>
320
  </Dialog>
321
);
322

323
export const RoleDefinition = ({ adding, editing, stateGroups, stateReleaseTags, onCancel, onSubmit, removeRole, selectedRole = { ...emptyRole } }) => {
5✔
324
  const [description, setDescription] = useState(selectedRole.description);
34✔
325
  const [groups, setGroups] = useState([]);
34✔
326
  const [releases, setReleases] = useState([]);
34✔
327
  const [name, setName] = useState(selectedRole.name);
34✔
328
  const [nameError, setNameError] = useState(false);
34✔
329
  const [auditlogPermissions, setAuditlogPermissions] = useState([]);
34✔
330
  const [groupSelections, setGroupSelections] = useState([]);
34✔
331
  const [releasesPermissions, setReleasesPermissions] = useState([]);
34✔
332
  const [releaseTagSelections, setReleaseTagSelections] = useState([]);
34✔
333
  const [removeDialog, setRemoveDialog] = useState(false);
34✔
334
  const [userManagementPermissions, setUserManagementPermissions] = useState([]);
34✔
335
  const { classes } = useStyles();
34✔
336

337
  useEffect(() => {
34✔
338
    const { name: roleName = '', description: roleDescription = '' } = selectedRole;
9!
339
    const { auditlog, groups: roleGroups = {}, releases: roleReleases = {}, userManagement } = { ...emptyUiPermissions, ...selectedRole.uiPermissions };
9!
340
    const disableEdit = editing && Boolean(rolesById[roleName] || !selectedRole.editable);
9✔
341
    setName(roleName);
9✔
342
    setDescription(roleDescription);
9✔
343
    setUserManagementPermissions(userManagement.map(permissionMapper));
9✔
344
    setAuditlogPermissions(auditlog.map(permissionMapper));
9✔
345
    const { filtered: filteredStateGroups, selections: groupSelections } = deriveItemsAndPermissions(stateGroups, roleGroups, {
9✔
346
      adding,
347
      disableEdit,
348
      filter: scopedPermissionAreas.groups.filter
349
    });
350
    setGroups(filteredStateGroups);
9✔
351
    setGroupSelections(groupSelections);
9✔
352
    const { filtered: filteredReleases, selections: releaseTagSelections } = deriveItemsAndPermissions(stateReleaseTags, roleReleases, {
9✔
353
      adding,
354
      disableEdit,
355
      filter: scopedPermissionAreas.releases.filter
356
    });
357
    setReleases(filteredReleases);
9✔
358
    setReleaseTagSelections(releaseTagSelections);
9✔
359
    setReleasesPermissions(
9✔
360
      releaseTagSelections.reduce((accu, { item, uiPermissions }) => {
361
        if (item === ALL_RELEASES) {
13!
UNCOV
362
          return [...accu, ...uiPermissions];
×
363
        }
364
        return accu;
13✔
365
      }, [])
366
    );
367
    // eslint-disable-next-line react-hooks/exhaustive-deps
368
  }, [adding, editing, JSON.stringify(selectedRole), JSON.stringify(stateGroups), JSON.stringify(stateReleaseTags)]);
369

370
  const validateNameChange = ({ target: { value } }) => {
34✔
UNCOV
371
    setNameError(!(value && validator.isWhitelisted(value, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-')));
×
UNCOV
372
    setName(value);
×
373
  };
374

375
  const onSubmitClick = () => {
34✔
376
    const allowUserManagement = userManagementPermissions.includes(uiPermissionsById.manage.value);
1✔
377
    const role = {
1✔
378
      source: selectedRole,
379
      allowUserManagement,
380
      description,
381
      name,
382
      uiPermissions: {
383
        auditlog: auditlogPermissions,
384
        groups: groupSelections,
385
        releases: releaseTagSelections,
386
        userManagement: userManagementPermissions
387
      }
388
    };
389
    onSubmit(role);
1✔
390
  };
391

392
  const onRemoveRole = () => {
34✔
393
    setRemoveDialog(false);
1✔
394
    removeRole(name);
1✔
395
    onCancel();
1✔
396
  };
397

398
  const onToggleRemoveDialog = () => setRemoveDialog(toggle);
34✔
399

400
  const disableEdit = editing && Boolean(rolesById[selectedRole.id] || !selectedRole.editable);
34✔
401
  const isSubmitDisabled = useMemo(() => {
34✔
402
    const changedPermissions = {
27✔
403
      ...emptyUiPermissions,
404
      auditlog: auditlogPermissions,
405
      userManagement: userManagementPermissions,
406
      groups: groupSelections.reduce(itemUiPermissionsReducer, {}),
407
      releases: releaseTagSelections.reduce(itemUiPermissionsReducer, {})
408
    };
409
    const { hasPartiallyDefinedAreas, hasAreaPermissions } = [...groupSelections, ...releaseTagSelections].reduce(
27✔
410
      (accu, { item, uiPermissions }) => {
411
        accu.hasPartiallyDefinedAreas = accu.hasPartiallyDefinedAreas || (item && !uiPermissions.length) || (!item && uiPermissions.length);
82✔
412
        accu.hasAreaPermissions = accu.hasAreaPermissions || !!(item && uiPermissions.length);
82✔
413
        return accu;
82✔
414
      },
415
      { hasPartiallyDefinedAreas: false, hasAreaPermissions: false }
416
    );
417
    return Boolean(
27✔
418
      disableEdit ||
124✔
419
        !name ||
420
        nameError ||
421
        hasPartiallyDefinedAreas ||
422
        !(auditlogPermissions.length || hasAreaPermissions || releasesPermissions.length || userManagementPermissions.length) ||
32!
423
        (Object.entries({ description, name }).every(([key, value]) => selectedRole[key] === value) &&
20✔
424
          uiPermissionCompare(selectedRole.uiPermissions, changedPermissions))
425
    );
426
  }, [
427
    auditlogPermissions,
428
    userManagementPermissions,
429
    groupSelections,
430
    releaseTagSelections,
431
    releasesPermissions,
432
    disableEdit,
433
    name,
434
    nameError,
435
    description,
436
    selectedRole
437
  ]);
438

439
  return (
34✔
440
    <Drawer anchor="right" open={adding || editing} PaperProps={{ style: { minWidth: 600, width: '50vw' } }}>
66✔
441
      <div className="flexbox margin-bottom-small space-between">
442
        <h3>{adding ? 'Add a' : 'Edit'} role</h3>
34✔
443
        <div className="flexbox center-aligned">
444
          {editing && !rolesById[selectedRole.id] && (
80✔
445
            <Button
446
              className={`flexbox center-aligned ${classes.roleDeletion}`}
447
              color="secondary"
448
              disabled={!!rolesById[selectedRole.id]}
449
              onClick={onToggleRemoveDialog}
450
            >
451
              delete role
452
            </Button>
453
          )}
454
          <IconButton onClick={onCancel} aria-label="close">
455
            <CloseIcon />
456
          </IconButton>
457
        </div>
458
      </div>
459
      <Divider />
460
      <div className="flexbox column" style={{ width: 500 }}>
461
        <FormControl>
462
          <TextField label="Name" id="role-name" value={name} disabled={disableEdit || editing} onChange={validateNameChange} />
68✔
463
          {nameError && <FormHelperText className="warning">Invalid character in role name. Valid characters are a-z, A-Z, 0-9, _ and -</FormHelperText>}
34!
464
        </FormControl>
465
        <TextField
466
          disabled={disableEdit}
467
          label="Description"
468
          id="role-description"
469
          multiline
470
          placeholder="-"
471
          onChange={e => setDescription(e.target.value)}
9✔
472
          value={description}
473
        />
474
      </div>
475

476
      <InputLabel className={`margin-top ${classes.permissionsTitle}`} shrink>
477
        Permissions
478
      </InputLabel>
479
      <div className="two-columns center-aligned margin-left-small" style={{ maxWidth: 500 }}>
480
        <PermissionsItem
481
          area={uiPermissionsByArea.userManagement}
482
          disabled={disableEdit}
483
          onChange={setUserManagementPermissions}
484
          values={userManagementPermissions}
485
        />
486
        <PermissionsItem disabled={disableEdit} area={uiPermissionsByArea.auditlog} onChange={setAuditlogPermissions} values={auditlogPermissions} />
487
      </div>
488
      {(!disableEdit || !!releaseTagSelections.length) && (
68!
489
        <ItemSelection
490
          disableEdit={disableEdit}
491
          excessiveAccessConfig={scopedPermissionAreas.releases.excessiveAccessConfig}
492
          items={releases}
493
          itemsSelection={releaseTagSelections}
494
          permissionsArea={scopedPermissionAreas.releases.key}
495
          placeholder={scopedPermissionAreas.releases.placeholder}
496
          setter={setReleaseTagSelections}
497
        />
498
      )}
499
      {(!disableEdit || !!groupSelections.length) && (
68!
500
        <ItemSelection
501
          disableEdit={disableEdit}
502
          excessiveAccessConfig={scopedPermissionAreas.groups.excessiveAccessConfig}
503
          items={groups}
504
          itemsSelection={groupSelections}
505
          permissionsArea={scopedPermissionAreas.groups.key}
506
          placeholder={scopedPermissionAreas.groups.placeholder}
507
          setter={setGroupSelections}
508
        />
509
      )}
510
      <Divider className="margin-top-large" light />
511
      <div className={`flexbox centered margin-top ${classes.buttons}`}>
512
        <Button className="margin-right" onClick={onCancel}>
513
          Cancel
514
        </Button>
515
        <Button color="secondary" variant="contained" target="_blank" disabled={isSubmitDisabled} onClick={onSubmitClick}>
516
          Submit
517
        </Button>
518
      </div>
519
      <DeleteRoleDialog dismiss={onToggleRemoveDialog} open={removeDialog} submit={onRemoveRole} name={name} />
520
    </Drawer>
521
  );
522
};
523

524
export default RoleDefinition;
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

© 2025 Coveralls, Inc