Skip to content

Commit dc5d9de

Browse files
feat(webpack-dev-server): add angular handler (#22314)
Co-authored-by: Zachary Williams <[email protected]>
1 parent 5ff1504 commit dc5d9de

37 files changed

+14655
-5
lines changed

cli/types/cypress.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3044,7 +3044,7 @@ declare namespace Cypress {
30443044

30453045
type DevServerConfigOptions = {
30463046
bundler: 'webpack'
3047-
framework: 'react' | 'vue' | 'vue-cli' | 'nuxt' | 'create-react-app' | 'next'
3047+
framework: 'react' | 'vue' | 'vue-cli' | 'nuxt' | 'create-react-app' | 'next' | 'angular'
30483048
webpackConfig?: PickConfigOpt<'webpackConfig'>
30493049
} | {
30503050
bundler: 'vite'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// <reference types="cypress" />
2+
/// <reference path="../support/e2e.ts" />
3+
import type { ProjectFixtureDir } from '@tooling/system-tests/lib/fixtureDirs'
4+
5+
const WEBPACK_REACT: ProjectFixtureDir[] = ['angular-13', 'angular-14']
6+
7+
// Add to this list to focus on a particular permutation
8+
const ONLY_PROJECTS: ProjectFixtureDir[] = []
9+
10+
for (const project of WEBPACK_REACT) {
11+
if (ONLY_PROJECTS.length && !ONLY_PROJECTS.includes(project)) {
12+
continue
13+
}
14+
15+
describe(`Working with ${project}`, () => {
16+
beforeEach(() => {
17+
cy.scaffoldProject(project)
18+
cy.openProject(project)
19+
cy.startAppServer('component')
20+
})
21+
22+
it('should mount a passing test', () => {
23+
cy.visitApp()
24+
cy.contains('app.component.cy.ts').click()
25+
cy.waitForSpecToFinish()
26+
cy.get('.passed > .num').should('contain', 1)
27+
})
28+
29+
it('should live-reload on src changes', () => {
30+
cy.visitApp()
31+
cy.contains('app.component.cy.ts').click()
32+
cy.waitForSpecToFinish()
33+
cy.get('.passed > .num').should('contain', 1)
34+
35+
cy.withCtx(async (ctx) => {
36+
await ctx.actions.file.writeFileInProject(
37+
ctx.path.join('src', 'app', 'app.component.html'),
38+
(await ctx.file.readFileInProject(ctx.path.join('src', 'app', 'app.component.html'))).replace('Hello World', 'Hello Cypress'),
39+
)
40+
})
41+
42+
cy.get('.failed > .num').should('contain', 1)
43+
44+
cy.withCtx(async (ctx) => {
45+
await ctx.actions.file.writeFileInProject(
46+
ctx.path.join('src', 'app', 'app.component.html'),
47+
(await ctx.file.readFileInProject(ctx.path.join('src', 'app', 'app.component.html'))).replace('Hello Cypress', 'Hello World'),
48+
)
49+
})
50+
51+
cy.get('.passed > .num').should('contain', 1)
52+
})
53+
54+
it('should detect new spec', () => {
55+
cy.visitApp()
56+
57+
cy.withCtx(async (ctx) => {
58+
await ctx.actions.file.writeFileInProject(
59+
ctx.path.join('src', 'app', 'new.component.cy.ts'),
60+
await ctx.file.readFileInProject(ctx.path.join('src', 'app', 'app.component.cy.ts')),
61+
)
62+
})
63+
64+
cy.contains('new.component.cy.ts').click()
65+
cy.waitForSpecToFinish()
66+
cy.get('.passed > .num').should('contain', 1)
67+
})
68+
})
69+
}

npm/webpack-dev-server/src/devServer.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { nuxtHandler } from './helpers/nuxtHandler'
1212
import { createReactAppHandler } from './helpers/createReactAppHandler'
1313
import { nextHandler } from './helpers/nextHandler'
1414
import { sourceDefaultWebpackDependencies, SourceRelativeWebpackResult } from './helpers/sourceRelativeWebpackModules'
15+
import { angularHandler } from './helpers/angularHandler'
1516

1617
const debug = debugLib('cypress:webpack-dev-server:devServer')
1718

@@ -25,7 +26,7 @@ export type WebpackDevServerConfig = {
2526
webpackConfig?: unknown // Derived from the user's webpack
2627
}
2728

28-
export const ALL_FRAMEWORKS = ['create-react-app', 'nuxt', 'react', 'vue-cli', 'next', 'vue'] as const
29+
export const ALL_FRAMEWORKS = ['create-react-app', 'nuxt', 'react', 'vue-cli', 'next', 'vue', 'angular'] as const
2930

3031
/**
3132
* @internal
@@ -115,6 +116,9 @@ async function getPreset (devServerConfig: WebpackDevServerConfig): Promise<Opti
115116
case 'next':
116117
return await nextHandler(devServerConfig)
117118

119+
case 'angular':
120+
return await angularHandler(devServerConfig)
121+
118122
case 'react':
119123
case 'vue':
120124
case undefined:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import * as fs from 'fs-extra'
2+
import { tmpdir } from 'os'
3+
import * as path from 'path'
4+
import { pathToFileURL } from 'url'
5+
import type { PresetHandlerResult, WebpackDevServerConfig } from '../devServer'
6+
import { sourceDefaultWebpackDependencies } from './sourceRelativeWebpackModules'
7+
8+
export type AngularJsonProjectConfig = {
9+
projectType: string
10+
root: string
11+
sourceRoot: string
12+
architect: {
13+
build: {
14+
options: { [key: string]: any } & { polyfills?: string }
15+
configurations?: {
16+
[configuration: string]: {
17+
[key: string]: any
18+
}
19+
}
20+
}
21+
}
22+
}
23+
24+
type AngularJson = {
25+
defaultProject?: string
26+
projects: {
27+
[project: string]: AngularJsonProjectConfig
28+
}
29+
}
30+
31+
const dynamicImport = new Function('specifier', 'return import(specifier)')
32+
33+
export async function angularHandler (devServerConfig: WebpackDevServerConfig): Promise<PresetHandlerResult> {
34+
const webpackConfig = await getAngularCliWebpackConfig(devServerConfig)
35+
36+
return { frameworkConfig: webpackConfig, sourceWebpackModulesResult: sourceDefaultWebpackDependencies(devServerConfig) }
37+
}
38+
39+
async function getAngularCliWebpackConfig (devServerConfig: WebpackDevServerConfig) {
40+
const { projectRoot } = devServerConfig.cypressConfig
41+
42+
const {
43+
generateBrowserWebpackConfigFromContext,
44+
getCommonConfig,
45+
getStylesConfig,
46+
} = await getAngularCliModules(projectRoot)
47+
48+
const angularJson = await getAngularJson(projectRoot)
49+
50+
let { defaultProject } = angularJson
51+
52+
if (!defaultProject) {
53+
defaultProject = Object.keys(angularJson.projects).find((name) => angularJson.projects[name].projectType === 'application')
54+
55+
if (!defaultProject) {
56+
throw new Error('Could not find a project with projectType "application" in "angular.json"')
57+
}
58+
}
59+
60+
const defaultProjectConfig = angularJson.projects[defaultProject]
61+
62+
const tsConfig = await generateTsConfig(devServerConfig, defaultProjectConfig)
63+
64+
const buildOptions = getAngularBuildOptions(defaultProjectConfig, tsConfig)
65+
66+
const context = createFakeContext(projectRoot, defaultProject, defaultProjectConfig)
67+
68+
const { config } = await generateBrowserWebpackConfigFromContext(
69+
buildOptions,
70+
context,
71+
(wco: any) => [getCommonConfig(wco), getStylesConfig(wco)],
72+
)
73+
74+
delete config.entry.main
75+
76+
return config
77+
}
78+
79+
export function getAngularBuildOptions (projectConfig: AngularJsonProjectConfig, tsConfig: string) {
80+
// Default options are derived from the @angular-devkit/build-angular browser builder, with some options from
81+
// the serve builder thrown in for development.
82+
// see: https://github.com/angular/angular-cli/blob/main/packages/angular_devkit/build_angular/src/builders/browser/schema.json
83+
return {
84+
outputPath: 'dist/angular-app',
85+
assets: [],
86+
styles: [],
87+
scripts: [],
88+
budgets: [],
89+
fileReplacements: [],
90+
outputHashing: 'all',
91+
inlineStyleLanguage: 'css',
92+
stylePreprocessorOptions: { includePaths: [] },
93+
resourcesOutputPath: undefined,
94+
commonChunk: true,
95+
baseHref: undefined,
96+
deployUrl: undefined,
97+
verbose: false,
98+
progress: false,
99+
i18nMissingTranslation: 'warning',
100+
i18nDuplicateTranslation: 'warning',
101+
localize: undefined,
102+
watch: true,
103+
poll: undefined,
104+
deleteOutputPath: true,
105+
preserveSymlinks: undefined,
106+
showCircularDependencies: false,
107+
subresourceIntegrity: false,
108+
serviceWorker: false,
109+
ngswConfigPath: undefined,
110+
statsJson: false,
111+
webWorkerTsConfig: undefined,
112+
crossOrigin: 'none',
113+
allowedCommonJsDependencies: [], // Add Cypress 'browser.js' entry point to ignore "CommonJS or AMD dependencies can cause optimization bailouts." warning
114+
buildOptimizer: false,
115+
optimization: false,
116+
vendorChunk: true,
117+
extractLicenses: false,
118+
sourceMap: true,
119+
namedChunks: true,
120+
...projectConfig.architect.build.options,
121+
...projectConfig.architect.build.configurations?.development || {},
122+
tsConfig,
123+
aot: false,
124+
}
125+
}
126+
127+
export async function generateTsConfig (devServerConfig: WebpackDevServerConfig, projectConfig: AngularJsonProjectConfig): Promise<string> {
128+
const { cypressConfig } = devServerConfig
129+
const { projectRoot } = cypressConfig
130+
131+
const specPattern = Array.isArray(cypressConfig.specPattern) ? cypressConfig.specPattern : [cypressConfig.specPattern]
132+
133+
const getProjectFilePath = (...fileParts: string[]): string => toPosix(path.join(projectRoot, ...fileParts))
134+
135+
const includePaths = [...specPattern.map((pattern) => getProjectFilePath(pattern))]
136+
137+
if (cypressConfig.supportFile) {
138+
includePaths.push(toPosix(cypressConfig.supportFile))
139+
}
140+
141+
if (projectConfig.architect.build.options.polyfills) {
142+
const polyfills = getProjectFilePath(projectConfig.architect.build.options.polyfills)
143+
144+
includePaths.push(polyfills)
145+
}
146+
147+
const cypressTypes = getProjectFilePath('node_modules', 'cypress', 'types', 'index.d.ts')
148+
149+
includePaths.push(cypressTypes)
150+
151+
const tsConfigContent = JSON.stringify({
152+
extends: getProjectFilePath('tsconfig.json'),
153+
compilerOptions: {
154+
outDir: getProjectFilePath('out-tsc/cy'),
155+
allowSyntheticDefaultImports: true,
156+
},
157+
include: includePaths,
158+
})
159+
160+
const tsConfigPath = path.join(await getTempDir(), 'tsconfig.json')
161+
162+
await fs.writeFile(tsConfigPath, tsConfigContent)
163+
164+
return tsConfigPath
165+
}
166+
167+
export async function getTempDir (): Promise<string> {
168+
const cypressTempDir = path.join(tmpdir(), 'cypress-angular-ct')
169+
170+
await fs.ensureDir(cypressTempDir)
171+
172+
return cypressTempDir
173+
}
174+
175+
export async function getAngularCliModules (projectRoot: string) {
176+
const [
177+
{ generateBrowserWebpackConfigFromContext },
178+
{ getCommonConfig },
179+
{ getStylesConfig },
180+
] = await Promise.all([
181+
'@angular-devkit/build-angular/src/utils/webpack-browser-config.js',
182+
'@angular-devkit/build-angular/src/webpack/configs/common.js',
183+
'@angular-devkit/build-angular/src/webpack/configs/styles.js',
184+
].map((dep) => {
185+
try {
186+
const depPath = require.resolve(dep, { paths: [projectRoot] })
187+
188+
const url = pathToFileURL(depPath).href
189+
190+
return dynamicImport(url)
191+
} catch (e) {
192+
throw new Error(`Could not resolve "${dep}". Do you have "@angular-devkit/build-angular" installed?`)
193+
}
194+
}))
195+
196+
return {
197+
generateBrowserWebpackConfigFromContext,
198+
getCommonConfig,
199+
getStylesConfig,
200+
}
201+
}
202+
203+
export async function getAngularJson (projectRoot: string): Promise<AngularJson> {
204+
const { findUp } = await dynamicImport('find-up') as typeof import('find-up')
205+
206+
const angularJsonPath = await findUp('angular.json', { cwd: projectRoot })
207+
208+
if (!angularJsonPath) {
209+
throw new Error(`Could not find angular.json. Looked in ${projectRoot} and up.`)
210+
}
211+
212+
const angularJson = await fs.readFile(angularJsonPath, 'utf8')
213+
214+
return JSON.parse(angularJson)
215+
}
216+
217+
function createFakeContext (projectRoot: string, defaultProject: string, defaultProjectConfig: any) {
218+
const logger = {
219+
createChild: () => ({}),
220+
}
221+
222+
const context = {
223+
target: {
224+
project: defaultProject,
225+
},
226+
workspaceRoot: projectRoot,
227+
getProjectMetadata: () => {
228+
return {
229+
root: defaultProjectConfig.root,
230+
sourceRoot: defaultProjectConfig.root,
231+
projectType: 'application',
232+
}
233+
},
234+
logger,
235+
}
236+
237+
return context
238+
}
239+
240+
export const toPosix = (filePath: string) => filePath.split(path.sep).join(path.posix.sep)

npm/webpack-dev-server/src/helpers/createReactAppHandler.ts

+2
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ function loadWebpackConfig (devServerConfig: WebpackDevServerConfig): Configurat
7777
webpackConfig = webpackConfig('development')
7878
}
7979

80+
delete webpackConfig.entry
81+
8082
return webpackConfig
8183
} catch (err) {
8284
throw new Error(`Failed to require webpack config at ${webpackConfigPath} with error: ${err}`)

npm/webpack-dev-server/src/helpers/nextHandler.ts

+2
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ async function loadWebpackConfig (devServerConfig: WebpackDevServerConfig): Prom
7272
},
7373
)
7474

75+
delete webpackConfig.entry
76+
7577
return webpackConfig
7678
}
7779

npm/webpack-dev-server/src/helpers/nuxtHandler.ts

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export async function nuxtHandler (devServerConfig: WebpackDevServerConfig): Pro
1919
// Nuxt has asset size warnings configured by default which will cause webpack overlays to appear
2020
// in the browser which we don't want.
2121
delete webpackConfig.performance
22+
delete webpackConfig.entry
2223

2324
debug('webpack config %o', webpackConfig)
2425

npm/webpack-dev-server/src/helpers/sourceRelativeWebpackModules.ts

+1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ const frameworkWebpackMapper: FrameworkWebpackMapper = {
6565
react: undefined,
6666
vue: undefined,
6767
next: 'next',
68+
'angular': '@angular-devkit/build-angular',
6869
}
6970

7071
// Source the users framework from the provided projectRoot. The framework, if available, will serve

npm/webpack-dev-server/src/helpers/vueCliHandler.ts

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export function vueCliHandler (devServerConfig: WebpackDevServerConfig): PresetH
1717

1818
debug('webpack config %o', webpackConfig)
1919

20+
delete webpackConfig.entry
21+
2022
return { frameworkConfig: webpackConfig, sourceWebpackModulesResult }
2123
} catch (e) {
2224
console.error(e) // eslint-disable-line no-console

0 commit comments

Comments
 (0)