From 1df282c75baa8245eb147339f47ec6324d98308c Mon Sep 17 00:00:00 2001 From: astone123 Date: Fri, 19 May 2023 12:15:04 -0700 Subject: [PATCH 1/2] chore: capture versions of relevant dependencies with x-dependencies header --- .../src/sources/UtilDataSource.ts | 6 +- .../src/sources/VersionsDataSource.ts | 75 +++++++++---------- .../unit/sources/VersionsDataSource.spec.ts | 42 +++++++---- .../launchpad/cypress/e2e/open-mode.cy.ts | 26 ++++++- packages/scaffold-config/src/dependencies.ts | 75 +++++++++++++++++++ packages/scaffold-config/src/frameworks.ts | 37 +++++++++ 6 files changed, 203 insertions(+), 58 deletions(-) diff --git a/packages/data-context/src/sources/UtilDataSource.ts b/packages/data-context/src/sources/UtilDataSource.ts index b17a716c21e5..7ccdf22fd9dc 100644 --- a/packages/data-context/src/sources/UtilDataSource.ts +++ b/packages/data-context/src/sources/UtilDataSource.ts @@ -1,6 +1,6 @@ import fetch from 'cross-fetch' import type { DataContext } from '../DataContext' -import { isDependencyInstalled } from '@packages/scaffold-config' +import { isDependencyInstalled, isDependencyInstalledByName } from '@packages/scaffold-config' // Require rather than import since data-context is stricter than network and there are a fair amount of errors in agent. const { agent } = require('@packages/network') @@ -23,4 +23,8 @@ export class UtilDataSource { isDependencyInstalled (dependency: Cypress.CypressComponentDependency, projectPath: string) { return isDependencyInstalled(dependency, projectPath) } + + isDependencyInstalledByName (packageName: string, projectPath: string) { + return isDependencyInstalledByName(packageName, projectPath) + } } diff --git a/packages/data-context/src/sources/VersionsDataSource.ts b/packages/data-context/src/sources/VersionsDataSource.ts index 15b0c301aa10..88cc16f7d254 100644 --- a/packages/data-context/src/sources/VersionsDataSource.ts +++ b/packages/data-context/src/sources/VersionsDataSource.ts @@ -3,8 +3,7 @@ import type { DataContext } from '..' import type { TestingType } from '@packages/types' import { CYPRESS_REMOTE_MANIFEST_URL, NPM_CYPRESS_REGISTRY_URL } from '@packages/types' import Debug from 'debug' -import { WIZARD_DEPENDENCIES } from '@packages/scaffold-config' -import semver from 'semver' +import { dependencyNamesToDetect } from '@packages/scaffold-config' const debug = Debug('cypress:data-context:sources:VersionsDataSource') @@ -161,45 +160,43 @@ export class VersionsDataSource { } } - try { - const projectPath = this.ctx.currentProject - - if (projectPath) { - const dependenciesToCheck = WIZARD_DEPENDENCIES - - debug('Checking %d dependencies in project', dependenciesToCheck.length) - // Check all dependencies of interest in parallel - const dependencyResults = await Promise.allSettled( - dependenciesToCheck.map(async (dependency) => { - const result = await this.ctx.util.isDependencyInstalled(dependency, projectPath) - - // If a dependency isn't satisfied then we are no longer interested in it, - // exclude from further processing by rejecting promise - if (!result.satisfied) { - throw new Error('Unsatisfied dependency') - } - - // We only want major version, fallback to `-1` if we couldn't detect version - const majorVersion = result.detectedVersion ? semver.major(result.detectedVersion) : -1 - - // For any satisfied dependencies, build a `package@version` string - return `${result.dependency.package}@${majorVersion}` - }), - ) - // Take any dependencies that were found and combine into comma-separated string - const headerValue = dependencyResults - .filter(this.isFulfilled) - .map((result) => result.value) - .join(',') - - if (headerValue) { - manifestHeaders['x-dependencies'] = headerValue + if (this._initialLaunch) { + try { + const projectPath = this.ctx.currentProject + + if (projectPath) { + debug('Checking %d dependencies in project', dependencyNamesToDetect.length) + // Check all dependencies of interest in parallel + const dependencyResults = await Promise.allSettled( + dependencyNamesToDetect.map(async (dependency) => { + const result = await this.ctx.util.isDependencyInstalledByName(dependency, projectPath) + + if (!result.detectedVersion) { + throw new Error(`Could not resolve dependency version for ${dependency}`) + } + + // For any satisfied dependencies, build a `package@version` string + return `${result.dependency}@${result.detectedVersion}` + }), + ) + + // Take any dependencies that were found and combine into comma-separated string + const headerValue = dependencyResults + .filter(this.isFulfilled) + .map((result) => result.value) + .join(',') + + if (headerValue) { + manifestHeaders['x-dependencies'] = headerValue + } + } else { + debug('No project path, skipping dependency check') } - } else { - debug('No project path, skipping dependency check') + } catch (err) { + debug('Failed to detect project dependencies', err) } - } catch (err) { - debug('Failed to detect project dependencies', err) + } else { + debug('Not initial launch of Cypress, skipping dependency check') } try { diff --git a/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts b/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts index 31ae8f2283b5..3bb8cdfc8cbe 100644 --- a/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts +++ b/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts @@ -14,7 +14,7 @@ describe('VersionsDataSource', () => { context('.versions', () => { let ctx: DataContext let fetchStub: sinon.SinonStub - let isDependencyInstalledStub: sinon.SinonStub + let isDependencyInstalledByNameStub: sinon.SinonStub let mockNow: Date = new Date() let versionsDataSource: VersionsDataSource let currentCypressVersion: string = pkg.version @@ -36,12 +36,12 @@ describe('VersionsDataSource', () => { ctx.coreData.currentTestingType = 'e2e' fetchStub = sinon.stub() - isDependencyInstalledStub = sinon.stub() + isDependencyInstalledByNameStub = sinon.stub() }) beforeEach(() => { sinon.stub(ctx.util, 'fetch').callsFake(fetchStub) - sinon.stub(ctx.util, 'isDependencyInstalled').callsFake(isDependencyInstalledStub) + sinon.stub(ctx.util, 'isDependencyInstalledByName').callsFake(isDependencyInstalledByNameStub) sinon.stub(os, 'platform').returns('darwin') sinon.stub(os, 'arch').returns('x64') sinon.useFakeTimers({ now: mockNow }) @@ -194,31 +194,41 @@ describe('VersionsDataSource', () => { }) it('generates x-framework, x-bundler, and x-dependencies headers', async () => { - isDependencyInstalledStub.callsFake(async (dependency) => { + isDependencyInstalledByNameStub.callsFake(async (packageName) => { // Should include any resolved dependency with a valid version - if (dependency.package === 'react') { + if (packageName === 'react') { return { - dependency, + dependency: packageName, detectedVersion: '1.2.3', - satisfied: true, } as Cypress.DependencyToInstall } - // Not satisfied dependency should be excluded - if (dependency.package === 'vue') { + if (packageName === 'vue') { return { - dependency, + dependency: packageName, detectedVersion: '4.5.6', - satisfied: false, } } - // Satisfied dependency without resolved version should result in -1 - if (dependency.package === 'typescript') { + if (packageName === '@builder.io/qwik') { return { - dependency, + dependency: packageName, + detectedVersion: '1.1.4', + } + } + + if (packageName === '@playwright/experimental-ct-core') { + return { + dependency: packageName, + detectedVersion: '1.33.0', + } + } + + // Dependency without resolved version should be excluded + if (packageName === 'typescript') { + return { + dependency: packageName, detectedVersion: null, - satisfied: true, } } @@ -238,7 +248,7 @@ describe('VersionsDataSource', () => { headers: sinon.match({ 'x-framework': 'react', 'x-dev-server': 'vite', - 'x-dependencies': 'typescript@-1,react@1', + 'x-dependencies': 'react@1.2.3,vue@4.5.6,@builder.io/qwik@1.1.4,@playwright/experimental-ct-core@1.33.0', }), }, ) diff --git a/packages/launchpad/cypress/e2e/open-mode.cy.ts b/packages/launchpad/cypress/e2e/open-mode.cy.ts index 458e13d71641..68dea29ade1e 100644 --- a/packages/launchpad/cypress/e2e/open-mode.cy.ts +++ b/packages/launchpad/cypress/e2e/open-mode.cy.ts @@ -65,7 +65,30 @@ describe('Launchpad: Open Mode', () => { cy.openProject('todos', ['--e2e']) }) - it('includes `x-framework`, `x-dev-server`, and `x-dependencies` headers, even when launched in e2e mode', () => { + it('includes `x-framework`, `x-dev-server`, and `x-dependencies` headers, even when launched in e2e mode if this is the initial launch of Cypress', () => { + cy.withCtx((ctx) => { + ctx.versions['_initialLaunch'] = true + }) + + cy.visitLaunchpad() + cy.skipWelcome() + cy.get('h1').should('contain', 'Choose a browser') + cy.withCtx((ctx, o) => { + expect(ctx.util.fetch).to.have.been.calledWithMatch('https://download.cypress.io/desktop.json', { + headers: { + 'x-framework': 'react', + 'x-dev-server': 'webpack', + 'x-dependencies': 'typescript@4.7.4', + }, + }) + }) + }) + + it('does not include `x-dependencies` header, if this is not the initial launch of Cypress', () => { + cy.withCtx((ctx) => { + ctx.versions['_initialLaunch'] = false + }) + cy.visitLaunchpad() cy.skipWelcome() cy.get('h1').should('contain', 'Choose a browser') @@ -74,7 +97,6 @@ describe('Launchpad: Open Mode', () => { headers: { 'x-framework': 'react', 'x-dev-server': 'webpack', - 'x-dependencies': 'typescript@4', }, }) }) diff --git a/packages/scaffold-config/src/dependencies.ts b/packages/scaffold-config/src/dependencies.ts index 27ebd58d590b..54b5c9d7d48e 100644 --- a/packages/scaffold-config/src/dependencies.ts +++ b/packages/scaffold-config/src/dependencies.ts @@ -175,3 +175,78 @@ export const WIZARD_BUNDLERS = [ WIZARD_DEPENDENCY_WEBPACK, WIZARD_DEPENDENCY_VITE, ] as const + +const componentDependenciesOfInterest = [ + '@angular/cli', + '@angular-devkit/build-angular', + '@angular/core', + '@angular/common', + '@angular/platform-browser-dynamic', + 'react', + 'react-dom', + 'react-scripts', + 'vue', + '@vue/cli-service', + 'svelte', + 'solid-js', + 'lit', + 'preact', + 'preact-cli', + 'ember', + '@stencil/core', + '@builder.io/qwik', + 'alpinejs', + '@glimmer/component', + 'typescript', +] + +const bundlerDependenciesOfInterest = [ + 'vite', + 'webpack', + 'parcel', + 'rollup', + 'snowpack', +] + +const testingDependenciesOfInterest = [ + 'jest', + 'jsdom', + 'jest-preview', + 'storybook', + '@storybook/addon-interactions', + '@storybook/addon-a11y', + 'chromatic', + '@testing-library/react', + '@testing-library/react-hooks', + '@testing-library/dom', + '@testing-library/jest-dom', + '@testing-library/cypress', + '@testing-library/user-event', + '@testing-library/vue', + '@testing-library/svelte', + '@testing-library/preact', + 'happy-dom', + 'vitest', + 'vitest-preview', + 'selenium-webdriver', + 'nightwatch', + 'karma', + 'playwright', + 'playwright-core', + '@playwright/experimental-ct-core', + '@playwright/experimental-ct-react', + '@playwright/experimental-ct-svelte', + '@playwright/experimental-ct-vue', + '@playwright/experimental-ct-vue2', + '@playwright/experimental-ct-solid', + '@playwright/experimental-ct-react17', + 'axe-core', + 'jest-axe', + 'enzyme', +] + +export const dependencyNamesToDetect = [ + ...componentDependenciesOfInterest, + ...bundlerDependenciesOfInterest, + ...testingDependenciesOfInterest, +] diff --git a/packages/scaffold-config/src/frameworks.ts b/packages/scaffold-config/src/frameworks.ts index 8553d2553767..69138c8c28af 100644 --- a/packages/scaffold-config/src/frameworks.ts +++ b/packages/scaffold-config/src/frameworks.ts @@ -14,6 +14,43 @@ export type WizardBundler = typeof dependencies.WIZARD_BUNDLERS[number] export type CodeGenFramework = Cypress.ResolvedComponentFrameworkDefinition['codeGenFramework'] +export async function isDependencyInstalledByName (packageName: string, projectPath: string): Promise<{dependency: string, detectedVersion: string | null}> { + try { + debug('detecting %s in %s', packageName, projectPath) + + const packageFilePath = resolvePackagePath(packageName, projectPath, false) + + if (!packageFilePath) { + debug('unable to resolve dependency %s', packageName) + + return { + dependency: packageName, + detectedVersion: null, + } + } + + const pkg = await fs.readJson(packageFilePath) as PkgJson + + debug('found package.json %o', pkg) + + if (!pkg.version) { + throw Error(`${pkg.version} for ${packageName} is not a valid semantic version.`) + } + + return { + dependency: packageName, + detectedVersion: pkg.version, + } + } catch (e) { + debug('error when detecting %s: %s', packageName, e.message) + + return { + dependency: packageName, + detectedVersion: null, + } + } +} + export async function isDependencyInstalled (dependency: Cypress.CypressComponentDependency, projectPath: string): Promise { try { debug('detecting %s in %s', dependency.package, projectPath) From 89f1bd17b4e1e2b6476aa6150046e76b4f8d36f0 Mon Sep 17 00:00:00 2001 From: astone123 Date: Mon, 22 May 2023 10:11:46 -0700 Subject: [PATCH 2/2] refactor isDependencyInstalled by name, add tests --- packages/scaffold-config/src/frameworks.ts | 22 +++---- .../test/unit/frameworks.spec.ts | 58 +++++++++++++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) create mode 100644 packages/scaffold-config/test/unit/frameworks.spec.ts diff --git a/packages/scaffold-config/src/frameworks.ts b/packages/scaffold-config/src/frameworks.ts index 69138c8c28af..caaad49ac886 100644 --- a/packages/scaffold-config/src/frameworks.ts +++ b/packages/scaffold-config/src/frameworks.ts @@ -15,18 +15,15 @@ export type WizardBundler = typeof dependencies.WIZARD_BUNDLERS[number] export type CodeGenFramework = Cypress.ResolvedComponentFrameworkDefinition['codeGenFramework'] export async function isDependencyInstalledByName (packageName: string, projectPath: string): Promise<{dependency: string, detectedVersion: string | null}> { + let detectedVersion: string | null = null + try { debug('detecting %s in %s', packageName, projectPath) const packageFilePath = resolvePackagePath(packageName, projectPath, false) if (!packageFilePath) { - debug('unable to resolve dependency %s', packageName) - - return { - dependency: packageName, - detectedVersion: null, - } + throw new Error('unable to resolve package file') } const pkg = await fs.readJson(packageFilePath) as PkgJson @@ -37,17 +34,14 @@ export async function isDependencyInstalledByName (packageName: string, projectP throw Error(`${pkg.version} for ${packageName} is not a valid semantic version.`) } - return { - dependency: packageName, - detectedVersion: pkg.version, - } + detectedVersion = pkg.version } catch (e) { debug('error when detecting %s: %s', packageName, e.message) + } - return { - dependency: packageName, - detectedVersion: null, - } + return { + dependency: packageName, + detectedVersion, } } diff --git a/packages/scaffold-config/test/unit/frameworks.spec.ts b/packages/scaffold-config/test/unit/frameworks.spec.ts new file mode 100644 index 000000000000..1634b29f23db --- /dev/null +++ b/packages/scaffold-config/test/unit/frameworks.spec.ts @@ -0,0 +1,58 @@ +import { expect } from 'chai' +import fs from 'fs-extra' +import path from 'path' +import os from 'os' +import { isDependencyInstalledByName } from '../../src/frameworks' + +describe('frameworks', () => { + describe('isDependencyInstalledByName', () => { + const TEMP_DIR = path.join(os.tmpdir(), 'is-dependency-installed-by-name-tmp') + const TEMP_NODE_MODULES = path.join(TEMP_DIR, 'node_modules') + + beforeEach(async () => { + await fs.mkdir(TEMP_DIR) + await fs.mkdir(TEMP_NODE_MODULES) + }) + + afterEach(async () => { + await fs.rm(TEMP_DIR, { recursive: true }) + }) + + it('returns null version if dependency is not found', async () => { + const result = await isDependencyInstalledByName('my-dep', TEMP_DIR) + + expect(result).to.eql({ dependency: 'my-dep', detectedVersion: null }) + }) + + it('returns null version if there is no version in the package file', async () => { + await fs.mkdir(path.join(TEMP_NODE_MODULES, 'my-dep')) + await fs.writeFile(path.join(TEMP_NODE_MODULES, 'my-dep', 'package.json'), `{ + "name": "my-dep", + "private": false, + "main": "index.js", + "license": "MIT" + } + `) + + const result = await isDependencyInstalledByName('my-dep', TEMP_DIR) + + expect(result).to.eql({ dependency: 'my-dep', detectedVersion: null }) + }) + + it('returns package version if it finds the dependency', async () => { + await fs.mkdir(path.join(TEMP_NODE_MODULES, 'my-dep')) + await fs.writeFile(path.join(TEMP_NODE_MODULES, 'my-dep', 'package.json'), `{ + "name": "my-dep", + "private": false, + "version": "1.2.3", + "main": "index.js", + "license": "MIT" + } + `) + + const result = await isDependencyInstalledByName('my-dep', TEMP_DIR) + + expect(result).to.eql({ dependency: 'my-dep', detectedVersion: '1.2.3' }) + }) + }) +})