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

graphty-org / graphty-element / 20529335707

26 Dec 2025 09:00PM UTC coverage: 70.595% (+0.04%) from 70.559%
20529335707

push

github

apowers313
Merge branch 'master' of https://github.com/graphty-org/graphty-element

9598 of 13377 branches covered (71.75%)

Branch coverage included in aggregate %.

25127 of 35812 relevant lines covered (70.16%)

6249.3 hits per line

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

80.31
/src/NodeBehavior.ts
1
import {
15✔
2
    ActionManager,
14✔
3
    ExecuteCodeAction,
14✔
4
    Matrix,
14✔
5
    type Observer,
6
    PointerEventTypes,
14✔
7
    type PointerInfoPre,
8
    Ray,
9
    Scene,
10
    Vector3,
14✔
11
} from "@babylonjs/core";
14✔
12

13
import type {Graph} from "./Graph";
14
import type {GraphContext} from "./managers/GraphContext";
15
import type {Node as GraphNode, NodeIdType} from "./Node";
16

17
interface NodeBehaviorOptions {
18
    pinOnDrag?: boolean;
19
}
20

21
// Define drag state interface
22
interface DragState {
23
    dragging: boolean;
24
    dragStartMeshPosition: Vector3 | null;
25
    dragStartWorldPosition: Vector3 | null;
26
    dragPlaneNormal: Vector3 | null;
27
}
28

29
// Click detection state
30
interface ClickState {
31
    pointerDownTime: number;
32
    pointerDownPosition: {x: number, y: number};
33
    hasMoved: boolean;
34
    pointerEvent: PointerEvent | null;
35
}
36

37
// Click detection thresholds
38
const CLICK_MAX_DURATION_MS = 300; // Maximum time for a click (vs long press/drag)
15✔
39
const CLICK_MAX_MOVEMENT_PX = 5; // Maximum pixels of movement for a click
15✔
40

41
/**
42
 * Main drag handler class - unified for both desktop and XR
43
 */
44
export class NodeDragHandler {
15✔
45
    private node: GraphNode;
46
    private dragState: DragState;
47
    private clickState: ClickState | null = null;
15✔
48
    private scene: Scene;
49
    private pointerObserver: Observer<PointerInfoPre> | null = null;
15✔
50
    private hoverObserver: Observer<PointerInfoPre> | null = null;
15✔
51
    private isHovered = false;
15✔
52
    private readonly zAxisAmplification: number;
53
    private readonly enableZAmplificationInDesktop: boolean;
54

55
    /**
56
     * Creates a new drag handler for a node.
57
     * @param node - The graph node to enable dragging on
58
     */
59
    constructor(node: GraphNode) {
15✔
60
        this.node = node;
6,807✔
61
        this.scene = node.mesh.getScene();
6,807✔
62
        this.dragState = {
6,807✔
63
            dragging: false,
6,807✔
64
            dragStartMeshPosition: null,
6,807✔
65
            dragStartWorldPosition: null,
6,807✔
66
            dragPlaneNormal: null,
6,807✔
67
        };
6,807✔
68

69
        // Read config from graph context
70
        const context = this.getContext();
6,807✔
71
        const xrConfig = context.getConfig().xr;
6,807✔
72

73
        this.zAxisAmplification = xrConfig?.input.zAxisAmplification ?? 10.0;
6,807!
74
        this.enableZAmplificationInDesktop = xrConfig?.input.enableZAmplificationInDesktop ?? false;
6,807!
75

76
        // Setup pointer event listeners
77
        this.setupPointerEvents();
6,807✔
78
        this.setupHoverEvents();
6,807✔
79
    }
6,807✔
80

81
    // Public API for both desktop and XR
82
    /**
83
     * Initiates a drag operation for the node.
84
     * @param worldPosition - World space position where drag started
85
     */
86
    public onDragStart(worldPosition: Vector3): void {
15✔
87
        // Debug: console.log("🔍 [Drag] Drag Start:", {
88
        //     nodeId: this.node.id,
89
        //     isXRMode: this.isXRMode(),
90
        // });
91

92
        this.dragState.dragging = true;
29✔
93
        this.dragState.dragStartMeshPosition = this.node.mesh.position.clone();
29✔
94
        this.dragState.dragStartWorldPosition = worldPosition.clone();
29✔
95
        this.node.dragging = true;
29✔
96

97
        // Capture the drag plane orientation at drag start
98
        // This prevents the plane from rotating with camera during drag
99
        const camera = this.scene.activeCamera;
29✔
100
        if (camera) {
29✔
101
            this.dragState.dragPlaneNormal = camera.getForwardRay().direction.clone();
29✔
102

103
            // Disable camera input handler during node drag
104
            // This prevents OrbitInputController from rotating camera while dragging nodes
105
            const cameraManager = this.scene.metadata?.cameraManager;
29✔
106
            if (cameraManager) {
29✔
107
                // Debug: console.log("📷 Disabling camera input during node drag");
108
                cameraManager.temporarilyDisableInput();
29✔
109
            }
29✔
110
        }
29✔
111

112
        // Make sure graph is running
113
        const context = this.getContext();
29✔
114
        context.setRunning(true);
29✔
115

116
        // Emit node-drag-start event
117
        const eventManager = context.getEventManager?.();
29✔
118
        if (eventManager) {
29✔
119
            const pos = this.node.mesh.position;
29✔
120
            eventManager.emitNodeEvent("node-drag-start", {
29✔
121
                node: this.node,
29✔
122
                position: {x: pos.x, y: pos.y, z: pos.z},
29✔
123
            });
29✔
124
        }
29✔
125
    }
29✔
126

127
    /**
128
     * Updates node position during drag operation.
129
     * @param worldPosition - Current world space position of the drag pointer
130
     */
131
    public onDragUpdate(worldPosition: Vector3): void {
15✔
132
        if (!this.dragState.dragging || !this.dragState.dragStartWorldPosition || !this.dragState.dragStartMeshPosition) {
8!
133
            return;
1✔
134
        }
1✔
135

136
        // Calculate delta from drag start
137
        const delta = worldPosition.subtract(this.dragState.dragStartWorldPosition);
7✔
138

139
        // TODO: Add back delta validation with appropriate threshold for XR mode
140
        // The previous MAX_REASONABLE_DELTA of 5.0 was too small for 10x amplification
141
        // and was blocking ALL movement in XR
142

143
        // Apply movement amplification in XR mode
144
        // In VR, all controller movements are physically constrained (not just Z-axis)
145
        // so we amplify all axes to make node manipulation practical
146
        const shouldAmplify = this.isXRMode() || this.enableZAmplificationInDesktop;
7✔
147

148
        if (shouldAmplify) {
8!
149
            delta.x *= this.zAxisAmplification;
×
150
            delta.y *= this.zAxisAmplification;
×
151
            delta.z *= this.zAxisAmplification;
×
152
        }
✔
153

154
        // Calculate new position
155
        const newPosition = this.dragState.dragStartMeshPosition.add(delta);
7✔
156

157
        // Update mesh position (triggers edge updates automatically)
158
        this.node.mesh.position.copyFrom(newPosition);
7✔
159

160
        // Update layout engine
161
        const context = this.getContext();
7✔
162
        context.getLayoutManager().layoutEngine?.setNodePosition(this.node, {
8✔
163
            x: newPosition.x,
8✔
164
            y: newPosition.y,
8✔
165
            z: newPosition.z,
8✔
166
        });
8✔
167
    }
8✔
168

169
    /**
170
     * Completes a drag operation and updates node state.
171
     */
172
    public onDragEnd(): void {
15✔
173
        if (!this.dragState.dragging) {
28!
174
            return;
×
175
        }
×
176

177
        // Debug: console.log("🏁 NodeDragHandler.onDragEnd called", {
178
        //     nodeId: this.node.id,
179
        //     finalPosition: this.node.mesh.position.asArray(),
180
        // });
181

182
        // Make sure graph is running
183
        const context = this.getContext();
28✔
184
        context.setRunning(true);
28✔
185

186
        // Pin after dragging if configured
187
        if (this.node.pinOnDrag) {
28✔
188
            this.node.pin();
27✔
189
        }
27✔
190

191
        // Re-enable camera input handler after node drag
192
        const cameraManager = this.scene.metadata?.cameraManager;
28✔
193
        if (cameraManager) {
28✔
194
            // Debug: console.log("📷 Re-enabling camera input after node drag");
195
            cameraManager.temporarilyEnableInput();
28✔
196
        }
28✔
197

198
        // Emit node-drag-end event before resetting drag state
199
        const eventManager = context.getEventManager?.();
28✔
200
        if (eventManager) {
28✔
201
            const pos = this.node.mesh.position;
28✔
202
            eventManager.emitNodeEvent("node-drag-end", {
28✔
203
                node: this.node,
28✔
204
                position: {x: pos.x, y: pos.y, z: pos.z},
28✔
205
            });
28✔
206
        }
28✔
207

208
        // Reset drag state
209
        this.node.dragging = false;
28✔
210
        this.dragState.dragging = false;
28✔
211
        this.dragState.dragStartMeshPosition = null;
28✔
212
        this.dragState.dragStartWorldPosition = null;
28✔
213
        this.dragState.dragPlaneNormal = null;
28✔
214
    }
28✔
215

216
    /**
217
     * Set node position directly (for XR mode).
218
     * XRInputHandler calculates the position with pivot transform and amplification,
219
     * then calls this method to update the node and layout engine.
220
     *
221
     * This bypasses the delta calculation in onDragUpdate() which doesn't account
222
     * for pivot rotation changes during drag.
223
     * @param newPosition - New position to set for the node
224
     */
225
    public setPositionDirect(newPosition: Vector3): void {
15✔
226
        if (!this.dragState.dragging) {
×
227
            return;
×
228
        }
×
229

230
        // Update mesh position
231
        this.node.mesh.position.copyFrom(newPosition);
×
232

233
        // Update layout engine
234
        const context = this.getContext();
×
235
        context.getLayoutManager().layoutEngine?.setNodePosition(this.node, {
×
236
            x: newPosition.x,
×
237
            y: newPosition.y,
×
238
            z: newPosition.z,
×
239
        });
×
240
    }
×
241

242
    /**
243
     * Get the node being dragged.
244
     * Used by XRInputHandler to access the node's mesh for position calculations.
245
     * @returns The graph node associated with this drag handler
246
     */
247
    public getNode(): GraphNode {
15✔
248
        return this.node;
×
249
    }
×
250

251
    // Internal methods
252
    private setupPointerEvents(): void {
15✔
253
        // Listen to pointer events for node dragging and clicking
254
        this.pointerObserver = this.scene.onPrePointerObservable.add((pointerInfo) => {
6,807✔
255
            // Skip desktop pointer handling in XR mode - XRInputHandler handles it
256
            // This prevents conflicts where XR generates pointer events that the
257
            // desktop handler would misinterpret with wrong world position calculations
258
            if (this.isXRMode()) {
174✔
259
                return;
×
260
            }
×
261

262
            switch (pointerInfo.type) {
174✔
263
                case PointerEventTypes.POINTERDOWN: {
174✔
264
                    // Check if we clicked on this node
265
                    const pickInfo = this.scene.pick(
75✔
266
                        this.scene.pointerX,
75✔
267
                        this.scene.pointerY,
75✔
268
                    );
75✔
269

270
                    // Use nodeId from mesh metadata for comparison
271
                    // This works with both regular and instanced meshes
272
                    const pickedNodeId = pickInfo.pickedMesh?.metadata?.nodeId;
75✔
273

274
                    if (pickedNodeId === this.node.id) {
75✔
275
                        // Initialize click tracking
276
                        this.clickState = {
16✔
277
                            pointerDownTime: Date.now(),
16✔
278
                            pointerDownPosition: {
16✔
279
                                x: this.scene.pointerX,
16✔
280
                                y: this.scene.pointerY,
16✔
281
                            },
16✔
282
                            hasMoved: false,
16✔
283
                            pointerEvent: pointerInfo.event as PointerEvent,
16✔
284
                        };
16✔
285

286
                        // Get world position from pointer
287
                        const ray = this.scene.createPickingRay(
16✔
288
                            this.scene.pointerX,
16✔
289
                            this.scene.pointerY,
16✔
290
                            Matrix.Identity(),
16✔
291
                            this.scene.activeCamera,
16✔
292
                        );
16✔
293
                        const worldPosition = this.getWorldPositionFromRay(ray);
16✔
294
                        this.onDragStart(worldPosition);
16✔
295
                    }
16✔
296

297
                    break;
75✔
298
                }
75✔
299

300
                case PointerEventTypes.POINTERMOVE:
174✔
301
                    if (this.dragState.dragging) {
15✔
302
                        // Track movement for click detection
303
                        if (this.clickState && !this.clickState.hasMoved) {
×
304
                            const dx = this.scene.pointerX - this.clickState.pointerDownPosition.x;
×
305
                            const dy = this.scene.pointerY - this.clickState.pointerDownPosition.y;
×
306
                            const distance = Math.sqrt((dx * dx) + (dy * dy));
×
307
                            if (distance > CLICK_MAX_MOVEMENT_PX) {
×
308
                                this.clickState.hasMoved = true;
×
309
                            }
×
310
                        }
×
311

312
                        const ray = this.scene.createPickingRay(
×
313
                            this.scene.pointerX,
×
314
                            this.scene.pointerY,
×
315
                            Matrix.Identity(),
×
316
                            this.scene.activeCamera,
×
317
                        );
×
318
                        const worldPosition = this.getWorldPositionFromRay(ray);
×
319
                        this.onDragUpdate(worldPosition);
×
320
                    }
×
321

322
                    break;
15✔
323

324
                case PointerEventTypes.POINTERUP:
174✔
325
                    if (this.dragState.dragging) {
72✔
326
                        // Check if this was a click (short duration, minimal movement)
327
                        const wasClick = this.isClick();
16✔
328

329
                        this.onDragEnd();
16✔
330

331
                        // If it was a click, select this node
332
                        if (wasClick) {
16✔
333
                            this.handleClick();
16✔
334
                        }
16✔
335
                    }
16✔
336

337
                    // Reset click state
338
                    this.clickState = null;
72✔
339
                    break;
72✔
340
                default:
174✔
341
                    // Ignore other pointer events
342
                    break;
12✔
343
            }
174✔
344
        });
6,807✔
345
    }
6,807✔
346

347
    /**
348
     * Setup hover detection for emitting node-hover events.
349
     */
350
    private setupHoverEvents(): void {
15✔
351
        this.hoverObserver = this.scene.onPrePointerObservable.add((pointerInfo) => {
6,807✔
352
            // Skip in XR mode
353
            if (this.isXRMode()) {
174✔
354
                return;
×
355
            }
×
356

357
            // Only process move events for hover detection
358
            if (pointerInfo.type !== PointerEventTypes.POINTERMOVE) {
174✔
359
                return;
159✔
360
            }
159✔
361

362
            // Skip if we're currently dragging
363
            if (this.dragState.dragging) {
42✔
364
                return;
×
365
            }
✔
366

367
            // Check if we're hovering over this node
368
            const pickInfo = this.scene.pick(
15✔
369
                this.scene.pointerX,
15✔
370
                this.scene.pointerY,
15✔
371
            );
15✔
372
            const pickedNodeId = pickInfo.pickedMesh?.metadata?.nodeId;
42✔
373
            const isOverThisNode = pickedNodeId === this.node.id;
174✔
374

375
            // Emit node-hover when entering the node (not when already hovering)
376
            if (isOverThisNode && !this.isHovered) {
174✔
377
                this.isHovered = true;
×
378
                const context = this.getContext();
×
379
                const eventManager = context.getEventManager?.();
×
380
                if (eventManager) {
×
381
                    eventManager.emitNodeEvent("node-hover", {
×
382
                        node: this.node,
×
383
                        data: this.node.data,
×
384
                    });
×
385
                }
×
386
            } else if (!isOverThisNode && this.isHovered) {
42✔
387
                // Reset hover state when leaving
388
                this.isHovered = false;
×
389
            }
×
390
        });
6,807✔
391
    }
6,807✔
392

393
    /**
394
     * Check if the current pointer interaction qualifies as a click.
395
     * A click is defined as a short duration interaction with minimal movement.
396
     * @returns True if the interaction qualifies as a click
397
     */
398
    private isClick(): boolean {
15✔
399
        if (!this.clickState) {
16!
400
            return false;
×
401
        }
×
402

403
        const duration = Date.now() - this.clickState.pointerDownTime;
16✔
404
        return duration < CLICK_MAX_DURATION_MS && !this.clickState.hasMoved;
16✔
405
    }
16✔
406

407
    /**
408
     * Handle a click on this node - select it and emit node-click event.
409
     */
410
    private handleClick(): void {
15✔
411
        // Get the selection manager from the graph context
412
        const context = this.getContext();
16✔
413
        const selectionManager = context.getSelectionManager?.();
16✔
414
        if (selectionManager) {
16✔
415
            selectionManager.select(this.node);
16✔
416
        }
16✔
417

418
        // Emit node-click event
419
        const eventManager = context.getEventManager?.();
16✔
420
        if (eventManager && this.clickState?.pointerEvent) {
16✔
421
            eventManager.emitNodeEvent("node-click", {
16✔
422
                node: this.node,
16✔
423
                data: this.node.data,
16✔
424
                event: this.clickState.pointerEvent,
16✔
425
            });
16✔
426
        }
16✔
427
    }
16✔
428

429
    /**
430
     * Public API to select this node.
431
     * Called by XRInputHandler when user taps on a node without dragging.
432
     */
433
    public select(): void {
15✔
434
        this.handleClick();
×
435
    }
×
436

437
    private getWorldPositionFromRay(ray: Ray): Vector3 {
15✔
438
        // Strategy: Plane intersection parallel to camera view
439
        // This maintains predictable drag behavior
440
        const camera = this.scene.activeCamera;
16✔
441
        if (!camera) {
16!
442
            return this.node.mesh.position.clone();
×
443
        }
×
444

445
        const nodePosition = this.node.mesh.position;
16✔
446

447
        // Use stored plane normal from drag start if available
448
        // Otherwise fall back to current camera forward (for initial calculation)
449
        const cameraForward = camera.getForwardRay().direction;
16✔
450
        const planeNormal = this.dragState.dragPlaneNormal ?? cameraForward;
16✔
451

452
        // Calculate distance from camera to node along plane normal
453
        const cameraToNode = nodePosition.subtract(camera.position);
16✔
454
        const depth = Vector3.Dot(cameraToNode, planeNormal);
16✔
455

456
        // Create plane at node depth, with orientation from drag start
457
        const planePoint = camera.position.add(planeNormal.scale(depth));
16✔
458

459
        // Ray-plane intersection
460
        const denominator = Vector3.Dot(ray.direction, planeNormal);
16✔
461
        if (Math.abs(denominator) < 0.0001) {
16!
462
            // Ray parallel to plane, return current position
463
            return this.node.mesh.position.clone();
×
464
        }
×
465

466
        const t = Vector3.Dot(
16✔
467
            planePoint.subtract(ray.origin),
16✔
468
            planeNormal,
16✔
469
        ) / denominator;
16✔
470

471
        return ray.origin.add(ray.direction.scale(t));
16✔
472
    }
16✔
473

474
    private isXRMode(): boolean {
15✔
475
        // Check if we're in an XR session
476
        // The scene has an xrSession property when XR is active
477
        const xrHelper = this.scene.metadata?.xrHelper;
355✔
478
        return xrHelper?.baseExperience?.state === 2; // WebXRState.IN_XR
355!
479
    }
355✔
480

481
    private getContext(): GraphContext {
15✔
482
        // Check if parentGraph has GraphContext methods
483
        if ("getStyles" in this.node.parentGraph) {
6,887✔
484
            return this.node.parentGraph;
6,887✔
485
        }
6,887!
486

487
        // Otherwise, it's a Graph instance which implements GraphContext
488
        return this.node.parentGraph;
×
489
    }
6,887✔
490

491
    /**
492
     * Cleans up event observers and releases resources.
493
     */
494
    public dispose(): void {
15✔
495
        if (this.pointerObserver) {
1,263✔
496
            this.scene.onPrePointerObservable.remove(this.pointerObserver);
1,262✔
497
            this.pointerObserver = null;
1,262✔
498
        }
1,262✔
499

500
        if (this.hoverObserver) {
1,263✔
501
            this.scene.onPrePointerObservable.remove(this.hoverObserver);
1,262✔
502
            this.hoverObserver = null;
1,262✔
503
        }
1,262✔
504
    }
1,263✔
505
}
15✔
506

507
/**
508
 * Manages node interaction behaviors including dragging and clicking.
509
 * This class uses static methods to provide utility functions for node behavior management.
510
 */
511
// eslint-disable-next-line @typescript-eslint/no-extraneous-class -- Static utility class pattern for node behavior management
512
export class NodeBehavior {
15✔
513
    /**
514
     * Add default interaction behaviors to a node
515
     * @param node - The graph node to add behaviors to
516
     * @param options - Configuration options for node behaviors
517
     */
518
    static addDefaultBehaviors(node: GraphNode, options: NodeBehaviorOptions = {}): void {
15✔
519
        node.mesh.isPickable = true;
6,807✔
520

521
        // Set pinOnDrag config
522
        node.pinOnDrag = options.pinOnDrag ?? true;
6,807✔
523

524
        // Create unified drag handler (replaces SixDofDragBehavior)
525
        const dragHandler = new NodeDragHandler(node);
6,807✔
526
        node.dragHandler = dragHandler;
6,807✔
527

528
        this.addClickBehavior(node);
6,807✔
529
    }
6,807✔
530

531
    /**
532
     * Add click behavior for node expansion
533
     * @param node - The graph node to add click behavior to
534
     */
535
    private static addClickBehavior(node: GraphNode): void {
15✔
536
        // click behavior setup
537
        const context = this.getContext(node);
6,807✔
538
        const scene = context.getScene();
6,807✔
539
        node.mesh.actionManager = node.mesh.actionManager ?? new ActionManager(scene);
6,807✔
540

541
        // Available triggers:
542
        // ActionManager.OnDoublePickTrigger
543
        // ActionManager.OnRightPickTrigger
544
        // ActionManager.OnCenterPickTrigger
545
        // ActionManager.OnLongPressTrigger
546

547
        // Only Graph has fetchNodes/fetchEdges, not GraphContext
548
        // For now, check if parentGraph is the full Graph instance
549
        const graph = node.parentGraph as Graph & {fetchNodes?: unknown, fetchEdges?: unknown};
6,807✔
550
        if (graph.fetchNodes && graph.fetchEdges) {
6,807!
551
            const {fetchNodes, fetchEdges} = graph;
2✔
552

553
            node.mesh.actionManager.registerAction(
2✔
554
                new ExecuteCodeAction(
2✔
555
                    {
2✔
556
                        trigger: ActionManager.OnDoublePickTrigger,
2✔
557
                        // trigger: ActionManager.OnLongPressTrigger,
558
                    },
2✔
559
                    () => {
2✔
560
                        // make sure the graph is running
561
                        context.setRunning(true);
1✔
562

563
                        // fetch all edges for current node
564
                        const edgeSet = fetchEdges(node, graph as unknown as Graph);
1✔
565
                        const edges = Array.from(edgeSet);
1✔
566

567
                        // create set of unique node ids
568
                        const nodeIds = new Set<NodeIdType>();
1✔
569
                        edges.forEach((e) => {
1✔
570
                            nodeIds.add(e.src);
2✔
571
                            nodeIds.add(e.dst);
2✔
572
                        });
1✔
573
                        nodeIds.delete(node.id);
1✔
574

575
                        // fetch all nodes from associated edges
576
                        const nodes = fetchNodes(nodeIds, graph);
1✔
577

578
                        // add all the nodes and edges we collected
579
                        const dataManager = context.getDataManager();
1✔
580
                        dataManager.addNodes([... nodes]);
1✔
581
                        dataManager.addEdges([... edges]);
1✔
582

583
                        // TODO: fetch and add secondary edges
584
                    },
1✔
585
                ),
2✔
586
            );
2✔
587
        }
2✔
588
    }
6,807✔
589

590
    /**
591
     * Helper to get GraphContext from a Node
592
     * @param node - The graph node to get context from
593
     * @returns The graph context for the node
594
     */
595
    private static getContext(node: GraphNode): GraphContext {
15✔
596
        // Check if parentGraph has GraphContext methods
597
        if ("getStyles" in node.parentGraph) {
6,807✔
598
            return node.parentGraph;
6,807✔
599
        }
6,807!
600

601
        // Otherwise, it's a Graph instance which implements GraphContext
602
        return node.parentGraph;
×
603
    }
6,807✔
604
}
15✔
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