feat: create specs ui (#18483)

Co-authored-by: Tim Griesser <tgriesser10@gmail.com>
This commit is contained in:
Jessica Sachs
2021-11-05 13:32:43 -04:00
committed by GitHub
parent 5f27e782c7
commit b457751a9b
115 changed files with 2640 additions and 888 deletions
+1 -1
View File
@@ -22,4 +22,4 @@
"source.fixAll.eslint": true
},
"typescript.tsdk": "node_modules/typescript/lib"
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ export default defineConfig({
'componentFolder': 'src',
'supportFile': false,
'component': {
'testFiles': '**/*.spec.{js,ts,tsx,jsx}',
'testFiles': '**/*.{spec,cy}.{js,ts,tsx,jsx}',
'supportFile': 'cypress/component/support/index.ts',
'pluginsFile': 'cypress/component/plugins/index.js',
},
@@ -1,204 +0,0 @@
describe('NewSpec', () => {
beforeEach(() => {
cy.setupE2E('spec-generation')
// Fails locally (cypress:open) unless I refresh browsers
cy.withCtx(async (ctx) => {
await ctx.actions.app.refreshBrowsers()
})
cy.initializeApp()
cy.withCtx((ctx, { testState }) => {
testState.generatedSpecs = {
story: 'src/Button.stories.cy.jsx',
storyCopy: 'src/Button.stories-copy-1.cy.jsx',
component: 'src/Button.cy.jsx',
componentCopy: 'src/Button-copy-1.cy.jsx',
integration: 'cypress/integration/HelloWorld.spec.js',
integrationCopy: 'cypress/integration/HelloWorld-copy-1.spec.js',
}
// Hack for `stop-only-all`
testState.generatedSpecContent = {
story: `import React from "react"
import { mount } from "@cypress/react"
import { composeStories } from "@storybook/testing-react"
import * as stories from "./Button.stories"
const composedStories = composeStories(stories)
describe('Button', () => {
${/** Hack for "stop-only-all" */'it'}.only('should render Primary', () => {
const { Primary } = composedStories
mount(<Primary />)
})
it('should render Secondary', () => {
const { Secondary } = composedStories
mount(<Secondary />)
})
it('should render Large', () => {
const { Large } = composedStories
mount(<Large />)
})
it('should render Small', () => {
const { Small } = composedStories
mount(<Small />)
})
})`,
component: `import { mount } from "@cypress/react"
import Button from "./Button"
describe('<Button />', () => {
it('renders', () => {
// see: https://reactjs.org/docs/test-utils.html
mount(<Button />)
})
})`,
integration: `describe('HelloWorld.spec.js', () => {
it('should visit', () => {
cy.visit('/')
})
})`,
}
})
cy.withCtx((ctx, { testState }) => {
const { fs, path, activeProject } = ctx
const projectRoot = activeProject?.projectRoot as string
for (const file of Object.values(testState.generatedSpecs) as string[]) {
fs.removeSync(path.join(projectRoot, file))
}
})
})
it('generates a spec from story', () => {
cy.visitApp('#/newspec')
cy.wait(1000)
cy.intercept('mutation-NewSpec_CodeGenSpec').as('codeGenSpec')
cy.findByText('Generate From Story').should('not.be.disabled').click()
cy.get('li').contains('Button.stories.jsx').as('codeGen').click()
cy.wait('@codeGenSpec')
cy.withCtx((ctx, { testState }) => {
const generatedSpec = ctx.activeProject?.generatedSpec
expect(generatedSpec?.spec.relative).eq(testState.generatedSpecs.story)
const fileContent = ctx.fs.readFileSync(
generatedSpec?.spec.absolute as string,
'utf-8',
)
expect(fileContent).eq(testState.generatedSpecContent.story)
})
// Test creating a copy
cy.get('@codeGen').click()
cy.wait('@codeGenSpec')
cy.withCtx((ctx, { testState }) => {
const generatedSpec = ctx.activeProject?.generatedSpec
expect(generatedSpec?.spec.relative).eq(
testState.generatedSpecs.storyCopy,
)
ctx.fs.accessSync(
generatedSpec?.spec.absolute as string,
ctx.fs.constants.F_OK,
)
})
})
it('generates a component from story', () => {
cy.visitApp('#/newspec')
cy.wait(1000)
cy.intercept('mutation-NewSpec_CodeGenSpec').as('codeGenSpec')
cy.findByText('Generate From Component').click()
cy.get('li').contains('Button.jsx').as('codeGen').click()
cy.wait('@codeGenSpec')
cy.withCtx((ctx, { testState }) => {
const generatedSpec = ctx.activeProject?.generatedSpec
expect(generatedSpec?.spec.relative).eq(
testState.generatedSpecs.component,
)
const fileContent = ctx.fs.readFileSync(
generatedSpec?.spec.absolute as string,
'utf-8',
)
expect(fileContent).eq(testState.generatedSpecContent.component)
})
// Test creating a copy
cy.get('@codeGen').click()
cy.wait('@codeGenSpec')
cy.withCtx((ctx, { testState }) => {
const generatedSpec = ctx.activeProject?.generatedSpec
expect(generatedSpec?.spec.relative).eq(
testState.generatedSpecs.componentCopy,
)
ctx.fs.accessSync(
generatedSpec?.spec.absolute as string,
ctx.fs.constants.F_OK,
)
})
})
it('generates an integration spec', () => {
cy.visitApp('#/newspec')
cy.wait(1000)
cy.intercept('mutation-NewSpec_CodeGenSpec').as('codeGenSpec')
cy.findByText('Generate Integration').click()
cy.get('#fileName').type('HelloWorld.spec.js')
cy.findByText('Generate Spec').as('generateSpec').click()
cy.wait('@codeGenSpec')
cy.withCtx((ctx, { testState }) => {
const generatedSpec = ctx.activeProject?.generatedSpec
expect(generatedSpec?.spec.relative).eq(
testState.generatedSpecs.integration,
)
const fileContent = ctx.fs.readFileSync(
generatedSpec?.spec.absolute as string,
'utf-8',
)
expect(fileContent).eq(testState.generatedSpecContent.integration)
})
cy.get('@generateSpec').click()
cy.wait('@codeGenSpec')
cy.withCtx((ctx, { testState }) => {
const generatedSpec = ctx.activeProject?.generatedSpec
expect(generatedSpec?.spec.relative).eq(
testState.generatedSpecs.integrationCopy,
)
ctx.fs.accessSync(
generatedSpec?.spec.absolute as string,
ctx.fs.constants.F_OK,
)
})
})
})
+7 -152
View File
@@ -1,160 +1,15 @@
// @ts-nocheck
// @ts-ignore
import * as JustMyLuck from 'just-my-luck'
import faker from 'faker'
import { template, keys, reduce, templateSettings } from 'lodash'
import combineProperties from 'combine-properties'
import type { AutSnapshot } from '../../src/runner/iframe-model'
templateSettings.interpolate = /{{([\s\S]+?)}}/g
let jml
const setupSeeds = () => {
const seed = 2
faker.seed(seed)
jml = new JustMyLuck(JustMyLuck.MersenneTwister(seed))
}
setupSeeds()
beforeEach(() => setupSeeds)
/**
* Component Naming Fixtures
*/
export const modifiers = [
'Async',
'Dynamic',
'Static',
'Virtual',
'Lazy',
]
export const domainModels = [
'Person',
'Product',
'Spec',
'Settings',
'Account',
'Login',
'Logout',
'Launchpad',
'Wizard',
]
export const componentNames = [
'List',
'Table',
'Header',
'Footer',
'Button',
'Cell',
'Row',
'Skeleton',
'Loader',
'Layout',
]
export const specPattern = ['.spec', '_spec']
export const fileExtension = ['.tsx', '.jsx', '.ts', '.js']
export const directories = {
rootDedicated: template('tests'),
rootSrc: template('src'),
monorepo: template('packages/{{component}}/test'),
jestRoot: template('__test__'),
jestNestedLib: template('lib/{{component}}{{component2}}/__test__'),
dedicatedNested: template('lib/{{component}}/test'),
jestNested: template('src/{{component}}/__test__'),
componentsNested: template('src/components/{{component}}'),
componentsFlat: template('src/{{component}}'),
viewsFlat: template('src/views'),
frontendFlat: template('frontend'),
frontendComponentsFlat: template('frontend/components'),
}
const nameTemplates = {
// Business Logic Components
longDomain: template(`{{prefix}}{{modifier}}{{domain}}{{component}}`),
longDomain2: template(`{{prefix}}{{domain}}{{component}}{{component2}}`),
// App Components
page1: template(`{{domain}}Page`),
layout: template(`{{domain}}Layout`),
presentationalShort: template(`Base{{component}}`),
presentationalLong: template(`Base{{component}}{{component2}}`),
medium1: template(`{{prefix}}{{modifier}}{{component}}`),
medium2: template(`{{prefix}}{{component}}{{component2}}`),
short: template(`{{prefix}}{{component}}`),
}
const prefixes = ['I', 'V', 'Cy', null]
export const componentNameGenerator = (options: { template: any, omit: any, overrides: any } = { template: nameTemplates.medium1, omit: [], overrides: {} }) => {
const withoutValues = reduce(options.omit, (acc, v) => {
acc[v] = null
return acc
}, {})
const components = jml.pickCombination(componentNames, 2)
const defaultOptions = {
modifier: jml.pick(modifiers),
domain: jml.pick(domainModels),
prefix: jml.pick(prefixes),
component: components[0],
component2: components[1],
}
return options.template({
...defaultOptions,
...withoutValues,
...options.overrides,
})
}
const allRandomComponents = combineProperties({
domain: domainModels,
modifier: modifiers,
prefix: prefixes,
component: componentNames,
component2: componentNames,
fileExtension,
specPattern,
directory: keys(directories),
})
export const randomComponents = (n = 200) => {
return faker.random.arrayElements(allRandomComponents, n).map((d) => {
const name = componentNameGenerator({
overrides: d,
template: faker.random.objectElement(nameTemplates),
})
const gitFileState = jml.pick(['modified', 'unmodified', 'added', 'deleted'])
return {
componentName: name,
relativePath: directories[d.directory](d),
specExtension: d.specPattern,
fileExtension: d.fileExtension,
name: `${name}${d.specPattern}${d.fileExtension}`,
id: faker.datatype.uuid(),
gitInfo: {
comitter: gitFileState ? faker.internet.userName() : undefined,
timeAgo: gitFileState ? faker.datatype.datetime() : undefined,
fileState: gitFileState,
},
}
}, n)
}
export const autSnapshot: AutSnapshot = {
id: 1,
name: 'DOM Test Snapshot',
$el: null,
coords: [0, 0],
scrollBy: {
x: 0,
y: 0,
},
highlightAttr: '',
snapshots: [],
htmlAttrs: {},
viewportHeight: 500,
@@ -0,0 +1,190 @@
import FileMatch from './FileMatch.vue'
import { ref } from 'vue'
import { defaultMessages } from '@cy/i18n'
import { each } from 'lodash'
/*---------- Selectors ----------*/
// Inputs
const anyFilenameInputSelector = `[placeholder="${defaultMessages.components.fileSearch.byFilenameInput}"]`
const filenameInputSelector = `${anyFilenameInputSelector}:first`
const extensionInputSelector = `[placeholder="${defaultMessages.components.fileSearch.byExtensionInput}"]`
const fileMatchButtonSelector = '[data-testid=file-match-button]'
// File Match Indicator
// X out of Y Matches when searching the file list
const fileMatchIndicatorSelector = '[data-testid=file-match-indicator]'
const initialExtension = '*.stories.*'
const initialPattern = ''
describe('<FileMatch />', { viewportWidth: 600, viewportHeight: 300 }, () => {
describe('with some matches', () => {
beforeEach(() => {
const extensionPattern = ref(initialExtension)
const pattern = ref(initialPattern)
const matches = { total: 10, found: 9 }
const onUpdateExtensionPatternSpy = cy.spy().as('onUpdateExtensionPatternSpy')
const onUpdatePatternSpy = cy.spy().as('onUpdatePatternSpy')
const methods = {
'onUpdate:extensionPattern': (newValue) => {
extensionPattern.value = newValue
onUpdateExtensionPatternSpy(newValue)
},
'onUpdate:pattern': (newValue) => {
pattern.value = newValue
onUpdatePatternSpy(newValue)
},
}
cy.mount(() => (<div class="p-12 resize overflow-auto">
<FileMatch
matches={matches}
extensionPattern={extensionPattern.value}
pattern={pattern.value}
{...methods} />
</div>))
})
describe('expanding/collapsing', () => {
it('can be expanded and collapsed by the extension button', () => {
cy.get(extensionInputSelector).should('not.exist')
.get(fileMatchButtonSelector).click()
.get(extensionInputSelector).should('be.visible')
.get(fileMatchButtonSelector).click()
.get(extensionInputSelector).should('not.exist')
})
it('shows the extension textfield when expanded', () => {
cy.get(extensionInputSelector).should('not.exist')
.get(fileMatchButtonSelector).click()
.get(extensionInputSelector)
.should('be.visible').and('have.value', initialExtension)
})
it('shows the file name textfield when collapsed', () => {
cy.get(filenameInputSelector).should('be.visible')
})
it('shows the file name textfield when expanded', () => {
cy.get(fileMatchButtonSelector).click()
.get(filenameInputSelector).should('be.visible')
})
it('persists the file name search between collapsing and expanding', () => {
const newText = 'New filename'
cy.get(filenameInputSelector).should('be.visible')
.clear()
.type(newText)
.get(fileMatchButtonSelector).click()
.get(anyFilenameInputSelector).should('have.length', 1)
.get(filenameInputSelector).should('have.value', newText)
.get(fileMatchButtonSelector).click()
.get(filenameInputSelector).should('have.value', newText)
})
it('persists the extension search between collapsing and expanding', () => {
const newText = 'New extension'
cy.get(extensionInputSelector).should('not.exist')
.get(fileMatchButtonSelector).click()
.get(extensionInputSelector).should('be.visible')
.clear()
.type(newText)
.get(fileMatchButtonSelector).click().click()
.get(extensionInputSelector).should('be.visible').and('have.value', newText)
})
it('displays the extension in the extension button when collapsed', () => {
const newText = 'New extension'
cy.get(extensionInputSelector).should('not.exist')
.get(fileMatchButtonSelector).click()
.get(extensionInputSelector).should('be.visible')
.clear()
.type(newText)
.get(fileMatchButtonSelector).click()
.should('contain.text', newText)
})
it('does not display in the extension button when expanded', () => {
cy.get(extensionInputSelector).should('not.exist')
.get(fileMatchButtonSelector).click()
.should('have.text', '')
.click()
.should('have.text', initialExtension)
})
})
describe('extensionPattern', () => {
it('emits the update:extensionPattern event', () => {
const newExtension = '*.tsx'
cy.get(fileMatchButtonSelector).click()
.get(extensionInputSelector)
.clear()
.type(newExtension)
.get('@onUpdateExtensionPatternSpy')
.should('have.been.calledWith', newExtension)
})
it('displays the initial extension pattern', () => {
cy.get(fileMatchButtonSelector).should('have.text', initialExtension)
.click()
.get(extensionInputSelector).should('have.value', initialExtension)
})
})
describe('pattern', () => {
it('emits the update:pattern event', () => {
const newFilePattern = 'BaseComponent'
cy.get(filenameInputSelector).type(newFilePattern)
.get('@onUpdatePatternSpy')
.should('have.been.calledWith', newFilePattern)
})
})
})
describe('indicator', () => {
/*---------- Fixtures ----------*/
// Matches
const total = 10
const matchesData: Record<string, [{ matches: {found: number, total: number}, pattern?: string, extensionPattern?: string }, string]> = {
all: [
{ matches: { found: 10, total } },
'10 Matches',
],
'ignores numerator when file pattern isn\'t searched': [
{ matches: { found: 9, total } },
'10 Matches',
],
'shows numerator when file pattern is searched': [
{ matches: { found: 9, total }, pattern: 'A Pattern', extensionPattern: '*.tsx' },
'9 of 10 Matches',
],
one: [
{ matches: { found: 1, total }, pattern: 'A Pattern', extensionPattern: '*.tsx' },
'1 of 10 Matches',
],
'one without a pattern': [
{ matches: { found: 1, total } },
'1 Match',
],
'no matches': [
{ matches: { found: 0, total: 0 } },
'No Matches',
],
}
each(matchesData, ([theProps, expected], key) => {
it(`displays ${key} matches`, () => {
cy.mount(() => <FileMatch matches={theProps.matches} pattern={theProps.pattern || ''} extensionPattern={theProps.extensionPattern || ''} />)
.get(fileMatchIndicatorSelector)
.should('have.text', expected)
})
})
})
})
+128
View File
@@ -0,0 +1,128 @@
<!--
/**==============================================
* * FileMatch.vue
* Complex component to search and edit multiple
* text filters at once
* ==============================================
* * Collapsed (default)
*
* ? The user is searching by filePattern only as is
* ? able to see the extensionPattern
*
* ? [[v extensionPattern][filePattern][matches]]
*
*
* ----------------------------------------------
* * Expanded
*
* ? The user has two inputs and can edit filePattern
* ? and extensionPattern independently.
*
* ? 1. The input is bound to extensionPattern.
* ? 2. The bottom input is bound to the filePattern.
*
* ? [[v][extensionPattern][matches]]
* ? [filePattern ]
* ============================================**/
-->
<template>
<div>
<div class="inline-flex items-center w-full rounded border-1 hocus-default focus-within-default h-40px truncate">
<FileMatchButton
:expanded="expanded"
@click="toggleExpanded()"
>
<span v-if="!expanded">{{ localExtensionPattern }}</span>
</FileMatchButton>
<div class="inline-flex items-center flex-grow min-w-min group">
<i-cy-magnifying-glass_x16
v-if="!expanded"
class="inline-block ml-12px mr-8px icon-light-gray-50 icon-dark-gray-500 group-focus-within:icon-light-indigo-50 group-focus-within:icon-dark-indigo-400"
/>
<FileMatchInput
v-if="expanded"
v-model="localExtensionPattern"
class="ml-12px"
:placeholder="t('components.fileSearch.byExtensionInput')"
/>
<FileMatchInput
v-else
v-model="localPattern"
aria-label="file-name-input"
:placeholder="t('components.fileSearch.byFilenameInput')"
/>
</div>
<slot name="matches">
<FileMatchIndicator
class="truncate"
data-testid="file-match-indicator"
>
{{ indicatorText }}
</FileMatchIndicator>
</slot>
</div>
<div
class="inline-flex items-center w-full rounded mt-8px border-1 hocus-default focus-within-default h-40px"
:class="{ 'hidden' : !expanded }"
>
<div class="inline-flex items-center flex-grow group">
<i-cy-magnifying-glass_x16 class="inline-block ml-12px mr-8px icon-light-gray-50 icon-dark-gray-500 group-focus-within:icon-light-indigo-50 group-focus-within:icon-dark-indigo-400" />
<FileMatchInput
v-model="localPattern"
:placeholder="t('components.fileSearch.byFilenameInput')"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useI18n } from '@cy/i18n'
import FileMatchInput from './FileMatchInput.vue'
import FileMatchButton from './FileMatchButton.vue'
import FileMatchIndicator from './FileMatchIndicator.vue'
import { computed } from 'vue'
import { useToggle, useVModels } from '@vueuse/core'
const { t } = useI18n()
type Matches = {
total: number
found: number
}
const props = defineProps<{
extensionPattern: string,
pattern: string
matches: Matches
}>()
const emits = defineEmits<{
(eventName: 'update:extensionPattern', value: string): void
(eventName: 'update:pattern', value: string): void
}>()
const { extensionPattern: localExtensionPattern, pattern: localPattern } = useVModels(props, emits)
// 2 of 22 Matches
// No Matches
const indicatorText = computed(() => {
const numerator = props.matches.found
const denominator = props.matches.total
if (localPattern.value) {
// When the user has attempted to search anything
// "No Matches | 1 Match | { denominator } Matches"
return t('components.fileSearch.matchesIndicator', { count: numerator, denominator, numerator })
}
// When the user has attempted to search by file path
// "No Matches | {numerator} of {denominator} Matches"
return t('components.fileSearch.matchesIndicatorEmptyFileSearch', { count: numerator, denominator, numerator })
})
const [expanded, toggleExpanded] = useToggle(false)
</script>
@@ -0,0 +1,44 @@
// Anything with onClick SHOULD work, but isn't...
// Defining emits for "Button.vue" removes the native handler on the
// <button> element. vue-tsc just can't handle this yet.
import FileMatchButton from './FileMatchButton.vue'
import faker from 'faker'
import { ref } from 'vue'
const fileMatchButtonSelector = '[data-testid=file-match-button]'
describe('<FileMatchButton />', () => {
it('renders a small extension', () => {
cy.mount(() => (<div class="p-12">
<FileMatchButton>
*.jsx
</FileMatchButton>
</div>))
})
it('renders a long bit of text', () => {
cy.mount(() => (<div class="p-12">
<FileMatchButton>
{ faker.lorem.paragraph(1) }
</FileMatchButton>
</div>))
})
it('takes in an optional expanded prop', () => {
const expanded = ref(false)
cy.mount(() => (<div class="p-12">
<FileMatchButton
// @ts-ignore - vue @click isn't represented in JSX
onClick={() => {
expanded.value = !expanded.value
}}
expanded={expanded.value}
>
*.stories.*
</FileMatchButton>
</div>))
.get(fileMatchButtonSelector)
.click()
.should('be.focused')
})
})
@@ -0,0 +1,20 @@
<template>
<button
data-testid="file-match-button"
class="inline-flex items-center h-full text-gray-700 transition duration-150 rounded-l outline-none group bg-gray-50 border-r-gray-100 border-r-1 hocus:bg-indigo-50 hocus:border-r-indigo-300 hocus:text-indigo-500 px-12px"
>
<i-cy-chevron-right-small_x16
class="transition duration-150 transform min-w-16px min-h-16px icon-dark-gray-400 group-hocus:icon-dark-indigo-400"
:class="{
'rotate-90': expanded
}"
/>
<slot />
</button>
</template>
<script lang="ts" setup>
withDefaults(defineProps<{
expanded: boolean
}>(), { expanded: false })
</script>
@@ -0,0 +1,20 @@
import FileMatchIndicator from './FileMatchIndicator.vue'
import faker from 'faker'
describe('<FileMatchIndicator />', () => {
it('renders a reasonable length text', () => {
cy.mount(() => (<div class="p-12">
<FileMatchIndicator>
No Matches
</FileMatchIndicator>
</div>))
})
it('renders a long bit of text', () => {
cy.mount(() => (<div class="p-12">
<FileMatchIndicator>
{ faker.lorem.paragraph(1) }
</FileMatchIndicator>
</div>))
})
})
@@ -0,0 +1,8 @@
<template>
<div class="inline-flex items-center">
<span class="text-center truncate rounded select-none bg-jade-100 text-jade-600 my-8px mr-8px px-8px">
<slot /></span>
</div>
</template>
<script lang="ts" setup>
</script>
@@ -0,0 +1,28 @@
import FileMatchInput from './FileMatchInput.vue'
import { ref } from 'vue'
describe('<FileMatchInput />', () => {
it('renders a reasonable length text and can be typed into', () => {
const initialText = 'Initial Text Value'
const newText = 'Hello'
const inputText = ref(initialText)
const onUpdateTextSpy = cy.spy().as('onUpdateTextSpy')
const methods = {
'onUpdate:modelValue': (newValue) => {
inputText.value = newValue
onUpdateTextSpy(newValue)
},
}
cy.mount(() => (<div class="p-12">
<FileMatchInput modelValue={inputText.value} {...methods} />
</div>))
.get('input[type=search]').should('have.value', initialText)
.clear().type(newText)
.get('@onUpdateTextSpy').should('have.been.calledWith', newText)
.invoke('getCalls').should('have.length.at.least', newText.length)
.get('input[type=search]').should('have.value', newText)
.clear()
.get('@onUpdateTextSpy').should('have.been.calledWith', '')
})
})
@@ -0,0 +1,29 @@
<template>
<input
v-model="localModelValue"
class="flex-grow p-0 text-gray-700 placeholder-gray-400 border-transparent outline-none placeholder-shown:overflow-ellipsis placeholder-shown:truncate hocus:border-transparent mr-8px"
type="search"
>
</template>
<script lang="ts" setup>
import { useVModels } from '@vueuse/core'
const props = defineProps<{
modelValue: string
}>()
const emits = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const { modelValue: localModelValue } = useVModels(props, emits)
</script>
<style scoped>
input[type="search"]::-webkit-search-decoration,
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-results-button,
input[type="search"]::-webkit-search-results-decoration { display: none !important; }
</style>
+1 -5
View File
@@ -26,7 +26,6 @@
<!-- </keep-alive> -->
</transition>
</router-view>
<ModalManager v-if="modalStore.activeModalId" />
</section>
</main>
<nav
@@ -43,11 +42,8 @@ import SidebarNavigation from '../navigation/SidebarNavigation.vue'
import HeaderBar from '@cy/gql-components/HeaderBar.vue'
import { useRoute } from 'vue-router'
import { computed } from 'vue'
import { useMainStore } from '../store'
import ModalManager from '../modals/ModalManager.vue'
import { useModalStore, useMainStore } from '../store'
const modalStore = useModalStore()
const mainStore = useMainStore()
const currentRoute = useRoute()
-20
View File
@@ -1,20 +0,0 @@
<template>
<StandardModal
:model-value="true /* default to open */"
@update:model-value="modalStore.close"
>
<component
:is="modals[modalStore.activeModalId]"
v-if="modalStore.activeModalId"
:key="modalStore.activeModalId"
/>
</StandardModal>
</template>
<script lang="ts" setup>
import StandardModal from '@cy/components/StandardModal.vue'
import { useModalStore } from '../store'
import { modals } from './modals'
const modalStore = useModalStore()
</script>
-3
View File
@@ -1,3 +0,0 @@
<template>
<div>A test modal</div>
</template>
-7
View File
@@ -1,7 +0,0 @@
import type { ModalTypes } from '../store'
import type { Component } from 'vue'
import TestModal from './TestModal.vue'
export const modals: Record<ModalTypes, Component | Promise<Component>> = {
createSpec: TestModal,
}
@@ -12,7 +12,6 @@
w-24px
h-24px"
/>
<i-bi-bookmark-star class="text-white w-18px h-18px" />
</div>
<nav
class="flex-1 px-2 mt-5 space-y-1 bg-gray-1000"
@@ -46,11 +45,9 @@ import SettingsIcon from '~icons/cy/settings_x24'
import { useMainStore } from '../store'
const navigation = [
{ name: 'Home', icon: SpecsIcon, href: '/' },
{ name: 'Specs', icon: CodeIcon, href: '/specs' },
{ name: 'Home', icon: CodeIcon, href: '/' },
{ name: 'Runs', icon: RunsIcon, href: '/runs' },
{ name: 'Settings', icon: SettingsIcon, href: '/settings' },
{ name: 'New Spec', icon: SettingsIcon, href: '/newspec' },
]
const mainStore = useMainStore()
@@ -1,33 +1,10 @@
<template>
<div
:class="[active ? 'before:bg-jade-300' : 'before:bg-transparent']"
class="w-full
min-w-40px
relative
flex
items-center
rounded-md
group
focus-visible:outline-none
hover:before:bg-indigo-300
focus:before:bg-indigo-300
before:content-open-square
before:mr-4px
before:rounded-r-md
before:text-transparent
before:h-40px
before:w-4px"
class="relative flex items-center w-full rounded-md min-w-40px group focus-visible:outline-none hover:before:bg-indigo-300 focus:before:bg-indigo-300 before:content-open-square before:mr-4px before:rounded-r-md before:text-transparent before:h-40px before:w-4px"
>
<span
class="h-full
flex
items-center
overflow-hidden
p-8px
gap-20px
rounded-lg
children:group-focus:text-indigo-300
children:group-hover:text-indigo-300"
class="flex items-center h-full overflow-hidden rounded-lg p-8px gap-20px children:group-focus:text-indigo-300 children:group-hover:text-indigo-300"
>
<component
:is="icon"
+10 -1
View File
@@ -1,6 +1,13 @@
<template>
<div v-if="query.data.value?.app">
<SpecsList :gql="query.data.value.app" />
<SpecsList
v-if="query.data.value.app.activeProject?.specs"
:gql="query.data.value.app"
/>
<CreateSpecPage
v-else
:gql="query.data.value.app"
/>
</div>
<div v-else>
@@ -12,11 +19,13 @@
import { gql, useQuery } from '@urql/vue'
import SpecsList from '../specs/SpecsList.vue'
import { SpecsPageContainerDocument } from '../generated/graphql'
import CreateSpecPage from '../specs/CreateSpecPage.vue'
gql`
query SpecsPageContainer {
app {
...Specs_SpecsList
...CreateSpecPage
}
}
`
-238
View File
@@ -1,238 +0,0 @@
<template>
<div class="flex flex-col">
<h2>New Spec</h2>
<div class="flex gap-4 justify-center">
<Button
:disabled="!newSpecQuery.data.value?.app.activeProject?.storybook"
@click="codeGenTypeClicked('story')"
>
Generate From Story
</Button>
<Button @click="codeGenTypeClicked('component')">
Generate From Component
</Button>
<Button @click="codeGenTypeClicked('integration')">
Generate Integration
</Button>
</div>
<template v-if="codeGenType">
<template v-if="codeGenType !== 'integration'">
<div class="p-16px">
<Input
id="glob-pattern"
v-model="codeGenGlob"
/>
</div>
<ul class="p-16px max-h-210px overflow-auto">
<li
v-for="candidate of codeGenCandidates"
:key="candidate.relative"
class="group"
@click="candidateClick(candidate.absolute)"
>
<span class="text-indigo-600 font-medium">{{
candidate.fileName
}}</span>
<span class="font-light text-gray-400">{{
candidate.fileExtension
}}</span>
<span
class="font-light text-gray-400 pl-16px hidden group-hover:inline"
>{{ candidate.relativeFromProjectRoot }}</span>
</li>
</ul>
</template>
<template v-else-if="codeGenType === 'integration'">
<div class="flex p-16px gap-4">
<Input
id="fileName"
v-model="fileNameInput"
class="flex-auto"
/>
<Button @Click="candidateClick(fileNameInput)">
Generate Spec
</Button>
</div>
</template>
<div
v-if="candidateChosen && generatedSpec"
class="p-16px flex flex-col items-center"
>
<Button @click="specClick">
{{ generatedSpec.spec.relative }}
</Button>
<pre>{{ generatedSpec.content }}</pre>
</div>
</template>
</div>
</template>
<route>
{
name: "New Spec Page",
meta: {
title: "New Spec"
}
}
</route>
<script lang="ts" setup>
import Button from '@cy/components/Button.vue'
import Input from '@cy/components/Input.vue'
import { gql, useMutation, useQuery } from '@urql/vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
NewSpec_NewSpecQueryDocument,
NewSpec_SearchCodeGenCandidatesDocument,
NewSpec_CodeGenGlobQueryDocument,
NewSpec_CodeGenSpecDocument,
NewSpec_SetCurrentSpecDocument,
CodeGenType,
} from '../generated/graphql'
gql`
query NewSpec_NewSpecQuery {
app {
activeProject {
id
storybook {
id
}
generatedSpec {
id
content
spec {
id
name
relative
}
}
}
}
}
`
gql`
query NewSpec_CodeGenGlobQuery($type: CodeGenType!) {
app {
activeProject {
id
codeGenGlob: codeGenGlob(type: $type)
}
}
}
`
gql`
fragment NewSpec_CodeGenCandidateNode on FilePartsEdge {
node {
id
relative
fileName
baseName
absolute
}
}
`
gql`
query NewSpec_SearchCodeGenCandidates($glob: String!) {
app {
activeProject {
id
codeGenCandidates: codeGenCandidates(first: 25, glob: $glob) {
edges {
...NewSpec_CodeGenCandidateNode
}
}
}
}
}
`
gql`
mutation NewSpec_CodeGenSpec($codeGenCandidate: String!, $type: CodeGenType!) {
codeGenSpec(codeGenCandidate: $codeGenCandidate, type: $type)
}
`
gql`
mutation NewSpec_SetCurrentSpec($id: ID!) {
setCurrentSpec(id: $id)
}
`
const newSpecQuery = useQuery({ query: NewSpec_NewSpecQueryDocument })
const codeGenType = ref<CodeGenType | null>(null)
// Urql allows reactive query variables (ref, computed) but has improper typing
type ReactiveGraphQLVar = any
const codeGenGlobQuery = useQuery({
query: NewSpec_CodeGenGlobQueryDocument,
variables: { type: codeGenType as ReactiveGraphQLVar },
pause: computed(() => !codeGenType.value),
})
// Query 'pause' with computed property was triggering an infinite loop so use ref and watch instead
const codeGenGlob = ref('')
watch(codeGenGlobQuery.data, (value, prevVal) => {
if (value?.app.activeProject?.codeGenGlob && value.app.activeProject.codeGenGlob !== prevVal?.app.activeProject?.codeGenGlob) {
codeGenGlob.value = value.app.activeProject.codeGenGlob
}
})
const searchCodeGenCandidates = useQuery({
query: NewSpec_SearchCodeGenCandidatesDocument,
variables: { glob: codeGenGlob as ReactiveGraphQLVar },
pause: computed(() => !codeGenGlob.value),
})
const codeGenCandidates = computed(() => {
return (
searchCodeGenCandidates.data.value?.app.activeProject?.codeGenCandidates?.edges.map(
({ node: story }) => {
return {
...story,
fileExtension: story.baseName.replace(story.fileName, ''),
relativeFromProjectRoot: story.relative.replace(story.baseName, ''),
}
},
) || []
)
})
const fileNameInput = ref('')
const mutation = useMutation(NewSpec_CodeGenSpecDocument)
const candidateChosen = ref(false)
const generatedSpec = computed(
() => newSpecQuery.data.value?.app.activeProject?.generatedSpec,
)
const setSpecMutation = useMutation(NewSpec_SetCurrentSpecDocument)
const router = useRouter()
async function specClick () {
const specId = newSpecQuery.data.value?.app.activeProject?.generatedSpec?.spec.id
if (!specId) {
return
}
await setSpecMutation.executeMutation({ id: specId })
router.push('runner')
}
function codeGenTypeClicked (type: CodeGenType) {
codeGenType.value = type
candidateChosen.value = false
}
function candidateClick (codeGenCandidate: string) {
candidateChosen.value = true
mutation.executeMutation({
codeGenCandidate,
type: codeGenType.value as CodeGenType,
})
}
</script>
+1 -1
View File
@@ -48,7 +48,7 @@ import type { RunsConnectFragment } from '../generated/graphql'
const { t } = useI18n()
gql`
fragment RunsConnect on Query{
fragment RunsConnect on Query {
cloudViewer{
id
}
+4 -5
View File
@@ -58,11 +58,12 @@ import type { RunsEmptyFragment } from '../generated/graphql'
const { t } = useI18n()
gql`
fragment RunsEmpty on Project{
fragment RunsEmpty on Project {
title
projectId
configFilePath
cloudProject {
id
recordKeys {
...RecordKey
}
@@ -83,12 +84,10 @@ const projectIdCode = computed(() => {
const projectName = computed(() => props.gql.title)
const configFilePath = computed(() => props.gql.configFilePath)
const firstRecordKey = computed(() => {
const allRecordKeys = props.gql.cloudProject?.recordKeys
return allRecordKeys?.[0] ?? '<record-key>'
return props.gql.cloudProject?.recordKeys?.[0]
})
const recordCommand = computed(() => {
return `cypress run --record --key ${firstRecordKey.value}`
return `cypress run --record --key ${firstRecordKey.value?.key}`
})
</script>
@@ -0,0 +1,61 @@
// Anything with onClick SHOULD work, but isn't...
// Defining emits for "Button.vue" removes the native handler on the
// <button> element. vue-tsc just can't handle this yet.
import CreateSpecCard from './CreateSpecCard.vue'
import DocumentCode from '~icons/cy/document-code_x48'
const iconSelector = '[data-testid=create-spec-card-icon]'
const specCardSelector = '[data-testid=create-spec-card]'
const header = 'My header text here'
const shortDescription = `We'll walk you through generating your first spec from a component.`
describe('<CreateSpecCard />', { viewportWidth: 400, viewportHeight: 400 }, () => {
it('renders with long text', () => {
const longHeader = 'Create from very long header content alsowhenwordshaveno-linebreaks'
const longDescription = `This is some description text to explain how we import stuff using this button. Specifically, we're going to create specs from your user input. Also, this is a very long description to test how our CreateSpecCard resizes.`
cy.mount(() => (
<div class="m-12">
<CreateSpecCard icon={DocumentCode} header={longHeader} description={longDescription} />
</div>
)).get(specCardSelector)
.should('contain.text', longHeader)
.and('contain.text', longDescription)
})
it('renders with normal length text', () => {
cy.mount(() => (
<div class="m-12">
<CreateSpecCard icon={DocumentCode} header={header} description={shortDescription} />
</div>
)).get(specCardSelector)
.should('contain.text', header)
.and('contain.text', shortDescription)
})
it('renders the icon passed in', () => {
cy.mount(() => (
<div class="m-12">
<CreateSpecCard icon={DocumentCode} header={header} description={shortDescription} />
</div>
)).get(iconSelector)
.should('be.visible')
})
// TODO: Brian says this can be fixed once the driver is hooked up properly
xit('emits click events bound to it', () => {
const onClickSpy = cy.spy().as('onClickSpy')
cy.mount(() => (<div class="m-12">
<CreateSpecCard icon={DocumentCode} header={header} description={shortDescription}
// @ts-ignore - vue @click isn't represented in JSX
onClick={onClickSpy} />
</div>))
.get(specCardSelector)
.focus()
.click()
.type('{enter}')
.type(' ')
// .get('@onClickSpy').should('have.been.calledThrice')
})
})
+51
View File
@@ -0,0 +1,51 @@
<template>
<div
class="block h-auto overflow-scroll text-center rounded outline-none
cursor-pointer w-280px m-2px min-h-216px max-h-350px px-32px pt-32px
pb-24px children:hyphens-manual border-1 hocus-default
focus-within-default"
:class="{
'bg-gray-50 border-gray-100 pointer-events-none': disabled
}"
data-testid="create-spec-card"
>
<component
:is="icon"
class="mx-auto my-0 w-48px h-48px"
:class="disabled ?
'icon-dark-gray-600 icon-light-gray-100 icon-dark-secondary-gray-600 icon-light-secondary-gray-300':
'icon-dark-indigo-400 icon-light-indigo-100 icon-light-secondary-jade-200 icon-dark-secondary-jade-400'
"
data-testid="create-spec-card-icon"
/>
<h2
class="leading-normal text-indigo-500 mt-10px text-18px mb-4px"
:class="{
'text-gray-700': disabled
}"
>
<button class="focus:outline-none focus:ring-0">
{{ header }}
</button>
</h2>
<p
class="text-gray-600 tracking-tight text-14px leading-[20px]"
:class="{
'': disabled
}"
>
{{ description }}
</p>
</div>
</template>
<script lang="ts" setup>
import type { FunctionalComponent, SVGAttributes } from 'vue'
defineProps<{
icon: FunctionalComponent<SVGAttributes>,
header: string,
description: string,
disabled?: boolean
}>()
</script>
@@ -0,0 +1,44 @@
<template>
<div class="flex flex-wrap gap-32px children:mx-auto">
<component
:is="generator.card"
v-for="generator in generators"
:key="generator.id"
:disabled="generator.disabled(props.gql.activeProject) || false"
@click="$emit('select', generator.id)"
/>
</div>
</template>
<script lang="ts" setup>
import { generatorList } from './generators'
// eslint-disable-next-line no-duplicate-imports
import type { GeneratorId } from './generators'
import { computed } from 'vue'
import type { CreateSpecCardsFragment } from '../generated/graphql'
import { gql } from '@urql/vue'
const props = defineProps<{
gql: CreateSpecCardsFragment
}>()
defineEmits<{
(eventName: 'select', id: GeneratorId): void
}>()
const generators = computed(() => generatorList.filter((g) => g.matches(props.gql.activeTestingType)))
gql`
fragment CreateSpecCards on App {
activeTestingType
activeProject {
id
storybook {
storybookRoot
}
}
}
`
</script>
@@ -0,0 +1,84 @@
import CreateSpecModal from './CreateSpecModal.vue'
import { ref } from 'vue'
import { defaultMessages } from '@cy/i18n'
const modalCloseSelector = '[aria-label=Close]'
const triggerButtonSelector = '[data-testid=trigger]'
const modalSelector = '[data-testid=create-spec-modal]'
const messages = defaultMessages.createSpec.component.importFromComponent
describe('<CreateSpecModal />', () => {
beforeEach(() => {
const show = ref(true)
cy.mount(() => (<div>
<CreateSpecModal
gql={{
activeProject: {
id: 'id',
codeGenGlob: '**.vue',
storybook: null,
},
activeTestingType: 'component',
}}
show={show.value}
onClose={() => show.value = false}
/>
</div>))
})
it('renders a modal', () => {
cy.get(modalSelector).should('be.visible')
})
describe('dismissing', () => {
it('is not dismissed when you press escape or click outside', () => {
cy.get(modalSelector)
.click(0, 0)
.get(modalSelector)
.should('be.visible')
})
it('is dismissed when the X button is clicked', () => {
cy.get(modalSelector)
.get(modalCloseSelector)
.click()
.get(modalSelector)
.should('not.exist')
})
})
describe('generator', () => {
it('renders the generator', () => {
cy.contains(messages.header).should('be.visible')
})
})
})
describe('playground', () => {
it('can be opened and closed via the show prop', () => {
const show = ref(false)
cy.mount(() => (<>
<button data-testid="trigger" onClick={() => show.value = true}>Open Modal</button>
<br/>
<CreateSpecModal
gql={{
activeProject: {
id: 'id',
codeGenGlob: '**.vue',
storybook: null,
},
activeTestingType: 'component',
}}
show={show.value}
onClose={() => show.value = false}
/>
</>)).get(triggerButtonSelector)
.click()
.get(modalSelector)
.should('be.visible')
.get(modalCloseSelector)
})
})
@@ -0,0 +1,85 @@
<template>
<StandardModal
class="transition duration-200 transition-all"
:click-outside="false"
variant="bare"
:title="title"
:model-value="show"
data-testid="create-spec-modal"
@update:model-value="$emit('close')"
>
<template #overlay="{classes}">
<DialogOverlay
:class="classes"
class="bg-gray-900 opacity-[0.97]"
/>
</template>
<div class="min-h-280px sm:min-w-640px flex flex-col">
<component
:is="generator.entry"
v-if="generator"
:key="generator.id"
v-model:title="title"
:code-gen-glob="props.gql.activeProject?.codeGenGlob"
@restart="currentGeneratorId = undefined"
/>
<div
v-else
class="flex-grow flex items-center self-center"
>
<CreateSpecCards
:gql="props.gql"
@select="currentGeneratorId = $event"
/>
</div>
</div>
</StandardModal>
</template>
<script lang ="ts" setup>
import { generators, SpecGenerator, GeneratorId } from './generators'
import { DialogOverlay } from '@headlessui/vue'
import StandardModal from '@cy/components/StandardModal.vue'
import StandardModalBody from '@cy/components/StandardModalBody.vue'
import StandardModalFooter from '@cy/components/StandardModalFooter.vue'
import CreateSpecCards from './CreateSpecCards.vue'
import { ref, computed, Ref } from 'vue'
import type { CreateSpecModalFragment } from '../generated/graphql'
import { gql } from '@urql/vue'
import { not, whenever } from '@vueuse/core'
import { useI18n } from '@cy/i18n'
const props = defineProps<{
initialGenerator?: GeneratorId,
show: boolean,
gql: CreateSpecModalFragment
}>()
defineEmits<{
(eventName: 'close'): void
}>()
gql`
fragment CreateSpecModal on App {
...CreateSpecCards
activeProject {
...ComponentGeneratorStepOne_codeGenGlob
}
}
`
const currentGeneratorId: Ref<GeneratorId | undefined> = ref(props.initialGenerator)
const { t } = useI18n()
const title = ref(t('createSpec.newSpecModalTitle'))
const generator = computed(() => {
if (currentGeneratorId.value) return generators[currentGeneratorId.value]
return null
})
whenever(not(generator), () => {
title.value = t('createSpec.newSpecModalTitle')
})
</script>
@@ -0,0 +1,90 @@
import Button from '@cy/components/Button.vue'
import CreateSpecPage from './CreateSpecPage.vue'
import { ref, Ref } from 'vue'
import { defaultMessages } from '@cy/i18n'
import type { TestingTypeEnum } from '../generated/graphql-test'
const pageTitleSelector = '[data-testid=create-spec-page-title]'
const pageDescriptionSelector = '[data-testid=create-spec-page-description]'
const noSpecsMessageSelector = '[data-testid=no-specs-message]'
const viewSpecsSelector = '[data-testid=view-spec-pattern]'
const messages = defaultMessages.createSpec
describe('<CreateSpecPage />', () => {
describe('mounting in component type', () => {
beforeEach(() => {
cy.mount(() => (<div class="p-12"><CreateSpecPage gql={{
activeProject: {
id: 'id',
storybook: null,
codeGenGlob: '**.vue',
}, activeTestingType: 'component',
}} /></div>))
})
it('renders the "No Specs" footer', () => {
cy.get(noSpecsMessageSelector)
.should('be.visible')
.and('contain.text', messages.noSpecsMessage)
.get(viewSpecsSelector)
.should('be.visible')
})
it('renders component mode', () => {
expect(true).to.be.true
})
it('renders the correct text for component testing', () => {
cy.get(pageTitleSelector).should('contain.text', messages.page.title)
.get(pageDescriptionSelector).should('contain.text', messages.page.component.description)
})
})
describe('mounting in e2e mode', () => {
beforeEach(() => {
cy.mount(() => (<div class="p-12"><CreateSpecPage gql={{
activeProject: {
id: 'id',
storybook: null,
codeGenGlob: '**.vue',
}, activeTestingType: 'e2e',
}} /></div>))
})
it('renders e2e mode', () => {
expect(true).to.be.true
})
it('renders the correct text', () => {
cy.get(pageTitleSelector)
.should('contain.text', messages.page.title)
.get(pageDescriptionSelector).should('contain.text', messages.page.e2e.description)
})
})
it('playground', () => {
const testingType: Ref<TestingTypeEnum> = ref('component')
cy.mount(() => (
<div class="p-12 space-y-10 resize overflow-auto">
{ /* Testing Utils */ }
<Button variant="outline"
size="md"
// @ts-ignore
onClick={() => testingType.value = testingType.value === 'component' ? 'e2e' : 'component'}>
Toggle Testing Types
</Button>
{ /* Subject Under Test */ }
<CreateSpecPage gql={{
activeProject: {
id: 'id',
storybook: null,
codeGenGlob: '**.vue',
},
activeTestingType: testingType.value,
}} />
</div>))
})
})
+89
View File
@@ -0,0 +1,89 @@
<template>
<CreateSpecModal
v-if="props.gql.activeTestingType"
:key="generator"
:initial-generator="generator"
:show="showModal"
:gql="props.gql"
@close="closeModal"
/>
<div
v-if="props.gql.activeTestingType"
class="overflow-scroll text-center max-w-600px mx-auto py-40px"
>
<h1
data-testid="create-spec-page-title"
class="text-gray-900 text-32px mb-12px"
>
{{ t('createSpec.page.title') }}
</h1>
<p
data-testid="create-spec-page-description"
class="leading-normal text-gray-600 text-18px mb-32px"
>
{{ t(`createSpec.page.${props.gql.activeTestingType}.description`) }}
</p>
<CreateSpecCards
data-testid="create-spec-page-cards"
:gql="props.gql"
@select="choose"
/>
<div class="text-center border-t-1 pt-32px mt-32px">
<p
data-testid="no-specs-message"
class="leading-normal text-gray-600 text-16px mb-16px"
>
{{ t('createSpec.noSpecsMessage') }}
</p>
<Button
data-testid="view-spec-pattern"
variant="outline"
prefix-icon-class="icon-light-gray-50 icon-dark-gray-400"
:prefix-icon="SettingsIcon"
class="mx-auto duration-300 hocus:ring-gray-50 hocus:border-gray-200"
@click.prevent=""
>
{{ t('createSpec.viewSpecPatternButton') }}
</Button>
</div>
</div>
</template>
<script lang="ts" setup>
import { useI18n } from '@cy/i18n'
import SettingsIcon from '~icons/cy/settings_x16'
import Button from '@cy/components/Button.vue'
import { ref, computed, Ref } from 'vue'
import CreateSpecModal from './CreateSpecModal.vue'
import CreateSpecCards from './CreateSpecCards.vue'
import { gql } from '@urql/vue'
import type { CreateSpecPageFragment } from '../generated/graphql'
const { t } = useI18n()
gql`
fragment CreateSpecPage on App {
activeTestingType
...CreateSpecCards
...CreateSpecModal
}
`
const props = defineProps<{
gql: CreateSpecPageFragment
}>()
const showModal = ref(false)
const generator = ref()
const closeModal = () => {
showModal.value = false
generator.value = null
}
const choose = (id) => {
showModal.value = true
generator.value = id
}
</script>
+11 -4
View File
@@ -1,9 +1,15 @@
<template>
<div class="p-24px">
<CreateSpecModal
v-if="props.gql.activeTestingType"
:show="showModal"
:gql="props.gql"
@close="showModal = false"
/>
<SpecsListHeader
v-model="search"
class="pb-32px"
@newSpec="modalStore.open('createSpec')"
@newSpec="showModal = true"
/>
<div class="grid items-center divide-y-1 children:h-40px">
<div class="grid grid-cols-2 children:text-gray-800 children:font-medium">
@@ -27,11 +33,10 @@ import SpecsListHeader from './SpecsListHeader.vue'
import SpecsListRow from './SpecsListRow.vue'
import { gql } from '@urql/vue'
import { computed, ref } from 'vue'
import CreateSpecModal from './CreateSpecModal.vue'
import type { Specs_SpecsListFragment, SpecNode_SpecsListFragment } from '../generated/graphql'
import { useI18n } from '@cy/i18n'
import { useModalStore } from '../store'
const modalStore = useModalStore()
const { t } = useI18n()
gql`
@@ -48,10 +53,11 @@ fragment SpecNode_SpecsList on SpecEdge {
gql`
fragment Specs_SpecsList on App {
...CreateSpecModal
activeProject {
id
projectRoot
specs: specs(first: 25) {
specs: specs(first: 100) {
edges {
...SpecNode_SpecsList
}
@@ -64,6 +70,7 @@ const props = defineProps<{
gql: Specs_SpecsListFragment
}>()
const showModal = ref(false)
const search = ref('')
const specs = computed(() => props.gql.activeProject?.specs?.edges)
@@ -30,10 +30,10 @@ describe('<SpecsListHeader />', { keystrokeDelay: 0 }, () => {
const onNewSpec = cy.spy().as('new-spec')
const search = ref('')
cy.mount(<div class="max-w-800px p-12 resize overflow-auto"><SpecsListHeader
cy.mount(() => (<div class="max-w-800px p-12 resize overflow-auto"><SpecsListHeader
modelValue={search.value}
onNewSpec={onNewSpec}
/></div>)
/></div>))
.get(buttonSelector)
.click()
.get('@new-spec')
+2 -1
View File
@@ -15,7 +15,8 @@
data-testid="new-spec-button"
:prefix-icon="IconAdd"
prefix-icon-class="justify-center text-lg text-center icon-light-transparent icon-dark-white"
class="min-w-127px text-size-16px focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500"
class="min-w-127px"
size="lg"
@click="$emit('newSpec')"
>
{{ t('specPage.newSpecButton') }}
@@ -0,0 +1,12 @@
<template>
<StandardModalBody
variant="bare"
class="w-640px h-444px overflow-scroll"
>
<slot />
</StandardModalBody>
</template>
<script lang="ts" setup>
import StandardModalBody from '@cy/components/StandardModalBody.vue'
</script>
@@ -0,0 +1,250 @@
import FileChooser from './FileChooser.vue'
import { randomComponents } from '@packages/frontend-shared/cypress/support/mock-graphql/testStubSpecs'
import { ref } from 'vue'
import { defaultMessages } from '@cy/i18n'
/*---------- Fixtures ----------*/
const numFiles = 20
const allFiles = randomComponents(numFiles, 'FileParts')
const extensionPattern = '*.jsx'
const existentExtensionPattern = '*.tsx'
const nonExistentFileName = 'non existent file'
/*---------- Selectors ----------*/
// File List
const fileRowSelector = '[data-testid=file-list-row]'
// Inputs
const filenameInputSelector = `[placeholder="${defaultMessages.components.fileSearch.byFilenameInput}"]:first`
const extensionInputSelector = `[placeholder="${defaultMessages.components.fileSearch.byExtensionInput}"]`
const fileMatchButtonSelector = '[data-testid=file-match-button]'
// File Match Indicator
// X out of Y Matches when searching the file list
const fileMatchIndicatorSelector = '[data-testid=file-match-indicator]'
// No Results
const noResultsSelector = '[data-testid=no-results]'
const noResultsClearButtonSelector = '[data-testid=no-results-clear]'
// Loading
const loadingSelector = '[data-testid=loading]'
describe('<FileChooser />', () => {
it('renders files in a list', () => {
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
.get(fileRowSelector)
.should('have.length', numFiles)
})
it('can search by file name', () => {
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
.get(filenameInputSelector)
.type('random string', { delay: 0 })
})
it('filters the files by file name', () => {
const query = 'base'
const expectedMatches = allFiles.filter((f) => f.relative.toLowerCase().includes(query)).length
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
.get(filenameInputSelector).type(query, { delay: 0 })
.get(fileRowSelector)
.should('have.length.at.least', expectedMatches)
.and('have.length.below', allFiles.length)
})
describe('matches', () => {
it('displays the total number of file matches', () => {
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
.get(fileMatchIndicatorSelector).should('contain.text', `${allFiles.length } Matches`)
})
it('handles pluralization', () => {
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={[allFiles[0]]} />))
.get(fileMatchIndicatorSelector).should('contain.text', `${1 } Match`)
})
it('handles no matches', () => {
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={[]} />))
.get(fileMatchIndicatorSelector).should('contain.text', 'No Matches')
})
it('updates the number of files found out of the total number available', () => {
const query = 'base'
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
.get(filenameInputSelector)
// Type some stuff in that will at least partially match components
.type(query, { delay: 0 })
.get(fileRowSelector)
.then(($rows) => {
// Figure out how many files were actually matched and make sure
// that they're out of the total files passed in
cy.get(fileMatchIndicatorSelector)
.should('contain.text', `${$rows.length} of ${allFiles.length} Matches`)
// Get back to an empty state where all files are shown
.get(filenameInputSelector).clear()
.get(fileMatchIndicatorSelector).should('contain.text', `${allFiles.length } Matches`)
// Go to the no matches state
.get(filenameInputSelector).type(nonExistentFileName, { delay: 0 })
.get(fileMatchIndicatorSelector).should('contain.text', 'No Matches')
})
})
})
describe('no results', () => {
it('does not show no results when there are files', () => {
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
.get(noResultsSelector).should('not.exist')
})
it('shows no results when there are no files', () => {
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={[]} />))
.get(noResultsSelector).should('be.visible')
})
describe('when searching the file pattern', () => {
it('handles "no results" with the right text', () => {
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
.get(filenameInputSelector)
.type(nonExistentFileName, { delay: 0 })
.get(noResultsSelector)
.should('contain.text', defaultMessages.noResults.defaultMessage)
.and('contain.text', nonExistentFileName)
})
it('clears the file search but leaves the extension search alone', () => {
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={allFiles} />))
.get(fileMatchButtonSelector)
.should('have.text', extensionPattern)
.click()
// Add some text to the extension to make it different than the
// initial one
.get(extensionInputSelector).clear().type(existentExtensionPattern, { delay: 0 })
// Add some text to the file name search to make it different than the
// initial one
.get(filenameInputSelector).type(nonExistentFileName, { delay: 0 })
// Clear
.get(noResultsClearButtonSelector).click()
// Extension pattern is still the same
.get(extensionInputSelector).should('have.value', existentExtensionPattern)
// And the file name input has been is cleared
.get(filenameInputSelector).should('have.value', '')
})
})
describe('when searching the extension', () => {
it('handles "no results" with the right text', () => {
cy.mount(() => (<FileChooser extensionPattern={extensionPattern} files={[]} />))
.get(noResultsSelector)
.findByText(defaultMessages.components.fileSearch.noMatchesForExtension)
.should('be.visible')
})
it('renders the extension inside of the no results view', () => {
cy.mount(() => (<FileChooser
extensionPattern={extensionPattern}
files={[]} />))
.get(noResultsSelector)
.findByText(extensionPattern).should('be.visible')
})
it('resets the extension to the initial extension', () => {
cy.mount(() => (<FileChooser
extensionPattern={extensionPattern}
files={[]} />))
.get(fileMatchButtonSelector)
.should('have.text', extensionPattern)
.click()
.get(extensionInputSelector).clear().type(existentExtensionPattern, { delay: 0 })
.get(noResultsClearButtonSelector).click()
.get(extensionInputSelector).should('have.value', extensionPattern)
})
})
})
describe('loading', () => {
it('is not loading by default', () => {
cy.mount(() => (<FileChooser
extensionPattern={extensionPattern}
files={[]} />))
.get(loadingSelector).should('not.be.visible')
})
it('toggles a reactive loading indicator', () => {
// Use a button to toggle "loading" state externally
const loading = ref(true)
const buttonSelector = '[data-testid=toggle-button]'
cy.mount(() => (<div>
<button data-testid="toggle-button" onClick={() => loading.value = !loading.value}>Toggle Loading</button>
<FileChooser files={allFiles} loading={loading.value} extensionPattern={extensionPattern} /></div>))
.get(loadingSelector).should('be.visible')
.get(buttonSelector).click()
.get(loadingSelector).should('not.be.visible')
.get(buttonSelector).click()
.get(loadingSelector).should('be.visible')
})
})
it('debounces the file extension input event', () => {
const onUpdateExtensionSpy = cy.spy().as('onUpdateExtensionSpy')
const spies = {
'onUpdate:extensionPattern': onUpdateExtensionSpy,
}
const newExtension = existentExtensionPattern
cy.mount(() => (
<FileChooser
{...spies}
extensionPattern={extensionPattern}
files={allFiles}></FileChooser>))
// Make sure the extension is in the button
.get(fileMatchButtonSelector)
.should('have.text', extensionPattern)
.click()
// Clear the extension
.get(extensionInputSelector)
.clear()
// Make sure we emit the update event with "clear"
.get('@onUpdateExtensionSpy').should('have.been.calledOnceWith', '')
// Type a new extension in
.get(extensionInputSelector)
.type(newExtension, { delay: 0 })
// Validate it's in there
.get(extensionInputSelector)
.should('have.value', newExtension)
// debounce should cause this to hit
.get('@onUpdateExtensionSpy').should('not.have.been.calledWith', newExtension)
// once the debounce is resolved, this will hit
.get('@onUpdateExtensionSpy').should('have.been.calledWith', newExtension)
})
it('fires a selectFile event when a file is clicked on', () => {
const onSelectFileSpy = cy.spy().as('onSelectFileSpy')
cy.mount(() => (
<FileChooser
onSelectFile={onSelectFileSpy}
extensionPattern={extensionPattern}
files={allFiles}></FileChooser>))
})
})
@@ -0,0 +1,159 @@
<!--
/**==============================================
* * FileChooser.vue
* Filter a list of files by a mix of glob pattern
* and a search string that includes a file's relative
* path.
*
* Features to note: debouncing + loading
* ==============================================
* * Debouncing
*
* ? To prevent calling update too frequently we debounce
* ? both the "No Results" UI and the update event
*
* ============================================**/
-->
<template>
<CreateSpecModalBody
variant="bare"
class="relative bg-white px-24px flex
flex-col"
>
<FileMatch
ref="fileMatchRef"
v-model:pattern="filePathSearch"
v-model:extensionPattern="localExtensionPattern"
class="sticky z-10 top-0px pt-24px pb-12px bg-white"
:matches="matches"
>
<template
v-if="loading"
#matches
>
<i-cy-loading_x16 class="h-24px w-24px mr-10px animate-spin" />
</template>
</FileMatch>
<div
v-show="loading"
data-testid="loading"
>
<!-- TODO(ryan): Get mocks for a loading state here -->
Loading
</div>
<FileList
v-show="!loading"
:style="{ paddingTop: `${fileMatchHeight + 36}px` }"
class="absolute left-24px right-24px"
:files="filteredFiles"
:search="filePathSearch"
@selectFile="selectFile"
>
<template #no-results>
<NoResults
empty-search
:search="noResults.search"
:message="noResults.message"
@clear="noResults.clear"
/>
</template>
</FileList>
</CreateSpecModalBody>
<div class="rounded-b w-full h-24px" />
</template>
<script setup lang="ts">
import { computed, ref, Ref, ComputedRef } from 'vue'
import { debouncedWatch, useDebounce, useElementSize } from '@vueuse/core'
import { useI18n } from '@cy/i18n'
import Button from '@cy/components/Button.vue'
import NoResults from '@cy/components/NoResults.vue'
import CreateSpecModalBody from './CreateSpecModalBody.vue'
import FileList from './FileList.vue'
import FileMatch from '../../components/FileMatch.vue'
import { gql } from '@urql/core'
const props = withDefaults(defineProps<{
files: any[]
extensionPattern: string,
loading?: boolean
}>(), {
loading: false,
})
const { t } = useI18n()
gql`
fragment FileChooser on FileParts {
relative
id
...FileListItem
}
`
const emits = defineEmits<{
(eventName: 'selectFile', value: File)
(eventName: 'update:extensionPattern', value: string)
}>()
// eslint-disable-next-line
const initialExtensionPattern = props.extensionPattern
const localExtensionPattern = ref(props.extensionPattern)
const filePathSearch = ref('')
const selectFile = (file) => {
emits('selectFile', file)
}
///*------- Styling -------*/
// For the FileList to be searchable without jumping to the top of the
// FileMatcher's top when focused, we need to use some manual layout.
// If we concede on position: sticky for the FileMatcher or on making the
// FileList accessible, we could position the FileList relatively.
const fileMatchRef = ref(null)
const { height: fileMatchHeight } = useElementSize(fileMatchRef)
///*------- Debounce -------*/
const debounce = 200
const debouncedExtensionPattern = useDebounce(localExtensionPattern, debounce)
debouncedWatch(localExtensionPattern, (value) => {
emits('update:extensionPattern', value)
}, { debounce })
///*------- Searching files -------*/
const filteredFiles = computed(() => {
return props.files?.filter((file) => {
return file.relative.toLowerCase().includes(filePathSearch.value.toLowerCase())
})
})
///*------- Matches Indicator -------*/
const matches = computed(() => {
return {
total: props.files.length,
found: filteredFiles.value.length,
}
})
///*------- No Results Options -------*/
const noResults = computed(() => {
return {
search: filePathSearch.value || debouncedExtensionPattern.value,
message: filePathSearch.value ? t('noResults.defaultMessage') : t('components.fileSearch.noMatchesForExtension'),
clear: filePathSearch.value ?
() => filePathSearch.value = '' :
() => localExtensionPattern.value = initialExtensionPattern,
}
})
</script>
@@ -0,0 +1,71 @@
import FileList from './FileList.vue'
import { randomComponents } from '@packages/frontend-shared/cypress/support/mock-graphql/testStubSpecs'
import { ref, Ref } from 'vue'
const difficultFile = {
baseName: '[...all].vue',
fileExtension: '.vue',
}
const noResultsSlot = () => <div data-testid="no-results">No Results</div>
const noResultsSelector = '[data-testid=no-results]'
const fileRowSelector = '[data-testid=file-list-row]'
const allFiles = randomComponents(10, 'FileParts')
allFiles[1] = { ...allFiles[1], ...difficultFile }
describe('<FileList />', { viewportHeight: 500, viewportWidth: 400 }, () => {
describe('with files', () => {
const files = allFiles
beforeEach(() => {
const selectFileSpy = cy.spy().as('selectFileSpy')
cy.mount(() => (<div class="resize overflow-auto min-w-300px m-2">
<FileList onSelectFile={selectFileSpy} files={files} />
</div>))
})
it('renders all of the files passed in', () => {
cy.get(fileRowSelector)
.should('have.length', 10)
})
it('emits a selectFile event when clicking on a row', () => {
cy.get(fileRowSelector)
.first()
.click()
.get('@selectFileSpy')
.should('have.been.calledWith', files[0])
})
it('correctly formats a difficult file', () => {
cy.get('body').contains('[...all]')
})
})
describe('without files', () => {
it('shows the no results slot', () => {
const files: Ref<typeof allFiles> = ref([])
let idx = 0
cy.mount(() => (<div>
<button data-testid="add-file"
onClick={() => {
files.value.push(allFiles[idx]); idx++
}}>
Add File
</button>
<FileList
v-slots={{ 'no-results': noResultsSlot }}
files={files.value} />
</div>))
.get(noResultsSelector).should('be.visible')
.get('[data-testid=add-file]')
.click()
.get(noResultsSelector).should('not.exist')
})
})
})
@@ -0,0 +1,63 @@
<template>
<div
class="h-full outline-none"
tabindex="0"
>
<ul v-if="files.length">
<!-- TODO(jess): up arrow and down arrow navigation -->
<li
v-for="file in files"
:key="file?.id"
class="cursor-pointer group children:h-40px children:py-8px last:py-0 flex gap-8px
items-center text-16px last:border-none border-b-1 border-b-gray-50 leading-normal"
data-testid="file-list-row"
@click="$emit('selectFile', file)"
>
<i-cy-document-blank_x16 class="icon-light-gray-50 icon-dark-gray-300 min-w-16px min-h-16px" />
<div class="h-full inline-flex whitespace-nowrap items-center overflow-hidden">
<span
class="font-medium text-indigo-500
group-hocus:text-indigo-500"
>{{
name(file) }}</span>
<span class="font-light text-gray-400">{{ file.fileExtension }}</span>
<span class="ml-20px font-light group-hocus:opacity-60 truncate opacity-0 duration-200 text-gray-600">{{ file.relative }}</span>
</div>
</li>
</ul>
<div
v-else
class="h-full flex items-center justify-center"
>
<slot name="no-results" />
</div>
</div>
</template>
<script setup lang="ts">
import { gql } from '@urql/vue'
import type { FileListItemFragment } from '../../generated/graphql'
gql`
fragment FileListItem on FileParts {
id
relative
fileName
fileExtension
baseName
}
`
const props = defineProps<{
files: FileListItemFragment[]
}>()
defineEmits<{
(eventName: 'selectFile', value: FileListItemFragment)
}>()
// [...all].vue returns as [ when using the normal fileName
const name = (file) => {
return file.baseName.replace(file.fileExtension, '')
}
</script>
@@ -0,0 +1,42 @@
import GeneratorSuccess from './GeneratorSuccess.vue'
import { randomComponents } from '@packages/frontend-shared/cypress/support/mock-graphql/testStubSpecs'
const targetSelector = '[data-testid=file-row]'
const spec = randomComponents(1, 'FileParts')[0]
const content = `
import ${spec.baseName} from './${spec.baseName}'
import { mount } from '@cypress/react'
describe('<${spec.baseName} />', () => {
it('renders', () => {
// https://docs.cypress.io
mount(<${spec.baseName} />)
})
})
`.trim()
describe('<GeneratorSuccess />', () => {
it('renders the relative file path', () => {
cy.mount(() => (<GeneratorSuccess file={{ spec, content }} />))
.get('body')
.contains(spec.relative)
})
it('can be expanded to show the content', () => {
cy.mount(() => (<GeneratorSuccess file={{ spec, content }} />))
.get(targetSelector)
.click()
.get('code .line')
.should('have.length', content.split('\n').length)
.wait(200) // just to show off the animation
.get(targetSelector)
.click()
})
it('handles really long file names and really long content', () => {
const relative = 'src/components/deep/nested/path/to/deep/nested/path/to/component/MyComponent/MyComponent.spec.tsx'
const longContent = Object.keys(Array.from(Array(100))).map((c) => content).join('\n')
cy.mount(() => (<GeneratorSuccess file={{ spec: { ...spec, relative }, content: longContent }} />))
})
})
@@ -0,0 +1,53 @@
<template>
<Collapsible
class="outline-none m-4px rounded overflow-hidden"
>
<template #target="{open}">
<div
class="gap-8px px-24px py-16px flex items-center cursor-pointer"
data-testid="file-row"
>
<i-cy-status-passed-solid_x16 />
<span class="text-jade-500 font-medium truncate">{{ file.spec.relative }}</span>
<div class="justify-self-end flex-grow flex justify-end">
<i-cy-chevron-down-small_x16
:class="{ 'rotate-180': open }"
class="transform transition duration-150 max-w-16px icon-dark-gray-400"
/>
</div>
</div>
</template>
<div class="rounded border-1 mx-24px mb-24px">
<ShikiHighlight
:code="file.content"
line-numbers
lang="js"
:copy-on-click="false"
/>
</div>
</Collapsible>
</template>
<script lang="ts" setup>
import ShikiHighlight from '@cy/components/ShikiHighlight.vue'
import Collapsible from '@cy/components/Collapsible.vue'
import { gql } from '@urql/core'
import type { GeneratorSuccessFragment } from '../../generated/graphql'
gql`
fragment GeneratorSuccess on GeneratedSpec {
content
spec {
fileName
fileExtension
baseName
relative
id
}
}
`
defineProps<{
file: GeneratorSuccessFragment
}>()
</script>
@@ -0,0 +1,6 @@
import type { TestingTypeEnum } from '../../generated/graphql'
export const filters = {
matchesCT: (testingType: TestingTypeEnum) => testingType === 'component',
matchesE2E: (testingType: TestingTypeEnum) => testingType === 'e2e',
}
@@ -0,0 +1,12 @@
import { filters } from '../GeneratorsCommon'
import ComponentGeneratorStepOne from './ComponentGeneratorStepOne.vue'
import type { SpecGenerator } from '../types'
import ComponentGeneratorCard from './ComponentGeneratorCard.vue'
export const ComponentGenerator: SpecGenerator = {
card: ComponentGeneratorCard,
entry: ComponentGeneratorStepOne,
disabled: () => { },
matches: filters.matchesCT,
id: 'component-generator',
}
@@ -0,0 +1,21 @@
<template>
<CreateSpecCard
:disabled="disabled"
:header="t('createSpec.component.importFromComponent.header')"
:description="t('createSpec.component.importFromComponent.description')"
:icon="DocumentCodeIcon"
/>
</template>
<script lang="ts" setup>
import CreateSpecCard from '../../CreateSpecCard.vue'
import { useI18n } from '@cy/i18n'
import DocumentCodeIcon from '~icons/cy/document-code_x48'
const { t } = useI18n()
defineProps<{
disabled: boolean
}>()
</script>
@@ -0,0 +1,157 @@
<template>
<div class="flex-grow">
<div
v-if="mutation.fetching.value"
class="inline-flex items-center w-full justify-center mt-48px"
>
<i-cy-loading_x16 class="animate-spin w-48px h-48px mr-12px" />
<p class="text-lg">
Loading
</p>
</div>
<FileChooser
v-else-if="!result"
v-model:extensionPattern="extensionPattern"
:files="allFiles"
:loading="query.fetching.value"
@selectFile="makeSpec"
/>
<GeneratorSuccess
v-else
:file="result"
/>
</div>
<div>
<div
v-if="!result"
class="rounded-b w-full h-24px"
/>
<StandardModalFooter
v-else
class="h-72px flex gap-16px"
>
<router-link
class="outline-none"
:to="{ path: 'runner', query: { file: result.spec.relative } }
"
>
<Button
:prefix-icon="TestResultsIcon"
prefix-icon-class="w-16px h-16px icon-dark-white"
>
{{ t('createSpec.successPage.runSpecButton') }}
</Button>
</router-link>
<Button
:prefix-icon="PlusButtonIcon"
prefix-icon-class="w-16px h-16px icon-dark-gray-500"
variant="outline"
@click="$emit('restart')"
>
{{ t('createSpec.successPage.createAnotherSpecButton') }}
</Button>
</StandardModalFooter>
</div>
</template>
<script setup lang="ts">
import { useVModels, whenever } from '@vueuse/core'
import { useI18n } from '@cy/i18n'
import FileChooser from '../FileChooser.vue'
import GeneratorSuccess from '../GeneratorSuccess.vue'
import { computed, ref, ComputedRef } from 'vue'
import { gql, useQuery, useMutation } from '@urql/vue'
import { ComponentGeneratorStepOneDocument, ComponentGeneratorStepOne_GenerateSpecDocument } from '../../../generated/graphql'
import StandardModalFooter from '@cy/components/StandardModalFooter.vue'
import Button from '@cy/components/Button.vue'
import PlusButtonIcon from '~icons/cy/add-large_x16.svg'
import TestResultsIcon from '~icons/cy/test-results_x24.svg'
const props = defineProps<{
title: string,
codeGenGlob: any
}>()
const { t } = useI18n()
const emits = defineEmits<{
(event: 'update:title', value: string): void,
(event: 'update:description', value: string): void
(event: 'restart'): void
}>()
const { title } = useVModels(props, emits)
title.value = t('createSpec.component.importFromComponent.chooseAComponentHeader')
gql`
fragment ComponentGeneratorStepOne_codeGenGlob on Project {
codeGenGlob(type: component)
}
`
gql`
query ComponentGeneratorStepOne($glob: String!) {
app {
activeProject {
id
codeGenCandidates(glob: $glob) {
id
name
fileName
fileExtension
absolute
relative
baseName
}
}
}
}
`
gql`
mutation ComponentGeneratorStepOne_generateSpec($codeGenCandidate: String!, $type: CodeGenType!) {
generateSpecFromSource(codeGenCandidate: $codeGenCandidate, type: $type) {
...GeneratorSuccess
}
}`
const mutation = useMutation(ComponentGeneratorStepOne_GenerateSpecDocument)
const extensionPattern = ref(props.codeGenGlob)
const glob = computed(() => {
return `**/${extensionPattern.value}`
})
const query = useQuery({
query: ComponentGeneratorStepOneDocument,
// @ts-ignore
variables: { glob },
})
const allFiles = computed((): any => {
if (query.data.value?.app?.activeProject?.codeGenCandidates) {
return query.data.value.app?.activeProject?.codeGenCandidates
}
return []
})
const result = ref()
whenever(result, () => {
title.value = t('createSpec.successPage.header')
})
const makeSpec = async (file) => {
const { data } = await mutation.executeMutation({
codeGenCandidate: file.relative,
type: 'component',
})
result.value = data?.generateSpecFromSource
}
</script>
@@ -0,0 +1 @@
export * from './ComponentGenerator'
@@ -0,0 +1,11 @@
import type { SpecGenerator } from '../types'
import { filters } from '../GeneratorsCommon'
import EmptyGeneratorCard from './EmptyGeneratorCard.vue'
export const EmptyGenerator: SpecGenerator = {
card: EmptyGeneratorCard,
entry: EmptyGeneratorCard,
matches: filters.matchesE2E,
disabled: () => { },
id: 'empty-generator',
}
@@ -0,0 +1,21 @@
<template>
<CreateSpecCard
:disabled="disabled"
:header="t('createSpec.e2e.importEmptySpec.header')"
:description="t('createSpec.e2e.importEmptySpec.description')"
:icon="DocumentCodeIcon"
/>
</template>
<script lang="ts" setup>
import CreateSpecCard from '../../CreateSpecCard.vue'
import { useI18n } from '@cy/i18n'
import DocumentCodeIcon from '~icons/cy/document-code_x48'
const { t } = useI18n()
defineProps<{
disabled: boolean
}>()
</script>
@@ -0,0 +1 @@
export * from './EmptyGenerator'
@@ -0,0 +1,27 @@
import { keyBy } from 'lodash'
import type { SpecGenerator, GeneratorId } from './types'
import { ComponentGenerator } from './component'
import { StoryGenerator } from './story'
import { ScaffoldGenerator } from './scaffold'
import { EmptyGenerator } from './empty'
export * from './types'
export * from './GeneratorsCommon'
export * from './component'
export * from './story'
export * from './scaffold'
export * from './empty'
export const generatorList: SpecGenerator[] = [
ComponentGenerator,
StoryGenerator,
ScaffoldGenerator,
EmptyGenerator,
]
export const generators = keyBy(generatorList, 'id') as Record<GeneratorId, SpecGenerator>
@@ -0,0 +1,11 @@
import type { SpecGenerator } from '../types'
import { filters } from '../GeneratorsCommon'
import ScaffoldGeneratorCard from './ScaffoldGeneratorCard.vue'
export const ScaffoldGenerator: SpecGenerator = {
card: ScaffoldGeneratorCard,
entry: ScaffoldGeneratorCard,
matches: filters.matchesE2E,
disabled: () => { },
id: 'scaffold-generator',
}
@@ -0,0 +1,21 @@
<template>
<CreateSpecCard
:disabled="disabled"
:header="t('createSpec.e2e.importFromScaffold.header')"
:description="t('createSpec.e2e.importFromScaffold.description')"
:icon="BoxOpenIcon"
/>
</template>
<script lang="ts" setup>
import CreateSpecCard from '../../CreateSpecCard.vue'
import { useI18n } from '@cy/i18n'
import BoxOpenIcon from '~icons/cy/box-open_x48'
const { t } = useI18n()
defineProps<{
disabled: boolean
}>()
</script>
@@ -0,0 +1 @@
export * from './ScaffoldGenerator'
@@ -0,0 +1,18 @@
import type { SpecGenerator } from '../types'
import { filters } from '../GeneratorsCommon'
import StoryGeneratorStepOne from './StoryGeneratorStepOne.vue'
import StoryGeneratorCard from './StoryGeneratorCard.vue'
export const StoryGenerator: SpecGenerator = {
card: StoryGeneratorCard,
entry: StoryGeneratorStepOne,
matches: filters.matchesCT,
disabled: (activeProject) => {
if (activeProject) {
return !activeProject.storybook
}
return false
},
id: 'story-generator',
}
@@ -0,0 +1,23 @@
<template>
<CreateSpecCard
:disabled="disabled"
:header="t('createSpec.component.importFromStory.header')"
:description="disabled ?
t('createSpec.component.importFromStory.notSetupDescription') :
t('createSpec.component.importFromStory.description')"
:icon="BookCodeIcon"
/>
</template>
<script lang="ts" setup>
import CreateSpecCard from '../../CreateSpecCard.vue'
import { useI18n } from '@cy/i18n'
import BookCodeIcon from '~icons/cy/book-code_x48'
const { t } = useI18n()
defineProps<{
disabled: boolean
}>()
</script>
@@ -0,0 +1,157 @@
<template>
<div class="flex-grow">
<div
v-if="mutation.fetching.value"
class="inline-flex items-center w-full justify-center mt-48px"
>
<i-cy-loading_x16 class="animate-spin w-48px h-48px mr-12px" />
<p class="text-lg">
Loading
</p>
</div>
<FileChooser
v-else-if="!result"
v-model:extensionPattern="extensionPattern"
:files="allFiles || []"
:loading="query.fetching.value"
@selectFile="makeSpec"
/>
<GeneratorSuccess
v-else
:file="result"
/>
</div>
<div>
<div
v-if="!result"
class="rounded-b w-full h-24px"
/>
<StandardModalFooter
v-else
class="h-72px flex gap-16px"
>
<router-link
class="outline-none"
:to="{ path: 'runner', query: { file: result.spec.relative } }
"
>
<Button
:prefix-icon="TestResultsIcon"
prefix-icon-class="w-16px h-16px icon-dark-white"
>
{{ t('createSpec.successPage.runSpecButton') }}
</Button>
</router-link>
<Button
:prefix-icon="PlusButtonIcon"
prefix-icon-class="w-16px h-16px icon-dark-gray-500"
variant="outline"
@click="$emit('restart')"
>
{{ t('createSpec.successPage.createAnotherSpecButton') }}
</Button>
</StandardModalFooter>
</div>
</template>
<script setup lang="ts">
import { useVModels, whenever } from '@vueuse/core'
import { useI18n } from '@cy/i18n'
import FileChooser from '../FileChooser.vue'
import GeneratorSuccess from '../GeneratorSuccess.vue'
import { computed, ref } from 'vue'
import { gql, useQuery, useMutation } from '@urql/vue'
import { StoryGeneratorStepOneDocument, StoryGeneratorStepOne_GenerateSpecDocument } from '../../../generated/graphql'
import StandardModalFooter from '@cy/components/StandardModalFooter.vue'
import Button from '@cy/components/Button.vue'
import PlusButtonIcon from '~icons/cy/add-large_x16.svg'
import TestResultsIcon from '~icons/cy/test-results_x24.svg'
const props = defineProps<{
title: string,
codeGenGlob: any
}>()
const { t } = useI18n()
const emits = defineEmits<{
(event: 'update:title', value: string): void,
(event: 'update:description', value: string): void
(event: 'restart'): void
}>()
const { title } = useVModels(props, emits)
title.value = t('createSpec.component.importFromStory.header')
gql`
fragment StoryGeneratorStepOne_codeGenGlob on Project {
codeGenGlob(type: story)
}
`
gql`
query StoryGeneratorStepOne($glob: String!) {
app {
activeProject {
id
codeGenCandidates(glob: $glob) {
id
name
fileName
fileExtension
absolute
relative
baseName
}
}
}
}
`
gql`
mutation StoryGeneratorStepOne_generateSpec($codeGenCandidate: String!, $type: CodeGenType!) {
generateSpecFromSource(codeGenCandidate: $codeGenCandidate, type: $type) {
...GeneratorSuccess
}
}`
const mutation = useMutation(StoryGeneratorStepOne_GenerateSpecDocument)
const extensionPattern = ref(props.codeGenGlob)
const glob = computed(() => {
return `**/${extensionPattern.value}`
})
const query = useQuery({
query: StoryGeneratorStepOneDocument,
// @ts-ignore
variables: { glob },
})
const allFiles = computed(() => {
if (query.data.value?.app?.activeProject?.codeGenCandidates) {
return query.data.value.app?.activeProject?.codeGenCandidates
}
return []
}) as any
const result = ref()
whenever(result, () => {
title.value = t('createSpec.successPage.header')
})
const makeSpec = async (file) => {
const { data } = await mutation.executeMutation({
codeGenCandidate: file.relative,
type: 'component',
})
result.value = data?.generateSpecFromSource
}
</script>
@@ -0,0 +1 @@
export * from './StoryGenerator'
@@ -0,0 +1,11 @@
import type { Component } from 'vue'
export type GeneratorId = 'component-generator' | 'empty-generator' | 'scaffold-generator' | 'story-generator'
export interface SpecGenerator {
card: Component
entry: Component
matches: (testingType) => boolean
disabled: (activeProject?) => boolean | void
id: GeneratorId
}
-2
View File
@@ -3,8 +3,6 @@ import { createPinia as _createPinia } from 'pinia'
// Pinia client-side storage
export * from './main-store'
export * from './modals'
export * from './specs-store'
export * from './aut-store'
-26
View File
@@ -1,26 +0,0 @@
import { defineStore } from 'pinia'
export type ModalTypes = 'createSpec'
export interface ModalStore {
activeModalId: ModalTypes | null
}
/**
* Modals Store allows parts of the application to open and close modals in
* unrelated parts of the application. This is useful for resolving
* conflicts between timing-based and interaction-based triggers (like the
* Growth Modals and the Create Spec modals)
*/
export const useModalStore = defineStore({
id: 'modal',
state: (): ModalStore => ({ activeModalId: null }),
actions: {
open (id: ModalTypes) {
this.activeModalId = id
},
close () {
this.activeModalId = null
},
},
})
@@ -65,7 +65,6 @@ export class ProjectActions {
title,
ctPluginsInitialized: false,
e2ePluginsInitialized: false,
generatedSpec: null,
config: null,
configChildProcess: null,
})
@@ -371,7 +370,7 @@ export class ProjectActions {
specFileExtension,
})
project.generatedSpec = {
return {
spec,
content: newSpec.content,
}
@@ -1,4 +1,4 @@
import { BUNDLERS, FoundBrowser, FoundSpec, FullConfig, GeneratedSpec, Preferences } from '@packages/types'
import { BUNDLERS, FoundBrowser, FoundSpec, FullConfig, Preferences } from '@packages/types'
import type { NexusGenEnums, TestingTypeEnum } from '@packages/graphql/src/gen/nxs.gen'
import type { BrowserWindow } from 'electron'
import type { ChildProcess } from 'child_process'
@@ -35,7 +35,6 @@ export interface ActiveProjectShape extends ProjectShape {
config: Promise<FullConfig> | null
configChildProcess: ConfigChildProcessShape | null
preferences?: Preferences| null
generatedSpec: GeneratedSpec | null
}
export interface AppDataShape {
@@ -33,7 +33,7 @@ export class FileDataSource {
}) as Promise<Result>
}
normalizeFileToFileParts (options: CreateFileParts): SpecFile {
normalizeFileToFileParts (options: CreateFileParts): SpecFile & { fileExtension: string } {
const parsed = path.parse(options.absolute)
return {
@@ -42,6 +42,7 @@ export class FileDataSource {
relative: path.relative(options.projectRoot, options.absolute),
baseName: parsed.base,
fileName: parsed.base.split('.')[0] || '',
fileExtension: parsed.ext,
}
}
@@ -54,11 +55,11 @@ export class FileDataSource {
}
}
async getFilesByGlob (glob: string | string[], globOptions?: GlobbyOptions) {
async getFilesByGlob (cwd: string, glob: string | string[], globOptions?: GlobbyOptions) {
const globs = (Array.isArray(glob) ? glob : [glob]).concat('!**/node_modules/**')
try {
const files = await globby(globs, { onlyFiles: true, absolute: true, ...globOptions })
const files = await globby(globs, { onlyFiles: true, absolute: true, cwd, ...globOptions })
return files
} catch (e) {
@@ -5,8 +5,13 @@ import type { GraphQLSchema } from 'graphql'
import type { DataContextShell } from '../DataContextShell'
import type * as allOperations from '../gen/all-operations.gen'
// Filter out non-Query shapes
type AllQueries<T> = {
[K in keyof T]: K
[K in keyof T]: T[K] extends { __resultType?: infer U }
? U extends { __typename?: 'Query' }
? K
: never
: never
}[keyof T]
export class GraphQLDataSource {
@@ -28,6 +33,10 @@ export class GraphQLDataSource {
// Late require'd to avoid erroring if codegen hasn't run (for legacy Cypress workflow)
const allQueries = (this._allQueries ??= require('../gen/all-operations.gen'))
if (!allQueries[document]) {
throw new Error(`Trying to execute unknown operation ${document}, needs to be one of: [${Object.keys(allQueries).join(', ')}]`)
}
return this._urqlClient.query(allQueries[document], variables).toPromise()
}
@@ -14,7 +14,6 @@ export class HtmlDataSource {
await Promise.all([
graphql.executeQuery('AppQueryDocument', {}),
graphql.executeQuery('NewSpec_NewSpecQueryDocument', {}),
graphql.executeQuery('SettingsDocument', {}),
graphql.executeQuery('SpecsPageContainerDocument', {}),
graphql.executeQuery('HeaderBar_HeaderBarQueryDocument', {}),
@@ -1,5 +1,5 @@
import type { CodeGenType, SpecType } from '@packages/graphql/src/gen/nxs.gen'
import { FrontendFramework, FRONTEND_FRAMEWORKS, ResolvedFromConfig, RESOLVED_FROM, SpecFile, STORYBOOK_GLOB } from '@packages/types'
import { FrontendFramework, FRONTEND_FRAMEWORKS, ResolvedFromConfig, RESOLVED_FROM, SpecFileWithExtension, STORYBOOK_GLOB } from '@packages/types'
import { scanFSForAvailableDependency } from 'create-cypress-tests'
import path from 'path'
@@ -146,7 +146,7 @@ export class ProjectDataSource {
return framework?.glob ?? looseComponentGlob
}
async getCodeGenCandidates (glob: string): Promise<SpecFile[]> {
async getCodeGenCandidates (glob: string): Promise<SpecFileWithExtension[]> {
// Storybook can support multiple globs, so show default one while
// still fetching all stories
if (glob === STORYBOOK_GLOB) {
@@ -161,7 +161,7 @@ export class ProjectDataSource {
const config = await this.ctx.project.getConfig(project.projectRoot)
const codeGenCandidates = await this.ctx.file.getFilesByGlob(glob)
const codeGenCandidates = await this.ctx.file.getFilesByGlob(config.projectRoot || process.cwd(), glob)
return codeGenCandidates.map(
(file) => {
@@ -1,4 +1,4 @@
import type { SpecFile, StorybookInfo } from '@packages/types'
import type { SpecFileWithExtension, StorybookInfo } from '@packages/types'
import * as path from 'path'
import type { DataContext } from '..'
@@ -20,7 +20,7 @@ export class StorybookDataSource {
return this.storybookInfoLoader.load(this.ctx.activeProject?.projectRoot)
}
async getStories (): Promise<SpecFile[]> {
async getStories (): Promise<SpecFileWithExtension[]> {
const project = this.ctx.activeProject
if (!project) {
@@ -35,7 +35,7 @@ export class StorybookDataSource {
const config = await this.ctx.project.getConfig(project.projectRoot)
const normalizedGlobs = storybook.storyGlobs.map((glob) => path.join(storybook.storybookRoot, glob))
const files = await this.ctx.file.getFilesByGlob(normalizedGlobs)
const files = await this.ctx.file.getFilesByGlob(project.projectRoot, normalizedGlobs)
// Don't currently support mdx
return files.reduce((acc, file) => {
@@ -50,7 +50,7 @@ export class StorybookDataSource {
})
return [...acc, spec]
}, [] as SpecFile[])
}, [] as SpecFileWithExtension[])
}
private storybookInfoLoader = this.ctx.loader<string, StorybookInfo | null>((projectRoots) => this.batchStorybookInfo(projectRoots))
+1
View File
@@ -0,0 +1 @@
{}
@@ -18,12 +18,14 @@ interface RuleConfig {
const makeRuleForClass = ({ name, theme, weight, color }: RuleConfig) => {
const resolvedColor = color ? color : weight ? theme?.(`colors.${name}.${weight}`) : theme?.(`colors.${name}`)
let [lightKey, darkKey] = [`.icon-light-${name}`, `.icon-dark-${name}`]
let [lightKey, darkKey, secondaryLightKey, secondaryDarkKey] = [`.icon-light-${name}`, `.icon-dark-${name}`, `.icon-light-secondary-${name}`, `.icon-dark-secondary-${name}`]
// transparent, black, and white
if (weight) {
lightKey += `-${weight}`
darkKey += `-${weight}`
secondaryLightKey += `-${weight}`
secondaryDarkKey += `-${weight}`
}
return {
@@ -40,18 +42,49 @@ const makeRuleForClass = ({ name, theme, weight, color }: RuleConfig) => {
'> *[fill][stroke].icon-light-fill': {
fill: resolvedColor,
},
'> *[fill][stroke].icon-light-stroke': {
stroke: resolvedColor,
},
},
[darkKey]: {
'> *[fill].icon-dark-fill': {
[secondaryLightKey]: {
'> *[fill].icon-light-secondary': {
fill: resolvedColor,
},
'> *[stroke].icon-light-secondary': {
stroke: resolvedColor,
},
'> *[fill][stroke].icon-light-secondary-fill': {
fill: resolvedColor,
},
'> *[fill][stroke].icon-light-secondary-stroke': {
stroke: resolvedColor,
},
},
[darkKey]: {
'> *[fill].icon-dark': {
fill: resolvedColor,
},
'> *[stroke].icon-dark': {
stroke: resolvedColor,
},
'> *[fill][stroke].icon-dark-stroke': {
'> *[fill].icon-dark-fill': {
fill: resolvedColor,
},
'> *[fill].icon-dark-stroke': {
stroke: resolvedColor,
},
},
[secondaryDarkKey]: {
'> *[fill].icon-dark-secondary': {
fill: resolvedColor,
},
'> *[stroke].icon-dark-secondary': {
stroke: resolvedColor,
},
'> *[fill][stroke].icon-dark-secondary-fill': {
fill: resolvedColor,
},
'> *[fill][stroke].icon-dark-secondary-stroke': {
stroke: resolvedColor,
},
},
@@ -0,0 +1,22 @@
/**
* Our default hover/focus behavior for buttons and cards is an indigo
* border that hovers in and out
* Animations not working? Border looking a little off? Make sure that you
* have border-1 set on the non-hocus state. If you *don't* want a gray
* outline with that, do border-transparent for the non-hocus state.
*/
const focusDefault = 'outline-none focus:border focus:border-indigo-300 focus:ring-2 focus:ring-indigo-100 focus:outline-transparent transition duration-150 disabled:hover:ring-0 disabled:hover:border-0'
// Usually what you want
const hocusDefault = focusDefault.replace(/focus:/g, 'hocus:')
// If you want to control a parent card when an inner button is in focus
const focusWithinDefault = focusDefault.replace(/focus:/g, 'focus-within:')
export const shortcuts = {
'hocus-default': hocusDefault,
'focus-within-default': focusWithinDefault,
'focus-default': focusDefault,
'hocus-link-default': 'focus:outline-transparent hocus:underline',
}
@@ -69,12 +69,12 @@ export const stubMutation: MaybeResolver<Mutation> = {
setProjectPreferences (source, args, ctx) {
return ctx.app
},
codeGenSpec (source, args, ctx) {
generateSpecFromSource (source, args, ctx) {
if (!ctx.app.activeProject) {
throw Error('Cannot set currentSpec without active project')
}
ctx.app.activeProject.generatedSpec = {
return {
__typename: 'GeneratedSpec',
spec: {
__typename: 'FileParts',
@@ -84,12 +84,11 @@ export const stubMutation: MaybeResolver<Mutation> = {
name: 'Basic',
fileName: 'Basic.spec.tsx',
baseName: 'Basic',
fileExtension: 'tsx',
},
content: `it('should do stuff', () => {})`,
id: 'U3BlYzovVXNlcnMvbGFjaGxhbi9jb2RlL3dvcmsvY3lwcmVzczUvcGFja2FnZXMvYXBwL3NyYy9CYXNpYy5zcGVjLnRzeA==',
}
return true
},
reconfigureProject (src, args, ctx) {
return true
@@ -28,14 +28,13 @@ export const createTestProject = (title: string): CodegenTypeMap['Project'] => {
},
__typename: 'SpecConnection' as const,
edges: [
...randomComponents(200).map((c) => {
...randomComponents(200, 'Spec').map((c) => {
return {
__typename: 'SpecEdge' as const,
cursor: 'eoifjew',
node: {
id: c.absolute,
__typename: 'Spec' as const,
...c,
id: c.absolute,
},
}
}),
@@ -1,8 +1,7 @@
import * as JustMyLuck from 'just-my-luck'
import faker from 'faker'
import { template, keys, reduce, templateSettings } from 'lodash'
import { template, keys, reduce, templateSettings, TemplateExecutor } from 'lodash'
import combineProperties from 'combine-properties'
import type { FoundSpec } from '@packages/types'
templateSettings.interpolate = /{{([\s\S]+?)}}/g
@@ -88,11 +87,17 @@ const nameTemplates = {
medium1: template(`{{prefix}}{{modifier}}{{component}}`),
medium2: template(`{{prefix}}{{component}}{{component2}}`),
short: template(`{{prefix}}{{component}}`),
}
} as const
const prefixes = ['I', 'V', 'Cy', null]
export const componentNameGenerator = (options: { template: any, omit?: any, overrides?: any } = { template: nameTemplates.medium1, omit: [], overrides: {} }) => {
interface ComponentNameGeneratorOptions {
template: TemplateExecutor
omit?: string[]
overrides?: object
}
export const componentNameGenerator = (options: ComponentNameGeneratorOptions = { template: nameTemplates.medium1, omit: [], overrides: {} }) => {
const withoutValues = reduce(options.omit, (acc, v) => {
acc[v] = null
@@ -126,11 +131,11 @@ const allRandomComponents = combineProperties({
directory: keys(directories),
})
export const randomComponents = (n = 200): FoundSpec[] => {
export const randomComponents = <T extends 'Spec' | 'FileParts'>(n = 200, baseTypename: T) => {
return faker.random.arrayElements(allRandomComponents, n).map((d: ReturnType<typeof combineProperties>) => {
const componentName = componentNameGenerator({
overrides: d,
template: faker.random.objectElement(nameTemplates),
template: faker.random.objectElement<TemplateExecutor>(nameTemplates),
})
const name = `${componentName}${d.specPattern}${d.fileExtension}`
@@ -142,13 +147,12 @@ export const randomComponents = (n = 200): FoundSpec[] => {
name: `${componentName}${d.specPattern}`,
specFileExtension: `${d.specPattern}${d.fileExtension}`,
fileExtension: d.fileExtension,
specType: 'component',
specType: 'component' as const,
fileName: componentName,
__typename: 'Spec',
__typename: baseTypename,
id: faker.datatype.uuid(),
gitInfo: {
__typename: 'GitInfo',
__typename: 'GitInfo' as const,
id: faker.datatype.uuid(),
author: faker.internet.userName(),
lastModifiedTimestamp: new Date(faker.random.arrayElement([
@@ -0,0 +1,14 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M40 5H10C8.89543 5 8 5.89543 8 7V35H10H39H40V5Z" fill="#D0D2E0" class="icon-light"/>
<path d="M40 43C40 43 39.3905 43 39 43M8 35V41C8 42.1046 8.89543 43 10 43V43M8 35V7C8 5.89543 8.89543 5 10 5V5M8 35H10M10 5H40V35H39M10 5V35M10 35V43M10 35H39M10 43C10 43 27.6748 43 39 43M39 35C39 35 38 39 39 43M21 17L18 20L21 23M29 17L32 20L29 23M23.5 25.5L26.5 14.5" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14 39H22V47L18 45.2857L14 47V39Z" fill="#69D3A7" class="icon-light-secondary"/>
<path d="M14 39V38H13V39H14ZM22 39H23V38H22V39ZM22 47L21.6061
47.9191C21.915 48.0516 22.2699 48.0199 22.5505 47.8348C22.8311 47.6498 23
47.3361 23 47H22ZM14 47H13C13 47.3361 13.1689 47.6498 13.4495
47.8348C13.7301 48.0199 14.085 48.0516 14.3939 47.9191L14 47ZM18
45.2857L18.3939 44.3666C18.1424 44.2588 17.8576 44.2588 17.6061 44.3666L18
45.2857ZM14 40H22V38H14V40ZM21 39V47H23V39H21ZM15 47V39H13V47H15ZM22.3939
46.0809L18.3939 44.3666L17.6061 46.2049L21.6061 47.9191L22.3939
46.0809ZM17.6061 44.3666L13.6061 46.0809L14.3939 47.9191L18.3939
46.2049L17.6061 44.3666Z" class="icon-dark-secondary" fill="#00814D"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,19 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M43 25.1622L28.5 33L24
28.4872V47L43 37.2564V25.1622Z" fill="#D0D2E0" class="icon-light"/>
<path d="M24 9L5 18.7436L24 28.4872L43 18.7436L24 9Z" fill="#D0D2E0" class="icon-light"/>
<path d="M24 47L5 37.2564V25.1622M24 47L43 37.2564V25.1622M24 47V28.4872M24
9L5 18.7436M24 9L43 18.7436M24 9L28 5L47 14.5L43 18.7436M24 9L20 5L1 14.5L5
18.7436M5 18.7436L24 28.4872M5 18.7436L1 23L5 25.1622M43 18.7436L24
28.4872M43 18.7436L47 23L43 25.1622M24 28.4872L19.5 33L5 25.1622M24
28.4872L28.5 33L43 25.1622" stroke="#1B1E2E" class="icon-dark" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"/>
<path d="M18 7L19.1314 9.86863L22 11L19.1314 12.1314L18 15L16.8686
12.1314L14 11L16.8686 9.86863L18 7Z" fill="#1FA971" stroke="#00814D" class="icon-dark-secondary-stroke icon-light-secondary-fill" stroke-width="2" stroke-linejoin="round"/>
<path d="M27 1L27.8485 3.15147L30 4L27.8485 4.84853L27 7L26.1515 4.84853L24
4L26.1515 3.15147L27 1Z" fill="#1FA971" stroke="#00814D" class="icon-dark-secondary-stroke icon-light-secondary-fill" stroke-width="2"
stroke-linejoin="round"/>
<path d="M28 12L29.4142 15.5858L33 17L29.4142 18.4142L28 22L26.5858
18.4142L23 17L26.5858 15.5858L28 12Z" fill="#1FA971" stroke="#00814D"
class="icon-dark-secondary-stroke icon-light-secondary-fill" stroke-width="2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,22 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 16.4116V7.58843C21 7.40548 20.95 7.22977 20.8599 7.07789L12
12V21.8561C12.1673 21.8561 12.3346 21.8141 12.4856 21.7302L20.4856
17.2858C20.8031 17.1094 21 16.7748 21 16.4116Z" fill="#D0D2E0" class="icon-light"/>
<path d="M3.51436 17.2858L11.5144 21.7302C11.6654 21.8141 11.8327 21.8561
12 21.8561V12L3.14014 7.07789C3.04997 7.22977 3 7.40548 3 7.58843V16.4116C3
16.7748 3.19689 17.1094 3.51436 17.2858Z" fill="#D0D2E0" class="icon-light"/>
<path d="M11.5144 2.26982L3.51436 6.71427C3.35682 6.80179 3.22897 6.92828
3.14014 7.07789L12 12L20.8599 7.07789C20.771 6.92828 20.6432 6.80179
20.4856 6.71427L12.4856 2.26982C12.1836 2.10203 11.8164 2.10203 11.5144
2.26982Z" fill="#D0D2E0" class="icon-light"/>
<path d="M12 12L20.8599 7.07789M12 12V21.8561M12 12L3.14014 7.07789M20.8599
7.07789C20.95 7.22977 21 7.40548 21 7.58843V16.4116C21 16.7748 20.8031
17.1094 20.4856 17.2858L12.4856 21.7302C12.3346 21.8141 12.1673 21.8561 12
21.8561M20.8599 7.07789C20.771 6.92828 20.6432 6.80179 20.4856
6.71427L12.4856 2.26982C12.1836 2.10203 11.8164 2.10203 11.5144
2.26982L3.51436 6.71427C3.35682 6.80179 3.22897 6.92828 3.14014 7.07789M12
21.8561C11.8327 21.8561 11.6654 21.8141 11.5144 21.7302L3.51436
17.2858C3.19689 17.1094 3 16.7748 3 16.4116V7.58843C3 7.40548 3.04997
7.22977 3.14014 7.07789" stroke="#1B1E2E" stroke-width="2"
stroke-linecap="round" class="icon-dark" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 9L8 12L11 9M5 4L8 7L11 4" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 242 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 5L8 11L14 5" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6L8 10L12 6" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

@@ -0,0 +1,3 @@
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 2.5L4 5.5L7 2.5" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 11L4 8L7 5M12 11L9 8L12 5" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.9999 13.9999L4.99994 7.99988L10.9999 1.99988" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 263 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 4L6 8L10 12" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 230 B

@@ -0,0 +1,3 @@
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 1L2.5 4L5.5 7" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 11L12 8L9 5M4 11L7 8L4 5" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 242 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.99994 13.9999L10.9999 7.99988L4.99994 1.99988" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 263 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 12L10 8L6 4" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

@@ -0,0 +1,3 @@
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 1L5.5 4L2.5 7" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 7L8 4L11 7M5 12L8 9L11 12" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 11L8 5L14 11" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 230 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.9999 9.99988L7.99991 5.99988L3.99991 9.99988" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 263 B

@@ -0,0 +1,3 @@
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 5.5L4 2.5L7 5.5" stroke="#1B1E2E" class="icon-dark" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

@@ -0,0 +1,18 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M32
5.17964V13H39.8204C39.7221 12.784 39.5852 12.5852 39.4142 12.4142L32.5858
5.58578C32.4148 5.41477 32.216 5.27792 32 5.17964Z" fill="#D0D2E0" class="icon-light"/>
<path d="M20 21L17 24L20 27M28 21L31 24L28 27M22.5 29.5L25.5 18.5M32
5.17964C31.7423 5.06237 31.4602 5 31.1716 5H9C8.44772 5 8 5.44772 8 6V42C8
42.5523 8.44772 43 9 43H39C39.5523 43 40 42.5523 40 42V13.8284C40 13.5398
39.9376 13.2577 39.8204 13M32 5.17964C32.216 5.27792 32.4148 5.41477
32.5858 5.58578L39.4142 12.4142C39.5852 12.5852 39.7221 12.784 39.8204
13M32 5.17964V13H39.8204" stroke="#1B1E2E" class="icon-dark"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M43 40C43 43.3137 40.3137 46 37 46C33.6863 46 31 43.3137 31 40C31
36.6863 33.6863 34 37 34C40.3137 34 43 36.6863 43 40Z" class="icon-light-secondary" fill="#A3E7CB"/>
<path d="M37 38V40M37 42V40M37 40H39H35M43 40C43 43.3137 40.3137 46 37
46C33.6863 46 31 43.3137 31 40C31 36.6863 33.6863 34 37 34C40.3137 34 43
36.6863 43 40Z" stroke="#00814D" class="icon-dark-secondary"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7" fill="#1FA971" class="icon-dark"/>
<path d="M10 6L8 10L6 8" stroke="white" stroke-width="2"
stroke-linecap="round" class="icon-light" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 291 B

@@ -2,7 +2,7 @@
<button
v-if="!href"
style="width: fit-content"
class="flex select-none items-center border rounded gap-8px outline-none"
class="flex items-center leading-tight border rounded gap-8px outline-none"
:class="classes"
>
<ButtonInternals>
@@ -25,6 +25,7 @@
</div>
<input
v-model="localValue"
:style="style"
:type="type"
:spellcheck="false"
:class="_inputClasses"
@@ -79,6 +80,7 @@ const props = withDefaults(defineProps<{
suffixIcon?: FunctionalComponent<SVGAttributes, {}>
suffixIconClasses?: string | string[] | Record<string, string>
modelValue?: string
style?: string
}>(), {
type: 'text',
modelValue: '',
@@ -1,17 +1,22 @@
<template>
<div
v-if="search"
v-if="search || emptySearch"
data-testid="no-results"
class="text-center"
>
<NoResultsIllustration
class="mx-auto mt-80px"
class="mx-auto"
alt
/>
<p class="text-gray-500 text-18px leading-normal">
{{ message || t('noResults.defaultMessage') }}:
<span class="block truncate text-purple-500">{{ search }}</span>
{{ message || t('noResults.defaultMessage') }}
<span
v-if="search"
class="block truncate text-purple-500"
>{{ search }}</span>
</p>
<Button
data-testid="no-results-clear"
class="mx-auto mt-20px"
size="lg"
variant="outline"
@@ -20,7 +25,7 @@
<template #prefix>
<i-cy-delete_x12 class="w-12px icon-dark-gray-400" />
</template>
Clear Search
{{ t('noResults.clearSearch') }}
</Button>
</div>
</template>
@@ -33,6 +38,7 @@ import NoResultsIllustration from '../assets/illustrations/no-results.svg'
defineProps<{
search?: string,
message?: string
emptySearch?: boolean
}>()
const emit = defineEmits<{
@@ -54,14 +54,15 @@ shikiWrapperClasses computed property.
@click="copyOnClick ? () => copyCode() : () => {}"
v-html="highlightedCode"
/>
<CopyButton
v-if="copyButton"
<Button
v-if="copyOnClick"
variant="outline"
tabindex="-1"
class="absolute bottom-8px right-8px"
:text="code"
no-icon
/>
@click="copyCode"
>
{{ copied ? t('clipboard.copied') : t('clipboard.copy') }}
</Button>
</div>
</template>
@@ -98,10 +99,11 @@ export { highlighter, inheritAttrs }
<script lang="ts" setup>
import { computed, onBeforeMount, ref } from 'vue'
import CopyButton from '@cy/components/CopyButton.vue'
import Button from '@cy/components/Button.vue'
// eslint-disable-next-line no-duplicate-imports
import type { Ref } from 'vue'
import { useClipboard } from '@vueuse/core'
import { useI18n } from '@cy/i18n'
const highlighterInitialized = ref(false)
@@ -128,6 +130,8 @@ const props = withDefaults(defineProps<{
class: undefined,
})
const { t } = useI18n()
const resolvedLang = computed(() => {
if (props.lang === 'javascript' || props.lang === 'js' || props.lang === 'jsx') return 'jsx'
@@ -165,7 +169,7 @@ avoid colliding with styles elsewhere in the document.
<style lang="scss" scoped>
$offset: 1em;
$offset: 1.1em;
.inline:deep(.shiki) {
@apply py-1 px-2 bg-gray-50 text-gray-500 inline-block;
@@ -181,13 +185,16 @@ $offset: 1em;
}
&.line-numbers:deep(.shiki) {
.line {
}
code {
counter-reset: step;
counter-increment: step 0;
// Keep bg-gray-50 synced with the box-shadows.
.line::before, .line:first-child::before {
@apply bg-gray-50 text-gray-500 min-w-40px inline-block text-right px-8px mr-16px sticky;
@apply bg-gray-50 text-gray-500 min-w-40px inline-block text-right px-8px mr-16px sticky group-hocus:bg-red-500;
left: 0px !important;
content: counter(step);
counter-increment: step;
@@ -196,12 +203,19 @@ $offset: 1em;
// Adding padding to the top and bottom of these children adds unwanted
// line-height to the line. This doesn't look good when you select the text.
// To avoid this, we use box-shadows and offset the parent container.
.line:first-child::before {
box-shadow: 0 (-1 * $offset) theme('colors.gray.50') !important;
:not(.line:only-child) {
&:first-child:before {
box-shadow: 0 (-1 * $offset) theme('colors.gray.50') !important;
}
&:last-child::before {
box-shadow: 0 $offset theme('colors.gray.50') !important;
}
}
.line:last-child::before {
box-shadow: 0 $offset theme('colors.gray.50') !important;
// If this rule was used for all of them, the gray would overlap between rows.
.line:only-child::before {
box-shadow: (-1 * $offset) 0 0 $offset theme('colors.gray.50') !important;
}
}
}
@@ -1,6 +1,6 @@
<template>
<Dialog
:open="localValue"
:open="modelValue"
class="fixed inset-0 z-10 overflow-y-auto"
@close="clickOutside && setIsOpen(false)"
>
@@ -11,31 +11,19 @@
>
<DialogOverlay class="fixed inset-0 bg-gray-800 opacity-90" />
</slot>
<div
class="relative mx-auto bg-white rounded"
:class="props.class || ''"
>
<div
class="sticky top-0 flex items-center justify-between bg-white rounded-t border-b-1px border-b-gray-100 min-h-56px px-24px z-1"
<StandardModalHeader
:help-link="helpLink"
:help-text="helpText"
@close="setIsOpen(false)"
>
<DialogTitle class="text-gray-900 text-18px">
<slot name="title" /> <span class="inline-block border-t border-t-gray-100 w-32px h-6px mx-8px" /> <a
:href="helpLink"
target="_blank"
class="text-indigo-500 group outline-transparent text-16px"
>
<span class="group-hocus:underline">{{ helpText }}</span>
<i-cy-circle-bg-question-mark_x16 class="relative inline-block icon-dark-indigo-500 icon-light-indigo-100 -top-2px ml-8px" /></a>
</DialogTitle>
<button
aria-label="Close"
class="border-transparent rounded-full p-5px border-1 hover:border-indigo-300 hocus-default"
@click="setIsOpen(false)"
>
<i-cy-delete_x12 class="icon-dark-gray-400 w-12px h-12px" />
</button>
</div>
<slot name="title">
{{ title }}
</slot>
</StandardModalHeader>
<DialogDescription
v-if="$slots.description"
@@ -43,14 +31,12 @@
>
<slot name="description" />
</DialogDescription>
<slot />
<div
v-if="$slots.footer"
class="border-t-1px px-24px py-16px bg-gray-50"
>
<StandardModalBody :variant="variant">
<slot />
</StandardModalBody>
<StandardModalFooter v-if="$slots.footer">
<slot name="footer" />
</div>
</StandardModalFooter>
</div>
</div>
</Dialog>
@@ -62,13 +48,13 @@ export const inheritAttrs = false
<script setup lang="ts">
import { useI18n } from '@cy/i18n'
import { computed } from 'vue'
import { useModelWrapper } from '@packages/frontend-shared/src/composables'
import StandardModalHeader from './StandardModalHeader.vue'
import StandardModalBody from './StandardModalBody.vue'
import StandardModalFooter from './StandardModalFooter.vue'
import {
Dialog,
DialogOverlay,
DialogTitle,
DialogDescription,
} from '@headlessui/vue'
@@ -81,6 +67,8 @@ const props = withDefaults(defineProps<{
helpLink?: string
helpText?: string
clickOutside?: boolean
variant?: 'bare'
title?: string,
class?: string | string[] | Record<string, any>
}>(), {
clickOutside: true,
@@ -88,14 +76,12 @@ const props = withDefaults(defineProps<{
helpText: 'Need help?',
helpLink: 'https://docs.cypress.io',
class: undefined,
variant: undefined,
title: '',
})
const localValue = useModelWrapper(props, emit, 'modelValue')
const setIsOpen = (val: boolean) => {
emit('update:modelValue', val)
}
const { t } = useI18n()
</script>
@@ -0,0 +1,12 @@
<template>
<div :class="variant === 'bare' ? 'p-0' : 'p-24px' ">
<slot />
</div>
</template>
<script lang="ts" setup>
defineProps<{
variant?: 'bare'
}>()
</script>
@@ -0,0 +1,5 @@
<template>
<div class="rounded-b border-t-1px px-24px py-16px bg-gray-50">
<slot />
</div>
</template>
@@ -0,0 +1,35 @@
<template>
<div
class="sticky top-0 flex items-center justify-between bg-white rounded-t border-b-1px border-b-gray-100 min-h-56px px-24px z-1"
>
<DialogTitle class="text-gray-900 text-18px">
<slot /> <span class="inline-block border-t border-t-gray-100 w-32px h-6px mx-8px" /> <a
:href="helpLink"
target="_blank"
class="text-indigo-500 group outline-transparent text-16px"
>
<span class="group-hocus:underline">{{ helpText }}</span>
<i-cy-circle-bg-question-mark_x16 class="relative inline-block icon-dark-indigo-500 icon-light-indigo-100 -top-2px ml-8px" /></a>
</DialogTitle>
<button
aria-label="Close"
class="border-transparent rounded-full p-5px border-1 hover:border-indigo-300 hocus-default"
@click="$emit('close')"
>
<i-cy-delete_x12 class="icon-dark-gray-400 w-12px h-12px m-4px" />
</button>
</div>
</template>
<script lang="ts" setup>
import { DialogTitle } from '@headlessui/vue'
defineProps<{
helpLink?: string,
helpText?: string
}>()
defineEmits<{
(eventName: 'close'): void
}>()
</script>

Some files were not shown because too many files have changed in this diff Show More