feat(launchpad): UI for Global Mode and Welcome Guide (#17667)

This commit is contained in:
Jessica Sachs
2021-08-11 09:32:19 -04:00
committed by GitHub
parent 522fb2b1fa
commit d15c34fbcf
43 changed files with 1251 additions and 570 deletions
+1
View File
@@ -1142,6 +1142,7 @@ jobs:
runner-integration-tests-electron:
<<: *defaults
resource_class: medium
parallelism: 2
steps:
- run-runner-integration-tests:
+1 -1
View File
@@ -24,7 +24,7 @@
"minimist": "1.2.5"
},
"devDependencies": {
"electron": "12.0.0-beta.14",
"electron": "13.1.7",
"execa": "4.1.0",
"mocha": "3.5.3"
},
+4 -1
View File
@@ -31,7 +31,10 @@
}
},
{
"files": "*.tsx",
"files": [
"*.tsx",
"*.jsx"
],
"rules": {
"no-unused-vars": "off",
"react/jsx-no-bind": "off",
+1 -1
View File
@@ -9,7 +9,7 @@
"openMode": 0
},
"nodeVersion": "system",
"testFiles": "**/*.spec.{ts,tsx}",
"testFiles": "**/*.spec.{ts,tsx,jsx}",
"reporter": "../../node_modules/cypress-multi-reporters/index.js",
"reporterOptions": {
"configFile": "../../mocha-reporter-config.json"
+1
View File
@@ -50,6 +50,7 @@
"cross-env": "6.0.3",
"graphql": "^15.5.1",
"graphql-tag": "^2.12.5",
"javascript-time-ago": "2.3.8",
"prismjs": "1.24.0",
"rollup-plugin-polyfill-node": "^0.7.0",
"vite": "^2.4.4",
@@ -38,15 +38,15 @@ const VariantClassesTable = {
const SizeClassesTable = {
sm: "px-1 py-1 text-xs",
md: 'px-2 py-1 text-xs',
md: 'px-2 py-1 text-sm',
lg: "px-4 py-2 text-sm",
xl: "px-6 py-3 text-lg"
}
const IconClassesTable = {
md: "h-1.25em w-1.25em",
lg: "h-2em w-2m",
xl: "h-2.5em w-2.5em"
md: "min-h-1.25em min-w-1.25em max-h-1.25em max-w-1.25em",
lg: "min-h-2em min-w-2em max-h-2em max-w-2em",
xl: "min-h-2.5em min-w-2.5em max-w-2.5em max-h-2.5em "
}
const props = defineProps<{
@@ -0,0 +1,17 @@
import { ref } from 'vue'
import Checkbox from './Checkbox.vue'
describe('<Checkbox />', () => {
it('renders', () => {
const value = ref(true)
cy.mount(() => (<Checkbox
label="Welcome guide settings"
description="The description"
id="welcome-opt-out"
vModel={value}
>
<span class="text-gray-800 font-light">Show the welcome guide when opening Cypress.</span>
</Checkbox>))
})
})
@@ -0,0 +1,48 @@
<template>
<div class="relative flex items-center">
<div class="flex items-center h-5">
<input
v-model="modelValue"
:id="id"
:aria-describedby="`${id}-description`"
:name="id"
type="checkbox"
class="border-1
rounded
border-gray-200
bg-white h-4 w-4
text-indigo-500
disabled:bg-gray-100
checked:bg-indigo-500
"
:class="{
'text-indigo-500 checked:border-indigo-300 checked:bg-indigo-600 checked:text-indigo-600': state === 'default',
'checked:border-jade-300 checked:bg-jade-600 checked:text-jade-600': state === 'success',
'checked:border-red-300 checked:bg-red-600 checked:text-red-600': state === 'danger'
}"
/>
</div>
<div class="ml-2 text-16px leading-normal">
<slot name="label">
<label v-if="label" :for="id" class="disabled:text-gray-500 text-gray-500 font-light select-none">
{{ label }}
</label>
</slot>
</div>
</div>
</template>
<script lang="ts" setup>
type InputState = 'success' | 'danger' | 'default'
withDefaults(defineProps<{
id: string
modelValue: boolean
state?: InputState
label?: string
}>(), {
state: 'default'
})
</script>
@@ -0,0 +1,20 @@
import HeartIcon from 'virtual:vite-icons/mdi/heart'
import InlineCodeEditor from './InlineCodeEditor.vue'
import { ref } from 'vue'
describe('InlineCodeEditor', () => {
it('renders', () => {
const value = ref('console.log("hello world")')
cy.mount(() => (
<div>
<InlineCodeEditor
data-testid="code-editor"
prefixIcon={HeartIcon}
vModel={value.value}
/>
</div>
))
.get('[data-testid=code-editor] textarea').clear().type(`const four = 2 + 2;`, { delay: 0 })
})
})
@@ -1,23 +0,0 @@
import HeartIcon from 'virtual:vite-icons/mdi/heart'
import InlineCodeEditor from './InlineCodeEditor.vue'
import Input from '../input/Input.vue'
describe('InlineCodeEditor', () => {
it('renders', () => {
cy.mount(() => (
<div>
<Input
class="font-mono"
modelValue='Some value'
/>
<InlineCodeEditor
data-testid="code-editor"
prefixIcon={HeartIcon}
modelValue="console.log('I LOVE formatted code.')"
// @ts-ignore
onUpdateModelValue={cy.stub()}
/>
</div>
)).get('[data-testid=code-editor] textarea').clear().type(`const four = 2 + 2;`, { delay: 0 })
})
})
@@ -1,40 +1,75 @@
<template>
<div class="relative text-gray-600 overflow-hidden w-350px">
<IconWrapper v-bind="{ ...iconWrapperProps, class: $attrs.class }">
<template #default="slotProps">
<CodeEditor
:class="{
[slotProps.iconOffsetClasses.prefix]: $slots.prefix || prefixIcon,
[slotProps.iconOffsetClasses.suffix]: $slots.suffix || suffixIcon
}"
class="font-mono w-full h-full rounded border-transparent disabled:bg-cool-gray-100 disabled:text-cool-gray-400 border-cool-gray-300 focus:border-gray-500 focus:bg-white bg-gray-100 focus:ring-0 focus:outline-none focus:bg-white focus:text-gray-900 border-1 py-2 px-4 whitespace-pre overflow-auto"
:readonly="readonly"
v-model="localValue"
/>
</template>
</IconWrapper>
<div class="p-0 m-0 border-0 text-sm" :class="$attrs.class">
<div class="relative rounded-md">
<div v-if="hasPrefix" class="absolute inset-y-0 left-0 pl-4 flex items-center">
<span class="text-gray-500 text-sm flex items-center justify-center">
<slot name="prefix">
<Icon
v-if="prefixIcon"
class="pointer-events-none"
:icon="prefixIcon"
:class="prefixIconClasses"
></Icon>
</slot>
</span>
</div>
<CodeEditor
:class="_inputClasses"
:readonly="readonly"
class="font-mono w-full h-full rounded border-transparent disabled:bg-gray-100 disabled:text-gray-400 border-gray-300 focus:border-gray-500 focus:bg-white bg-gray-100 focus:ring-0 focus:outline-none focus:bg-white focus:text-gray-900 border-1 py-2 px-4 whitespace-pre overflow-auto"
v-model="localValue"
/>
<div v-if="hasSuffix" class="absolute inset-y-0 right-0 pr-3 flex items-center">
<span class="text-gray-500 text-sm flex items-center justify-center">
<slot name="suffix">
<Icon :icon="suffixIcon" class="pointer-events-none" :class="suffixIconClasses"></Icon>
</slot>
</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import _ from 'lodash'
import "prismjs"
import "@packages/reporter/src/errors/prism.scss"
import CodeEditor from './CodeEditor.vue'
import type { FunctionalComponent, SVGAttributes } from 'vue'
import { computed, useSlots } from 'vue'
import { useModelWrapper } from '../../composables'
import IconWrapper, { iconProps } from '../icon/IconWrapper.vue'
import { pick, keys } from 'lodash'
const props = defineProps({
modelValue: {
type: String,
},
readonly: {
type: Boolean
},
...iconProps
const slots = useSlots()
const props = withDefaults(defineProps<{
inputClasses?: string | string[] | Record<string, string>
prefixIcon?: FunctionalComponent<SVGAttributes, {}>
prefixIconClasses?: string | string[] | Record<string, string>
suffixIcon?: FunctionalComponent<SVGAttributes, {}>
suffixIconClasses?: string | string[] | Record<string, string>
modelValue?: string
readonly?: boolean
}>(), {
modelValue: ''
})
const emit = defineEmits(['update:modelValue'])
const iconWrapperProps = pick(props, keys(iconProps))
const localValue = useModelWrapper(props, emit, 'modelValue')
const emits = defineEmits(['update:modelValue'])
const localValue = useModelWrapper(props, emits, 'modelValue')
const hasPrefix = computed(() => {
return slots.prefix || props.prefixIcon
})
const hasSuffix = computed(() => {
return slots.suffix || props.suffixIcon
})
const _inputClasses = computed(() => ([
props.inputClasses,
hasPrefix ? 'pl-10' : 'pl-4',
hasSuffix ? 'pr-6' : 'pr-0'
]))
</script>
<style lang="scss">
@@ -57,5 +92,9 @@ const localValue = useModelWrapper(props, emit, 'modelValue')
.prism-editor__editor,
.prism-editor__textarea {
@apply whitespace-pre;
.token {
background: none !important;
}
}
</style>
@@ -0,0 +1,16 @@
import Input from './Input.vue'
import { ref } from 'vue'
describe('<Input/>', () => {
it('binds to v-model', () => {
const value = ref('')
const textToType = 'My wonderful input text'
cy.mount(() => <Input vModel={value.value}/>)
cy.get('input').type(textToType, { delay: 0 })
cy.should(() => {
expect(value.value).to.equal(textToType)
})
})
})
@@ -1,70 +0,0 @@
import { ref } from 'vue'
import Input from './Input.vue'
import CoffeeIcon from 'virtual:vite-icons/mdi/coffee'
import HeartIcon from 'virtual:vite-icons/mdi/heart'
describe('<Input />', () => {
it('playground', () => {
const lorem = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '
const visible = ref(true)
const text = ref('hello world')
cy.mount(() => (
<div class='m-10'>
<div>
Input with text:
<Input modelValue={lorem} />
</div>
<div>Input with Icon
<Input
modelValue={lorem}
prefixIcon={HeartIcon}
prefixIconClass='text-gray-cool-500'
/>
</div>
<div>Input with Icon
<Input
modelValue={lorem}
prefixIcon={HeartIcon}
prefixIconClass='text-gray-cool-500'
suffixIcon={CoffeeIcon}
suffixIconClass='text-gray-cool-500'
/>
</div>
<div>
Input with text, disabled:
<Input disabled modelValue={lorem} />
</div>
<div>
Secure input with text:
<Input
type={visible.value ? 'text' : 'password'}
modelValue={text.value}
/>
<button onClick={() => visible.value = !visible.value}>
Toggle
</button>
</div>
<div>
Secure input with text (disabled):
<Input
type={visible.value ? 'text' : 'password'}
modelValue={text.value}
disabled
/>
<button onClick={() => visible.value = !visible.value}>Toggle</button>
</div>
<div>
Search input:
<Input data-testid='search' type='search'/>
</div>
</div>
))
})
})
@@ -1,67 +1,84 @@
<template>
<div
class="relative text-gray-600 overflow-hidden w-350px"
:class="[{ 'focus-within:text-gray-400': disabled }]"
>
<IconWrapper v-bind="{ ...iconWrapperProps, class: $attrs.class }">
<template #prefix>
<slot name="prefix"></slot>
</template>
<template #suffix>
<slot name="suffix"></slot>
</template>
<template #default="slotProps">
<input
v-bind="{ ...omitAttrs($attrs), disabled }"
autocomplete="off"
autocorrect="off"
:spellcheck="false"
v-model="localValue"
class="w-full h-full rounded border-transparent disabled:bg-cool-gray-100 disabled:text-cool-gray-400 border-cool-gray-300 focus:border-gray-500 focus:bg-white bg-gray-100 focus:ring-0 focus:outline-none focus:bg-white focus:text-gray-900 border-1 py-2 px-4 whitespace-pre overflow-auto"
:type="type"
:class="[inputClass, {
[slotProps.iconOffsetClasses.prefix]: $slots.prefix || prefixIcon,
[slotProps.iconOffsetClasses.suffix]: $slots.suffix || suffixIcon
}]"
/>
</template>
</IconWrapper>
<div class="p-0 m-0 border-0" :class="$attrs.class">
<div class="relative rounded-md">
<div v-if="hasPrefix" class="absolute inset-y-0 left-0 pl-4 flex items-center">
<span class="text-gray-500 flex items-center justify-center">
<slot name="prefix">
<Icon
v-if="prefixIcon"
class="pointer-events-none"
:icon="prefixIcon"
:class="prefixIconClasses"
></Icon>
<Icon v-else-if="type === 'search'" :icon="IconSearch" />
</slot>
</span>
</div>
<input
:type="type"
v-model="localValue"
:class="_inputClasses"
class="placeholder-gray-400 disabled:bg-gray-100 disabled:text-gray-400 leading-tight focus:ring-indigo-500 focus:border-indigo-500 block w-full pl-10 py-2 border-gray-300 rounded-md"
v-bind="inputAttrs"
/>
<div v-if="hasSuffix" class="absolute inset-y-0 right-0 pr-3 flex items-center">
<span class="text-gray-500 flex items-center justify-center">
<slot name="suffix">
<Icon :icon="suffixIcon" class="pointer-events-none" :class="suffixIconClasses"></Icon>
</slot>
</span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import type { FunctionalComponent, SVGAttributes } from 'vue'
import { useModelWrapper } from '../../composables'
import { omit, pick, keys } from 'lodash'
import IconWrapper, { iconProps } from '../icon/IconWrapper.vue'
const props = withDefaults(defineProps<{
prefixIcon?: FunctionalComponent<SVGAttributes>,
prefixIconClass?: string,
suffixIcon?: FunctionalComponent<SVGAttributes>,
suffixIconClass?: string,
type?: HTMLInputElement['type']
modelValue?: string
disabled?: boolean
inputClass?: string | Array<any> | object
}>(), {
modelValue: '',
disabled: false,
type: 'text'
})
const emit = defineEmits(['update:modelValue'])
const iconWrapperProps = computed(() => pick(props, keys(iconProps)))
const type = computed(() => props.type || 'text')
const localValue = useModelWrapper(props, emit, 'modelValue')
const omitAttrs = (attrs) => omit(attrs, 'class')
<script lang="ts">
export default {
inheritAttrs: false
}
</script>
<style>
input {
line-height: 1 !important;
}
</style>
<script lang="ts" setup>
import _ from 'lodash'
import IconSearch from 'virtual:vite-icons/mdi/magnify'
import type { InputHTMLAttributes, FunctionalComponent, SVGAttributes } from 'vue'
import { computed, useSlots, useAttrs } from 'vue'
import { useModelWrapper } from '../../composables'
const slots = useSlots()
const attrs = useAttrs()
const inputAttrs = _.omit(attrs, 'class')
const props = withDefaults(defineProps<{
type?: InputHTMLAttributes['type']
inputClasses?: string | string[] | Record<string, string>
prefixIcon?: FunctionalComponent<SVGAttributes, {}>
prefixIconClasses?: string | string[] | Record<string, string>
suffixIcon?: FunctionalComponent<SVGAttributes, {}>
suffixIconClasses?: string | string[] | Record<string, string>
modelValue?: string
}>(), {
type: 'text',
modelValue: ''
})
const emits = defineEmits(['update:modelValue'])
const localValue = useModelWrapper(props, emits, 'modelValue')
const hasPrefix = computed(() => {
return slots.prefix || props.prefixIcon || props.type === 'search'
})
const hasSuffix = computed(() => {
return slots.suffix || props.suffixIcon
})
const _inputClasses = computed(() => ([
props.inputClasses,
hasPrefix ? 'pl-10' : 'pl-4',
hasSuffix ? 'pr-6' : 'pr-0'
]))
</script>
@@ -0,0 +1,22 @@
import { defaultMessages } from '../locales/i18n'
import GlobalEmpty from './GlobalEmpty.vue'
const emptyText = defaultMessages.globalPage.empty
describe('<GlobalEmpty />', () => {
it('renders the empty state', () => {
cy.mount(() => (<div
class="p-12 min-w-280px max-w-650px overflow-auto resize-x">
<GlobalEmpty />
</div>))
cy.contains(emptyText.title)
cy.contains(emptyText.helper)
const parts = emptyText.dropText.split('{0}')
cy.contains(parts[0])
cy.contains(emptyText.browseManually)
})
})
@@ -0,0 +1,29 @@
<template>
<main class="text-center">
<h1 class="text-2rem mb-2">{{ t('globalPage.empty.title') }}</h1>
<p class="text-lg font-light text-gray-600 mb-6">{{ t('globalPage.empty.helper') }}</p>
<button
type="button"
@click="selectProject"
class="min-w-220px relative block w-full border-2 bg-gray-50 border-gray-300 border-dashed rounded-lg p-12 text-center hover:border-gray-400 text-center"
>
<IconPlaceholder
class="-mb-8px mx-auto max-w-65px h-full relative justify-center w-full text-indigo-600"
/>
<span class="mt-13px block text-lg font-medium text-gray-700 font-light">
<i18n-t keypath="globalPage.empty.dropText">
<a class="font-normal" @click="selectProject">{{ t('globalPage.empty.browseManually') }}</a>
</i18n-t>
</span>
</button>
</main>
</template>
<script lang="ts" setup>
import { useI18n } from "../composables"
import IconPlaceholder from 'virtual:vite-icons/icons8/circle-thin'
const { t } = useI18n()
const selectProject = () => { }
</script>
@@ -0,0 +1,53 @@
import { defaultMessages } from '../locales/i18n'
import GlobalPage from './GlobalPage.vue'
const searchSelector = `input[placeholder="${defaultMessages.globalPage.searchPlaceholder}"`
const emptyMessages = defaultMessages.globalPage.empty
describe('<GlobalPage />', { viewportHeight: 900, viewportWidth: 1200 }, () => {
beforeEach(() => {
cy.mount(() => (<div>
<GlobalPage />
</div>))
})
// TODO: add gql so that we can mock out the fragment response
describe.skip('without projects', () => {
it('renders the empty state', () => {
cy.findByText(emptyMessages.title).should('be.visible')
cy.findByText(emptyMessages.helper).should('be.visible')
// TODO: This should open a native file picker
cy.findByText(emptyMessages.browseManually).click()
})
})
describe('with projects', () => {
it('renders an empty input field', () => {
cy.get(searchSelector).should('be.visible').and('not.have.value')
})
it('renders projects', () => {
// TODO: add gql so that we can mock out the fragment response
cy.findByText('Ten Days ago').should('be.visible')
cy.findByText('Project Name 2').should('be.visible')
})
it('can filter down the projects', () => {
cy.findByText('Ten Days ago').should('be.visible')
cy.findByText('Project Name 2').should('be.visible')
cy.get(searchSelector).type('Project Name 2', { delay: 0 })
cy.findByText('Project Name 2').should('be.visible')
cy.findByText('Ten Days ago').should('not.exist')
cy.get(searchSelector).clear()
cy.findByText('Ten Days ago').should('be.visible')
cy.findByText('Project Name 2').should('be.visible')
})
describe('Welcome Guide', () => {
it('renders the welcome guide', () => {
cy.findByText(defaultMessages.welcomeGuide.header.description).should('be.visible')
})
})
})
})
@@ -0,0 +1,102 @@
<template>
<template v-if="projects.length">
<!-- Welcome Guide can fetch its own information for if it should render -->
<WelcomeGuide />
<!-- If there are projects -->
<div class="grid grid-cols-1 gap-6 pt-6 grid-cols-2">
<div class="min-w-full col-start-1 col-end-3 flex items-center gap-6">
<GlobalPageHeader v-model="match" />
</div>
<GlobalProjectCard v-for="project, idx in filteredProjects" :key="idx" :project="project" />
</div>
</template>
<!-- Else, show the empty state -->
<GlobalEmpty v-else />
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import WelcomeGuide from './WelcomeGuide.vue'
import GlobalProjectCard from './GlobalProjectCard.vue'
import GlobalPageHeader from './GlobalPageHeader.vue'
import GlobalEmpty from './GlobalEmpty.vue'
type Project = {
name: string,
lastRunStatus: 'passed' | 'failed' | 'pending'
lastRun: number
}
const testProject: Project = {
name: 'Project Name',
lastRunStatus: 'passed',
lastRun: Date.now() - 1000 * 60 * 60 * 24 * 255 // 255 days ago
}
// I don't know why this isn't type checking correctly
// but it'll be deleted momentarily when this data is pulled in from gql
// @ts-ignore
const projects: Ref<Project[]> = ref([
testProject,
{
name: 'Project Name 2',
lastRunStatus: 'failed',
lastRun: Date.now() - 1000 * 60 * 60 * 24 * 100 // 100 days ago
},
{
name: 'Fifty Days Ago',
lastRunStatus: 'pending',
lastRun: Date.now() - 1000 * 60 * 60 * 24 * 50 // 50 days ago
},
{
name: 'Ten Days ago',
lastRunStatus: 'passed',
lastRun: Date.now() - 1000 * 60 * 60 * 24 * 10 // 10 days ago
},
{
name: 'Five days',
lastRunStatus: 'passed',
lastRun: Date.now() - 1000 * 60 * 60 * 24 * 5 // 5 days ago
},
{
name: 'Project Name 6',
lastRunStatus: 'failed',
lastRun: Date.now() - 1000 * 60 * 60 * 24 * 1 // 1 day ago
},
{
name: 'Project Name 6',
lastRunStatus: 'failed',
lastRun: Date.now() - 1000 * 60 * 60 * 24 * 1 // 1 day ago
},
{
name: 'Project Name 6',
lastRunStatus: 'failed',
lastRun: Date.now() - 1000 * 60 * 60 * 24 * 1 // 1 day ago
},
{
name: 'Project Name 6',
lastRunStatus: 'failed',
lastRun: Date.now() - 1000 * 60 * 60 * 24 * 1 // 1 day ago
},
{
name: 'Project Name 7',
lastRunStatus: 'passed',
lastRun: Date.now() - 1000 * 60 * 60 * 24 * 0 // today
},
{
name: 'Project Name 8',
lastRunStatus: 'failed',
lastRun: Date.now() - 1000 * 60 * 60 * 24 * 0 // yesterday
},
])
const filteredProjects = computed(() => {
return projects.value.filter(p => p.name.toLowerCase().indexOf(match.value.toLowerCase()) !== -1)
})
const match = ref('')
</script>
@@ -0,0 +1,19 @@
import { defaultMessages } from '../locales/i18n'
import GlobalPageHeader from './GlobalPageHeader.vue'
import { ref } from 'vue'
const searchSelector = `input[placeholder="${defaultMessages.globalPage.searchPlaceholder}"]`
describe('<GlobalPageHeader />', () => {
it('renders and has a reactive input', () => {
const search = ref('')
cy.mount(() => (<div class="p-12 overflow-auto resize-x max-w-600px"><GlobalPageHeader vModel={search.value}/></div>))
const searchText = 'My project name goes here'
cy.get(searchSelector).should('be.visible')
cy.get(searchSelector).type(searchText)
cy.should(() => expect(search.value).to.equal(searchText))
})
})
@@ -0,0 +1,31 @@
<template>
<Input
type="search"
class="min-w-200px w-80% flex-grow"
:placeholder="t('globalPage.searchPlaceholder')"
v-model="localValue"
/>
<Button
@click="$emit('new-project')"
:prefixIcon="IconPlus"
prefixIconClass="text-center justify-center text-lg"
class="w-20% min-w-120px text-size-16px h-full focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500"
>{{ t('globalPage.newProjectButton') }}</Button>
</template>
<script lang="ts" setup>
import Button from '../components/button/Button.vue'
import Input from '../components/input/Input.vue'
import IconPlus from 'virtual:vite-icons/mdi/plus'
import { useI18n, useModelWrapper } from '../composables'
const { t } = useI18n()
const props = defineProps<{
modelValue: string
}>()
const emits = defineEmits(['update:modelValue', 'new-project'])
const localValue = useModelWrapper(props, emits, 'modelValue')
</script>
@@ -0,0 +1,15 @@
import GlobalProjectCard from './GlobalProjectCard.vue'
describe('<GlobalProjectCard />', () => {
it('renders', () => {
const project = {
name: 'Lorem ipsum dolor sit amet consectetur adipisicing elit.',
lastRun: new Date('2021-08-01T00:00:00.000Z').getTime(),
lastRunStatus: 'passed',
}
cy.mount(() => <div class="p-12 overflow-auto resize-x max-w-600px"><GlobalProjectCard project={project} /></div>)
cy.findByText(project.name).should('be.visible')
cy.findByText('1 week ago').should('be.visible')
})
})
@@ -0,0 +1,65 @@
<template>
<div class="relative min-w-200px rounded-lg border border-gray-300 bg-white px-16px pt-13px pb-15px shadow-sm flex items-center space-x-3 hover:border-gray-400 focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500">
<div class="flex-1 min-w-0">
<button class="focus:outline-none underline-transparent grid w-full text-left children:truncate" @click="$emit('click', project)">
<p class="text-16px row-[1] leading-normal font-medium text-indigo-600">{{ project.name }}</p>
<p class="text-sm text-gray-500 relative flex flex-wrap self-end items-center gap-1 bullet-points children:flex children:items-center children:gap-1">
<span>{{ getTimeAgo(project.lastRun) }}</span>
<span>project/master</span>
<span>v8.0</span>
</p>
<Icon :icon="iconForStatus.icon" :class="iconForStatus.classes" class="ml-2 justify-self-end self-center row-start-1 row-end-3 col-start-2 text-sm"/>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Icon from '../components/icon/Icon.vue'
import IconChecked from 'virtual:vite-icons/mdi/check-circle'
import IconX from 'virtual:vite-icons/mdi/plus-circle'
import IconPending from 'virtual:vite-icons/mdi/refresh-circle'
import { getTimeAgo } from "../utils/time";
const icons = {
passed: {
icon: IconChecked,
classes: 'text-green-500'
},
failed: {
icon: IconX,
classes: 'text-red-500 rotate-45 translate'
},
pending: {
icon: IconPending,
classes: 'text-blue-500'
}
}
// TODO: I want to use an enum here for 'lastRunStatus'
// but I'm struggling to get the types within the tests
// When GQL exists, I'll be able to pull in the shared types.
const props = defineProps<{
project: { name: string, lastRun: number, lastRunStatus: string }
}>()
const iconForStatus = computed(() => icons[props.project.lastRunStatus])
</script>
<style scoped lang="scss">
// You can't do things like `children:after:....` inside of the inline classes.
// Not sure why.
.bullet-points > * {
&:after {
@apply min-h-4px max-h-4px max-w-4px min-w-4px rounded-full bg-gray-300;
content: '';
display: inline-block;
}
&:last-child:after {
@apply hidden;
}
}
</style>
@@ -0,0 +1,21 @@
import { defaultMessages } from '../locales/i18n'
import WelcomeGuide from './WelcomeGuide.vue'
const welcomeGuide = defaultMessages.welcomeGuide.header
describe('<WelcomeGuide />', () => {
it('should be dismissable when clicking on the Dismiss button', () => {
cy.viewport(1200, 800)
cy.mount(() => <div class="min-w-400px max-w-1100px resize-x overflow-auto"><WelcomeGuide /></div>)
cy.findByText(welcomeGuide.title).should('be.visible')
cy.findByText(welcomeGuide.description).should('be.visible')
cy.findByText(defaultMessages.components.modal.dismiss).click()
cy.findByText(welcomeGuide.title).should('not.exist')
cy.findByText(welcomeGuide.description).should('not.exist')
})
it('renders', () => {
cy.viewport(1200, 800)
cy.mount(() => <div class="min-w-400px max-w-1100px resize-x overflow-auto"><WelcomeGuide /></div>)
})
})
@@ -0,0 +1,100 @@
<template>
<div class="bg-indigo-50 border-b-1 border-b-indigo-100 w-full p-12 flex relative" v-if="show">
<Button
variant="link"
class="absolute top-1 right-1 sibling:absolute"
:suffixIcon="IconCircleX"
@click="show = !show"
>{{ t('components.modal.dismiss') }}</Button>
<IconPlaceholder
class="min-w-100px mr-[5%] max-w-224px self-start h-full relative justify-center w-full text-indigo-600"
/>
<div class="divide-y divide-gray-300">
<div class="grid">
<div class="children:leading-normal ml-2 mb-4">
<h1 class="text-2rem text-gray-900">{{ t('welcomeGuide.header.title') }}</h1>
<p class="text-16px text-gray-500 font-light">{{ t('welcomeGuide.header.description') }}</p>
</div>
<div class="grid link-wrappers justify-between">
<WelcomeGuideLinks
class="mb-2"
:header="t('welcomeGuide.projectListHeader')"
:items="projects.slice(0, 3)"
@click="chooseProject"
>
<template #="{ item }">{{ item.path }}</template>
</WelcomeGuideLinks>
<WelcomeGuideLinks
:header="t('welcomeGuide.linkHeader')"
:items="links"
@click="openLink"
>
<template #="{ item }">{{ item.description }}</template>
</WelcomeGuideLinks>
</div>
</div>
<div class="py-16px">
<Checkbox
v-model="showWelcomeGuideOnStartup"
id="show-welcome-guide"
:label="t('welcomeGuide.confirmWelcomeGuide')"
></Checkbox>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useI18n } from "../composables"
import Checkbox from '../components/checkbox/Checkbox.vue'
import Button from '../components/button/Button.vue'
import WelcomeGuideLinks from './WelcomeGuideLinks.vue'
import IconCircleX from 'virtual:vite-icons/akar-icons/circle-x'
import IconPlaceholder from 'virtual:vite-icons/icons8/circle-thin'
const projects = [
{ path: '~/Documents/GitHub/web' },
{ path: '~/Documents/GitHub/web/vue-ts-starter' },
{ path: '~/Documents/GitHub/marketing' },
// Only show the first 3
{ path: '~Documents/wherever/definitely-foo' },
{ path: '~Documents/somewhere/else' },
]
const links = [
{
description: 'Getting Started',
href: 'https://on.cypress.io/'
},
{
description: 'Learning Academy',
href: 'https://on.cypress.io/'
},
{
description: 'Cypress Releases',
href: 'https://on.cypress.io/'
}
]
const { t } = useI18n()
const show = ref(true)
const showWelcomeGuideOnStartup = ref(true)
const chooseProject = (project) => {
// TODO: Opens the project
}
const openLink = (item) => {
// TODO: Open the item's href
}
</script>
<style scoped lang="scss">
.link-wrappers {
// for some reason, I can't get this to work inside of an inline class.
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
</style>
@@ -0,0 +1,19 @@
import WelcomeGuideLinks from './WelcomeGuideLinks.vue'
describe('<WelcomeGuideLinks />', () => {
it('should render correctly', () => {
// cy.viewport(600, 50)
const lorem = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.'
const vSlots = {
default ({ item }) {
return (<p class="truncate">{ item.value }</p>)
},
}
cy.mount(() => (<WelcomeGuideLinks class="grid max-w-400px" header="This is my header" items={[
{ value: lorem.slice(50, 200) },
{ value: lorem.slice(40, 100) },
{ value: lorem.slice(0, 40) },
]} vSlots={vSlots} />))
})
})
@@ -0,0 +1,28 @@
<template>
<ul>
<h2 class="mb-1 text-gray-900 text-size-16px">{{ header }}</h2>
<li :key="idx" v-for="item, idx in items" class="truncate">
<Button
@click="$emit('click', item)"
class="text-left text-16px truncate w-full children:truncate children:leading-normal"
variant="link"
>
<slot :item="item"></slot>
<template #prefix>
<div
class="border-indigo-500 flex-grow bg-indigo-200 w-14px max-w-14px min-w-14px h-14px min-h-14px rounded-full border-width-2px"
/>
</template>
</Button>
</li>
</ul>
</template>
<script lang="ts" setup>
import Button from '../components/button/Button.vue'
defineProps<{
items: Record<string, string>[]
header: string
}>()
</script>
+23 -1
View File
@@ -3,6 +3,9 @@
"learnMore": "Learn more."
},
"components": {
"modal": {
"dismiss": "Dismiss"
},
"select": {
"placeholder": "Choose an option..."
}
@@ -18,7 +21,7 @@
"enabled": "Enabled",
"disabled": "Disabled"
},
"launchpad": {
"setupPage": {
"projectSetup": {
"frameworkLabel": "Front-end Framework",
"frameworkPlaceholder": "Pick a framework",
@@ -34,6 +37,25 @@
"back": "Back"
}
},
"globalPage": {
"empty": {
"title": "Welcome to Cypress!",
"helper": "Add your first project below to start testing with Cypress.",
"dropText": "Drag your project here or {0}",
"browseManually": "browse manually."
},
"searchPlaceholder": "Search Projects",
"newProjectButton": "New Project"
},
"welcomeGuide": {
"header": {
"title": "Cypress",
"description": "A powerful open-source testing platform for developers and engineering teams all around the world."
},
"projectListHeader": "Recently Viewed Projects",
"linkHeader": "Quick Access",
"confirmWelcomeGuide": "Show the welcome guide when opening Cypress."
},
"settingsPage": {
"config": {
"title": "Resolved Configuration",
-2
View File
@@ -101,7 +101,6 @@ textarea:focus {
--tw-ring-color: #2563eb;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
border-color: #2563eb;
}
@@ -193,7 +192,6 @@ select {
--tw-ring-color: #2563eb;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
[type=checkbox]:checked,
@@ -8,7 +8,7 @@
</template>
<div class="inline-grid grid-flow-col justify-start gap-10px">
<InlineCodeEditor
class="text-sm"
class="max-w-400px"
:prefixIcon="IconCodeBraces"
prefixIconClass="text-cool-gray-400"
readonly
@@ -1,6 +1,6 @@
<template>
<ProjectSettingsSection>
<template #title>{{t('settingsPage.recordKey.title')}}</template>
<template #title>{{ t('settingsPage.recordKey.title') }}</template>
<template #description>
<i18n-t keypath="settingsPage.recordKey.description">
<a href="https://docs.cypress.io" target="_blank">{{ t('links.learnMore') }}</a>
@@ -9,7 +9,8 @@
<div class="inline-flex justify-start gap-10px">
<Input
v-model="recordKey"
inputClass="font-mono text-xs"
class="font-mono"
inputClasses="text-sm"
disabled
:type="showRecordKey ? 'text' : 'password'"
>
@@ -40,7 +41,6 @@
prefixIconClass="text-cool-gray-500 w-1.2rem h-1.2rem"
>{{ t('settingsPage.recordKey.manageKeys') }}</Button>
</div>
</ProjectSettingsSection>
</template>
@@ -2,19 +2,19 @@
<WizardLayout :canNavigateForward="gql.canNavigateForward">
<div class="m-5">
<SelectFramework
:name="t('launchpad.projectSetup.frameworkLabel')"
:name="t('setupPage.projectSetup.frameworkLabel')"
@select="setFEFramework"
:options="frameworks ?? []"
:value="gql.framework?.id ?? undefined"
:placeholder="t('launchpad.projectSetup.frameworkPlaceholder')"
:placeholder="t('setupPage.projectSetup.frameworkPlaceholder')"
/>
<SelectFramework
:name="t('launchpad.projectSetup.bundlerLabel')"
:name="t('setupPage.projectSetup.bundlerLabel')"
:disabled="bundlers.length === 1"
@select="setFEBundler"
:options="bundlers || []"
:value="gql.bundler?.id ?? undefined"
:placeholder="t('launchpad.projectSetup.bundlerPlaceholder')"
:placeholder="t('setupPage.projectSetup.bundlerPlaceholder')"
/>
</div>
</WizardLayout>
@@ -56,8 +56,8 @@ export default defineComponent({
const toggleManual = useMutation(InstallDependenciesManualInstallDocument)
const nextButtonName = computed(() =>
props.gql.isManualInstall ?
t('launchpad.install.confirmManualInstall') :
t('launchpad.install.startButton')
t('setupPage.install.confirmManualInstall') :
t('setupPage.install.startButton')
);
return {
@@ -58,8 +58,8 @@ const props = defineProps<{
nextFn?: (...args: unknown[]) => any,
}>()
const nextLabel = computed(() => props.next || t('launchpad.step.next'))
const backLabel = computed(() => props.back || t('launchpad.step.back'))
const nextLabel = computed(() => props.next || t('setupPage.step.next'))
const backLabel = computed(() => props.back || t('setupPage.step.back'))
const navigate = useMutation(WizardLayoutNavigateDocument)
+9
View File
@@ -0,0 +1,9 @@
import enTimeAgo from 'javascript-time-ago/locale/en'
import TimeAgo from 'javascript-time-ago'
TimeAgo.addDefaultLocale(enTimeAgo)
const timeAgo = new TimeAgo('en-US')
export function getTimeAgo (value) {
return timeAgo.format(value)
}
@@ -19,6 +19,11 @@ export type CyCookie = Pick<chrome.cookies.Cookie, 'name' | 'value' | 'expiratio
// https://developer.chrome.com/extensions/cookies#method-getAll
type CyCookieFilter = chrome.cookies.GetAllDetails
export const screencastOpts: cdp.Page.StartScreencastRequest = {
format: 'jpeg',
everyNthFrame: Number(process.env.CYPRESS_EVERY_NTH_FRAME || 5),
}
function convertSameSiteExtensionToCdp (str: CyCookie['sameSite']): cdp.Network.CookieSameSite | undefined {
return str ? ({
'no_restriction': 'None',
+2 -4
View File
@@ -11,7 +11,7 @@ import { launch } from '@packages/launcher'
import appData from '../util/app_data'
import { fs } from '../util/fs'
import { CdpAutomation } from './cdp_automation'
import { CdpAutomation, screencastOpts } from './cdp_automation'
import * as CriClient from './cri-client'
import * as protocol from './protocol'
import utils from './utils'
@@ -273,9 +273,7 @@ const _maybeRecordVideo = async function (client, options) {
client.send('Page.screencastFrameAck', { sessionId: meta.sessionId })
})
await client.send('Page.startScreencast', {
format: 'jpeg',
})
await client.send('Page.startScreencast', screencastOpts)
return client
}
+3 -5
View File
@@ -5,7 +5,7 @@ const Bluebird = require('bluebird')
const debug = require('debug')('cypress:server:browsers:electron')
const menu = require('../gui/menu')
const Windows = require('../gui/windows')
const { CdpAutomation } = require('./cdp_automation')
const { CdpAutomation, screencastOpts } = require('./cdp_automation')
const savedState = require('../saved_state')
const utils = require('./utils')
const errors = require('../errors')
@@ -62,7 +62,7 @@ const _getAutomation = function (win, options, parent) {
return fn(message, data)
}
await sendCommand('Page.startScreencast')
await sendCommand('Page.startScreencast', screencastOpts)
const ret = await fn(message, data)
@@ -104,9 +104,7 @@ const _maybeRecordVideo = function (webContents, options) {
}
})
await webContents.debugger.sendCommand('Page.startScreencast', {
format: 'jpeg',
})
await webContents.debugger.sendCommand('Page.startScreencast', screencastOpts)
}
}
-348
View File
@@ -1,348 +0,0 @@
const _ = require('lodash')
const utils = require('fluent-ffmpeg/lib/utils')
const debug = require('debug')('cypress:server:video')
const ffmpeg = require('fluent-ffmpeg')
const stream = require('stream')
const Promise = require('bluebird')
const ffmpegPath = require('@ffmpeg-installer/ffmpeg').path
const BlackHoleStream = require('black-hole-stream')
const { fs } = require('./util/fs')
// extra verbose logs for logging individual frames
const debugFrames = require('debug')('cypress-verbose:server:video:frames')
debug('using ffmpeg from %s', ffmpegPath)
ffmpeg.setFfmpegPath(ffmpegPath)
const deferredPromise = function () {
let reject
let resolve = (reject = null)
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})
return { promise, resolve, reject }
}
module.exports = {
generateFfmpegChaptersConfig (tests) {
if (!tests) {
return null
}
const configString = tests.map((test) => {
return test.attempts.map((attempt, i) => {
const { videoTimestamp, wallClockDuration } = attempt
let title = test.title ? test.title.join(' ') : ''
if (i > 0) {
title += `attempt ${i}`
}
return [
'[CHAPTER]',
'TIMEBASE=1/1000',
`START=${videoTimestamp - wallClockDuration}`,
`END=${videoTimestamp}`,
`title=${title}`,
].join('\n')
}).join('\n')
}).join('\n')
return `;FFMETADATA1\n${configString}`
},
getMsFromDuration (duration) {
return utils.timemarkToSeconds(duration) * 1000
},
getCodecData (src) {
return new Promise((resolve, reject) => {
return ffmpeg()
.on('stderr', (stderr) => {
return debug('get codecData stderr log %o', { message: stderr })
}).on('codecData', resolve)
.input(src)
.format('null')
.output(new BlackHoleStream())
.run()
}).tap((data) => {
return debug('codecData %o', {
src,
data,
})
}).tapCatch((err) => {
return debug('getting codecData failed', { err })
})
},
getChapters (fileName) {
return new Promise((resolve, reject) => {
ffmpeg.ffprobe(fileName, ['-show_chapters'], (err, metadata) => {
if (err) {
return reject(err)
}
resolve(metadata)
})
})
},
copy (src, dest) {
debug('copying from %s to %s', src, dest)
return fs
.copyAsync(src, dest, { overwrite: true })
.catch({ code: 'ENOENT' }, () => {})
},
// dont yell about ENOENT errors
start (name, options = {}) {
const pt = stream.PassThrough()
const ended = deferredPromise()
let done = false
let wantsWrite = true
let skippedChunksCount = 0
let writtenChunksCount = 0
_.defaults(options, {
onError () {},
})
const endVideoCapture = function (waitForMoreChunksTimeout = 3000) {
debugFrames('frames written:', writtenChunksCount)
// in some cases (webm) ffmpeg will crash if fewer than 2 buffers are
// written to the stream, so we don't end capture until we get at least 2
if (writtenChunksCount < 2) {
return new Promise((resolve) => {
pt.once('data', resolve)
})
.then(endVideoCapture)
.timeout(waitForMoreChunksTimeout)
}
done = true
pt.end()
// return the ended promise which will eventually
// get resolve or rejected
return ended.promise
}
const lengths = {}
const writeVideoFrame = function (data) {
// make sure we haven't ended
// our stream yet because paint
// events can linger beyond
// finishing the actual video
if (done) {
return
}
// when `data` is empty, it is sent as an empty Buffer (`<Buffer >`)
// which can crash the process. this can happen if there are
// errors in the video capture process, which are handled later
// on, so just skip empty frames here.
// @see https://github.com/cypress-io/cypress/pull/6818
if (_.isEmpty(data)) {
debugFrames('empty chunk received %o', data)
return
}
if (options.webmInput) {
if (lengths[data.length]) {
// this prevents multiple chunks of webm metadata from being written to the stream
// which would crash ffmpeg
debugFrames('duplicate length frame received:', data.length)
return
}
lengths[data.length] = true
}
writtenChunksCount++
debugFrames('writing video frame')
if (wantsWrite) {
if (!(wantsWrite = pt.write(data))) {
return pt.once('drain', () => {
debugFrames('video stream drained')
wantsWrite = true
})
}
} else {
skippedChunksCount += 1
return debugFrames('skipping video frame %o', { skipped: skippedChunksCount })
}
}
const startCapturing = () => {
return new Promise((resolve) => {
const cmd = ffmpeg({
source: pt,
priority: 20,
})
.videoCodec('libx264')
.outputOptions('-preset ultrafast')
.on('start', (command) => {
debug('capture started %o', { command })
return resolve({
cmd,
startedVideoCapture: new Date,
})
}).on('codecData', (data) => {
return debug('capture codec data: %o', data)
}).on('stderr', (stderr) => {
return debug('capture stderr log %o', { message: stderr })
}).on('error', (err, stdout, stderr) => {
debug('capture errored: %o', { error: err.message, stdout, stderr })
// bubble errors up
options.onError(err, stdout, stderr)
// reject the ended promise
return ended.reject(err)
}).on('end', () => {
debug('capture ended')
return ended.resolve()
})
// this is to prevent the error "invalid data input" error
// when input frames have an odd resolution
.videoFilters(`crop='floor(in_w/2)*2:floor(in_h/2)*2'`)
if (options.webmInput) {
cmd
.inputFormat('webm')
// assume 18 fps. This number comes from manual measurement of avg fps coming from firefox.
// TODO: replace this with the 'vfr' option below when dropped frames issue is fixed.
.inputFPS(18)
// 'vsync vfr' (variable framerate) works perfectly but fails on top page navigation
// since video timestamp resets to 0, timestamps already written will be dropped
// .outputOption('-vsync vfr')
} else {
cmd
.inputFormat('image2pipe')
.inputOptions('-use_wallclock_as_timestamps 1')
}
return cmd.save(name)
})
}
return startCapturing()
.then(({ cmd, startedVideoCapture }) => {
return {
_pt: pt,
cmd,
endVideoCapture,
writeVideoFrame,
startedVideoCapture,
}
})
},
async process (name, cname, videoCompression, ffmpegchaptersConfig, onProgress = function () {}) {
const metaFileName = `${name}.meta`
const maybeGenerateMetaFile = Promise.method(() => {
if (!ffmpegchaptersConfig) {
return false
}
// Writing the metadata to filesystem is necessary because fluent-ffmpeg is just a wrapper of ffmpeg command.
return fs.writeFile(metaFileName, ffmpegchaptersConfig).then(() => true)
})
const addChaptersMeta = await maybeGenerateMetaFile()
let total = null
return new Promise((resolve, reject) => {
debug('processing video from %s to %s video compression %o',
name, cname, videoCompression)
const command = ffmpeg()
const outputOptions = [
'-preset fast',
`-crf ${videoCompression}`,
]
if (addChaptersMeta) {
command.input(metaFileName)
outputOptions.push('-map_metadata 1')
}
command.input(name)
.videoCodec('libx264')
.outputOptions(outputOptions)
// .videoFilters("crop='floor(in_w/2)*2:floor(in_h/2)*2'")
.on('start', (command) => {
debug('compression started %o', { command })
})
.on('codecData', (data) => {
debug('compression codec data: %o', data)
total = utils.timemarkToSeconds(data.duration)
})
.on('stderr', (stderr) => {
debug('compression stderr log %o', { message: stderr })
})
.on('progress', (progress) => {
// bail if we dont have total yet
if (!total) {
return
}
debug('compression progress: %o', progress)
const progressed = utils.timemarkToSeconds(progress.timemark)
const percent = progressed / total
if (percent < 1) {
return onProgress(percent)
}
})
.on('error', (err, stdout, stderr) => {
debug('compression errored: %o', { error: err.message, stdout, stderr })
return reject(err)
})
.on('end', () => {
debug('compression ended')
// we are done progressing
onProgress(1)
// rename and obliterate the original
return fs.moveAsync(cname, name, {
overwrite: true,
})
.then(() => {
if (addChaptersMeta) {
return fs.unlink(metaFileName)
}
})
.then(() => {
return resolve()
})
}).save(cname)
})
},
}
+362
View File
@@ -0,0 +1,362 @@
import _ from 'lodash'
import utils from 'fluent-ffmpeg/lib/utils'
import Debug from 'debug'
import ffmpeg from 'fluent-ffmpeg'
import stream from 'stream'
import Bluebird from 'bluebird'
import { path as ffmpegPath } from '@ffmpeg-installer/ffmpeg'
import BlackHoleStream from 'black-hole-stream'
import { fs } from './util/fs'
const debug = Debug('cypress:server:video')
// extra verbose logs for logging individual frames
const debugFrames = Debug('cypress-verbose:server:video:frames')
debug('using ffmpeg from %s', ffmpegPath)
ffmpeg.setFfmpegPath(ffmpegPath)
const deferredPromise = function () {
let reject
let resolve
const promise = new Bluebird((_resolve, _reject) => {
resolve = _resolve
reject = _reject
})
return { promise, resolve, reject }
}
export function generateFfmpegChaptersConfig (tests) {
if (!tests) {
return null
}
const configString = tests.map((test) => {
return test.attempts.map((attempt, i) => {
const { videoTimestamp, wallClockDuration } = attempt
let title = test.title ? test.title.join(' ') : ''
if (i > 0) {
title += `attempt ${i}`
}
return [
'[CHAPTER]',
'TIMEBASE=1/1000',
`START=${videoTimestamp - wallClockDuration}`,
`END=${videoTimestamp}`,
`title=${title}`,
].join('\n')
}).join('\n')
}).join('\n')
return `;FFMETADATA1\n${configString}`
}
export function getMsFromDuration (duration) {
return utils.timemarkToSeconds(duration) * 1000
}
export function getCodecData (src) {
return new Bluebird((resolve, reject) => {
return ffmpeg()
.on('stderr', (stderr) => {
return debug('get codecData stderr log %o', { message: stderr })
}).on('codecData', resolve)
.input(src)
.format('null')
.output(new BlackHoleStream())
.run()
}).tap((data) => {
return debug('codecData %o', {
src,
data,
})
}).tapCatch((err) => {
return debug('getting codecData failed', { err })
})
}
export function getChapters (fileName) {
return new Bluebird((resolve, reject) => {
ffmpeg.ffprobe(fileName, ['-show_chapters'], (err, metadata) => {
if (err) {
return reject(err)
}
resolve(metadata)
})
})
}
export function copy (src, dest) {
debug('copying from %s to %s', src, dest)
return fs
.copy(src, dest, { overwrite: true })
.catch((err) => {
if (err.code === 'ENOENT') {
debug('caught ENOENT error on copy, ignoring %o', { src, dest, err })
return
}
throw err
})
}
type StartOptions = {
// If set, expect input frames as webm chunks.
webmInput?: boolean
// Callback for asynchronous errors in video processing/compression.
onError?: (err: Error, stdout: string, stderr: string) => void
}
export function start (name, options: StartOptions = {}) {
const pt = new stream.PassThrough()
const ended = deferredPromise()
let done = false
let wantsWrite = true
let skippedChunksCount = 0
let writtenChunksCount = 0
_.defaults(options, {
onError () {},
})
const endVideoCapture = function (waitForMoreChunksTimeout = 3000) {
debugFrames('frames written:', writtenChunksCount)
// in some cases (webm) ffmpeg will crash if fewer than 2 buffers are
// written to the stream, so we don't end capture until we get at least 2
if (writtenChunksCount < 2) {
return new Bluebird((resolve) => {
pt.once('data', resolve)
})
.then(() => endVideoCapture())
.timeout(waitForMoreChunksTimeout)
}
done = true
pt.end()
// return the ended promise which will eventually
// get resolve or rejected
return ended.promise
}
const lengths = {}
const writeVideoFrame = function (data) {
// make sure we haven't ended
// our stream yet because paint
// events can linger beyond
// finishing the actual video
if (done) {
return
}
// when `data` is empty, it is sent as an empty Buffer (`<Buffer >`)
// which can crash the process. this can happen if there are
// errors in the video capture process, which are handled later
// on, so just skip empty frames here.
// @see https://github.com/cypress-io/cypress/pull/6818
if (_.isEmpty(data)) {
debugFrames('empty chunk received %o', data)
return
}
if (options.webmInput) {
if (lengths[data.length]) {
// this prevents multiple chunks of webm metadata from being written to the stream
// which would crash ffmpeg
debugFrames('duplicate length frame received:', data.length)
return
}
lengths[data.length] = true
}
writtenChunksCount++
debugFrames('writing video frame')
if (wantsWrite) {
if (!(wantsWrite = pt.write(data))) {
return pt.once('drain', () => {
debugFrames('video stream drained')
wantsWrite = true
})
}
} else {
skippedChunksCount += 1
return debugFrames('skipping video frame %o', { skipped: skippedChunksCount })
}
}
const startCapturing = () => {
return new Bluebird((resolve) => {
const cmd = ffmpeg({
source: pt,
priority: 20,
})
.videoCodec('libx264')
.outputOptions('-preset ultrafast')
.on('start', (command) => {
debug('capture started %o', { command })
return resolve({
cmd,
startedVideoCapture: new Date,
})
}).on('codecData', (data) => {
return debug('capture codec data: %o', data)
}).on('stderr', (stderr) => {
return debug('capture stderr log %o', { message: stderr })
}).on('error', (err, stdout, stderr) => {
debug('capture errored: %o', { error: err.message, stdout, stderr })
// bubble errors up
options.onError?.(err, stdout, stderr)
// reject the ended promise
return ended.reject(err)
}).on('end', () => {
debug('capture ended')
return ended.resolve()
})
// this is to prevent the error "invalid data input" error
// when input frames have an odd resolution
.videoFilters(`crop='floor(in_w/2)*2:floor(in_h/2)*2'`)
if (options.webmInput) {
cmd
.inputFormat('webm')
// assume 18 fps. This number comes from manual measurement of avg fps coming from firefox.
// TODO: replace this with the 'vfr' option below when dropped frames issue is fixed.
.inputFPS(18)
// 'vsync vfr' (variable framerate) works perfectly but fails on top page navigation
// since video timestamp resets to 0, timestamps already written will be dropped
// .outputOption('-vsync vfr')
} else {
cmd
.inputFormat('image2pipe')
.inputOptions('-use_wallclock_as_timestamps 1')
}
return cmd.save(name)
})
}
return startCapturing()
.then(({ cmd, startedVideoCapture }: any) => {
return {
_pt: pt,
cmd,
endVideoCapture,
writeVideoFrame,
startedVideoCapture,
}
})
}
// Progress callback called with percentage `0 <= p <= 1` of compression progress.
type OnProgress = (p: number) => void
export async function process (name, cname, videoCompression, ffmpegchaptersConfig, onProgress: OnProgress = function () {}) {
const metaFileName = `${name}.meta`
const maybeGenerateMetaFile = Bluebird.method(() => {
if (!ffmpegchaptersConfig) {
return false
}
// Writing the metadata to filesystem is necessary because fluent-ffmpeg is just a wrapper of ffmpeg command.
return fs.writeFile(metaFileName, ffmpegchaptersConfig).then(() => true)
})
const addChaptersMeta = await maybeGenerateMetaFile()
let total = null
return new Bluebird((resolve, reject) => {
debug('processing video from %s to %s video compression %o',
name, cname, videoCompression)
const command = ffmpeg()
const outputOptions = [
'-preset fast',
`-crf ${videoCompression}`,
]
if (addChaptersMeta) {
command.input(metaFileName)
outputOptions.push('-map_metadata 1')
}
command.input(name)
.videoCodec('libx264')
.outputOptions(outputOptions)
// .videoFilters("crop='floor(in_w/2)*2:floor(in_h/2)*2'")
.on('start', (command) => {
debug('compression started %o', { command })
})
.on('codecData', (data) => {
debug('compression codec data: %o', data)
total = utils.timemarkToSeconds(data.duration)
})
.on('stderr', (stderr) => {
debug('compression stderr log %o', { message: stderr })
})
.on('progress', (progress) => {
// bail if we dont have total yet
if (!total) {
return
}
debug('compression progress: %o', progress)
const progressed = utils.timemarkToSeconds(progress.timemark)
// @ts-ignore
const percent = progressed / total
if (percent < 1) {
return onProgress(percent)
}
})
.on('error', (err, stdout, stderr) => {
debug('compression errored: %o', { error: err.message, stdout, stderr })
return reject(err)
})
.on('end', async () => {
debug('compression ended')
// we are done progressing
onProgress(1)
// rename and obliterate the original
await fs.move(cname, name, {
overwrite: true,
})
if (addChaptersMeta) {
await fs.unlink(metaFileName)
}
resolve()
}).save(cname)
})
}
@@ -1,5 +1,5 @@
const { expect, sinon } = require('../spec_helper')
import videoCapture from '../../lib/video_capture'
import * as videoCapture from '../../lib/video_capture'
import path from 'path'
import fse from 'fs-extra'
import os from 'os'
@@ -788,6 +788,9 @@ const e2e = {
// Emulate no typescript environment
CYPRESS_INTERNAL_NO_TYPESCRIPT: options.noTypeScript ? '1' : '0',
// disable frame skipping to make quick Chromium tests have matching snapshots/working video
CYPRESS_EVERY_NTH_FRAME: 1,
// force file watching for use with --no-exit
...(options.noExit ? { CYPRESS_INTERNAL_FORCE_FILEWATCH: '1' } : {}),
})
@@ -0,0 +1,48 @@
<template>
<button :disabled="disabled" :class="className">
<slot />
</button>
</template>
<script lang="ts">
import { defineComponent, computed } from "vue";
export default defineComponent({
props: {
primary: Boolean,
outline: Boolean,
disabled: Boolean,
},
setup(props) {
const className = computed(() => {
return {
base: true,
primary: props.primary,
outline: props.outline,
disabled: props.disabled,
};
});
return {
className,
};
},
});
</script>
<style lang="scss" scoped>
.base {
@apply m-5
px-4
py-2;
}
.disabled {
@apply opacity-50;
}
.outline {
@apply text-blue-500 rounded border-blue-500 border-1 border-inset;
}
.primary {
@apply bg-blue-500 text-white rounded;
}
</style>
+22 -9
View File
@@ -17985,10 +17985,10 @@ electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.378, electron-to-chromi
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.727.tgz#857e310ca00f0b75da4e1db6ff0e073cc4a91ddf"
integrity sha512-Mfz4FIB4FSvEwBpDfdipRIrwd6uo8gUDoRDF4QEYb4h4tSuI3ov594OrjU6on042UlFHouIJpClDODGkPcBSbg==
electron@12.0.0-beta.14:
version "12.0.0-beta.14"
resolved "https://registry.npmjs.org/electron/-/electron-12.0.0-beta.14.tgz#f8c40c7e479879c305e519380e710c0a357aa734"
integrity sha512-PYM+EepIEj9kLePXEb9gIxzZk5H4zM7LGg5iw60OHt+SYEECPNFJmPj3N6oHKu3W+KrCG7285Vgz2ZCp1u0kKA==
electron@13.1.7:
version "13.1.7"
resolved "https://registry.yarnpkg.com/electron/-/electron-13.1.7.tgz#7e17f5c93a8d182a2a486884fed3dc34ab101be9"
integrity sha512-sVfpP/0s6a82FK32LMuEe9L+aWZw15u3uYn9xUJArPjy4OZHteE6yM5871YCNXNiDnoCLQ5eqQWipiVgHsf8nQ==
dependencies:
"@electron/get" "^1.0.1"
"@types/node" "^14.6.2"
@@ -24511,6 +24511,13 @@ javascript-stringify@^2.0.1:
resolved "https://registry.yarnpkg.com/javascript-stringify/-/javascript-stringify-2.1.0.tgz#27c76539be14d8bd128219a2d731b09337904e79"
integrity sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==
javascript-time-ago@2.3.8:
version "2.3.8"
resolved "https://registry.yarnpkg.com/javascript-time-ago/-/javascript-time-ago-2.3.8.tgz#7e1cd94a770987cc00db82e60e655d3efdd25629"
integrity sha512-ahVSuInQC6iJtwy/XsburOc6JMsI0OI/84b3nAhtMlDhCm9g4Py+zuiPASnt02B4GkaURqWtiyw98ce0ICAZYQ==
dependencies:
relative-time-format "^1.0.5"
jest-changed-files@^24.9.0:
version "24.9.0"
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.9.0.tgz#08d8c15eb79a7fa3fc98269bc14b451ee82f8039"
@@ -34608,6 +34615,11 @@ relateurl@0.2.x, relateurl@^0.2.7:
resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
relative-time-format@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/relative-time-format/-/relative-time-format-1.0.5.tgz#3fb7c76ae39156afe0a3a7ff0cb7bf30aa0f0fb6"
integrity sha512-MAgx/YKcUQYJpIaWcfetPstElnWf26JxVis4PirdwVrrymFdbxyCSm6yENpfB1YuwFbtHSHksN3aBajVNxk10Q==
relay-compiler@10.1.0:
version "10.1.0"
resolved "https://registry.yarnpkg.com/relay-compiler/-/relay-compiler-10.1.0.tgz#fb4672cdbe9b54869a3a79759edd8c2d91609cbe"
@@ -40825,16 +40837,17 @@ vue-demi@*:
integrity sha512-J+X8Au6BhQdcej6LY4O986634hZLu55L0ewU2j8my7WIKlu8cK0dqmdUxqVHHMd/cMrKKZ9SywB/id6aLhwCtA==
vue-eslint-parser@^7.0.0:
version "7.6.0"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.6.0.tgz#01ea1a2932f581ff244336565d712801f8f72561"
integrity sha512-QXxqH8ZevBrtiZMZK0LpwaMfevQi9UL7lY6Kcp+ogWHC88AuwUPwwCIzkOUc1LR4XsYAt/F9yHXAB/QoD17QXA==
version "7.10.0"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-7.10.0.tgz#ea4e4b10fd10aa35c8a79ac783488d8abcd29be8"
integrity sha512-7tc/ewS9Vq9Bn741pvpg8op2fWJPH3k32aL+jcIcWGCTzh/zXSdh7pZ5FV3W2aJancP9+ftPAv292zY5T5IPCg==
dependencies:
debug "^4.1.1"
eslint-scope "^5.0.0"
eslint-scope "^5.1.1"
eslint-visitor-keys "^1.1.0"
espree "^6.2.1"
esquery "^1.4.0"
lodash "^4.17.15"
lodash "^4.17.21"
semver "^6.3.0"
vue-hot-reload-api@^2.3.0:
version "2.3.4"