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

nette / forms / 21851967935

10 Feb 2026 04:40AM UTC coverage: 93.076% (+0.2%) from 92.892%
21851967935

push

github

dg
netteForms: restructured package, includes UMD and ESM (BC break)

2070 of 2224 relevant lines covered (93.08%)

0.93 hits per line

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

92.63
/src/Forms/Form.php
1
<?php
2

3
/**
4
 * This file is part of the Nette Framework (https://nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
declare(strict_types=1);
9

10
namespace Nette\Forms;
11

12
use Nette;
13
use Nette\Utils\Arrays;
14
use Nette\Utils\Html;
15
use Stringable;
16
use function array_key_first, array_merge, array_search, array_unique, count, headers_sent, in_array, is_array, is_scalar, is_string, sprintf, strcasecmp, strtolower;
17

18

19
/**
20
 * Creates, validates and renders HTML forms.
21
 *
22
 * @property-read string[] $errors
23
 * @property-read array<string|Stringable> $ownErrors
24
 * @property-read Html $elementPrototype
25
 * @property-deprecated FormRenderer $renderer
26
 * @property-deprecated string $action
27
 * @property-deprecated string $method
28
 */
29
class Form extends Container implements Nette\HtmlStringable
30
{
31
        /** validator */
32
        public const
33
                Equal = ':equal',
34
                IsIn = self::Equal,
35
                NotEqual = ':notEqual',
36
                IsNotIn = self::NotEqual,
37
                Filled = ':filled',
38
                Blank = ':blank',
39
                Required = self::Filled,
40
                Valid = ':valid',
41

42
                // button
43
                Submitted = ':submitted',
44

45
                // text
46
                MinLength = ':minLength',
47
                MaxLength = ':maxLength',
48
                Length = ':length',
49
                Email = ':email',
50
                URL = ':url',
51
                Pattern = ':pattern',
52
                PatternInsensitive = ':patternCaseInsensitive',
53
                Integer = ':integer',
54
                Numeric = ':numeric',
55
                Float = ':float',
56
                Min = ':min',
57
                Max = ':max',
58
                Range = ':range',
59

60
                // multiselect
61
                Count = self::Length,
62

63
                // file upload
64
                MaxFileSize = ':fileSize',
65
                MimeType = ':mimeType',
66
                Image = ':image',
67
                MaxPostSize = ':maxPostSize';
68

69
        /** method */
70
        public const
71
                Get = 'get',
72
                Post = 'post';
73

74
        /** submitted data types */
75
        public const
76
                DataText = 1,
77
                DataLine = 2,
78
                DataFile = 3,
79
                DataKeys = 8;
80

81
        /** @internal tracker ID */
82
        public const TrackerId = '_form_';
83

84
        /** @internal protection token ID */
85
        public const ProtectorId = '_token_';
86

87
        #[\Deprecated('use Form::Equal')]
88
        public const EQUAL = self::Equal;
89

90
        #[\Deprecated('use Form::IsIn')]
91
        public const IS_IN = self::IsIn;
92

93
        #[\Deprecated('use Form::NotEqual')]
94
        public const NOT_EQUAL = self::NotEqual;
95

96
        #[\Deprecated('use Form::IsNotIn')]
97
        public const IS_NOT_IN = self::IsNotIn;
98

99
        #[\Deprecated('use Form::Filled')]
100
        public const FILLED = self::Filled;
101

102
        #[\Deprecated('use Form::Blank')]
103
        public const BLANK = self::Blank;
104

105
        #[\Deprecated('use Form::Required')]
106
        public const REQUIRED = self::Required;
107

108
        #[\Deprecated('use Form::Valid')]
109
        public const VALID = self::Valid;
110

111
        #[\Deprecated('use Form::Submitted')]
112
        public const SUBMITTED = self::Submitted;
113

114
        #[\Deprecated('use Form::MinLength')]
115
        public const MIN_LENGTH = self::MinLength;
116

117
        #[\Deprecated('use Form::MaxLength')]
118
        public const MAX_LENGTH = self::MaxLength;
119

120
        #[\Deprecated('use Form::Length')]
121
        public const LENGTH = self::Length;
122

123
        #[\Deprecated('use Form::Email')]
124
        public const EMAIL = self::Email;
125

126
        #[\Deprecated('use Form::Pattern')]
127
        public const PATTERN = self::Pattern;
128

129
        #[\Deprecated('use Form::PatternCI')]
130
        public const PATTERN_ICASE = self::PatternInsensitive;
131

132
        #[\Deprecated('use Form::Integer')]
133
        public const INTEGER = self::Integer;
134

135
        #[\Deprecated('use Form::Numeric')]
136
        public const NUMERIC = self::Numeric;
137

138
        #[\Deprecated('use Form::Float')]
139
        public const FLOAT = self::Float;
140

141
        #[\Deprecated('use Form::Min')]
142
        public const MIN = self::Min;
143

144
        #[\Deprecated('use Form::Max')]
145
        public const MAX = self::Max;
146

147
        #[\Deprecated('use Form::Range')]
148
        public const RANGE = self::Range;
149

150
        #[\Deprecated('use Form::Count')]
151
        public const COUNT = self::Count;
152

153
        #[\Deprecated('use Form::MaxFileSize')]
154
        public const MAX_FILE_SIZE = self::MaxFileSize;
155

156
        #[\Deprecated('use Form::MimeType')]
157
        public const MIME_TYPE = self::MimeType;
158

159
        #[\Deprecated('use Form::Image')]
160
        public const IMAGE = self::Image;
161

162
        #[\Deprecated('use Form::MaxPostSize')]
163
        public const MAX_POST_SIZE = self::MaxPostSize;
164

165
        #[\Deprecated('use Form::Get')]
166
        public const GET = self::Get;
167

168
        #[\Deprecated('use Form::Post')]
169
        public const POST = self::Post;
170

171
        #[\Deprecated('use Form::DataText')]
172
        public const DATA_TEXT = self::DataText;
173

174
        #[\Deprecated('use Form::DataLine')]
175
        public const DATA_LINE = self::DataLine;
176

177
        #[\Deprecated('use Form::DataFile')]
178
        public const DATA_FILE = self::DataFile;
179

180
        #[\Deprecated('use Form::DataKeys')]
181
        public const DATA_KEYS = self::DataKeys;
182

183
        #[\Deprecated('use Form::TrackerId')]
184
        public const TRACKER_ID = self::TrackerId;
185

186
        #[\Deprecated('use Form::ProtectorId')]
187
        public const PROTECTOR_ID = self::ProtectorId;
188

189
        /**
190
         * Occurs when the form is submitted and successfully validated
191
         * @var array<callable(self, mixed[]|object): void | callable(mixed[]|object): void>
192
         */
193
        public array $onSuccess = [];
194

195
        /** @var array<callable(self): void>  Occurs when the form is submitted and is not valid */
196
        public array $onError = [];
197

198
        /** @var array<callable(self): void>  Occurs when the form is submitted */
199
        public array $onSubmit = [];
200

201
        /** @var array<callable(self): void>  Occurs before the form is rendered */
202
        public array $onRender = [];
203

204
        /** @internal used only by standalone form */
205
        public Nette\Http\IRequest $httpRequest;
206
        protected bool $crossOrigin = false;
207
        private static ?Nette\Http\IRequest $defaultHttpRequest = null;
208
        private SubmitterControl|bool $submittedBy = false;
209

210
        /** @var mixed[] */
211
        private array $httpData;
212
        private Html $element;
213
        private FormRenderer $renderer;
214
        private ?Nette\Localization\Translator $translator = null;
215

216
        /** @var ControlGroup[] */
217
        private array $groups = [];
218

219
        /** @var list<string|Stringable> */
220
        private array $errors = [];
221
        private bool $beforeRenderCalled = false;
222

223

224
        public function __construct(?string $name = null)
1✔
225
        {
226
                if ($name !== null) {
1✔
227
                        $this->getElementPrototype()->id = 'frm-' . $name;
1✔
228
                        $tracker = new Controls\HiddenField($name);
1✔
229
                        $tracker->setOmitted();
1✔
230
                        $this[self::TrackerId] = $tracker;
1✔
231
                        $this->setParent(null, $name);
1✔
232
                }
233

234
                $this->monitor(self::class, function (): void {
1✔
235
                        throw new Nette\InvalidStateException('Nested forms are forbidden.');
236
                });
1✔
237
        }
1✔
238

239

240
        /**
241
         * Returns self.
242
         */
243
        public function getForm(bool $throw = true): static
1✔
244
        {
245
                return $this;
1✔
246
        }
247

248

249
        /**
250
         * Sets form's action.
251
         */
252
        public function setAction(string|Stringable $url): static
1✔
253
        {
254
                $this->getElementPrototype()->action = $url;
1✔
255
                return $this;
1✔
256
        }
257

258

259
        /**
260
         * Returns form's action.
261
         */
262
        public function getAction(): string|Stringable
263
        {
264
                return $this->getElementPrototype()->action;
1✔
265
        }
266

267

268
        /**
269
         * Sets form's method GET or POST.
270
         */
271
        public function setMethod(string $method): static
1✔
272
        {
273
                if (isset($this->httpData)) {
1✔
274
                        throw new Nette\InvalidStateException(__METHOD__ . '() must be called until the form is empty.');
×
275
                }
276

277
                $this->getElementPrototype()->method = strtolower($method);
1✔
278
                return $this;
1✔
279
        }
280

281

282
        /**
283
         * Returns form's method.
284
         */
285
        public function getMethod(): string
286
        {
287
                return $this->getElementPrototype()->method;
1✔
288
        }
289

290

291
        /**
292
         * Checks if the request method is the given one.
293
         */
294
        public function isMethod(string $method): bool
1✔
295
        {
296
                return strcasecmp($this->getElementPrototype()->method, $method) === 0;
1✔
297
        }
298

299

300
        /**
301
         * Changes forms's HTML attribute.
302
         */
303
        public function setHtmlAttribute(string $name, mixed $value = true): static
304
        {
305
                $this->getElementPrototype()->$name = $value;
×
306
                return $this;
×
307
        }
308

309

310
        /**
311
         * Disables CSRF protection using a SameSite cookie.
312
         */
313
        public function allowCrossOrigin(): void
314
        {
315
                $this->crossOrigin = true;
1✔
316
        }
1✔
317

318

319
        /**
320
         * @deprecated default protection is sufficient
321
         */
322
        public function addProtection(?string $errorMessage = null): Controls\CsrfProtection
1✔
323
        {
324
                $control = new Controls\CsrfProtection($errorMessage);
1✔
325
                $children = $this->getComponents();
1✔
326
                $first = $children ? (string) array_key_first($children) : null;
1✔
327
                $this->addComponent($control, self::ProtectorId, $first);
1✔
328
                return $control;
1✔
329
        }
330

331

332
        /**
333
         * Adds fieldset group to the form.
334
         */
335
        public function addGroup(string|Stringable|null $caption = null, bool $setAsCurrent = true): ControlGroup
1✔
336
        {
337
                $group = new ControlGroup;
1✔
338
                $group->setOption('label', $caption);
1✔
339
                $group->setOption('visual', true);
1✔
340

341
                if ($setAsCurrent) {
1✔
342
                        $this->setCurrentGroup($group);
1✔
343
                }
344

345
                return !is_scalar($caption) || isset($this->groups[$caption])
1✔
346
                        ? $this->groups[] = $group
1✔
347
                        : $this->groups[$caption] = $group;
1✔
348
        }
349

350

351
        /**
352
         * Removes fieldset group from form.
353
         */
354
        public function removeGroup(string|ControlGroup $name): void
355
        {
356
                if (is_string($name) && isset($this->groups[$name])) {
×
357
                        $group = $this->groups[$name];
×
358

359
                } elseif ($name instanceof ControlGroup && in_array($name, $this->groups, strict: true)) {
×
360
                        $group = $name;
×
361
                        $name = array_search($group, $this->groups, strict: true);
×
362

363
                } else {
364
                        throw new Nette\InvalidArgumentException("Group not found in form '{$this->getName()}'");
×
365
                }
366

367
                foreach ($group->getControls() as $control) {
×
368
                        $control->getParent()->removeComponent($control);
×
369
                }
370

371
                unset($this->groups[$name]);
×
372
        }
373

374

375
        /**
376
         * Returns all defined groups.
377
         * @return ControlGroup[]
378
         */
379
        public function getGroups(): array
380
        {
381
                return $this->groups;
1✔
382
        }
383

384

385
        /**
386
         * Returns the specified group.
387
         */
388
        public function getGroup(string|int $name): ?ControlGroup
1✔
389
        {
390
                return $this->groups[$name] ?? null;
1✔
391
        }
392

393

394
        /********************* translator ****************d*g**/
395

396

397
        /**
398
         * Sets translate adapter.
399
         */
400
        public function setTranslator(?Nette\Localization\Translator $translator): static
1✔
401
        {
402
                $this->translator = $translator;
1✔
403
                return $this;
1✔
404
        }
405

406

407
        /**
408
         * Returns translate adapter.
409
         */
410
        public function getTranslator(): ?Nette\Localization\Translator
411
        {
412
                return $this->translator;
1✔
413
        }
414

415

416
        /********************* submission ****************d*g**/
417

418

419
        /**
420
         * Tells if the form is anchored.
421
         */
422
        public function isAnchored(): bool
423
        {
424
                return true;
1✔
425
        }
426

427

428
        /**
429
         * Tells if the form was submitted.
430
         */
431
        public function isSubmitted(): SubmitterControl|bool
432
        {
433
                if (!isset($this->httpData)) {
1✔
434
                        $this->getHttpData();
1✔
435
                }
436

437
                return $this->submittedBy;
1✔
438
        }
439

440

441
        /**
442
         * Tells if the form was submitted and successfully validated.
443
         */
444
        public function isSuccess(): bool
445
        {
446
                return $this->isSubmitted() && $this->isValid();
1✔
447
        }
448

449

450
        /**
451
         * Sets the submittor control.
452
         * @internal
453
         */
454
        public function setSubmittedBy(?SubmitterControl $by): static
1✔
455
        {
456
                $this->submittedBy = $by ?? false;
1✔
457
                return $this;
1✔
458
        }
459

460

461
        /**
462
         * Returns submitted HTTP data.
463
         * @return string|string[]|Nette\Http\FileUpload|null
464
         */
465
        public function getHttpData(?int $type = null, ?string $htmlName = null): string|array|Nette\Http\FileUpload|null
1✔
466
        {
467
                if (!isset($this->httpData)) {
1✔
468
                        if (!$this->isAnchored()) {
1✔
469
                                throw new Nette\InvalidStateException('Form is not anchored and therefore can not determine whether it was submitted.');
×
470
                        }
471

472
                        $data = $this->receiveHttpData();
1✔
473
                        $this->httpData = (array) $data;
1✔
474
                        $this->submittedBy = is_array($data);
1✔
475
                }
476

477
                return $htmlName === null
1✔
478
                        ? $this->httpData
1✔
479
                        : Helpers::extractHttpData($this->httpData, $htmlName, $type);
1✔
480
        }
481

482

483
        /**
484
         * Fires submit/click events.
485
         */
486
        public function fireEvents(): void
487
        {
488
                if (!$this->isSubmitted()) {
1✔
489
                        return;
1✔
490

491
                } elseif (!$this->getErrors()) {
1✔
492
                        $this->validate();
1✔
493
                }
494

495
                $handled = count($this->onSuccess ?? []) || count($this->onSubmit ?? []) || $this->submittedBy === true;
1✔
496

497
                if ($this->submittedBy instanceof Controls\SubmitButton) {
1✔
498
                        $handled = $handled || count($this->submittedBy->onClick ?? []);
1✔
499
                        if ($this->isValid()) {
1✔
500
                                $this->invokeHandlers($this->submittedBy->onClick, $this->submittedBy);
1✔
501
                        } else {
502
                                Arrays::invoke($this->submittedBy->onInvalidClick, $this->submittedBy);
1✔
503
                        }
504
                }
505

506
                if ($this->isValid()) {
1✔
507
                        $this->invokeHandlers($this->onSuccess);
1✔
508
                }
509

510
                if (!$this->isValid()) {
1✔
511
                        Arrays::invoke($this->onError, $this);
1✔
512
                }
513

514
                Arrays::invoke($this->onSubmit, $this);
1✔
515

516
                if (!$handled) {
1✔
517
                        trigger_error("Form was submitted but there are no associated handlers (form '{$this->getName()}').", E_USER_WARNING);
1✔
518
                }
519
        }
1✔
520

521

522
        /** @param  iterable<callable>  $handlers */
523
        private function invokeHandlers(iterable $handlers, ?SubmitterControl $button = null): void
1✔
524
        {
525
                foreach ($handlers as $handler) {
1✔
526
                        $params = Nette\Utils\Callback::toReflection($handler)->getParameters();
1✔
527
                        $args = [];
1✔
528
                        if ($params) {
1✔
529
                                $type = Helpers::getSingleType($params[0]);
1✔
530
                                $args[] = match (true) {
1✔
531
                                        !$type => $button ?? $this,
1✔
532
                                        $this instanceof $type => $this,
1✔
533
                                        $button instanceof $type => $button,
1✔
534
                                        default => $this->getValues($type),
1✔
535
                                };
536
                                if (isset($params[1])) {
1✔
537
                                        $args[] = $this->getValues(Helpers::getSingleType($params[1]));
1✔
538
                                }
539
                        }
540

541
                        $handler(...$args);
1✔
542

543
                        if (!$this->isValid()) {
1✔
544
                                return;
1✔
545
                        }
546
                }
547
        }
1✔
548

549

550
        /**
551
         * Resets form.
552
         */
553
        public function reset(): static
554
        {
555
                $this->setSubmittedBy(null);
1✔
556
                $this->setValues([], erase: true);
1✔
557
                return $this;
1✔
558
        }
559

560

561
        /**
562
         * Internal: returns submitted HTTP data or null when form was not submitted.
563
         * @return ?mixed[]
564
         */
565
        protected function receiveHttpData(): ?array
566
        {
567
                $httpRequest = $this->getHttpRequest();
1✔
568
                if (strcasecmp($this->getMethod(), $httpRequest->getMethod())) {
1✔
569
                        return null;
1✔
570
                }
571

572
                if ($httpRequest->isMethod('post')) {
1✔
573
                        if (!$this->crossOrigin && !$httpRequest->isFrom('same-origin')) {
1✔
574
                                return null;
1✔
575
                        }
576

577
                        $data = Nette\Utils\Arrays::mergeTree($httpRequest->getPost(), $httpRequest->getFiles());
1✔
578
                } else {
579
                        $data = $httpRequest->getQuery();
1✔
580
                        if (!$data) {
1✔
581
                                return null;
1✔
582
                        }
583
                }
584

585
                if ($tracker = $this->getComponent(self::TrackerId, throw: false)) {
1✔
586
                        if (!isset($data[self::TrackerId]) || $data[self::TrackerId] !== $tracker->getValue()) {
1✔
587
                                return null;
×
588
                        }
589
                }
590

591
                return $data;
1✔
592
        }
593

594

595
        /********************* validation ****************d*g**/
596

597

598
        /** @param  ?(Control|Container)[]  $controls */
599
        public function validate(?array $controls = null): void
1✔
600
        {
601
                $this->cleanErrors();
1✔
602
                if ($controls === null && $this->submittedBy instanceof SubmitterControl) {
1✔
603
                        $controls = $this->submittedBy->getValidationScope();
1✔
604
                }
605

606
                $this->validateMaxPostSize();
1✔
607
                parent::validate($controls);
1✔
608
        }
1✔
609

610

611
        /** @internal */
612
        public function validateMaxPostSize(): void
613
        {
614
                if (!$this->submittedBy || !$this->isMethod('post') || empty($_SERVER['CONTENT_LENGTH'])) {
1✔
615
                        return;
1✔
616
                }
617

618
                $maxSize = Helpers::iniGetSize('post_max_size');
1✔
619
                if ($maxSize > 0 && $maxSize < $_SERVER['CONTENT_LENGTH']) {
1✔
620
                        $this->addError(sprintf(Validator::$messages[self::MaxFileSize], $maxSize));
1✔
621
                }
622
        }
1✔
623

624

625
        /**
626
         * Adds global error message.
627
         */
628
        public function addError(string|Stringable $message, bool $translate = true): void
1✔
629
        {
630
                if ($translate && $this->translator) {
1✔
631
                        $message = $this->translator->translate($message);
1✔
632
                }
633

634
                $this->errors[] = $message;
1✔
635
        }
1✔
636

637

638
        /**
639
         * Returns global validation errors.
640
         * @return string[]
641
         */
642
        public function getErrors(): array
643
        {
644
                return array_unique(array_merge($this->errors, parent::getErrors()));
1✔
645
        }
646

647

648
        public function hasErrors(): bool
649
        {
650
                return (bool) $this->getErrors();
1✔
651
        }
652

653

654
        public function cleanErrors(): void
655
        {
656
                $this->errors = [];
1✔
657
        }
1✔
658

659

660
        /**
661
         * Returns form's validation errors.
662
         * @return list<string|Stringable>
663
         */
664
        public function getOwnErrors(): array
665
        {
666
                return array_unique($this->errors);
1✔
667
        }
668

669

670
        /********************* rendering ****************d*g**/
671

672

673
        /**
674
         * Returns form's HTML element template.
675
         */
676
        public function getElementPrototype(): Html
677
        {
678
                if (!isset($this->element)) {
1✔
679
                        $this->element = Html::el('form');
1✔
680
                        $this->element->action = ''; // RFC 1808 -> empty uri means 'this'
1✔
681
                        $this->element->method = self::Post;
1✔
682
                }
683

684
                return $this->element;
1✔
685
        }
686

687

688
        /**
689
         * Sets form renderer.
690
         */
691
        public function setRenderer(?FormRenderer $renderer): static
1✔
692
        {
693
                $this->renderer = $renderer;
1✔
694
                return $this;
1✔
695
        }
696

697

698
        /**
699
         * Returns form renderer.
700
         */
701
        public function getRenderer(): FormRenderer
702
        {
703
                if (!isset($this->renderer)) {
1✔
704
                        $this->renderer = new Rendering\DefaultFormRenderer;
1✔
705
                }
706

707
                return $this->renderer;
1✔
708
        }
709

710

711
        protected function beforeRender()
712
        {
713
        }
1✔
714

715

716
        /**
717
         * Must be called before form is rendered and render() is not used.
718
         */
719
        public function fireRenderEvents(): void
720
        {
721
                if (!$this->beforeRenderCalled) {
1✔
722
                        $this->beforeRenderCalled = true;
1✔
723
                        $this->beforeRender();
1✔
724
                        Arrays::invoke($this->onRender, $this);
1✔
725
                }
726
        }
1✔
727

728

729
        /**
730
         * Renders form.
731
         */
732
        public function render(mixed ...$args): void
1✔
733
        {
734
                $this->fireRenderEvents();
1✔
735
                echo $this->getRenderer()->render($this, ...$args);
1✔
736
        }
1✔
737

738

739
        /**
740
         * Renders form to string.
741
         */
742
        public function __toString(): string
743
        {
744
                $this->fireRenderEvents();
1✔
745
                return $this->getRenderer()->render($this);
1✔
746
        }
747

748

749
        /** @return array<string, bool> */
750
        public function getToggles(): array
751
        {
752
                $toggles = [];
1✔
753
                foreach ($this->getComponentTree() as $control) {
1✔
754
                        if ($control instanceof Controls\BaseControl) {
1✔
755
                                $toggles = $control->getRules()->getToggleStates($toggles);
1✔
756
                        }
757
                }
758

759
                return $toggles;
1✔
760
        }
761

762

763
        /********************* backend ****************d*g**/
764

765

766
        /**
767
         * Initialize standalone forms.
768
         */
769
        public static function initialize(bool|Nette\Http\IRequest $reinit = false): void
1✔
770
        {
771
                self::$defaultHttpRequest = match ($reinit) {
1✔
772
                        true => null,
1✔
773
                        false => self::$defaultHttpRequest ?? (new Nette\Http\RequestFactory)->fromGlobals(),
1✔
774
                        default => $reinit,
1✔
775
                };
776
        }
1✔
777

778

779
        private function getHttpRequest(): Nette\Http\IRequest
780
        {
781
                if (!isset($this->httpRequest)) {
1✔
782
                        self::initialize();
1✔
783
                        $this->httpRequest = self::$defaultHttpRequest;
1✔
784
                }
785

786
                return $this->httpRequest;
1✔
787
        }
788
}
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