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

temporalio / sdk-java / #349

03 Aug 2026 05:42AM UTC coverage: 68.221% (+0.08%) from 68.143%
#349

push

github

web-flow
Add Standalone Activities to Temporal Nexus Operation Handler (#2918)

* Enable Nexus activity operations without regressing workflow updates

Compose standalone activity support with the workflow-update Nexus model already present on master. Shared token, callback, link, client, and cancellation paths retain both operation families.

Constraint: Preserve the workflow-update token and API contracts from master
Rejected: Choose one conflict side | each side would drop a supported Nexus operation family
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep activity execution at token type 2 and workflow update at token type 3
Tested: Focused temporal-sdk token, link, invoker, client, async activity, and cancellation tests
Not-tested: Full repository suite and real-server-only standalone activity cases

* Make sure we test attaching

* Make sure to handle ActivityAlreadyStartedException

* Add test with SANO

* Bump test server

7220 of 12610 branches covered (57.26%)

Branch coverage included in aggregate %.

182 of 283 new or added lines in 15 files covered. (64.31%)

13 existing lines in 3 files now uncovered.

29918 of 41828 relevant lines covered (71.53%)

0.72 hits per line

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

68.07
/temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java
1
package io.temporal.internal.common;
2

3
import static io.temporal.internal.common.ProtoEnumNameUtils.*;
4

5
import io.temporal.api.common.v1.Link;
6
import io.temporal.api.enums.v1.EventType;
7
import java.io.UnsupportedEncodingException;
8
import java.net.URI;
9
import java.net.URLDecoder;
10
import java.net.URLEncoder;
11
import java.nio.charset.StandardCharsets;
12
import java.util.*;
13
import java.util.AbstractMap.SimpleImmutableEntry;
14
import java.util.stream.Collectors;
15
import org.slf4j.Logger;
16
import org.slf4j.LoggerFactory;
17

18
public class LinkConverter {
×
19

20
  private static final Logger log = LoggerFactory.getLogger(LinkConverter.class);
1✔
21

22
  private static final String temporalUrlScheme = "temporal";
23
  private static final String linkPathFormat = "temporal:///namespaces/%s/workflows/%s/%s/history";
24
  private static final String nexusOperationLinkPathFormat =
25
      "temporal:///namespaces/%s/nexus-operations/%s/%s/details";
26
  private static final String activityLinkPathFormat =
27
      "temporal:///namespaces/%s/activities/%s/%s/details";
28
  private static final String linkReferenceTypeKey = "referenceType";
29
  private static final String linkEventIDKey = "eventID";
30
  private static final String linkEventTypeKey = "eventType";
31
  private static final String linkRequestIDKey = "requestID";
32

33
  private static final String eventReferenceType =
34
      Link.WorkflowEvent.EventReference.getDescriptor().getName();
1✔
35
  private static final String requestIDReferenceType =
36
      Link.WorkflowEvent.RequestIdReference.getDescriptor().getName();
1✔
37
  private static final String workflowEventLinkType =
38
      Link.WorkflowEvent.getDescriptor().getFullName();
1✔
39
  private static final String nexusOperationLinkType =
40
      Link.NexusOperation.getDescriptor().getFullName();
1✔
41
  private static final String workflowLinkType = Link.Workflow.getDescriptor().getFullName();
1✔
42
  private static final String activityLinkType = Link.Activity.getDescriptor().getFullName();
1✔
43

44
  public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.WorkflowEvent we) {
45
    try {
46

47
      String url =
1✔
48
          String.format(
1✔
49
              linkPathFormat,
50
              URLEncoder.encode(we.getNamespace(), StandardCharsets.UTF_8.toString()),
1✔
51
              // The 'replace' below handles spaces - the encoder will convert them to a plus,
52
              // which the UI then handles as a plus, thus breaking the link as the
53
              // space is lost.
54
              // It's a known quirk with the URLEncoder as it encodes for forms, not general URIs.
55
              // Only done for the WorkflowId as the other two are values we control,
56
              // and will never have spaces.
57
              URLEncoder.encode(we.getWorkflowId(), StandardCharsets.UTF_8.toString())
1✔
58
                  .replace("+", "%20"),
1✔
59
              URLEncoder.encode(we.getRunId(), StandardCharsets.UTF_8.toString()));
1✔
60

61
      List<Map.Entry<String, String>> queryParams = new ArrayList<>();
1✔
62
      if (we.hasEventRef()) {
1✔
63
        queryParams.add(new SimpleImmutableEntry<>(linkReferenceTypeKey, eventReferenceType));
1✔
64
        Link.WorkflowEvent.EventReference eventRef = we.getEventRef();
1✔
65
        if (eventRef.getEventId() > 0) {
1✔
66
          queryParams.add(
1✔
67
              new SimpleImmutableEntry<>(linkEventIDKey, String.valueOf(eventRef.getEventId())));
1✔
68
        }
69
        final String eventType =
1✔
70
            URLEncoder.encode(
1✔
71
                encodeEventType(eventRef.getEventType()), StandardCharsets.UTF_8.toString());
1✔
72
        queryParams.add(new SimpleImmutableEntry<>(linkEventTypeKey, eventType));
1✔
73
      } else if (we.hasRequestIdRef()) {
1!
74
        queryParams.add(new SimpleImmutableEntry<>(linkReferenceTypeKey, requestIDReferenceType));
1✔
75
        Link.WorkflowEvent.RequestIdReference requestIDRef = we.getRequestIdRef();
1✔
76
        final String requestID =
1✔
77
            URLEncoder.encode(requestIDRef.getRequestId(), StandardCharsets.UTF_8.toString());
1✔
78
        queryParams.add(new SimpleImmutableEntry<>(linkRequestIDKey, requestID));
1✔
79
        final String eventType =
1✔
80
            URLEncoder.encode(
1✔
81
                encodeEventType(requestIDRef.getEventType()), StandardCharsets.UTF_8.toString());
1✔
82
        queryParams.add(new SimpleImmutableEntry<>(linkEventTypeKey, eventType));
1✔
83
      }
84

85
      url +=
1✔
86
          "?"
87
              + queryParams.stream()
1✔
88
                  .map((item) -> item.getKey() + "=" + item.getValue())
1✔
89
                  .collect(Collectors.joining("&"));
1✔
90

91
      return io.temporal.api.nexus.v1.Link.newBuilder()
1✔
92
          .setUrl(url)
1✔
93
          .setType(we.getDescriptorForType().getFullName())
1✔
94
          .build();
1✔
95
    } catch (Exception e) {
×
96
      log.error("Failed to encode Nexus link URL", e);
×
97
    }
98
    return null;
×
99
  }
100

101
  public static io.temporal.api.nexus.v1.Link workflowLinkToNexusLink(Link.Workflow w) {
102
    try {
103
      String namespace = URLEncoder.encode(w.getNamespace(), StandardCharsets.UTF_8.toString());
×
104
      String workflowId =
×
105
          URLEncoder.encode(w.getWorkflowId(), StandardCharsets.UTF_8.toString())
×
106
              .replace("+", "%20"); // handle workflowIds supporting spaces
×
107
      String runId = URLEncoder.encode(w.getRunId(), StandardCharsets.UTF_8.toString());
×
108
      String url = String.format(linkPathFormat, namespace, workflowId, runId);
×
109
      return io.temporal.api.nexus.v1.Link.newBuilder()
×
110
          .setUrl(url)
×
111
          .setType(workflowLinkType)
×
112
          .build();
×
113
    } catch (Exception e) {
×
114
      log.error("Failed to convert WorkflowLink {} to NexusLink", w, e);
×
115
      return null;
×
116
    }
117
  }
118

119
  public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusLink) {
120
    Link.Builder link = Link.newBuilder();
1✔
121
    try {
122
      URI uri = new URI(nexusLink.getUrl());
1✔
123

124
      if (!uri.getScheme().equals("temporal")) {
1✔
125
        log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme());
1✔
126
        return null;
1✔
127
      }
128

129
      StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/");
1✔
130
      if (!st.nextToken().equals("namespaces")) {
1!
131
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
132
        return null;
×
133
      }
134
      String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
135
      if (!st.nextToken().equals("workflows")) {
1✔
136
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
1✔
137
        return null;
1✔
138
      }
139
      String workflowID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
140
      String runID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
141
      if (!st.hasMoreTokens() || !st.nextToken().equals("history")) {
1!
142
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
1✔
143
        return null;
1✔
144
      }
145

146
      Link.WorkflowEvent.Builder we =
147
          Link.WorkflowEvent.newBuilder()
1✔
148
              .setNamespace(namespace)
1✔
149
              .setWorkflowId(workflowID)
1✔
150
              .setRunId(runID);
1✔
151

152
      Map<String, String> queryParams = parseQueryParams(uri);
1✔
153
      String referenceType = queryParams.get(linkReferenceTypeKey);
1✔
154
      if (referenceType.equals(eventReferenceType)) {
1✔
155
        Link.WorkflowEvent.EventReference.Builder eventRef =
156
            Link.WorkflowEvent.EventReference.newBuilder();
1✔
157
        String eventID = queryParams.get(linkEventIDKey);
1✔
158
        if (eventID != null && !eventID.isEmpty()) {
1!
159
          eventRef.setEventId(Long.parseLong(eventID));
1✔
160
        }
161
        String eventType = queryParams.get(linkEventTypeKey);
1✔
162
        if (eventType != null && !eventType.isEmpty()) {
1!
163
          eventRef.setEventType(decodeEventType(eventType));
1✔
164
        }
165
        we.setEventRef(eventRef);
1✔
166
      } else if (referenceType.equals(requestIDReferenceType)) {
1!
167
        Link.WorkflowEvent.RequestIdReference.Builder requestIDRef =
168
            Link.WorkflowEvent.RequestIdReference.newBuilder();
1✔
169
        String requestID = queryParams.get(linkRequestIDKey);
1✔
170
        if (requestID != null && !requestID.isEmpty()) {
1!
171
          requestIDRef.setRequestId(requestID);
1✔
172
        }
173
        String eventType = queryParams.get(linkEventTypeKey);
1✔
174
        if (eventType != null && !eventType.isEmpty()) {
1!
175
          requestIDRef.setEventType(decodeEventType(eventType));
1✔
176
        }
177
        we.setRequestIdRef(requestIDRef);
1✔
178
      } else {
1✔
179
        log.error("Failed to parse Nexus link URL: invalid reference type: {}", referenceType);
×
180
        return null;
×
181
      }
182

183
      link.setWorkflowEvent(we);
1✔
184
    } catch (Exception e) {
1✔
185
      // Swallow un-parsable links since they are not critical to processing
186
      log.error("Failed to parse Nexus link URL", e);
1✔
187
      return null;
1✔
188
    }
1✔
189
    return link.build();
1✔
190
  }
191

192
  public static Link nexusLinkToWorkflowLink(io.temporal.api.nexus.v1.Link nexusLink) {
193
    Link.Builder link = Link.newBuilder();
×
194
    try {
195
      URI uri = new URI(nexusLink.getUrl());
×
196
      log.debug("Parsing nexus link URL: {}", uri.getRawPath());
×
197
      if (!uri.getScheme().equals(temporalUrlScheme)) {
×
198
        log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme());
×
199
        return null;
×
200
      }
201
      StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/");
×
202
      // maybe add constants for "namespaces", "workflows" too
203
      if (!st.nextToken().equals("namespaces")) {
×
204
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
205
        return null;
×
206
      }
207
      String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
×
208
      if (!st.nextToken().equals("workflows")) {
×
209
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
210
        return null;
×
211
      }
212
      String workflowID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
×
213
      if (!st.hasMoreTokens()) {
×
214
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
215
        return null;
×
216
      }
217
      String runID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
×
218
      link.setWorkflow(
×
219
          Link.Workflow.newBuilder()
×
220
              .setNamespace(namespace)
×
221
              .setWorkflowId(workflowID)
×
222
              .setRunId(runID));
×
223
    } catch (Exception e) {
×
224
      log.error("Failed to convert NexusLink {} to WorkflowLink", nexusLink, e);
×
225
      return null;
×
226
    }
×
227
    return link.build();
×
228
  }
229

230
  /**
231
   * Dispatches on the oneof variant of {@code commonLink} and converts to the matching {@link
232
   * io.temporal.api.nexus.v1.Link}. Returns {@code null} if no variant is set or encoding fails.
233
   */
234
  public static io.temporal.api.nexus.v1.Link linkToNexusLink(Link commonLink) {
235
    if (commonLink.hasWorkflowEvent()) {
1✔
236
      return workflowEventToNexusLink(commonLink.getWorkflowEvent());
1✔
237
    }
238
    if (commonLink.hasNexusOperation()) {
1✔
239
      return nexusOperationToNexusLink(commonLink.getNexusOperation());
1✔
240
    }
241
    if (commonLink.hasWorkflow()) {
1!
242
      return workflowLinkToNexusLink(commonLink.getWorkflow());
×
243
    }
244
    if (commonLink.hasActivity()) {
1✔
245
      return activityToNexusLink(commonLink.getActivity());
1✔
246
    }
247
    return null;
1✔
248
  }
249

250
  /**
251
   * Dispatches on {@link io.temporal.api.nexus.v1.Link#getType()} and converts to the matching
252
   * {@link Link} variant. Returns {@code null} for unknown or unparseable types.
253
   */
254
  public static Link nexusLinkToLink(io.temporal.api.nexus.v1.Link nexusLink) {
255
    String type = nexusLink.getType();
1✔
256
    if (workflowEventLinkType.equals(type)) {
1✔
257
      return nexusLinkToWorkflowEvent(nexusLink);
1✔
258
    }
259
    if (nexusOperationLinkType.equals(type)) {
1✔
260
      return nexusLinkToNexusOperation(nexusLink);
1✔
261
    }
262
    if (workflowLinkType.equals(type)) {
1!
263
      return nexusLinkToWorkflowLink(nexusLink);
×
264
    }
265
    if (activityLinkType.equals(type)) {
1✔
266
      return nexusLinkToActivity(nexusLink);
1✔
267
    }
268
    log.warn("ignoring unsupported nexus link type: {}", type);
1✔
269
    return null;
1✔
270
  }
271

272
  public static io.temporal.api.nexus.v1.Link activityToNexusLink(Link.Activity activity) {
273
    try {
274
      String url =
1✔
275
          String.format(
1✔
276
              activityLinkPathFormat,
277
              URLEncoder.encode(activity.getNamespace(), StandardCharsets.UTF_8.toString()),
1✔
278
              URLEncoder.encode(activity.getActivityId(), StandardCharsets.UTF_8.toString())
1✔
279
                  .replace("+", "%20"),
1✔
280
              URLEncoder.encode(activity.getRunId(), StandardCharsets.UTF_8.toString()));
1✔
281
      return io.temporal.api.nexus.v1.Link.newBuilder()
1✔
282
          .setUrl(url)
1✔
283
          .setType(activityLinkType)
1✔
284
          .build();
1✔
NEW
285
    } catch (Exception e) {
×
NEW
286
      log.error("Failed to encode activity Nexus link URL", e);
×
287
    }
NEW
288
    return null;
×
289
  }
290

291
  public static Link nexusLinkToActivity(io.temporal.api.nexus.v1.Link nexusLink) {
292
    if (!activityLinkType.equals(nexusLink.getType())) {
1!
NEW
293
      log.error(
×
294
          "Failed to parse Nexus link URL: cannot parse link type {} to {}",
NEW
295
          nexusLink.getType(),
×
296
          activityLinkType);
NEW
297
      return null;
×
298
    }
299
    Link.Builder link = Link.newBuilder();
1✔
300
    try {
301
      URI uri = new URI(nexusLink.getUrl());
1✔
302
      if (!"temporal".equals(uri.getScheme())) {
1!
NEW
303
        log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme());
×
NEW
304
        return null;
×
305
      }
306
      StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/");
1✔
307
      if (!st.hasMoreTokens() || !st.nextToken().equals("namespaces")) {
1!
NEW
308
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
NEW
309
        return null;
×
310
      }
311
      String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
312
      if (!st.hasMoreTokens() || !st.nextToken().equals("activities")) {
1!
NEW
313
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
NEW
314
        return null;
×
315
      }
316
      String activityId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
317
      if (!st.hasMoreTokens()) {
1!
NEW
318
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
NEW
319
        return null;
×
320
      }
321
      String runId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
322
      if (!st.hasMoreTokens() || !st.nextToken().equals("details")) {
1!
323
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
1✔
324
        return null;
1✔
325
      }
326
      link.setActivity(
1✔
327
          Link.Activity.newBuilder()
1✔
328
              .setNamespace(namespace)
1✔
329
              .setActivityId(activityId)
1✔
330
              .setRunId(runId));
1✔
NEW
331
    } catch (Exception e) {
×
NEW
332
      log.error("Failed to parse activity Nexus link URL", e);
×
NEW
333
      return null;
×
334
    }
1✔
335
    return link.build();
1✔
336
  }
337

338
  public static io.temporal.api.nexus.v1.Link nexusOperationToNexusLink(Link.NexusOperation no) {
339
    try {
340
      String url =
1✔
341
          String.format(
1✔
342
              nexusOperationLinkPathFormat,
343
              URLEncoder.encode(no.getNamespace(), StandardCharsets.UTF_8.toString()),
1✔
344
              // See the WorkflowId comment in workflowEventToNexusLink for why '+' is rewritten to
345
              // '%20'. OperationId is user-supplied and can legally contain spaces.
346
              URLEncoder.encode(no.getOperationId(), StandardCharsets.UTF_8.toString())
1✔
347
                  .replace("+", "%20"),
1✔
348
              URLEncoder.encode(no.getRunId(), StandardCharsets.UTF_8.toString()));
1✔
349
      return io.temporal.api.nexus.v1.Link.newBuilder()
1✔
350
          .setUrl(url)
1✔
351
          .setType(nexusOperationLinkType)
1✔
352
          .build();
1✔
353
    } catch (Exception e) {
×
354
      log.error("Failed to encode Nexus operation link URL", e);
×
355
    }
356
    return null;
×
357
  }
358

359
  public static Link nexusLinkToNexusOperation(io.temporal.api.nexus.v1.Link nexusLink) {
360
    if (!nexusOperationLinkType.equals(nexusLink.getType())) {
1✔
361
      log.error(
1✔
362
          "Failed to parse Nexus link URL: cannot parse link type {} to {}",
363
          nexusLink.getType(),
1✔
364
          nexusOperationLinkType);
365
      return null;
1✔
366
    }
367
    Link.Builder link = Link.newBuilder();
1✔
368
    try {
369
      URI uri = new URI(nexusLink.getUrl());
1✔
370

371
      if (!"temporal".equals(uri.getScheme())) {
1✔
372
        log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme());
1✔
373
        return null;
1✔
374
      }
375

376
      StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/");
1✔
377
      if (!st.hasMoreTokens() || !st.nextToken().equals("namespaces")) {
1!
378
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
379
        return null;
×
380
      }
381
      String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
382
      if (!st.hasMoreTokens() || !st.nextToken().equals("nexus-operations")) {
1!
383
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
384
        return null;
×
385
      }
386
      String operationId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
387
      if (!st.hasMoreTokens()) {
1!
388
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
×
389
        return null;
×
390
      }
391
      String runId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString());
1✔
392
      if (!st.hasMoreTokens() || !st.nextToken().equals("details")) {
1!
393
        log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath());
1✔
394
        return null;
1✔
395
      }
396

397
      link.setNexusOperation(
1✔
398
          Link.NexusOperation.newBuilder()
1✔
399
              .setNamespace(namespace)
1✔
400
              .setOperationId(operationId)
1✔
401
              .setRunId(runId));
1✔
402
    } catch (Exception e) {
×
403
      log.error("Failed to parse Nexus link URL", e);
×
404
      return null;
×
405
    }
1✔
406
    return link.build();
1✔
407
  }
408

409
  private static Map<String, String> parseQueryParams(URI uri) throws UnsupportedEncodingException {
410
    final String query = uri.getQuery();
1✔
411
    if (query == null || query.isEmpty()) {
1!
412
      return Collections.emptyMap();
×
413
    }
414
    Map<String, String> queryParams = new HashMap<>();
1✔
415
    for (String pair : query.split("&")) {
1✔
416
      final String[] kv = pair.split("=", 2);
1✔
417
      final String key = URLDecoder.decode(kv[0], StandardCharsets.UTF_8.toString());
1✔
418
      final String value =
419
          kv.length == 2 && !kv[1].isEmpty()
1!
420
              ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8.toString())
1✔
421
              : null;
1✔
422
      queryParams.put(key, value);
1✔
423
    }
424
    return queryParams;
1✔
425
  }
426

427
  private static String encodeEventType(EventType eventType) {
428
    return uniqueToSimplifiedName(eventType.name(), EVENT_TYPE_PREFIX);
1✔
429
  }
430

431
  private static EventType decodeEventType(String eventType) {
432
    // Have to handle the SCREAMING_CASE enum or the traditional temporal PascalCase enum to
433
    // EventType
434
    if (eventType.startsWith(EVENT_TYPE_PREFIX)) {
1✔
435
      return EventType.valueOf(eventType);
1✔
436
    }
437
    return EventType.valueOf(simplifiedToUniqueName(eventType, EVENT_TYPE_PREFIX));
1✔
438
  }
439
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc