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

adobe / spectrum-web-components / 18017563598

25 Sep 2025 06:48PM UTC coverage: 97.916% (-0.09%) from 98.002%
18017563598

push

github

web-flow
chore: update playwright and @web/test-runner dependencies with comprehensive test refactoring (#5578)

This pull request represents a comprehensive update to testing infrastructure and dependencies across the entire Spectrum Web Components codebase. The changes include major dependency updates for Playwright and @web/test-runner packages, extensive test refactoring for improved reliability and maintainability, and enhanced CI/CD configurations.

Major Dependency Updates
Playwright:

Updated Docker image in CI from mcr.microsoft.com/playwright:v1.44.0 to v1.53.1
Updated @playwright/test version from ^1.44.0 to ^1.53.1
@web/test-runner Ecosystem:

Updated @web/test-runner from ^0.18.3 to ^0.20.2 across multiple packages
Updated @web/test-runner-junit-reporter from ^0.7.2 to ^0.8.0
Updated @web/test-runner-playwright from ^0.11.0 to ^0.11.1
Updated @web/test-runner-visual-regression from ^0.9.0 to ^0.10.0
Updated wireit from ^0.14.3 to ^0.14.12
Infrastructure Improvements
CI/CD Configuration:

Enhanced CircleCI configuration with updated Docker images and improved parallelism
Added new Chromium memory testing configuration (web-test-runner.config.ci-chromium-memory.js)
Updated GitHub Actions workflows for better coverage reporting
Added concurrency settings across all browser test configurations
ESLint and Code Quality:

Expanded ESLint coverage to include **/stories/*.ts files
Updated ESM import syntax from assert { type: 'json' } to with { type: 'json' }
Fixed import paths for visual regression commands
Dependency Management:

Added comprehensive patching system documentation in CONTRIBUTING.md
Created Yarn patches for @web/test-runner-playwright and @web/test-runner-visual-regression
Removed legacy patch-package dependency in favor of Yarn 4's built-in patching
Comprehensive Test Suite Refactoring (132 files affected)
Testing Helper Functions:

Refactored mouse interaction helpers: sendMouse → mouseClickOn, mouseClickAway, mouseMoveOver, mouseMoveAwa... (continued)

5324 of 5638 branches covered (94.43%)

Branch coverage included in aggregate %.

91 of 93 new or added lines in 8 files covered. (97.85%)

204 existing lines in 31 files now uncovered.

34050 of 34574 relevant lines covered (98.48%)

628.74 hits per line

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

93.51
/packages/overlay/src/OverlayStack.ts
1
/**
44✔
2
 * Copyright 2025 Adobe. All rights reserved.
44✔
3
 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
44✔
4
 * you may not use this file except in compliance with the License. You may obtain a copy
44✔
5
 * of the License at http://www.apache.org/licenses/LICENSE-2.0
44✔
6
 *
44✔
7
 * Unless required by applicable law or agreed to in writing, software distributed under
44✔
8
 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
44✔
9
 * OF ANY KIND, either express or implied. See the License for the specific language
44✔
10
 * governing permissions and limitations under the License.
44✔
11
 */
44✔
12

44✔
13
import { Overlay } from './Overlay.js';
44✔
14

44✔
15
const supportsPopover = 'showPopover' in document.createElement('div');
44✔
16

44✔
17
class OverlayStack {
44✔
18
    private get document(): Document {
44✔
19
        return this.root.ownerDocument /* c8 ignore next */ || document;
44✔
20
    }
44✔
21

44✔
22
    private pointerdownPath?: EventTarget[];
44✔
23

44✔
24
    private lastOverlay?: Overlay;
44✔
25

44✔
26
    private root: HTMLElement = document.body;
44✔
27

44✔
28
    stack: Overlay[] = [];
44✔
29

44✔
30
    private originalBodyOverflow = '';
44✔
31

44✔
32
    private bodyScrollBlocked = false;
44✔
33

44✔
34
    constructor() {
44✔
35
        this.bindEvents();
44✔
36
    }
44✔
37

44✔
38
    bindEvents(): void {
44✔
39
        this.document.addEventListener('pointerdown', this.handlePointerdown);
44✔
40
        this.document.addEventListener('pointerup', this.handlePointerup);
44✔
41
        this.document.addEventListener('keydown', this.handleKeydown);
44✔
42
        this.document.addEventListener('scroll', this.handleScroll, {
44✔
43
            capture: true,
44✔
44
        });
44✔
45
    }
44✔
46

44✔
47
    private handleScroll = (event: Event): void => {
44✔
48
        // Only handle document/body level scrolls
28✔
49
        // Skip any component scrolls
28✔
50
        if (
28✔
51
            event.target !== document &&
28✔
52
            event.target !== document.documentElement &&
5✔
53
            event.target !== document.body
5✔
54
        ) {
28✔
55
            return;
5✔
56
        }
5✔
57
        // Update positions of all open overlays
23✔
58
        this.stack.forEach((overlay) => {
23✔
59
            if (overlay.open) {
8✔
60
                // Don't close pickers on document scroll
8✔
61
                if (
8✔
62
                    overlay.type === 'auto' &&
8✔
63
                    overlay.triggerElement instanceof HTMLElement &&
5✔
64
                    overlay.triggerElement.closest('sp-picker, sp-action-menu')
5✔
65
                ) {
8✔
66
                    event.stopPropagation();
2✔
67
                }
2✔
68
                // Update the overlay's position by dispatching the update event
8✔
69
                document.dispatchEvent(
8✔
70
                    new CustomEvent('sp-update-overlays', {
8✔
71
                        bubbles: true,
8✔
72
                        composed: true,
8✔
73
                        cancelable: true,
8✔
74
                    })
8✔
75
                );
8✔
76
            }
8✔
77
        });
23✔
78
    };
28✔
79

44✔
80
    private closeOverlay(overlay: Overlay): void {
44✔
81
        const overlayIndex = this.stack.indexOf(overlay);
540✔
82
        if (overlayIndex > -1) {
540✔
83
            this.stack.splice(overlayIndex, 1);
478✔
84
        }
478✔
85
        overlay.open = false;
540✔
86

540✔
87
        this.manageBodyScroll();
540✔
88
    }
540✔
89

44✔
90
    /**
44✔
91
     * Manage body scroll blocking based on modal/page overlays
44✔
92
     */
44✔
93
    private manageBodyScroll(): void {
44✔
94
        const shouldBlock = this.stack.some(
1,018✔
95
            (overlay) => overlay.type === 'modal' || overlay.type === 'page'
1,018✔
96
        );
1,018✔
97
        if (shouldBlock && !this.bodyScrollBlocked) {
1,018✔
98
            this.originalBodyOverflow = document.body.style.overflow || '';
51✔
99
            document.body.style.overflow = 'hidden';
51✔
100
            this.bodyScrollBlocked = true;
51✔
101
        } else if (!shouldBlock && this.bodyScrollBlocked) {
1,013✔
102
            document.body.style.overflow = this.originalBodyOverflow;
51✔
103
            this.bodyScrollBlocked = false;
51✔
104
        }
51✔
105
    }
1,018✔
106

44✔
107
    /**
44✔
108
     * Cach the `pointerdownTarget` for later testing
44✔
109
     *
44✔
110
     * @param event {ClickEvent}
44✔
111
     */
44✔
112
    handlePointerdown = (event: Event): void => {
44✔
113
        this.pointerdownPath = event.composedPath();
181✔
114
        this.lastOverlay = this.stack[this.stack.length - 1];
181✔
115
    };
181✔
116

44✔
117
    /**
44✔
118
     * Close all overlays that are not ancestors of this click event
44✔
119
     *
44✔
120
     * @param event {ClickEvent}
44✔
121
     */
44✔
122
    handlePointerup = (): void => {
44✔
123
        // Test against the composed path in `pointerdown` in case the visitor moved their
1,297✔
124
        // pointer during the course of the interaction.
1,297✔
125
        // Ensure that this value is cleared even if the work in this method goes undone.
1,297✔
126
        const composedPath = this.pointerdownPath;
1,297✔
127
        this.pointerdownPath = undefined;
1,297✔
128
        if (!this.stack.length) return;
1,297✔
129
        if (!composedPath?.length) return;
1,297✔
130
        const lastOverlay = this.lastOverlay;
64✔
131
        this.lastOverlay = undefined;
64✔
132

64✔
133
        const lastIndex = this.stack.length - 1;
64✔
134
        const nonAncestorOverlays = this.stack.filter((overlay, i) => {
64✔
135
            const inStack = composedPath.find(
66✔
136
                (el) =>
66✔
137
                    // The Overlay is in the stack
904✔
138
                    el === overlay ||
904✔
139
                    // The Overlay trigger is in the stack and the Overlay is a "hint"
845✔
140
                    (el === overlay?.triggerElement &&
845!
141
                        'hint' === overlay?.type) ||
5!
142
                    // The last Overlay in the stack is not the last Overlay at `pointerdown` time and has a
841✔
143
                    // `triggerInteraction` of "longpress", meaning it was opened by this poitner interaction
841✔
144
                    (i === lastIndex &&
841✔
145
                        overlay !== lastOverlay &&
784✔
146
                        overlay.triggerInteraction === 'longpress')
2✔
147
            );
66✔
148
            return (
66✔
149
                !inStack &&
66✔
150
                !overlay.shouldPreventClose() &&
1✔
151
                overlay.type !== 'manual' &&
1!
UNCOV
152
                // Don't close if this overlay is modal and not on top of the overlay stack.
×
UNCOV
153
                !(overlay.type === 'modal' && lastOverlay !== overlay)
×
154
            );
66✔
155
        }) as Overlay[];
64✔
156
        nonAncestorOverlays.reverse();
64✔
157
        nonAncestorOverlays.forEach((overlay) => {
64✔
UNCOV
158
            this.closeOverlay(overlay);
×
UNCOV
159
            let parentToClose = overlay.parentOverlayToForceClose;
×
UNCOV
160
            while (parentToClose) {
×
UNCOV
161
                this.closeOverlay(parentToClose);
×
UNCOV
162
                parentToClose = parentToClose.parentOverlayToForceClose;
×
UNCOV
163
            }
×
164
        });
64✔
165
    };
1,297✔
166

44✔
167
    handleBeforetoggle = (event: Event): void => {
44✔
168
        const { target, newState: open } = event as Event & {
478✔
169
            newState: string;
478✔
170
        };
478✔
171
        if (open === 'open') return;
478✔
172
        this.closeOverlay(target as Overlay);
32✔
173
    };
478✔
174

44✔
175
    private handleKeydown = (event: KeyboardEvent): void => {
44✔
176
        if (event.code !== 'Escape') return;
366✔
177
        if (!this.stack.length) return;
33✔
178
        const last = this.stack[this.stack.length - 1];
21✔
179
        if (last?.type === 'page') {
366!
180
            event.preventDefault();
1✔
181
            return;
1✔
182
        }
1✔
183
        if (last?.type === 'manual') {
366!
184
            // Manual overlays should close on "Escape" key, but not when losing focus or interacting with other parts of the page.
1✔
185
            this.closeOverlay(last);
1✔
186
            return;
1✔
187
        }
1✔
188
        if (supportsPopover) return;
19!
UNCOV
189
        if (!last) return;
×
UNCOV
190
        this.closeOverlay(last);
×
191
    };
366✔
192

44✔
193
    /**
44✔
194
     * Get an array of Overlays that all share the same trigger element.
44✔
195
     *
44✔
196
     * @param triggerElement {HTMLELement}
44✔
197
     * @returns {Overlay[]}
44✔
198
     */
44✔
199
    overlaysByTriggerElement(triggerElement: HTMLElement): Overlay[] {
44✔
200
        return this.stack.filter(
×
201
            (overlay) => overlay.triggerElement === triggerElement
×
202
        );
×
UNCOV
203
    }
×
204

44✔
205
    /**
44✔
206
     * When overlays are added manage the open state of exisiting overlays appropriately:
44✔
207
     * - 'modal': should close other non-'modal' and non-'manual' overlays
44✔
208
     * - 'page': should close other non-'modal' and non-'manual' overlays
44✔
209
     * - 'auto': should close other 'auto' overlays and other 'hint' overlays, but not 'manual' overlays
44✔
210
     * - 'manual': shouldn't close other overlays
44✔
211
     * - 'hint': shouldn't close other overlays and give way to all other overlays on a trigger
44✔
212
     */
44✔
213
    add(overlay: Overlay): void {
44✔
214
        if (this.stack.includes(overlay)) {
480✔
215
            const overlayIndex = this.stack.indexOf(overlay);
2✔
216
            if (overlayIndex > -1) {
2✔
217
                this.stack.splice(overlayIndex, 1);
2✔
218
                this.stack.push(overlay);
2✔
219
            }
2✔
220
            return;
2✔
221
        }
2✔
222
        if (
478✔
223
            overlay.type === 'auto' ||
478✔
224
            overlay.type === 'modal' ||
153✔
225
            overlay.type === 'page'
104✔
226
        ) {
480✔
227
            // manage closing open overlays
383✔
228
            const queryPathEventName = 'sp-overlay-query-path';
383✔
229
            const queryPathEvent = new Event(queryPathEventName, {
383✔
230
                composed: true,
383✔
231
                bubbles: true,
383✔
232
            });
383✔
233
            overlay.addEventListener(
383✔
234
                queryPathEventName,
383✔
235
                (event: Event) => {
383✔
236
                    const path = event.composedPath();
383✔
237
                    this.stack.forEach((overlayEl) => {
383✔
238
                        const inPath = path.find((el) => el === overlayEl);
53✔
239
                        if (
53✔
240
                            !inPath &&
53✔
241
                            overlayEl.type !== 'manual' &&
28✔
242
                            overlayEl.type !== 'modal'
26✔
243
                        ) {
53✔
244
                            this.closeOverlay(overlayEl);
25✔
245
                        }
25✔
246
                    });
383✔
247
                },
383✔
248
                { once: true }
383✔
249
            );
383✔
250
            overlay.dispatchEvent(queryPathEvent);
383✔
251
        } else if (overlay.type === 'hint') {
472✔
252
            const hasPrevious = this.stack.some((overlayEl) => {
85✔
253
                return (
9✔
254
                    overlayEl.type !== 'manual' &&
9✔
255
                    overlayEl.triggerElement &&
9✔
256
                    overlayEl.triggerElement === overlay.triggerElement
7✔
257
                );
9✔
258
            });
85✔
259
            if (hasPrevious) {
85!
UNCOV
260
                overlay.open = false;
×
UNCOV
261
                return;
×
UNCOV
262
            }
×
263
            this.stack.forEach((overlayEl) => {
85✔
264
                if (overlayEl.type === 'hint') {
9✔
265
                    this.closeOverlay(overlayEl);
3✔
266
                }
3✔
267
            });
85✔
268
        }
85✔
269
        requestAnimationFrame(() => {
478✔
270
            this.stack.push(overlay);
478✔
271
            overlay.addEventListener('beforetoggle', this.handleBeforetoggle, {
478✔
272
                once: true,
478✔
273
            });
478✔
274
            this.manageBodyScroll();
478✔
275
        });
478✔
276
    }
480✔
277

44✔
278
    remove(overlay: Overlay): void {
44✔
279
        this.closeOverlay(overlay);
479✔
280
    }
479✔
281
}
44✔
282

44✔
283
export const overlayStack = new OverlayStack();
44✔
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