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

adobe / spectrum-web-components / 10834407286

12 Sep 2024 03:56PM UTC coverage: 98.181% (-0.02%) from 98.205%
10834407286

Pull #4743

github

web-flow
Merge ce7275334 into 302d6fe92
Pull Request #4743: chore: refactor sp-theme

5181 of 5441 branches covered (95.22%)

Branch coverage included in aggregate %.

196 of 207 new or added lines in 2 files covered. (94.69%)

2 existing lines in 1 file now uncovered.

32431 of 32868 relevant lines covered (98.67%)

382.08 hits per line

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

96.4
/tools/theme/src/Theme.ts
1
/*
104✔
2
Copyright 2020 Adobe. All rights reserved.
104✔
3
This file is licensed to you under the Apache License, Version 2.0 (the "License");
104✔
4
you may not use this file except in compliance with the License. You may obtain a copy
104✔
5
of the License at http://www.apache.org/licenses/LICENSE-2.0
104✔
6

104✔
7
Unless required by applicable law or agreed to in writing, software distributed under
104✔
8
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
104✔
9
OF ANY KIND, either express or implied. See the License for the specific language
104✔
10
governing permissions and limitations under the License.
104✔
11
*/
104✔
12

104✔
13
import { CSSResult, CSSResultGroup } from '@spectrum-web-components/base';
104✔
14
import { version } from '@spectrum-web-components/base/src/version.js';
104✔
15
import {
104✔
16
    Color,
104✔
17
    COLOR_VALUES,
104✔
18
    FragmentMap,
104✔
19
    FragmentName,
104✔
20
    FragmentType,
104✔
21
    ProvideLang,
104✔
22
    Scale,
104✔
23
    SCALE_VALUES,
104✔
24
    SettableFragmentTypes,
104✔
25
    ShadowRootWithAdoptedStyleSheets,
104✔
26
    SYSTEM_VARIANT_VALUES,
104✔
27
    SystemVariant,
104✔
28
    ThemeData,
104✔
29
    ThemeFragmentMap,
104✔
30
    ThemeKindProvider,
104✔
31
} from './theme-interfaces';
104✔
32
export type { ProvideLang, ThemeFragmentMap, Color, Scale, SystemVariant };
104✔
33
/**
104✔
34
 * @element sp-theme
104✔
35
 * @attr {string} [lang=""] - The language of the content scoped to this `sp-theme` element, see: <a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang" target="_blank">MDN reference</a>.
104✔
36
 *
104✔
37
 * @slot - Content on which to apply the CSS Custom Properties defined by the current theme configuration
104✔
38
 */
104✔
39
export class Theme extends HTMLElement implements ThemeKindProvider {
104✔
40
    private static themeFragmentsByKind: ThemeFragmentMap = new Map();
104✔
41
    private static defaultFragments: Set<FragmentName> = new Set(['spectrum']);
104✔
42
    private static templateElement?: HTMLTemplateElement;
104✔
43
    private static instances: Set<Theme> = new Set();
104✔
44
    static VERSION = version;
104✔
45

104✔
46
    static get observedAttributes(): string[] {
104✔
47
        return [
104✔
48
            'color',
104✔
49
            'scale',
104✔
50
            'lang',
104✔
51
            'dir',
104✔
52
            'system',
104✔
53
            /* deprecated attributes, but still observing */
104✔
54
            'theme',
104✔
55
        ];
104✔
56
    }
104✔
57

104✔
58
    _dir: 'ltr' | 'rtl' | '' = '';
104✔
59

104✔
60
    override set dir(dir: 'ltr' | 'rtl' | '') {
104✔
61
        if (dir === this.dir) return;
981✔
62
        this.setAttribute('dir', dir);
980✔
63
        this._dir = dir;
980✔
64
        const targetDir = dir === 'rtl' ? dir : 'ltr';
981✔
65
        this.trackedChildren.forEach((el) => {
981✔
66
            el.setAttribute('dir', targetDir);
14✔
67
        });
981✔
68
    }
981✔
69

104✔
70
    /**
104✔
71
     * Reading direction of the content scoped to this `sp-theme` element.
104✔
72
     * @type {"ltr" | "rtl" | ""}
104✔
73
     * @attr
104✔
74
     */
104✔
75
    override get dir(): 'ltr' | 'rtl' | '' {
104✔
76
        return this._dir;
8,698✔
77
    }
8,698✔
78

104✔
79
    protected attributeChangedCallback(
104✔
80
        attrName: SettableFragmentTypes | 'lang' | 'dir',
3,325✔
81
        old: string | null,
3,325✔
82
        value: string | null
3,325✔
83
    ): void {
3,325✔
84
        if (old === value) {
3,325✔
85
            return;
1,450✔
86
        }
1,450✔
87
        if (attrName === 'color') {
3,325✔
88
            this.color = value as Color;
485✔
89
        } else if (attrName === 'scale') {
3,325✔
90
            this.scale = value as Scale;
480✔
91
        } else if (attrName === 'lang' && !!value) {
1,387✔
92
            this.lang = value;
1✔
93
            this._provideContext();
1✔
94
        } else if (attrName === 'theme') {
910✔
95
            this.theme = value as SystemVariant;
88✔
96
            warnDeprecatedSystem(this, value as SystemVariant);
88✔
97
        } else if (attrName === 'system') {
886✔
98
            this.system = value as SystemVariant;
331✔
99
            warnDeprecatedSystem(this, value as SystemVariant);
331✔
100
        } else if (attrName === 'dir') {
579✔
101
            this.dir = value as 'ltr' | 'rtl' | '';
490✔
102
        }
490✔
103
    }
3,325✔
104
    private requestUpdate(): void {
104✔
105
        this.shouldAdoptStyles();
1,053✔
106
    }
1,053✔
107

104✔
108
    public override shadowRoot!: ShadowRootWithAdoptedStyleSheets;
104✔
109

104✔
110
    private _system: SystemVariant | '' = 'spectrum';
104✔
111
    /**
104✔
112
     * The Spectrum system that is applied to the content scoped to this `sp-theme` element.
104✔
113
     *
104✔
114
     * A value is requried.
104✔
115
     * @type {"spectrum" | "express" }
104✔
116
     * @attr
104✔
117
     */
104✔
118
    get system(): SystemVariant | '' {
104✔
119
        const systemFragments = Theme.themeFragmentsByKind.get('system');
3,053✔
120
        const { name } =
3,053✔
121
            (systemFragments && systemFragments.get('default')) || {};
3,053✔
122
        return this._system || (name as SystemVariant) || '';
3,053!
123
    }
3,053✔
124

104✔
125
    set system(newValue: SystemVariant | '') {
104✔
126
        if (newValue === this._system) return;
419✔
127
        const system =
26✔
128
            !!newValue && SYSTEM_VARIANT_VALUES.includes(newValue)
26✔
129
                ? newValue
5✔
130
                : this.system;
21✔
131
        if (system !== this._system) {
419✔
132
            this._system = system;
5✔
133
            this.requestUpdate();
5✔
134
        }
5✔
135
        if (system) {
26✔
136
            this.setAttribute('system', system);
26✔
137
            /* c8 ignore next 3 */
104✔
138
        } else {
104✔
139
            this.removeAttribute('system');
104✔
140
        }
104✔
141
    }
419✔
142

104✔
143
    /*
104✔
144
     * @deprecated The `theme` attribute has been deprecated in favor of the `system` attribute.
104✔
145
     */
104✔
146
    get theme(): SystemVariant | '' {
104✔
147
        if (!this.system) {
1,004!
148
            this.removeAttribute('system');
×
149
        }
×
150
        return this.system;
1,004✔
151
    }
1,004✔
152

104✔
153
    /*
104✔
154
     * @deprecated The `theme` attribute has been deprecated in favor of the `system` attribute.
104✔
155
     */
104✔
156
    set theme(newValue: SystemVariant | '') {
104✔
157
        this.system = newValue;
88✔
158
        this.requestUpdate();
88✔
159
    }
88✔
160

104✔
161
    private _color: Color | '' = '';
104✔
162

104✔
163
    /**
104✔
164
     * The Spectrum color stops to apply to content scoped by this `sp-theme` element.
104✔
165
     *
104✔
166
     * A value is requried.
104✔
167
     * @type {"lightest" | "light" | "dark" | "darkest" | ""}
104✔
168
     * @attr
104✔
169
     */
104✔
170
    get color(): Color | '' {
104✔
171
        const themeFragments = Theme.themeFragmentsByKind.get('color');
999✔
172
        const { name } =
999✔
173
            (themeFragments && themeFragments.get('default')) || {};
999✔
174
        return this._color || (name as Color) || '';
999✔
175
    }
999✔
176

104✔
177
    set color(newValue: Color | '') {
104✔
178
        if (newValue === this._color) return;
486✔
179
        const color =
483✔
180
            !!newValue && COLOR_VALUES.includes(newValue)
483✔
181
                ? newValue
481✔
182
                : this.color;
2✔
183
        if (color !== this._color) {
486✔
184
            this._color = color;
482✔
185
            this.requestUpdate();
482✔
186
        }
482✔
187
        if (color) {
483✔
188
            this.setAttribute('color', color);
483✔
189
        } else {
486!
UNCOV
190
            this.removeAttribute('color');
×
UNCOV
191
        }
×
192
    }
486✔
193

104✔
194
    private _scale: Scale | '' = '';
104✔
195

104✔
196
    /**
104✔
197
     * The Spectrum platform scale to apply to content scoped by this `sp-theme` element.
104✔
198
     *
104✔
199
     * A value is requried.
104✔
200
     * @type {"medium" | "large" | ""}
104✔
201
     * @attr
104✔
202
     */
104✔
203
    get scale(): Scale | '' {
104✔
204
        const themeFragments = Theme.themeFragmentsByKind.get('scale');
999✔
205
        const { name } =
999✔
206
            (themeFragments && themeFragments.get('default')) || {};
999✔
207
        return this._scale || (name as Scale) || '';
999✔
208
    }
999✔
209

104✔
210
    set scale(newValue: Scale | '') {
104✔
211
        if (newValue === this._scale) return;
481✔
212
        const scale =
478✔
213
            !!newValue && SCALE_VALUES.includes(newValue)
478✔
214
                ? newValue
476✔
215
                : this.scale;
2✔
216
        if (scale !== this._scale) {
481✔
217
            this._scale = scale;
478✔
218
            this.requestUpdate();
478✔
219
        }
478✔
220
        if (scale) {
478✔
221
            this.setAttribute('scale', scale);
478✔
222
            /* c8 ignore next 3 */
104✔
223
        } else {
104✔
224
            this.removeAttribute('scale');
104✔
225
        }
104✔
226
    }
481✔
227

104✔
228
    private get styles(): CSSResultGroup[] {
104✔
229
        const themeKinds: FragmentType[] = [
500✔
230
            ...Theme.themeFragmentsByKind.keys(),
500✔
231
        ];
500✔
232
        const getStyle = (
500✔
233
            fragments: FragmentMap,
1,492✔
234
            name: FragmentName,
1,492✔
235
            kind?: FragmentType
1,492✔
236
        ): CSSResultGroup | undefined => {
1,492✔
237
            const currentStyles =
1,492✔
238
                kind &&
1,492✔
239
                kind !== 'theme' &&
1,490✔
240
                kind !== 'system' &&
1,481✔
241
                this.theme !== 'spectrum' &&
990✔
242
                this.system !== 'spectrum'
14✔
243
                    ? fragments.get(`${name}-${this.system}`)
14✔
244
                    : fragments.get(name);
1,478✔
245
            // theme="spectrum" is available by default and doesn't need to be applied.
1,492✔
246
            const isAppliedFragment =
1,492✔
247
                name === 'spectrum' || !kind || this.hasAttribute(kind);
1,492✔
248
            if (currentStyles && isAppliedFragment) {
1,492✔
249
                return currentStyles.styles;
1,452✔
250
            }
1,452✔
251
            return;
40✔
252
        };
1,492✔
253
        const styles = themeKinds.reduce((acc, kind) => {
500✔
254
            const kindFragments = Theme.themeFragmentsByKind.get(
1,492✔
255
                kind
1,492✔
256
            ) as FragmentMap;
1,492✔
257
            let style: CSSResultGroup | undefined;
1,492✔
258
            if (kind === 'app' || kind === 'core') {
1,492✔
259
                style = getStyle(kindFragments, kind);
2✔
260
            } else {
1,492✔
261
                const { [kind]: name } = this;
1,490✔
262
                style = getStyle(kindFragments, <FragmentName>name, kind);
1,490✔
263
            }
1,490✔
264
            if (style) {
1,492✔
265
                acc.push(style);
1,452✔
266
            }
1,452✔
267
            return acc;
1,492✔
268
        }, [] as CSSResultGroup[]);
500✔
269

500✔
270
        const themeFragmentsByKind = Theme.themeFragmentsByKind; // Use public getter for theme fragments
500✔
271

500✔
272
        checkForIssues(
500✔
273
            this,
500✔
274
            this.system,
500✔
275
            this.color,
500✔
276
            this.scale,
500✔
277
            this.hasAttribute('theme'),
500✔
278
            themeFragmentsByKind
500✔
279
        );
500✔
280

500✔
281
        return [...styles];
500✔
282
    }
500✔
283

104✔
284
    private static get template(): HTMLTemplateElement {
104✔
285
        if (!this.templateElement) {
489✔
286
            this.templateElement = document.createElement('template');
40✔
287
            this.templateElement.innerHTML = '<slot></slot>';
40✔
288
        }
40✔
289
        return this.templateElement;
489✔
290
    }
489✔
291

104✔
292
    constructor() {
104✔
293
        super();
489✔
294
        this.attachShadow({ mode: 'open' });
489✔
295
        const node = document.importNode(Theme.template.content, true);
489✔
296
        this.shadowRoot.appendChild(node);
489✔
297
        this.shouldAdoptStyles();
489✔
298
        this.addEventListener(
489✔
299
            'sp-query-theme',
489✔
300
            this.onQueryTheme as EventListener
489✔
301
        );
489✔
302
        this.addEventListener(
489✔
303
            'sp-language-context',
489✔
304
            this._handleContextPresence as EventListener
489✔
305
        );
489✔
306
        this.updateComplete = this.__createDeferredPromise();
489✔
307
    }
489✔
308

104✔
309
    public updateComplete!: Promise<boolean>;
104✔
310
    private __resolve!: (compelted: boolean) => void;
104✔
311

104✔
312
    private __createDeferredPromise(): Promise<boolean> {
104✔
313
        return new Promise((resolve) => {
989✔
314
            this.__resolve = resolve;
989✔
315
        });
989✔
316
    }
989✔
317
    /* c8 ignore next 12 */
104✔
318
    private onQueryTheme(event: CustomEvent<ThemeData>): void {
104✔
319
        if (event.defaultPrevented) {
104✔
320
            return;
104✔
321
        }
104✔
322
        event.preventDefault();
104✔
323
        const { detail: theme } = event;
104✔
324
        theme.color = this.color || undefined;
104✔
325
        theme.scale = this.scale || undefined;
104✔
326
        theme.lang =
104✔
327
            this.lang || document.documentElement.lang || navigator.language;
104✔
328
        // `theme` is deprecated in favor of `system` but maintaining `theme` as a deprecated path.
104✔
329
        theme.theme = this.system || undefined;
104✔
330
        theme.system = this.system || undefined;
×
331
    }
×
332

104✔
333
    protected connectedCallback(): void {
104✔
334
        // Note, first update/render handles styleElement so we only call this if
490✔
335
        // connected after first update.
490✔
336
        this.shouldAdoptStyles();
490✔
337

490✔
338
        // Add `this` to the instances array.
490✔
339
        Theme.instances.add(this);
490✔
340
        if (!this.hasAttribute('dir')) {
490✔
341
            let dirParent = ((this as HTMLElement).assignedSlot ||
489✔
342
                this.parentNode) as HTMLElement | DocumentFragment | ShadowRoot;
453✔
343
            while (
489✔
344
                dirParent !== document.documentElement &&
489✔
345
                !(dirParent instanceof Theme)
1,013✔
346
            ) {
489✔
347
                dirParent = ((dirParent as HTMLElement).assignedSlot || // step into the shadow DOM of the parent of a slotted node
977✔
348
                    dirParent.parentNode || // DOM Element detected
977✔
349
                    (dirParent as ShadowRoot).host) as
36✔
350
                    | HTMLElement
977✔
351
                    | DocumentFragment
977✔
352
                    | ShadowRoot;
977✔
353
            }
977✔
354
            this.dir = dirParent.dir === 'rtl' ? dirParent.dir : 'ltr';
489✔
355
        }
489✔
356
    }
490✔
357

104✔
358
    protected disconnectedCallback(): void {
104✔
359
        // Remove `this` to the instances array.
489✔
360
        Theme.instances.delete(this);
489✔
361
    }
489✔
362

104✔
363
    public startManagingContentDirection(el: HTMLElement): void {
104✔
364
        this.trackedChildren.add(el);
7,676✔
365
    }
7,676✔
366

104✔
367
    public stopManagingContentDirection(el: HTMLElement): void {
104✔
368
        this.trackedChildren.delete(el);
7,676✔
369
    }
7,676✔
370

104✔
371
    private trackedChildren: Set<HTMLElement> = new Set();
104✔
372

104✔
373
    private _updateRequested = false;
104✔
374

104✔
375
    private async shouldAdoptStyles(): Promise<void> {
104✔
376
        if (!this._updateRequested) {
2,042✔
377
            this.updateComplete = this.__createDeferredPromise();
500✔
378
            this._updateRequested = true;
500✔
379
            this._updateRequested = await false;
500✔
380
            this.adoptStyles();
500✔
381
            this.__resolve(true);
500✔
382
        }
500✔
383
    }
2,042✔
384

104✔
385
    protected adoptStyles(): void {
104✔
386
        const styles = this.styles;
500✔
387
        const styleSheets: CSSStyleSheet[] = [];
500✔
388
        for (const style of styles) {
500✔
389
            styleSheets.push((style as CSSResult).styleSheet!);
1,452✔
390
        }
1,452✔
391
        this.shadowRoot.adoptedStyleSheets = styleSheets;
500✔
392
    }
500✔
393

104✔
394
    static registerThemeFragment(
104✔
395
        name: FragmentName,
723✔
396
        kind: FragmentType,
723✔
397
        styles: CSSResultGroup
723✔
398
    ): void {
723✔
399
        const fragmentMap = Theme.themeFragmentsByKind.get(kind) || new Map();
723✔
400
        if (fragmentMap.size === 0) {
723✔
401
            Theme.themeFragmentsByKind.set(kind, fragmentMap);
315✔
402
            // we're adding our first fragment for this kind, set as default
315✔
403
            fragmentMap.set('default', { name, styles });
315✔
404
            Theme.defaultFragments.add(name);
315✔
405
        }
315✔
406
        fragmentMap.set(name, { name, styles });
723✔
407
        Theme.instances.forEach((instance) => instance.shouldAdoptStyles());
723✔
408
    }
723✔
409

104✔
410
    private _contextConsumers = new Map<
104✔
411
        HTMLElement,
104✔
412
        [ProvideLang['callback'], () => void]
104✔
413
    >();
104✔
414

104✔
415
    /* c8 ignore next 5 */
104✔
416
    private _provideContext(): void {
104✔
417
        this._contextConsumers.forEach(([callback, unsubscribe]) =>
104✔
418
            callback(this.lang, unsubscribe)
104✔
419
        );
104✔
420
    }
104✔
421

104✔
422
    private _handleContextPresence(event: CustomEvent<ProvideLang>): void {
104✔
423
        event.stopPropagation();
66✔
424
        const target = event.composedPath()[0] as HTMLElement;
66✔
425
        /* c8 ignore next 3 */
104✔
426
        if (this._contextConsumers.has(target)) {
104✔
427
            return;
104✔
428
        }
104✔
429
        this._contextConsumers.set(target, [
66✔
430
            event.detail.callback,
66✔
431
            () => this._contextConsumers.delete(target),
66✔
432
        ]);
66✔
433
        const [callback, unsubscribe] =
66✔
434
            this._contextConsumers.get(target) || [];
66!
435
        if (callback && unsubscribe) {
66✔
436
            callback(
66✔
437
                this.lang ||
66✔
438
                    document.documentElement.lang ||
65✔
439
                    navigator.language,
65✔
440
                unsubscribe
66✔
441
            );
66✔
442
        }
66✔
443
    }
66✔
444
}
104✔
445

104✔
446
function warnDeprecatedSystem(instance: Theme, value: SystemVariant): void {
419✔
447
    if (window.__swc.DEBUG) {
419✔
448
        window.__swc.warn(
419✔
449
            instance,
419✔
450
            'property theme in <sp-theme> has been deprecated. Please use system instead like this <sp-theme system="spectrum"/>',
419✔
451
            'https://opensource.adobe.com/spectrum-web-components/tools/themes/#deprecation',
419✔
452
            { level: 'deprecation' }
419✔
453
        );
419✔
454
        if (value === 'spectrum-two') {
419!
NEW
455
            window.__swc.warn(
×
NEW
456
                instance,
×
NEW
457
                'You are currently using the beta version of Spectrum Two theme. Consumption of this system may be subject to unexpected changes before the 1.0 release of SWC.',
×
NEW
458
                'https://s2.spectrum.adobe.com/',
×
NEW
459
                { level: 'high' }
×
NEW
460
            );
×
NEW
461
        }
×
462
    }
419✔
463
}
419✔
464

104✔
465
function checkForIssues(
500✔
466
    instance: Theme,
500✔
467
    system: SystemVariant | '',
500✔
468
    color: Color | '',
500✔
469
    scale: Scale | '',
500✔
470
    hasThemeAttribute: boolean,
500✔
471
    themeFragmentsByKind: ThemeFragmentMap
500✔
472
): void {
500✔
473
    if (window.__swc.DEBUG) {
500✔
474
        const issues: string[] = [];
500✔
475
        const checkForAttribute = (
500✔
476
            name: 'system' | 'color' | 'scale',
1,500✔
477
            resolvedValue?: string,
1,500✔
478
            actualValue?: string
1,500✔
479
        ): void => {
1,500✔
480
            const systemModifier =
1,500✔
481
                system && system !== 'spectrum' ? `-${system}` : '';
1,500✔
482
            if (!resolvedValue) {
1,500✔
483
                issues.push(
4✔
484
                    `You have not explicitly set the "${name}" attribute and there is no default value on which to fallback.`
4✔
485
                );
4✔
486
            } else if (!actualValue) {
1,500!
NEW
487
                issues.push(
×
NEW
488
                    `You have not explicitly set the "${name}" attribute, the default value ("${resolvedValue}") is being used as a fallback.`
×
NEW
489
                );
×
NEW
490
            } else if (
×
491
                !themeFragmentsByKind
1,496✔
492
                    .get(name)
1,481✔
493
                    ?.get(
1,481✔
494
                        resolvedValue +
1,481✔
495
                            (name === 'system' ? '' : systemModifier)
1,481✔
496
                    )
1,496✔
497
            ) {
1,496✔
498
                issues.push(
40✔
499
                    `You have set "${name}='${resolvedValue}'" but the associated system fragment has not been loaded.`
40✔
500
                );
40✔
501
            }
40✔
502
        };
1,500✔
503
        checkForAttribute('system', system, system);
500✔
504
        checkForAttribute('color', color, color);
500✔
505
        checkForAttribute('scale', scale, scale);
500✔
506

500✔
507
        if (hasThemeAttribute) {
500✔
508
            issues.push(
88✔
509
                `The "theme" attribute has been deprecated in favor of "system".`
88✔
510
            );
88✔
511
        }
88✔
512

500✔
513
        if (issues.length) {
500✔
514
            window.__swc.warn(
101✔
515
                instance,
101✔
516
                'You are leveraging an <sp-theme> element and the following issues may disrupt your theme delivery:',
101✔
517
                'https://opensource.adobe.com/spectrum-web-components/components/theme/#example',
101✔
518
                { issues }
101✔
519
            );
101✔
520
        }
101✔
521

500✔
522
        if (['lightest', 'darkest'].includes(color || '')) {
500✔
523
            window.__swc.warn(
7✔
524
                instance,
7✔
525
                `Color lightest and darkest are deprecated and will be removed in a future release`,
7✔
526
                'https://opensource.adobe.com/spectrum-web-components/tools/themes/#deprecation',
7✔
527
                { level: 'deprecation' }
7✔
528
            );
7✔
529
        }
7✔
530
    }
500✔
531
}
500✔
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