feat: Set up cypress in cypress (#19602)

Co-authored-by: Brian Mann <brian.mann86@gmail.com>
This commit is contained in:
Ryan Manuel
2022-01-14 17:07:07 -06:00
committed by GitHub
parent 2755f139d9
commit ed51bcbdda
65 changed files with 595 additions and 361 deletions
+2 -1
View File
@@ -37,6 +37,7 @@ export const makeCypressPlugin = (
supportFilePath: string | false,
devServerEvents: NodeJS.EventEmitter,
specs: Spec[],
namespace: string,
indexHtml?: string,
): Plugin => {
let base = '/'
@@ -60,7 +61,7 @@ export const makeCypressPlugin = (
return {
define: {
'import.meta.env.__cypress_supportPath': JSON.stringify(normalizedSupportFilePath),
'import.meta.env.__cypress_originAutUrl': JSON.stringify(`__cypress/iframes/${convertPathToPosix(projectRoot)}/`),
'import.meta.env.__cypress_originAutUrl': JSON.stringify(`${namespace}/iframes/${convertPathToPosix(projectRoot)}/`),
},
}
}
+3 -3
View File
@@ -26,16 +26,16 @@ export interface StartDevServerOptions {
}
const resolveServerConfig = async ({ viteConfig, options, indexHtml }: StartDevServerOptions): Promise<InlineConfig> => {
const { projectRoot, supportFile } = options.config
const { projectRoot, supportFile, namespace } = options.config
const requiredOptions: InlineConfig = {
base: '/__cypress/src/',
base: `/${namespace}/src/`,
root: projectRoot,
}
const finalConfig: InlineConfig = { ...viteConfig, ...requiredOptions }
finalConfig.plugins = [...(finalConfig.plugins || []), makeCypressPlugin(projectRoot, supportFile, options.devServerEvents, options.specs, indexHtml)]
finalConfig.plugins = [...(finalConfig.plugins || []), makeCypressPlugin(projectRoot, supportFile, options.devServerEvents, options.specs, options.config.namespace, indexHtml)]
// This alias is necessary to avoid a "prefixIdentifiers" issue from slots mounting
// only cjs compiler-core accepts using prefixIdentifiers in slots which vue test utils use.
+8
View File
@@ -6,6 +6,12 @@ const CYPRESS_INTERNAL_CLOUD_ENV = getenv('CYPRESS_INTERNAL_CLOUD_ENV', process.
export default defineConfig({
projectId: CYPRESS_INTERNAL_CLOUD_ENV === 'staging' ? 'ypt4pf' : 'sehy69',
// @ts-ignore We are setting these namespaces in order to properly test Cypress in Cypress
clientRoute: '/__app/',
namespace: '__cypress-app',
socketIoRoute: '/__app-socket.io',
socketIoCookie: '__app-socket.io',
devServerPublicPathRoute: '/__cypress-app/src',
viewportWidth: 800,
viewportHeight: 850,
retries: {
@@ -38,6 +44,8 @@ export default defineConfig({
pluginsFile: 'cypress/e2e/plugins/index.ts',
supportFile: 'cypress/e2e/support/e2eSupport.ts',
async setupNodeEvents (on, config) {
// Delete this as we only want to honor it on parent Cypress when doing E2E Cypress in Cypress testing
delete process.env.HTTP_PROXY_TARGET_FOR_ORIGIN_REQUESTS
process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF = 'true'
// process.env.DEBUG = '*'
const { e2ePluginSetup } = require('@packages/frontend-shared/cypress/e2e/e2ePluginSetup')
@@ -0,0 +1,14 @@
describe('Cypress In Cypress', () => {
it('test component', () => {
cy.scaffoldProject('cypress-in-cypress')
cy.openProject('cypress-in-cypress')
cy.startAppServer('component')
cy.visitApp()
cy.contains('TestComponent.spec').click()
cy.location().should((location) => {
expect(location.hash).to.contain('TestComponent.spec')
})
cy.get('[data-model-state="passed"]').should('contain', 'renders the test component')
})
})
@@ -0,0 +1,14 @@
describe('Cypress In Cypress', () => {
it('test e2e', () => {
cy.scaffoldProject('cypress-in-cypress')
cy.openProject('cypress-in-cypress')
cy.startAppServer()
cy.visitApp()
cy.contains('dom-content.spec').click()
cy.location().should((location) => {
expect(location.hash).to.contain('dom-content.spec')
})
cy.get('[data-model-state="passed"]').should('contain', 'renders the test content')
})
})
+29 -25
View File
@@ -16,6 +16,35 @@ describe('App: Index', () => {
cy.visitApp()
})
// TODO(ryan m and tim): Skipping until https://github.com/cypress-io/cypress/pull/19619 is merged
const tempSkip = new Date() > new Date('2022-01-21') ? context : context.skip
tempSkip('scaffold example specs', () => {
const assertSpecs = (createdSpecs: FoundSpec[]) => cy.wrap(createdSpecs).each((spec: FoundSpec) => cy.contains(spec.baseName).scrollIntoView().should('be.visible'))
it('should generate example specs', () => {
let createdSpecs: FoundSpec[]
cy.visitApp()
cy.intercept('POST', 'mutation-ScaffoldGeneratorStepOne_scaffoldIntegration').as('scaffoldIntegration')
cy.contains(defaultMessages.createSpec.e2e.importFromScaffold.header).click()
cy.wait('@scaffoldIntegration').then((interception: Interception) => {
createdSpecs = interception.response?.body.data.scaffoldIntegration.map((res) => res.file)
expect(createdSpecs).lengthOf.above(0)
cy.contains(defaultMessages.createSpec.e2e.importFromScaffold.specsAddedHeader).should('be.visible')
assertSpecs(createdSpecs)
})
cy.contains(defaultMessages.createSpec.e2e.importFromScaffold.specsAddedButton).click()
cy.visitApp().then(() => assertSpecs(createdSpecs))
})
})
context('with no specs', () => {
it('shows "Create spec" title', () => {
// TODO: we need more e2e tests around this, but it requires changes to how we set up config in our
@@ -57,29 +86,4 @@ describe('App: Index', () => {
cy.findByTestId('spec-item').should('contain', 'new-file')
})
})
context('scaffold example specs', () => {
const assertSpecs = (createdSpecs: FoundSpec[]) => cy.wrap(createdSpecs).each((spec: FoundSpec) => cy.contains(spec.baseName).scrollIntoView().should('be.visible'))
it('should generate example specs', () => {
let createdSpecs: FoundSpec[]
cy.visitApp()
cy.intercept('mutation-ScaffoldGeneratorStepOne_scaffoldIntegration').as('scaffoldIntegration')
cy.contains(defaultMessages.createSpec.e2e.importFromScaffold.header).click()
cy.wait('@scaffoldIntegration').then((interception: Interception) => {
createdSpecs = interception.response?.body.data.scaffoldIntegration.map((res) => res.file)
expect(createdSpecs).lengthOf.above(0)
cy.contains(defaultMessages.createSpec.e2e.importFromScaffold.specsAddedHeader).should('be.visible')
assertSpecs(createdSpecs)
})
cy.contains(defaultMessages.createSpec.e2e.importFromScaffold.specsAddedButton).click()
cy.visitApp().then(() => assertSpecs(createdSpecs))
})
})
})
+2 -2
View File
@@ -183,7 +183,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
context('Runs - Unauthorized Project Requested', () => {
beforeEach(() => {
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {\'projectId\': \'abcdef\'}')
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {\'projectId\': \'abcdef\' }')
})
cy.loginUser()
@@ -215,7 +215,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
context('Runs - No Runs', () => {
it('when no runs and not connected, shows connect to dashboard button', () => {
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {projectId: null}')
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {projectId: null }')
})
cy.loginUser()
+2 -2
View File
@@ -25,7 +25,7 @@ describe('App: Settings', () => {
})
it('can reconfigure a project', () => {
cy.visitApp('#settings')
cy.visitApp('settings')
cy.intercept('mutation-SettingsContainer_ReconfigureProject', { 'data': { 'reconfigureProject': true } }).as('ReconfigureProject')
cy.findByText('Reconfigure Project').click()
@@ -49,7 +49,7 @@ describe('App: Settings', () => {
ctx.coreData.localSettings.preferences.preferredEditorBinary = undefined
})
cy.visitApp('#settings')
cy.visitApp('settings')
cy.contains('Device Settings').click()
})
+1
View File
@@ -5,6 +5,7 @@
<meta charset="utf-8" />
<link href="/__cypress/assets/favicon.png" rel="icon">
<title>Cypress</title>
<link href="/__cypress/runner/favicon.ico" rel="icon">
</head>
<body>
+5 -4
View File
@@ -8,10 +8,11 @@
"clean": "rimraf dist && rimraf ./node_modules/.vite && echo 'cleaned'",
"clean-deps": "rimraf node_modules",
"test": "echo 'ok'",
"cypress:launch": "cross-env TZ=America/New_York gulp open --project ${PWD}",
"cypress:open": "cross-env TZ=America/New_York gulp open --project ${PWD}",
"cypress:run:ct": "cross-env TZ=America/New_York node ../../scripts/cypress run --component --project ${PWD}",
"cypress:run:e2e": "cross-env TZ=America/New_York node ../../scripts/cypress run --project ${PWD}",
"cypress:run-cypress-in-cypress": "cross-env HTTP_PROXY_TARGET_FOR_ORIGIN_REQUESTS=http://localhost:4455 CYPRESS_REMOTE_DEBUGGING_PORT=6666 TZ=America/New_York",
"cypress:launch": "yarn cypress:run-cypress-in-cypress gulp open --project ${PWD}",
"cypress:open": "yarn cypress:run-cypress-in-cypress gulp open --project ${PWD}",
"cypress:run:ct": "yarn cypress:run-cypress-in-cypress node ../../scripts/cypress run --component --project ${PWD}",
"cypress:run:e2e": "yarn cypress:run-cypress-in-cypress node ../../scripts/cypress run --project ${PWD}",
"debug": "gulp debug --project ${PWD}",
"dev": "gulp dev --project ${PWD}",
"start": "echo \"run 'yarn dev' from the root\" && exit 1",
+5 -2
View File
@@ -4,6 +4,7 @@ import 'virtual:windi.css'
import urql from '@urql/vue'
import App from './App.vue'
import { makeUrqlClient } from '@packages/frontend-shared/src/graphql/urqlClient'
import { decodeBase64Unicode } from '@packages/frontend-shared/src/utils/base64'
import { createI18n } from '@cy/i18n'
import { createRouter } from './router/router'
import { createPinia } from './store'
@@ -19,7 +20,9 @@ window.__vite__ = true
const app = createApp(App)
const ws = createWebsocket()
const config = JSON.parse(decodeBase64Unicode(window.__CYPRESS_CONFIG__.base64Config)) as Cypress.Config
const ws = createWebsocket(config.socketIoRoute)
window.ws = ws
@@ -27,7 +30,7 @@ app.use(Toast, {
position: POSITION.BOTTOM_RIGHT,
})
app.use(urql, makeUrqlClient('app'))
app.use(urql, makeUrqlClient({ target: 'app', socketIoRoute: config.socketIoRoute }))
app.use(createRouter())
app.use(createI18n())
app.use(createPinia())
+1 -3
View File
@@ -16,8 +16,6 @@ type $Cypress = any
const noop = () => {}
const automationElementId = '__cypress-string' as const
const driverToReporterEvents = 'paused session:add'.split(' ')
const driverToLocalAndReporterEvents = 'run:start run:end'.split(' ')
const driverToSocketEvents = 'backend:request automation:request mocha recorder:frame'.split(' ')
@@ -58,7 +56,7 @@ export class EventManager {
return Cypress
}
addGlobalListeners (state: BaseStore, connectionInfo: { automationElement: typeof automationElementId, randomString: string }) {
addGlobalListeners (state: BaseStore, connectionInfo: { element: string, string: string }) {
const rerun = () => {
if (!this) {
// if the tests have been reloaded
+11 -14
View File
@@ -24,18 +24,17 @@ import { IframeModel } from './iframe-model'
import { AutIframe } from './aut-iframe'
import { EventManager } from './event-manager'
import { client } from '@packages/socket/lib/browser'
import { decodeBase64Unicode } from '@packages/frontend-shared/src/utils/base64'
let _eventManager: EventManager | undefined
export function createWebsocket () {
const PORT_MATCH = /serverPort=(\d+)/.exec(window.location.search)
export function createWebsocket (socketIoRoute) {
const socketConfig = {
path: '/__socket.io',
path: socketIoRoute,
transports: ['websocket'],
}
const ws = PORT_MATCH ? client(`http://localhost:${PORT_MATCH[1]}`, socketConfig) : client(socketConfig)
const ws = client(socketConfig)
ws.on('connect', () => {
ws.emit('runner:connected')
@@ -116,12 +115,12 @@ function createIframeModel () {
* for communication between driver, runner, reporter via event bus,
* and server (via web socket).
*/
function setupRunner () {
function setupRunner (namespace) {
const mobxRunnerStore = getMobxRunnerStore()
getEventManager().addGlobalListeners(mobxRunnerStore, {
automationElement: '__cypress-string',
randomString,
element: `${namespace}-string`,
string: randomString,
})
getEventManager().start(window.UnifiedRunner.config)
@@ -285,16 +284,14 @@ function runSpecE2E (spec: BaseSpec) {
async function initialize () {
isTorndown = false
await injectBundle()
const config = JSON.parse(decodeBase64Unicode(window.__CYPRESS_CONFIG__.base64Config))
await injectBundle(config.namespace)
if (isTorndown) {
return
}
const response = await window.fetch('/api')
const data = await response.json()
const config = window.UnifiedRunner.decodeBase64(data.base64Config) as any
const autStore = useAutStore()
// TODO(lachlan): use GraphQL to get the viewport dimensions
@@ -316,7 +313,7 @@ async function initialize () {
store.updateDimensions(config.viewportWidth, config.viewportHeight)
})
window.UnifiedRunner.MobX.runInAction(() => setupRunner())
window.UnifiedRunner.MobX.runInAction(() => setupRunner(config.namespace))
}
/**
+3 -3
View File
@@ -1,5 +1,5 @@
export async function injectBundle () {
const src = '/__cypress/runner/cypress_runner.js'
export async function injectBundle (namespace: string) {
const src = `/${namespace}/runner/cypress_runner.js`
const alreadyInjected = document.querySelector(`script[src="${src}"]`)
@@ -15,7 +15,7 @@ export async function injectBundle () {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = '/__cypress/runner/cypress_runner.css'
link.href = `/${namespace}/runner/cypress_runner.css`
document.head.appendChild(script)
document.head.appendChild(link)
@@ -24,8 +24,6 @@ exports['src/index .getDefaultValues returns list of public config keys 1'] = {
"e2e": {
"specPattern": "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}"
},
"defaultCommandTimeout": 4000,
"downloadsFolder": "cypress/downloads",
"env": {},
"execTimeout": 60000,
"exit": true,
@@ -1,5 +1,5 @@
import type { CodeGenType, MutationSetProjectPreferencesArgs, NexusGenObjects, TestingTypeEnum } from '@packages/graphql/src/gen/nxs.gen'
import type { InitializeProjectOptions, FoundBrowser, FoundSpec, LaunchOpts, OpenProjectLaunchOptions, Preferences, TestingType, AddProject } from '@packages/types'
import type { InitializeProjectOptions, FoundBrowser, FoundSpec, LaunchOpts, OpenProjectLaunchOptions, Preferences, TestingType, ReceivedCypressOptions, AddProject } from '@packages/types'
import execa from 'execa'
import path from 'path'
import assert from 'assert'
@@ -31,6 +31,7 @@ export interface ProjectApiShape {
clearProjectPreferences(projectTitle: string): Promise<unknown>
clearAllProjectPreferences(): Promise<unknown>
closeActiveProject(shouldCloseBrowser?: boolean): Promise<unknown>
getConfig(): ReceivedCypressOptions | undefined
getCurrentProjectSavedState(): {} | undefined
setPromptShown(slug: string): void
getDevServer (): {
@@ -59,24 +59,33 @@ export class HtmlDataSource {
throw err
}
async makeServeConfig () {
return {
projectName: this.ctx.lifecycleManager.projectTitle,
base64Config: Buffer.from(JSON.stringify(this.ctx._apis.projectApi.getConfig())).toString('base64'),
}
}
/**
* The app html includes the SSR'ed data to bootstrap the page for the app
*/
async appHtml () {
const [appHtml, appInitialData] = await Promise.all([
const [appHtml, appInitialData, serveConfig] = await Promise.all([
this.fetchAppHtml(),
this.fetchAppInitialData(),
this.makeServeConfig(),
])
return this.replaceBody(appHtml, appInitialData)
return this.replaceBody(appHtml, appInitialData, serveConfig)
}
private replaceBody (html: string, initialData: object) {
private replaceBody (html: string, initialData: object, serveConfig: object) {
return html.replace('<body>', `
<body>
<script>
window.__CYPRESS_GRAPHQL_PORT__ = ${JSON.stringify(this.ctx.gqlServerPort)};
window.__CYPRESS_INITIAL_DATA__ = ${JSON.stringify(initialData)};
window.__CYPRESS_CONFIG__ = ${JSON.stringify(serveConfig)}
</script>
`)
}
+1 -1
View File
@@ -71,7 +71,7 @@ const setPostMessageLocalStorage = async (specWindow, originOptions) => {
if (!origins.length) return []
_.each(origins, (u) => {
const $iframe = $(`<iframe src="${`${u}/__cypress/automation/setLocalStorage?${u}`}"></iframe>`)
const $iframe = $(`<iframe src="${`${u}/${Cypress.config('namespace')}/automation/setLocalStorage?${u}`}"></iframe>`)
$iframe.appendTo($iframeContainer)
iframes.push($iframe)
@@ -232,7 +232,7 @@ async function makeE2ETasks () {
throw new Error(`${projectName} has not been scaffolded. Be sure to call cy.scaffoldProject('${projectName}') in the test, a before, or beforeEach hook`)
}
const openArgv = [...argv, '--project', Fixtures.projectPath(projectName)]
const openArgv = [...argv, '--project', Fixtures.projectPath(projectName), '--port', '4455']
// Runs the launchArgs through the whole pipeline for the CLI open process,
// which probably needs a bit of refactoring / consolidating
@@ -13,6 +13,7 @@ export const e2eProjectDirs = [
'config-with-short-timeout',
'config-with-ts',
'cookies',
'cypress-in-cypress',
'default-layout',
'downloads',
'e2e',
@@ -195,59 +195,61 @@ function openProject (projectName: ProjectFixture, argv: string[] = []) {
function startAppServer (mode: 'component' | 'e2e' = 'e2e') {
return logInternal('startAppServer', (log) => {
return cy.withCtx(async (ctx, o) => {
ctx.actions.project.setCurrentTestingType(o.mode)
const isInitialized = o.pDefer()
const initializeActive = ctx.actions.project.initializeActiveProject
const onErrorStub = o.sinon.stub(ctx, 'onError')
// @ts-expect-error - errors b/c it's a private method
const onLoadErrorStub = o.sinon.stub(ctx.lifecycleManager, 'onLoadError')
const initializeActiveProjectStub = o.sinon.stub(ctx.actions.project, 'initializeActiveProject')
return cy.window({ log: false }).then((win) => {
return cy.withCtx(async (ctx, o) => {
ctx.actions.project.setCurrentTestingType(o.mode)
const isInitialized = o.pDefer()
const initializeActive = ctx.actions.project.initializeActiveProject
const onErrorStub = o.sinon.stub(ctx, 'onError')
// @ts-expect-error - errors b/c it's a private method
const onLoadErrorStub = o.sinon.stub(ctx.lifecycleManager, 'onLoadError')
const initializeActiveProjectStub = o.sinon.stub(ctx.actions.project, 'initializeActiveProject')
function restoreStubs () {
onErrorStub.restore()
onLoadErrorStub.restore()
initializeActiveProjectStub.restore()
}
function restoreStubs () {
onErrorStub.restore()
onLoadErrorStub.restore()
initializeActiveProjectStub.restore()
}
function onStartAppError (e: Error) {
isInitialized.reject(e)
restoreStubs()
}
onErrorStub.callsFake(onStartAppError)
onLoadErrorStub.callsFake(onStartAppError)
initializeActiveProjectStub.callsFake(async function (this: any, ...args) {
try {
const result = await initializeActive.apply(this, args)
isInitialized.resolve(result)
return result
} catch (e) {
function onStartAppError (e: Error) {
isInitialized.reject(e)
} finally {
restoreStubs()
}
return
onErrorStub.callsFake(onStartAppError)
onLoadErrorStub.callsFake(onStartAppError)
initializeActiveProjectStub.callsFake(async function (this: any, ...args) {
try {
const result = await initializeActive.apply(this, args)
isInitialized.resolve(result)
return result
} catch (e) {
isInitialized.reject(e)
} finally {
restoreStubs()
}
return
})
await isInitialized.promise
await ctx.actions.project.launchProject(o.mode, { url: o.url })
return ctx.appServerPort
}, { log: false, mode, url: win.top ? win.top.location.href : undefined }).then((serverPort) => {
log.set({ message: `port: ${serverPort}` })
Cypress.env('e2e_serverPort', serverPort)
})
await isInitialized.promise
await ctx.actions.project.launchProject(o.mode, {})
return ctx.appServerPort
}, { log: false, mode }).then((serverPort) => {
log.set({ message: `port: ${serverPort}` })
Cypress.env('e2e_serverPort', serverPort)
})
})
}
function visitApp (href?: string) {
const { e2e_serverPort, e2e_gqlPort } = Cypress.env()
const { e2e_serverPort } = Cypress.env()
if (!e2e_serverPort) {
throw new Error(`
@@ -258,15 +260,11 @@ function visitApp (href?: string) {
}
return cy.withCtx(async (ctx) => {
return JSON.stringify(await ctx.html.fetchAppInitialData())
}, { log: false }).then((ssrData) => {
return cy.visit(`dist-app/index.html?serverPort=${e2e_serverPort}${href || ''}`, {
onBeforeLoad (win) {
// Simulates the inject SSR data when we're loading the page normally in the app
win.__CYPRESS_INITIAL_DATA__ = JSON.parse(ssrData)
win.__CYPRESS_GRAPHQL_PORT__ = e2e_gqlPort
},
})
const config = await ctx.lifecycleManager.getFullInitialConfig()
return config.clientRoute
}).then((clientRoute) => {
return cy.visit(`http://localhost:${e2e_serverPort}${clientRoute || '/__/'}#${href || ''}`)
})
}
@@ -32,6 +32,9 @@ declare global {
interface Window {
__CYPRESS_INITIAL_DATA__: SSRData
__CYPRESS_GRAPHQL_PORT__?: string
__CYPRESS_CONFIG__: {
base64Config: string
}
}
}
@@ -49,7 +52,7 @@ function gqlPort () {
export async function preloadLaunchpadData () {
try {
const resp = await fetch(`http://localhost:${gqlPort()}/__cypress/launchpad-preload`)
const resp = await fetch(`http://localhost:${gqlPort()}/__launchpad/preload`)
window.__CYPRESS_INITIAL_DATA__ = await resp.json()
} catch {
@@ -57,7 +60,18 @@ export async function preloadLaunchpadData () {
}
}
export function makeUrqlClient (target: 'launchpad' | 'app'): Client {
interface LaunchpadUrqlClientConfig {
target: 'launchpad'
}
interface AppUrqlClientConfig {
target: 'app'
socketIoRoute: string
}
export type UrqlClientConfig = LaunchpadUrqlClientConfig | AppUrqlClientConfig
export function makeUrqlClient (config: UrqlClientConfig): Client {
const port = gqlPort()
const GRAPHQL_URL = `http://localhost:${port}/graphql`
@@ -65,7 +79,7 @@ export function makeUrqlClient (target: 'launchpad' | 'app'): Client {
// If we're in the launchpad, we connect to the known GraphQL Socket port,
// otherwise we connect to the /__socket.io of the current domain, unless we've explicitly
//
const io = getPubSubSource({ target, gqlPort: port, serverPort: SERVER_PORT_MATCH?.[1] })
const io = getPubSubSource({ gqlPort: port, serverPort: SERVER_PORT_MATCH?.[1], ...config })
let hasError = false
@@ -120,12 +134,21 @@ export function makeUrqlClient (target: 'launchpad' | 'app'): Client {
})
}
interface PubSubConfig {
target: 'launchpad' | 'app'
interface LaunchpadPubSubConfig {
target: 'launchpad'
gqlPort: string
serverPort?: string
}
interface AppPubSubConfig {
target: 'app'
gqlPort: string
serverPort?: string
socketIoRoute: string
}
type PubSubConfig = LaunchpadPubSubConfig | AppPubSubConfig
function getPubSubSource (config: PubSubConfig) {
if (config.target === 'launchpad') {
return client(`http://localhost:${config.gqlPort}`, {
@@ -137,13 +160,13 @@ function getPubSubSource (config: PubSubConfig) {
// Only happens during testing
if (config.serverPort) {
return client(`http://localhost:${config.serverPort}`, {
path: '/__socket.io',
path: config.socketIoRoute,
transports: ['websocket'],
})
}
return client({
path: '/__socket.io',
path: config.socketIoRoute,
transports: ['websocket'],
})
}
@@ -0,0 +1,39 @@
/**
* Correctly decodes Unicode string in encoded in base64
* Copied from driver/src/cypress/utils.ts
*
* @see https://github.com/cypress-io/cypress/issues/5435
* @see https://github.com/cypress-io/cypress/issues/7507
* @see https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings
*
* @example
```
Buffer.from(JSON.stringify({state: '🙂'})).toString('base64')
// 'eyJzdGF0ZSI6IvCfmYIifQ=='
// "window.atob" does NOT work
// atob('eyJzdGF0ZSI6IvCfmYIifQ==')
// "{"state":"🙂"}"
// but this function works
b64DecodeUnicode('eyJzdGF0ZSI6IvCfmYIifQ==')
'{"state":"🙂"}'
```
*/
export function decodeBase64Unicode (str: string) {
return decodeURIComponent(atob(str).split('').map((char) => {
return `%${(`00${char.charCodeAt(0).toString(16)}`).slice(-2)}`
}).join(''))
}
/**
* Copied from driver/src/cypress/utils.ts
*
* Correctly encodes Unicode string to base64
* @see https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings
*/
export function encodeBase64Unicode (str: string) {
return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => {
// @ts-ignore
return String.fromCharCode(`0x${p1}`)
}))
}
+1 -5
View File
@@ -27,11 +27,7 @@ export async function makeGraphQLServer () {
app.use(cors())
app.get('/__cypress/shiki-themes', (req, res) => {
res.json([{}, {}])
})
app.get('/__cypress/launchpad-preload', (req, res) => {
app.get('/__launchpad/preload', (req, res) => {
const ctx = getCtx()
ctx.html.fetchLaunchpadInitialData().then((data) => {
-60
View File
@@ -1,60 +0,0 @@
//
import { graphqlHTTP, GraphQLParams } from 'express-graphql'
import { graphqlSchema } from './schema'
import type { DataContext } from '@packages/data-context'
import type express from 'express'
import { parse } from 'graphql'
const SHOW_GRAPHIQL = process.env.CYPRESS_INTERNAL_ENV !== 'production'
export function addGraphQLHTTP (app: ReturnType<typeof express>, context: DataContext) {
app.use('/graphql/:operationName?', graphqlHTTP((req, res, params) => {
const ctx = SHOW_GRAPHIQL ? maybeProxyContext(params, context) : context
return {
schema: graphqlSchema,
graphiql: SHOW_GRAPHIQL,
context: ctx,
}
}))
return app
}
/**
* Adds runtime validations during development to ensure patterns of access are enforced
* on the DataContext
*/
function maybeProxyContext (params: GraphQLParams | undefined, context: DataContext): DataContext {
if (params?.query) {
const parsed = parse(params.query)
const def = parsed.definitions[0]
if (def?.kind === 'OperationDefinition') {
return def.operation === 'query' ? proxyContext(context, def.name?.value ?? '(anonymous)') : context
}
}
return context
}
function proxyContext (ctx: DataContext, operationName: string) {
return new Proxy(ctx, {
get (target, p, receiver) {
// Allows us to get the context value, deref'ed so it's not guarded
if (p === 'deref') {
return Reflect.get(ctx, 'deref', ctx)
}
if (p === 'actions' || p === 'emitter') {
throw new Error(
`Cannot access ctx.${p} within a query, only within mutations / outside of a GraphQL request\n` +
`Seen in operation: ${operationName}`,
)
}
return Reflect.get(target, p, receiver)
},
})
}
+1 -1
View File
@@ -35,7 +35,7 @@ Promise.all([
initHighlighter(),
preloadLaunchpadData(),
]).then(() => {
launchpadClient = makeUrqlClient('launchpad')
launchpadClient = makeUrqlClient({ target: 'launchpad' })
app.use(urql, launchpadClient)
app.mount('#app')
@@ -5,7 +5,7 @@ describe('<ManualInstall />', () => {
it('playground', { viewportWidth: 800, viewportHeight: 600 }, () => {
cy.mountFragment(ManualInstallFragmentDoc, {
render: (gqlVal) => (
<div class="m-10 border-1 rounded border-gray-400">
<div class="rounded border-1 border-gray-400 m-10">
<ManualInstall gql={gqlVal} />
</div>
),
+16 -2
View File
@@ -203,6 +203,20 @@ export class CombinedAgent {
}
}
const getProxyOrTargetOverrideForUrl = (href) => {
// HTTP_PROXY_TARGET_FOR_ORIGIN_REQUESTS is used for Cypress in Cypress E2E testing and will
// force the parent Cypress server to treat the child Cypress server like a proxy without
// having HTTP_PROXY set and will force traffic ONLY bound to that origin to behave
// like a proxy
const targetHost = process.env.HTTP_PROXY_TARGET_FOR_ORIGIN_REQUESTS
if (targetHost && href.includes(targetHost)) {
return targetHost
}
return getProxyForUrl(href)
}
class HttpAgent extends http.Agent {
httpsAgent: https.Agent
@@ -214,8 +228,8 @@ class HttpAgent extends http.Agent {
}
addRequest (req: http.ClientRequest, options: http.RequestOptions) {
if (process.env.HTTP_PROXY) {
const proxy = getProxyForUrl(options.href)
if (process.env.HTTP_PROXY || process.env.HTTP_PROXY_TARGET_FOR_ORIGIN_REQUESTS) {
const proxy = getProxyOrTargetOverrideForUrl(options.href)
if (proxy) {
options.proxy = proxy
+15 -1
View File
@@ -83,7 +83,7 @@ describe('lib/agent', function () {
})
afterEach(function () {
process.env.NO_PROXY = process.env.HTTP_PROXY = process.env.HTTPS_PROXY = ''
process.env.NO_PROXY = process.env.HTTP_PROXY = process.env.HTTPS_PROXY = process.env.HTTP_PROXY_TARGET_FOR_ORIGIN_REQUESTS = ''
sinon.restore()
})
@@ -413,6 +413,20 @@ describe('lib/agent', function () {
})
})
})
it('HTTP pages can be loaded with the Upstream target URL', function (done) {
process.env.HTTP_PROXY = process.env.HTTPS_PROXY = ''
process.env.NO_PROXY = ''
process.env.HTTP_PROXY_TARGET_FOR_ORIGIN_REQUESTS = `http://localhost:${HTTP_PORT}`
this.request({
url: `http://localhost:${HTTP_PORT}/get`,
}).on('response', (response) => {
expect(response.req.path).to.equal('http://localhost:31080/get')
done()
})
})
})
})
@@ -82,6 +82,7 @@ class Runnable extends Component<RunnableProps> {
'runnable-retried': model.hasRetried,
'runnable-studio': appState.studioActive,
})}
data-model-state={model.state}
>
{model.type === 'test' ? <Test model={model as TestModel} /> : <Suite model={model as SuiteModel} />}
</li>
+3 -3
View File
@@ -32,7 +32,9 @@ const Runner: any = {
},
start (el, base64Config) {
const ws = createWebsocket()
const config = JSON.parse(driverUtils.decodeBase64Unicode(base64Config))
const ws = createWebsocket(config.socketIoRoute)
// NOTE: this is exposed for testing, ideally we should only expose this if a test flag is set
window.runnerWs = ws
@@ -47,8 +49,6 @@ const Runner: any = {
)
MobX.action('started', () => {
const config = JSON.parse(driverUtils.decodeBase64Unicode(base64Config))
const NO_COMMAND_LOG = config.env && config.env.NO_COMMAND_LOG
const configState = config.state || {}
+3 -3
View File
@@ -7,13 +7,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{projectName}}</title>
<link href="/__cypress/runner/favicon.ico" rel="icon">
<link href="/{{namespace}}/runner/favicon.ico" rel="icon">
<link rel="stylesheet" href="/__cypress/runner/cypress_runner.css">
<link rel="stylesheet" href="/{{namespace}}/runner/cypress_runner.css">
</head>
<body>
<div id="app"></div>
<script type="text/javascript" src="/__cypress/runner/cypress_runner.js"></script>
<script type="text/javascript" src="/{{namespace}}/runner/cypress_runner.js"></script>
<script type="text/javascript">
// set a global so we know the 'top' window
window.__Cypress__ = true
@@ -1,16 +1,16 @@
import React from 'react'
export const automationElementId = '__cypress-string' as const
interface AutomationElementProps {
namespace: string
randomString: string
}
export const AutomationElement: React.FC<AutomationElementProps> = ({
namespace,
randomString,
}) => {
return (
<div id={automationElementId} style={{ display: 'none' }}>
<div id={`${namespace}-string`} style={{ display: 'none' }}>
{randomString}
</div>
)
@@ -6,15 +6,17 @@ import App from '@packages/runner/src/app/app'
import { AutomationDisconnected } from '../automation-disconnected'
import { automation } from '../automation'
import { NoAutomation } from '../no-automation'
import { automationElementId } from '../automation-element'
import NoSpec from '@packages/runner/src/errors/no-spec'
import { Container } from '.'
const automationElementId = '__cypress-string'
const createProps = () => ({
runner: 'e2e',
hasSpecFile: sinon.stub(),
config: {
namespace: '__cypress',
browsers: [],
integrationFolder: '',
numTestsKeptInMemory: 1,
@@ -4,7 +4,7 @@ import React, { Component } from 'react'
import { AutomationDisconnected } from '../automation-disconnected'
import { automation } from '../automation'
import { NoAutomation } from '../no-automation'
import { automationElementId, AutomationElement } from '../automation-element'
import { AutomationElement } from '../automation-element'
@observer
export class Container extends Component {
@@ -18,7 +18,7 @@ export class Container extends Component {
componentDidMount () {
this.props.eventManager.addGlobalListeners(this.props.state, {
element: automationElementId,
element: `${this.props.config.namespace}-string`,
string: this.randomString,
})
}
@@ -49,7 +49,7 @@ export class Container extends Component {
_automationElement () {
return (
<AutomationElement randomString={this.randomString} />
<AutomationElement namespace={this.props.config.namespace} randomString={this.randomString} />
)
}
@@ -112,6 +112,7 @@ function createCypress (defaultOptions = {}) {
return cy.visit('/fixtures/isolated-runner.html#/tests/cypress/fixtures/empty_spec.js')
.then({ timeout: 60000 }, (win) => {
win.Runner._initialize('/__socket.io')
win.runnerWs.destroy()
allStubs = cy.stub().snapshot(enableStubSnapshots).log(false)
+33 -24
View File
@@ -12,37 +12,46 @@ import { createWebsocket } from '@packages/app/src/runner'
import util from './lib/util'
import { UnifiedRunner } from '@packages/runner-ct/unified-runner'
const driverUtils = $Cypress.utils
window.UnifiedRunner = UnifiedRunner
MobX.configure({ enforceActions: 'always' })
const ws = createWebsocket()
// NOTE: this is exposed for testing, ideally we should only expose this if a test flag is set
window.runnerWs = ws
window.ws = ws
const eventManager = new EventManager(
$Cypress,
MobX,
selectorPlaygroundModel,
StudioRecorder,
ws,
)
// NOTE: this is for testing Cypress-in-Cypress, window.Cypress is undefined here
// unless Cypress has been loaded into the AUT frame
if (window.Cypress) {
window.eventManager = eventManager
}
let ws
let eventManager
const Runner = {
start (el, base64Config) {
MobX.action('started', () => {
const config = JSON.parse(driverUtils.decodeBase64Unicode(base64Config))
_initialize (socketIoRoute) {
if (!ws) {
ws = createWebsocket(socketIoRoute)
}
// NOTE: this is exposed for testing, ideally we should only expose this if a test flag is set
window.runnerWs = ws
window.ws = ws
if (!eventManager) {
eventManager = new EventManager(
$Cypress,
MobX,
selectorPlaygroundModel,
StudioRecorder,
ws,
)
}
// NOTE: this is for testing Cypress-in-Cypress, window.Cypress is undefined here
// unless Cypress has been loaded into the AUT frame
if (window.Cypress) {
window.eventManager = eventManager
}
},
start (el, base64Config) {
const config = UnifiedRunner.decodeBase64(base64Config)
this._initialize(config.socketIoRoute)
MobX.action('started', () => {
const NO_COMMAND_LOG = config.env && config.env.NO_COMMAND_LOG
const useInlineSpecList = false
+3 -3
View File
@@ -5,13 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{projectName}}</title>
<link href="/__cypress/runner/favicon.ico" rel="icon">
<link href="/{{namespace}}/runner/favicon.ico" rel="icon">
<link rel="stylesheet" href="/__cypress/runner/cypress_runner.css">
<link rel="stylesheet" href="/{{namespace}}/runner/cypress_runner.css">
</head>
<body>
<div id="app"></div>
<script type="text/javascript" src="/__cypress/runner/cypress_runner.js"></script>
<script type="text/javascript" src="/{{namespace}}/runner/cypress_runner.js"></script>
<script type="text/javascript">
// set a global so we know the 'top' window
window.__Cypress__ = true
+9 -2
View File
@@ -247,13 +247,13 @@ const _disableRestorePagesPrompt = function (userDir) {
// After the browser has been opened, we can connect to
// its remote interface via a websocket.
const _connectToChromeRemoteInterface = function (port, onError, browserDisplayName) {
const _connectToChromeRemoteInterface = function (port, onError, browserDisplayName, url?) {
// @ts-ignore
la(check.userPort(port), 'expected port number to connect CRI to', port)
debug('connecting to Chrome remote interface at random port %d', port)
return protocol.getWsTargetFor(port, browserDisplayName)
return protocol.getWsTargetFor(port, browserDisplayName, url)
.then((wsUrl) => {
debug('received wsUrl %s for port %d', wsUrl, port)
@@ -443,6 +443,13 @@ export = {
return args
},
async connectToExisting (browser: Browser, options: CypressConfiguration = {}, automation) {
const port = await protocol.getRemoteDebuggingPort()
const criClient = await this._connectToChromeRemoteInterface(port, options, browser.displayName, options.url)
this._setAutomation(criClient, automation)
},
async open (browser: Browser, url, options: CypressConfiguration = {}, automation) {
const { isTextTerminal } = options
+4
View File
@@ -380,6 +380,10 @@ module.exports = {
})
},
async connectToExisting () {
throw new Error('Attempting to connect to existing browser for Cypress in Cypress which is not yet implemented for electron')
},
open (browser, url, options = {}, automation) {
const { projectRoot, isTextTerminal } = options
+4
View File
@@ -357,6 +357,10 @@ export function _createDetachedInstance (browserInstance: BrowserInstance): Brow
return detachedInstance
}
export function connectToExisting () {
throw new Error('Attempting to connect to existing browser for Cypress in Cypress which is not yet implemented for browser')
}
export async function open (browser: Browser, url, options: any = {}, automation): Promise<BrowserInstance> {
// see revision comment here https://wiki.mozilla.org/index.php?title=WebDriver/RemoteProtocol&oldid=1234946
const hasCdp = browser.majorVersion >= 86
+16 -4
View File
@@ -92,21 +92,33 @@ module.exports = {
return utils.getBrowsers()
},
connectToExisting (browser, options = {}, automation) {
const browserLauncher = getBrowserLauncher(browser)
if (!browserLauncher) {
utils.throwBrowserNotFound(browser.name, options.browsers)
}
return browserLauncher.connectToExisting(browser, options, automation)
},
open (browser, options = {}, automation) {
return kill(true)
.then(() => {
let browserLauncher; let url
_.defaults(options, {
onBrowserOpen () {},
onBrowserClose () {},
})
if (!(browserLauncher = getBrowserLauncher(browser))) {
const browserLauncher = getBrowserLauncher(browser)
if (!browserLauncher) {
utils.throwBrowserNotFound(browser.name, options.browsers)
}
if (!(url = options.url)) {
const { url } = options
if (!url) {
throw new Error('options.url must be provided when opening a browser. You passed:', options)
}
+8 -8
View File
@@ -45,15 +45,15 @@ export function _connectAsync (opts) {
*
* @returns {string} web socket debugger url
*/
const findStartPage = (targets) => {
debug('CRI List %o', { numTargets: targets.length, targets })
const findStartPage = (targets, url = 'about:blank') => {
debug('CRI List %o', { numTargets: targets.length, targets, url })
// activate the first available id
// find the first target page that's a real tab
// and not the dev tools or background page.
// since we open a blank page first, it has a special url
const newTabTargetFields = {
type: 'page',
url: 'about:blank',
url,
}
const target = _.find(targets, newTabTargetFields)
@@ -65,17 +65,17 @@ const findStartPage = (targets) => {
return target.webSocketDebuggerUrl
}
const findStartPageTarget = (connectOpts) => {
const findStartPageTarget = (connectOpts, url) => {
debug('CRI.List %o', connectOpts)
// what happens if the next call throws an error?
// it seems to leave the browser instance open
// need to clone connectOpts, CRI modifies it
return CRI.List(_.clone(connectOpts)).then(findStartPage)
return CRI.List(_.clone(connectOpts)).then((targets) => findStartPage(targets, url))
}
export async function getRemoteDebuggingPort () {
const port = Number(process.env.CYPRESS_REMOTE_DEBUGGING_PORT)
const port = Number(process.env.CYPRESS_REMOTE_DEBUGGING_PORT) || utils.getPort()
return port || utils.getPort()
}
@@ -85,7 +85,7 @@ export async function getRemoteDebuggingPort () {
* @param {number} port Port number to connect to
* @param {string} browserName Browser name, for warning/error messages
*/
export const getWsTargetFor = (port: number, browserName: string) => {
export const getWsTargetFor = (port: number, browserName: string, url?: string | null) => {
debug('Getting WS connection to CRI on port %d', port)
la(is.port(port), 'expected port number', port)
@@ -108,7 +108,7 @@ export const getWsTargetFor = (port: number, browserName: string) => {
const retry = () => {
debug('attempting to find CRI target... %o', { retryIndex })
return findStartPageTarget(connectOpts)
return findStartPageTarget(connectOpts, url)
.catch((err) => {
retryIndex++
const delay = _getDelayMsForRetry(retryIndex, browserName)
+9 -9
View File
@@ -7,8 +7,6 @@ const debug = require('debug')('cypress:server:controllers')
const { escapeFilenameInUrl } = require('../util/escape_filename')
const { getCtx } = require('@packages/data-context')
const SPEC_URL_PREFIX = '/__cypress/tests?p'
module.exports = {
handleIframe (req, res, config, getRemoteState, extraOptions) {
const test = req.params[0]
@@ -50,7 +48,7 @@ module.exports = {
debug('converted %s to %s', spec, convertedSpec)
return this.prepareForBrowser(convertedSpec, config.projectRoot)
return this.prepareForBrowser(convertedSpec, config.projectRoot, config.namespace)
}
const specFilter = _.get(extraOptions, 'specFilter')
@@ -108,7 +106,9 @@ module.exports = {
})
},
prepareForBrowser (filePath, projectRoot) {
prepareForBrowser (filePath, projectRoot, namespace) {
const SPEC_URL_PREFIX = `/${namespace}/tests?p`
filePath = filePath.replace(SPEC_URL_PREFIX, '__CYPRESS_SPEC_URL_PREFIX__')
filePath = escapeFilenameInUrl(filePath).replace('__CYPRESS_SPEC_URL_PREFIX__', SPEC_URL_PREFIX)
const relativeFilePath = path.relative(projectRoot, filePath)
@@ -116,12 +116,12 @@ module.exports = {
return {
absolute: filePath,
relative: relativeFilePath,
relativeUrl: this.getTestUrl(relativeFilePath),
relativeUrl: this.getTestUrl(relativeFilePath, namespace),
}
},
getTestUrl (file) {
const url = `${SPEC_URL_PREFIX}=${file}`
getTestUrl (file, namespace) {
const url = `/${namespace}/tests?p=${file}`
debug('test url for file %o', { file, url })
@@ -137,7 +137,7 @@ module.exports = {
},
getSupportFile (config) {
const { projectRoot, supportFile } = config
const { projectRoot, supportFile, namespace } = config
let files = []
@@ -167,7 +167,7 @@ module.exports = {
return glob(p, { nodir: true })
}).then(_.flatten)
.map((filePath) => {
return this.prepareForBrowser(filePath, projectRoot)
return this.prepareForBrowser(filePath, projectRoot, namespace)
})
},
@@ -36,6 +36,7 @@ export const serveRunner = (runnerPkg: RunnerPkg, config: Cfg, res: Response) =>
return res.render(runnerPath, {
base64Config,
projectName: config.projectName,
namespace: config.namespace,
})
}
+3
View File
@@ -117,6 +117,9 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext {
closeActiveProject () {
return openProject.closeActiveProject()
},
getConfig () {
return openProject.getConfig()
},
getCurrentProjectSavedState () {
// TODO: See if this is the best way we should be getting this config,
// shouldn't we have this already in the DataContext?
+5 -2
View File
@@ -106,7 +106,10 @@ export class OpenProject {
// set the current browser object on options
// so we can pass it down
options.browser = browser
options.url = url
if (!process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF) {
options.url = url
}
this.projectBase.setCurrentSpecAndBrowser(spec, browser)
@@ -177,7 +180,7 @@ export class OpenProject {
.then(() => {
// TODO: Stub this so we can detect it being called
if (process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF) {
return
return browsers.connectToExisting(browser, options, automation)
}
return browsers.open(browser, options, automation)
+13 -11
View File
@@ -25,21 +25,14 @@ import { ensureProp } from './util/class-helpers'
import { fs } from './util/fs'
import preprocessor from './plugins/preprocessor'
import { checkSupportFile } from './project_utils'
import type { FoundBrowser, OpenProjectLaunchOptions, FoundSpec, TestingType, ReceivedCypressOptions } from '@packages/types'
import devServer from './plugins/dev-server'
import type { FoundBrowser, OpenProjectLaunchOptions, FoundSpec } from '@packages/types'
import { DataContext, getCtx } from '@packages/data-context'
// Cannot just use RuntimeConfigOptions as is because some types are not complete.
// Instead, this is an interface of values that have been manually validated to exist
// and are required when creating a project.
type ReceivedCypressOptions =
Pick<Cypress.RuntimeConfigOptions, 'hosts' | 'projectName' | 'clientRoute' | 'devServerPublicPathRoute' | 'namespace' | 'report' | 'socketIoCookie' | 'configFile' | 'isTextTerminal' | 'isNewProject' | 'proxyUrl' | 'browsers' | 'browserUrl' | 'socketIoRoute' | 'arch' | 'platform' | 'spec' | 'specs' | 'browser' | 'version' | 'remote'>
& Pick<Cypress.ResolvedConfigOptions, 'chromeWebSecurity' | 'supportFolder' | 'experimentalSourceRewriting' | 'fixturesFolder' | 'reporter' | 'reporterOptions' | 'screenshotsFolder' | 'pluginsFile' | 'supportFile' | 'baseUrl' | 'viewportHeight' | 'viewportWidth' | 'port' | 'experimentalInteractiveRunEvents' | 'userAgent' | 'downloadsFolder' | 'env' | 'testFiles' | 'ignoreSpecPattern' | 'specPattern'> // TODO: Figure out how to type this better.
export interface Cfg extends ReceivedCypressOptions {
projectRoot: string
proxyServer?: Cypress.RuntimeConfigOptions['proxyUrl']
testingType: TestingType
exit?: boolean
state?: {
firstOpened?: number | null
@@ -502,7 +495,10 @@ export class ProjectBase<TServer extends Server> extends EE {
async initializeConfig (): Promise<Cfg> {
this.ctx.lifecycleManager.setCurrentTestingType(this.testingType)
let theCfg: Cfg = await this.ctx.lifecycleManager.getFullInitialConfig() as Cfg // ?? types are definitely wrong here I think
let theCfg: Cfg = {
...(await this.ctx.lifecycleManager.getFullInitialConfig()),
testingType: this.testingType,
} as Cfg // ?? types are definitely wrong here I think
theCfg = this.testingType === 'e2e'
? theCfg
@@ -531,7 +527,13 @@ export class ProjectBase<TServer extends Server> extends EE {
debug('project has config %o', this._cfg)
return this._cfg
return {
...this._cfg,
remote: this._server?._getRemoteState() ?? {} as Cypress.RemoteState,
browser: this.browser,
testingType: this.ctx.coreData.currentTestingType ?? 'e2e',
specs: [],
}
}
// Saved state
+1 -1
View File
@@ -18,7 +18,7 @@ export const createRoutesCT = ({
}: InitializeRoutes) => {
const routesCt = Router()
routesCt.get('/__cypress/static/*', (req, res) => {
routesCt.get(`/${config.namespace}/static/*`, (req, res) => {
const pathToFile = getPathToDist('static', req.params[0])
return send(req, pathToFile)
+7 -7
View File
@@ -20,22 +20,22 @@ export const createRoutesE2E = ({
// routing for the actual specs which are processed automatically
// this could be just a regular .js file or a .coffee file
routesE2E.get('/__cypress/tests', (req, res, next) => {
routesE2E.get(`/${config.namespace}/tests`, (req, res, next) => {
// slice out the cache buster
const test = decodeURIComponent(CacheBuster.strip(req.query.p))
specController.handle(test, req, res, config, next, onError)
})
routesE2E.get('/__cypress/socket.io.js', (req, res) => {
routesE2E.get(`/${config.namespace}/socket.io.js`, (req, res) => {
client.handle(req, res)
})
routesE2E.get('/__cypress/reporter/*', (req, res) => {
routesE2E.get(`/${config.namespace}/reporter/*`, (req, res) => {
reporter.handle(req, res)
})
routesE2E.get('/__cypress/automation/getLocalStorage', (req, res) => {
routesE2E.get(`/${config.namespace}/automation/getLocalStorage`, (req, res) => {
// gathers and sends localStorage and sessionStorage via postMessage to the Cypress frame
// detect existence of local/session storage with JSON.stringify(...).length since localStorage.length may not be accurate
res.send(`<html><body><script>(${(function () {
@@ -62,7 +62,7 @@ export const createRoutesE2E = ({
})
/* eslint-disable no-undef */
routesE2E.get('/__cypress/automation/setLocalStorage', (req, res) => {
routesE2E.get(`/${config.namespace}/automation/setLocalStorage`, (req, res) => {
const origin = req.originalUrl.slice(req.originalUrl.indexOf('?') + 1)
networkProxy.http.getRenderedHTMLOrigins()[origin] = true
@@ -103,7 +103,7 @@ export const createRoutesE2E = ({
})
/* eslint-enable no-undef */
routesE2E.get('/__cypress/source-maps/:id.map', (req, res) => {
routesE2E.get(`/${config.namespace}/source-maps/:id.map`, (req, res) => {
networkProxy.handleSourceMapRequest(req, res)
})
@@ -115,7 +115,7 @@ export const createRoutesE2E = ({
})
// special fallback - serve dist'd (bundled/static) files from the project path folder
routesE2E.get('/__cypress/bundled/*', (req, res) => {
routesE2E.get(`/${config.namespace}/bundled/*`, (req, res) => {
const file = AppData.getBundledFilePath(config.projectRoot, path.join('src', req.params[0]))
debug(`Serving dist'd bundle at file path: %o`, { path: file, url: req.url })
+6 -46
View File
@@ -1,5 +1,4 @@
import httpProxy from 'http-proxy'
import _ from 'lodash'
import Debug from 'debug'
import { ErrorRequestHandler, Router } from 'express'
import send from 'send'
@@ -9,7 +8,7 @@ import type { Browser } from './browsers/types'
import type { NetworkProxy } from '@packages/proxy'
import type { Cfg } from './project-base'
import xhrs from './controllers/xhrs'
import { runner, ServeOptions } from './controllers/runner'
import { runner } from './controllers/runner'
import { iframesController } from './controllers/iframes'
import type { FoundSpec } from '@packages/types'
import { getCtx } from '@packages/data-context'
@@ -38,45 +37,8 @@ export const createCommonRoutes = ({
nodeProxy,
exit,
}: InitializeRoutes) => {
const makeServeConfig = (options: Partial<ServeOptions>) => {
const config = {
...options.config,
testingType,
browser: options.getCurrentBrowser?.(),
} as Cfg
if (testingType === 'e2e') {
config.remote = getRemoteState()
}
// TODO: move the component file watchers in here
// and update them in memory when they change and serve
// them straight to the HTML on load
debug('serving runner index.html with config %o',
_.pick(config, 'version', 'platform', 'arch', 'projectName'))
// base64 before embedding so user-supplied contents can't break out of <script>
// https://github.com/cypress-io/cypress/issues/4952
const base64Config = Buffer.from(JSON.stringify(config)).toString('base64')
return {
base64Config,
projectName: config.projectName,
}
}
const router = Router()
router.get(['/api', '/__/api'], (req, res) => {
const options = makeServeConfig({
config,
getCurrentBrowser,
})
res.json(options)
})
const { clientRoute, namespace } = config
if (process.env.CYPRESS_INTERNAL_VITE_DEV) {
const proxy = httpProxy.createProxyServer({
@@ -94,15 +56,15 @@ export const createCommonRoutes = ({
})
}
router.get('/__cypress/runner/*', (req, res) => {
router.get(`/${namespace}/runner/*`, (req, res) => {
runner.handle(testingType, req, res)
})
router.all('/__cypress/xhrs/*', (req, res, next) => {
router.all(`/${namespace}/xhrs/*`, (req, res, next) => {
xhrs.handle(req, res, config, next)
})
router.get('/__cypress/iframes/*', (req, res) => {
router.get(`/${namespace}/iframes/*`, (req, res) => {
if (testingType === 'e2e') {
iframesController.e2e({ config, getSpec, getRemoteState }, req, res)
}
@@ -112,8 +74,6 @@ export const createCommonRoutes = ({
}
})
const clientRoute = config.clientRoute
if (!clientRoute) {
throw Error(`clientRoute is required. Received ${clientRoute}`)
}
@@ -121,7 +81,7 @@ export const createCommonRoutes = ({
router.get(clientRoute, (req, res) => {
debug('Serving Cypress front-end by requested URL:', req.url)
if (process.env.LAUNCHPAD) {
if (process.env.LAUNCHPAD || process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF) {
getCtx().html.appHtml()
.then((html) => res.send(html))
.catch((e) => res.status(500).send({ stack: e.stack }))
+1
View File
@@ -40,6 +40,7 @@ export const makeServeConfig = (options) => {
return {
base64Config,
projectName: config.projectName,
namespace: config.namespace,
}
}
+13 -9
View File
@@ -32,12 +32,6 @@ import { createRoutesE2E } from './routes-e2e'
import { createRoutesCT } from './routes-ct'
import type { FoundSpec } from '@packages/types'
const ALLOWED_PROXY_BYPASS_URLS = [
'/',
'/__cypress/runner/cypress_runner.css',
'/__cypress/runner/cypress_runner.js', // TODO: fix this
'/__cypress/runner/favicon.ico',
]
const DEFAULT_DOMAIN_NAME = 'localhost'
const fullyQualifiedRe = /^https?:\/\//
@@ -49,7 +43,14 @@ const _isNonProxiedRequest = (req) => {
return req.proxiedUrl.startsWith('/')
}
const _forceProxyMiddleware = function (clientRoute) {
const _forceProxyMiddleware = function (clientRoute, namespace = '__cypress') {
const ALLOWED_PROXY_BYPASS_URLS = [
'/',
`/${namespace}/runner/cypress_runner.css`,
`/${namespace}/runner/cypress_runner.js`, // TODO: fix this
`/${namespace}/runner/favicon.ico`,
]
// normalize clientRoute to help with comparison
const trimmedClientRoute = _.trimEnd(clientRoute, '/')
@@ -233,7 +234,7 @@ export abstract class ServerBase<TSocket extends SocketE2E | SocketCt> {
}
createExpressApp (config) {
const { morgan, clientRoute } = config
const { morgan, clientRoute, namespace } = config
const app = express()
// set the cypress config from the cypress.config.{ts|js} file
@@ -258,7 +259,7 @@ export abstract class ServerBase<TSocket extends SocketE2E | SocketCt> {
return next()
})
app.use(_forceProxyMiddleware(clientRoute))
app.use(_forceProxyMiddleware(clientRoute, namespace))
app.use(require('cookie-parser')())
app.use(compression({ filter: notSSE }))
@@ -518,6 +519,9 @@ export abstract class ServerBase<TSocket extends SocketE2E | SocketCt> {
port,
protocol,
},
headers: {
'x-cypress-forwarded-from-cypress': true,
},
agent,
}, onProxyErr)
}
+5 -2
View File
@@ -36,8 +36,11 @@ export class SocketAllowed {
*/
isRequestAllowed (req: Request) {
const { remotePort, remoteAddress } = req.socket
const isAllowed = this.allowedLocalPorts.includes(remotePort!)
&& ['127.0.0.1', '::1'].includes(remoteAddress!)
const remotePortInAllowList = this.allowedLocalPorts.includes(remotePort!)
// When testing cypress in cypress, we pass along the x-cypress-forwarded-from-cypress header to signify this is a safe request
const trustedSourceUsingCypressInCypress = !!process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF && !!req.headers['x-cypress-forwarded-from-cypress']
const isAllowed = (remotePortInAllowList || trustedSourceUsingCypressInCypress) && ['127.0.0.1', '::1'].includes(remoteAddress!)
debug('is incoming request allowed? %o', { isAllowed, reqUrl: req.url, remotePort, remoteAddress })
+7
View File
@@ -24,6 +24,13 @@ export interface FullConfig extends Partial<Cypress.RuntimeConfigOptions & Cypre
resolved: ResolvedConfigurationOptions
}
// Cannot just use RuntimeConfigOptions as is because some types are not complete.
// Instead, this is an interface of values that have been manually validated to exist
// and are required when creating a project.
export type ReceivedCypressOptions =
Pick<Cypress.RuntimeConfigOptions, 'hosts' | 'projectName' | 'clientRoute' | 'devServerPublicPathRoute' | 'namespace' | 'report' | 'socketIoCookie' | 'configFile' | 'isTextTerminal' | 'isNewProject' | 'proxyUrl' | 'browsers' | 'browserUrl' | 'socketIoRoute' | 'arch' | 'platform' | 'spec' | 'specs' | 'browser' | 'version' | 'remote'>
& Pick<Cypress.ResolvedConfigOptions, 'chromeWebSecurity' | 'supportFolder' | 'experimentalSourceRewriting' | 'fixturesFolder' | 'reporter' | 'reporterOptions' | 'screenshotsFolder' | 'pluginsFile' | 'supportFile' | 'baseUrl' | 'viewportHeight' | 'viewportWidth' | 'port' | 'experimentalInteractiveRunEvents' | 'userAgent' | 'downloadsFolder' | 'env' | 'testFiles' | 'ignoreSpecPattern' | 'specPattern'> // TODO: Figure out how to type this better.
export interface SampleConfigFile{
status: 'changes' | 'valid' | 'skipped' | 'error'
filePath: string
+4
View File
@@ -266,9 +266,13 @@ export async function scaffoldProjectNodeModules (project: string, updateYarnLoc
export async function scaffoldCommonNodeModules () {
await Promise.all([
'@babel/preset-env',
'@babel/preset-react',
'babel-loader',
// Used for import { defineConfig } from 'cypress'
'cypress',
'@cypress/code-coverage',
'@cypress/react',
'@cypress/webpack-dev-server',
'@packages/socket',
'@packages/ts',
@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}
@@ -0,0 +1,58 @@
{
"globals": {
"defineProps": "readonly",
"defineEmits": "readonly",
"defineExpose": "readonly",
"withDefaults": "readonly"
},
"plugins": [
"cypress",
"@cypress/dev"
],
"extends": [
"../../../packages/frontend-shared/.eslintrc.json"
],
"env": {
"cypress/globals": true
},
"overrides": [
{
"files": [
"*.ts"
],
"rules": {
"no-dupe-class-members": "off",
"@typescript-eslint/no-dupe-class-members": "error"
}
},
{
"files": [
"lib/*"
],
"rules": {
"no-console": 1
}
},
{
"files": [
"**/*.json"
],
"rules": {
"quotes": "off",
"comma-dangle": "off"
}
},
{
"files": [
"*.tsx",
"*.jsx"
],
"rules": {
"no-unused-vars": "off",
"react/jsx-no-bind": "off",
"react/react-in-jsx-scope": "off",
"react/no-unknown-property": "off"
}
}
]
}
@@ -0,0 +1,16 @@
module.exports = {
projectId: 'abc123',
experimentalInteractiveRunEvents: true,
component: {
specPattern: 'src/**/*.{spec,cy}.{js,ts,tsx,jsx}',
supportFile: false,
devServer: require('@cypress/react/plugins/load-webpack'),
devServerConfig: {
webpackFilename: 'webpack.config.js',
},
},
e2e: {
specPattern: 'cypress/e2e/**/*.spec.{js,ts}',
supportFile: false,
},
}
@@ -0,0 +1,3 @@
import { registerMountFn } from '@packages/frontend-shared/cypress/support/common'
registerMountFn()
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DOM Content</title>
</head>
<body>
<div class="container">
<ul class="list">
<li>
Item 1
</li>
<li>
Item 2
</li>
<li>
Item 3
</li>
</ul>
</div>
</body>
@@ -0,0 +1,5 @@
describe('Dom Content', () => {
it('renders the test content', () => {
cy.visit('cypress/e2e/dom-content.html')
})
})
@@ -0,0 +1,10 @@
import React from 'react'
import { mount } from '@cypress/react'
describe('TestComponent', () => {
it('renders the test component', () => {
mount(<div>Component Test</div>)
cy.contains('Component Test').should('be.visible')
})
})
@@ -0,0 +1,13 @@
module.exports = {
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
},
},
],
},
}
+26 -26
View File
@@ -9527,9 +9527,9 @@
webpack "^5"
"@types/webpack@^4", "@types/webpack@^4.4.31", "@types/webpack@^4.41.12", "@types/webpack@^4.41.21", "@types/webpack@^4.41.26", "@types/webpack@^4.41.8":
version "4.41.30"
resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.30.tgz#fd3db6d0d41e145a8eeeafcd3c4a7ccde9068ddc"
integrity sha512-GUHyY+pfuQ6haAfzu4S14F+R5iGRwN6b2FRNJY7U0NilmFAqbsOfK6j1HwuLBAqwRIT+pVdNDJGJ6e8rpp0KHA==
version "4.41.32"
resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.32.tgz#a7bab03b72904070162b2f169415492209e94212"
integrity sha512-cb+0ioil/7oz5//7tZUSwbrSAN/NWHrQylz5cW8G0dWTcF/g+/dSdMlKVZspBYuMAN1+WnwHrkxiRrLcwd0Heg==
dependencies:
"@types/node" "*"
"@types/tapable" "^1"
@@ -18857,10 +18857,10 @@ enhanced-resolve@^4.0.0, enhanced-resolve@^4.1.0, enhanced-resolve@^4.3.0, enhan
memory-fs "^0.5.0"
tapable "^1.0.0"
enhanced-resolve@^5.7.0, enhanced-resolve@^5.8.0:
version "5.8.2"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz#15ddc779345cbb73e97c611cd00c01c1e7bf4d8b"
integrity sha512-F27oB3WuHDzvR2DOGNTaYy0D5o0cnrv8TeI482VM4kYgQd/FT9lUQwuNsJ0oOHtBUq7eiW5ytqzp7nBFknL+GA==
enhanced-resolve@^5.7.0, enhanced-resolve@^5.8.3:
version "5.8.3"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.8.3.tgz#6d552d465cce0423f5b3d718511ea53826a7b2f0"
integrity sha512-EGAbGvH7j7Xt2nc0E7D99La1OiEs8LnyimkRgwExpUMScN6O+3x9tIWs7PLQZVNx4YD+00skHXPXi1yQHpAmZA==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.2.0"
@@ -19074,10 +19074,10 @@ es-get-iterator@^1.0.2, es-get-iterator@^1.1.1:
is-string "^1.0.5"
isarray "^2.0.5"
es-module-lexer@^0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.7.1.tgz#c2c8e0f46f2df06274cdaf0dd3f3b33e0a0b267d"
integrity sha512-MgtWFl5No+4S3TmhDmCz2ObFGm6lEpTnzbQi+Dd+pw4mlTIZTmM2iAs5gRlmx5zS9luzobCSBSI90JM/1/JgOw==
es-module-lexer@^0.9.0:
version "0.9.3"
resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19"
integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==
es-to-primitive@^1.2.1:
version "1.2.1"
@@ -42134,10 +42134,10 @@ watchpack@^1.6.0, watchpack@^1.7.4:
chokidar "^3.4.1"
watchpack-chokidar2 "^2.0.1"
watchpack@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.2.0.tgz#47d78f5415fe550ecd740f99fe2882323a58b1ce"
integrity sha512-up4YAn/XHgZHIxFBVCdlMiWDj6WaLKpwVeGQk2I5thdYxF/KmF0aaz6TfJZ/hfl1h/XlcDr7k1KH7ThDagpFaA==
watchpack@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.3.1.tgz#4200d9447b401156eeca7767ee610f8809bc9d25"
integrity sha512-x0t0JuydIo8qCNctdDrn1OzH/qDzk2+rdCOC3YzumZ42fiMqmQ7T3xQurykYMhYfHaPHTp4ZxAx2NfUo1K6QaA==
dependencies:
glob-to-regexp "^0.4.1"
graceful-fs "^4.1.2"
@@ -42556,10 +42556,10 @@ webpack-sources@^1.1.0, webpack-sources@^1.2.0, webpack-sources@^1.3.0, webpack-
source-list-map "^2.0.0"
source-map "~0.6.1"
webpack-sources@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.0.tgz#b16973bcf844ebcdb3afde32eda1c04d0b90f89d"
integrity sha512-fahN08Et7P9trej8xz/Z7eRu8ltyiygEo/hnRi9KqBUs80KeDcnf96ZJo++ewWd84fEf3xSX9bp4ZS9hbw0OBw==
webpack-sources@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.2.tgz#d88e3741833efec57c4c789b6010db9977545260"
integrity sha512-cp5qdmHnu5T8wRg2G3vZZHoJPN14aqQ89SyQ11NpGH5zEMDCclt49rzo+MaRazk7/UeILhAI+/sEtcM+7Fr0nw==
webpack-subresource-integrity@1.5.2:
version "1.5.2"
@@ -42697,9 +42697,9 @@ webpack@4.44.2:
webpack-sources "^1.4.1"
webpack@^5, webpack@^5.38.1:
version "5.52.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.52.0.tgz#88d997c2c3ebb62abcaa453d2a26e0fd917c71a3"
integrity sha512-yRZOat8jWGwBwHpco3uKQhVU7HYaNunZiJ4AkAVQkPCUGoZk/tiIXiwG+8HIy/F+qsiZvSOa+GLQOj3q5RKRYg==
version "5.65.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.65.0.tgz#ed2891d9145ba1f0d318e4ea4f89c3fa18e6f9be"
integrity sha512-Q5or2o6EKs7+oKmJo7LaqZaMOlDWQse9Tm5l1WAfU/ujLGN5Pb0SqGeVkN/4bpPmEqEP5RnVhiqsOtWtUVwGRw==
dependencies:
"@types/eslint-scope" "^3.7.0"
"@types/estree" "^0.0.50"
@@ -42710,8 +42710,8 @@ webpack@^5, webpack@^5.38.1:
acorn-import-assertions "^1.7.6"
browserslist "^4.14.5"
chrome-trace-event "^1.0.2"
enhanced-resolve "^5.8.0"
es-module-lexer "^0.7.1"
enhanced-resolve "^5.8.3"
es-module-lexer "^0.9.0"
eslint-scope "5.1.1"
events "^3.2.0"
glob-to-regexp "^0.4.1"
@@ -42723,8 +42723,8 @@ webpack@^5, webpack@^5.38.1:
schema-utils "^3.1.0"
tapable "^2.1.1"
terser-webpack-plugin "^5.1.3"
watchpack "^2.2.0"
webpack-sources "^3.2.0"
watchpack "^2.3.1"
webpack-sources "^3.2.2"
websocket-driver@>=0.5.1, websocket-driver@^0.7.4:
version "0.7.4"