diff --git a/.github/ISSUE_TEMPLATE/4-flaky-test.yml b/.github/ISSUE_TEMPLATE/4-flaky-test.yml new file mode 100644 index 000000000000..abcc4baa9553 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/4-flaky-test.yml @@ -0,0 +1,44 @@ +name: "❄️ Flaky test in `cypress-io/cypress` repository" +title: "Flaky test: " +labels: ["topic: flake ❄️", "stage: fire watch"] +description: Report a flaky test in the Cypress open-source repository. +body: + - type: markdown + attributes: + value: | + Have a question? 👉 [Start a new discussion](https://github.com/cypress-io/cypress/discussions) or [ask in chat](https://on.cypress.io/discord). + - type: textarea + id: dashboard + attributes: + label: Link to dashboard or CircleCI failure + description: Please include a link to the failure in the Cypress Dashboard or in CircleCI. + validations: + required: true + - type: textarea + id: github-link + attributes: + label: Link to failing test in GitHub + description: Provide the GitHub link to the failing test with the line number. + validations: + required: true + - type: textarea + id: analysis + attributes: + label: Analysis + description: If you can, provide a quick analysis of why this test is flaky. + placeholder: ex. The test appears to be flaky because... + validations: + required: true + - type: input + id: version + attributes: + label: Cypress Version + description: Provide the version of Cypress where the flake is occurring. + placeholder: ex. 10.4.0 + validations: + required: true + - type: textarea + id: other + attributes: + label: Other + placeholder: Any other details? diff --git a/browser-versions.json b/browser-versions.json index 32af1b24f52c..381e5fab2ea3 100644 --- a/browser-versions.json +++ b/browser-versions.json @@ -1,4 +1,4 @@ { - "chrome:beta": "104.0.5112.79", + "chrome:beta": "105.0.5195.19", "chrome:stable": "104.0.5112.79" } diff --git a/npm/angular/package.json b/npm/angular/package.json index bd35918c57ce..9ebd6c020c74 100644 --- a/npm/angular/package.json +++ b/npm/angular/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "scripts": { "prebuild": "rimraf dist", - "build": "tsc || echo 'built, with type errors'", + "build": "rollup -c rollup.config.js", "postbuild": "node ../../scripts/sync-exported-npm-with-cli.js", "build-prod": "yarn build", "check-ts": "tsc --noEmit" @@ -15,6 +15,8 @@ "@angular/common": "^14.0.6", "@angular/core": "^14.0.6", "@angular/platform-browser-dynamic": "^14.0.6", + "@rollup/plugin-node-resolve": "^11.1.1", + "rollup-plugin-typescript2": "^0.29.0", "typescript": "~4.2.3", "zone.js": "~0.11.4" }, diff --git a/npm/angular/rollup.config.js b/npm/angular/rollup.config.js new file mode 100644 index 000000000000..975310fc9131 --- /dev/null +++ b/npm/angular/rollup.config.js @@ -0,0 +1,61 @@ +import ts from 'rollup-plugin-typescript2' +import resolve from '@rollup/plugin-node-resolve' + +import pkg from './package.json' + +const banner = ` +/** + * ${pkg.name} v${pkg.version} + * (c) ${new Date().getFullYear()} Cypress.io + * Released under the MIT License + */ +` + +function createEntry () { + const input = 'src/index.ts' + const format = 'es' + + const config = { + input, + external: [ + '@angular/core', + '@angular/core/testing', + '@angular/common', + '@angular/platform-browser-dynamic/testing', + 'zone.js', + 'zone.js/testing', + ], + plugins: [ + resolve(), + ], + output: { + banner, + name: 'CypressAngular', + file: pkg.module, + format, + exports: 'auto', + }, + } + + console.log(`Building ${format}: ${config.output.file}`) + + config.plugins.push( + ts({ + check: true, + tsconfigOverride: { + compilerOptions: { + declaration: true, + target: 'es6', // not sure what this should be? + module: 'esnext', + }, + exclude: [], + }, + }), + ) + + return config +} + +export default [ + createEntry(), +] diff --git a/npm/angular/src/mount.ts b/npm/angular/src/mount.ts index 15a41bdca9d7..35b9c2f6917c 100644 --- a/npm/angular/src/mount.ts +++ b/npm/angular/src/mount.ts @@ -8,7 +8,7 @@ window.Mocha['__zone_patch__'] = false import 'zone.js/testing' import { CommonModule } from '@angular/common' -import { Type } from '@angular/core' +import { Component, EventEmitter, Type } from '@angular/core' import { ComponentFixture, getTestBed, @@ -19,24 +19,55 @@ import { BrowserDynamicTestingModule, platformBrowserDynamicTesting, } from '@angular/platform-browser-dynamic/testing' +import { + setupHooks, +} from '@cypress/mount-utils' /** * Additional module configurations needed while mounting the component, like * providers, declarations, imports and even component @Inputs() * * - * @interface TestBedConfig + * @interface MountConfig * @see https://angular.io/api/core/testing/TestModuleMetadata */ -export interface TestBedConfig extends TestModuleMetadata { +export interface MountConfig extends TestModuleMetadata { + /** + * @memberof MountConfig + * @description flag to automatically create a cy.spy() for every component @Output() property + * @example + * export class ButtonComponent { + * @Output clicked = new EventEmitter() + * } + * + * cy.mount(ButtonComponent, { autoSpyOutputs: true }) + * cy.get('@clickedSpy).should('have.been.called') + */ + autoSpyOutputs?: boolean + + /** + * @memberof MountConfig + * @description flag defaulted to true to automatically detect changes in your components + */ + autoDetectChanges?: boolean /** - * @memberof TestBedConfig + * @memberof MountConfig * @example * import { ButtonComponent } from 'button/button.component' * it('renders a button with Save text', () => { * cy.mount(ButtonComponent, { componentProperties: { text: 'Save' }}) * cy.get('button').contains('Save') * }) + * + * it('renders a button with a cy.spy() replacing EventEmitter', () => { + * cy.mount(ButtonComponent, { + * componentProperties: { + * clicked: cy.spy().as('mySpy) + * } + * }) + * cy.get('button').click() + * cy.get('@mySpy').should('have.been.called') + * }) */ componentProperties?: Partial<{ [P in keyof T]: T[P] }> } @@ -45,7 +76,7 @@ export interface TestBedConfig extends TestModuleMetadata { * Type that the `mount` function returns * @type MountResponse */ -export type MountResponse = { +export type MountResponse = { /** * Fixture for debugging and testing a component. * @@ -54,14 +85,6 @@ export type MountResponse = { */ fixture: ComponentFixture - /** - * Configures and initializes environment and provides methods for creating components and services. - * - * @memberof MountResponse - * @see https://angular.io/api/core/testing/TestBed - */ - testBed: TestBed - /** * The instance of the root component class * @@ -75,13 +98,13 @@ export type MountResponse = { * Bootstraps the TestModuleMetaData passed to the TestBed * * @param {Type} component Angular component being mounted - * @param {TestBedConfig} config TestBed configuration passed into the mount function - * @returns {TestBedConfig} TestBedConfig + * @param {MountConfig} config TestBed configuration passed into the mount function + * @returns {MountConfig} MountConfig */ -function bootstrapModule ( +function bootstrapModule ( component: Type, - config: TestBedConfig, -): TestBedConfig { + config: MountConfig, +): MountConfig { const { componentProperties, ...testModuleMetaData } = config if (!testModuleMetaData.declarations) { @@ -92,7 +115,12 @@ function bootstrapModule ( testModuleMetaData.imports = [] } - testModuleMetaData.declarations.push(component) + // check if the component is a standalone component + if ((component as any).ɵcmp.standalone) { + testModuleMetaData.imports.push(component) + } else { + testModuleMetaData.declarations.push(component) + } if (!testModuleMetaData.imports.includes(CommonModule)) { testModuleMetaData.imports.push(CommonModule) @@ -104,74 +132,84 @@ function bootstrapModule ( /** * Initializes the TestBed * - * @param {Type} component Angular component being mounted - * @param {TestBedConfig} config TestBed configuration passed into the mount function - * @returns {TestBed} TestBed + * @param {Type | string} component Angular component being mounted or its template + * @param {MountConfig} config TestBed configuration passed into the mount function + * @returns {Type} componentFixture */ -function initTestBed ( - component: Type, - config: TestBedConfig, -): TestBed { +function initTestBed ( + component: Type | string, + config: MountConfig, +): Type { const { providers, ...configRest } = config - const testBed: TestBed = getTestBed() + const componentFixture = createComponentFixture(component) as Type - testBed.resetTestEnvironment() - - testBed.initTestEnvironment( - BrowserDynamicTestingModule, - platformBrowserDynamicTesting(), - { - teardown: { destroyAfterEach: false }, - }, - ) - - testBed.configureTestingModule({ - ...bootstrapModule(component, configRest), + TestBed.configureTestingModule({ + ...bootstrapModule(componentFixture, configRest), }) if (providers != null) { - testBed.overrideComponent(component, { + TestBed.overrideComponent(componentFixture, { add: { providers, }, }) } - return testBed + return componentFixture +} + +@Component({ selector: 'cy-wrapper-component', template: '' }) +class WrapperComponent { } + +/** + * Returns the Component if Type or creates a WrapperComponent + * + * @param {Type | string} component The component you want to create a fixture of + * @returns {Type | WrapperComponent} + */ +function createComponentFixture ( + component: Type | string, +): Type { + if (typeof component === 'string') { + TestBed.overrideTemplate(WrapperComponent, component) + + return WrapperComponent + } + + return component } /** * Creates the ComponentFixture * - * @param component Angular component being mounted - * @param testBed TestBed - * @param autoDetectChanges boolean flag defaulted to true that turns on change detection automatically + * @param {Type} component Angular component being mounted + * @param {MountConfig} config MountConfig + * @returns {ComponentFixture} ComponentFixture */ -function setupFixture ( +function setupFixture ( component: Type, - testBed: TestBed, - autoDetectChanges: boolean, + config: MountConfig, ): ComponentFixture { - const fixture = testBed.createComponent(component) + const fixture = TestBed.createComponent(component) fixture.whenStable().then(() => { - fixture.autoDetectChanges(autoDetectChanges) + fixture.autoDetectChanges(config.autoDetectChanges ?? true) }) return fixture } /** - * Gets the componentInstance and Object.assigns any componentProperties() passed in the TestBedConfig + * Gets the componentInstance and Object.assigns any componentProperties() passed in the MountConfig * - * @param {TestBedConfig} config TestBed configuration passed into the mount function + * @param {MountConfig} config TestBed configuration passed into the mount function * @param {ComponentFixture} fixture Fixture for debugging and testing a component. * @returns {T} Component being mounted */ -function setupComponent ( - config: TestBedConfig, +function setupComponent ( + config: MountConfig, fixture: ComponentFixture, ): T { let component: T = fixture.componentInstance @@ -180,15 +218,24 @@ function setupComponent ( component = Object.assign(component, config.componentProperties) } + if (config.autoSpyOutputs) { + Object.keys(component).forEach((key: string, index: number, keys: string[]) => { + const property = component[key] + + if (property instanceof EventEmitter) { + component[key] = createOutputSpy(`${key}Spy`) + } + }) + } + return component } /** * Mounts an Angular component inside Cypress browser * - * @param {Type} component imported from angular file - * @param {TestBedConfig} config configuration used to configure the TestBed - * @param {boolean} autoDetectChanges boolean flag defaulted to true that turns on change detection automatically + * @param {Type | string} component Angular component being mounted or its template + * @param {MountConfig} config configuration used to configure the TestBed * @example * import { HelloWorldComponent } from 'hello-world/hello-world.component' * import { MyService } from 'services/my.service' @@ -201,28 +248,67 @@ function setupComponent ( * }) * cy.get('h1').contains('Hello World') * }) + * + * or + * + * it('can mount with template', () => { + * mount('', { + * declarations: [HelloWorldComponent], + * providers: [MyService], + * imports: [SharedModule] + * }) + * }) * @returns Cypress.Chainable> */ -export function mount ( - component: Type, - config: TestBedConfig = {}, - autoDetectChanges = true, +export function mount ( + component: Type | string, + config: MountConfig = { }, ): Cypress.Chainable> { - const testBed: TestBed = initTestBed(component, config) - const fixture = setupFixture(component, testBed, autoDetectChanges) + const componentFixture = initTestBed(component, config) + const fixture = setupFixture(componentFixture, config) const componentInstance = setupComponent(config, fixture) const mountResponse: MountResponse = { - testBed, fixture, component: componentInstance, } + const logMessage = typeof component === 'string' ? 'Component' : componentFixture.name + Cypress.log({ name: 'mount', - message: component.name, + message: logMessage, consoleProps: () => ({ result: mountResponse }), }) return cy.wrap(mountResponse, { log: false }) } + +/** + * Creates a new Event Emitter and then spies on it's `emit` method + * + * @param {string} alias name you want to use for your cy.spy() alias + * @returns EventEmitter + */ +export const createOutputSpy = (alias: string) => { + const emitter = new EventEmitter() + + cy.spy(emitter, 'emit').as(alias) + + return emitter as any +} + +// Only needs to run once, we reset before each test +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), + { + teardown: { destroyAfterEach: false }, + }, +) + +setupHooks(() => { + // Not public, we need to call this to remove the last component from the DOM + TestBed['tearDownTestingModule']() + TestBed.resetTestingModule() +}) diff --git a/npm/angular/tsconfig.json b/npm/angular/tsconfig.json index d09e57c203ce..b21ac64dacea 100644 --- a/npm/angular/tsconfig.json +++ b/npm/angular/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "experimentalDecorators": true, "target": "es2020", "module": "es2020", "skipLibCheck": true, diff --git a/npm/webpack-dev-server/cypress/e2e/angular.cy.ts b/npm/webpack-dev-server/cypress/e2e/angular.cy.ts index 7731aba900c4..312ac07c8161 100644 --- a/npm/webpack-dev-server/cypress/e2e/angular.cy.ts +++ b/npm/webpack-dev-server/cypress/e2e/angular.cy.ts @@ -69,13 +69,5 @@ for (const project of WEBPACK_REACT) { cy.waitForSpecToFinish() cy.get('.passed > .num').should('contain', 1) }) - - it('proves out mount API', () => { - cy.visitApp() - - cy.contains('mount.cy.ts').click() - cy.waitForSpecToFinish() - cy.get('.passed > .num').should('contain', 6) - }) }) } diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts index 498db301a25d..4b9758b04026 100644 --- a/packages/config/src/options.ts +++ b/packages/config/src/options.ts @@ -39,7 +39,6 @@ interface ResolvedConfigOption { * Can be mutated with Cypress.config() or test-specific configuration overrides */ canUpdateDuringTestTime?: boolean - specificTestingType?: TestingType requireRestartOnChange?: 'server' | 'browser' } diff --git a/packages/data-context/src/DataContext.ts b/packages/data-context/src/DataContext.ts index de305f9702ba..1cb4dd4be1f8 100644 --- a/packages/data-context/src/DataContext.ts +++ b/packages/data-context/src/DataContext.ts @@ -157,20 +157,11 @@ export class DataContext { } get baseError () { - return this.coreData.currentProjectData?.testingTypeData?.activeAppData?.error - ?? this.coreData.currentProjectData?.testingTypeData?.error - ?? this.coreData.currentProjectData?.error - ?? this.coreData.baseError - ?? null + return this.coreData.diagnostics.error } get warnings () { - return [ - ...this.coreData.currentProjectData?.testingTypeData?.activeAppData?.warnings ?? [], - ...this.coreData.currentProjectData?.testingTypeData?.warnings ?? [], - ...this.coreData.currentProjectData?.warnings ?? [], - ...this.coreData.warnings ?? [], - ] + return this.coreData.diagnostics.warnings } @cached @@ -386,14 +377,8 @@ export class DataContext { } this.update((d) => { - if (d.currentProjectData?.testingTypeData?.activeAppData) { - d.currentProjectData.testingTypeData.activeAppData.error = err - } else if (d.currentProjectData?.testingTypeData) { - d.currentProjectData.testingTypeData.error = err - } else if (d.currentProjectData) { - d.currentProjectData.error = err - } else { - d.baseError = err + if (d.diagnostics) { + d.diagnostics.error = err } }) @@ -413,15 +398,7 @@ export class DataContext { } this.update((d) => { - if (d.currentProjectData?.testingTypeData?.activeAppData) { - d.currentProjectData.testingTypeData.activeAppData.warnings.push(warning) - } else if (d.currentProjectData?.testingTypeData) { - d.currentProjectData.testingTypeData.warnings.push(warning) - } else if (d.currentProjectData) { - d.currentProjectData.warnings.push(warning) - } else { - d.warnings.push(warning) - } + d.diagnostics.warnings.push(warning) }) this.emitter.errorWarningChange() diff --git a/packages/data-context/src/actions/BrowserActions.ts b/packages/data-context/src/actions/BrowserActions.ts index ac4f1b9d1ae3..a78dd045cff9 100644 --- a/packages/data-context/src/actions/BrowserActions.ts +++ b/packages/data-context/src/actions/BrowserActions.ts @@ -30,9 +30,6 @@ export class BrowserActions { this.ctx.update((d) => { d.activeBrowser = browser - if (d.currentProjectData?.testingTypeData) { - d.currentProjectData.testingTypeData.activeAppData = { error: null, warnings: [] } - } }) this.ctx._apis.projectApi.insertProjectPreferencesToCache(this.ctx.lifecycleManager.projectTitle, { diff --git a/packages/data-context/src/actions/ErrorActions.ts b/packages/data-context/src/actions/ErrorActions.ts index 5e15f61aa8a7..c61edf1e576a 100644 --- a/packages/data-context/src/actions/ErrorActions.ts +++ b/packages/data-context/src/actions/ErrorActions.ts @@ -9,20 +9,8 @@ export class ErrorActions { */ clearError (id: string) { this.ctx.update((d) => { - if (d.currentProjectData?.testingTypeData?.activeAppData?.error?.id === id) { - d.currentProjectData.testingTypeData.activeAppData.error = null - } - - if (d.currentProjectData?.testingTypeData?.error?.id === id) { - d.currentProjectData.testingTypeData.error = null - } - - if (d.currentProjectData?.error?.id === id) { - d.currentProjectData.error = null - } - - if (d.baseError?.id === id) { - d.baseError = null + if (d.diagnostics.error?.id === id) { + d.diagnostics.error = null } }) } @@ -33,34 +21,10 @@ export class ErrorActions { */ clearWarning (id: string) { this.ctx.update((d) => { - const warningsIndex = d.warnings.findIndex((v) => v.id === id) - - if (warningsIndex != null && warningsIndex !== -1) { - d.warnings.splice(warningsIndex, 1) - - return - } - - const projectWarningsIndex = d.currentProjectData?.warnings.findIndex((v) => v.id === id) - - if (projectWarningsIndex != null && projectWarningsIndex !== -1) { - d.currentProjectData?.warnings.splice(projectWarningsIndex, 1) - - return - } - - const testingTypeWarningsIndex = d.currentProjectData?.testingTypeData?.warnings.findIndex((v) => v.id === id) - - if (testingTypeWarningsIndex != null && testingTypeWarningsIndex !== -1) { - d.currentProjectData?.testingTypeData?.warnings.splice(testingTypeWarningsIndex, 1) - - return - } - - const appWarningsIndex = d.currentProjectData?.testingTypeData?.activeAppData?.warnings.findIndex((v) => v.id === id) + const warningsIndex = d.diagnostics.warnings.findIndex((v) => v.id === id) - if (appWarningsIndex != null && appWarningsIndex !== -1) { - d.currentProjectData?.testingTypeData?.activeAppData?.warnings.splice(appWarningsIndex, 1) + if (warningsIndex !== -1) { + d.diagnostics.warnings.splice(warningsIndex, 1) } }) } diff --git a/packages/data-context/src/actions/ProjectActions.ts b/packages/data-context/src/actions/ProjectActions.ts index c5075c7c5843..e28454505688 100644 --- a/packages/data-context/src/actions/ProjectActions.ts +++ b/packages/data-context/src/actions/ProjectActions.ts @@ -83,10 +83,13 @@ export class ProjectActions { async clearCurrentProject () { this.ctx.update((d) => { - d.baseError = null d.activeBrowser = null d.currentProject = null - d.currentProjectData = null + d.diagnostics = { + error: null, + warnings: [], + } + d.currentTestingType = null d.forceReconfigureProject = null d.scaffoldedFiles = null diff --git a/packages/data-context/src/data/ProjectLifecycleManager.ts b/packages/data-context/src/data/ProjectLifecycleManager.ts index a4181d97bedd..c414b6cd1707 100644 --- a/packages/data-context/src/data/ProjectLifecycleManager.ts +++ b/packages/data-context/src/data/ProjectLifecycleManager.ts @@ -23,7 +23,6 @@ import { EventRegistrar } from './EventRegistrar' import { getServerPluginHandlers, resetPluginHandlers } from '../util/pluginHandlers' import { detectLanguage } from '@packages/scaffold-config' import { validateNeedToRestartOnChange } from '@packages/config' -import { makeTestingTypeData } from './coreDataShape' export interface SetupFullConfigOptions { projectName: string @@ -408,7 +407,7 @@ export class ProjectLifecycleManager { }) } - s.currentProjectData = { error: null, warnings: [], testingTypeData: null } + s.diagnostics = { error: null, warnings: [] } s.packageManager = packageManagerUsed }) @@ -495,8 +494,11 @@ export class ProjectLifecycleManager { d.currentTestingType = testingType d.wizard.chosenBundler = null d.wizard.chosenFramework = null - if (d.currentProjectData) { - d.currentProjectData.testingTypeData = makeTestingTypeData(testingType) + if (testingType) { + d.diagnostics = { + error: null, + warnings: [], + } } }) @@ -516,9 +518,6 @@ export class ProjectLifecycleManager { d.currentTestingType = testingType d.wizard.chosenBundler = null d.wizard.chosenFramework = null - if (d.currentProjectData) { - d.currentProjectData.testingTypeData = makeTestingTypeData(testingType) - } }) if (this._currentTestingType === testingType) { diff --git a/packages/data-context/src/data/coreDataShape.ts b/packages/data-context/src/data/coreDataShape.ts index 6a3cc863b04d..df4048c658cc 100644 --- a/packages/data-context/src/data/coreDataShape.ts +++ b/packages/data-context/src/data/coreDataShape.ts @@ -107,23 +107,11 @@ export interface ForceReconfigureProjectDataShape { component?: boolean | null } -export interface ActiveAppData { +interface Diagnostics { error: ErrorWrapperSource | null warnings: ErrorWrapperSource[] } -export interface CurrentTestingTypeData { - error: ErrorWrapperSource | null - warnings: ErrorWrapperSource[] - activeAppData: ActiveAppData | null -} - -export interface CurrentProjectData { - error: ErrorWrapperSource | null - warnings: ErrorWrapperSource[] - testingTypeData: CurrentTestingTypeData | null -} - export interface CoreDataShape { cliBrowser: string | null cliTestingType: string | null @@ -139,7 +127,6 @@ export interface CoreDataShape { gqlSocketServer?: Maybe } hasInitializedMode: 'run' | 'open' | null - baseError: ErrorWrapperSource | null dashboardGraphQLError: ErrorWrapperSource | null dev: DevStateShape localSettings: LocalSettingsDataShape @@ -147,17 +134,13 @@ export interface CoreDataShape { currentProject: string | null currentProjectGitInfo: GitDataSource | null currentTestingType: TestingType | null - - // TODO: Move everything under this container, to make it simpler to reset the data when switching - currentProjectData: CurrentProjectData | null - + diagnostics: Diagnostics wizard: WizardDataShape migration: MigrationDataShape user: AuthenticatedUserShape | null electron: ElectronShape authState: AuthStateShape scaffoldedFiles: NexusGenObjects['ScaffoldedFile'][] | null - warnings: ErrorWrapperSource[] packageManager: typeof PACKAGE_MANAGERS[number] forceReconfigureProject: ForceReconfigureProjectDataShape | null versionData: { @@ -176,7 +159,6 @@ export function makeCoreData (modeOptions: Partial = {}): CoreDa cliTestingType: modeOptions.testingType ?? null, machineBrowsers: null, hasInitializedMode: null, - baseError: null, dashboardGraphQLError: null, dev: { refreshState: null, @@ -198,7 +180,7 @@ export function makeCoreData (modeOptions: Partial = {}): CoreDa browserOpened: false, }, currentProject: modeOptions.projectRoot ?? null, - currentProjectData: makeCurrentProjectData(modeOptions.projectRoot, modeOptions.testingType), + diagnostics: { error: null, warnings: [] }, currentProjectGitInfo: null, currentTestingType: modeOptions.testingType ?? null, wizard: { @@ -225,7 +207,6 @@ export function makeCoreData (modeOptions: Partial = {}): CoreDa shouldAddCustomE2ESpecPattern: false, }, }, - warnings: [], activeBrowser: null, user: null, electron: { @@ -238,27 +219,3 @@ export function makeCoreData (modeOptions: Partial = {}): CoreDa versionData: null, } } - -export function makeCurrentProjectData (projectRoot: Maybe, testingType: Maybe): CurrentProjectData | null { - if (projectRoot) { - return { - error: null, - warnings: [], - testingTypeData: makeTestingTypeData(testingType), - } - } - - return null -} - -export function makeTestingTypeData (testingType: Maybe): CurrentTestingTypeData | null { - if (testingType) { - return { - error: null, - warnings: [], - activeAppData: null, - } - } - - return null -} diff --git a/packages/data-context/src/sources/BrowserDataSource.ts b/packages/data-context/src/sources/BrowserDataSource.ts index 94b6999ee580..1ed4746af6c6 100644 --- a/packages/data-context/src/sources/BrowserDataSource.ts +++ b/packages/data-context/src/sources/BrowserDataSource.ts @@ -48,7 +48,7 @@ export class BrowserDataSource { }).catch((e) => { this.ctx.update((coreData) => { coreData.machineBrowsers = null - coreData.baseError = e + coreData.diagnostics.error = e }) throw e diff --git a/packages/graphql/src/plugins/nexusMutationErrorPlugin.ts b/packages/graphql/src/plugins/nexusMutationErrorPlugin.ts index fae0d4d2446c..b7ed5c736bbd 100644 --- a/packages/graphql/src/plugins/nexusMutationErrorPlugin.ts +++ b/packages/graphql/src/plugins/nexusMutationErrorPlugin.ts @@ -15,7 +15,7 @@ export const mutationErrorPlugin = plugin({ return (source, args, ctx: DataContext, info, next) => { return plugin.completeValue(next(source, args, ctx, info), (v) => v, (err) => { ctx.update((d) => { - d.baseError = { + d.diagnostics.error = { id: _.uniqueId('Error'), cypressError: err.isCypressErr ? err diff --git a/packages/net-stubbing/lib/server/index.ts b/packages/net-stubbing/lib/server/index.ts index c0383655b165..4fb680325593 100644 --- a/packages/net-stubbing/lib/server/index.ts +++ b/packages/net-stubbing/lib/server/index.ts @@ -8,7 +8,7 @@ export { InterceptResponse } from './middleware/response' export { NetStubbingState } from './types' -export { getRouteForRequest } from './route-matching' +export { getRoutesForRequest } from './route-matching' import { state } from './state' diff --git a/packages/net-stubbing/lib/server/middleware/request.ts b/packages/net-stubbing/lib/server/middleware/request.ts index 54e15d752ba9..314856b74572 100644 --- a/packages/net-stubbing/lib/server/middleware/request.ts +++ b/packages/net-stubbing/lib/server/middleware/request.ts @@ -10,7 +10,7 @@ import { CyHttpMessages, SERIALIZABLE_REQ_PROPS, } from '../../types' -import { getRouteForRequest, matchesRoutePreflight } from '../route-matching' +import { getRoutesForRequest, matchesRoutePreflight } from '../route-matching' import { sendStaticResponse, setDefaultHeaders, @@ -41,21 +41,7 @@ export const InterceptRequest: RequestMiddleware = async function () { }) } - const matchingRoutes: BackendRoute[] = [] - - const populateMatchingRoutes = (prevRoute?) => { - const route = getRouteForRequest(this.netStubbingState.routes, this.req, prevRoute) - - if (!route) { - return - } - - matchingRoutes.push(route) - - populateMatchingRoutes(route) - } - - populateMatchingRoutes() + const matchingRoutes: BackendRoute[] = [...getRoutesForRequest(this.netStubbingState.routes, this.req)] if (!matchingRoutes.length) { // not intercepted, carry on normally... diff --git a/packages/net-stubbing/lib/server/route-matching.ts b/packages/net-stubbing/lib/server/route-matching.ts index a6ef43b1db1b..587028f8772f 100644 --- a/packages/net-stubbing/lib/server/route-matching.ts +++ b/packages/net-stubbing/lib/server/route-matching.ts @@ -123,22 +123,19 @@ export function _getMatchableForRequest (req: CypressIncomingRequest) { } /** - * Try to match a `BackendRoute` to a request, optionally starting after `prevRoute`. + * Find all `BackendRoute`s that match the supplied request. */ -export function getRouteForRequest (routes: BackendRoute[], req: CypressIncomingRequest, prevRoute?: BackendRoute) { +export function* getRoutesForRequest (routes: BackendRoute[], req: CypressIncomingRequest) { const [middleware, handlers] = _.partition(routes, (route) => route.routeMatcher.middleware === true) // First, match the oldest matching route handler with `middleware: true`. // Then, match the newest matching route handler. const orderedRoutes = middleware.concat(handlers.reverse()) - const possibleRoutes = prevRoute ? orderedRoutes.slice(_.findIndex(orderedRoutes, prevRoute) + 1) : orderedRoutes - for (const route of possibleRoutes) { + for (const route of orderedRoutes) { if (!route.disabled && _doesRouteMatch(route.routeMatcher, req)) { - return route + yield route } } - - return } function isPreflightRequest (req: CypressIncomingRequest) { diff --git a/packages/net-stubbing/test/unit/route-matching-spec.ts b/packages/net-stubbing/test/unit/route-matching-spec.ts index 3c78e3934fa3..0d629d0ef532 100644 --- a/packages/net-stubbing/test/unit/route-matching-spec.ts +++ b/packages/net-stubbing/test/unit/route-matching-spec.ts @@ -1,7 +1,7 @@ import { _doesRouteMatch, _getMatchableForRequest, - getRouteForRequest, + getRoutesForRequest, } from '../../lib/server/route-matching' import { RouteMatcherOptions } from '../../lib/types' import { expect } from 'chai' @@ -172,7 +172,7 @@ describe('intercept-request', function () { }) }) - context('.getRouteForRequest', function () { + context('.getRoutesForRequest', function () { it('matches middleware, then handlers', function () { const routes: Partial[] = [ { @@ -209,15 +209,53 @@ describe('intercept-request', function () { proxiedUrl: 'http://bar.baz/foo?_', } - let prevRoute: BackendRoute - let e: string[] = [] + const e: string[] = [] // @ts-ignore - while ((prevRoute = getRouteForRequest(routes, req, prevRoute))) { - e.push(prevRoute.id) + for (const route of getRoutesForRequest(routes, req)) { + e.push(route.id) } expect(e).to.deep.eq(['1', '3', '4', '2']) }) + + it('yields identical matches', function () { + // This is a reproduction of issue #22693 + const routes: Partial[] = [ + { + id: '1', + routeMatcher: { + pathname: '/foo', + }, + }, + { + id: '1', + routeMatcher: { + pathname: '/foo', + }, + }, + { + id: '2', + routeMatcher: { + pathname: '/bar', + }, + }, + ] + + const req: Partial = { + method: 'GET', + headers: {}, + proxiedUrl: 'https://example.com/foo', + } + + const matchedRouteIds: string[] = [] + + // @ts-ignore + for (const route of getRoutesForRequest(routes, req)) { + matchedRouteIds.push(route.id) + } + + expect(matchedRouteIds).to.deep.eq(['1', '1']) + }) }) }) diff --git a/packages/server/lib/server-e2e.ts b/packages/server/lib/server-e2e.ts index 9867dee8a34d..67bd35e0eb8e 100644 --- a/packages/server/lib/server-e2e.ts +++ b/packages/server/lib/server-e2e.ts @@ -5,7 +5,7 @@ import _ from 'lodash' import stream from 'stream' import url from 'url' import httpsProxy from '@packages/https-proxy' -import { getRouteForRequest } from '@packages/net-stubbing' +import { getRoutesForRequest } from '@packages/net-stubbing' import { concatStream, cors } from '@packages/network' import { graphqlWS } from '@packages/graphql/src/makeGraphQLServer' @@ -190,7 +190,11 @@ export class ServerE2E extends ServerBase { } // @ts-ignore - return !!getRouteForRequest(this.netStubbingState?.routes, proxiedReq) + const iterator = getRoutesForRequest(this.netStubbingState?.routes, proxiedReq) + // If the iterator is exhausted (done) on the first try, then 0 matches were found + const zeroMatches = iterator.next().done + + return !zeroMatches } return this._urlResolver = (p = new Bluebird>((resolve, reject, onCancel) => { diff --git a/system-tests/__snapshots__/component_testing_spec.ts.js b/system-tests/__snapshots__/component_testing_spec.ts.js index 2d6049be80de..c2fa0bef51d0 100644 --- a/system-tests/__snapshots__/component_testing_spec.ts.js +++ b/system-tests/__snapshots__/component_testing_spec.ts.js @@ -572,7 +572,7 @@ We detected that you have versions of dependencies that are not officially suppo If you're experiencing problems, downgrade dependencies and restart Cypress. - 28 modules + 29 modules ==================================================================================================== @@ -581,7 +581,7 @@ If you're experiencing problems, downgrade dependencies and restart Cypress. ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │ Cypress: 1.2.3 │ │ Browser: FooBrowser 88 │ - │ Specs: 2 found (fails.spec.js, foo.spec.js) │ + │ Specs: 3 found (fails.spec.js, foo.spec.js, retries.spec.js) │ │ Searched: **/*.spec.js │ │ Experiments: experimentalSingleTabRunMode=true │ └────────────────────────────────────────────────────────────────────────────────────────────────┘ @@ -589,10 +589,10 @@ If you're experiencing problems, downgrade dependencies and restart Cypress. ──────────────────────────────────────────────────────────────────────────────────────────────────── - Running: fails.spec.js (1 of 2) + Running: fails.spec.js (1 of 3) - simple passing spec + simple failing spec 1) fails 2) fails again @@ -600,7 +600,7 @@ If you're experiencing problems, downgrade dependencies and restart Cypress. 0 passing 2 failing - 1) simple passing spec + 1) simple failing spec fails: AssertionError: expected 1 to equal 2 @@ -611,7 +611,7 @@ If you're experiencing problems, downgrade dependencies and restart Cypress. [stack trace lines] - 2) simple passing spec + 2) simple failing spec fails again: AssertionError: expected 1 to equal 3 @@ -642,9 +642,9 @@ If you're experiencing problems, downgrade dependencies and restart Cypress. (Screenshots) - - /XXX/XXX/XXX/cypress/screenshots/fails.spec.js/simple passing spec -- fails (fai (1280x720) + - /XXX/XXX/XXX/cypress/screenshots/fails.spec.js/simple failing spec -- fails (fai (1280x720) led).png - - /XXX/XXX/XXX/cypress/screenshots/fails.spec.js/simple passing spec -- fails agai (1280x720) + - /XXX/XXX/XXX/cypress/screenshots/fails.spec.js/simple failing spec -- fails agai (1280x720) n (failed).png @@ -656,7 +656,7 @@ If you're experiencing problems, downgrade dependencies and restart Cypress. ──────────────────────────────────────────────────────────────────────────────────────────────────── - Running: foo.spec.js (2 of 2) + Running: foo.spec.js (2 of 3) component @@ -690,6 +690,40 @@ If you're experiencing problems, downgrade dependencies and restart Cypress. - Finished processing: /XXX/XXX/XXX/cypress/videos/foo.spec.js.mp4 (X second) +──────────────────────────────────────────────────────────────────────────────────────────────────── + + Running: retries.spec.js (3 of 3) + + + retries + (Attempt 1 of 2) passes after 1 failure + ✓ passes after 1 failure + + + 1 passing + + + (Results) + + ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ + │ Tests: 1 │ + │ Passing: 1 │ + │ Failing: 0 │ + │ Pending: 0 │ + │ Skipped: 0 │ + │ Screenshots: 0 │ + │ Video: true │ + │ Duration: X seconds │ + │ Spec Ran: retries.spec.js │ + └────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + (Video) + + - Started processing: Compressing to 32 CRF + - Finished processing: /XXX/XXX/XXX/cypress/videos/retries.spec.js.mp4 (X second) + + ==================================================================================================== (Run Finished) @@ -700,8 +734,10 @@ If you're experiencing problems, downgrade dependencies and restart Cypress. │ ✖ fails.spec.js XX:XX 2 - 2 - - │ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │ ✔ foo.spec.js XX:XX 1 1 - - - │ + ├────────────────────────────────────────────────────────────────────────────────────────────────┤ + │ ✔ retries.spec.js XX:XX 1 1 - - - │ └────────────────────────────────────────────────────────────────────────────────────────────────┘ - ✖ 1 of 2 failed (50%) XX:XX 3 1 2 - - + ✖ 1 of 3 failed (33%) XX:XX 4 2 2 - - ` diff --git a/system-tests/project-fixtures/angular/src/app/components/button-output.component.ts b/system-tests/project-fixtures/angular/src/app/components/button-output.component.ts index e8228a5e7528..891dc8761a92 100644 --- a/system-tests/project-fixtures/angular/src/app/components/button-output.component.ts +++ b/system-tests/project-fixtures/angular/src/app/components/button-output.component.ts @@ -1,6 +1,7 @@ import { Component, EventEmitter, Output } from "@angular/core"; @Component({ + selector: 'app-button-output', template: `` }) export class ButtonOutputComponent { diff --git a/system-tests/project-fixtures/angular/src/app/components/projection.component.ts b/system-tests/project-fixtures/angular/src/app/components/projection.component.ts new file mode 100644 index 000000000000..69956687d92b --- /dev/null +++ b/system-tests/project-fixtures/angular/src/app/components/projection.component.ts @@ -0,0 +1,7 @@ +import { Component } from '@angular/core' + +@Component({ + selector: 'app-projection', + template: `

` +}) +export class ProjectionComponent {} \ No newline at end of file diff --git a/system-tests/project-fixtures/angular/src/app/mount.cy.ts b/system-tests/project-fixtures/angular/src/app/mount.cy.ts index f13c8e2ef981..57ecc3c41c3e 100644 --- a/system-tests/project-fixtures/angular/src/app/mount.cy.ts +++ b/system-tests/project-fixtures/angular/src/app/mount.cy.ts @@ -5,6 +5,14 @@ import { CounterService } from "./components/counter.service"; import { ChildComponent } from "./components/child.component"; import { WithDirectivesComponent } from "./components/with-directives.component"; import { ButtonOutputComponent } from "./components/button-output.component"; +import { createOutputSpy } from 'cypress/angular'; +import { EventEmitter, Component } from '@angular/core'; +import { ProjectionComponent } from "./components/projection.component"; + +@Component({ + template: `Hello World` +}) +class WrapperComponent {} describe("angular mount", () => { it("pushes CommonModule into component", () => { @@ -44,6 +52,31 @@ describe("angular mount", () => { }); }); + it('can bind the spy to the componentProperties bypassing types', () => { + cy.mount(ButtonOutputComponent, { + componentProperties: { + clicked: { + emit: cy.spy().as('onClickedSpy') + } as any + } + }) + cy.get('button').click() + cy.get('@onClickedSpy').should('have.been.calledWith', true) + }) + + it('can bind the spy to the componentProperties bypassing types using template', () => { + cy.mount('', { + declarations: [ButtonOutputComponent], + componentProperties: { + clicked: { + emit: cy.spy().as('onClickedSpy') + } as any + } + }) + cy.get('button').click() + cy.get('@onClickedSpy').should('have.been.calledWith', true) + }) + it('can spy on EventEmitters', () => { cy.mount(ButtonOutputComponent).then(({ component }) => { cy.spy(component.clicked, 'emit').as('mySpy') @@ -51,4 +84,94 @@ describe("angular mount", () => { cy.get('@mySpy').should('have.been.calledWith', true) }) }) + + it('can use a template string instead of Type for component', () => { + cy.mount('', { + declarations: [ButtonOutputComponent], + componentProperties: { + login: cy.spy().as('myClickedSpy') + } + }) + cy.get('button').click() + cy.get('@myClickedSpy').should('have.been.calledWith', true) + }) + + it('can spy on EventEmitter for mount using template', () => { + cy.mount('', { + declarations: [ButtonOutputComponent], + componentProperties: { + handleClick: new EventEmitter() + } + }).then(({ component }) => { + cy.spy(component.handleClick, 'emit').as('handleClickSpy') + cy.get('button').click() + cy.get('@handleClickSpy').should('have.been.calledWith', true) + }) + }) + + it('can accept a createOutputSpy for an Output property', () => { + cy.mount(ButtonOutputComponent, { + componentProperties: { + clicked: createOutputSpy('mySpy') + } + }) + cy.get('button').click(); + cy.get('@mySpy').should('have.been.calledWith', true) + }) + + it('can accept a createOutputSpy for an Output property with a template', () => { + cy.mount('', { + declarations: [ButtonOutputComponent], + componentProperties: { + clicked: createOutputSpy('mySpy') + } + }) + cy.get('button').click() + cy.get('@mySpy').should('have.been.called') + }) + + it('can reference the autoSpyOutput alias on component @Outputs()', () => { + cy.mount(ButtonOutputComponent, { + autoSpyOutputs: true, + }) + cy.get('button').click() + cy.get('@clickedSpy').should('have.been.calledWith', true) + }) + + + it('can reference the autoSpyOutput alias on component @Outputs() with a template', () => { + cy.mount('', { + declarations: [ButtonOutputComponent], + autoSpyOutputs: true, + componentProperties: { + clicked: new EventEmitter() + } + }) + cy.get('button').click() + cy.get('@clickedSpy').should('have.been.calledWith', true) + }) + + it('can handle content projection with a WrapperComponent', () => { + cy.mount(WrapperComponent, { + declarations: [ProjectionComponent] + }) + cy.get('h3').contains('Hello World') + }) + + it('can handle content projection using template', () => { + cy.mount('Hello World', { + declarations: [ProjectionComponent] + }) + cy.get('h3').contains('Hello World') + }) + + describe("teardown", () => { + beforeEach(() => { + cy.get("[id^=root]").should("not.exist"); + }); + + it("should mount", () => { + cy.mount(ButtonOutputComponent); + }); + }); }); diff --git a/system-tests/projects/angular-14/src/app/components/standalone.component.cy.ts b/system-tests/projects/angular-14/src/app/components/standalone.component.cy.ts new file mode 100644 index 000000000000..74bf96cd41e6 --- /dev/null +++ b/system-tests/projects/angular-14/src/app/components/standalone.component.cy.ts @@ -0,0 +1,21 @@ +import { StandaloneComponent } from './standalone.component' + +describe('StandaloneComponent', () => { + it('can mount a standalone component', () => { + cy.mount(StandaloneComponent, { + componentProperties: { + name: 'Angular', + }, + }) + + cy.get('h1').contains('Hello Angular') + }) + + it('can mount a standalone component using template', () => { + cy.mount('', { + imports: [StandaloneComponent], + }) + + cy.get('h1').contains('Hello Angular') + }) +}) diff --git a/system-tests/projects/angular-14/src/app/components/standalone.component.ts b/system-tests/projects/angular-14/src/app/components/standalone.component.ts new file mode 100644 index 000000000000..bf2355b493da --- /dev/null +++ b/system-tests/projects/angular-14/src/app/components/standalone.component.ts @@ -0,0 +1,10 @@ +import { Component, Input } from '@angular/core' + +@Component({ + standalone: true, + selector: 'app-standalone', + template: `

Hello {{ name }}

`, +}) +export class StandaloneComponent { + @Input() name!: string +} diff --git a/system-tests/projects/component-tests/cypress/component-tests/fails.spec.js b/system-tests/projects/component-tests/cypress/component-tests/fails.spec.js index 842cf72208e9..f83991c974bd 100644 --- a/system-tests/projects/component-tests/cypress/component-tests/fails.spec.js +++ b/system-tests/projects/component-tests/cypress/component-tests/fails.spec.js @@ -1,4 +1,4 @@ -describe('simple passing spec', () => { +describe('simple failing spec', () => { it('fails', () => { expect(1).to.eq(2) }) diff --git a/system-tests/projects/component-tests/cypress/component-tests/retries.spec.js b/system-tests/projects/component-tests/cypress/component-tests/retries.spec.js new file mode 100644 index 000000000000..36485124c142 --- /dev/null +++ b/system-tests/projects/component-tests/cypress/component-tests/retries.spec.js @@ -0,0 +1,12 @@ +describe('retries', () => { + let i = 0 + + it('passes after 1 failure', { retries: 1 }, () => { + if (i === 0) { + i++ + expect(1).to.eq(2) + } + + expect(1).to.eq(1) + }) +}) diff --git a/system-tests/test/component_testing_spec.ts b/system-tests/test/component_testing_spec.ts index 237786beb923..ef72ae4dc563 100644 --- a/system-tests/test/component_testing_spec.ts +++ b/system-tests/test/component_testing_spec.ts @@ -107,6 +107,24 @@ describe(`React major versions with Webpack`, function () { } }) +const ANGULAR_MAJOR_VERSIONS = ['13', '14'] + +describe(`Angular CLI major versions`, () => { + systemTests.setup() + + for (const majorVersion of ANGULAR_MAJOR_VERSIONS) { + const spec = `${majorVersion === '14' ? 'src/app/components/standalone.component.cy.ts,src/app/mount.cy.ts' : 'src/app/mount.cy.ts'}` + + systemTests.it(`v${majorVersion} with mount tests`, { + project: `angular-${majorVersion}`, + spec, + testingType: 'component', + browser: 'chrome', + expectedExitCode: 0, + }) + } +}) + describe('experimentalSingleTabRunMode', function () { systemTests.setup()