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

temporalio / sdk-java / #333

16 Oct 2024 07:28PM UTC coverage: 78.65% (+0.6%) from 78.085%
#333

push

github

web-flow
Fix code coverage (#2275)

22670 of 28824 relevant lines covered (78.65%)

0.79 hits per line

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

86.16
/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java
1
/*
2
 * Copyright (C) 2022 Temporal Technologies, Inc. All Rights Reserved.
3
 *
4
 * Copyright (C) 2012-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
5
 *
6
 * Modifications copyright (C) 2017 Uber Technologies, Inc.
7
 *
8
 * Licensed under the Apache License, Version 2.0 (the "License");
9
 * you may not use this material except in compliance with the License.
10
 * You may obtain a copy of the License at
11
 *
12
 *   http://www.apache.org/licenses/LICENSE-2.0
13
 *
14
 * Unless required by applicable law or agreed to in writing, software
15
 * distributed under the License is distributed on an "AS IS" BASIS,
16
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
 * See the License for the specific language governing permissions and
18
 * limitations under the License.
19
 */
20

21
package io.temporal.testing;
22

23
import static io.temporal.testing.internal.TestServiceUtils.applyNexusServiceOptions;
24

25
import com.uber.m3.tally.Scope;
26
import io.temporal.api.common.v1.WorkflowExecution;
27
import io.temporal.api.enums.v1.IndexedValueType;
28
import io.temporal.api.history.v1.History;
29
import io.temporal.api.nexus.v1.Endpoint;
30
import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc;
31
import io.temporal.client.WorkflowClient;
32
import io.temporal.client.WorkflowClientOptions;
33
import io.temporal.client.WorkflowOptions;
34
import io.temporal.client.WorkflowStub;
35
import io.temporal.common.Experimental;
36
import io.temporal.common.SearchAttributeKey;
37
import io.temporal.common.interceptors.WorkerInterceptor;
38
import io.temporal.internal.common.env.DebugModeUtils;
39
import io.temporal.internal.docker.RegisterTestNamespace;
40
import io.temporal.serviceclient.WorkflowServiceStubs;
41
import io.temporal.serviceclient.WorkflowServiceStubsOptions;
42
import io.temporal.worker.Worker;
43
import io.temporal.worker.WorkerFactoryOptions;
44
import io.temporal.worker.WorkerOptions;
45
import io.temporal.worker.WorkflowImplementationOptions;
46
import java.time.Instant;
47
import java.util.HashMap;
48
import java.util.Map;
49
import java.util.UUID;
50
import javax.annotation.Nonnull;
51
import javax.annotation.Nullable;
52
import org.junit.Test;
53
import org.junit.rules.TestRule;
54
import org.junit.rules.TestWatcher;
55
import org.junit.rules.Timeout;
56
import org.junit.runner.Description;
57
import org.junit.runners.model.Statement;
58

59
/**
60
 * JUnit4
61
 *
62
 * <p>Test rule that sets up test environment, simplifying workflow worker creation and shutdown.
63
 * Can be used with both in-memory and standalone temporal service. (see {@link
64
 * Builder#setUseExternalService(boolean)} and {@link Builder#setTarget(String)}})
65
 *
66
 * <p>Example of usage:
67
 *
68
 * <pre><code>
69
 *   public class MyTest {
70
 *
71
 *  {@literal @}Rule
72
 *   public TestWorkflowRule workflowRule =
73
 *       TestWorkflowRule.newBuilder()
74
 *           .setWorkflowTypes(TestWorkflowImpl.class)
75
 *           .setActivityImplementations(new TestActivities())
76
 *           .build();
77
 *
78
 *  {@literal @}Test
79
 *   public void testMyWorkflow() {
80
 *       TestWorkflow workflow = workflowRule.getWorkflowClient().newWorkflowStub(
81
 *                 TestWorkflow.class, WorkflowOptions.newBuilder().setTaskQueue(workflowRule.getTaskQueue()).build());
82
 *       ...
83
 *   }
84
 * </code></pre>
85
 */
86
public class TestWorkflowRule implements TestRule {
87

88
  private final String namespace;
89
  private final boolean useExternalService;
90
  private final boolean doNotStart;
91
  private final boolean doNotSetupNexusEndpoint;
92
  @Nullable private final Timeout globalTimeout;
93

94
  private final Class<?>[] workflowTypes;
95
  private final Object[] activityImplementations;
96
  private final Object[] nexusServiceImplementations;
97
  private final WorkflowServiceStubsOptions serviceStubsOptions;
98
  private final WorkflowClientOptions clientOptions;
99
  private final WorkerFactoryOptions workerFactoryOptions;
100
  private final WorkflowImplementationOptions workflowImplementationOptions;
101
  private final WorkerOptions workerOptions;
102
  private final String target;
103
  private final boolean useTimeskipping;
104
  private final Scope metricsScope;
105

106
  @Nonnull private final Map<String, IndexedValueType> searchAttributes;
107

108
  private String taskQueue;
109
  private String nexusEndpointName;
110
  private Endpoint nexusEndpoint;
111
  private final TestWorkflowEnvironment testEnvironment;
112
  private final TestWatcher watchman =
1✔
113
      new TestWatcher() {
1✔
114
        @Override
115
        protected void failed(Throwable e, Description description) {
116
          System.err.println("WORKFLOW EXECUTION HISTORIES:\n" + testEnvironment.getDiagnostics());
×
117
        }
×
118
      };
119

120
  private TestWorkflowRule(Builder builder) {
1✔
121
    this.doNotStart = builder.doNotStart;
1✔
122
    this.doNotSetupNexusEndpoint = builder.doNotSetupNexusEndpoint;
1✔
123
    this.useExternalService = builder.useExternalService;
1✔
124
    this.namespace =
1✔
125
        (builder.namespace == null) ? RegisterTestNamespace.NAMESPACE : builder.namespace;
1✔
126
    this.workflowTypes = (builder.workflowTypes == null) ? new Class[0] : builder.workflowTypes;
1✔
127
    this.activityImplementations =
1✔
128
        (builder.activityImplementations == null) ? new Object[0] : builder.activityImplementations;
1✔
129
    this.nexusServiceImplementations =
1✔
130
        (builder.nexusServiceImplementations == null)
1✔
131
            ? new Object[0]
1✔
132
            : builder.nexusServiceImplementations;
1✔
133
    this.serviceStubsOptions =
1✔
134
        (builder.workflowServiceStubsOptions == null)
1✔
135
            ? WorkflowServiceStubsOptions.newBuilder().build()
1✔
136
            : builder.workflowServiceStubsOptions;
1✔
137
    this.clientOptions =
1✔
138
        (builder.workflowClientOptions == null)
1✔
139
            ? WorkflowClientOptions.newBuilder().setNamespace(namespace).build()
1✔
140
            : builder.workflowClientOptions.toBuilder().setNamespace(namespace).build();
1✔
141
    this.workerOptions =
1✔
142
        (builder.workerOptions == null)
1✔
143
            ? WorkerOptions.newBuilder().build()
1✔
144
            : builder.workerOptions;
1✔
145
    this.workerFactoryOptions =
1✔
146
        (builder.workerFactoryOptions == null)
1✔
147
            ? WorkerFactoryOptions.newBuilder().build()
×
148
            : builder.workerFactoryOptions;
1✔
149
    this.workflowImplementationOptions =
1✔
150
        (builder.workflowImplementationOptions == null)
1✔
151
            ? WorkflowImplementationOptions.newBuilder().build()
1✔
152
            : builder.workflowImplementationOptions;
1✔
153
    this.globalTimeout =
1✔
154
        !DebugModeUtils.isTemporalDebugModeOn() && builder.testTimeoutSeconds != 0
1✔
155
            ? Timeout.seconds(builder.testTimeoutSeconds)
×
156
            : null;
1✔
157

158
    this.target = builder.target;
1✔
159
    this.useTimeskipping = builder.useTimeskipping;
1✔
160
    this.metricsScope = builder.metricsScope;
1✔
161
    this.searchAttributes = builder.searchAttributes;
1✔
162

163
    this.testEnvironment =
1✔
164
        TestWorkflowEnvironment.newInstance(createTestEnvOptions(builder.initialTimeMillis));
1✔
165
  }
1✔
166

167
  protected TestEnvironmentOptions createTestEnvOptions(long initialTimeMillis) {
168
    return TestEnvironmentOptions.newBuilder()
1✔
169
        .setWorkflowServiceStubsOptions(serviceStubsOptions)
1✔
170
        .setWorkflowClientOptions(clientOptions)
1✔
171
        .setWorkerFactoryOptions(workerFactoryOptions)
1✔
172
        .setUseExternalService(useExternalService)
1✔
173
        .setUseTimeskipping(useTimeskipping)
1✔
174
        .setTarget(target)
1✔
175
        .setInitialTimeMillis(initialTimeMillis)
1✔
176
        .setMetricsScope(metricsScope)
1✔
177
        .setSearchAttributes(searchAttributes)
1✔
178
        .build();
1✔
179
  }
180

181
  public static Builder newBuilder() {
182
    return new Builder();
1✔
183
  }
184

185
  public static class Builder {
186

187
    private String namespace;
188
    private String target;
189
    private boolean useExternalService;
190
    private boolean doNotStart;
191
    private boolean doNotSetupNexusEndpoint;
192
    private long initialTimeMillis;
193
    // Default to TestEnvironmentOptions isUseTimeskipping
194
    private boolean useTimeskipping =
1✔
195
        TestEnvironmentOptions.getDefaultInstance().isUseTimeskipping();
1✔
196

197
    private Class<?>[] workflowTypes;
198
    private Object[] activityImplementations;
199
    private Object[] nexusServiceImplementations;
200
    private WorkflowServiceStubsOptions workflowServiceStubsOptions;
201
    private WorkflowClientOptions workflowClientOptions;
202
    private WorkerFactoryOptions workerFactoryOptions;
203
    private WorkflowImplementationOptions workflowImplementationOptions;
204
    private WorkerOptions workerOptions;
205
    private long testTimeoutSeconds;
206
    @Nonnull private final Map<String, IndexedValueType> searchAttributes = new HashMap<>();
1✔
207
    private Scope metricsScope;
208

209
    protected Builder() {}
1✔
210

211
    public Builder setWorkerOptions(WorkerOptions options) {
212
      this.workerOptions = options;
1✔
213
      return this;
1✔
214
    }
215

216
    public void setWorkflowServiceStubsOptions(
217
        WorkflowServiceStubsOptions workflowServiceStubsOptions) {
218
      this.workflowServiceStubsOptions = workflowServiceStubsOptions;
1✔
219
    }
1✔
220

221
    /**
222
     * Override {@link WorkflowClientOptions} for test environment. If set, takes precedence over
223
     * {@link #setNamespace(String) namespace}.
224
     */
225
    public Builder setWorkflowClientOptions(WorkflowClientOptions workflowClientOptions) {
226
      this.workflowClientOptions = workflowClientOptions;
1✔
227
      return this;
1✔
228
    }
229

230
    public Builder setWorkerFactoryOptions(WorkerFactoryOptions options) {
231
      this.workerFactoryOptions = options;
1✔
232
      return this;
1✔
233
    }
234

235
    public Builder setNamespace(String namespace) {
236
      this.namespace = namespace;
×
237
      return this;
×
238
    }
239

240
    public Builder setWorkflowTypes(Class<?>... workflowTypes) {
241
      this.workflowTypes = workflowTypes;
1✔
242
      return this;
1✔
243
    }
244

245
    public Builder setWorkflowTypes(
246
        WorkflowImplementationOptions implementationOptions, Class<?>... workflowTypes) {
247
      this.workflowImplementationOptions = implementationOptions;
1✔
248
      this.workflowTypes = workflowTypes;
1✔
249
      return this;
1✔
250
    }
251

252
    /**
253
     * Specify Nexus service implementations to register with the Temporal worker. If any Nexus
254
     * services are registered with the worker, the rule will automatically create a Nexus Endpoint
255
     * for the test and the endpoint will be set on the per-service options and default options in
256
     * {@link WorkflowImplementationOptions} if none are provided.
257
     *
258
     * <p>This can be disabled by setting {@link #setDoNotSetupNexusEndpoint(boolean)} to true.
259
     *
260
     * @see Worker#registerNexusServiceImplementation(Object...)
261
     */
262
    @Experimental
263
    public Builder setNexusServiceImplementation(Object... nexusServiceImplementations) {
264
      this.nexusServiceImplementations = nexusServiceImplementations;
1✔
265
      return this;
1✔
266
    }
267

268
    public Builder setActivityImplementations(Object... activityImplementations) {
269
      this.activityImplementations = activityImplementations;
1✔
270
      return this;
1✔
271
    }
272

273
    /**
274
     * Switches between in-memory and external temporal service implementations.
275
     *
276
     * @param useExternalService use external service if true.
277
     *     <p>Default is false.
278
     */
279
    public Builder setUseExternalService(boolean useExternalService) {
280
      this.useExternalService = useExternalService;
×
281
      return this;
×
282
    }
283

284
    /**
285
     * Sets TestEnvironmentOptions.setUseTimeskippings. If true, no actual wall-clock time will pass
286
     * when a workflow sleeps or sets a timer.
287
     *
288
     * <p>Default is true
289
     */
290
    public Builder setUseTimeskipping(boolean useTimeskipping) {
291
      this.useTimeskipping = useTimeskipping;
1✔
292
      return this;
1✔
293
    }
294

295
    /**
296
     * Optional parameter that defines an endpoint which will be used for the communication with
297
     * standalone temporal service. Has no effect if {@link #setUseExternalService(boolean)} is set
298
     * to false.
299
     *
300
     * <p>Default is to use 127.0.0.1:7233
301
     */
302
    public Builder setTarget(String target) {
303
      this.target = target;
×
304
      return this;
×
305
    }
306

307
    /**
308
     * @deprecated Temporal test rule shouldn't be responsible for enforcing test timeouts. Use
309
     *     toolchain of your test framework to enforce timeouts.
310
     */
311
    @Deprecated
312
    public Builder setTestTimeoutSeconds(long testTimeoutSeconds) {
313
      this.testTimeoutSeconds = testTimeoutSeconds;
×
314
      return this;
×
315
    }
316

317
    /**
318
     * Set the initial time for the workflow virtual clock, milliseconds since epoch.
319
     *
320
     * <p>Default is current time
321
     */
322
    public Builder setInitialTimeMillis(long initialTimeMillis) {
323
      this.initialTimeMillis = initialTimeMillis;
1✔
324
      return this;
1✔
325
    }
326

327
    /**
328
     * Set the initial time for the workflow virtual clock.
329
     *
330
     * <p>Default is current time
331
     */
332
    public Builder setInitialTime(Instant initialTime) {
333
      this.initialTimeMillis = initialTime.toEpochMilli();
×
334
      return this;
×
335
    }
336

337
    /**
338
     * When set to true the {@link TestWorkflowEnvironment#start()} is not called by the rule before
339
     * executing the test. This to support tests that register activities and workflows with workers
340
     * directly instead of using only {@link TestWorkflowRule.Builder}.
341
     */
342
    public Builder setDoNotStart(boolean doNotStart) {
343
      this.doNotStart = doNotStart;
1✔
344
      return this;
1✔
345
    }
346

347
    /**
348
     * When set to true the {@link TestWorkflowEnvironment} will not automatically create a Nexus
349
     * Endpoint. This is useful when you want to manually create a Nexus Endpoint for your test.
350
     */
351
    @Experimental
352
    public Builder setDoNotSetupNexusEndpoint(boolean doNotSetupNexusEndpoint) {
353
      this.doNotSetupNexusEndpoint = doNotSetupNexusEndpoint;
×
354
      return this;
×
355
    }
356

357
    /**
358
     * Add a search attribute to be registered on the Temporal Server.
359
     *
360
     * @param name name of the search attribute
361
     * @param type search attribute type
362
     * @return {@code this}
363
     * @see <a
364
     *     href="https://docs.temporal.io/docs/tctl/how-to-add-a-custom-search-attribute-to-a-cluster-using-tctl">Add
365
     *     a Custom Search Attribute Using tctl</a>
366
     */
367
    public Builder registerSearchAttribute(String name, IndexedValueType type) {
368
      this.searchAttributes.put(name, type);
1✔
369
      return this;
1✔
370
    }
371

372
    /**
373
     * Add a search attribute to be registered on the Temporal Server.
374
     *
375
     * @param key key to register
376
     * @return {@code this}
377
     * @see <a
378
     *     href="https://docs.temporal.io/docs/tctl/how-to-add-a-custom-search-attribute-to-a-cluster-using-tctl">Add
379
     *     a Custom Search Attribute Using tctl</a>
380
     */
381
    public Builder registerSearchAttribute(SearchAttributeKey<?> key) {
382
      return this.registerSearchAttribute(key.getName(), key.getValueType());
1✔
383
    }
384

385
    /**
386
     * Sets the scope to be used for metrics reporting. Optional. Default is to not report metrics.
387
     *
388
     * <p>Note: Don't mock {@link Scope} in tests! If you need to verify the metrics behavior,
389
     * create a real Scope and mock, stub or spy a reporter instance:<br>
390
     *
391
     * <pre>{@code
392
     * StatsReporter reporter = mock(StatsReporter.class);
393
     * Scope metricsScope =
394
     *     new RootScopeBuilder()
395
     *         .reporter(reporter)
396
     *         .reportEvery(com.uber.m3.util.Duration.ofMillis(10));
397
     * }</pre>
398
     *
399
     * @param metricsScope the scope to be used for metrics reporting.
400
     * @return {@code this}
401
     */
402
    public Builder setMetricsScope(Scope metricsScope) {
403
      this.metricsScope = metricsScope;
1✔
404
      return this;
1✔
405
    }
406

407
    public TestWorkflowRule build() {
408
      return new TestWorkflowRule(this);
1✔
409
    }
410
  }
411

412
  @Override
413
  public Statement apply(Statement base, Description description) {
414
    taskQueue = init(description);
1✔
415
    Statement testWorkflowStatement =
1✔
416
        new Statement() {
1✔
417
          @Override
418
          public void evaluate() throws Throwable {
419
            start();
1✔
420
            base.evaluate();
1✔
421
            shutdown();
1✔
422
          }
1✔
423
        };
424

425
    Test annotation = description.getAnnotation(Test.class);
1✔
426
    boolean timeoutIsOverriddenOnTestAnnotation = annotation != null && annotation.timeout() > 0;
1✔
427

428
    if (globalTimeout != null && !timeoutIsOverriddenOnTestAnnotation) {
1✔
429
      testWorkflowStatement = globalTimeout.apply(testWorkflowStatement, description);
×
430
    }
431

432
    return watchman.apply(testWorkflowStatement, description);
1✔
433
  }
434

435
  private String init(Description description) {
436
    String testMethod = description.getMethodName();
1✔
437
    String taskQueue = "WorkflowTest-" + testMethod + "-" + UUID.randomUUID();
1✔
438
    nexusEndpointName = String.format("WorkflowTestNexusEndpoint-%s", UUID.randomUUID());
1✔
439
    Worker worker = testEnvironment.newWorker(taskQueue, workerOptions);
1✔
440
    WorkflowImplementationOptions workflowImplementationOptions =
1✔
441
        this.workflowImplementationOptions;
442
    if (!doNotSetupNexusEndpoint) {
1✔
443
      workflowImplementationOptions =
1✔
444
          applyNexusServiceOptions(
1✔
445
              workflowImplementationOptions, nexusServiceImplementations, nexusEndpointName);
446
    }
447
    worker.registerWorkflowImplementationTypes(workflowImplementationOptions, workflowTypes);
1✔
448
    worker.registerActivitiesImplementations(activityImplementations);
1✔
449
    worker.registerNexusServiceImplementation(nexusServiceImplementations);
1✔
450
    return taskQueue;
1✔
451
  }
452

453
  private void start() {
454
    if (!doNotSetupNexusEndpoint && nexusServiceImplementations.length > 0) {
1✔
455
      nexusEndpoint = testEnvironment.createNexusEndpoint(nexusEndpointName, taskQueue);
1✔
456
    }
457
    if (!doNotStart) {
1✔
458
      testEnvironment.start();
1✔
459
    }
460
  }
1✔
461

462
  protected void shutdown() {
463
    if (nexusEndpoint != null && !testEnvironment.getOperatorServiceStubs().isShutdown()) {
1✔
464
      testEnvironment.deleteNexusEndpoint(nexusEndpoint);
1✔
465
    }
466
    testEnvironment.close();
1✔
467
  }
1✔
468

469
  /**
470
   * See {@link Builder#setUseExternalService(boolean)}
471
   *
472
   * @return true if the rule is using external temporal service.
473
   */
474
  public boolean isUseExternalService() {
475
    return useExternalService;
×
476
  }
477

478
  public TestWorkflowEnvironment getTestEnvironment() {
479
    return testEnvironment;
1✔
480
  }
481

482
  /**
483
   * @return name of the task queue that test worker is polling.
484
   */
485
  public String getTaskQueue() {
486
    return taskQueue;
1✔
487
  }
488

489
  /**
490
   * @return endpoint of the nexus service created for the test.
491
   */
492
  public Endpoint getNexusEndpoint() {
493
    return nexusEndpoint;
1✔
494
  }
495

496
  /**
497
   * @return client to the Temporal service used to start and query workflows.
498
   */
499
  public WorkflowClient getWorkflowClient() {
500
    return testEnvironment.getWorkflowClient();
1✔
501
  }
502

503
  /**
504
   * @return stubs connected to the test server (in-memory or external)
505
   */
506
  public WorkflowServiceStubs getWorkflowServiceStubs() {
507
    return testEnvironment.getWorkflowServiceStubs();
1✔
508
  }
509

510
  /**
511
   * @return blockingStub
512
   */
513
  public WorkflowServiceGrpc.WorkflowServiceBlockingStub blockingStub() {
514
    return getWorkflowServiceStubs().blockingStub();
×
515
  }
516

517
  /**
518
   * @return tracer.
519
   */
520
  public <T extends WorkerInterceptor> T getInterceptor(Class<T> type) {
521
    if (workerFactoryOptions.getWorkerInterceptors() != null) {
1✔
522
      for (WorkerInterceptor interceptor : workerFactoryOptions.getWorkerInterceptors()) {
1✔
523
        if (type.isInstance(interceptor)) {
1✔
524
          return type.cast(interceptor);
1✔
525
        }
526
      }
527
    }
528
    return null;
1✔
529
  }
530

531
  /**
532
   * @return workflow execution history
533
   * @deprecated use {@link WorkflowClient#fetchHistory(String, String)}. To obtain a WorkflowClient
534
   *     use {@link #getWorkflowClient()}
535
   */
536
  @Deprecated
537
  public History getHistory(@Nonnull WorkflowExecution execution) {
538
    return testEnvironment.getWorkflowExecutionHistory(execution).getHistory();
×
539
  }
540

541
  /**
542
   * @return name of the task queue that test worker is polling.
543
   * @deprecated use {@link WorkflowClient#fetchHistory(String, String)}. To obtain a WorkflowClient
544
   *     use {@link #getWorkflowClient()}
545
   */
546
  @Deprecated
547
  public History getWorkflowExecutionHistory(WorkflowExecution execution) {
548
    return testEnvironment.getWorkflowExecutionHistory(execution).getHistory();
×
549
  }
550

551
  /**
552
   * This worker listens to the default task queue which is obtainable via the {@link
553
   * #getTaskQueue()} method.
554
   *
555
   * @return the default worker created for each test method.
556
   */
557
  public Worker getWorker() {
558
    return testEnvironment.getWorkerFactory().getWorker(getTaskQueue());
1✔
559
  }
560

561
  public WorkerFactoryOptions getWorkerFactoryOptions() {
562
    return workerFactoryOptions;
×
563
  }
564

565
  public <T> T newWorkflowStub(Class<T> workflow) {
566
    return getWorkflowClient()
1✔
567
        .newWorkflowStub(workflow, newWorkflowOptionsForTaskQueue(getTaskQueue()));
1✔
568
  }
569

570
  public WorkflowStub newUntypedWorkflowStub(String workflow) {
571
    return getWorkflowClient()
1✔
572
        .newUntypedWorkflowStub(workflow, newWorkflowOptionsForTaskQueue(getTaskQueue()));
1✔
573
  }
574

575
  private static WorkflowOptions newWorkflowOptionsForTaskQueue(String taskQueue) {
576
    return WorkflowOptions.newBuilder().setTaskQueue(taskQueue).build();
1✔
577
  }
578
}
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