diff --git a/packages/data-context/src/sources/UtilDataSource.ts b/packages/data-context/src/sources/UtilDataSource.ts index 778496e5b28b..b17a716c21e5 100644 --- a/packages/data-context/src/sources/UtilDataSource.ts +++ b/packages/data-context/src/sources/UtilDataSource.ts @@ -1,5 +1,6 @@ import fetch from 'cross-fetch' import type { DataContext } from '../DataContext' +import { isDependencyInstalled } 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') @@ -18,4 +19,8 @@ export class UtilDataSource { // which is what will be used here return fetch(input, { agent, ...init }) } + + isDependencyInstalled (dependency: Cypress.CypressComponentDependency, projectPath: string) { + return isDependencyInstalled(dependency, projectPath) + } } diff --git a/packages/data-context/src/sources/VersionsDataSource.ts b/packages/data-context/src/sources/VersionsDataSource.ts index 7791c4d6c7dc..f2bfcf29d1d5 100644 --- a/packages/data-context/src/sources/VersionsDataSource.ts +++ b/packages/data-context/src/sources/VersionsDataSource.ts @@ -3,6 +3,8 @@ 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' const debug = Debug('cypress:data-context:sources:VersionsDataSource') @@ -160,6 +162,47 @@ 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 + } + } else { + debug('No project path, skipping dependency check') + } + } catch (err) { + debug('Failed to detect project dependencies', err) + } + try { const manifestResponse = await this.ctx.util.fetch(CYPRESS_REMOTE_MANIFEST_URL, { headers: manifestHeaders, @@ -190,4 +233,8 @@ export class VersionsDataSource { return undefined } } + + private isFulfilled (item: PromiseSettledResult): item is PromiseFulfilledResult { + return item.status === 'fulfilled' + } } diff --git a/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts b/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts index c99f08bd8077..68285554507d 100644 --- a/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts +++ b/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts @@ -16,6 +16,7 @@ describe('VersionsDataSource', () => { let ctx: DataContext let nmiStub: sinon.SinonStub let fetchStub: sinon.SinonStub + let isDependencyInstalledStub: sinon.SinonStub let mockNow: Date = new Date() let versionsDataSource: VersionsDataSource let currentCypressVersion: string = pkg.version @@ -23,14 +24,26 @@ describe('VersionsDataSource', () => { before(() => { ctx = createTestDataContext('open') + ;(ctx.lifecycleManager as any)._cachedInitialConfig = { + component: { + devServer: { + framework: 'react', + bundler: 'vite', + }, + }, + } + + ctx.coreData.currentProject = '/abc' ctx.coreData.currentTestingType = 'e2e' fetchStub = sinon.stub() + isDependencyInstalledStub = sinon.stub() }) beforeEach(() => { nmiStub = sinon.stub(nmi, 'machineId') sinon.stub(ctx.util, 'fetch').callsFake(fetchStub) + sinon.stub(ctx.util, 'isDependencyInstalled').callsFake(isDependencyInstalledStub) sinon.stub(os, 'platform').returns('darwin') sinon.stub(os, 'arch').returns('x64') sinon.useFakeTimers({ now: mockNow }) @@ -45,7 +58,7 @@ describe('VersionsDataSource', () => { fetchStub .withArgs(CYPRESS_REMOTE_MANIFEST_URL, { - headers: { + headers: sinon.match({ 'Content-Type': 'application/json', 'x-cypress-version': currentCypressVersion, 'x-os-name': 'darwin', @@ -54,7 +67,7 @@ describe('VersionsDataSource', () => { 'x-machine-id': 'abcd123', 'x-testing-type': 'e2e', 'x-logged-in': 'false', - }, + }), }).resolves({ json: sinon.stub().resolves({ name: 'Cypress', @@ -99,7 +112,7 @@ describe('VersionsDataSource', () => { fetchStub .withArgs(CYPRESS_REMOTE_MANIFEST_URL, { - headers: { + headers: sinon.match({ 'Content-Type': 'application/json', 'x-cypress-version': currentCypressVersion, 'x-os-name': 'darwin', @@ -107,7 +120,7 @@ describe('VersionsDataSource', () => { 'x-initial-launch': String(false), 'x-testing-type': 'component', 'x-logged-in': 'false', - }, + }), }).resolves({ json: sinon.stub().resolves({ name: 'Cypress', @@ -121,7 +134,7 @@ describe('VersionsDataSource', () => { versionsDataSource.resetLatestVersionTelemetry() - const latestVersion = await ctx.coreData.versionData.latestVersion + const latestVersion = await ctx.coreData.versionData?.latestVersion expect(latestVersion).to.eql('16.0.0') }) @@ -131,7 +144,7 @@ describe('VersionsDataSource', () => { fetchStub .withArgs(CYPRESS_REMOTE_MANIFEST_URL, { - headers: { + headers: sinon.match({ 'Content-Type': 'application/json', 'x-cypress-version': currentCypressVersion, 'x-os-name': 'darwin', @@ -140,7 +153,7 @@ describe('VersionsDataSource', () => { 'x-machine-id': 'abcd123', 'x-testing-type': 'e2e', 'x-logged-in': 'false', - }, + }), }) .rejects() .withArgs(NPM_CYPRESS_REGISTRY_URL) @@ -158,7 +171,7 @@ describe('VersionsDataSource', () => { fetchStub .withArgs(CYPRESS_REMOTE_MANIFEST_URL, { - headers: { + headers: sinon.match({ 'Content-Type': 'application/json', 'x-cypress-version': currentCypressVersion, 'x-os-name': 'darwin', @@ -167,7 +180,7 @@ describe('VersionsDataSource', () => { 'x-machine-id': 'abcd123', 'x-testing-type': 'e2e', 'x-logged-in': 'false', - }, + }), }) .callsFake(async () => new Response('Error')) .withArgs(NPM_CYPRESS_REGISTRY_URL) @@ -183,9 +196,60 @@ describe('VersionsDataSource', () => { versionsDataSource.resetLatestVersionTelemetry() - await ctx.coreData.versionData.latestVersion + await ctx.coreData.versionData?.latestVersion expect(versionInfo.current.version).to.eql(currentCypressVersion) }) + + it('generates x-framework, x-bundler, and x-dependencies headers', async () => { + isDependencyInstalledStub.callsFake(async (dependency) => { + // Should include any resolved dependency with a valid version + if (dependency.package === 'react') { + return { + dependency, + detectedVersion: '1.2.3', + satisfied: true, + } as Cypress.DependencyToInstall + } + + // Not satisfied dependency should be excluded + if (dependency.package === 'vue') { + return { + dependency, + detectedVersion: '4.5.6', + satisfied: false, + } + } + + // Satisfied dependency without resolved version should result in -1 + if (dependency.package === 'typescript') { + return { + dependency, + detectedVersion: null, + satisfied: true, + } + } + + // Any dependencies that error while resolving should be excluded + throw new Error('Failed check') + }) + + ctx.coreData.currentTestingType = 'component' + versionsDataSource = new VersionsDataSource(ctx) + ctx.coreData.currentTestingType = 'e2e' + versionsDataSource.resetLatestVersionTelemetry() + await versionsDataSource.versionData() + + expect(fetchStub).to.have.been.calledWith( + CYPRESS_REMOTE_MANIFEST_URL, + { + headers: sinon.match({ + 'x-framework': 'react', + 'x-dev-server': 'vite', + 'x-dependencies': 'typescript@-1,react@1', + }), + }, + ) + }) }) }) diff --git a/packages/launchpad/cypress/e2e/open-mode.cy.ts b/packages/launchpad/cypress/e2e/open-mode.cy.ts index 87b25221b29b..458e13d71641 100644 --- a/packages/launchpad/cypress/e2e/open-mode.cy.ts +++ b/packages/launchpad/cypress/e2e/open-mode.cy.ts @@ -65,7 +65,7 @@ describe('Launchpad: Open Mode', () => { cy.openProject('todos', ['--e2e']) }) - it('includes x-framework and x-dev-server, even when launched in e2e mode', () => { + it('includes `x-framework`, `x-dev-server`, and `x-dependencies` headers, even when launched in e2e mode', () => { cy.visitLaunchpad() cy.skipWelcome() cy.get('h1').should('contain', 'Choose a browser') @@ -74,6 +74,7 @@ describe('Launchpad: Open Mode', () => { headers: { 'x-framework': 'react', 'x-dev-server': 'webpack', + 'x-dependencies': 'typescript@4', }, }) })