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

supabase / pg_graphql / 16431879287

22 Jul 2025 01:05AM UTC coverage: 92.335% (-1.7%) from 94.056%
16431879287

Pull #590

github

web-flow
Merge e434169cd into a899acda9
Pull Request #590: Add support for single record queries by primary key

219 of 317 new or added lines in 5 files covered. (69.09%)

43 existing lines in 4 files now uncovered.

7649 of 8284 relevant lines covered (92.33%)

1137.48 hits per line

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

90.38
/src/parser_util.rs
1
use crate::graphql::{EnumSource, __InputValue, __Type, ___Type};
2
use crate::{gson, merge::merge};
3
use graphql_parser::query::*;
4
use std::collections::HashMap;
5
use std::hash::Hash;
6

7
pub fn alias_or_name<'a, T>(query_field: &graphql_parser::query::Field<'a, T>) -> String
34,659✔
8
where
34,659✔
9
    T: Text<'a> + Eq + AsRef<str>,
34,659✔
10
{
11
    query_field
34,659✔
12
        .alias
34,659✔
13
        .as_ref()
34,659✔
14
        .map(|x| x.as_ref().to_string())
34,659✔
15
        .unwrap_or_else(|| query_field.name.as_ref().to_string())
34,659✔
16
}
34,659✔
17

18
pub fn normalize_selection_set<'a, 'b, T>(
7,008✔
19
    selection_set: &'b SelectionSet<'a, T>,
7,008✔
20
    fragment_definitions: &'b Vec<FragmentDefinition<'a, T>>,
7,008✔
21
    type_name: &String,            // for inline fragments
7,008✔
22
    variables: &serde_json::Value, // for directives
7,008✔
23
) -> Result<Vec<Field<'a, T>>, String>
7,008✔
24
where
7,008✔
25
    T: Text<'a> + Eq + AsRef<str> + Clone,
7,008✔
26
    T::Value: Hash,
7,008✔
27
{
28
    let mut selections: Vec<Field<'a, T>> = vec![];
7,008✔
29

30
    for selection in &selection_set.items {
22,918✔
31
        let sel = selection;
15,912✔
32
        match normalize_selection(sel, fragment_definitions, type_name, variables) {
15,912✔
33
            Ok(sels) => selections.extend(sels),
15,910✔
34
            Err(err) => return Err(err),
2✔
35
        }
36
    }
37
    let selections = merge(selections)?;
7,006✔
38
    Ok(selections)
6,998✔
39
}
7,008✔
40

41
/// Combines @skip and @include
42
pub fn selection_is_skipped<'a, 'b, T>(
15,912✔
43
    query_selection: &'b Selection<'a, T>,
15,912✔
44
    variables: &serde_json::Value,
15,912✔
45
) -> Result<bool, String>
15,912✔
46
where
15,912✔
47
    T: Text<'a> + Eq + AsRef<str>,
15,912✔
48
{
49
    let directives = match query_selection {
15,912✔
50
        Selection::Field(x) => &x.directives,
14,623✔
51
        Selection::FragmentSpread(x) => &x.directives,
1,275✔
52
        Selection::InlineFragment(x) => &x.directives,
14✔
53
    };
54

55
    if !directives.is_empty() {
15,912✔
56
        for directive in directives {
32✔
57
            let directive_name = directive.name.as_ref();
24✔
58
            match directive_name {
24✔
59
                "skip" => {
24✔
60
                    if directive.arguments.len() != 1 {
11✔
61
                        return Err("Incorrect arguments to directive @skip".to_string());
×
62
                    }
11✔
63
                    let arg = &directive.arguments[0];
11✔
64
                    if arg.0.as_ref() != "if" {
11✔
65
                        return Err(format!("Unknown argument to @skip: {}", arg.0.as_ref()));
×
66
                    }
11✔
67

68
                    // the argument to @skip(if: <value>)
69
                    match &arg.1 {
11✔
70
                        Value::Boolean(x) => {
8✔
71
                            if *x {
8✔
72
                                return Ok(true);
4✔
73
                            }
4✔
74
                        }
75
                        Value::Variable(var_name) => {
3✔
76
                            let var = variables.get(var_name.as_ref());
3✔
77
                            match var {
2✔
78
                                Some(serde_json::Value::Bool(bool_val)) => {
2✔
79
                                    if *bool_val {
2✔
80
                                        // skip immediately
81
                                        return Ok(true);
1✔
82
                                    }
1✔
83
                                }
84
                                _ => {
85
                                    return Err("Value for \"if\" in @skip directive is required"
1✔
86
                                        .to_string());
1✔
87
                                }
88
                            }
89
                        }
90
                        _ => (),
×
91
                    }
92
                }
93
                "include" => {
13✔
94
                    if directive.arguments.len() != 1 {
13✔
95
                        return Err("Incorrect arguments to directive @include".to_string());
×
96
                    }
13✔
97
                    let arg = &directive.arguments[0];
13✔
98
                    if arg.0.as_ref() != "if" {
13✔
99
                        return Err(format!("Unknown argument to @include: {}", arg.0.as_ref()));
×
100
                    }
13✔
101

102
                    // the argument to @include(if: <value>)
103
                    match &arg.1 {
13✔
104
                        Value::Boolean(x) => {
10✔
105
                            if !*x {
10✔
106
                                return Ok(true);
4✔
107
                            }
6✔
108
                        }
109
                        Value::Variable(var_name) => {
3✔
110
                            let var = variables.get(var_name.as_ref());
3✔
111
                            match var {
2✔
112
                                Some(serde_json::Value::Bool(bool_val)) => {
2✔
113
                                    if !bool_val {
2✔
114
                                        return Ok(true);
1✔
115
                                    }
1✔
116
                                }
117
                                _ => {
118
                                    return Err(
1✔
119
                                        "Value for \"if\" in @include directive is required"
1✔
120
                                            .to_string(),
1✔
121
                                    );
1✔
122
                                }
123
                            }
124
                        }
125
                        _ => (),
×
126
                    }
127
                }
128
                _ => return Err(format!("Unknown directive {}", directive_name)),
×
129
            }
130
        }
131
    }
15,892✔
132
    Ok(false)
15,900✔
133
}
15,912✔
134

135
/// Normalizes literal selections, fragment spreads, and inline fragments
136
pub fn normalize_selection<'a, 'b, T>(
15,912✔
137
    query_selection: &'b Selection<'a, T>,
15,912✔
138
    fragment_definitions: &'b Vec<FragmentDefinition<'a, T>>,
15,912✔
139
    type_name: &String,            // for inline fragments
15,912✔
140
    variables: &serde_json::Value, // for directives
15,912✔
141
) -> Result<Vec<Field<'a, T>>, String>
15,912✔
142
where
15,912✔
143
    T: Text<'a> + Eq + AsRef<str> + Clone,
15,912✔
144
    T::Value: Hash,
15,912✔
145
{
146
    let mut selections: Vec<Field<'a, T>> = vec![];
15,912✔
147

148
    if selection_is_skipped(query_selection, variables)? {
15,912✔
149
        return Ok(selections);
10✔
150
    }
15,900✔
151

152
    match query_selection {
15,900✔
153
        Selection::Field(field) => {
14,611✔
154
            selections.push(field.clone());
14,611✔
155
        }
14,611✔
156
        Selection::FragmentSpread(fragment_spread) => {
1,275✔
157
            let frag_name = &fragment_spread.fragment_name;
1,275✔
158

159
            // Fragments can have type conditions
160
            // https://spec.graphql.org/June2018/#sec-Type-Conditions
161
            // so we must check the type too...
162
            let frag_def = match fragment_definitions
1,275✔
163
                .iter()
1,275✔
164
                .filter(|x| &x.name == frag_name)
3,041✔
165
                .find(|x| match &x.type_condition {
1,275✔
166
                    // TODO match when no type condition is specified?
167
                    TypeCondition::On(frag_type_name) => frag_type_name.as_ref() == type_name,
1,275✔
168
                }) {
1,275✔
169
                Some(frag) => frag,
1,275✔
170
                None => {
171
                    return Err(format!(
×
172
                        "no fragment named {} on type {}",
×
173
                        frag_name.as_ref(),
×
174
                        type_name
×
175
                    ))
×
176
                }
177
            };
178

179
            // TODO handle directives?
180
            let frag_selections = normalize_selection_set(
1,275✔
181
                &frag_def.selection_set,
1,275✔
182
                fragment_definitions,
1,275✔
183
                type_name,
1,275✔
184
                variables,
1,275✔
185
            );
186
            match frag_selections {
1,275✔
187
                Ok(sels) => selections.extend(sels),
1,275✔
188
                Err(err) => return Err(err),
×
189
            };
190
        }
191
        Selection::InlineFragment(inline_fragment) => {
14✔
192
            let inline_fragment_applies: bool = match &inline_fragment.type_condition {
14✔
193
                Some(infrag) => match infrag {
12✔
194
                    TypeCondition::On(infrag_name) => infrag_name.as_ref() == type_name,
12✔
195
                },
196
                None => true,
2✔
197
            };
198

199
            if inline_fragment_applies {
14✔
200
                let infrag_selections = normalize_selection_set(
11✔
201
                    &inline_fragment.selection_set,
11✔
202
                    fragment_definitions,
11✔
203
                    type_name,
11✔
204
                    variables,
11✔
UNCOV
205
                )?;
×
206
                selections.extend(infrag_selections);
11✔
207
            }
3✔
208
        }
209
    }
210

211
    Ok(selections)
15,900✔
212
}
15,912✔
213

214
pub fn to_gson<'a, T>(
1,326✔
215
    graphql_value: &Value<'a, T>,
1,326✔
216
    variables: &serde_json::Value,
1,326✔
217
    variable_definitions: &Vec<VariableDefinition<'a, T>>,
1,326✔
218
) -> Result<gson::Value, String>
1,326✔
219
where
1,326✔
220
    T: Text<'a> + AsRef<str>,
1,326✔
221
{
222
    let result = match graphql_value {
1,326✔
223
        Value::Null => gson::Value::Null,
18✔
224
        Value::Boolean(x) => gson::Value::Boolean(*x),
9✔
225
        Value::Int(x) => {
231✔
226
            let val = x.as_i64();
231✔
227
            match val {
231✔
228
                Some(num) => {
231✔
229
                    let i_val = gson::Number::Integer(num);
231✔
230
                    gson::Value::Number(i_val)
231✔
231
                }
232
                None => return Err("Invalid Int input".to_string()),
×
233
            }
234
        }
235
        Value::Float(x) => {
19✔
236
            let val: gson::Number = gson::Number::Float(*x);
19✔
237
            gson::Value::Number(val)
19✔
238
        }
239
        Value::String(x) => gson::Value::String(x.to_owned()),
365✔
240
        Value::Enum(x) => gson::Value::String(x.as_ref().to_string()),
45✔
241
        Value::List(x_arr) => {
120✔
242
            let mut out_arr: Vec<gson::Value> = vec![];
120✔
243
            for x in x_arr {
322✔
244
                let val = to_gson(x, variables, variable_definitions)?;
202✔
245
                out_arr.push(val);
202✔
246
            }
247
            gson::Value::Array(out_arr)
120✔
248
        }
249
        Value::Object(obj) => {
436✔
250
            let mut out_map: HashMap<String, gson::Value> = HashMap::new();
436✔
251
            for (key, graphql_val) in obj.iter() {
516✔
252
                let val = to_gson(graphql_val, variables, variable_definitions)?;
516✔
253
                out_map.insert(key.as_ref().to_string(), val);
516✔
254
            }
255
            gson::Value::Object(out_map)
436✔
256
        }
257
        Value::Variable(var_name) => {
83✔
258
            let var = variables.get(var_name.as_ref());
83✔
259
            match var {
83✔
260
                Some(x) => gson::json_to_gson(x)?,
73✔
261
                None => {
262
                    let variable_default: Option<&graphql_parser::query::Value<'a, T>> =
10✔
263
                        variable_definitions
10✔
264
                            .iter()
10✔
265
                            .find(|var_def| var_def.name.as_ref() == var_name.as_ref())
13✔
266
                            .and_then(|x| x.default_value.as_ref());
10✔
267

268
                    match variable_default {
10✔
269
                        Some(x) => to_gson(x, variables, variable_definitions)?,
1✔
270
                        None => gson::Value::Absent,
9✔
271
                    }
272
                }
273
            }
274
        }
275
    };
276
    Ok(result)
1,326✔
277
}
1,326✔
278

279
pub fn validate_arg_from_type(type_: &__Type, value: &gson::Value) -> Result<gson::Value, String> {
6,329✔
280
    use crate::graphql::Scalar;
281
    use crate::gson::Number as GsonNumber;
282
    use crate::gson::Value as GsonValue;
283

284
    let res: GsonValue = match type_ {
6,329✔
285
        __Type::Scalar(scalar) => {
3,081✔
286
            match scalar {
818✔
287
                Scalar::String(None) => match value {
404✔
288
                    GsonValue::Absent | GsonValue::Null | GsonValue::String(_) => value.clone(),
404✔
289
                    _ => return Err(format!("Invalid input for {:?} type", scalar)),
×
290
                },
291
                Scalar::String(Some(max_length)) => match value {
414✔
292
                    GsonValue::Absent | GsonValue::Null => value.clone(),
347✔
293
                    GsonValue::String(string_content) => {
67✔
294
                        match string_content.len() as i32 > *max_length {
67✔
295
                            false => value.clone(),
64✔
296
                            true => {
297
                                return Err(format!(
3✔
298
                                    "Invalid input for {} type. Maximum character length {}",
3✔
299
                                    scalar.name().unwrap_or("String".to_string()),
3✔
300
                                    max_length
3✔
301
                                ))
3✔
302
                            }
303
                        }
304
                    }
305
                    _ => return Err(format!("Invalid input for {:?} type", scalar)),
×
306
                },
307
                Scalar::Int => match value {
228✔
308
                    GsonValue::Absent => value.clone(),
848✔
309
                    GsonValue::Null => value.clone(),
377✔
310
                    GsonValue::Number(GsonNumber::Integer(_)) => value.clone(),
228✔
311
                    _ => return Err(format!("Invalid input for {:?} type", scalar)),
5✔
312
                },
313
                Scalar::Float => match value {
22✔
314
                    GsonValue::Absent => value.clone(),
2✔
315
                    GsonValue::Null => value.clone(),
×
316
                    GsonValue::Number(_) => value.clone(),
20✔
317
                    _ => return Err(format!("Invalid input for {:?} type", scalar)),
×
318
                },
319
                Scalar::Boolean => match value {
12✔
320
                    GsonValue::Absent | GsonValue::Null | GsonValue::Boolean(_) => value.clone(),
12✔
321
                    _ => return Err(format!("Invalid input for {:?} type", scalar)),
×
322
                },
323
                Scalar::Date => {
324
                    match value {
15✔
325
                        // XXX: future - validate date here
326
                        GsonValue::Absent | GsonValue::Null | GsonValue::String(_) => value.clone(),
15✔
327
                        _ => return Err(format!("Invalid input for {:?} type", scalar)),
×
328
                    }
329
                }
330
                Scalar::Time => {
331
                    match value {
15✔
332
                        // XXX: future - validate time here
333
                        GsonValue::Absent | GsonValue::Null | GsonValue::String(_) => value.clone(),
15✔
334
                        _ => return Err(format!("Invalid input for {:?} type", scalar)),
×
335
                    }
336
                }
337
                Scalar::Datetime => {
338
                    match value {
19✔
339
                        // XXX: future - validate datetime here
340
                        GsonValue::Absent | GsonValue::Null | GsonValue::String(_) => value.clone(),
19✔
341
                        _ => return Err(format!("Invalid input for {:?} type", scalar)),
×
342
                    }
343
                }
344
                Scalar::BigInt => match value {
32✔
345
                    GsonValue::Absent
346
                    | GsonValue::Null
347
                    | GsonValue::String(_)
348
                    | GsonValue::Number(_) => value.clone(),
32✔
349
                    _ => return Err(format!("Invalid input for {:?} type", scalar)),
×
350
                },
351
                Scalar::UUID => {
352
                    match value {
22✔
353
                        // XXX: future - validate uuid here
354
                        GsonValue::Absent | GsonValue::Null | GsonValue::String(_) => value.clone(),
22✔
355
                        _ => return Err(format!("Invalid input for {:?} type", scalar)),
×
356
                    }
357
                }
358
                Scalar::JSON => {
359
                    match value {
20✔
360
                        // XXX: future - validate json here
361
                        GsonValue::Absent | GsonValue::Null | GsonValue::String(_) => value.clone(),
20✔
362
                        _ => return Err(format!("Invalid input for {:?} type", scalar)),
×
363
                    }
364
                }
365
                Scalar::Cursor => {
366
                    match value {
585✔
367
                        // XXX: future - validate cursor here
368
                        GsonValue::Absent | GsonValue::Null | GsonValue::String(_) => value.clone(),
585✔
369
                        _ => return Err(format!("Invalid input for {:?} type", scalar)),
×
370
                    }
371
                }
372
                Scalar::ID => {
373
                    match value {
23✔
374
                        // XXX: future - validate cursor here
375
                        GsonValue::Absent | GsonValue::Null | GsonValue::String(_) => value.clone(),
23✔
376
                        _ => return Err(format!("Invalid input for {:?} type", scalar)),
×
377
                    }
378
                }
379
                Scalar::BigFloat => match value {
32✔
380
                    GsonValue::Absent | GsonValue::Null | GsonValue::String(_) => value.clone(),
30✔
381
                    _ => {
382
                        return Err(format!(
2✔
383
                            "Invalid input for {:?} type. String required",
2✔
384
                            scalar
2✔
385
                        ))
2✔
386
                    }
387
                },
388
                // No validation possible for unknown types. Lean on postgres for parsing
389
                Scalar::Opaque => value.clone(),
8✔
390
            }
391
        }
392
        __Type::Enum(enum_) => {
200✔
393
            let enum_name = enum_.name().expect("enum type should have a name");
200✔
394
            match value {
200✔
395
                GsonValue::Absent => value.clone(),
1✔
396
                GsonValue::Null => value.clone(),
170✔
397
                GsonValue::String(user_input_string) => {
29✔
398
                    let matches_enum_value = enum_
29✔
399
                        .enum_values(true)
29✔
400
                        .into_iter()
29✔
401
                        .flatten()
29✔
402
                        .find(|x| x.name().as_str() == user_input_string);
50✔
403
                    match matches_enum_value {
29✔
404
                        Some(_) => {
405
                            match &enum_.enum_ {
28✔
406
                                EnumSource::Enum(e) => e
21✔
407
                                    .directives
21✔
408
                                    .mappings
21✔
409
                                    .as_ref()
21✔
410
                                    // Use mappings if available and mapped
411
                                    .and_then(|mappings| mappings.get_by_right(user_input_string))
21✔
412
                                    .map(|val| GsonValue::String(val.clone()))
21✔
413
                                    .unwrap_or_else(|| value.clone()),
21✔
414
                                EnumSource::FilterIs => value.clone(),
7✔
415
                            }
416
                        }
417
                        None => return Err(format!("Invalid input for {} type", enum_name)),
1✔
418
                    }
419
                }
420
                _ => return Err(format!("Invalid input for {} type", enum_name)),
×
421
            }
422
        }
423
        __Type::OrderBy(enum_) => {
273✔
424
            let enum_name = enum_.name().expect("order by type should have a name");
273✔
425
            match value {
273✔
426
                GsonValue::Absent => value.clone(),
2✔
427
                GsonValue::Null => value.clone(),
208✔
428
                GsonValue::String(user_input_string) => {
63✔
429
                    let matches_enum_value = enum_
63✔
430
                        .enum_values(true)
63✔
431
                        .into_iter()
63✔
432
                        .flatten()
63✔
433
                        .find(|x| x.name().as_str() == user_input_string);
163✔
434
                    match matches_enum_value {
63✔
435
                        Some(_) => value.clone(),
58✔
436
                        None => return Err(format!("Invalid input for {} type", enum_name)),
5✔
437
                    }
438
                }
439
                _ => return Err(format!("Invalid input for {} type", enum_name)),
×
440
            }
441
        }
442
        __Type::List(list_type) => {
873✔
443
            let inner_type: __Type = *list_type.type_.clone();
873✔
444
            match value {
873✔
445
                GsonValue::Absent => value.clone(),
237✔
446
                GsonValue::Null => value.clone(),
489✔
447
                GsonValue::Array(input_arr) => {
130✔
448
                    let mut output_arr = vec![];
130✔
449
                    for input_elem in input_arr {
327✔
450
                        let out_elem = validate_arg_from_type(&inner_type, input_elem)?;
212✔
451
                        output_arr.push(out_elem);
197✔
452
                    }
453
                    GsonValue::Array(output_arr)
115✔
454
                }
455
                _ => {
456
                    // Single elements must be coerced to a single element list
457
                    let out_elem = validate_arg_from_type(&inner_type, value)?;
17✔
458
                    GsonValue::Array(vec![out_elem])
13✔
459
                }
460
            }
461
        }
462
        __Type::NonNull(nonnull_type) => {
391✔
463
            let inner_type: __Type = *nonnull_type.type_.clone();
391✔
464
            let out_elem = validate_arg_from_type(&inner_type, value)?;
391✔
465
            match out_elem {
366✔
466
                GsonValue::Absent | GsonValue::Null => {
467
                    return Err("Invalid input for NonNull type".to_string())
28✔
468
                }
469
                _ => out_elem,
338✔
470
            }
471
        }
472
        __Type::InsertInput(_) => validate_arg_from_input_object(type_, value)?,
40✔
473
        __Type::UpdateInput(_) => validate_arg_from_input_object(type_, value)?,
28✔
474
        __Type::OrderByEntity(_) => validate_arg_from_input_object(type_, value)?,
54✔
475
        __Type::FilterType(_) => validate_arg_from_input_object(type_, value)?,
838✔
476
        __Type::FilterEntity(_) => validate_arg_from_input_object(type_, value)?,
551✔
477
        _ => {
478
            return Err(format!(
×
479
                "Invalid Type used as input argument {}",
×
480
                type_.name().unwrap_or_default()
×
481
            ))
×
482
        }
483
    };
484
    Ok(res)
6,205✔
485
}
6,329✔
486

487
pub fn validate_arg_from_input_object(
1,511✔
488
    input_type: &__Type,
1,511✔
489
    value: &gson::Value,
1,511✔
490
) -> Result<gson::Value, String> {
1,511✔
491
    use crate::graphql::__TypeKind;
492
    use crate::gson::Value as GsonValue;
493

494
    let input_type_name = input_type.name().unwrap_or_default();
1,511✔
495

496
    if input_type.kind() != __TypeKind::INPUT_OBJECT {
1,511✔
497
        return Err(format!("Invalid input type {}", input_type_name));
×
498
    }
1,511✔
499

500
    let res: GsonValue = match value {
1,511✔
501
        GsonValue::Absent => value.clone(),
199✔
502
        GsonValue::Null => value.clone(),
830✔
503
        GsonValue::Object(input_obj) => {
470✔
504
            let mut out_map: HashMap<String, GsonValue> = HashMap::new();
470✔
505
            let type_input_fields: Vec<__InputValue> =
470✔
506
                input_type.input_fields().unwrap_or_default();
470✔
507

508
            // Confirm that there are no extra keys
509
            let mut extra_input_keys = vec![];
470✔
510
            for (k, _) in input_obj.iter() {
553✔
511
                if !type_input_fields.iter().map(|x| x.name()).any(|x| x == *k) {
1,670✔
512
                    extra_input_keys.push(k);
5✔
513
                }
548✔
514
            }
515
            if !extra_input_keys.is_empty() {
470✔
516
                return Err(format!(
5✔
517
                    "Input for type {} contains extra keys {:?}",
5✔
518
                    input_type_name, extra_input_keys
5✔
519
                ));
5✔
520
            }
465✔
521

522
            for obj_field in type_input_fields {
3,709✔
523
                let obj_field_type: __Type = obj_field.type_();
3,263✔
524
                let obj_field_key: String = obj_field.name();
3,263✔
525

526
                match input_obj.get(&obj_field_key) {
3,263✔
527
                    None => {
528
                        validate_arg_from_type(&obj_field_type, &GsonValue::Null)?;
2,699✔
529
                    }
530
                    Some(x) => {
564✔
531
                        let out_val = validate_arg_from_type(&obj_field_type, x)?;
564✔
532
                        out_map.insert(obj_field_key, out_val);
545✔
533
                    }
534
                };
535
            }
536
            GsonValue::Object(out_map)
446✔
537
        }
538
        _ => return Err(format!("Invalid input for {} type", input_type_name)),
12✔
539
    };
540
    Ok(res)
1,475✔
541
}
1,511✔
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