mirror of
https://github.com/cypress-io/cypress.git
synced 2026-05-06 23:10:22 -05:00
feat(launchpad): UI for Global Mode and Welcome Guide (#17667)
This commit is contained in:
@@ -1142,6 +1142,7 @@ jobs:
|
||||
|
||||
runner-integration-tests-electron:
|
||||
<<: *defaults
|
||||
resource_class: medium
|
||||
parallelism: 2
|
||||
steps:
|
||||
- run-runner-integration-tests:
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.tsx",
|
||||
"files": [
|
||||
"*.tsx",
|
||||
"*.jsx"
|
||||
],
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
"react/jsx-no-bind": "off",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
},
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user