@coherent.js/testing 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +446 -0
- package/dist/index.js +746 -0
- package/package.json +46 -0
- package/types/index.d.ts +164 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Thomas Drouvin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
# @coherent.js/testing
|
|
2
|
+
|
|
3
|
+
Complete testing utilities for Coherent.js applications.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install --save-dev @coherent.js/testing
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- ✅ **Test Renderer** - Render components in test environment
|
|
14
|
+
- ✅ **Query Utilities** - Find elements by testId, text, className
|
|
15
|
+
- ✅ **Event Simulation** - Simulate user interactions
|
|
16
|
+
- ✅ **Async Testing** - Wait for conditions and elements
|
|
17
|
+
- ✅ **Mock Functions** - Create mocks and spies
|
|
18
|
+
- ✅ **Custom Matchers** - Coherent.js-specific assertions
|
|
19
|
+
- ✅ **Snapshot Testing** - Component snapshot support
|
|
20
|
+
- ✅ **User Events** - Realistic user interaction simulation
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```javascript
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { renderComponent, extendExpect } from '@coherent.js/testing';
|
|
27
|
+
|
|
28
|
+
// Extend expect with custom matchers
|
|
29
|
+
extendExpect(expect);
|
|
30
|
+
|
|
31
|
+
describe('MyComponent', () => {
|
|
32
|
+
it('should render correctly', () => {
|
|
33
|
+
const component = {
|
|
34
|
+
div: {
|
|
35
|
+
'data-testid': 'my-div',
|
|
36
|
+
text: 'Hello World'
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const { getByTestId } = renderComponent(component);
|
|
41
|
+
|
|
42
|
+
expect(getByTestId('my-div')).toHaveText('Hello World');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## API Reference
|
|
48
|
+
|
|
49
|
+
### Rendering
|
|
50
|
+
|
|
51
|
+
#### `renderComponent(component, options)`
|
|
52
|
+
|
|
53
|
+
Render a component for testing.
|
|
54
|
+
|
|
55
|
+
```javascript
|
|
56
|
+
const { getByTestId, getByText } = renderComponent({
|
|
57
|
+
div: { 'data-testid': 'test', text: 'Hello' }
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
#### `renderComponentAsync(component, props, options)`
|
|
62
|
+
|
|
63
|
+
Render an async component.
|
|
64
|
+
|
|
65
|
+
```javascript
|
|
66
|
+
const result = await renderComponentAsync(asyncComponent, { name: 'World' });
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
#### `createTestRenderer(component, options)`
|
|
70
|
+
|
|
71
|
+
Create a test renderer for component updates.
|
|
72
|
+
|
|
73
|
+
```javascript
|
|
74
|
+
const renderer = createTestRenderer(component);
|
|
75
|
+
renderer.render();
|
|
76
|
+
renderer.update(newComponent);
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
#### `shallowRender(component)`
|
|
80
|
+
|
|
81
|
+
Shallow render (top-level only).
|
|
82
|
+
|
|
83
|
+
```javascript
|
|
84
|
+
const shallow = shallowRender(component);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Queries
|
|
88
|
+
|
|
89
|
+
#### `getByTestId(testId)`
|
|
90
|
+
|
|
91
|
+
Get element by test ID (throws if not found).
|
|
92
|
+
|
|
93
|
+
```javascript
|
|
94
|
+
const element = getByTestId('submit-btn');
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### `queryByTestId(testId)`
|
|
98
|
+
|
|
99
|
+
Query element by test ID (returns null if not found).
|
|
100
|
+
|
|
101
|
+
```javascript
|
|
102
|
+
const element = queryByTestId('submit-btn');
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
#### `getByText(text)`
|
|
106
|
+
|
|
107
|
+
Get element by text content.
|
|
108
|
+
|
|
109
|
+
```javascript
|
|
110
|
+
const element = getByText('Submit');
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
#### `getByClassName(className)`
|
|
114
|
+
|
|
115
|
+
Get element by class name.
|
|
116
|
+
|
|
117
|
+
```javascript
|
|
118
|
+
const element = getByClassName('btn-primary');
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
#### `getAllByTagName(tagName)`
|
|
122
|
+
|
|
123
|
+
Get all elements by tag name.
|
|
124
|
+
|
|
125
|
+
```javascript
|
|
126
|
+
const items = getAllByTagName('li');
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Events
|
|
130
|
+
|
|
131
|
+
#### `fireEvent(element, eventType, eventData)`
|
|
132
|
+
|
|
133
|
+
Simulate an event.
|
|
134
|
+
|
|
135
|
+
```javascript
|
|
136
|
+
fireEvent(button, 'click');
|
|
137
|
+
fireEvent(input, 'change', { target: { value: 'test' } });
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
#### `userEvent`
|
|
141
|
+
|
|
142
|
+
Realistic user interactions.
|
|
143
|
+
|
|
144
|
+
```javascript
|
|
145
|
+
await userEvent.click(button);
|
|
146
|
+
await userEvent.type(input, 'Hello');
|
|
147
|
+
await userEvent.clear(input);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Async Utilities
|
|
151
|
+
|
|
152
|
+
#### `waitFor(condition, options)`
|
|
153
|
+
|
|
154
|
+
Wait for a condition to be true.
|
|
155
|
+
|
|
156
|
+
```javascript
|
|
157
|
+
await waitFor(() => getByText('Loaded').exists, { timeout: 2000 });
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
#### `waitForElement(queryFn, options)`
|
|
161
|
+
|
|
162
|
+
Wait for an element to appear.
|
|
163
|
+
|
|
164
|
+
```javascript
|
|
165
|
+
const element = await waitForElement(() => queryByTestId('loaded'));
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
#### `waitForElementToBeRemoved(queryFn, options)`
|
|
169
|
+
|
|
170
|
+
Wait for an element to disappear.
|
|
171
|
+
|
|
172
|
+
```javascript
|
|
173
|
+
await waitForElementToBeRemoved(() => queryByTestId('loading'));
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
#### `act(callback)`
|
|
177
|
+
|
|
178
|
+
Batch updates.
|
|
179
|
+
|
|
180
|
+
```javascript
|
|
181
|
+
await act(async () => {
|
|
182
|
+
// Perform updates
|
|
183
|
+
});
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Mocks & Spies
|
|
187
|
+
|
|
188
|
+
#### `createMock(implementation)`
|
|
189
|
+
|
|
190
|
+
Create a mock function.
|
|
191
|
+
|
|
192
|
+
```javascript
|
|
193
|
+
const mock = createMock((x) => x * 2);
|
|
194
|
+
mock(5); // returns 10
|
|
195
|
+
|
|
196
|
+
expect(mock).toHaveBeenCalledWith(5);
|
|
197
|
+
expect(mock).toHaveBeenCalledTimes(1);
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
#### `createSpy(object, method)`
|
|
201
|
+
|
|
202
|
+
Spy on an object method.
|
|
203
|
+
|
|
204
|
+
```javascript
|
|
205
|
+
const spy = createSpy(obj, 'method');
|
|
206
|
+
obj.method('test');
|
|
207
|
+
expect(spy).toHaveBeenCalledWith('test');
|
|
208
|
+
spy.mockRestore();
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Custom Matchers
|
|
212
|
+
|
|
213
|
+
Extend expect with Coherent.js-specific matchers:
|
|
214
|
+
|
|
215
|
+
```javascript
|
|
216
|
+
import { extendExpect } from '@coherent.js/testing';
|
|
217
|
+
extendExpect(expect);
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Available matchers:
|
|
221
|
+
|
|
222
|
+
- `toHaveText(text)` - Element has exact text
|
|
223
|
+
- `toContainText(text)` - Element contains text
|
|
224
|
+
- `toHaveClass(className)` - Element has class
|
|
225
|
+
- `toBeInTheDocument()` - Element exists
|
|
226
|
+
- `toBeVisible()` - Element has visible content
|
|
227
|
+
- `toBeEmpty()` - Element is empty
|
|
228
|
+
- `toContainHTML(html)` - HTML contains string
|
|
229
|
+
- `toHaveAttribute(attr, value)` - Element has attribute
|
|
230
|
+
- `toHaveTagName(tagName)` - Element has tag name
|
|
231
|
+
- `toRenderSuccessfully()` - Component rendered
|
|
232
|
+
- `toBeValidHTML()` - HTML is valid
|
|
233
|
+
- `toHaveBeenCalled()` - Mock was called
|
|
234
|
+
- `toHaveBeenCalledWith(...args)` - Mock called with args
|
|
235
|
+
- `toHaveBeenCalledTimes(n)` - Mock called n times
|
|
236
|
+
|
|
237
|
+
### Utilities
|
|
238
|
+
|
|
239
|
+
#### `within(container)`
|
|
240
|
+
|
|
241
|
+
Scope queries to a container.
|
|
242
|
+
|
|
243
|
+
```javascript
|
|
244
|
+
const container = getByTestId('container');
|
|
245
|
+
const scoped = within(container);
|
|
246
|
+
scoped.getByText('Inner text');
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
#### `screen`
|
|
250
|
+
|
|
251
|
+
Global query utility.
|
|
252
|
+
|
|
253
|
+
```javascript
|
|
254
|
+
screen.setResult(result);
|
|
255
|
+
screen.getByTestId('test');
|
|
256
|
+
screen.debug();
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
#### `cleanup()`
|
|
260
|
+
|
|
261
|
+
Clean up after tests.
|
|
262
|
+
|
|
263
|
+
```javascript
|
|
264
|
+
afterEach(() => {
|
|
265
|
+
cleanup();
|
|
266
|
+
});
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Examples
|
|
270
|
+
|
|
271
|
+
### Testing a Button Component
|
|
272
|
+
|
|
273
|
+
```javascript
|
|
274
|
+
import { renderComponent, fireEvent, createMock } from '@coherent.js/testing';
|
|
275
|
+
|
|
276
|
+
it('should handle click events', () => {
|
|
277
|
+
const handleClick = createMock();
|
|
278
|
+
|
|
279
|
+
const button = {
|
|
280
|
+
button: {
|
|
281
|
+
'data-testid': 'my-btn',
|
|
282
|
+
text: 'Click me',
|
|
283
|
+
onclick: handleClick
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const { getByTestId } = renderComponent(button);
|
|
288
|
+
fireEvent(getByTestId('my-btn'), 'click');
|
|
289
|
+
|
|
290
|
+
expect(handleClick).toHaveBeenCalled();
|
|
291
|
+
});
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Testing Async Components
|
|
295
|
+
|
|
296
|
+
```javascript
|
|
297
|
+
import { renderComponentAsync, waitForElement } from '@coherent.js/testing';
|
|
298
|
+
|
|
299
|
+
it('should load data', async () => {
|
|
300
|
+
const AsyncComponent = async () => {
|
|
301
|
+
const data = await fetchData();
|
|
302
|
+
return { div: { text: data.message } };
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const result = await renderComponentAsync(AsyncComponent);
|
|
306
|
+
const element = await waitForElement(() => result.queryByText('Loaded'));
|
|
307
|
+
|
|
308
|
+
expect(element).toBeInTheDocument();
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Testing Forms
|
|
313
|
+
|
|
314
|
+
```javascript
|
|
315
|
+
import { renderComponent, userEvent } from '@coherent.js/testing';
|
|
316
|
+
|
|
317
|
+
it('should submit form', async () => {
|
|
318
|
+
const handleSubmit = createMock();
|
|
319
|
+
|
|
320
|
+
const form = {
|
|
321
|
+
form: {
|
|
322
|
+
onsubmit: handleSubmit,
|
|
323
|
+
children: [
|
|
324
|
+
{ input: { 'data-testid': 'name-input', type: 'text' } },
|
|
325
|
+
{ button: { 'data-testid': 'submit-btn', text: 'Submit' } }
|
|
326
|
+
]
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const { getByTestId } = renderComponent(form);
|
|
331
|
+
|
|
332
|
+
await userEvent.type(getByTestId('name-input'), 'John');
|
|
333
|
+
await userEvent.click(getByTestId('submit-btn'));
|
|
334
|
+
|
|
335
|
+
expect(handleSubmit).toHaveBeenCalled();
|
|
336
|
+
});
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Snapshot Testing
|
|
340
|
+
|
|
341
|
+
```javascript
|
|
342
|
+
import { renderComponent } from '@coherent.js/testing';
|
|
343
|
+
|
|
344
|
+
it('should match snapshot', () => {
|
|
345
|
+
const component = {
|
|
346
|
+
div: {
|
|
347
|
+
className: 'card',
|
|
348
|
+
children: [
|
|
349
|
+
{ h2: { text: 'Title' } },
|
|
350
|
+
{ p: { text: 'Content' } }
|
|
351
|
+
]
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const result = renderComponent(component);
|
|
356
|
+
expect(result.toSnapshot()).toMatchSnapshot();
|
|
357
|
+
});
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
## Best Practices
|
|
361
|
+
|
|
362
|
+
### 1. Use Test IDs
|
|
363
|
+
|
|
364
|
+
Add `data-testid` attributes for reliable querying:
|
|
365
|
+
|
|
366
|
+
```javascript
|
|
367
|
+
const component = {
|
|
368
|
+
button: {
|
|
369
|
+
'data-testid': 'submit-button',
|
|
370
|
+
text: 'Submit'
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### 2. Clean Up After Tests
|
|
376
|
+
|
|
377
|
+
Always clean up to avoid test interference:
|
|
378
|
+
|
|
379
|
+
```javascript
|
|
380
|
+
afterEach(() => {
|
|
381
|
+
cleanup();
|
|
382
|
+
});
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### 3. Use Custom Matchers
|
|
386
|
+
|
|
387
|
+
Extend expect for better assertions:
|
|
388
|
+
|
|
389
|
+
```javascript
|
|
390
|
+
extendExpect(expect);
|
|
391
|
+
expect(element).toHaveText('Hello');
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### 4. Test User Interactions
|
|
395
|
+
|
|
396
|
+
Use `userEvent` for realistic interactions:
|
|
397
|
+
|
|
398
|
+
```javascript
|
|
399
|
+
await userEvent.click(button);
|
|
400
|
+
await userEvent.type(input, 'text');
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### 5. Wait for Async Updates
|
|
404
|
+
|
|
405
|
+
Use `waitFor` for async operations:
|
|
406
|
+
|
|
407
|
+
```javascript
|
|
408
|
+
await waitFor(() => getByText('Loaded').exists);
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
## Integration with Testing Frameworks
|
|
412
|
+
|
|
413
|
+
### Vitest
|
|
414
|
+
|
|
415
|
+
```javascript
|
|
416
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
417
|
+
import { renderComponent, extendExpect, cleanup } from '@coherent.js/testing';
|
|
418
|
+
|
|
419
|
+
extendExpect(expect);
|
|
420
|
+
|
|
421
|
+
describe('MyComponent', () => {
|
|
422
|
+
afterEach(cleanup);
|
|
423
|
+
|
|
424
|
+
it('should work', () => {
|
|
425
|
+
// Test code
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
### Jest
|
|
431
|
+
|
|
432
|
+
```javascript
|
|
433
|
+
import { renderComponent, extendExpect, cleanup } from '@coherent.js/testing';
|
|
434
|
+
|
|
435
|
+
extendExpect(expect);
|
|
436
|
+
|
|
437
|
+
afterEach(cleanup);
|
|
438
|
+
|
|
439
|
+
test('should work', () => {
|
|
440
|
+
// Test code
|
|
441
|
+
});
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
## License
|
|
445
|
+
|
|
446
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
// src/test-renderer.js
|
|
2
|
+
import { render } from "@coherent.js/core";
|
|
3
|
+
var TestRendererResult = class {
|
|
4
|
+
constructor(component, html, container = null) {
|
|
5
|
+
this.component = component;
|
|
6
|
+
this.html = html;
|
|
7
|
+
this.container = container;
|
|
8
|
+
this.queries = /* @__PURE__ */ new Map();
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Get element by test ID
|
|
12
|
+
* @param {string} testId - Test ID to search for
|
|
13
|
+
* @returns {Object|null} Element or null
|
|
14
|
+
*/
|
|
15
|
+
getByTestId(testId) {
|
|
16
|
+
const regex = new RegExp(`data-testid="${testId}"[^>]*>([^<]*)<`, "i");
|
|
17
|
+
const match = this.html.match(regex);
|
|
18
|
+
if (!match) {
|
|
19
|
+
throw new Error(`Unable to find element with testId: ${testId}`);
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
text: match[1],
|
|
23
|
+
html: match[0],
|
|
24
|
+
testId,
|
|
25
|
+
exists: true
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Query element by test ID (returns null if not found)
|
|
30
|
+
* @param {string} testId - Test ID to search for
|
|
31
|
+
* @returns {Object|null} Element or null
|
|
32
|
+
*/
|
|
33
|
+
queryByTestId(testId) {
|
|
34
|
+
try {
|
|
35
|
+
return this.getByTestId(testId);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Get element by text content
|
|
42
|
+
* @param {string|RegExp} text - Text to search for
|
|
43
|
+
* @returns {Object} Element
|
|
44
|
+
*/
|
|
45
|
+
getByText(text) {
|
|
46
|
+
const regex = typeof text === "string" ? new RegExp(`>([^<]*${text}[^<]*)<`, "i") : new RegExp(`>([^<]*)<`, "i");
|
|
47
|
+
const match = this.html.match(regex);
|
|
48
|
+
if (!match || typeof text === "string" && !match[1].includes(text)) {
|
|
49
|
+
throw new Error(`Unable to find element with text: ${text}`);
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
text: match[1],
|
|
53
|
+
html: match[0],
|
|
54
|
+
exists: true
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Query element by text (returns null if not found)
|
|
59
|
+
* @param {string|RegExp} text - Text to search for
|
|
60
|
+
* @returns {Object|null} Element or null
|
|
61
|
+
*/
|
|
62
|
+
queryByText(text) {
|
|
63
|
+
try {
|
|
64
|
+
return this.getByText(text);
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Get element by class name
|
|
71
|
+
* @param {string} className - Class name to search for
|
|
72
|
+
* @returns {Object} Element
|
|
73
|
+
*/
|
|
74
|
+
getByClassName(className) {
|
|
75
|
+
const regex = new RegExp(`class="[^"]*${className}[^"]*"[^>]*>([^<]*)<`, "i");
|
|
76
|
+
const match = this.html.match(regex);
|
|
77
|
+
if (!match) {
|
|
78
|
+
throw new Error(`Unable to find element with className: ${className}`);
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
text: match[1],
|
|
82
|
+
html: match[0],
|
|
83
|
+
className,
|
|
84
|
+
exists: true
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Query element by class name (returns null if not found)
|
|
89
|
+
* @param {string} className - Class name to search for
|
|
90
|
+
* @returns {Object|null} Element or null
|
|
91
|
+
*/
|
|
92
|
+
queryByClassName(className) {
|
|
93
|
+
try {
|
|
94
|
+
return this.getByClassName(className);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get all elements by tag name
|
|
101
|
+
* @param {string} tagName - Tag name to search for
|
|
102
|
+
* @returns {Array<Object>} Array of elements
|
|
103
|
+
*/
|
|
104
|
+
getAllByTagName(tagName) {
|
|
105
|
+
const regex = new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, "gi");
|
|
106
|
+
const matches = [...this.html.matchAll(regex)];
|
|
107
|
+
return matches.map((match) => ({
|
|
108
|
+
text: match[1],
|
|
109
|
+
html: match[0],
|
|
110
|
+
tagName,
|
|
111
|
+
exists: true
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Check if element exists
|
|
116
|
+
* @param {string} selector - Selector (testId, text, className)
|
|
117
|
+
* @param {string} type - Type of selector ('testId', 'text', 'className')
|
|
118
|
+
* @returns {boolean} True if exists
|
|
119
|
+
*/
|
|
120
|
+
exists(selector, type = "testId") {
|
|
121
|
+
switch (type) {
|
|
122
|
+
case "testId":
|
|
123
|
+
return this.queryByTestId(selector) !== null;
|
|
124
|
+
case "text":
|
|
125
|
+
return this.queryByText(selector) !== null;
|
|
126
|
+
case "className":
|
|
127
|
+
return this.queryByClassName(selector) !== null;
|
|
128
|
+
default:
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Get the rendered HTML
|
|
134
|
+
* @returns {string} HTML string
|
|
135
|
+
*/
|
|
136
|
+
getHTML() {
|
|
137
|
+
return this.html;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Get the component
|
|
141
|
+
* @returns {Object} Component object
|
|
142
|
+
*/
|
|
143
|
+
getComponent() {
|
|
144
|
+
return this.component;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Create a snapshot of the rendered output
|
|
148
|
+
* @returns {string} Formatted HTML for snapshot testing
|
|
149
|
+
*/
|
|
150
|
+
toSnapshot() {
|
|
151
|
+
return this.html.replace(/>\s+</g, "><").trim();
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Debug: print the rendered HTML
|
|
155
|
+
*/
|
|
156
|
+
debug() {
|
|
157
|
+
console.log("=== Rendered HTML ===");
|
|
158
|
+
console.log(this.html);
|
|
159
|
+
console.log("=== Component ===");
|
|
160
|
+
console.log(JSON.stringify(this.component, null, 2));
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
function renderComponent(component, options = {}) {
|
|
164
|
+
const html = render(component, options);
|
|
165
|
+
return new TestRendererResult(component, html);
|
|
166
|
+
}
|
|
167
|
+
async function renderComponentAsync(component, props = {}, options = {}) {
|
|
168
|
+
const resolvedComponent = typeof component === "function" ? await component(props) : component;
|
|
169
|
+
const html = render(resolvedComponent, options);
|
|
170
|
+
return new TestRendererResult(resolvedComponent, html);
|
|
171
|
+
}
|
|
172
|
+
var TestRenderer = class {
|
|
173
|
+
constructor(component, options = {}) {
|
|
174
|
+
this.component = component;
|
|
175
|
+
this.options = options;
|
|
176
|
+
this.result = null;
|
|
177
|
+
this.renderCount = 0;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Render the component
|
|
181
|
+
* @returns {TestRendererResult} Render result
|
|
182
|
+
*/
|
|
183
|
+
render() {
|
|
184
|
+
this.renderCount++;
|
|
185
|
+
const html = render(this.component, this.options);
|
|
186
|
+
this.result = new TestRendererResult(this.component, html);
|
|
187
|
+
return this.result;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Update the component and re-render
|
|
191
|
+
* @param {Object} newComponent - Updated component
|
|
192
|
+
* @returns {TestRendererResult} Render result
|
|
193
|
+
*/
|
|
194
|
+
update(newComponent) {
|
|
195
|
+
this.component = newComponent;
|
|
196
|
+
return this.render();
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Get the current result
|
|
200
|
+
* @returns {TestRendererResult|null} Current result
|
|
201
|
+
*/
|
|
202
|
+
getResult() {
|
|
203
|
+
return this.result;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Get render count
|
|
207
|
+
* @returns {number} Number of renders
|
|
208
|
+
*/
|
|
209
|
+
getRenderCount() {
|
|
210
|
+
return this.renderCount;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Unmount the component
|
|
214
|
+
*/
|
|
215
|
+
unmount() {
|
|
216
|
+
this.component = null;
|
|
217
|
+
this.result = null;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
function createTestRenderer(component, options = {}) {
|
|
221
|
+
return new TestRenderer(component, options);
|
|
222
|
+
}
|
|
223
|
+
function shallowRender(component) {
|
|
224
|
+
const shallow = { ...component };
|
|
225
|
+
Object.keys(shallow).forEach((key) => {
|
|
226
|
+
if (shallow[key] && typeof shallow[key] === "object") {
|
|
227
|
+
if (shallow[key].children) {
|
|
228
|
+
shallow[key] = {
|
|
229
|
+
...shallow[key],
|
|
230
|
+
children: Array.isArray(shallow[key].children) ? shallow[key].children.map(() => ({ _shallow: true })) : { _shallow: true }
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
return shallow;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/test-utils.js
|
|
239
|
+
function fireEvent(element, eventType, eventData = {}) {
|
|
240
|
+
if (!element) {
|
|
241
|
+
throw new Error("Element is required for fireEvent");
|
|
242
|
+
}
|
|
243
|
+
const event = {
|
|
244
|
+
type: eventType,
|
|
245
|
+
target: element,
|
|
246
|
+
currentTarget: element,
|
|
247
|
+
preventDefault: () => {
|
|
248
|
+
},
|
|
249
|
+
stopPropagation: () => {
|
|
250
|
+
},
|
|
251
|
+
...eventData
|
|
252
|
+
};
|
|
253
|
+
const handlerName = `on${eventType}`;
|
|
254
|
+
if (element[handlerName] && typeof element[handlerName] === "function") {
|
|
255
|
+
element[handlerName](event);
|
|
256
|
+
}
|
|
257
|
+
return event;
|
|
258
|
+
}
|
|
259
|
+
var fireEvent_click = (element, eventData) => fireEvent(element, "click", eventData);
|
|
260
|
+
var fireEvent_change = (element, value) => fireEvent(element, "change", { target: { value } });
|
|
261
|
+
var fireEvent_input = (element, value) => fireEvent(element, "input", { target: { value } });
|
|
262
|
+
var fireEvent_keyDown = (element, key) => fireEvent(element, "keydown", { key });
|
|
263
|
+
var fireEvent_keyUp = (element, key) => fireEvent(element, "keyup", { key });
|
|
264
|
+
var fireEvent_focus = (element) => fireEvent(element, "focus");
|
|
265
|
+
var fireEvent_blur = (element) => fireEvent(element, "blur");
|
|
266
|
+
function waitFor(condition, options = {}) {
|
|
267
|
+
const { timeout = 1e3, interval = 50 } = options;
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
const startTime = Date.now();
|
|
270
|
+
const check = () => {
|
|
271
|
+
try {
|
|
272
|
+
if (condition()) {
|
|
273
|
+
resolve();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
}
|
|
278
|
+
if (Date.now() - startTime >= timeout) {
|
|
279
|
+
reject(new Error(`Timeout waiting for condition after ${timeout}ms`));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
setTimeout(check, interval);
|
|
283
|
+
};
|
|
284
|
+
check();
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
async function waitForElement(queryFn, options = {}) {
|
|
288
|
+
let element = null;
|
|
289
|
+
await waitFor(() => {
|
|
290
|
+
element = queryFn();
|
|
291
|
+
return element !== null;
|
|
292
|
+
}, options);
|
|
293
|
+
return element;
|
|
294
|
+
}
|
|
295
|
+
async function waitForElementToBeRemoved(queryFn, options = {}) {
|
|
296
|
+
await waitFor(() => {
|
|
297
|
+
const element = queryFn();
|
|
298
|
+
return element === null;
|
|
299
|
+
}, options);
|
|
300
|
+
}
|
|
301
|
+
async function act(callback) {
|
|
302
|
+
await callback();
|
|
303
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
304
|
+
}
|
|
305
|
+
function createMock(implementation) {
|
|
306
|
+
const calls = [];
|
|
307
|
+
const results = [];
|
|
308
|
+
const mockFn = function(...args) {
|
|
309
|
+
calls.push(args);
|
|
310
|
+
let result;
|
|
311
|
+
let error;
|
|
312
|
+
try {
|
|
313
|
+
result = implementation ? implementation(...args) : void 0;
|
|
314
|
+
results.push({ type: "return", value: result });
|
|
315
|
+
} catch (err) {
|
|
316
|
+
error = err;
|
|
317
|
+
results.push({ type: "throw", value: error });
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
return result;
|
|
321
|
+
};
|
|
322
|
+
mockFn.mock = {
|
|
323
|
+
calls,
|
|
324
|
+
results,
|
|
325
|
+
instances: []
|
|
326
|
+
};
|
|
327
|
+
mockFn.mockClear = () => {
|
|
328
|
+
calls.length = 0;
|
|
329
|
+
results.length = 0;
|
|
330
|
+
};
|
|
331
|
+
mockFn.mockReset = () => {
|
|
332
|
+
mockFn.mockClear();
|
|
333
|
+
implementation = void 0;
|
|
334
|
+
};
|
|
335
|
+
mockFn.mockImplementation = (fn) => {
|
|
336
|
+
implementation = fn;
|
|
337
|
+
return mockFn;
|
|
338
|
+
};
|
|
339
|
+
mockFn.mockReturnValue = (value) => {
|
|
340
|
+
implementation = () => value;
|
|
341
|
+
return mockFn;
|
|
342
|
+
};
|
|
343
|
+
mockFn.mockResolvedValue = (value) => {
|
|
344
|
+
implementation = () => Promise.resolve(value);
|
|
345
|
+
return mockFn;
|
|
346
|
+
};
|
|
347
|
+
mockFn.mockRejectedValue = (error) => {
|
|
348
|
+
implementation = () => Promise.reject(error);
|
|
349
|
+
return mockFn;
|
|
350
|
+
};
|
|
351
|
+
return mockFn;
|
|
352
|
+
}
|
|
353
|
+
function createSpy(object, method) {
|
|
354
|
+
const original = object[method];
|
|
355
|
+
const spy = createMock(original.bind(object));
|
|
356
|
+
object[method] = spy;
|
|
357
|
+
spy.mockRestore = () => {
|
|
358
|
+
object[method] = original;
|
|
359
|
+
};
|
|
360
|
+
return spy;
|
|
361
|
+
}
|
|
362
|
+
function cleanup() {
|
|
363
|
+
}
|
|
364
|
+
function within(container) {
|
|
365
|
+
return {
|
|
366
|
+
getByTestId: (testId) => container.getByTestId(testId),
|
|
367
|
+
queryByTestId: (testId) => container.queryByTestId(testId),
|
|
368
|
+
getByText: (text) => container.getByText(text),
|
|
369
|
+
queryByText: (text) => container.queryByText(text),
|
|
370
|
+
getByClassName: (className) => container.getByClassName(className),
|
|
371
|
+
queryByClassName: (className) => container.queryByClassName(className)
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
var screen = {
|
|
375
|
+
_result: null,
|
|
376
|
+
setResult(result) {
|
|
377
|
+
this._result = result;
|
|
378
|
+
},
|
|
379
|
+
getByTestId(testId) {
|
|
380
|
+
if (!this._result) throw new Error("No component rendered");
|
|
381
|
+
return this._result.getByTestId(testId);
|
|
382
|
+
},
|
|
383
|
+
queryByTestId(testId) {
|
|
384
|
+
if (!this._result) return null;
|
|
385
|
+
return this._result.queryByTestId(testId);
|
|
386
|
+
},
|
|
387
|
+
getByText(text) {
|
|
388
|
+
if (!this._result) throw new Error("No component rendered");
|
|
389
|
+
return this._result.getByText(text);
|
|
390
|
+
},
|
|
391
|
+
queryByText(text) {
|
|
392
|
+
if (!this._result) return null;
|
|
393
|
+
return this._result.queryByText(text);
|
|
394
|
+
},
|
|
395
|
+
getByClassName(className) {
|
|
396
|
+
if (!this._result) throw new Error("No component rendered");
|
|
397
|
+
return this._result.getByClassName(className);
|
|
398
|
+
},
|
|
399
|
+
queryByClassName(className) {
|
|
400
|
+
if (!this._result) return null;
|
|
401
|
+
return this._result.queryByClassName(className);
|
|
402
|
+
},
|
|
403
|
+
debug() {
|
|
404
|
+
if (this._result) {
|
|
405
|
+
this._result.debug();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
var userEvent = {
|
|
410
|
+
/**
|
|
411
|
+
* Simulate user typing
|
|
412
|
+
*/
|
|
413
|
+
type: async (element, text, options = {}) => {
|
|
414
|
+
const { delay = 0 } = options;
|
|
415
|
+
for (const char of text) {
|
|
416
|
+
fireEvent_keyDown(element, char);
|
|
417
|
+
fireEvent_input(element, element.value + char);
|
|
418
|
+
fireEvent_keyUp(element, char);
|
|
419
|
+
if (delay > 0) {
|
|
420
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
/**
|
|
425
|
+
* Simulate user click
|
|
426
|
+
*/
|
|
427
|
+
click: async (element) => {
|
|
428
|
+
fireEvent_focus(element);
|
|
429
|
+
fireEvent_click(element);
|
|
430
|
+
},
|
|
431
|
+
/**
|
|
432
|
+
* Simulate user double click
|
|
433
|
+
*/
|
|
434
|
+
dblClick: async (element) => {
|
|
435
|
+
await userEvent.click(element);
|
|
436
|
+
await userEvent.click(element);
|
|
437
|
+
},
|
|
438
|
+
/**
|
|
439
|
+
* Simulate user clearing input
|
|
440
|
+
*/
|
|
441
|
+
clear: async (element) => {
|
|
442
|
+
fireEvent_input(element, "");
|
|
443
|
+
fireEvent_change(element, "");
|
|
444
|
+
},
|
|
445
|
+
/**
|
|
446
|
+
* Simulate user selecting option
|
|
447
|
+
*/
|
|
448
|
+
selectOptions: async (element, values) => {
|
|
449
|
+
const valueArray = Array.isArray(values) ? values : [values];
|
|
450
|
+
fireEvent_change(element, valueArray[0]);
|
|
451
|
+
},
|
|
452
|
+
/**
|
|
453
|
+
* Simulate user tab navigation
|
|
454
|
+
*/
|
|
455
|
+
tab: async () => {
|
|
456
|
+
const activeElement = document.activeElement;
|
|
457
|
+
if (activeElement) {
|
|
458
|
+
fireEvent_keyDown(activeElement, "Tab");
|
|
459
|
+
fireEvent_blur(activeElement);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
// src/matchers.js
|
|
465
|
+
var customMatchers = {
|
|
466
|
+
/**
|
|
467
|
+
* Check if element has specific text
|
|
468
|
+
*/
|
|
469
|
+
toHaveText(received, expected) {
|
|
470
|
+
const pass = received && received.text === expected;
|
|
471
|
+
return {
|
|
472
|
+
pass,
|
|
473
|
+
message: () => pass ? `Expected element not to have text "${expected}"` : `Expected element to have text "${expected}", but got "${received?.text || "null"}"`
|
|
474
|
+
};
|
|
475
|
+
},
|
|
476
|
+
/**
|
|
477
|
+
* Check if element contains text
|
|
478
|
+
*/
|
|
479
|
+
toContainText(received, expected) {
|
|
480
|
+
const pass = received && received.text && received.text.includes(expected);
|
|
481
|
+
return {
|
|
482
|
+
pass,
|
|
483
|
+
message: () => pass ? `Expected element not to contain text "${expected}"` : `Expected element to contain text "${expected}", but got "${received?.text || "null"}"`
|
|
484
|
+
};
|
|
485
|
+
},
|
|
486
|
+
/**
|
|
487
|
+
* Check if element has specific class
|
|
488
|
+
*/
|
|
489
|
+
toHaveClass(received, expected) {
|
|
490
|
+
const pass = received && received.className && received.className.includes(expected);
|
|
491
|
+
return {
|
|
492
|
+
pass,
|
|
493
|
+
message: () => pass ? `Expected element not to have class "${expected}"` : `Expected element to have class "${expected}", but got "${received?.className || "null"}"`
|
|
494
|
+
};
|
|
495
|
+
},
|
|
496
|
+
/**
|
|
497
|
+
* Check if element exists
|
|
498
|
+
*/
|
|
499
|
+
toBeInTheDocument(received) {
|
|
500
|
+
const pass = received && received.exists === true;
|
|
501
|
+
return {
|
|
502
|
+
pass,
|
|
503
|
+
message: () => pass ? "Expected element not to be in the document" : "Expected element to be in the document"
|
|
504
|
+
};
|
|
505
|
+
},
|
|
506
|
+
/**
|
|
507
|
+
* Check if element is visible (has content)
|
|
508
|
+
*/
|
|
509
|
+
toBeVisible(received) {
|
|
510
|
+
const pass = received && received.text && received.text.trim().length > 0;
|
|
511
|
+
return {
|
|
512
|
+
pass,
|
|
513
|
+
message: () => pass ? "Expected element not to be visible" : "Expected element to be visible (have text content)"
|
|
514
|
+
};
|
|
515
|
+
},
|
|
516
|
+
/**
|
|
517
|
+
* Check if element is empty
|
|
518
|
+
*/
|
|
519
|
+
toBeEmpty(received) {
|
|
520
|
+
const pass = !received || !received.text || received.text.trim().length === 0;
|
|
521
|
+
return {
|
|
522
|
+
pass,
|
|
523
|
+
message: () => pass ? "Expected element not to be empty" : "Expected element to be empty"
|
|
524
|
+
};
|
|
525
|
+
},
|
|
526
|
+
/**
|
|
527
|
+
* Check if HTML contains specific string
|
|
528
|
+
*/
|
|
529
|
+
toContainHTML(received, expected) {
|
|
530
|
+
const html = received?.html || received;
|
|
531
|
+
const pass = typeof html === "string" && html.includes(expected);
|
|
532
|
+
return {
|
|
533
|
+
pass,
|
|
534
|
+
message: () => pass ? `Expected HTML not to contain "${expected}"` : `Expected HTML to contain "${expected}"`
|
|
535
|
+
};
|
|
536
|
+
},
|
|
537
|
+
/**
|
|
538
|
+
* Check if element has attribute
|
|
539
|
+
*/
|
|
540
|
+
toHaveAttribute(received, attribute, value) {
|
|
541
|
+
const html = received?.html || "";
|
|
542
|
+
const regex = new RegExp(`${attribute}="([^"]*)"`, "i");
|
|
543
|
+
const match = html.match(regex);
|
|
544
|
+
const pass = value !== void 0 ? match && match[1] === value : match !== null;
|
|
545
|
+
return {
|
|
546
|
+
pass,
|
|
547
|
+
message: () => {
|
|
548
|
+
if (value !== void 0) {
|
|
549
|
+
return pass ? `Expected element not to have attribute ${attribute}="${value}"` : `Expected element to have attribute ${attribute}="${value}", but got "${match?.[1] || "none"}"`;
|
|
550
|
+
}
|
|
551
|
+
return pass ? `Expected element not to have attribute ${attribute}` : `Expected element to have attribute ${attribute}`;
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
},
|
|
555
|
+
/**
|
|
556
|
+
* Check if component matches snapshot
|
|
557
|
+
*/
|
|
558
|
+
toMatchSnapshot(received) {
|
|
559
|
+
const _snapshot = received?.toSnapshot ? received.toSnapshot() : received;
|
|
560
|
+
return {
|
|
561
|
+
pass: true,
|
|
562
|
+
message: () => "Snapshot comparison"
|
|
563
|
+
};
|
|
564
|
+
},
|
|
565
|
+
/**
|
|
566
|
+
* Check if element has specific tag name
|
|
567
|
+
*/
|
|
568
|
+
toHaveTagName(received, tagName) {
|
|
569
|
+
const html = received?.html || "";
|
|
570
|
+
const regex = new RegExp(`<${tagName}[^>]*>`, "i");
|
|
571
|
+
const pass = regex.test(html);
|
|
572
|
+
return {
|
|
573
|
+
pass,
|
|
574
|
+
message: () => pass ? `Expected element not to have tag name "${tagName}"` : `Expected element to have tag name "${tagName}"`
|
|
575
|
+
};
|
|
576
|
+
},
|
|
577
|
+
/**
|
|
578
|
+
* Check if render result contains element
|
|
579
|
+
*/
|
|
580
|
+
toContainElement(received, element) {
|
|
581
|
+
const html = received?.html || received;
|
|
582
|
+
const elementHtml = element?.html || element;
|
|
583
|
+
const pass = typeof html === "string" && html.includes(elementHtml);
|
|
584
|
+
return {
|
|
585
|
+
pass,
|
|
586
|
+
message: () => pass ? "Expected not to contain element" : "Expected to contain element"
|
|
587
|
+
};
|
|
588
|
+
},
|
|
589
|
+
/**
|
|
590
|
+
* Check if mock was called
|
|
591
|
+
*/
|
|
592
|
+
toHaveBeenCalled(received) {
|
|
593
|
+
const pass = received?.mock?.calls?.length > 0;
|
|
594
|
+
return {
|
|
595
|
+
pass,
|
|
596
|
+
message: () => pass ? "Expected mock not to have been called" : "Expected mock to have been called"
|
|
597
|
+
};
|
|
598
|
+
},
|
|
599
|
+
/**
|
|
600
|
+
* Check if mock was called with specific args
|
|
601
|
+
*/
|
|
602
|
+
toHaveBeenCalledWith(received, ...expectedArgs) {
|
|
603
|
+
const calls = received?.mock?.calls || [];
|
|
604
|
+
const pass = calls.some(
|
|
605
|
+
(call) => call.length === expectedArgs.length && call.every((arg, i) => arg === expectedArgs[i])
|
|
606
|
+
);
|
|
607
|
+
return {
|
|
608
|
+
pass,
|
|
609
|
+
message: () => pass ? `Expected mock not to have been called with ${JSON.stringify(expectedArgs)}` : `Expected mock to have been called with ${JSON.stringify(expectedArgs)}`
|
|
610
|
+
};
|
|
611
|
+
},
|
|
612
|
+
/**
|
|
613
|
+
* Check if mock was called N times
|
|
614
|
+
*/
|
|
615
|
+
toHaveBeenCalledTimes(received, times) {
|
|
616
|
+
const callCount = received?.mock?.calls?.length || 0;
|
|
617
|
+
const pass = callCount === times;
|
|
618
|
+
return {
|
|
619
|
+
pass,
|
|
620
|
+
message: () => pass ? `Expected mock not to have been called ${times} times` : `Expected mock to have been called ${times} times, but was called ${callCount} times`
|
|
621
|
+
};
|
|
622
|
+
},
|
|
623
|
+
/**
|
|
624
|
+
* Check if component rendered successfully
|
|
625
|
+
*/
|
|
626
|
+
toRenderSuccessfully(received) {
|
|
627
|
+
const pass = received && received.html && received.html.length > 0;
|
|
628
|
+
return {
|
|
629
|
+
pass,
|
|
630
|
+
message: () => pass ? "Expected component not to render successfully" : "Expected component to render successfully"
|
|
631
|
+
};
|
|
632
|
+
},
|
|
633
|
+
/**
|
|
634
|
+
* Check if HTML is valid
|
|
635
|
+
*/
|
|
636
|
+
toBeValidHTML(received) {
|
|
637
|
+
const html = received?.html || received;
|
|
638
|
+
const openTags = (html.match(/<[^/][^>]*>/g) || []).length;
|
|
639
|
+
const closeTags = (html.match(/<\/[^>]+>/g) || []).length;
|
|
640
|
+
const selfClosing = (html.match(/<[^>]+\/>/g) || []).length;
|
|
641
|
+
const pass = openTags === closeTags + selfClosing;
|
|
642
|
+
return {
|
|
643
|
+
pass,
|
|
644
|
+
message: () => pass ? "Expected HTML not to be valid" : `Expected HTML to be valid (open: ${openTags}, close: ${closeTags}, self-closing: ${selfClosing})`
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
function extendExpect(expect) {
|
|
649
|
+
if (expect && expect.extend) {
|
|
650
|
+
expect.extend(customMatchers);
|
|
651
|
+
} else {
|
|
652
|
+
console.warn("Could not extend expect - expect.extend not available");
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
var assertions = {
|
|
656
|
+
/**
|
|
657
|
+
* Assert element has text
|
|
658
|
+
*/
|
|
659
|
+
assertHasText(element, text) {
|
|
660
|
+
if (!element || element.text !== text) {
|
|
661
|
+
throw new Error(`Expected element to have text "${text}", but got "${element?.text || "null"}"`);
|
|
662
|
+
}
|
|
663
|
+
},
|
|
664
|
+
/**
|
|
665
|
+
* Assert element exists
|
|
666
|
+
*/
|
|
667
|
+
assertExists(element) {
|
|
668
|
+
if (!element || !element.exists) {
|
|
669
|
+
throw new Error("Expected element to exist");
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
/**
|
|
673
|
+
* Assert element has class
|
|
674
|
+
*/
|
|
675
|
+
assertHasClass(element, className) {
|
|
676
|
+
if (!element || !element.className || !element.className.includes(className)) {
|
|
677
|
+
throw new Error(`Expected element to have class "${className}"`);
|
|
678
|
+
}
|
|
679
|
+
},
|
|
680
|
+
/**
|
|
681
|
+
* Assert HTML contains string
|
|
682
|
+
*/
|
|
683
|
+
assertContainsHTML(html, substring) {
|
|
684
|
+
const htmlString = html?.html || html;
|
|
685
|
+
if (!htmlString || !htmlString.includes(substring)) {
|
|
686
|
+
throw new Error(`Expected HTML to contain "${substring}"`);
|
|
687
|
+
}
|
|
688
|
+
},
|
|
689
|
+
/**
|
|
690
|
+
* Assert component rendered
|
|
691
|
+
*/
|
|
692
|
+
assertRendered(result) {
|
|
693
|
+
if (!result || !result.html || result.html.length === 0) {
|
|
694
|
+
throw new Error("Expected component to render");
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
// src/index.js
|
|
700
|
+
var index_default = {
|
|
701
|
+
// Renderer
|
|
702
|
+
renderComponent,
|
|
703
|
+
renderComponentAsync,
|
|
704
|
+
createTestRenderer,
|
|
705
|
+
shallowRender,
|
|
706
|
+
// Utilities
|
|
707
|
+
fireEvent,
|
|
708
|
+
waitFor,
|
|
709
|
+
waitForElement,
|
|
710
|
+
waitForElementToBeRemoved,
|
|
711
|
+
act,
|
|
712
|
+
createMock,
|
|
713
|
+
createSpy,
|
|
714
|
+
cleanup,
|
|
715
|
+
within,
|
|
716
|
+
screen,
|
|
717
|
+
userEvent,
|
|
718
|
+
// Matchers
|
|
719
|
+
customMatchers,
|
|
720
|
+
extendExpect,
|
|
721
|
+
assertions
|
|
722
|
+
};
|
|
723
|
+
export {
|
|
724
|
+
TestRenderer,
|
|
725
|
+
TestRendererResult,
|
|
726
|
+
act,
|
|
727
|
+
assertions,
|
|
728
|
+
cleanup,
|
|
729
|
+
createMock,
|
|
730
|
+
createSpy,
|
|
731
|
+
createTestRenderer,
|
|
732
|
+
customMatchers,
|
|
733
|
+
index_default as default,
|
|
734
|
+
extendExpect,
|
|
735
|
+
fireEvent,
|
|
736
|
+
renderComponent,
|
|
737
|
+
renderComponentAsync,
|
|
738
|
+
screen,
|
|
739
|
+
shallowRender,
|
|
740
|
+
userEvent,
|
|
741
|
+
waitFor,
|
|
742
|
+
waitForElement,
|
|
743
|
+
waitForElementToBeRemoved,
|
|
744
|
+
within
|
|
745
|
+
};
|
|
746
|
+
//# sourceMappingURL=index.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@coherent.js/testing",
|
|
3
|
+
"version": "1.0.0-beta.2",
|
|
4
|
+
"description": "Testing utilities for Coherent.js applications",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js",
|
|
9
|
+
"./renderer": "./dist/test-renderer.js",
|
|
10
|
+
"./utils": "./dist/test-utils.js",
|
|
11
|
+
"./matchers": "./dist/matchers.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"coherent",
|
|
15
|
+
"testing",
|
|
16
|
+
"test-utils",
|
|
17
|
+
"component-testing"
|
|
18
|
+
],
|
|
19
|
+
"author": "Coherent.js Team",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@coherent.js/core": "1.0.0-beta.2"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"vitest": "^1.0.0"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/Tomdrouv1/coherent.js.git"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"types": "./types/index.d.ts",
|
|
35
|
+
"files": [
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"README.md",
|
|
38
|
+
"types/"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "node build.mjs",
|
|
42
|
+
"clean": "rm -rf dist",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"test:watch": "vitest"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coherent.js Testing Utilities TypeScript Definitions
|
|
3
|
+
* @module @coherent.js/testing
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ===== Test Renderer Types =====
|
|
7
|
+
|
|
8
|
+
export interface RenderOptions {
|
|
9
|
+
wrapper?: any;
|
|
10
|
+
context?: Record<string, any>;
|
|
11
|
+
props?: Record<string, any>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TestRendererResult {
|
|
15
|
+
html: string;
|
|
16
|
+
component: any;
|
|
17
|
+
container: HTMLElement | null;
|
|
18
|
+
rerender(component: any): void;
|
|
19
|
+
unmount(): void;
|
|
20
|
+
debug(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class TestRenderer {
|
|
24
|
+
render(component: any, options?: RenderOptions): TestRendererResult;
|
|
25
|
+
renderAsync(component: any, options?: RenderOptions): Promise<TestRendererResult>;
|
|
26
|
+
shallow(component: any): TestRendererResult;
|
|
27
|
+
cleanup(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function renderComponent(component: any, options?: RenderOptions): TestRendererResult;
|
|
31
|
+
export function renderComponentAsync(component: any, options?: RenderOptions): Promise<TestRendererResult>;
|
|
32
|
+
export function createTestRenderer(): TestRenderer;
|
|
33
|
+
export function shallowRender(component: any): TestRendererResult;
|
|
34
|
+
|
|
35
|
+
// ===== Test Utilities Types =====
|
|
36
|
+
|
|
37
|
+
export interface EventOptions {
|
|
38
|
+
bubbles?: boolean;
|
|
39
|
+
cancelable?: boolean;
|
|
40
|
+
composed?: boolean;
|
|
41
|
+
[key: string]: any;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const fireEvent: {
|
|
45
|
+
(element: Element, event: Event): boolean;
|
|
46
|
+
click(element: Element, options?: EventOptions): boolean;
|
|
47
|
+
change(element: Element, options?: EventOptions & { target?: { value?: any } }): boolean;
|
|
48
|
+
input(element: Element, options?: EventOptions & { target?: { value?: any } }): boolean;
|
|
49
|
+
submit(element: Element, options?: EventOptions): boolean;
|
|
50
|
+
keyDown(element: Element, options?: EventOptions & { key?: string; code?: string }): boolean;
|
|
51
|
+
keyUp(element: Element, options?: EventOptions & { key?: string; code?: string }): boolean;
|
|
52
|
+
focus(element: Element, options?: EventOptions): boolean;
|
|
53
|
+
blur(element: Element, options?: EventOptions): boolean;
|
|
54
|
+
mouseEnter(element: Element, options?: EventOptions): boolean;
|
|
55
|
+
mouseLeave(element: Element, options?: EventOptions): boolean;
|
|
56
|
+
[key: string]: any;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export interface WaitOptions {
|
|
60
|
+
timeout?: number;
|
|
61
|
+
interval?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function waitFor<T>(callback: () => T | Promise<T>, options?: WaitOptions): Promise<T>;
|
|
65
|
+
export function waitForElement(selector: string, options?: WaitOptions): Promise<Element>;
|
|
66
|
+
export function waitForElementToBeRemoved(selector: string | Element, options?: WaitOptions): Promise<void>;
|
|
67
|
+
|
|
68
|
+
export function act<T>(callback: () => T | Promise<T>): Promise<T>;
|
|
69
|
+
|
|
70
|
+
export interface Mock<T extends (...args: any[]) => any = (...args: any[]) => any> {
|
|
71
|
+
(...args: Parameters<T>): ReturnType<T>;
|
|
72
|
+
mock: {
|
|
73
|
+
calls: Parameters<T>[];
|
|
74
|
+
results: Array<{ type: 'return' | 'throw'; value: any }>;
|
|
75
|
+
instances: any[];
|
|
76
|
+
};
|
|
77
|
+
mockClear(): void;
|
|
78
|
+
mockReset(): void;
|
|
79
|
+
mockRestore(): void;
|
|
80
|
+
mockImplementation(fn: T): this;
|
|
81
|
+
mockReturnValue(value: ReturnType<T>): this;
|
|
82
|
+
mockReturnValueOnce(value: ReturnType<T>): this;
|
|
83
|
+
mockResolvedValue(value: ReturnType<T> extends Promise<infer U> ? U : never): this;
|
|
84
|
+
mockRejectedValue(error: any): this;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createMock<T extends (...args: any[]) => any>(implementation?: T): Mock<T>;
|
|
88
|
+
export function createSpy<T extends (...args: any[]) => any>(object: any, method: string): Mock<T>;
|
|
89
|
+
|
|
90
|
+
export function cleanup(): void;
|
|
91
|
+
|
|
92
|
+
export interface Within {
|
|
93
|
+
getByText(text: string | RegExp): Element;
|
|
94
|
+
getByRole(role: string, options?: { name?: string | RegExp }): Element;
|
|
95
|
+
getByLabelText(text: string | RegExp): Element;
|
|
96
|
+
getByPlaceholderText(text: string | RegExp): Element;
|
|
97
|
+
getByTestId(testId: string): Element;
|
|
98
|
+
queryByText(text: string | RegExp): Element | null;
|
|
99
|
+
queryByRole(role: string, options?: { name?: string | RegExp }): Element | null;
|
|
100
|
+
queryAllByText(text: string | RegExp): Element[];
|
|
101
|
+
findByText(text: string | RegExp): Promise<Element>;
|
|
102
|
+
findAllByText(text: string | RegExp): Promise<Element[]>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function within(element: Element): Within;
|
|
106
|
+
export const screen: Within;
|
|
107
|
+
|
|
108
|
+
export const userEvent: {
|
|
109
|
+
click(element: Element): Promise<void>;
|
|
110
|
+
dblClick(element: Element): Promise<void>;
|
|
111
|
+
type(element: Element, text: string, options?: { delay?: number }): Promise<void>;
|
|
112
|
+
clear(element: Element): Promise<void>;
|
|
113
|
+
selectOptions(element: Element, values: string | string[]): Promise<void>;
|
|
114
|
+
tab(options?: { shift?: boolean }): Promise<void>;
|
|
115
|
+
hover(element: Element): Promise<void>;
|
|
116
|
+
unhover(element: Element): Promise<void>;
|
|
117
|
+
upload(element: Element, files: File | File[]): Promise<void>;
|
|
118
|
+
paste(element: Element, text: string): Promise<void>;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ===== Matchers Types =====
|
|
122
|
+
|
|
123
|
+
export interface CustomMatchers<R = void> {
|
|
124
|
+
toHaveHTML(html: string): R;
|
|
125
|
+
toContainHTML(html: string): R;
|
|
126
|
+
toHaveTextContent(text: string | RegExp): R;
|
|
127
|
+
toHaveAttribute(attr: string, value?: string): R;
|
|
128
|
+
toHaveClass(className: string): R;
|
|
129
|
+
toBeInTheDocument(): R;
|
|
130
|
+
toBeVisible(): R;
|
|
131
|
+
toBeDisabled(): R;
|
|
132
|
+
toBeEnabled(): R;
|
|
133
|
+
toHaveValue(value: any): R;
|
|
134
|
+
toHaveStyle(style: Record<string, any>): R;
|
|
135
|
+
toHaveFocus(): R;
|
|
136
|
+
toBeChecked(): R;
|
|
137
|
+
toBeValid(): R;
|
|
138
|
+
toBeInvalid(): R;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const customMatchers: CustomMatchers;
|
|
142
|
+
|
|
143
|
+
export function extendExpect(matchers: Record<string, (...args: any[]) => any>): void;
|
|
144
|
+
|
|
145
|
+
export const assertions: {
|
|
146
|
+
assertElement(element: any): asserts element is Element;
|
|
147
|
+
assertHTMLElement(element: any): asserts element is HTMLElement;
|
|
148
|
+
assertInDocument(element: Element | null): asserts element is Element;
|
|
149
|
+
assertVisible(element: Element): void;
|
|
150
|
+
assertHasAttribute(element: Element, attr: string): void;
|
|
151
|
+
assertHasClass(element: Element, className: string): void;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Extend Jest/Vitest expect
|
|
155
|
+
declare global {
|
|
156
|
+
namespace Vi {
|
|
157
|
+
interface Matchers<R = void> extends CustomMatchers<R> {}
|
|
158
|
+
interface AsymmetricMatchers extends CustomMatchers {}
|
|
159
|
+
}
|
|
160
|
+
namespace jest {
|
|
161
|
+
interface Matchers<R = void> extends CustomMatchers<R> {}
|
|
162
|
+
interface Expect extends CustomMatchers {}
|
|
163
|
+
}
|
|
164
|
+
}
|