From bfa2d60a44c42b9d362215faaa63f43b9efe3e28 Mon Sep 17 00:00:00 2001 From: Lachlan Miller Date: Wed, 15 Feb 2023 13:03:56 +1000 Subject: [PATCH] support namespaced definitions (#25804) --- cli/types/cypress.d.ts | 3 +- npm/webpack-dev-server/src/devServer.ts | 13 +++- .../src/ct-detect-third-party.ts | 68 ++++++++----------- packages/scaffold-config/src/frameworks.ts | 6 +- .../test/unit/ct-detect-third-party.spec.ts | 46 +++++++++++-- .../@org/cypress-ct-qwik/definition.cjs | 28 ++++++++ .../qwik-app/@org/cypress-ct-qwik/index.mjs | 3 + .../@org/cypress-ct-qwik/package.json | 9 +++ 8 files changed, 125 insertions(+), 51 deletions(-) create mode 100644 system-tests/projects/qwik-app/@org/cypress-ct-qwik/definition.cjs create mode 100644 system-tests/projects/qwik-app/@org/cypress-ct-qwik/index.mjs create mode 100644 system-tests/projects/qwik-app/@org/cypress-ct-qwik/package.json diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index cb34587ca045..52dd86aa9892 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -3298,7 +3298,8 @@ declare namespace Cypress { interface ResolvedComponentFrameworkDefinition { /** - * A semantic, unique identifier. Must begin with `cypress-ct-` for third party implementations. + * A semantic, unique identifier. + * Must begin with `cypress-ct-` or `@org/cypress-ct-` for third party implementations. * @example 'reactscripts' * @example 'nextjs' * @example 'cypress-ct-solid-js' diff --git a/npm/webpack-dev-server/src/devServer.ts b/npm/webpack-dev-server/src/devServer.ts index 4e0d90d81148..675328144e22 100644 --- a/npm/webpack-dev-server/src/devServer.ts +++ b/npm/webpack-dev-server/src/devServer.ts @@ -114,11 +114,22 @@ export type PresetHandlerResult = { frameworkConfig: Configuration, sourceWebpac type Optional = Pick, K> & Omit +const thirdPartyDefinitionPrefixes = { + // matches @org/cypress-ct-* + namespacedPrefixRe: /^@.+?\/cypress-ct-.+/, + globalPrefix: 'cypress-ct-', +} + +export function isThirdPartyDefinition (framework: string) { + return framework.startsWith(thirdPartyDefinitionPrefixes.globalPrefix) || + thirdPartyDefinitionPrefixes.namespacedPrefixRe.test(framework) +} + async function getPreset (devServerConfig: WebpackDevServerConfig): Promise> { const defaultWebpackModules = () => ({ sourceWebpackModulesResult: sourceDefaultWebpackDependencies(devServerConfig) }) // Third party library (eg solid-js, lit, etc) - if (devServerConfig.framework?.startsWith('cypress-ct-')) { + if (devServerConfig.framework && isThirdPartyDefinition(devServerConfig.framework)) { return defaultWebpackModules() } diff --git a/packages/scaffold-config/src/ct-detect-third-party.ts b/packages/scaffold-config/src/ct-detect-third-party.ts index eef6f3391b12..44a9cbeb0be7 100644 --- a/packages/scaffold-config/src/ct-detect-third-party.ts +++ b/packages/scaffold-config/src/ct-detect-third-party.ts @@ -1,6 +1,7 @@ import path from 'path' import globby from 'globby' import { z } from 'zod' +import fs from 'fs-extra' import Debug from 'debug' const debug = Debug('cypress:scaffold-config:ct-detect-third-party') @@ -18,8 +19,19 @@ const DependencyArraySchema = z.array(DependencySchema) const BundlerSchema = z.enum(['webpack', 'vite']) +const thirdPartyDefinitionPrefixes = { + // matches @org/cypress-ct-* + namespacedPrefixRe: /^@.+?\/cypress-ct-.+/, + globalPrefix: 'cypress-ct-', +} + +export function isThirdPartyDefinition (definition: Cypress.ComponentFrameworkDefinition | Cypress.ThirdPartyComponentFrameworkDefinition): definition is Cypress.ThirdPartyComponentFrameworkDefinition { + return definition.type.startsWith(thirdPartyDefinitionPrefixes.globalPrefix) || + thirdPartyDefinitionPrefixes.namespacedPrefixRe.test(definition.type) +} + const ThirdPartyComponentFrameworkSchema = z.object({ - type: z.string().startsWith('cypress-ct-'), + type: z.string().startsWith(thirdPartyDefinitionPrefixes.globalPrefix).or(z.string().regex(thirdPartyDefinitionPrefixes.namespacedPrefixRe)), name: z.string(), supportedBundlers: z.array(BundlerSchema), detectors: DependencyArraySchema, @@ -27,45 +39,21 @@ const ThirdPartyComponentFrameworkSchema = z.object({ componentIndexHtml: z.optional(z.function()), }) -const CT_FRAMEWORK_GLOB = path.join('node_modules', 'cypress-ct-*', 'package.json') - -// tsc will compile `import(...)` calls to require unless a different tsconfig.module value -// is used (e.g. module=node16). To change this, we would also have to change the ts-node behavior when requiring the -// Cypress config file. This hack for keeping dynamic imports from being converted works across all -// of our supported node versions - -// const _dynamicImport = new Function('specifier', 'return import(specifier)') - -// const dynamicImport = (module: string) => { -// return _dynamicImport(module) as Promise -// } - -// const dynamicAbsoluteImport = (filePath: string) => { -// return dynamicImport(pathToFileURL(filePath).href) as Promise -// } - -/** - * When compiling CJS -> ESM, TS can produce: - * Imported [Module: null prototype] { __esModule: true, default: { default: { type: 'cypress-ct-solid-js', ... } } } - * We just keep getting `default` property until none exists. - */ -function getDefaultExport (mod: T): T { - if (mod?.default) { - return getDefaultExport(mod.default) - } - - return mod?.default ?? mod -} +const CT_FRAMEWORK_GLOBAL_GLOB = path.join('node_modules', 'cypress-ct-*', 'package.json') +const CT_FRAMEWORK_NAMESPACED_GLOB = path.join('node_modules', '@*?/cypress-ct-*?', 'package.json') export async function detectThirdPartyCTFrameworks ( projectRoot: string, ): Promise { try { - const fullPathGlob = path.join(projectRoot, CT_FRAMEWORK_GLOB).replaceAll('\\', '/') + const fullPathGlobs = [ + path.join(projectRoot, CT_FRAMEWORK_GLOBAL_GLOB), + path.join(projectRoot, CT_FRAMEWORK_NAMESPACED_GLOB), + ].map((x) => x.replaceAll('\\', '/')) - const packageJsonPaths = await globby(fullPathGlob) + const packageJsonPaths = await globby(fullPathGlobs) - debug('Found packages matching %s glob: %o', fullPathGlob, packageJsonPaths) + debug('Found packages matching %s glob: %o', fullPathGlobs, packageJsonPaths) const modules = await Promise.all( packageJsonPaths.map(async (packageJsonPath) => { @@ -86,21 +74,19 @@ export async function detectThirdPartyCTFrameworks ( * } * } */ - const packageName = path.basename(path.dirname(packageJsonPath)) + const pkgJson = await fs.readJSON(packageJsonPath) - debug('Attempting to resolve third party module with require.resolve: %s', packageName) + debug('`name` in package.json', pkgJson.name) - const modulePath = require.resolve(packageName, { paths: [projectRoot] }) + debug('Attempting to resolve third party module with require.resolve: %s', pkgJson.name) + + const modulePath = require.resolve(pkgJson.name, { paths: [projectRoot] }) debug('Resolve successful: %s', modulePath) debug('require(%s)', modulePath) - const m = require(modulePath) - - debug('Imported %o', m) - - const mod = getDefaultExport(m) + const mod = require(modulePath) debug('Module is %o', mod) diff --git a/packages/scaffold-config/src/frameworks.ts b/packages/scaffold-config/src/frameworks.ts index dc2f57331b9b..1145b1e8aca0 100644 --- a/packages/scaffold-config/src/frameworks.ts +++ b/packages/scaffold-config/src/frameworks.ts @@ -4,6 +4,7 @@ import * as dependencies from './dependencies' import componentIndexHtmlGenerator from './component-index-template' import debugLib from 'debug' import semver from 'semver' +import { isThirdPartyDefinition } from './ct-detect-third-party' const debug = debugLib('cypress:scaffold-config:frameworks') @@ -281,16 +282,13 @@ export const CT_FRAMEWORKS: Cypress.ComponentFrameworkDefinition[] = [ }, ] -function isThirdPartyDefinition (definition: Cypress.ComponentFrameworkDefinition | Cypress.ThirdPartyComponentFrameworkDefinition): definition is Cypress.ThirdPartyComponentFrameworkDefinition { - return definition.type.startsWith('cypress-ct') -} /** * Given a first or third party Component Framework Definition, * resolves into a unified ResolvedComponentFrameworkDefinition. * This way we have a single type used throughout Cypress. */ export function resolveComponentFrameworkDefinition (definition: Cypress.ComponentFrameworkDefinition | Cypress.ThirdPartyComponentFrameworkDefinition): Cypress.ResolvedComponentFrameworkDefinition { - const thirdParty = isThirdPartyDefinition(definition) // type.startsWith('cypress-ct-') + const thirdParty = isThirdPartyDefinition(definition) const dependencies: Cypress.ResolvedComponentFrameworkDefinition['dependencies'] = async (bundler, projectPath) => { const declaredDeps = definition.dependencies(bundler) diff --git a/packages/scaffold-config/test/unit/ct-detect-third-party.spec.ts b/packages/scaffold-config/test/unit/ct-detect-third-party.spec.ts index db1f5d4f38d5..bef11ec59b43 100644 --- a/packages/scaffold-config/test/unit/ct-detect-third-party.spec.ts +++ b/packages/scaffold-config/test/unit/ct-detect-third-party.spec.ts @@ -1,14 +1,14 @@ import { scaffoldMigrationProject, fakeDepsInNodeModules } from './detect.spec' import fs from 'fs-extra' import path from 'path' -import { detectThirdPartyCTFrameworks, validateThirdPartyModule } from '../../src' +import { detectThirdPartyCTFrameworks, validateThirdPartyModule, isThirdPartyDefinition } from '../../src' import { expect } from 'chai' import solidJs from './fixtures' -async function scaffoldQwikApp (thirdPartyModuleNames: Array<'cypress-ct-qwik' | 'misconfigured-cypress-ct-qwik'>) { +async function scaffoldQwikApp (thirdPartyModuleNames: Array<'cypress-ct-qwik' | '@org/cypress-ct-qwik' | 'misconfigured-cypress-ct-qwik'>) { const projectRoot = await scaffoldMigrationProject('qwik-app') - await fakeDepsInNodeModules(projectRoot, [{ dependency: '@builder.io/qwik', version: '0.17.5' }]) + fakeDepsInNodeModules(projectRoot, [{ dependency: '@builder.io/qwik', version: '0.17.5' }]) for (const thirdPartyModuleName of thirdPartyModuleNames) { const nodeModulePath = path.join(projectRoot, 'node_modules', thirdPartyModuleName) @@ -19,8 +19,38 @@ async function scaffoldQwikApp (thirdPartyModuleNames: Array<'cypress-ct-qwik' | return projectRoot } +describe('isThirdPartyDefinition', () => { + context('global package', () => { + it('returns false for invalid prefix', () => { + const res = isThirdPartyDefinition({ ...solidJs, type: 'non-cypress-ct' }) + + expect(res).to.be.false + }) + + it('returns true for valid prefix', () => { + const res = isThirdPartyDefinition({ ...solidJs, type: 'cypress-ct-solid-js' }) + + expect(res).to.be.true + }) + }) + + context('namespaced package', () => { + it('returns false for non third party with namespace', () => { + const res = isThirdPartyDefinition({ ...solidJs, type: '@org/non-cypress-ct' }) + + expect(res).to.be.false + }) + + it('returns true for third party with namespace', () => { + const res = isThirdPartyDefinition({ ...solidJs, type: '@org/cypress-ct-solid-js' }) + + expect(res).to.be.true + }) + }) +}) + describe('detectThirdPartyCTFrameworks', () => { - it('detects third party frameworks', async () => { + it('detects third party frameworks in global namespace', async () => { const projectRoot = await scaffoldQwikApp(['cypress-ct-qwik']) const thirdPartyFrameworks = await detectThirdPartyCTFrameworks(projectRoot) @@ -28,6 +58,14 @@ describe('detectThirdPartyCTFrameworks', () => { expect(thirdPartyFrameworks[0].type).eq('cypress-ct-qwik') }) + it('detects third party frameworks in org namespace', async () => { + const projectRoot = await scaffoldQwikApp(['@org/cypress-ct-qwik']) + + const thirdPartyFrameworks = await detectThirdPartyCTFrameworks(projectRoot) + + expect(thirdPartyFrameworks[0].type).eq('@org/cypress-ct-qwik') + }) + it('ignores misconfigured third party frameworks', async () => { const projectRoot = await scaffoldQwikApp(['cypress-ct-qwik', 'misconfigured-cypress-ct-qwik']) diff --git a/system-tests/projects/qwik-app/@org/cypress-ct-qwik/definition.cjs b/system-tests/projects/qwik-app/@org/cypress-ct-qwik/definition.cjs new file mode 100644 index 000000000000..d057f77e27ed --- /dev/null +++ b/system-tests/projects/qwik-app/@org/cypress-ct-qwik/definition.cjs @@ -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: '@org/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: '', +} diff --git a/system-tests/projects/qwik-app/@org/cypress-ct-qwik/index.mjs b/system-tests/projects/qwik-app/@org/cypress-ct-qwik/index.mjs new file mode 100644 index 000000000000..0c91719efdcf --- /dev/null +++ b/system-tests/projects/qwik-app/@org/cypress-ct-qwik/index.mjs @@ -0,0 +1,3 @@ +export default function mount () { + return 'Legit mount function' +} diff --git a/system-tests/projects/qwik-app/@org/cypress-ct-qwik/package.json b/system-tests/projects/qwik-app/@org/cypress-ct-qwik/package.json new file mode 100644 index 000000000000..7b437a1e9b4e --- /dev/null +++ b/system-tests/projects/qwik-app/@org/cypress-ct-qwik/package.json @@ -0,0 +1,9 @@ +{ + "name": "@org/cypress-ct-qwik", + "version": "1.0.0", + "main": "index.js", + "exports": { + "node": "./definition.cjs", + "default": "./index.mjs" + } +}