Skip to content

Commit

Permalink
fix: remove last mounted component upon subsequent mount calls (#24470)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: remove last mounted component upon subsequent mount calls of mount
  • Loading branch information
ZachJW34 committed Nov 3, 2022
1 parent 33875d7 commit f39eb1c
Show file tree
Hide file tree
Showing 35 changed files with 1,528 additions and 235 deletions.
53 changes: 36 additions & 17 deletions npm/angular/src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ window.Mocha['__zone_patch__'] = false
import 'zone.js/testing'

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

let activeFixture: ComponentFixture<any> | null = null

function cleanup () {
// Not public, we need to call this to remove the last component from the DOM
try {
(getTestBed() as any).tearDownTestingModule()
} catch (e) {
const notSupportedError = new Error(`Failed to teardown component. The version of Angular you are using may not be officially supported.`)

;(notSupportedError as any).docsUrl = 'https://on.cypress.io/component-framework-configuration'
throw notSupportedError
}

getTestBed().resetTestingModule()
activeFixture = null
}

/**
* Type that the `mount` function returns
* @type MountResponse<T>
Expand Down Expand Up @@ -209,6 +226,8 @@ function setupFixture<T> (
): ComponentFixture<T> {
const fixture = getTestBed().createComponent(component)

setupComponent(config, fixture)

fixture.whenStable().then(() => {
fixture.autoDetectChanges(config.autoDetectChanges ?? true)
})
Expand All @@ -223,17 +242,18 @@ function setupFixture<T> (
* @param {ComponentFixture<T>} fixture Fixture for debugging and testing a component.
* @returns {T} Component being mounted
*/
function setupComponent<T extends { ngOnChanges? (changes: SimpleChanges): void }> (
function setupComponent<T> (
config: MountConfig<T>,
fixture: ComponentFixture<T>): T {
let component: T = fixture.componentInstance
fixture: ComponentFixture<T>,
): void {
let component = fixture.componentInstance as unknown as { [key: string]: any } & Partial<OnChanges>

if (config?.componentProperties) {
component = Object.assign(component, config.componentProperties)
}

if (config.autoSpyOutputs) {
Object.keys(component).forEach((key: string, index: number, keys: string[]) => {
Object.keys(component).forEach((key) => {
const property = component[key]

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

return acc
}, {})
}, {} as {[key: string]: SimpleChange})

if (Object.keys(componentProperties).length > 0) {
component.ngOnChanges(simpleChanges)
}
}

return component
}

/**
Expand Down Expand Up @@ -295,13 +313,18 @@ export function mount<T> (
component: Type<T> | string,
config: MountConfig<T> = { },
): Cypress.Chainable<MountResponse<T>> {
// Remove last mounted component if cy.mount is called more than once in a test
if (activeFixture) {
cleanup()
}

const componentFixture = initTestBed(component, config)
const fixture = setupFixture(componentFixture, config)
const componentInstance = setupComponent(config, fixture)

activeFixture = setupFixture(componentFixture, config)

const mountResponse: MountResponse<T> = {
fixture,
component: componentInstance,
fixture: activeFixture,
component: activeFixture.componentInstance,
}

const logMessage = typeof component === 'string' ? 'Component' : componentFixture.name
Expand Down Expand Up @@ -338,8 +361,4 @@ getTestBed().initTestEnvironment(
},
)

setupHooks(() => {
// Not public, we need to call this to remove the last component from the DOM
getTestBed()['tearDownTestingModule']()
getTestBed().resetTestingModule()
})
setupHooks(cleanup)
6 changes: 3 additions & 3 deletions npm/angular/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@
"allowJs": true,
"declaration": true,
"outDir": "dist",
"strict": false,
"noImplicitAny": false,
"strict": true,
"baseUrl": "./",
"types": [
"cypress"
],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"moduleResolution": "node"
"moduleResolution": "node",
"noPropertyAccessFromIndexSignature": true,
},
"include": ["src/**/*.*"],
"exclude": ["src/**/*-spec.*"]
Expand Down
15 changes: 12 additions & 3 deletions npm/mount-utils/create-rollup-entry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,19 @@ export function createEntries (options) {
console.log(`Building ${format}: ${finalConfig.output.file}`)

return finalConfig
}).concat({
}).concat([{
input,
output: [{ file: 'dist/index.d.ts', format: 'es' }],
plugins: [dts({ respectExternal: true })],
plugins: [
dts({ respectExternal: true }),
{
name: 'cypress-types-reference',
// rollup-plugin-dts does not add '// <reference types="cypress" />' like rollup-plugin-typescript2 did so we add it here.
renderChunk (...[code]) {
return `/// <reference types="cypress" />\n\n${code}`
},
},
],
external: config.external || [],
})
}])
}
3 changes: 3 additions & 0 deletions npm/react/src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ export function mount (jsx: React.ReactNode, options: MountOptions = {}, rerende
Cypress.log({ name: 'warning', message })
}

// Remove last mounted component if cy.mount is called more than once in a test
cleanup()

const internalOptions: InternalMountOptions = {
reactDom: ReactDOM,
render: (reactComponent: ReturnType<typeof React.createElement>, el: HTMLElement, reactDomToUse: typeof ReactDOM) => {
Expand Down
4 changes: 4 additions & 0 deletions npm/react18/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const cleanup = () => {
}

export function mount (jsx: React.ReactNode, options: MountOptions = {}, rerenderKey?: string) {
// Remove last mounted component if cy.mount is called more than once in a test
// React by default removes the last component when calling render, but we should remove the root
// to wipe away any state
cleanup()
const internalOptions: InternalMountOptions = {
reactDom: ReactDOM,
render: (reactComponent: ReturnType<typeof React.createElement>, el: HTMLElement) => {
Expand Down
3 changes: 3 additions & 0 deletions npm/svelte/src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export function mount<T extends SvelteComponent> (
options: MountOptions<T> = {},
): Cypress.Chainable<MountReturn<T>> {
return cy.then(() => {
// Remove last mounted component if cy.mount is called more than once in a test
cleanup()

const target = getContainerEl()

injectStylesBeforeElement(options, document, target)
Expand Down
30 changes: 12 additions & 18 deletions npm/vue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const {
export { VueTestUtils }

const DEFAULT_COMP_NAME = 'unknown'
const VUE_ROOT = '__cy_vue_root'

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

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

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

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

Cypress.on('test:before:run', () => {
Cypress.vueWrapper?.unmount()
const el = getContainerEl()
;(Cypress as any).vueWrapper = null

el.innerHTML = ''
})
})
;(Cypress as any).vue = null
}

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

// implementation
export function mount (componentOptions: any, options: any = {}) {
// Remove last mounted component if cy.mount is called more than once in a test
cleanup()

// TODO: get the real displayName and props from VTU shallowMount
const componentName = getComponentDisplayName(componentOptions)

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

const componentNode = document.createElement('div')

componentNode.id = '__cy_vue_root'
componentNode.id = VUE_ROOT

el.append(componentNode)

Expand Down Expand Up @@ -484,4 +478,4 @@ export function mountCallback (
// import { registerCT } from 'cypress/<my-framework>'
// registerCT()
// Note: This would be a breaking change
setupHooks()
setupHooks(cleanup)
18 changes: 8 additions & 10 deletions npm/vue2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
mount as testUtilsMount,
VueTestUtilsConfigOptions,
Wrapper,
enableAutoDestroy,
} from '@vue/test-utils'
import {
injectStylesBeforeElement,
Expand Down Expand Up @@ -266,6 +265,10 @@ declare global {
}
}

const cleanup = () => {
Cypress.vueWrapper?.destroy()
}

/**
* Direct Vue errors to the top error handler
* where they will fail Cypress test
Expand All @@ -280,14 +283,6 @@ function failTestOnVueError (err, vm, info) {
})
}

function registerAutoDestroy ($destroy: () => void) {
Cypress.on('test:before:run', () => {
$destroy()
})
}

enableAutoDestroy(registerAutoDestroy)

const injectStyles = (options: StyleOptions) => {
return injectStylesBeforeElement(options, document, getContainerEl())
}
Expand Down Expand Up @@ -336,6 +331,9 @@ export const mount = (
wrapper: Wrapper<Vue, Element>
component: Wrapper<Vue, Element>['vm']
}> => {
// Remove last mounted component if cy.mount is called more than once in a test
cleanup()

const options: Partial<MountOptions> = Cypress._.pick(
optionsOrProps,
defaultOptions,
Expand Down Expand Up @@ -442,4 +440,4 @@ export const mountCallback = (
// import { registerCT } from 'cypress/<my-framework>'
// registerCT()
// Note: This would be a breaking change
setupHooks()
setupHooks(cleanup)
2 changes: 1 addition & 1 deletion npm/webpack-dev-server/src/devServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ async function getPreset (devServerConfig: WebpackDevServerConfig): Promise<Opti
return { sourceWebpackModulesResult: sourceDefaultWebpackDependencies(devServerConfig) }

default:
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`)
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`)
}
}

Expand Down
25 changes: 9 additions & 16 deletions packages/app/src/runs/RunResults.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,17 @@ import RunResults from './RunResults.vue'

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

cy.mountFragment(RunCardFragmentDoc, {
onResult (result) {
Object.keys(result).forEach((key) => {
result[key] = res[key]
})
},
render (props) {
return <RunResults gql={props} />
},
})
cy.mount(() => cloudRuns.map((cloudRun, i) => (<RunResults data-cy={`run-result-${i}`} gql={cloudRun} />)))

cy.get(`[title=${defaultMessages.runs.results.passed}]`).should('contain.text', res.totalPassed)
cy.get(`[title=${defaultMessages.runs.results.failed}]`).should('contain.text', res.totalFailed)
cy.get(`[title=${defaultMessages.runs.results.skipped}]`).should('contain.text', res.totalSkipped)
cy.get(`[title=${defaultMessages.runs.results.pending}`).should('contain.text', res.totalPending)
cloudRuns.forEach((cloudRun, i) => {
cy.get(`[data-cy=run-result-${i}]`).within(() => {
cy.get(`[title=${defaultMessages.runs.results.passed}]`).should('contain.text', cloudRun.totalPassed)
cy.get(`[title=${defaultMessages.runs.results.failed}]`).should('contain.text', cloudRun.totalFailed)
cy.get(`[title=${defaultMessages.runs.results.skipped}]`).should('contain.text', cloudRun.totalSkipped)
cy.get(`[title=${defaultMessages.runs.results.pending}]`).should('contain.text', cloudRun.totalPending)
})
})

cy.percySnapshot()
Expand Down
18 changes: 5 additions & 13 deletions packages/app/src/specs/SpecsListHeader.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,27 +110,19 @@ describe('<SpecsListHeader />', { keystrokeDelay: 0 }, () => {
})

it('shows the count correctly while searching', () => {
const mountWithCounts = (resultCount = 0, specCount = 0) => {
cy.mount(() => (<div class="max-w-800px p-12 resize overflow-auto"><SpecsListHeader
const counts = [[0, 0], [0, 22], [0, 1], [1, 1], [5, 22]]

cy.mount(() => counts.map(([resultCount, specCount]) => (
<div class="max-w-800px p-12 resize overflow-auto"><SpecsListHeader
modelValue={'foo'}
resultCount={resultCount}
specCount={specCount}
/></div>))
}
/></div>)))

mountWithCounts(0, 0)
cy.contains('No matches')

mountWithCounts(0, 22)
cy.contains('0 of 22 matches')

mountWithCounts(0, 1)
cy.contains('0 of 1 match').should('be.visible')

mountWithCounts(1, 1)
cy.contains('1 of 1 match').should('be.visible')

mountWithCounts(5, 22)
cy.contains('5 of 22 matches').should('be.visible')

cy.percySnapshot()
Expand Down
7 changes: 5 additions & 2 deletions packages/frontend-shared/src/components/Alert.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ const suffixIcon = () => <LoadingIcon data-cy="loading-icon" class="animate-spin
describe('<Alert />', () => {
describe('classes', () => {
it('can change the text and background color for the alert', () => {
cy.mount(() => <Alert headerClass="underline text-pink-500 bg-pink-100" bodyClass="bg-pink-50" icon={suffixIcon}>test</Alert>)
cy.mount(() => <Alert headerClass="underline text-teal-500 bg-teal-100" bodyClass="bg-teal-50" icon={suffixIcon}>test</Alert>)
cy.mount(() =>
(<div class="flex flex-col m-8px gap-8px">
<Alert headerClass="underline text-pink-500 bg-pink-100" bodyClass="bg-pink-50" icon={suffixIcon}>test</Alert>
<Alert headerClass="underline text-teal-500 bg-teal-100" bodyClass="bg-teal-50" icon={suffixIcon}>test</Alert>
</div>))

cy.percySnapshot()
})
Expand Down

0 comments on commit f39eb1c

Please sign in to comment.