Skip to content

Commit 846f1e8

Browse files
authored
test: Real browser tests (adobe#9516)
* initialize S2 tests with Vitest Browser Mode * fix icons/illustrations * fix lint * fix tests * yarn.lock * revert tests * remove extra browser tests * update tests * move config to root * fail on console errors * get drag and drop working in chromium * fix types * restore unaffected tests * add icon/illustration wrappers * fix @storybook/blocks issue * remove unnecessary mocks * fix deps and config * add playwright install to script * run on desktop and mobile viewports * remove patch
1 parent 5c0e4a2 commit 846f1e8

File tree

13 files changed

+2180
-26
lines changed

13 files changed

+2180
-26
lines changed

bin/imports.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,15 @@ const devDependencies = new Set([
2222
'@parcel/macros',
2323
'@adobe/spectrum-tokens',
2424
'playwright',
25-
'axe-playwright'
25+
'axe-playwright',
26+
'vitest',
27+
'@vitejs/plugin-react',
28+
'@vitest/browser',
29+
'@vitest/browser-playwright',
30+
'@vitest/ui',
31+
'unplugin-parcel-macros',
32+
'vite',
33+
'vite-plugin-svgr'
2634
]);
2735

2836
module.exports = {

jest.config.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ module.exports = {
170170
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
171171
testPathIgnorePatterns: [
172172
'/node_modules/',
173-
'\\.ssr\\.test\\.[tj]sx?$'
173+
'\\.ssr\\.test\\.[tj]sx?$',
174+
'\\.browser\\.test\\.[tj]sx?$'
174175
],
175176
testTimeout: 20000,
176177

package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"start:mcp": "yarn workspace @react-spectrum/s2-docs generate:md && yarn build:mcp && node packages/dev/mcp/s2/dist/index.js && node packages/dev/mcp/react-aria/dist/index.js",
3838
"test:mcp": "yarn build:s2-docs && yarn build:mcp && node packages/dev/mcp/scripts/smoke-list-pages.mjs",
3939
"test": "cross-env STRICT_MODE=1 VIRT_ON=1 yarn jest",
40+
"test:browser": " yarn playwright install && vitest --config=vitest.browser.config.ts",
4041
"test:lint": "node packages/**/*.test-lint.js",
4142
"test-loose": "cross-env VIRT_ON=1 yarn jest",
4243
"test-storybook": "test-storybook --url http://localhost:9003 --browsers chromium --no-cache",
@@ -134,6 +135,9 @@
134135
"@types/react": "^19.0.0",
135136
"@types/react-dom": "^19.0.0",
136137
"@typescript/native-preview": "^7.0.0-dev.20251223.1",
138+
"@vitejs/plugin-react": "^5.1.4",
139+
"@vitest/browser-playwright": "^4.0.17",
140+
"@vitest/browser-preview": "^4.0.17",
137141
"@vueless/storybook-dark-mode": "^9.0.6",
138142
"@yarnpkg/types": "^4.0.0",
139143
"autoprefixer": "^9.6.0",
@@ -202,7 +206,11 @@
202206
"tempy": "^0.5.0",
203207
"typescript": "^5.8.2",
204208
"typescript-eslint": "^8.38.0",
209+
"unplugin-parcel-macros": "^0.1.1",
205210
"verdaccio": "^6.0.0",
211+
"vite-plugin-svgr": "^4.5.0",
212+
"vitest": "^4.0.17",
213+
"vitest-browser-react": "^2.0.2",
206214
"walk-object": "^4.0.0",
207215
"xml": "^1.0.1"
208216
},

packages/@react-spectrum/s2/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,8 +145,7 @@
145145
"@storybook/jest": "^0.2.3",
146146
"@testing-library/dom": "^10.1.0",
147147
"@testing-library/react": "^16.0.0",
148-
"@testing-library/user-event": "^14.0.0",
149-
"jest": "^29.5.0"
148+
"@testing-library/user-event": "^14.0.0"
150149
},
151150
"dependencies": {
152151
"@internationalized/date": "^3.12.0",
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*/
7+
8+
import {Button, ButtonGroup, Content, DropZone, FileTrigger, Heading, IllustratedMessage} from '../src';
9+
import CloudUpload from '@react-spectrum/s2/illustrations/gradient/generic1/CloudUpload';
10+
import {describe, expect, it, vi} from 'vitest';
11+
import {dragAndDrop} from './utils/dragAndDrop';
12+
import {page} from 'vitest/browser';
13+
import React from 'react';
14+
import {render} from './utils/render';
15+
import {style} from '../style' with {type: 'macro'};
16+
import {useDrag} from '@react-aria/dnd';
17+
18+
function Draggable({type}: {type: string}) {
19+
let {dragProps} = useDrag({
20+
getItems() {
21+
return [{
22+
[type]: 'hello world'
23+
}];
24+
}
25+
});
26+
27+
return (
28+
<div {...dragProps} role="button" tabIndex={0} data-testid="drag-source">
29+
Drag me
30+
</div>
31+
);
32+
}
33+
34+
describe('DropZone browser interactions', () => {
35+
it('should handle drag and drop of valid drop types', async () => {
36+
let onDrop = vi.fn();
37+
38+
await render(
39+
<>
40+
<Draggable type="text/plain" />
41+
<DropZone
42+
data-testid="dropzone"
43+
isFilled
44+
replaceMessage="Replace file"
45+
styles={style({width: 320, maxWidth: '90%'})}
46+
getDropOperation={types => (types.has('text/plain') ? 'copy' : 'cancel')}
47+
onDrop={onDrop}>
48+
<IllustratedMessage>
49+
<CloudUpload />
50+
<Heading>
51+
Drag and drop your file
52+
</Heading>
53+
<Content>
54+
Or, select a file from your computer
55+
</Content>
56+
<ButtonGroup>
57+
<FileTrigger
58+
acceptedFileTypes={['text/plain']}>
59+
<Button variant="accent">Browse files</Button>
60+
</FileTrigger>
61+
</ButtonGroup>
62+
</IllustratedMessage>
63+
</DropZone>
64+
</>
65+
);
66+
67+
let sourceEl = page.getByTestId('drag-source').element();
68+
let targetEl = page.getByTestId('dropzone').element();
69+
await dragAndDrop(sourceEl, targetEl);
70+
71+
await expect.poll(() => onDrop).toHaveBeenCalledTimes(1);
72+
let event = onDrop.mock.calls[0][0];
73+
expect(event.dropOperation).toBe('copy');
74+
expect(event.items.length).toBeGreaterThanOrEqual(1);
75+
});
76+
77+
it('should reject unsupported drop types', async () => {
78+
let onDrop = vi.fn();
79+
80+
await render(
81+
<>
82+
<Draggable type="application/json" />
83+
<DropZone
84+
data-testid="dropzone"
85+
isFilled
86+
replaceMessage="Replace file"
87+
styles={style({width: 320, maxWidth: '90%'})}
88+
getDropOperation={types => (types.has('text/plain') ? 'copy' : 'cancel')}
89+
onDrop={onDrop}>
90+
<IllustratedMessage>
91+
<CloudUpload />
92+
<Heading>
93+
Drag and drop your file
94+
</Heading>
95+
<Content>
96+
Or, select a file from your computer
97+
</Content>
98+
<ButtonGroup>
99+
<FileTrigger
100+
acceptedFileTypes={['text/plain']}>
101+
<Button variant="accent">Browse files</Button>
102+
</FileTrigger>
103+
</ButtonGroup>
104+
</IllustratedMessage>
105+
</DropZone>
106+
</>
107+
);
108+
109+
let sourceEl = page.getByTestId('drag-source').element();
110+
let targetEl = page.getByTestId('dropzone').element();
111+
await dragAndDrop(sourceEl, targetEl);
112+
113+
expect(onDrop).not.toHaveBeenCalled();
114+
});
115+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {server, userEvent} from 'vitest/browser';
14+
15+
/**
16+
* Creates a DataTransfer wrapper that works around Chromium restrictions on synthetic events.
17+
*
18+
* Chromium silently ignores writes to effectAllowed and dropEffect on DataTransfer objects
19+
* attached to untrusted (dispatched) DragEvents. This proxy intercepts those writes and
20+
* stores them in a local overlay, while forwarding everything else to the real DataTransfer.
21+
*/
22+
function createWritableDataTransfer(): {dataTransfer: DataTransfer, proxy: DataTransfer} {
23+
let dataTransfer = new DataTransfer();
24+
let overrides: Record<string, string> = {};
25+
26+
let proxy = new Proxy(dataTransfer, {
27+
get(target, prop) {
28+
if (prop in overrides) {
29+
return overrides[prop as string];
30+
}
31+
let value = Reflect.get(target, prop, target);
32+
if (typeof value === 'function') {
33+
return value.bind(target);
34+
}
35+
return value;
36+
},
37+
set(target, prop, value) {
38+
if (prop === 'effectAllowed' || prop === 'dropEffect') {
39+
overrides[prop] = value;
40+
try { (target as any)[prop] = value; } catch { /* noop - Chromium rejects this */ }
41+
return true;
42+
}
43+
(target as any)[prop] = value;
44+
return true;
45+
}
46+
});
47+
48+
return {dataTransfer, proxy};
49+
}
50+
51+
interface DragAndDropOptions {
52+
/** Clicks on the source element at this point relative to the top-left corner of the element's padding box. */
53+
sourcePosition?: {x: number, y: number},
54+
/** Drops on the target element at this point relative to the top-left corner of the element's padding box. */
55+
targetPosition?: {x: number, y: number}
56+
}
57+
58+
/** Returns the clientX/clientY for a position relative to an element's padding box, or the element's center if no position is given. */
59+
function resolveClientPosition(element: Element, position?: {x: number, y: number}): {clientX: number, clientY: number} {
60+
let rect = element.getBoundingClientRect();
61+
if (position) {
62+
return {clientX: rect.left + position.x, clientY: rect.top + position.y};
63+
}
64+
return {clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2};
65+
}
66+
67+
/**
68+
* Perform a drag-and-drop from source to target.
69+
*
70+
* On non-Chromium browsers, delegates to `userEvent.dragAndDrop` which uses the
71+
* browser provider's native drag support.
72+
*
73+
* On Chromium, Playwright's CDP-based drag bypasses the native dragstart event,
74+
* which prevents useDrag from populating the DataTransfer. We fall back to
75+
* dispatching the full DragEvent lifecycle with synthetic events instead.
76+
*/
77+
export async function dragAndDrop(source: Element, target: Element, options?: DragAndDropOptions): Promise<void> {
78+
if (server.browser !== 'chromium') {
79+
await userEvent.dragAndDrop(source, target, options);
80+
return;
81+
}
82+
83+
let {dataTransfer, proxy} = createWritableDataTransfer();
84+
85+
let sourcePos = resolveClientPosition(source, options?.sourcePosition);
86+
let targetPos = resolveClientPosition(target, options?.targetPosition);
87+
88+
// Patch DragEvent.prototype.dataTransfer to return our proxy whenever the
89+
// event carries our real dataTransfer. This is the only reliable way to make
90+
// effectAllowed/dropEffect writable in Chromium for synthetic events.
91+
let origDesc = Object.getOwnPropertyDescriptor(DragEvent.prototype, 'dataTransfer')!;
92+
Object.defineProperty(DragEvent.prototype, 'dataTransfer', {
93+
get() {
94+
let real = origDesc.get!.call(this);
95+
return real === dataTransfer ? proxy : real;
96+
},
97+
configurable: true
98+
});
99+
100+
try {
101+
// Dispatch dragstart so useDrag populates the DataTransfer with items and sets effectAllowed.
102+
source.dispatchEvent(new DragEvent('dragstart', {dataTransfer, bubbles: true, cancelable: true, ...sourcePos}));
103+
104+
// Allow React state updates from the dragstart handler to flush.
105+
await new Promise(resolve => requestAnimationFrame(resolve));
106+
107+
target.dispatchEvent(new DragEvent('dragenter', {dataTransfer, bubbles: true, cancelable: true, ...targetPos}));
108+
target.dispatchEvent(new DragEvent('dragover', {dataTransfer, bubbles: true, cancelable: true, ...targetPos}));
109+
110+
// In a real browser drag, the drop event only fires when dropEffect is not 'none'.
111+
// useDrop sets dropEffect during dragenter/dragover based on getDropOperation.
112+
if (proxy.dropEffect !== 'none') {
113+
target.dispatchEvent(new DragEvent('drop', {dataTransfer, bubbles: true, cancelable: true, ...targetPos}));
114+
}
115+
116+
source.dispatchEvent(new DragEvent('dragend', {dataTransfer, bubbles: true, cancelable: true, ...targetPos}));
117+
} finally {
118+
// Restore the original dataTransfer getter.
119+
Object.defineProperty(DragEvent.prototype, 'dataTransfer', origDesc);
120+
}
121+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
export * from './dragAndDrop';
14+
export * from './render';
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import '../../src/page';
14+
import {Provider, type ProviderProps} from '../../src';
15+
import React, {ReactElement} from 'react';
16+
import {render as vitestBrowserRender} from 'vitest-browser-react';
17+
18+
export interface S2RenderOptions {
19+
/**
20+
* Options to pass to the Provider.
21+
*/
22+
providerOptions?: Omit<ProviderProps, 'children'>
23+
}
24+
25+
/**
26+
* Custom render function that wraps components in the S2 Provider.
27+
* Uses vitest-browser-react for browser mode testing.
28+
*/
29+
export async function render(
30+
ui: ReactElement,
31+
options: S2RenderOptions = {}
32+
) {
33+
const {providerOptions = {}} = options;
34+
const {locale = 'en-US', colorScheme = 'light', ...restProviderOptions} = providerOptions;
35+
36+
function Wrapper({children}: {children: React.ReactNode}) {
37+
return (
38+
<Provider locale={locale} colorScheme={colorScheme} {...restProviderOptions}>
39+
{children}
40+
</Provider>
41+
);
42+
}
43+
44+
return await vitestBrowserRender(<Wrapper>{ui}</Wrapper>);
45+
}

patches/@mdx-js+react+2.0.0-rc.2.patch

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ index be47704..c7ca78e 100644
33
--- a/node_modules/@mdx-js/react/lib/index.d.ts
44
+++ b/node_modules/@mdx-js/react/lib/index.d.ts
55
@@ -1,3 +1,4 @@
6-
+import {JSX} from 'react';
6+
+import type {JSX} from 'react';
77
/**
88
* @param {import('react').ComponentType<any>} Component
99
* @deprecated
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
diff --git a/node_modules/@vitest/browser/matchers.d.ts b/node_modules/@vitest/browser/matchers.d.ts
2+
index 1234567..abcdefg 100644
3+
--- a/node_modules/@vitest/browser/matchers.d.ts
4+
+++ b/node_modules/@vitest/browser/matchers.d.ts
5+
@@ -1,9 +1,8 @@
6+
import type { Locator } from './context.js'
7+
-import type { TestingLibraryMatchers } from './jest-dom.js'
8+
import type { Assertion, ExpectPollOptions } from 'vitest'
9+
10+
declare module 'vitest' {
11+
- interface JestAssertion<T = any> extends TestingLibraryMatchers<void, T> {}
12+
- interface AsymmetricMatchersContaining extends TestingLibraryMatchers<void, void> {}
13+
+ interface JestAssertion<T = any> {}
14+
+ interface AsymmetricMatchersContaining {}
15+
16+
type Promisify<O> = {

0 commit comments

Comments
 (0)