Skip to content

Commit f39eb1c

Browse files
authored
fix: remove last mounted component upon subsequent mount calls (#24470)
BREAKING CHANGE: remove last mounted component upon subsequent mount calls of mount
1 parent 33875d7 commit f39eb1c

File tree

35 files changed

+1528
-235
lines changed

35 files changed

+1528
-235
lines changed

npm/angular/src/mount.ts

+36-17
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ window.Mocha['__zone_patch__'] = false
88
import 'zone.js/testing'
99

1010
import { CommonModule } from '@angular/common'
11-
import { Component, ErrorHandler, EventEmitter, Injectable, SimpleChange, SimpleChanges, Type } from '@angular/core'
11+
import { Component, ErrorHandler, EventEmitter, Injectable, SimpleChange, SimpleChanges, Type, OnChanges } from '@angular/core'
1212
import {
1313
ComponentFixture,
1414
getTestBed,
@@ -72,6 +72,23 @@ export interface MountConfig<T> extends TestModuleMetadata {
7272
componentProperties?: Partial<{ [P in keyof T]: T[P] }>
7373
}
7474

75+
let activeFixture: ComponentFixture<any> | null = null
76+
77+
function cleanup () {
78+
// Not public, we need to call this to remove the last component from the DOM
79+
try {
80+
(getTestBed() as any).tearDownTestingModule()
81+
} catch (e) {
82+
const notSupportedError = new Error(`Failed to teardown component. The version of Angular you are using may not be officially supported.`)
83+
84+
;(notSupportedError as any).docsUrl = 'https://on.cypress.io/component-framework-configuration'
85+
throw notSupportedError
86+
}
87+
88+
getTestBed().resetTestingModule()
89+
activeFixture = null
90+
}
91+
7592
/**
7693
* Type that the `mount` function returns
7794
* @type MountResponse<T>
@@ -209,6 +226,8 @@ function setupFixture<T> (
209226
): ComponentFixture<T> {
210227
const fixture = getTestBed().createComponent(component)
211228

229+
setupComponent(config, fixture)
230+
212231
fixture.whenStable().then(() => {
213232
fixture.autoDetectChanges(config.autoDetectChanges ?? true)
214233
})
@@ -223,17 +242,18 @@ function setupFixture<T> (
223242
* @param {ComponentFixture<T>} fixture Fixture for debugging and testing a component.
224243
* @returns {T} Component being mounted
225244
*/
226-
function setupComponent<T extends { ngOnChanges? (changes: SimpleChanges): void }> (
245+
function setupComponent<T> (
227246
config: MountConfig<T>,
228-
fixture: ComponentFixture<T>): T {
229-
let component: T = fixture.componentInstance
247+
fixture: ComponentFixture<T>,
248+
): void {
249+
let component = fixture.componentInstance as unknown as { [key: string]: any } & Partial<OnChanges>
230250

231251
if (config?.componentProperties) {
232252
component = Object.assign(component, config.componentProperties)
233253
}
234254

235255
if (config.autoSpyOutputs) {
236-
Object.keys(component).forEach((key: string, index: number, keys: string[]) => {
256+
Object.keys(component).forEach((key) => {
237257
const property = component[key]
238258

239259
if (property instanceof EventEmitter) {
@@ -252,14 +272,12 @@ function setupComponent<T extends { ngOnChanges? (changes: SimpleChanges): void
252272
acc[key] = new SimpleChange(null, value, true)
253273

254274
return acc
255-
}, {})
275+
}, {} as {[key: string]: SimpleChange})
256276

257277
if (Object.keys(componentProperties).length > 0) {
258278
component.ngOnChanges(simpleChanges)
259279
}
260280
}
261-
262-
return component
263281
}
264282

265283
/**
@@ -295,13 +313,18 @@ export function mount<T> (
295313
component: Type<T> | string,
296314
config: MountConfig<T> = { },
297315
): Cypress.Chainable<MountResponse<T>> {
316+
// Remove last mounted component if cy.mount is called more than once in a test
317+
if (activeFixture) {
318+
cleanup()
319+
}
320+
298321
const componentFixture = initTestBed(component, config)
299-
const fixture = setupFixture(componentFixture, config)
300-
const componentInstance = setupComponent(config, fixture)
322+
323+
activeFixture = setupFixture(componentFixture, config)
301324

302325
const mountResponse: MountResponse<T> = {
303-
fixture,
304-
component: componentInstance,
326+
fixture: activeFixture,
327+
component: activeFixture.componentInstance,
305328
}
306329

307330
const logMessage = typeof component === 'string' ? 'Component' : componentFixture.name
@@ -338,8 +361,4 @@ getTestBed().initTestEnvironment(
338361
},
339362
)
340363

341-
setupHooks(() => {
342-
// Not public, we need to call this to remove the last component from the DOM
343-
getTestBed()['tearDownTestingModule']()
344-
getTestBed().resetTestingModule()
345-
})
364+
setupHooks(cleanup)

npm/angular/tsconfig.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,15 @@
1111
"allowJs": true,
1212
"declaration": true,
1313
"outDir": "dist",
14-
"strict": false,
15-
"noImplicitAny": false,
14+
"strict": true,
1615
"baseUrl": "./",
1716
"types": [
1817
"cypress"
1918
],
2019
"allowSyntheticDefaultImports": true,
2120
"esModuleInterop": true,
22-
"moduleResolution": "node"
21+
"moduleResolution": "node",
22+
"noPropertyAccessFromIndexSignature": true,
2323
},
2424
"include": ["src/**/*.*"],
2525
"exclude": ["src/**/*-spec.*"]

npm/mount-utils/create-rollup-entry.mjs

+12-3
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,19 @@ export function createEntries (options) {
6868
console.log(`Building ${format}: ${finalConfig.output.file}`)
6969

7070
return finalConfig
71-
}).concat({
71+
}).concat([{
7272
input,
7373
output: [{ file: 'dist/index.d.ts', format: 'es' }],
74-
plugins: [dts({ respectExternal: true })],
74+
plugins: [
75+
dts({ respectExternal: true }),
76+
{
77+
name: 'cypress-types-reference',
78+
// rollup-plugin-dts does not add '// <reference types="cypress" />' like rollup-plugin-typescript2 did so we add it here.
79+
renderChunk (...[code]) {
80+
return `/// <reference types="cypress" />\n\n${code}`
81+
},
82+
},
83+
],
7584
external: config.external || [],
76-
})
85+
}])
7786
}

npm/react/src/mount.ts

+3
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ export function mount (jsx: React.ReactNode, options: MountOptions = {}, rerende
3333
Cypress.log({ name: 'warning', message })
3434
}
3535

36+
// Remove last mounted component if cy.mount is called more than once in a test
37+
cleanup()
38+
3639
const internalOptions: InternalMountOptions = {
3740
reactDom: ReactDOM,
3841
render: (reactComponent: ReturnType<typeof React.createElement>, el: HTMLElement, reactDomToUse: typeof ReactDOM) => {

npm/react18/src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ const cleanup = () => {
2626
}
2727

2828
export function mount (jsx: React.ReactNode, options: MountOptions = {}, rerenderKey?: string) {
29+
// Remove last mounted component if cy.mount is called more than once in a test
30+
// React by default removes the last component when calling render, but we should remove the root
31+
// to wipe away any state
32+
cleanup()
2933
const internalOptions: InternalMountOptions = {
3034
reactDom: ReactDOM,
3135
render: (reactComponent: ReturnType<typeof React.createElement>, el: HTMLElement) => {

npm/svelte/src/mount.ts

+3
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ export function mount<T extends SvelteComponent> (
6262
options: MountOptions<T> = {},
6363
): Cypress.Chainable<MountReturn<T>> {
6464
return cy.then(() => {
65+
// Remove last mounted component if cy.mount is called more than once in a test
66+
cleanup()
67+
6568
const target = getContainerEl()
6669

6770
injectStylesBeforeElement(options, document, target)

npm/vue/src/index.ts

+12-18
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const {
4444
export { VueTestUtils }
4545

4646
const DEFAULT_COMP_NAME = 'unknown'
47+
const VUE_ROOT = '__cy_vue_root'
4748

4849
type GlobalMountOptions = Required<VTUMountingOptions<any>>['global']
4950

@@ -72,24 +73,14 @@ type MountingOptions<Props, Data = {}> = Omit<VTUMountingOptions<Props, Data>, '
7273

7374
export type CyMountOptions<Props, Data = {}> = MountingOptions<Props, Data>
7475

75-
Cypress.on('run:start', () => {
76-
// `mount` is designed to work with component testing only.
77-
// it assumes ROOT_SELECTOR exists, which is not the case in e2e.
78-
// if the user registers a custom command that imports `cypress/vue`,
79-
// this event will be registered and cause an error when the user
80-
// launches e2e (since it's common to use Cypress for both CT and E2E.
81-
// https://github.com/cypress-io/cypress/issues/17438
82-
if (Cypress.testingType !== 'component') {
83-
return
84-
}
76+
const cleanup = () => {
77+
Cypress.vueWrapper?.unmount()
78+
Cypress.$(`#${VUE_ROOT}`).remove()
8579

86-
Cypress.on('test:before:run', () => {
87-
Cypress.vueWrapper?.unmount()
88-
const el = getContainerEl()
80+
;(Cypress as any).vueWrapper = null
8981

90-
el.innerHTML = ''
91-
})
92-
})
82+
;(Cypress as any).vue = null
83+
}
9384

9485
/**
9586
* The types for mount have been copied directly from the VTU mount
@@ -378,6 +369,9 @@ export function mount<
378369

379370
// implementation
380371
export function mount (componentOptions: any, options: any = {}) {
372+
// Remove last mounted component if cy.mount is called more than once in a test
373+
cleanup()
374+
381375
// TODO: get the real displayName and props from VTU shallowMount
382376
const componentName = getComponentDisplayName(componentOptions)
383377

@@ -409,7 +403,7 @@ export function mount (componentOptions: any, options: any = {}) {
409403

410404
const componentNode = document.createElement('div')
411405

412-
componentNode.id = '__cy_vue_root'
406+
componentNode.id = VUE_ROOT
413407

414408
el.append(componentNode)
415409

@@ -484,4 +478,4 @@ export function mountCallback (
484478
// import { registerCT } from 'cypress/<my-framework>'
485479
// registerCT()
486480
// Note: This would be a breaking change
487-
setupHooks()
481+
setupHooks(cleanup)

npm/vue2/src/index.ts

+8-10
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
mount as testUtilsMount,
66
VueTestUtilsConfigOptions,
77
Wrapper,
8-
enableAutoDestroy,
98
} from '@vue/test-utils'
109
import {
1110
injectStylesBeforeElement,
@@ -266,6 +265,10 @@ declare global {
266265
}
267266
}
268267

268+
const cleanup = () => {
269+
Cypress.vueWrapper?.destroy()
270+
}
271+
269272
/**
270273
* Direct Vue errors to the top error handler
271274
* where they will fail Cypress test
@@ -280,14 +283,6 @@ function failTestOnVueError (err, vm, info) {
280283
})
281284
}
282285

283-
function registerAutoDestroy ($destroy: () => void) {
284-
Cypress.on('test:before:run', () => {
285-
$destroy()
286-
})
287-
}
288-
289-
enableAutoDestroy(registerAutoDestroy)
290-
291286
const injectStyles = (options: StyleOptions) => {
292287
return injectStylesBeforeElement(options, document, getContainerEl())
293288
}
@@ -336,6 +331,9 @@ export const mount = (
336331
wrapper: Wrapper<Vue, Element>
337332
component: Wrapper<Vue, Element>['vm']
338333
}> => {
334+
// Remove last mounted component if cy.mount is called more than once in a test
335+
cleanup()
336+
339337
const options: Partial<MountOptions> = Cypress._.pick(
340338
optionsOrProps,
341339
defaultOptions,
@@ -442,4 +440,4 @@ export const mountCallback = (
442440
// import { registerCT } from 'cypress/<my-framework>'
443441
// registerCT()
444442
// Note: This would be a breaking change
445-
setupHooks()
443+
setupHooks(cleanup)

npm/webpack-dev-server/src/devServer.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ async function getPreset (devServerConfig: WebpackDevServerConfig): Promise<Opti
137137
return { sourceWebpackModulesResult: sourceDefaultWebpackDependencies(devServerConfig) }
138138

139139
default:
140-
throw new Error(`Unexpected framework ${(devServerConfig as any).framework}, please visit https://docs.cypress.io/guides/component-testing/component-framework-configuration to see a list of supported frameworks`)
140+
throw new Error(`Unexpected framework ${(devServerConfig as any).framework}, please visit https://on.cypress.io/component-framework-configuration to see a list of supported frameworks`)
141141
}
142142
}
143143

packages/app/src/runs/RunResults.cy.tsx

+9-16
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,17 @@ import RunResults from './RunResults.vue'
55

66
describe('<RunResults />', { viewportHeight: 150, viewportWidth: 250 }, () => {
77
it('shows number of passed, skipped, pending and failed tests', () => {
8-
cy.wrap(Object.keys(CloudRunStubs)).each((cloudRunStub: string) => {
9-
const res = CloudRunStubs[cloudRunStub]
8+
const cloudRuns = Object.values(CloudRunStubs)
109

11-
cy.mountFragment(RunCardFragmentDoc, {
12-
onResult (result) {
13-
Object.keys(result).forEach((key) => {
14-
result[key] = res[key]
15-
})
16-
},
17-
render (props) {
18-
return <RunResults gql={props} />
19-
},
20-
})
10+
cy.mount(() => cloudRuns.map((cloudRun, i) => (<RunResults data-cy={`run-result-${i}`} gql={cloudRun} />)))
2111

22-
cy.get(`[title=${defaultMessages.runs.results.passed}]`).should('contain.text', res.totalPassed)
23-
cy.get(`[title=${defaultMessages.runs.results.failed}]`).should('contain.text', res.totalFailed)
24-
cy.get(`[title=${defaultMessages.runs.results.skipped}]`).should('contain.text', res.totalSkipped)
25-
cy.get(`[title=${defaultMessages.runs.results.pending}`).should('contain.text', res.totalPending)
12+
cloudRuns.forEach((cloudRun, i) => {
13+
cy.get(`[data-cy=run-result-${i}]`).within(() => {
14+
cy.get(`[title=${defaultMessages.runs.results.passed}]`).should('contain.text', cloudRun.totalPassed)
15+
cy.get(`[title=${defaultMessages.runs.results.failed}]`).should('contain.text', cloudRun.totalFailed)
16+
cy.get(`[title=${defaultMessages.runs.results.skipped}]`).should('contain.text', cloudRun.totalSkipped)
17+
cy.get(`[title=${defaultMessages.runs.results.pending}]`).should('contain.text', cloudRun.totalPending)
18+
})
2619
})
2720

2821
cy.percySnapshot()

packages/app/src/specs/SpecsListHeader.cy.tsx

+5-13
Original file line numberDiff line numberDiff line change
@@ -110,27 +110,19 @@ describe('<SpecsListHeader />', { keystrokeDelay: 0 }, () => {
110110
})
111111

112112
it('shows the count correctly while searching', () => {
113-
const mountWithCounts = (resultCount = 0, specCount = 0) => {
114-
cy.mount(() => (<div class="max-w-800px p-12 resize overflow-auto"><SpecsListHeader
113+
const counts = [[0, 0], [0, 22], [0, 1], [1, 1], [5, 22]]
114+
115+
cy.mount(() => counts.map(([resultCount, specCount]) => (
116+
<div class="max-w-800px p-12 resize overflow-auto"><SpecsListHeader
115117
modelValue={'foo'}
116118
resultCount={resultCount}
117119
specCount={specCount}
118-
/></div>))
119-
}
120+
/></div>)))
120121

121-
mountWithCounts(0, 0)
122122
cy.contains('No matches')
123-
124-
mountWithCounts(0, 22)
125123
cy.contains('0 of 22 matches')
126-
127-
mountWithCounts(0, 1)
128124
cy.contains('0 of 1 match').should('be.visible')
129-
130-
mountWithCounts(1, 1)
131125
cy.contains('1 of 1 match').should('be.visible')
132-
133-
mountWithCounts(5, 22)
134126
cy.contains('5 of 22 matches').should('be.visible')
135127

136128
cy.percySnapshot()

packages/frontend-shared/src/components/Alert.cy.tsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,11 @@ const suffixIcon = () => <LoadingIcon data-cy="loading-icon" class="animate-spin
4040
describe('<Alert />', () => {
4141
describe('classes', () => {
4242
it('can change the text and background color for the alert', () => {
43-
cy.mount(() => <Alert headerClass="underline text-pink-500 bg-pink-100" bodyClass="bg-pink-50" icon={suffixIcon}>test</Alert>)
44-
cy.mount(() => <Alert headerClass="underline text-teal-500 bg-teal-100" bodyClass="bg-teal-50" icon={suffixIcon}>test</Alert>)
43+
cy.mount(() =>
44+
(<div class="flex flex-col m-8px gap-8px">
45+
<Alert headerClass="underline text-pink-500 bg-pink-100" bodyClass="bg-pink-50" icon={suffixIcon}>test</Alert>
46+
<Alert headerClass="underline text-teal-500 bg-teal-100" bodyClass="bg-teal-50" icon={suffixIcon}>test</Alert>
47+
</div>))
4548

4649
cy.percySnapshot()
4750
})

0 commit comments

Comments
 (0)