feat(app): render spec list, command log, iframe (#18372)

* wip

* wip

* wip

* pre-bundle deps

* wip

* wip: spec

* clear iframe

* wip: it kind of works

* wip, docs

* wip

* update runner API

* wip

* remove comments

* do not render header

* revert some stuff

* remove test

* update tests to work with react 17

* types

* wip

* revert react deps

* move code around

* add back hash change code

* lint

* comment

* update readme

* remove commet

* revert yarn lock

* remove unused file

* Delete HelloWorld1.spec.tsx

* Delete HelloWorld2.spec.tsx

* update types

* fix ui

* fix bugs

* fix test

* remove dead code

* more dead code

Co-authored-by: Jessica Sachs <jess@jessicasachs.io>
This commit is contained in:
Lachlan Miller
2021-10-08 14:29:00 +10:00
committed by GitHub
parent 66100832e5
commit a8b43eec78
38 changed files with 677 additions and 258 deletions

View File

@@ -4,11 +4,25 @@ This is the front-end for the Cypress App.
## Development
1. Use existing project to get a server (for example `cd packages/runner-ct && yarn cypress:open`)
2. It will open in a new browser on port 8080
3. Do `yarn start`. It will start the front-end for the new Cypress app
4. To back to the browser opened in step 2
5. Visit http://localhost:8080/__vite__/ for the new front-end powered by Vite (currently running the RunnerCt)
1. `yarn dev` (inside of `packages/app`)
2. It will open launchpad
3. Select Component Testing (current E2E is not fully working)
3. Open chrome (or another browser)
4. This launches the existing CT Runner. Change the URL to http://localhost:3000/__vite__/ (note the trailing `/`)
5. It should show the new Vite powered app
## Using existing, Vite-incompatible modules
Some of our modules, like `@packages/reporter`, `@packages/driver` and `@packages/runner-shared` cannot be easily
used with Vite due to circular dependencies and modules that do not have compatible ESM builds.
To work around this, when consuming existing code, it is bundled with webpack and made available under the
`window.UnifiedRunner` namespace. It is injected via [`injectBundle`](./src/runner/injectBundle.ts).
To add more code to the bundle, add it in the bundle root, `@packages/runner-ct/src/main.tsx` and attach it to
`window.UnifiedRunner`.
As a rule of thumb, avoid importing from the older, webpack based modules into this package. Instead, if you want to consume code from those older, webpack bundled modules, you should add them to the webpack root and consume them via `window.UnifiedRunner`. Ideally, update [`index.d.ts`](./index.d.ts) to add the types, as well.
### Icons
@@ -63,3 +77,4 @@ You can see this in the video with the Settings cog. It uses and `evenodd` fill
## Diagram
![](./unified-runner-diagram.png)]

View File

@@ -1,7 +1,7 @@
describe('App', () => {
it('resolves the home page', () => {
cy.visit('http://localhost:5556')
cy.get('[href="#/runs"]').click()
cy.get('[href="#/settings"]').click()
cy.get('[href="/__vite__/runner"]').click()
cy.get('[href="/__vite__/settings"]').click()
})
})

78
packages/app/index.d.ts vendored Normal file
View File

@@ -0,0 +1,78 @@
import type { Store } from './src/store'
interface ConnectionInfo {
automationElement: '__cypress-string',
randomString: string
}
export {}
/**
* The eventManager and driver are bundled separately
* by webpack. We cannot import them because of
* circular dependencies.
* To work around this, we build the driver, eventManager
* and some other dependencies using webpack, and consumed the dist'd
* source code.
*
* This is attached to `window` under the `UnifiedRunner` namespace.
*
* For now, just declare the types that we need to give us type safety where possible.
* Eventually, we should decouple the event manager and import it directly.
*/
declare global {
interface Window {
UnifiedRunner: {
/**
* decode config, which we receive as a base64 string
* This comes from Driver.utils
*/
decodeBase64: (base64: string) => Record<string, unknown>
/**
* Proxy event to the reporter via `Reporter.defaultEvents.emit`
*/
emit (evt: string, ...args: unknown[]): void
/**
* This is the eventManager which orchestrates all the communication
* between the reporter, driver, and server, as well as handle
* setup, teardown and running of specs.
*
* It's only used on the "Runner" part of the unified runner.
*/
eventManager: {
addGlobalListeners: (state: Store, connectionInfo: ConnectionInfo) => void
setup: (config: Record<string, unknown>) => void
initialize: ($autIframe: JQuery<HTMLIFrameElement>, config: Record<string, unknown>) => void
teardown: (state: Store) => Promise<void>
teardownReporter: () => Promise<void>
[key: string]: any
}
/**
* To ensure we are only a single copy of React
* We get a reference to the copy of React (and React DOM)
* that is used in the Reporter and Driver, which are bundled with
* webpack.
*
* Unfortunately, attempting to have React in a project
* using Vue causes mad conflicts because React'S JSX type
* is ambient, so we cannot actually type it.
*/
React: any
ReactDOM: any
/**
* Any React components or general code needed from
* runner-shared, reporter or driver are also bundled with
* webpack and made available via the window.UnifedRunner namespace.
*
* We cannot import the correct types, because this causes the linter and type
* checker to run on runner-shared and reporter, and it blows up.
*/
AutIframe: any
Reporter: any
}
}
}

View File

@@ -1,7 +1,12 @@
<template>
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
<!--
TODO(lachlan): put this back after doing proper cleanup when unmounting the runner page
keep-alive works fine for Vue but has some weird effects on the React based Reporter
For now it's way more simple to just unmount and remount the components when changing page.
-->
<!-- <keep-alive> -->
<component :is="Component" />
<!-- </keep-alive> -->
</router-view>
</template>

View File

@@ -16,11 +16,11 @@
name="fade"
mode="out-in"
>
<keep-alive>
<component
:is="Component"
/>
</keep-alive>
<!-- <keep-alive> -->
<component
:is="Component"
/>
<!-- </keep-alive> -->
</transition>
</router-view>
</section>

View File

@@ -16,15 +16,13 @@
>
<router-link
v-for="item in navigation"
v-slot="{ href, isActive }"
v-slot="{ isActive }"
:key="item.name"
custom
:to="item.href"
>
<SidebarNavigationRow
:active="isActive"
:icon="item.icon"
:href="href"
>
{{ item.name }}
</SidebarNavigationRow>
@@ -43,7 +41,7 @@ import SettingsIcon from '~icons/cy/settings_x24'
const navigation = [
{ name: 'Specs', icon: SpecsIcon, href: '/' },
{ name: 'Runs', icon: CodeIcon, href: '/runs' },
{ name: 'Runs', icon: CodeIcon, href: '/runner' },
{ name: 'Settings', icon: SettingsIcon, href: '/settings' },
]

View File

@@ -1,6 +1,5 @@
<template>
<a
href="#"
<div
:class="[active ? 'before:bg-jade-300' : 'before:bg-transparent']"
class="w-full
min-w-40px
@@ -43,16 +42,16 @@
<slot />
</span>
</span>
</a>
</div>
</template>
<script lang="ts" setup>
import type { FunctionalComponent, SVGAttributes } from 'vue'
withDefaults(defineProps <{
icon?: FunctionalComponent<SVGAttributes, {}>
icon: FunctionalComponent<SVGAttributes, {}>
// Currently active row (generally the current route)
active?: boolean
active: boolean
}>(), {
active: false,
icon: undefined,

View File

@@ -1,9 +1,27 @@
<template>
<Specs />
<div>
<div v-if="query.fetching.value">
Loading...
</div>
<div v-else-if="query.data.value?.app?.activeProject">
<Specs :gql="query.data.value.app" />
</div>
</div>
</template>
<script setup lang="ts">
import { gql, useQuery } from '@urql/vue'
import Specs from './Specs.vue'
import { IndexDocument } from '../generated/graphql'
gql`
query Index {
app {
...Specs_Specs
}
}`
const query = useQuery({ query: IndexDocument })
</script>
<route>

View File

@@ -1,33 +1,49 @@
<template>
<div>
<h2>Runs Page</h2>
<h4 v-if="!ready">
Loading
</h4>
<h4 v-else>
Error: TODO add gql backend for getting the base64Config into the browser
</h4>
<h2>Runner Page</h2>
<div v-once>
<div :id="RUNNER_ID" />
<div :id="REPORTER_ID" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { renderRunner } from '../../runner/renderRunner'
<script lang="ts" setup>
import { onMounted } from 'vue'
import type { SpecFile } from '@packages/types/src'
import { UnifiedRunnerAPI } from '../../runner'
import { REPORTER_ID, RUNNER_ID } from '../../runner/utils'
import { useRoute } from 'vue-router'
const ready = ref(false)
// @ts-ignore
window.__Cypress__ = true
renderRunner(() => {
setTimeout(function () {
// @ts-ignore
window.Runner.start(document.getElementById('app'), '{{base64Config | safe}}')
}, 0)
ready.value = true
onMounted(() => {
UnifiedRunnerAPI.initialize(executeSpec)
})
const route = useRoute()
function executeSpec () {
const absolute = route.hash.slice(1)
if (absolute) {
// @ts-ignore
execute({
absolute,
relative: `src/Basic.spec.tsx`,
name: `Basic.spec.tsx`,
})
}
}
const execute = (spec?: SpecFile) => {
if (!spec) {
return
}
UnifiedRunnerAPI.executeSpec(spec)
}
</script>
<route>
{
name: "Runner"

View File

@@ -1,36 +1,42 @@
<template>
<div>
<h2>Specs Page</h2>
<template v-if="query.data.value?.app">
<SpecsList :gql="query.data.value?.app" />
</template>
<p v-else>
Loading...
</p>
</div>
<h2>Specs Page</h2>
<SpecsList
v-if="props.gql"
:gql="props?.gql"
/>
<p v-else>
Loading...
</p>
</template>
<script lang="ts" setup>
import { gql } from '@urql/core'
import { useQuery } from '@urql/vue'
import { Specs_AppDocument } from '../generated/graphql'
import { gql } from '@urql/vue'
import SpecsList from '../specs/SpecsList.vue'
import { REPORTER_ID, RUNNER_ID } from '../runner/utils'
import type { Specs_SpecsFragment } from '../generated/graphql'
gql`
query Specs_App {
app {
...SpecsList
}
}
`
const query = useQuery({
query: Specs_AppDocument,
})
fragment Specs_Specs on App {
...Specs_SpecsList
}`
const props = defineProps<{
gql: Specs_SpecsFragment
}>()
</script>
<route>
{
name: "Specs Page"
}
</route>
<style>
iframe {
border: 5px solid black;
margin: 10px;
background: lightgray;
}
</style>

View File

@@ -1,4 +1,4 @@
import { createWebHashHistory, createRouter as _createRouter } from 'vue-router'
import { createRouter as _createRouter, createWebHistory } from 'vue-router'
import generatedRoutes from 'virtual:generated-pages'
import { setupLayouts } from 'virtual:generated-layouts'
@@ -6,7 +6,7 @@ export const createRouter = () => {
const routes = setupLayouts(generatedRoutes)
return _createRouter({
history: createWebHashHistory(),
history: createWebHistory('/__vite__/'),
routes,
})
}

View File

@@ -0,0 +1,154 @@
/// <reference types="../../index" />
/**
* This is the seam between the new "unified app", built with
* Vite and Vue, and the existing code, including:
* - driver
* - reporter
* - event manager
* - anything in runner-shared, such as AutIframe, etc.
* which are built with React and bundle with webpack.
*
* The entry point for the webpack bundle is `runner-ct/main.tsx`.
* Any time you need to consume some existing code, add it to the `window.UnifiedRunner`
* namespace there, and access it with `window.UnifiedRunner`.
*
*/
import { store, Store } from '../store'
import { injectBundle } from './injectBundle'
import type { SpecFile } from '@packages/types/src/spec'
import { UnifiedReporterAPI } from './reporter'
import { getRunnerElement } from './utils'
function empty (el: HTMLElement) {
while (el.lastChild) {
if (el && el.firstChild) {
el.removeChild(el.firstChild)
}
}
}
const randomString = `${Math.random()}`
/**
* One-time setup. Required `window.UnifiedRunner` to exist,
* so this is passed as a callback to the `renderRunner` function,
* which injects `UnifiedRunner` onto `window`.
* Everything on `window.UnifiedRunner` is bundled using webpack.
*
* Creates Cypress instance, initializes various event buses to allow
* for communication between driver, runner, reporter via event bus,
* and server (via web socket).
*/
function setupRunner (done: () => void) {
window.UnifiedRunner.eventManager.addGlobalListeners(store, {
automationElement: '__cypress-string',
randomString,
})
done()
}
/**
* Get the URL for the spec. This is the URL of the AUT IFrame.
*/
function getSpecUrl (namespace: string, spec: SpecFile, prefix = '') {
return spec ? `${prefix}/${namespace}/iframes/${spec.absolute}` : ''
}
/**
* Clean up the current Cypress instance and anything else prior to
* running a new spec.
* This should be called before you execute a spec,
* or re-running the current spec.
*/
function teardownSpec (store: Store) {
return window.UnifiedRunner.eventManager.teardown(store)
}
/**
* Set up a spec by creating a fresh AUT and initializing
* Cypress on it.
*
*/
function setupSpec (spec: SpecFile) {
// @ts-ignore - TODO: figure out how to manage window.config.
const config = window.config
// this is how the Cypress driver knows which spec to run.
config.spec = spec
// creates a new instance of the Cypress driver for this spec,
// initializes a bunch of listeners
// watches spec file for changes.
window.UnifiedRunner.eventManager.setup(config)
const $runnerRoot = getRunnerElement()
// clear AUT, if there is one.
empty($runnerRoot)
// create root for new AUT
const $container = document.createElement('div')
$runnerRoot.append($container)
// create new AUT
const autIframe = new window.UnifiedRunner.AutIframe('Test Project')
const $autIframe: JQuery<HTMLIFrameElement> = autIframe.create().appendTo($container)
const specSrc = getSpecUrl(config.namespace, spec)
autIframe.showInitialBlankContents()
$autIframe.prop('src', specSrc)
// initialize Cypress (driver) with the AUT!
window.UnifiedRunner.eventManager.initialize($autIframe, config)
}
/**
* Inject the global `UnifiedRunner` via a <script src="..."> tag.
* which includes the event manager and AutIframe constructor.
* It is bundlded via webpack and consumed like a third party module.
*
* This only needs to happen once, prior to running the first spec.
*/
function initialize (ready: () => void) {
injectBundle(() => setupRunner(ready))
}
/**
* This wraps all of the required interactions to run a spec.
* Here are the things that happen:
*
* 1. set the current spec in the store. The Reporter, Driver etc
* are all coupled to MobX tightly and require the MobX store containing
* the current spec.
*
* 2. Reset the Reporter. We use the same instance of the Reporter,
* but reset the internal state each time we run a spec.
*
* 3. Teardown spec. This does a few things, primaily stopping the current
* spec run, which involves stopping the driver and runner.
*
* 4. Force the Reporter to re-render with the new spec we are executed.
*
* 5. Setup the spec. This involves a few things, see the `setupSpec` function's
* description for more information.
*/
async function executeSpec (spec: SpecFile) {
store.setSpec(spec)
await UnifiedReporterAPI.resetReporter()
await teardownSpec(store)
UnifiedReporterAPI.setupReporter()
return setupSpec(spec)
}
export const UnifiedRunnerAPI = {
initialize,
executeSpec,
}

View File

@@ -0,0 +1,40 @@
function injectReporterStyle () {
const style = document.createElement('style')
style.innerText = `
.reporter {
min-height: 0;
width: 300px;
left: 750px;
position: absolute;
}
`
document.head.appendChild(style)
}
export async function injectBundle (ready: () => void) {
const response = await window.fetch('/api')
const data = await response.json()
const script = document.createElement('script')
script.src = '/__cypress/runner/cypress_runner.js'
script.type = 'text/javascript'
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = '/__cypress/runner/cypress_runner.css'
document.head.appendChild(script)
document.head.appendChild(link)
injectReporterStyle()
script.onload = () => {
// @ts-ignore - just stick config on window until we figure out how we are
// going to manage it
window.config = window.UnifiedRunner.decodeBase64(data.base64Config)
ready()
}
}

View File

@@ -1,18 +0,0 @@
export function renderRunner (ready: () => void) {
const script = document.createElement('script')
script.src = '/__cypress/runner/cypress_runner.js'
script.type = 'text/javascript'
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = '/__cypress/runner/cypress_runner.css'
document.head.appendChild(script)
document.head.appendChild(link)
script.onload = () => {
ready()
}
}

View File

@@ -0,0 +1,59 @@
import { store, Store } from '../store'
import { getReporterElement } from './utils'
let hasInitializeReporter = false
async function unmountReporter () {
// We do not need to unmount the reporter at any point right now,
// but this will likely be useful for cleaning up at some point.
window.UnifiedRunner.ReactDOM.unmountComponentAtNode(getReporterElement())
}
async function resetReporter () {
if (hasInitializeReporter) {
await window.UnifiedRunner.eventManager.teardownReporter()
}
}
function setupReporter () {
const $reporterRoot = getReporterElement()
renderReporter($reporterRoot, store, window.UnifiedRunner.eventManager)
hasInitializeReporter = true
}
function renderReporter (
root: HTMLElement,
store: Store,
eventManager: typeof window.UnifiedRunner.eventManager,
) {
class EmptyHeader extends window.UnifiedRunner.React.Component {
render () {
return window.UnifiedRunner.React.createElement('div')
}
}
const reporter = window.UnifiedRunner.React.createElement(window.UnifiedRunner.Reporter, {
runMode: 'single' as const,
runner: eventManager.reporterBus,
key: store.specRunId,
spec: store.spec,
specRunId: store.specRunId,
error: null, // errorMessages.reporterError(props.state.scriptError, props.state.spec.relative),
resetStatsOnSpecChange: true,
experimentalStudioEnabled: false,
// TODO: Are we re-skinning the Reporter header?
// If so, with React or Vue?
// For now, just render and empty div.
renderReporterHeader: (props: null) => window.UnifiedRunner.React.createElement(EmptyHeader, props),
})
window.UnifiedRunner.ReactDOM.render(reporter, root)
}
export const UnifiedReporterAPI = {
unmountReporter,
setupReporter,
hasInitializeReporter,
resetReporter,
}

View File

@@ -0,0 +1,21 @@
export const RUNNER_ID = 'unified-runner'
export const REPORTER_ID = 'unified-reporter'
function getElementById (id: string) {
const el = document.querySelector<HTMLElement>(`#${id}`)
if (!el) {
throw Error(`Expected element with #${id} but did not find it.`)
}
return el
}
export function getRunnerElement () {
return getElementById(RUNNER_ID)
}
export function getReporterElement () {
return getElementById(REPORTER_ID)
}

View File

@@ -1,11 +0,0 @@
import SpecsListHeader from './SpecsListHeader.vue'
describe('<SpecsListHeader />', () => {
beforeEach(() => {
cy.mount(() => (
<div class="py-4 px-8">
<SpecsListHeader modelValue='' />
</div>
))
})
})

View File

@@ -1,5 +1,5 @@
import SpecsList from './SpecsList.vue'
import { SpecsListFragmentDoc, SpecListRowFragment } from '../generated/graphql-test'
import { Specs_SpecsListFragmentDoc, SpecListRowFragment } from '../generated/graphql-test'
import { defaultMessages } from '@cy/i18n'
const rowSelector = '[data-testid=specs-list-row]'
@@ -20,7 +20,7 @@ let specs: Array<SpecListRowFragment> = []
describe('<SpecsList />', { keystrokeDelay: 0 }, () => {
beforeEach(() => {
cy.mountFragment(SpecsListFragmentDoc, {
cy.mountFragment(Specs_SpecsListFragmentDoc, {
onResult: (ctx) => {
specs = ctx.activeProject?.specs?.edges || []

View File

@@ -14,7 +14,6 @@
v-slot="{ navigate }"
:key="spec.node.id"
:to="path(spec)"
custom
>
<SpecsListRow
:gql="spec"
@@ -31,25 +30,32 @@ import SpecsListHeader from './SpecsListHeader.vue'
import SpecsListRow from './SpecsListRow.vue'
import { gql } from '@urql/vue'
import { computed, ref } from 'vue'
import type { SpecsListFragment } from '../generated/graphql'
import type { Specs_SpecsListFragment, SpecNode_SpecsListFragment } from '../generated/graphql'
import { useI18n } from '@cy/i18n'
const { t } = useI18n()
const path = (spec) => `/runner/tests/${spec.node.specType}/${spec.node.name}${spec.node.fileExtension}`
const path = (spec: SpecNode_SpecsListFragment) => `/runner/#${spec.node.absolute}`
gql`
fragment SpecsList on App {
fragment SpecNode_SpecsList on SpecEdge {
node {
name
specType
absolute
relative
}
...SpecListRow
}
`
gql`
fragment Specs_SpecsList on App {
activeProject {
id
projectRoot
specs(first: 1) {
specs(first: 10) {
edges {
node {
name
specType
relative
}
...SpecListRow
...SpecNode_SpecsList
}
}
}
@@ -57,14 +63,19 @@ fragment SpecsList on App {
`
const props = defineProps<{
gql: SpecsListFragment
gql: Specs_SpecsListFragment
}>()
const search = ref('')
const specs = computed(() => props.gql.activeProject?.specs?.edges)
// If this search becomes any more complex, push it into the server
const sortByGitStatus = (a, b) => a.node.gitInfo ? 1 : -1
const sortByGitStatus = (
a: SpecNode_SpecsListFragment,
b: SpecNode_SpecsListFragment,
) => {
return a.node.gitInfo ? 1 : -1
}
const filteredSpecs = computed(() => {
return specs.value?.filter((s) => {
return s.node.relative.toLowerCase().includes(search.value.toLowerCase())

View File

@@ -1,5 +1,5 @@
import SpecsListHeader from './SpecsListHeader.vue'
import { ref } from 'vue'
import { defineComponent, ref, h } from 'vue'
const buttonSelector = '[data-testid=new-spec-button]'
const inputSelector = 'input[type=search]'
@@ -9,10 +9,19 @@ describe('<SpecsListHeader />', { keystrokeDelay: 0 }, () => {
const search = ref('')
const searchString = 'my/component.cy.tsx'
cy.mount(<SpecsListHeader modelValue={search.value} />)
cy.mount(defineComponent({
setup () {
return () => h(SpecsListHeader, {
modelValue: search.value,
'onUpdate:modelValue': (val: string) => {
search.value = val
},
})
},
}))
.get(inputSelector)
.type(searchString, { delay: 0 })
.should(() => {
.then(() => {
expect(search.value).to.equal(searchString)
})
})

View File

@@ -5,7 +5,7 @@
class="flex-grow h-full min-w-200px"
prefix-icon-classes="icon-light-gray-50 icon-dark-gray-500"
:prefix-icon="IconMagnifyingGlass"
:model-value="inputValue"
:model-value="props.modelValue"
:placeholder="t('specPage.searchPlaceholder')"
@input="onInput"
/>
@@ -25,7 +25,6 @@
</template>
<script lang="ts" setup>
import { useVModel } from '@vueuse/core'
import { useI18n } from '@cy/i18n'
import Button from '@cy/components/Button.vue'
import Input from '@cy/components/Input.vue'
@@ -43,9 +42,9 @@ const emit = defineEmits<{
(e: 'newSpec'): void
}>()
const inputValue = useVModel(props, 'modelValue', emit)
const onInput = (e: Event) => {
const value = (e.target as HTMLInputElement).value
const onInput = (e) => {
inputValue.value = (e as Event & { target: HTMLInputElement | null }).target?.value || ''
emit('update:modelValue', value)
}
</script>

View File

@@ -12,7 +12,7 @@
/>
<div>
<span class="font-medium text-gray-700 group-hocus:text-indigo-500">{{ spec.fileName }}</span>
<span class="font-light text-gray-400 group-hocus:text-indigo-500">{{ spec.specFileExtension + spec.fileExtension }}</span>
<span class="font-light text-gray-400 group-hocus:text-indigo-500">{{ spec.specFileExtension }}</span>
</div>
</div>
<div class="grid git-info-row grid-cols-[16px,auto] items-center gap-9px">

View File

@@ -0,0 +1,5 @@
import { BaseStore } from '@packages/runner-shared/src/store'
export class Store extends BaseStore {}
export const store = new Store()

View File

@@ -5,6 +5,7 @@
"src/**/*.tsx",
"cypress/**/*.ts",
"cypress/**/*.tsx",
"index.d.ts",
"../frontend-shared/src/**/*.vue",
"../frontend-shared/src/**/*.tsx",
"../frontend-shared/cypress/**/*.ts"
@@ -14,6 +15,7 @@
"@cy/i18n": ["../frontend-shared/src/locales/i18n"],
"@cy/components/*": ["../frontend-shared/src/components/*"],
"@packages/*": ["../*"]
}
},
"allowJs": true
}
}

View File

@@ -7,6 +7,8 @@ export default makeConfig({
include: [
'@urql/core',
'vue-i18n',
'@cypress/vue',
'@vue/test-utils',
'vue-router',
'@urql/devtools',
'@urql/exchange-graphcache',

View File

@@ -59,20 +59,6 @@ describe('runnables', () => {
cy.percySnapshot()
})
it('displays multi-spec reporters', () => {
start({ runMode: 'multi', allSpecs: [
{
relative: 'fizz',
},
{
relative: 'buzz',
},
] })
cy.contains('buzz').should('be.visible')
cy.percySnapshot()
})
it('displays bundle error if specified', () => {
const error = {
title: 'Oops...we found an error preparing this test file:',

View File

@@ -2,7 +2,6 @@
import { action, runInAction } from 'mobx'
import { observer } from 'mobx-react'
import cs from 'classnames'
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import { render } from 'react-dom'
// @ts-ignore
@@ -19,14 +18,16 @@ import shortcuts from './lib/shortcuts'
import Header, { ReporterHeaderProps } from './header/header'
import Runnables from './runnables/runnables'
let runnerListenersAdded = false
interface BaseReporterProps {
appState: AppState
appState?: AppState
className?: string
runnablesStore: RunnablesStore
runnablesStore?: RunnablesStore
runner: Runner
scroller: Scroller
statsStore: StatsStore
events: Events
scroller?: Scroller
statsStore?: StatsStore
events?: Events
error?: RunnablesErrorModel
resetStatsOnSpecChange?: boolean
renderReporterHeader?: (props: ReporterHeaderProps) => JSX.Element
@@ -40,33 +41,9 @@ export interface SingleReporterProps extends BaseReporterProps{
runMode: 'single'
}
export interface MultiReporterProps extends BaseReporterProps{
runMode: 'multi'
allSpecs: Array<Cypress.Cypress['spec']>
}
@observer
class Reporter extends Component<SingleReporterProps | MultiReporterProps> {
static propTypes = {
error: PropTypes.shape({
title: PropTypes.string.isRequired,
link: PropTypes.string,
callout: PropTypes.string,
message: PropTypes.string.isRequired,
}),
runner: PropTypes.shape({
emit: PropTypes.func.isRequired,
on: PropTypes.func.isRequired,
}).isRequired,
spec: PropTypes.shape({
name: PropTypes.string.isRequired,
relative: PropTypes.string.isRequired,
absolute: PropTypes.string.isRequired,
}),
experimentalStudioEnabled: PropTypes.bool,
}
static defaultProps = {
class Reporter extends Component<SingleReporterProps> {
static defaultProps: Partial<SingleReporterProps> = {
runMode: 'single',
appState,
events,
@@ -79,7 +56,6 @@ class Reporter extends Component<SingleReporterProps | MultiReporterProps> {
const {
appState,
className,
runMode,
runnablesStore,
scroller,
error,
@@ -90,29 +66,18 @@ class Reporter extends Component<SingleReporterProps | MultiReporterProps> {
return (
<div className={cs(className, 'reporter', {
multiSpecs: runMode === 'multi',
'experimental-studio-enabled': experimentalStudioEnabled,
'studio-active': appState.studioActive,
})}>
{renderReporterHeader({ appState, statsStore })}
{this.props.runMode === 'single' ? (
<Runnables
appState={appState}
error={error}
runnablesStore={runnablesStore}
scroller={scroller}
spec={this.props.spec}
/>
) : this.props.allSpecs.map((spec) => (
<Runnables
key={spec.relative}
appState={appState}
error={error}
runnablesStore={runnablesStore}
scroller={scroller}
spec={spec}
/>
))}
<Runnables
appState={appState}
error={error}
runnablesStore={runnablesStore}
scroller={scroller}
spec={this.props.spec}
/>
)
</div>
)
}
@@ -121,7 +86,6 @@ class Reporter extends Component<SingleReporterProps | MultiReporterProps> {
// it never happens in normal e2e but can happen in component-testing mode
componentDidUpdate (newProps: BaseReporterProps) {
this.props.runnablesStore.setRunningSpec(this.props.spec.relative)
if (
this.props.resetStatsOnSpecChange &&
this.props.specRunId !== newProps.specRunId
@@ -146,7 +110,10 @@ class Reporter extends Component<SingleReporterProps | MultiReporterProps> {
statsStore,
})
this.props.events.listen(runner)
if (!runnerListenersAdded) {
this.props.events.listen(runner)
runnerListenersAdded = true
}
shortcuts.start()
EQ.init()

View File

@@ -1,2 +1,2 @@
import '@packages/runner/src/main.scss'
import './main.jsx'
import './main.tsx'

View File

@@ -58,7 +58,6 @@ const _defaults: Defaults = {
export default class State extends BaseStore {
defaults = _defaults
@observable isLoading = true
@observable isRunning = false
@observable waitingForInitialBuild = false
@@ -211,10 +210,6 @@ export default class State extends BaseStore {
this.isSpecsListOpen = open
}
@action setIsLoading (isLoading) {
this.isLoading = isLoading
}
@action updateReporterWidth (width: number) {
this.reporterWidth = width
}

View File

@@ -1,23 +1,54 @@
import { autorun, action, configure } from 'mobx'
import React from 'react'
import { render } from 'react-dom'
import ReactDOM from 'react-dom'
import $Cypress from '@packages/driver'
const driverUtils = $Cypress.utils
import { eventManager, AutIframe, Container } from '@packages/runner-shared'
import defaultEvents from '@packages/reporter/src/lib/events'
import { Reporter } from '@packages/reporter/src/main'
export function getSpecUrl (namespace: string, spec: FoundSpec, prefix = '') {
return spec ? `${prefix}/${namespace}/iframes/${spec.absolute}` : ''
}
const UnifiedRunner = {
React,
ReactDOM,
Reporter,
AutIframe,
defaultEvents,
eventManager,
decodeBase64: (base64: string) => {
return JSON.parse(driverUtils.decodeBase64Unicode(base64))
},
emit (evt: string, ...args: unknown[]) {
defaultEvents.emit(evt, ...args)
},
}
// @ts-ignore
window.UnifiedRunner = UnifiedRunner
/** This is the OG runner-ct */
import 'regenerator-runtime/runtime'
import type { FoundSpec } from '@packages/types/src/spec'
import { autorun, action, configure } from 'mobx'
import App from './app/RunnerCt'
import State from './lib/state'
import { Container, eventManager } from '@packages/runner-shared'
import util from './lib/util'
// to support async/await
import 'regenerator-runtime/runtime'
const driverUtils = $Cypress.utils
configure({ enforceActions: 'always' })
const Runner = {
emit (evt, ...args) {
const Runner: any = {
emit (evt: string, ...args: unknown[]) {
defaultEvents.emit(evt, ...args)
},
@@ -78,9 +109,10 @@ const Runner = {
/>
)
render(container, el)
ReactDOM.render(container, el)
})()
},
}
// @ts-ignore
window.Runner = Runner

View File

@@ -1,6 +1,6 @@
import React from 'react'
export const automationElementId = '__cypress-string'
export const automationElementId = '__cypress-string' as const
interface AutomationElementProps {
randomString: string

View File

@@ -1,6 +1,8 @@
export const automation = {
export const automationStatus = ['CONNECTING', 'MISSING', 'CONNECTED', 'DISCONNECTED'] as const
export const automation: Record<string, typeof automationStatus[number]> = {
CONNECTING: 'CONNECTING',
MISSING: 'MISSING',
CONNECTED: 'CONNECTED',
DISCONNECTED: 'DISCONNECTED',
}
} as const

View File

@@ -4,6 +4,7 @@ import Promise from 'bluebird'
import { action } from 'mobx'
import { client } from '@packages/socket'
import type { BaseStore } from './store'
import { studioRecorder } from './studio'
import { automation } from './automation'
@@ -11,6 +12,7 @@ import { logger } from './logger'
import { selectorPlaygroundModel } from './selector-playground'
import $Cypress from '@packages/driver'
import type { automationElementId } from './automation-element'
const $ = $Cypress.$
const ws = client.connect({
@@ -55,7 +57,7 @@ export const eventManager = {
return Cypress
},
addGlobalListeners (state, connectionInfo) {
addGlobalListeners (state: BaseStore, connectionInfo: { automationElement: typeof automationElementId, randomString: string }) {
const rerun = () => {
if (!this) {
// if the tests have been reloaded
@@ -63,7 +65,7 @@ export const eventManager = {
return
}
return this._reRun(state)
return this.runSpec(state)
}
ws.emit('is:automation:client:connected', connectionInfo, action('automationEnsured', (isConnected) => {
@@ -292,6 +294,7 @@ export const eventManager = {
const $window = $(window)
// TODO(lachlan): best place to do this?
$window.on('hashchange', rerun)
// when we actually unload then
@@ -331,7 +334,7 @@ export const eventManager = {
window.Cypress = Cypress
}
this._addListeners(Cypress)
this._addListeners()
ws.emit('watch:test:file', config.spec)
},
@@ -342,7 +345,7 @@ export const eventManager = {
return this.Cypress.isBrowser(browserName)
},
initialize ($autIframe, config) {
initialize ($autIframe: JQuery<HTMLIFrameElement>, config: Record<string, any>) {
performance.mark('initialize-start')
return Cypress.initialize({
@@ -525,8 +528,10 @@ export const eventManager = {
ws.off()
},
_reRun (state) {
if (!Cypress) return
async teardown (state: BaseStore) {
if (!Cypress) {
return
}
state.setIsLoading(true)
@@ -536,26 +541,37 @@ export const eventManager = {
studioRecorder.setInactive()
selectorPlaygroundModel.setOpen(false)
return this._restart()
.then(() => {
// this probably isn't 100% necessary
// since Cypress will fall out of scope
// but we want to be aggressive here
// and force GC early and often
Cypress.removeAllListeners()
localBus.emit('restart')
})
},
_restart () {
teardownReporter () {
return new Promise((resolve) => {
reporterBus.once('reporter:restarted', resolve)
reporterBus.emit('reporter:restart:test:run')
})
},
async _rerun () {
await this.teardownReporter()
// this probably isn't 100% necessary
// since Cypress will fall out of scope
// but we want to be aggressive here
// and force GC early and often
Cypress.removeAllListeners()
localBus.emit('restart')
},
async runSpec (state: BaseStore) {
if (!Cypress) {
return
}
await this.teardown(state)
return this._rerun()
},
_interceptStudio (displayProps) {
if (studioRecorder.isActive) {
displayProps.hookId = studioRecorder.hookId

View File

@@ -1,4 +1,4 @@
import _ from 'lodash'
import _, { DebouncedFunc } from 'lodash'
import $Cypress from '@packages/driver'
import * as blankContents from '../blank-contents'
import { visitFailure } from '../visit-failure'
@@ -11,14 +11,16 @@ import { studioRecorder } from '../studio'
const $ = $Cypress.$
export class AutIframe {
constructor (config) {
this.config = config
debouncedToggleSelectorPlayground: DebouncedFunc<(isEnabled: any) => void>
$iframe?: JQuery<HTMLIFrameElement>
constructor (private projectName: string) {
this.debouncedToggleSelectorPlayground = _.debounce(this.toggleSelectorPlayground, 300)
}
create () {
this.$iframe = $('<iframe>', {
id: `Your App: '${this.config.projectName}'`,
id: `Your App: '${this.projectName}'`,
class: 'aut-iframe',
})
@@ -42,7 +44,7 @@ export class AutIframe {
}
_showContents (contents) {
this._body().html(contents)
this._body()?.html(contents)
}
_contents () {
@@ -50,15 +52,15 @@ export class AutIframe {
}
_window () {
return this.$iframe.prop('contentWindow')
return this.$iframe?.prop('contentWindow')
}
_document () {
return this.$iframe.prop('contentDocument')
return this.$iframe?.prop('contentDocument')
}
_body () {
return this._contents() && this._contents().find('body')
return this._contents()?.find('body')
}
detachDom = () => {

View File

@@ -1,22 +1,18 @@
import { action, observable } from 'mobx'
import { nanoid } from 'nanoid'
import { automation, automationStatus } from './automation'
export type RunMode = 'single' | 'multi'
export type RunMode = 'single'
export abstract class BaseStore {
@observable spec: Cypress.Cypress['spec'] | undefined
@observable specs: Cypress.Cypress['spec'][] = []
export class BaseStore {
@observable spec: Cypress.Spec | undefined
@observable specs: Cypress.Spec[] = []
@observable specRunId: string | undefined
/** @type {"single" | "multi"} */
@observable runMode: RunMode = 'single'
@observable multiSpecs: Cypress.Cypress['spec'][] = [];
@action setSingleSpec (spec: Cypress.Cypress['spec'] | undefined) {
if (this.runMode === 'multi') {
this.runMode = 'single'
this.multiSpecs = []
}
@observable automation: typeof automationStatus[number] = automation.CONNECTING
@observable isLoading = true
@action setSingleSpec (spec: Cypress.Spec | undefined) {
this.setSpec(spec)
}
@@ -25,7 +21,7 @@ export abstract class BaseStore {
this.specRunId = nanoid()
}
@action setSpecs (specs: Cypress.Cypress['spec'][]) {
@action setSpecs (specs: Cypress.Spec[]) {
this.specs = specs
}
@@ -36,4 +32,8 @@ export abstract class BaseStore {
this.spec = foundSpec
}
}
@action setIsLoading (isLoading) {
this.isLoading = isLoading
}
}

View File

@@ -22,7 +22,6 @@ const _defaults = {
export default class State extends BaseStore {
defaults = _defaults
@observable isLoading = true
@observable isRunning = false
@observable messageTitle = _defaults.messageTitle
@@ -109,10 +108,6 @@ export default class State extends BaseStore {
}
}
@action setIsLoading (isLoading) {
this.isLoading = isLoading
}
@action updateDimensions (width, height) {
this.width = width
this.height = height

View File

@@ -43,6 +43,16 @@ export const createRoutesCT = ({
// TODO If prod, serve the build app files from app/dist
routesCt.get('/api', (req, res) => {
const options = makeServeConfig({
config,
getCurrentBrowser,
specsStore,
})
res.json(options)
})
routesCt.get('/__/api', (req, res) => {
const options = makeServeConfig({
config,

View File

@@ -11,12 +11,18 @@ export type FindSpecs = {
integrationFolder: Cypress.ResolvedConfigOptions['integrationFolder']
} & CommonSearchOptions
export interface FoundSpec {
// represents a spec file on file system
export interface SpecFile {
name: string
baseName: string
fileName: string
relative: string
absolute: string
}
// represents a spec file on file system and
// additional Cypress-specific information
export interface FoundSpec extends SpecFile {
specFileExtension: string
fileExtension: string
specType: Cypress.CypressSpecType