@stencil/vitest 1.8.3 → 1.9.1
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/README.md +237 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -1
- package/dist/core.d.ts +2 -0
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +1 -0
- package/dist/globals.d.ts +10 -0
- package/dist/setup/config-loader.d.ts +7 -1
- package/dist/setup/config-loader.d.ts.map +1 -1
- package/dist/setup/config-loader.js +20 -0
- package/dist/testing/render.d.ts +5 -0
- package/dist/testing/render.d.ts.map +1 -1
- package/dist/testing/render.js +79 -4
- package/dist/testing/spy-helper.d.ts +123 -0
- package/dist/testing/spy-helper.d.ts.map +1 -0
- package/dist/testing/spy-helper.js +258 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -155,6 +155,18 @@ await setProps({ name: 'Stencil' });
|
|
|
155
155
|
unmount();
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
+
#### `waitForReady` Option
|
|
159
|
+
|
|
160
|
+
By default, `render()` waits for components to be fully hydrated before returning. It detects when Stencil applies the hydrated flag (class or attribute) to your component, respecting your `stencil.config` settings.
|
|
161
|
+
|
|
162
|
+
```tsx
|
|
163
|
+
// Default behaviour - waits for hydration
|
|
164
|
+
const { root } = await render(<my-component />);
|
|
165
|
+
|
|
166
|
+
// Skip hydration wait (useful for testing loading states)
|
|
167
|
+
const { root } = await render(<my-component />, { waitForReady: false });
|
|
168
|
+
```
|
|
169
|
+
|
|
158
170
|
### Available matchers:
|
|
159
171
|
|
|
160
172
|
```typescript
|
|
@@ -177,6 +189,231 @@ await expect(element).toEqualHtml('<div>Expected HTML</div>');
|
|
|
177
189
|
await expect(element).toEqualLightHtml('<div>Light DOM only</div>');
|
|
178
190
|
```
|
|
179
191
|
|
|
192
|
+
### Spying and Mocking
|
|
193
|
+
|
|
194
|
+
Spy on component methods, props, and lifecycle hooks to verify behaviour without modifying your component code.
|
|
195
|
+
|
|
196
|
+
> **Setup requirement:** Load your components in a `beforeAll` block (typically in your setup file). The spy system patches `customElements.define`, so components must be registered after the test framework initializes.
|
|
197
|
+
>
|
|
198
|
+
> ```diff
|
|
199
|
+
> // vitest-setup.ts
|
|
200
|
+
> - await import('./dist/test-components/test-components.esm.js');
|
|
201
|
+
>
|
|
202
|
+
> + import { beforeAll } from 'vitest';
|
|
203
|
+
> + beforeAll(async () => {
|
|
204
|
+
> + await import('./dist/test-components/test-components.esm.js');
|
|
205
|
+
> + });
|
|
206
|
+
> ```
|
|
207
|
+
|
|
208
|
+
#### Method Spying
|
|
209
|
+
|
|
210
|
+
Spy on methods while still calling the original implementation:
|
|
211
|
+
|
|
212
|
+
```tsx
|
|
213
|
+
const { root, spies } = await render(<my-button>Click me</my-button>, {
|
|
214
|
+
spyOn: {
|
|
215
|
+
methods: ['handleClick'],
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Trigger the method
|
|
220
|
+
root.shadowRoot?.querySelector('button')?.click();
|
|
221
|
+
|
|
222
|
+
// Assert the method was called
|
|
223
|
+
expect(spies?.methods.handleClick).toHaveBeenCalledTimes(1);
|
|
224
|
+
expect(spies?.methods.handleClick).toHaveBeenCalledWith(expect.objectContaining({ type: 'click' }));
|
|
225
|
+
|
|
226
|
+
// Reset call history
|
|
227
|
+
spies?.methods.handleClick.mockClear();
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
#### Method Mocking
|
|
231
|
+
|
|
232
|
+
Replace methods with pre-configured mocks:
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
// Create mock with desired return value *before* render
|
|
236
|
+
const fetchUserMock = vi.fn().mockResolvedValue({
|
|
237
|
+
id: '123',
|
|
238
|
+
name: 'Test User',
|
|
239
|
+
email: 'test@example.com',
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Mock is applied before initialisation
|
|
243
|
+
const { root, spies, waitForChanges } = await render(<user-profile userId="123" />, {
|
|
244
|
+
spyOn: {
|
|
245
|
+
mocks: { fetchUserData: fetchUserMock },
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
await waitForChanges();
|
|
249
|
+
|
|
250
|
+
expect(fetchUserMock).toHaveBeenCalledWith('123');
|
|
251
|
+
expect(root.shadowRoot?.querySelector('.name')?.textContent).toBe('Test User');
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Access the original implementation to augment rather than fully replace:
|
|
255
|
+
|
|
256
|
+
```tsx
|
|
257
|
+
const fetchMock = vi.fn();
|
|
258
|
+
const { spies } = await render(<my-component />, {
|
|
259
|
+
spyOn: { mocks: { fetchData: fetchMock } },
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// Wrap the original to add logging or modify behaviour
|
|
263
|
+
fetchMock.mockImplementation(async (...args) => {
|
|
264
|
+
console.log('Fetching data with args:', args);
|
|
265
|
+
const result = await spies?.mocks.fetchData.original?.(...args);
|
|
266
|
+
console.log('Got result:', result);
|
|
267
|
+
return result;
|
|
268
|
+
});
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
#### Prop Spying
|
|
272
|
+
|
|
273
|
+
Track when props are changed:
|
|
274
|
+
|
|
275
|
+
```tsx
|
|
276
|
+
const { spies, setProps, waitForChanges } = await render(<my-button variant="primary">Click me</my-button>, {
|
|
277
|
+
spyOn: {
|
|
278
|
+
props: ['variant', 'disabled'],
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await setProps({ variant: 'danger' });
|
|
283
|
+
await waitForChanges();
|
|
284
|
+
|
|
285
|
+
expect(spies?.props.variant).toHaveBeenCalledWith('danger');
|
|
286
|
+
expect(spies?.props.variant).toHaveBeenCalledTimes(1);
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
#### Lifecycle Spying
|
|
290
|
+
|
|
291
|
+
Spy on lifecycle methods. Methods that don't exist on the component are auto-stubbed:
|
|
292
|
+
|
|
293
|
+
```tsx
|
|
294
|
+
const { spies, setProps, waitForChanges } = await render(<my-button>Click me</my-button>, {
|
|
295
|
+
spyOn: {
|
|
296
|
+
lifecycle: ['componentWillLoad', 'componentDidLoad', 'componentWillRender', 'componentDidRender'],
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Lifecycle methods are called during initial render
|
|
301
|
+
expect(spies?.lifecycle.componentWillLoad).toHaveBeenCalledTimes(1);
|
|
302
|
+
expect(spies?.lifecycle.componentDidRender).toHaveBeenCalledTimes(1);
|
|
303
|
+
|
|
304
|
+
// Trigger a re-render
|
|
305
|
+
await setProps({ variant: 'danger' });
|
|
306
|
+
await waitForChanges();
|
|
307
|
+
|
|
308
|
+
// Re-render lifecycle methods called again
|
|
309
|
+
expect(spies?.lifecycle.componentWillRender).toHaveBeenCalledTimes(2);
|
|
310
|
+
expect(spies?.lifecycle.componentDidRender).toHaveBeenCalledTimes(2);
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
#### Resetting Spies
|
|
314
|
+
|
|
315
|
+
Reset all spies at once using `resetAll()`. This clears call histories AND resets mock implementations:
|
|
316
|
+
|
|
317
|
+
```tsx
|
|
318
|
+
const fetchMock = vi.fn().mockReturnValue('mocked');
|
|
319
|
+
const { root, spies, setProps, waitForChanges } = await render(<my-button variant="primary">Click me</my-button>, {
|
|
320
|
+
spyOn: {
|
|
321
|
+
methods: ['handleClick'],
|
|
322
|
+
mocks: { fetchData: fetchMock },
|
|
323
|
+
props: ['variant'],
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Trigger some calls
|
|
328
|
+
root.shadowRoot?.querySelector('button')?.click();
|
|
329
|
+
await setProps({ variant: 'danger' });
|
|
330
|
+
|
|
331
|
+
// Reset everything
|
|
332
|
+
spies?.resetAll();
|
|
333
|
+
|
|
334
|
+
// Call histories cleared
|
|
335
|
+
expect(spies?.methods.handleClick).toHaveBeenCalledTimes(0);
|
|
336
|
+
expect(spies?.props.variant).toHaveBeenCalledTimes(0);
|
|
337
|
+
|
|
338
|
+
// Mock implementations reset to default (returns undefined)
|
|
339
|
+
expect(fetchMock()).toBeUndefined();
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
#### Nested Components
|
|
343
|
+
|
|
344
|
+
When the root element is not a custom element, or when you have multiple custom elements, use `getComponentSpies()` to retrieve spies for specific elements:
|
|
345
|
+
|
|
346
|
+
```tsx
|
|
347
|
+
import { render, getComponentSpies, h } from '@stencil/vitest';
|
|
348
|
+
|
|
349
|
+
// Root is a div, not a custom element
|
|
350
|
+
const { root } = await render(
|
|
351
|
+
<div>
|
|
352
|
+
<my-button>Click me</my-button>
|
|
353
|
+
</div>,
|
|
354
|
+
{
|
|
355
|
+
spyOn: { methods: ['handleClick'] },
|
|
356
|
+
},
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// Query the nested custom element
|
|
360
|
+
const button = root.querySelector('my-button') as HTMLElement;
|
|
361
|
+
|
|
362
|
+
// Get spies for the nested element
|
|
363
|
+
const buttonSpies = getComponentSpies(button);
|
|
364
|
+
expect(buttonSpies?.methods.handleClick).toBeDefined();
|
|
365
|
+
|
|
366
|
+
// Multiple instances have independent spies
|
|
367
|
+
const { root: container } = await render(
|
|
368
|
+
<div>
|
|
369
|
+
<my-button class="a">A</my-button>
|
|
370
|
+
<my-button class="b">B</my-button>
|
|
371
|
+
</div>,
|
|
372
|
+
{ spyOn: { methods: ['handleClick'] } },
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
const spiesA = getComponentSpies(container.querySelector('.a') as HTMLElement);
|
|
376
|
+
const spiesB = getComponentSpies(container.querySelector('.b') as HTMLElement);
|
|
377
|
+
|
|
378
|
+
// Each has its own spy instance
|
|
379
|
+
container.querySelector('.a')?.shadowRoot?.querySelector('button')?.click();
|
|
380
|
+
expect(spiesA?.methods.handleClick).toHaveBeenCalledTimes(1);
|
|
381
|
+
expect(spiesB?.methods.handleClick).toHaveBeenCalledTimes(0);
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
#### Per-Component Configurations
|
|
385
|
+
|
|
386
|
+
When rendering multiple component types, use the `components` property for tag-specific spy configs:
|
|
387
|
+
|
|
388
|
+
```tsx
|
|
389
|
+
import { render, getComponentSpies, h } from '@stencil/vitest';
|
|
390
|
+
|
|
391
|
+
const { root } = await render(
|
|
392
|
+
<my-card cardTitle="Test">
|
|
393
|
+
<my-button slot="footer">Click me</my-button>
|
|
394
|
+
</my-card>,
|
|
395
|
+
{
|
|
396
|
+
spyOn: {
|
|
397
|
+
lifecycle: ['componentDidLoad'], // base - applies to all
|
|
398
|
+
components: {
|
|
399
|
+
'my-card': { props: ['cardTitle'] },
|
|
400
|
+
'my-button': { methods: ['handleClick'] },
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const cardSpies = getComponentSpies(root);
|
|
407
|
+
const buttonSpies = getComponentSpies(root.querySelector('my-button') as HTMLElement);
|
|
408
|
+
|
|
409
|
+
// Both get base lifecycle spy + their specific config
|
|
410
|
+
expect(cardSpies?.lifecycle.componentDidLoad).toHaveBeenCalled();
|
|
411
|
+
expect(cardSpies?.props.cardTitle).toBeDefined();
|
|
412
|
+
|
|
413
|
+
expect(buttonSpies?.lifecycle.componentDidLoad).toHaveBeenCalled();
|
|
414
|
+
expect(buttonSpies?.methods.handleClick).toBeDefined();
|
|
415
|
+
```
|
|
416
|
+
|
|
180
417
|
### Event Testing
|
|
181
418
|
|
|
182
419
|
Test custom events emitted by your components:
|
package/dist/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AACA,OAAO,EAAgB,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AACA,OAAO,EAAgB,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAQlE,OAAO,KAAK,EAAE,MAAM,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAMtE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkEG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,GAAE,cAAc,GAAG;IAAE,aAAa,CAAC,EAAE,MAAM,GAAG,aAAa,CAAA;CAAO,GACvE,OAAO,CAAC,cAAc,CAAC,CAqBzB"}
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url';
|
|
2
2
|
import { defineConfig } from 'vitest/config';
|
|
3
|
-
import { loadStencilConfig, getStencilSrcDir, getStencilOutputDirs, getStencilResolveAliases, } from './setup/config-loader.js';
|
|
3
|
+
import { loadStencilConfig, getStencilSrcDir, getStencilOutputDirs, getStencilResolveAliases, getStencilHydratedFlag, } from './setup/config-loader.js';
|
|
4
4
|
// Resolve the path to the stencil environment module at config load time
|
|
5
5
|
// This is necessary for pnpm which doesn't hoist transitive dependencies
|
|
6
6
|
const stencilEnvironmentPath = fileURLToPath(import.meta.resolve('@stencil/vitest/environments/stencil'));
|
|
@@ -130,6 +130,7 @@ function applyStencilDefaults(config, stencilConfig) {
|
|
|
130
130
|
__STENCIL_PROD__: JSON.stringify(process.env.STENCIL_PROD === 'true'),
|
|
131
131
|
__STENCIL_SERVE__: JSON.stringify(process.env.STENCIL_SERVE === 'true'),
|
|
132
132
|
__STENCIL_PORT__: JSON.stringify(process.env.STENCIL_PORT || ''),
|
|
133
|
+
__STENCIL_HYDRATED_FLAG__: JSON.stringify(getStencilHydratedFlag(stencilConfig)),
|
|
133
134
|
};
|
|
134
135
|
if (!result.define) {
|
|
135
136
|
result.define = stencilEnvDefines;
|
|
@@ -342,6 +343,7 @@ function enhanceProject(project, stencilConfig) {
|
|
|
342
343
|
__STENCIL_PROD__: JSON.stringify(process.env.STENCIL_PROD === 'true'),
|
|
343
344
|
__STENCIL_SERVE__: JSON.stringify(process.env.STENCIL_SERVE === 'true'),
|
|
344
345
|
__STENCIL_PORT__: JSON.stringify(process.env.STENCIL_PORT || ''),
|
|
346
|
+
__STENCIL_HYDRATED_FLAG__: JSON.stringify(getStencilHydratedFlag(stencilConfig)),
|
|
345
347
|
};
|
|
346
348
|
if (!enhanced.define) {
|
|
347
349
|
enhanced.define = stencilEnvDefines;
|
package/dist/core.d.ts
CHANGED
|
@@ -4,4 +4,6 @@ export { h } from '@stencil/core';
|
|
|
4
4
|
export { render, waitForStable, waitForExist } from './testing/render.js';
|
|
5
5
|
export { serializeHtml, prettifyHtml, SerializeOptions } from './testing/html-serializer.js';
|
|
6
6
|
export type { RenderOptions, RenderResult } from './types.js';
|
|
7
|
+
export { getComponentSpies, clearComponentSpies } from './testing/spy-helper.js';
|
|
8
|
+
export type { SpyConfig, ComponentSpies } from './testing/spy-helper.js';
|
|
7
9
|
//# sourceMappingURL=core.d.ts.map
|
package/dist/core.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AACA,OAAO,uBAAuB,CAAC;AAC/B,OAAO,kCAAkC,CAAC;AAE1C,OAAO,EAAE,CAAC,EAAE,MAAM,eAAe,CAAC;AAClC,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAC1E,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAC7F,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AACA,OAAO,uBAAuB,CAAC;AAC/B,OAAO,kCAAkC,CAAC;AAE1C,OAAO,EAAE,CAAC,EAAE,MAAM,eAAe,CAAC;AAClC,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAC1E,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;AAC7F,YAAY,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC9D,OAAO,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAC;AACjF,YAAY,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC"}
|
package/dist/core.js
CHANGED
|
@@ -4,3 +4,4 @@ import './testing/snapshot-serializer.js';
|
|
|
4
4
|
export { h } from '@stencil/core';
|
|
5
5
|
export { render, waitForStable, waitForExist } from './testing/render.js';
|
|
6
6
|
export { serializeHtml, prettifyHtml } from './testing/html-serializer.js';
|
|
7
|
+
export { getComponentSpies, clearComponentSpies } from './testing/spy-helper.js';
|
package/dist/globals.d.ts
CHANGED
|
@@ -21,3 +21,13 @@
|
|
|
21
21
|
declare const __STENCIL_PROD__: boolean;
|
|
22
22
|
declare const __STENCIL_SERVE__: boolean;
|
|
23
23
|
declare const __STENCIL_PORT__: string;
|
|
24
|
+
|
|
25
|
+
interface StencilHydratedFlag {
|
|
26
|
+
name: string;
|
|
27
|
+
selector: 'class' | 'attribute';
|
|
28
|
+
property: string;
|
|
29
|
+
initialValue: string;
|
|
30
|
+
hydratedValue: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
declare const __STENCIL_HYDRATED_FLAG__: StencilHydratedFlag | null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Config as StencilConfig } from '@stencil/core/internal';
|
|
1
|
+
import type { Config as StencilConfig, HydratedFlag } from '@stencil/core/internal';
|
|
2
2
|
/**
|
|
3
3
|
* Load Stencil configuration from a file path
|
|
4
4
|
* Uses jiti to handle TypeScript files in Node.js
|
|
@@ -12,6 +12,12 @@ export declare function getStencilSrcDir(config?: StencilConfig): string;
|
|
|
12
12
|
* Get all output directories from Stencil config for exclusion
|
|
13
13
|
*/
|
|
14
14
|
export declare function getStencilOutputDirs(config?: StencilConfig): string[];
|
|
15
|
+
/**
|
|
16
|
+
* Get the hydrated flag configuration from Stencil config.
|
|
17
|
+
* Returns null if hydration indication is explicitly disabled.
|
|
18
|
+
* Returns defaults if not configured.
|
|
19
|
+
*/
|
|
20
|
+
export declare function getStencilHydratedFlag(config?: StencilConfig): HydratedFlag | null;
|
|
15
21
|
/**
|
|
16
22
|
* Create resolve aliases from Stencil config
|
|
17
23
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config-loader.d.ts","sourceRoot":"","sources":["../../src/setup/config-loader.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"config-loader.d.ts","sourceRoot":"","sources":["../../src/setup/config-loader.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,IAAI,aAAa,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAIpF;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC,CAsB9F;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,CAAC,EAAE,aAAa,GAAG,MAAM,CAE/D;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,aAAa,GAAG,MAAM,EAAE,CAyCrE;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,CAAC,EAAE,aAAa,GAAG,YAAY,GAAG,IAAI,CAclF;AAED;;GAEG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,CAAC,EAAE,aAAa,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAmBvF"}
|
|
@@ -70,6 +70,26 @@ export function getStencilOutputDirs(config) {
|
|
|
70
70
|
const validDirs = Array.from(outputDirs).filter((dir) => !dir.includes('..'));
|
|
71
71
|
return validDirs.length > 0 ? validDirs : ['dist', 'www', 'build', '.stencil'];
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Get the hydrated flag configuration from Stencil config.
|
|
75
|
+
* Returns null if hydration indication is explicitly disabled.
|
|
76
|
+
* Returns defaults if not configured.
|
|
77
|
+
*/
|
|
78
|
+
export function getStencilHydratedFlag(config) {
|
|
79
|
+
// If explicitly null, hydration indication is disabled
|
|
80
|
+
if (config?.hydratedFlag === null) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
// If undefined or object, return with defaults applied
|
|
84
|
+
const flag = config?.hydratedFlag;
|
|
85
|
+
return {
|
|
86
|
+
name: flag?.name ?? 'hydrated',
|
|
87
|
+
selector: flag?.selector ?? 'class',
|
|
88
|
+
property: flag?.property ?? 'visibility',
|
|
89
|
+
initialValue: flag?.initialValue ?? 'hidden',
|
|
90
|
+
hydratedValue: flag?.hydratedValue ?? 'inherit',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
73
93
|
/**
|
|
74
94
|
* Create resolve aliases from Stencil config
|
|
75
95
|
*/
|
package/dist/testing/render.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RenderResult } from '../types.js';
|
|
2
|
+
import { type SpyConfig } from './spy-helper.js';
|
|
2
3
|
interface RenderOptions {
|
|
3
4
|
/**
|
|
4
5
|
* Whether to clear existing stage containers before rendering. Defaults to true.
|
|
@@ -14,6 +15,10 @@ interface RenderOptions {
|
|
|
14
15
|
* Defaults to true.
|
|
15
16
|
*/
|
|
16
17
|
waitForReady?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Spy configuration for this render call. Spies on methods, props, and lifecycle hooks.
|
|
20
|
+
*/
|
|
21
|
+
spyOn?: SpyConfig;
|
|
17
22
|
}
|
|
18
23
|
/**
|
|
19
24
|
* Poll until element has dimensions (is rendered/visible in real browser).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/testing/render.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAY,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/testing/render.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAY,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAyC,KAAK,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAExF,UAAU,aAAa;IACrB;;OAEG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACpC;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;OAEG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;CACnB;AA+FD;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,iBAAiB,EAAE,OAAO,GAAG,MAAM,EAAE,OAAO,SAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAyCtG;AAED;;;;GAIG;AACH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,SAAO,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAY5F;AAED;;GAEG;AACH,wBAAsB,MAAM,CAAC,CAAC,SAAS,WAAW,GAAG,WAAW,EAAE,CAAC,GAAG,GAAG,EACvE,QAAQ,EAAE,GAAG,GAAG,MAAM,EACtB,OAAO,GAAE,aAGR,GACA,OAAO,CAAC,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAqK7B"}
|
package/dist/testing/render.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { render as stencilRender } from '@stencil/core';
|
|
2
|
+
import { setRenderSpyConfig, getComponentSpies } from './spy-helper.js';
|
|
2
3
|
// Track event spies
|
|
3
4
|
const eventSpies = new WeakMap();
|
|
4
5
|
/**
|
|
@@ -21,6 +22,66 @@ function isRealBrowser() {
|
|
|
21
22
|
}
|
|
22
23
|
return true;
|
|
23
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Get the hydrated flag config, with defaults when not configured.
|
|
27
|
+
* Returns null if hydration is explicitly disabled.
|
|
28
|
+
*/
|
|
29
|
+
function getHydratedFlag() {
|
|
30
|
+
// If global is defined, use it (could be null if explicitly disabled)
|
|
31
|
+
if (typeof __STENCIL_HYDRATED_FLAG__ !== 'undefined') {
|
|
32
|
+
return __STENCIL_HYDRATED_FLAG__;
|
|
33
|
+
}
|
|
34
|
+
// Default to 'hydrated' class when no config loaded
|
|
35
|
+
return { name: 'hydrated', selector: 'class' };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Find the first custom element (tag contains '-') in the tree.
|
|
39
|
+
* If the element itself is a custom element, returns it.
|
|
40
|
+
* Otherwise walks down to find the topmost custom element child.
|
|
41
|
+
*/
|
|
42
|
+
function findCustomElement(element) {
|
|
43
|
+
if (element.tagName.includes('-')) {
|
|
44
|
+
return element;
|
|
45
|
+
}
|
|
46
|
+
// Breadth-first search for first custom element
|
|
47
|
+
const queue = Array.from(element.children);
|
|
48
|
+
while (queue.length > 0) {
|
|
49
|
+
const child = queue.shift();
|
|
50
|
+
if (child.tagName.includes('-')) {
|
|
51
|
+
return child;
|
|
52
|
+
}
|
|
53
|
+
queue.push(...Array.from(child.children));
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Wait for element to be hydrated based on Stencil's hydrated flag config.
|
|
59
|
+
* Checks for the hydrated class or attribute as configured.
|
|
60
|
+
* Returns immediately if hydration is disabled (flag is null).
|
|
61
|
+
*/
|
|
62
|
+
async function waitForHydrated(element, timeout = 5000) {
|
|
63
|
+
const flag = getHydratedFlag();
|
|
64
|
+
// If hydration is disabled, skip this check
|
|
65
|
+
if (flag === null) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Find the custom element to check - hydrated flag is applied to the component, not wrappers
|
|
69
|
+
const customElement = findCustomElement(element);
|
|
70
|
+
if (!customElement) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const start = Date.now();
|
|
74
|
+
while (Date.now() - start < timeout) {
|
|
75
|
+
const isHydrated = flag.selector === 'attribute'
|
|
76
|
+
? customElement.hasAttribute(flag.name)
|
|
77
|
+
: customElement.classList.contains(flag.name);
|
|
78
|
+
if (isHydrated) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
82
|
+
}
|
|
83
|
+
// Don't throw - component might not use hydration or might be ready without flag
|
|
84
|
+
}
|
|
24
85
|
/**
|
|
25
86
|
* Poll until element has dimensions (is rendered/visible in real browser).
|
|
26
87
|
* Accepts either an Element or a CSS selector string.
|
|
@@ -94,6 +155,10 @@ export async function render(template, options = {
|
|
|
94
155
|
existingStages.forEach((stage) => stage.remove());
|
|
95
156
|
}
|
|
96
157
|
document.body.appendChild(container);
|
|
158
|
+
// Set per-render spy config before element creation
|
|
159
|
+
if (options.spyOn) {
|
|
160
|
+
setRenderSpyConfig(options.spyOn);
|
|
161
|
+
}
|
|
97
162
|
if (typeof template === 'string') {
|
|
98
163
|
// Handle string template - add as innerHTML
|
|
99
164
|
container.innerHTML = template;
|
|
@@ -107,10 +172,19 @@ export async function render(template, options = {
|
|
|
107
172
|
if (!element) {
|
|
108
173
|
throw new Error('Failed to render component');
|
|
109
174
|
}
|
|
175
|
+
// Wait for custom element to be defined
|
|
176
|
+
const tagName = element.tagName.toLowerCase();
|
|
177
|
+
if (tagName.includes('-')) {
|
|
178
|
+
await customElements.whenDefined(tagName);
|
|
179
|
+
}
|
|
110
180
|
// Wait for component to be ready
|
|
111
181
|
if (typeof element.componentOnReady === 'function') {
|
|
112
182
|
await element.componentOnReady();
|
|
113
183
|
}
|
|
184
|
+
// Clear per-render spy config after component is ready
|
|
185
|
+
if (options.spyOn) {
|
|
186
|
+
setRenderSpyConfig(null);
|
|
187
|
+
}
|
|
114
188
|
// Define waitForChanges first so we can use it in the ready check
|
|
115
189
|
function waitForChanges(documentElement = element) {
|
|
116
190
|
return new Promise((resolve) => {
|
|
@@ -145,10 +219,8 @@ export async function render(template, options = {
|
|
|
145
219
|
}
|
|
146
220
|
// Wait for component to be fully rendered if requested (default: true)
|
|
147
221
|
if (options.waitForReady !== false) {
|
|
148
|
-
if
|
|
149
|
-
|
|
150
|
-
await waitForStable(element);
|
|
151
|
-
}
|
|
222
|
+
// Wait for Stencil's hydration flag (skipped if hydration disabled)
|
|
223
|
+
await waitForHydrated(element);
|
|
152
224
|
// Always wait for Stencil's update cycle to complete
|
|
153
225
|
await waitForChanges();
|
|
154
226
|
}
|
|
@@ -206,6 +278,8 @@ export async function render(template, options = {
|
|
|
206
278
|
if (element.__stencil__getHostRef) {
|
|
207
279
|
instance = element.__stencil__getHostRef()?.$lazyInstance$ || element;
|
|
208
280
|
}
|
|
281
|
+
// Get spies if spyOn option was used
|
|
282
|
+
const spies = options.spyOn ? getComponentSpies(element) : undefined;
|
|
209
283
|
return {
|
|
210
284
|
root: element,
|
|
211
285
|
waitForChanges,
|
|
@@ -213,5 +287,6 @@ export async function render(template, options = {
|
|
|
213
287
|
setProps,
|
|
214
288
|
unmount,
|
|
215
289
|
spyOnEvent,
|
|
290
|
+
spies,
|
|
216
291
|
};
|
|
217
292
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { type Mock } from 'vitest';
|
|
2
|
+
/**
|
|
3
|
+
* Base configuration for what to spy on in a component
|
|
4
|
+
*/
|
|
5
|
+
interface SpyConfigBase {
|
|
6
|
+
/**
|
|
7
|
+
* Method names to spy on (calls original implementation)
|
|
8
|
+
*/
|
|
9
|
+
methods?: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Pre-configured mocks to replace methods. The mock is applied before lifecycle runs,
|
|
12
|
+
* allowing you to control return values for methods called during initialization.
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* const loadUserMock = vi.fn().mockResolvedValue({ id: 1, name: 'Test' });
|
|
16
|
+
* const { root } = await render(<my-component />, {
|
|
17
|
+
* spyOn: { mocks: { loadUser: loadUserMock } }
|
|
18
|
+
* });
|
|
19
|
+
* expect(loadUserMock).toHaveBeenCalled();
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
mocks?: Record<string, Mock>;
|
|
23
|
+
/**
|
|
24
|
+
* Property names to spy on
|
|
25
|
+
*/
|
|
26
|
+
props?: string[];
|
|
27
|
+
/**
|
|
28
|
+
* Lifecycle method names to spy on ('componentWillLoad', 'componentDidRender')
|
|
29
|
+
*/
|
|
30
|
+
lifecycle?: string[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Configuration for what to spy on in a component.
|
|
34
|
+
* Can include per-component overrides via the `components` property.
|
|
35
|
+
*/
|
|
36
|
+
export interface SpyConfig extends SpyConfigBase {
|
|
37
|
+
/**
|
|
38
|
+
* Per-component spy configurations, keyed by tag name.
|
|
39
|
+
* These override the base config for specific components.
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* spyOn: {
|
|
43
|
+
* lifecycle: ['componentDidLoad'], // applies to all
|
|
44
|
+
* components: {
|
|
45
|
+
* 'my-select': { methods: ['open', 'close'] },
|
|
46
|
+
* 'my-option': { methods: ['select'] },
|
|
47
|
+
* }
|
|
48
|
+
* }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
components?: Record<string, SpyConfigBase>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A mock with access to the original implementation
|
|
55
|
+
*/
|
|
56
|
+
interface MockWithOriginal extends Mock {
|
|
57
|
+
/**
|
|
58
|
+
* The original method implementation, bound to the component instance.
|
|
59
|
+
* Call this within mockImplementation to augment rather than replace.
|
|
60
|
+
*/
|
|
61
|
+
original?: (...args: any[]) => any;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Container for all spies on a component instance
|
|
65
|
+
*/
|
|
66
|
+
export interface ComponentSpies {
|
|
67
|
+
/**
|
|
68
|
+
* Spies on component methods (calls through to original)
|
|
69
|
+
*/
|
|
70
|
+
methods: Record<string, Mock>;
|
|
71
|
+
/**
|
|
72
|
+
* Mocks on component methods (pure stubs, doesn't call original).
|
|
73
|
+
* Each mock has an `original` property to access the original implementation.
|
|
74
|
+
*/
|
|
75
|
+
mocks: Record<string, MockWithOriginal>;
|
|
76
|
+
/**
|
|
77
|
+
* Spies on property setters
|
|
78
|
+
*/
|
|
79
|
+
props: Record<string, Mock>;
|
|
80
|
+
/**
|
|
81
|
+
* Spies on lifecycle methods
|
|
82
|
+
*/
|
|
83
|
+
lifecycle: Record<string, Mock>;
|
|
84
|
+
/**
|
|
85
|
+
* The target instance (either $lazyInstance$ or the element itself for custom-elements output)
|
|
86
|
+
*/
|
|
87
|
+
instance: any;
|
|
88
|
+
/**
|
|
89
|
+
* Reset all spies/mocks - clears call history AND resets implementations to default.
|
|
90
|
+
*/
|
|
91
|
+
resetAll: () => void;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Set spy config for the next render call. Used internally by render().
|
|
95
|
+
* @internal
|
|
96
|
+
*/
|
|
97
|
+
export declare function setRenderSpyConfig(config: SpyConfig | null): void;
|
|
98
|
+
/**
|
|
99
|
+
* Get the spies for a rendered component instance.
|
|
100
|
+
* Spies are lazily applied on first call to ensure the instance is fully constructed.
|
|
101
|
+
*
|
|
102
|
+
* @param element - The rendered component element
|
|
103
|
+
* @returns The spies object or undefined if no spies were registered
|
|
104
|
+
*/
|
|
105
|
+
export declare function getComponentSpies(element: HTMLElement): ComponentSpies | undefined;
|
|
106
|
+
/**
|
|
107
|
+
* Clear spy registrations. Call this in afterEach to reset state between tests.
|
|
108
|
+
*
|
|
109
|
+
* @param tagName - Optional tag name to clear. If omitted, clears all registrations.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* afterEach(() => {
|
|
114
|
+
* clearComponentSpies(); // Clear all
|
|
115
|
+
* });
|
|
116
|
+
*
|
|
117
|
+
* // Or clear specific component
|
|
118
|
+
* clearComponentSpies('my-button');
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
export declare function clearComponentSpies(tagName?: string): void;
|
|
122
|
+
export {};
|
|
123
|
+
//# sourceMappingURL=spy-helper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spy-helper.d.ts","sourceRoot":"","sources":["../../src/testing/spy-helper.ts"],"names":[],"mappings":"AAAA,OAAO,EAAM,KAAK,IAAI,EAAE,MAAM,QAAQ,CAAC;AAEvC;;GAEG;AACH,UAAU,aAAa;IACrB;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IAC7B;;OAEG;IACH,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB;;OAEG;IACH,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;CACtB;AAED;;;GAGG;AACH,MAAM,WAAW,SAAU,SAAQ,aAAa;IAC9C;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;CAC5C;AAED;;GAEG;AACH,UAAU,gBAAiB,SAAQ,IAAI;IACrC;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B;;OAEG;IACH,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IAC9B;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;IACxC;;OAEG;IACH,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IAC5B;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;IAChC;;OAEG;IACH,QAAQ,EAAE,GAAG,CAAC;IACd;;OAEG;IACH,QAAQ,EAAE,MAAM,IAAI,CAAC;CACtB;AAgDD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,GAAG,IAAI,CAEjE;AAqLD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,WAAW,GAAG,cAAc,GAAG,SAAS,CAkBlF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAQ1D"}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
// Registry of components to spy on (by tag name)
|
|
3
|
+
const spyTargets = {};
|
|
4
|
+
// Store spies by element reference (after applied)
|
|
5
|
+
const elementSpies = new WeakMap();
|
|
6
|
+
// Store pending spy configs by element (before applied)
|
|
7
|
+
const pendingSpies = new WeakMap();
|
|
8
|
+
// Per-render spy config (set by render(), consumed by constructor)
|
|
9
|
+
let pendingRenderConfig = null;
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the spy config for a specific tag, merging base config with per-component overrides.
|
|
12
|
+
*/
|
|
13
|
+
function resolveConfigForTag(config, tagName) {
|
|
14
|
+
const tagLower = tagName.toLowerCase();
|
|
15
|
+
const tagConfig = config.components?.[tagLower];
|
|
16
|
+
// Extract base config (everything except `components`)
|
|
17
|
+
const { components: _components, ...baseConfig } = config;
|
|
18
|
+
const hasBaseConfig = baseConfig.methods?.length ||
|
|
19
|
+
(baseConfig.mocks && Object.keys(baseConfig.mocks).length) ||
|
|
20
|
+
baseConfig.props?.length ||
|
|
21
|
+
baseConfig.lifecycle?.length;
|
|
22
|
+
if (!tagConfig && !hasBaseConfig) {
|
|
23
|
+
return null; // No config applies to this tag
|
|
24
|
+
}
|
|
25
|
+
if (!tagConfig) {
|
|
26
|
+
return baseConfig; // Only base config
|
|
27
|
+
}
|
|
28
|
+
if (!hasBaseConfig) {
|
|
29
|
+
return tagConfig; // Only tag-specific config
|
|
30
|
+
}
|
|
31
|
+
// Merge: tag-specific config extends base config
|
|
32
|
+
return {
|
|
33
|
+
methods: [...(baseConfig.methods || []), ...(tagConfig.methods || [])],
|
|
34
|
+
mocks: { ...(baseConfig.mocks || {}), ...(tagConfig.mocks || {}) },
|
|
35
|
+
props: [...(baseConfig.props || []), ...(tagConfig.props || [])],
|
|
36
|
+
lifecycle: [...(baseConfig.lifecycle || []), ...(tagConfig.lifecycle || [])],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Set spy config for the next render call. Used internally by render().
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
export function setRenderSpyConfig(config) {
|
|
44
|
+
pendingRenderConfig = config;
|
|
45
|
+
}
|
|
46
|
+
// Store original define before patching (only in browser/jsdom environments)
|
|
47
|
+
const origDefine = typeof customElements !== 'undefined' ? customElements.define.bind(customElements) : undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Apply spies to a target instance (shared logic)
|
|
50
|
+
*/
|
|
51
|
+
function applySpies(target, config) {
|
|
52
|
+
const spies = {
|
|
53
|
+
methods: {},
|
|
54
|
+
mocks: {},
|
|
55
|
+
props: {},
|
|
56
|
+
lifecycle: {},
|
|
57
|
+
instance: target,
|
|
58
|
+
resetAll() {
|
|
59
|
+
// Reset all method spies
|
|
60
|
+
for (const spy of Object.values(this.methods)) {
|
|
61
|
+
spy.mockReset();
|
|
62
|
+
}
|
|
63
|
+
// Reset all mocks
|
|
64
|
+
for (const mock of Object.values(this.mocks)) {
|
|
65
|
+
mock.mockReset();
|
|
66
|
+
}
|
|
67
|
+
// Reset all prop spies
|
|
68
|
+
for (const spy of Object.values(this.props)) {
|
|
69
|
+
spy.mockReset();
|
|
70
|
+
}
|
|
71
|
+
// Reset all lifecycle spies
|
|
72
|
+
for (const spy of Object.values(this.lifecycle)) {
|
|
73
|
+
spy.mockReset();
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
// Spy on methods (calls through to original)
|
|
78
|
+
if (config.methods) {
|
|
79
|
+
for (const methodName of config.methods) {
|
|
80
|
+
const method = target[methodName];
|
|
81
|
+
if (typeof method === 'function' && !method.__isSpy) {
|
|
82
|
+
const spy = vi.fn((...args) => method.apply(target, args));
|
|
83
|
+
spy.__isSpy = true;
|
|
84
|
+
spies.methods[methodName] = spy;
|
|
85
|
+
Object.defineProperty(target, methodName, {
|
|
86
|
+
value: spy,
|
|
87
|
+
writable: true,
|
|
88
|
+
configurable: true,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Mock methods (use pre-configured mocks, original is accessible)
|
|
94
|
+
if (config.mocks) {
|
|
95
|
+
for (const [methodName, mock] of Object.entries(config.mocks)) {
|
|
96
|
+
const original = target[methodName];
|
|
97
|
+
const mockWithOriginal = mock;
|
|
98
|
+
mockWithOriginal.__isSpy = true;
|
|
99
|
+
// Store the original so users can call it if they want to augment rather than replace
|
|
100
|
+
if (typeof original === 'function') {
|
|
101
|
+
mockWithOriginal.original = (...args) => original.apply(target, args);
|
|
102
|
+
}
|
|
103
|
+
spies.mocks[methodName] = mockWithOriginal;
|
|
104
|
+
Object.defineProperty(target, methodName, {
|
|
105
|
+
value: mockWithOriginal,
|
|
106
|
+
writable: true,
|
|
107
|
+
configurable: true,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Spy on props - need to intercept $instanceValues$ since Stencil stores props there
|
|
112
|
+
if (config.props) {
|
|
113
|
+
const hostRef = target.__stencil__getHostRef?.();
|
|
114
|
+
const instanceValues = hostRef?.$instanceValues$;
|
|
115
|
+
if (instanceValues) {
|
|
116
|
+
// Wrap the Map's set method to intercept prop changes
|
|
117
|
+
const originalSet = instanceValues.set.bind(instanceValues);
|
|
118
|
+
instanceValues.set = (key, value) => {
|
|
119
|
+
const result = originalSet(key, value);
|
|
120
|
+
// If this prop is being spied on, call the spy
|
|
121
|
+
if (config.props.includes(key) && spies.props[key]) {
|
|
122
|
+
spies.props[key](value);
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// Create spies for each prop
|
|
128
|
+
for (const propName of config.props) {
|
|
129
|
+
const spy = vi.fn();
|
|
130
|
+
spies.props[propName] = spy;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Spy on lifecycle methods (auto-stub if not defined)
|
|
134
|
+
if (config.lifecycle) {
|
|
135
|
+
for (const lifecycleName of config.lifecycle) {
|
|
136
|
+
const method = target[lifecycleName];
|
|
137
|
+
let spy;
|
|
138
|
+
if (typeof method === 'function' && !method.__isSpy) {
|
|
139
|
+
// Method exists - wrap it
|
|
140
|
+
spy = vi.fn((...args) => method.apply(target, args));
|
|
141
|
+
}
|
|
142
|
+
else if (typeof method !== 'function') {
|
|
143
|
+
// Method doesn't exist - create stub so Stencil will call it
|
|
144
|
+
spy = vi.fn();
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
// Already a spy
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
spy.__isSpy = true;
|
|
151
|
+
spies.lifecycle[lifecycleName] = spy;
|
|
152
|
+
Object.defineProperty(target, lifecycleName, {
|
|
153
|
+
value: spy,
|
|
154
|
+
writable: true,
|
|
155
|
+
configurable: true,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return spies;
|
|
160
|
+
}
|
|
161
|
+
// Patch customElements.define to intercept component registration (only in browser/jsdom environments)
|
|
162
|
+
if (typeof customElements !== 'undefined' && origDefine) {
|
|
163
|
+
customElements.define = function (name, ctor, options) {
|
|
164
|
+
const lc = name.toLowerCase();
|
|
165
|
+
const OrigCtor = ctor;
|
|
166
|
+
// Wrap ALL components to enable per-render spies without module-level registration
|
|
167
|
+
const Wrapped = class extends OrigCtor {
|
|
168
|
+
constructor(...args) {
|
|
169
|
+
super(...args);
|
|
170
|
+
// Check for spy config: per-render takes priority, then module-level
|
|
171
|
+
// Capture config now, at constructor time (before async callbacks)
|
|
172
|
+
const baseConfig = pendingRenderConfig || spyTargets[lc];
|
|
173
|
+
if (!baseConfig)
|
|
174
|
+
return; // No spying configured, quick exit
|
|
175
|
+
// Resolve config for this specific tag (handles per-component overrides)
|
|
176
|
+
const configToUse = resolveConfigForTag(baseConfig, lc);
|
|
177
|
+
if (!configToUse)
|
|
178
|
+
return; // No config applies to this tag
|
|
179
|
+
// After super(), registerHost has run and we have access to hostRef
|
|
180
|
+
const hostRef = this.__stencil__getHostRef?.();
|
|
181
|
+
if (hostRef && hostRef.$fetchedCbList$) {
|
|
182
|
+
// Lazy-load path: Use $fetchedCbList$ to apply spies after constructor but before render
|
|
183
|
+
const element = this;
|
|
184
|
+
// Capture config in closure for when callback executes
|
|
185
|
+
const capturedConfig = configToUse;
|
|
186
|
+
hostRef.$fetchedCbList$.push(() => {
|
|
187
|
+
const instance = hostRef.$lazyInstance$;
|
|
188
|
+
if (instance) {
|
|
189
|
+
const spies = applySpies(instance, capturedConfig);
|
|
190
|
+
elementSpies.set(element, spies);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
else if (hostRef) {
|
|
195
|
+
// Custom-elements output with Stencil runtime: element IS the instance
|
|
196
|
+
// Apply spies immediately since there's no lazy loading
|
|
197
|
+
const spies = applySpies(this, configToUse);
|
|
198
|
+
elementSpies.set(this, spies);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// Custom-elements output path: element IS the instance
|
|
202
|
+
const spies = applySpies(this, configToUse);
|
|
203
|
+
elementSpies.set(this, spies);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
return origDefine.call(customElements, name, Wrapped, options);
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Get the spies for a rendered component instance.
|
|
212
|
+
* Spies are lazily applied on first call to ensure the instance is fully constructed.
|
|
213
|
+
*
|
|
214
|
+
* @param element - The rendered component element
|
|
215
|
+
* @returns The spies object or undefined if no spies were registered
|
|
216
|
+
*/
|
|
217
|
+
export function getComponentSpies(element) {
|
|
218
|
+
// Return existing spies if already applied
|
|
219
|
+
const existing = elementSpies.get(element);
|
|
220
|
+
if (existing) {
|
|
221
|
+
return existing;
|
|
222
|
+
}
|
|
223
|
+
// Check for pending spy config
|
|
224
|
+
const pending = pendingSpies.get(element);
|
|
225
|
+
if (pending) {
|
|
226
|
+
// Apply spies now that the instance is fully constructed
|
|
227
|
+
const spies = applySpies(pending.instance, pending.config);
|
|
228
|
+
elementSpies.set(element, spies);
|
|
229
|
+
pendingSpies.delete(element);
|
|
230
|
+
return spies;
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Clear spy registrations. Call this in afterEach to reset state between tests.
|
|
236
|
+
*
|
|
237
|
+
* @param tagName - Optional tag name to clear. If omitted, clears all registrations.
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* ```ts
|
|
241
|
+
* afterEach(() => {
|
|
242
|
+
* clearComponentSpies(); // Clear all
|
|
243
|
+
* });
|
|
244
|
+
*
|
|
245
|
+
* // Or clear specific component
|
|
246
|
+
* clearComponentSpies('my-button');
|
|
247
|
+
* ```
|
|
248
|
+
*/
|
|
249
|
+
export function clearComponentSpies(tagName) {
|
|
250
|
+
if (tagName) {
|
|
251
|
+
delete spyTargets[tagName.toLowerCase()];
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
for (const key of Object.keys(spyTargets)) {
|
|
255
|
+
delete spyTargets[key];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface EventSpy {
|
|
|
23
23
|
*/
|
|
24
24
|
length: number;
|
|
25
25
|
}
|
|
26
|
+
import type { SpyConfig } from './testing/spy-helper.js';
|
|
26
27
|
/**
|
|
27
28
|
* Component render options
|
|
28
29
|
*/
|
|
@@ -48,7 +49,13 @@ export interface RenderOptions {
|
|
|
48
49
|
* Additional HTML attributes
|
|
49
50
|
*/
|
|
50
51
|
attributes?: Record<string, string>;
|
|
52
|
+
/**
|
|
53
|
+
* Spy configuration for this render call. Spies on methods, props, and lifecycle hooks.
|
|
54
|
+
* Takes priority over module-level spyOnComponent() calls.
|
|
55
|
+
*/
|
|
56
|
+
spyOn?: SpyConfig;
|
|
51
57
|
}
|
|
58
|
+
import type { ComponentSpies } from './testing/spy-helper.js';
|
|
52
59
|
/**
|
|
53
60
|
* Render result for component testing
|
|
54
61
|
*/
|
|
@@ -78,5 +85,9 @@ export interface RenderResult<T = HTMLElement, I = any> {
|
|
|
78
85
|
* Spy on a custom event
|
|
79
86
|
*/
|
|
80
87
|
spyOnEvent: (eventName: string) => EventSpy;
|
|
88
|
+
/**
|
|
89
|
+
* Component spies (only present when `spyOn` option is used)
|
|
90
|
+
*/
|
|
91
|
+
spies?: ComponentSpies;
|
|
81
92
|
}
|
|
82
93
|
//# sourceMappingURL=types.d.ts.map
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,WAAW,QAAQ;IACvB;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAElB;;OAEG;IACH,MAAM,EAAE,WAAW,EAAE,CAAC;IAEtB;;OAEG;IACH,UAAU,EAAE,WAAW,GAAG,SAAS,CAAC;IAEpC;;OAEG;IACH,SAAS,EAAE,WAAW,GAAG,SAAS,CAAC;IAEnC;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B;;OAEG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAE5B;;OAEG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,CAAC,CAAC;IAE7C;;OAEG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IAEtB;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,WAAW,QAAQ;IACvB;;OAEG;IACH,SAAS,EAAE,MAAM,CAAC;IAElB;;OAEG;IACH,MAAM,EAAE,WAAW,EAAE,CAAC;IAEtB;;OAEG;IACH,UAAU,EAAE,WAAW,GAAG,SAAS,CAAC;IAEpC;;OAEG;IACH,SAAS,EAAE,WAAW,GAAG,SAAS,CAAC;IAEnC;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAEzD;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B;;OAEG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAE5B;;OAEG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,CAAC,CAAC;IAE7C;;OAEG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IAEtB;;OAEG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAEpC;;;OAGG;IACH,KAAK,CAAC,EAAE,SAAS,CAAC;CACnB;AAED,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAE9D;;GAEG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,WAAW,EAAE,CAAC,GAAG,GAAG;IACpD;;OAEG;IACH,IAAI,EAAE,CAAC,CAAC;IAER;;OAEG;IACH,cAAc,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAEpC;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,CAAC;IAEb;;OAEG;IACH,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAExD;;OAEG;IACH,OAAO,EAAE,MAAM,IAAI,CAAC;IAEpB;;OAEG;IACH,UAAU,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,QAAQ,CAAC;IAE5C;;OAEG;IACH,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB"}
|
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"type": "git",
|
|
5
5
|
"url": "https://github.com/stenciljs/vitest"
|
|
6
6
|
},
|
|
7
|
-
"version": "1.
|
|
7
|
+
"version": "1.9.1",
|
|
8
8
|
"description": "First-class testing utilities for Stencil design systems with Vitest",
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"type": "module",
|
|
@@ -100,7 +100,7 @@
|
|
|
100
100
|
"dependencies": {
|
|
101
101
|
"jiti": "^2.6.1",
|
|
102
102
|
"local-pkg": "^1.1.2",
|
|
103
|
-
"vitest-environment-stencil": "1.
|
|
103
|
+
"vitest-environment-stencil": "1.9.1"
|
|
104
104
|
},
|
|
105
105
|
"devDependencies": {
|
|
106
106
|
"@eslint/js": "^9.39.2",
|