Skip to content

Commit

Permalink
fix: discover CT community definitions in monorepos with hoisted depe…
Browse files Browse the repository at this point in the history
…ndencies (#26066)

* WIP find a way to resolve dependencies that does not rely on exports

* use `resolvePackagePath` package

* revert schema changes

* something is going on with Percy

* remove loc

* feedback

* upgrade vite react plugin

* update yarn lock

* fix unit test

* chore: discover CT community definitions in monorepos with hoisted dependencies

* [run ci]

* use find-up v5 because cjs

* delete solid yarn lock [run ci]

* improve repo root detection [run ci]

* [run ci]

* add some unit tests

* update comments

* update links

* use os.tmpdir

* add test for monorepo third-party dependency resolution

* add changelog entry

* add e2e test

* fix paths for windows

---------

Co-authored-by: Lachlan Miller <lachlan.miller.1990@outlook.com>
  • Loading branch information
astone123 and lmiller1990 committed Mar 14, 2023
1 parent 1c5a67f commit cacdb1d
Show file tree
Hide file tree
Showing 14 changed files with 1,481 additions and 15 deletions.
1 change: 1 addition & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ _Released 03/14/2023 (PENDING)_

- Fixed an issue where using `Cypress.require()` would throw the error `Cannot find module 'typescript'`. Fixes [#25885](https://github.com/cypress-io/cypress/issues/25885).
- The [`before:spec`](https://docs.cypress.io/api/plugins/before-spec-api) API was updated to correctly support async event handlers in `run` mode. Fixes [#24403](https://github.com/cypress-io/cypress/issues/24403).
- Updated the Component Testing community framework definition detection logic to take into account monorepo structures that hoist dependencies. Fixes [#25993](https://github.com/cypress-io/cypress/issues/25993)

**Misc:**

Expand Down
10 changes: 9 additions & 1 deletion packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,15 @@ async function makeE2ETasks () {
}
},
async __internal_openProject ({ argv, projectName }: InternalOpenProjectArgs): Promise<ResetOptionsResult> {
if (!scaffoldedProjects.has(projectName)) {
let projectMatched = false

for (const scaffoldedProject of scaffoldedProjects.keys()) {
if (projectName.startsWith(scaffoldedProject)) {
projectMatched = true
}
}

if (!projectMatched) {
throw new Error(`${projectName} has not been scaffolded. Be sure to call cy.scaffoldProject('${projectName}') in the test, a before, or beforeEach hook`)
}

Expand Down
6 changes: 4 additions & 2 deletions packages/frontend-shared/cypress/support/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,10 @@ function openGlobalMode (options: OpenGlobalModeOptions = {}) {
})
}

function openProject (projectName: ProjectFixtureDir, argv: string[] = []) {
if (!fixtureDirs.includes(projectName)) {
type WithPrefix<T extends string> = `${T}${string}`;

function openProject (projectName: WithPrefix<ProjectFixtureDir>, argv: string[] = []) {
if (!fixtureDirs.some((dir) => projectName.startsWith(dir))) {
throw new Error(`Unknown project ${projectName}`)
}

Expand Down
20 changes: 20 additions & 0 deletions packages/launchpad/cypress/e2e/scaffold-component-testing.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,5 +232,25 @@ describe('scaffolding component testing', {

verifyConfigFile('cypress.config.js')
})

it('Scaffolds component testing for monorepos with hoisted dependencies', () => {
cy.scaffoldProject('ct-monorepo-unconfigured')
cy.openProject('ct-monorepo-unconfigured/packages/foo')

cy.withCtx(async (ctx) => {
await ctx.actions.file.removeFileInProject(ctx.path.join('..', '..', 'node_modules', 'cypress-ct-qwik'))
await ctx.actions.file.moveFileInProject(
ctx.path.join('..', '..', 'cypress-ct-qwik'),
ctx.path.join('..', '..', 'node_modules', 'cypress-ct-qwik'),
)
})

cy.visitLaunchpad()
cy.skipWelcome()

cy.contains('Component Testing').click()
cy.get(`[data-testid="select-framework"]`).click()
cy.contains('Qwik').should('be.visible')
})
})
})
1 change: 1 addition & 0 deletions packages/scaffold-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"dependencies": {
"compare-versions": "4.1.3",
"debug": "^4.3.4",
"find-up": "^5.0.0",
"fs-extra": "^9.1.0",
"globby": "^11.0.1",
"resolve-package-path": "^4.0.3",
Expand Down
87 changes: 80 additions & 7 deletions packages/scaffold-config/src/ct-detect-third-party.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import globby from 'globby'
import { z } from 'zod'
import fs from 'fs-extra'
import Debug from 'debug'
import findUp from 'find-up'

const debug = Debug('cypress:scaffold-config:ct-detect-third-party')

Expand All @@ -25,6 +26,46 @@ const thirdPartyDefinitionPrefixes = {
globalPrefix: 'cypress-ct-',
}

const ROOT_PATHS = [
'.git',

// https://pnpm.io/workspaces
'pnpm-workspace.yaml',

// https://rushjs.io/pages/advanced/config_files/
'rush.json',

// https://nx.dev/deprecated/workspace-json#workspace.json
// https://nx.dev/reference/nx-json#nx.json
'workspace.json',
'nx.json',

// https://lerna.js.org/docs/api-reference/configuration
'lerna.json',
]

async function hasWorkspacePackageJson (directory: string) {
try {
const pkg = await fs.readJson(path.join(directory, 'package.json'))

debug('package file for %s: %o', directory, pkg)

return !!pkg.workspaces
} catch (e) {
debug('error reading package.json in %s. this is not the repository root', directory)

return false
}
}

export async function isRepositoryRoot (directory: string) {
if (ROOT_PATHS.some((rootPath) => fs.existsSync(path.join(directory, rootPath)))) {
return true
}

return hasWorkspacePackageJson(directory)
}

export function isThirdPartyDefinition (definition: Cypress.ComponentFrameworkDefinition | Cypress.ThirdPartyComponentFrameworkDefinition): boolean {
return definition.type.startsWith(thirdPartyDefinitionPrefixes.globalPrefix) ||
thirdPartyDefinitionPrefixes.namespacedPrefixRe.test(definition.type)
Expand All @@ -46,14 +87,46 @@ export async function detectThirdPartyCTFrameworks (
projectRoot: string,
): Promise<Cypress.ThirdPartyComponentFrameworkDefinition[]> {
try {
const fullPathGlobs = [
path.join(projectRoot, CT_FRAMEWORK_GLOBAL_GLOB),
path.join(projectRoot, CT_FRAMEWORK_NAMESPACED_GLOB),
].map((x) => x.replaceAll('\\', '/'))
let fullPathGlobs
let packageJsonPaths: string[] = []

// Start at the project root and check each directory above it until we see
// an indication that the current directory is the root of the repository.
await findUp(async (directory: string) => {
fullPathGlobs = [
path.join(directory, CT_FRAMEWORK_GLOBAL_GLOB),
path.join(directory, CT_FRAMEWORK_NAMESPACED_GLOB),
].map((x) => x.replaceAll('\\', '/'))

debug('searching for third-party dependencies with globs %o', fullPathGlobs)

const newPackagePaths = await globby(fullPathGlobs)

if (newPackagePaths.length > 0) {
debug('found third-party dependencies %o', newPackagePaths)
}

packageJsonPaths = [...packageJsonPaths, ...newPackagePaths]

const isCurrentRepositoryRoot = await isRepositoryRoot(directory)

if (isCurrentRepositoryRoot) {
debug('stopping search at %s because it is believed to be the repository root', directory)

return findUp.stop
}

// Return undefined to keep searching
return undefined
}, { cwd: projectRoot })

if (packageJsonPaths.length === 0) {
debug('no third-party dependencies detected')

const packageJsonPaths = await globby(fullPathGlobs)
return []
}

debug('Found packages matching %s glob: %o', fullPathGlobs, packageJsonPaths)
debug('found third-party dependencies %o', packageJsonPaths)

const modules = await Promise.all(
packageJsonPaths.map(async (packageJsonPath) => {
Expand Down Expand Up @@ -96,7 +169,7 @@ export async function detectThirdPartyCTFrameworks (

return defaultEntry
} catch (e) {
debug('Ignoring %s due to error resolving: %o', e)
debug('Ignoring %s due to error resolving module', e)
}
}),
).then((modules) => {
Expand Down
88 changes: 83 additions & 5 deletions packages/scaffold-config/test/unit/ct-detect-third-party.spec.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { scaffoldMigrationProject, fakeDepsInNodeModules } from './detect.spec'
import fs from 'fs-extra'
import path from 'path'
import { detectThirdPartyCTFrameworks, validateThirdPartyModule, isThirdPartyDefinition } from '../../src'
import { detectThirdPartyCTFrameworks, validateThirdPartyModule, isThirdPartyDefinition, isRepositoryRoot } from '../../src'
import { expect } from 'chai'
import os from 'os'
import solidJs from './fixtures'

async function copyNodeModule (root, moduleName) {
const nodeModulePath = path.join(root, 'node_modules', moduleName)

await fs.remove(nodeModulePath)
await fs.copy(path.join(root, moduleName), nodeModulePath)
}

async function scaffoldQwikApp (thirdPartyModuleNames: Array<'cypress-ct-qwik' | '@org/cypress-ct-qwik' | 'misconfigured-cypress-ct-qwik'>) {
const projectRoot = await scaffoldMigrationProject('qwik-app')

fakeDepsInNodeModules(projectRoot, [{ dependency: '@builder.io/qwik', version: '0.17.5' }])
for (const thirdPartyModuleName of thirdPartyModuleNames) {
const nodeModulePath = path.join(projectRoot, 'node_modules', thirdPartyModuleName)

await fs.remove(nodeModulePath)
await fs.copy(path.join(projectRoot, thirdPartyModuleName), nodeModulePath)
await copyNodeModule(projectRoot, thirdPartyModuleName)
}

return projectRoot
Expand Down Expand Up @@ -49,6 +54,65 @@ describe('isThirdPartyDefinition', () => {
})
})

describe('isRepositoryRoot', () => {
const TEMP_DIR = path.join(os.tmpdir(), 'is-repository-root-tmp')

beforeEach(async () => {
await fs.mkdir(TEMP_DIR)
})

afterEach(async () => {
await fs.rm(TEMP_DIR, { recursive: true })
})

it('returns false if there is nothing in the directory', async () => {
const isCurrentRepositoryRoot = await isRepositoryRoot(TEMP_DIR)

expect(isCurrentRepositoryRoot).to.be.false
})

it('returns true if there is a Git directory', async () => {
await fs.mkdir(path.join(TEMP_DIR, '.git'))

const isCurrentRepositoryRoot = await isRepositoryRoot(TEMP_DIR)

expect(isCurrentRepositoryRoot).to.be.true
})

it('returns false if there is a package.json without workspaces field', async () => {
await fs.writeFile(path.join(TEMP_DIR, 'package.json'), `{
"name": "@packages/foo",
"private": true,
"version": "1.0.0",
"main": "index.js",
"license": "MIT"
}
`)

const isCurrentRepositoryRoot = await isRepositoryRoot(TEMP_DIR)

expect(isCurrentRepositoryRoot).to.be.false
})

it('returns true if there is a package.json with workspaces field', async () => {
await fs.writeFile(path.join(TEMP_DIR, 'package.json'), `{
"name": "monorepo-repo",
"private": true,
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"workspaces": [
"packages/*"
]
}
`)

const isCurrentRepositoryRoot = await isRepositoryRoot(TEMP_DIR)

expect(isCurrentRepositoryRoot).to.be.true
})
})

describe('detectThirdPartyCTFrameworks', () => {
it('detects third party frameworks in global namespace', async () => {
const projectRoot = await scaffoldQwikApp(['cypress-ct-qwik'])
Expand All @@ -75,6 +139,20 @@ describe('detectThirdPartyCTFrameworks', () => {
expect(thirdPartyFrameworks[0].type).eq('cypress-ct-qwik')
})

it('detects third party frameworks in monorepos with hoisted dependencies', async () => {
const repositoryRoot = await scaffoldMigrationProject('ct-monorepo-unconfigured')

// Copy 'cypress-ct-qwik' third-party module into node_modules in the monorepo root
await copyNodeModule(repositoryRoot, 'cypress-ct-qwik')

const projectRoot = path.join(repositoryRoot, 'packages', 'foo')

// Look for third-party modules in packages/foo (where Cypress was launched from)
const thirdPartyFrameworks = await detectThirdPartyCTFrameworks(projectRoot)

expect(thirdPartyFrameworks[0].type).eq('cypress-ct-qwik')
})

it('validates third party module', () => {
expect(() => validateThirdPartyModule(solidJs)).to.not.throw()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const qwikDep = {
type: 'qwik',
name: 'Qwik',
package: '@builder.io/qwik',
installer: '@builder.io/qwik',
description:
'An Open-Source sub-framework designed with a focus on server-side-rendering, lazy-loading, and styling/animation.',
minVersion: '^0.17.5',
}

module.exports = {
type: 'cypress-ct-qwik',

category: 'library',

name: 'Qwik',

supportedBundlers: ['vite'],

detectors: [qwikDep],

// Cypress will include the bundler dependency here, if they selected one.
dependencies: () => {
return [qwikDep]
},

icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 256 274"><defs><linearGradient id="logosQwik0" x1="22.347%" x2="77.517%" y1="49.545%" y2="50.388%"><stop offset="0%" stop-color="#4340C4"/><stop offset="12%" stop-color="#4642C8"/><stop offset="100%" stop-color="#594EE4"/></linearGradient><linearGradient id="logosQwik1" x1="38.874%" x2="60.879%" y1="49.845%" y2="50.385%"><stop offset="0%" stop-color="#4340C4"/><stop offset="74%" stop-color="#534ADB"/><stop offset="100%" stop-color="#594EE4"/></linearGradient><linearGradient id="logosQwik2" x1="-.004%" x2="100.123%" y1="49.529%" y2="50.223%"><stop offset="0%" stop-color="#4340C4"/><stop offset="23%" stop-color="#4340C4"/><stop offset="60%" stop-color="#4F48D5"/><stop offset="100%" stop-color="#594EE4"/></linearGradient><linearGradient id="logosQwik3" x1="35.4%" x2="64.895%" y1="49.459%" y2="50.085%"><stop offset="0%" stop-color="#0080FF"/><stop offset="100%" stop-color="#00B9FF"/></linearGradient><linearGradient id="logosQwik4" x1="-.243%" x2="100.411%" y1="49.366%" y2="50.467%"><stop offset="0%" stop-color="#0080FF"/><stop offset="17%" stop-color="#008BFF"/><stop offset="47%" stop-color="#00A7FF"/><stop offset="63%" stop-color="#00B9FF"/><stop offset="100%" stop-color="#00B9FF"/></linearGradient><linearGradient id="logosQwik5" x1="-.125%" x2="100.225%" y1="49.627%" y2="50.101%"><stop offset="0%" stop-color="#00B9FF"/><stop offset="30%" stop-color="#0080FF"/><stop offset="60%" stop-color="#2D67F1"/><stop offset="86%" stop-color="#4D55E8"/><stop offset="100%" stop-color="#594EE4"/></linearGradient><linearGradient id="logosQwik6" x1="4.557%" x2="99.354%" y1="50.184%" y2="51.298%"><stop offset="0%" stop-color="#4340C4"/><stop offset="12%" stop-color="#4642C8"/><stop offset="100%" stop-color="#594EE4"/></linearGradient></defs><path fill="url(#logosQwik0)" d="m175.051 236.859l25.162-15.071l49.298-86.929l-76.287 89.097z"/><path fill="url(#logosQwik1)" d="m242.337 80.408l-4.926-9.4l-1.932-3.663l-.2.196l-25.818-47.015C202.984 8.65 190.631 1.231 177.01 1.074l-25.074.206L188.15 114.8l-23.958 23.331l8.924 86.245l73.769-84.021c10.005-11.587 11.97-28.09 4.92-41.646l-9.466-18.302h-.002Z"/><path fill="url(#logosQwik2)" d="m201.113 72.256l-43.18-70.907L83.41.003C70.165-.15 57.83 6.573 50.88 17.87L7.01 101.747l34.443-33.334L84.701 8.356l97.894 112.153l18.3-18.626c8.397-8.142 5.54-19.558.22-29.625l-.002-.002Z"/><path fill="url(#logosQwik3)" d="M97.784 95.26L84.522 8.796l-73.148 88.03c-12.328 11.935-14.897 30.662-6.419 45.49l42.98 74.727c6.553 11.464 18.755 18.577 32.024 18.729l42.945.49L71.46 119.607L97.784 95.26Z"/><path fill="url(#logosQwik4)" d="M173.227 223.9L71.38 119.022l-13.196 12.59c-10.812 10.248-11.106 27.332-.728 37.921l43.99 66.384l70.65.907l1.127-12.926h.003Z"/><path fill="url(#logosQwik5)" d="m101.584 235.903l72.292-11.599l47.704 49.464z"/><path fill="url(#logosQwik6)" d="m173.111 224.483l27.168-3.457l24.096 49.915c1.06 2.06-1.719 3.977-3.373 2.302l-47.89-48.76Z"/><path fill="#FFF" d="M182.708 120.058L84.681 8.601l12.502 85.958L71.16 118.78l101.772 105.372l-7.595-85.905l17.371-18.192z"/></svg>',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function mount () {
return 'Legit mount function'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "cypress-ct-qwik",
"version": "1.0.0",
"main": "index.js",
"exports": {
"node": "./definition.cjs",
"default": "./index.mjs"
}
}
10 changes: 10 additions & 0 deletions system-tests/projects/ct-monorepo-unconfigured/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"name": "ct-monorepo-unconfigured",
"version": "1.0.0",
"private": true,
"main": "index.js",
"license": "MIT",
"workspaces": [
"packages/*"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@packages/bar",
"version": "1.0.0",
"private": true,
"main": "index.js",
"license": "MIT"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@packages/foo",
"version": "1.0.0",
"private": true,
"main": "index.js",
"license": "MIT"
}
Loading

5 comments on commit cacdb1d

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on cacdb1d Mar 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the linux x64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/12.8.0/linux-x64/develop-cacdb1d0c3a0b8362b476794c73b23c2e8f47d7d/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on cacdb1d Mar 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the linux arm64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/12.8.0/linux-arm64/develop-cacdb1d0c3a0b8362b476794c73b23c2e8f47d7d/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on cacdb1d Mar 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the darwin arm64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/12.8.0/darwin-arm64/develop-cacdb1d0c3a0b8362b476794c73b23c2e8f47d7d/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on cacdb1d Mar 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the darwin x64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/12.8.0/darwin-x64/develop-cacdb1d0c3a0b8362b476794c73b23c2e8f47d7d/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on cacdb1d Mar 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the win32 x64 version of the Test Runner.

Learn more about this pre-release build at https://on.cypress.io/advanced-installation#Install-pre-release-version

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/12.8.0/win32-x64/develop-cacdb1d0c3a0b8362b476794c73b23c2e8f47d7d/cypress.tgz

Please sign in to comment.