fix: save spec filter term (#22755)

This commit is contained in:
Mark Noonan
2022-08-09 12:44:35 -04:00
committed by GitHub
parent 63b5a8394e
commit 3d98f98136
32 changed files with 716 additions and 460 deletions

View File

@@ -341,7 +341,7 @@ describe('App: Settings', () => {
cy.contains('Choose your editor...').click()
cy.contains('Well known editor').click()
cy.withRetryableCtx((ctx) => {
expect((ctx.actions.localSettings.setPreferences as SinonStub).lastCall.lastArg).to.include('/usr/bin/well-known')
expect((ctx.actions.localSettings.setPreferences as SinonStub).lastCall.args[0]).to.include('/usr/bin/well-known')
})
// navigate away and come back
@@ -363,7 +363,7 @@ describe('App: Settings', () => {
// assert contains `/usr/local/bin/vim'
cy.findByPlaceholderText('/path/to/editor').clear().invoke('val', '/usr/local/bin/vim').trigger('input').trigger('change')
cy.withRetryableCtx((ctx) => {
expect((ctx.actions.localSettings.setPreferences as SinonStub).lastCall.lastArg).to.include('/usr/local/bin/vim')
expect((ctx.actions.localSettings.setPreferences as SinonStub).lastCall.args[0]).to.include('/usr/local/bin/vim')
})
// navigate away and come back
@@ -380,7 +380,7 @@ describe('App: Settings', () => {
cy.get('[data-cy="computer"]').click()
cy.withRetryableCtx((ctx) => {
expect((ctx.actions.localSettings.setPreferences as SinonStub).lastCall.lastArg).to.include('computer')
expect((ctx.actions.localSettings.setPreferences as SinonStub).lastCall.args[0]).to.include('computer')
})
cy.get('[data-cy="custom-editor"]').should('not.exist')
@@ -390,7 +390,7 @@ describe('App: Settings', () => {
cy.contains('Choose your editor...').click()
cy.contains('Null binary editor').click()
cy.withRetryableCtx((ctx) => {
expect((ctx.actions.localSettings.setPreferences as SinonStub).lastCall.lastArg).to.include('{"preferredEditorBinary":null')
expect((ctx.actions.localSettings.setPreferences as SinonStub).lastCall.args[0]).to.include('{"preferredEditorBinary":null')
})
// navigate away and come back

View File

@@ -48,7 +48,7 @@ describe('Sidebar Navigation', { viewportWidth: 1280 }, () => {
context('as e2e testing type with localSettings', () => {
it('use saved state for nav size', () => {
cy.withCtx(async (ctx) => {
await ctx.actions.localSettings.setPreferences(JSON.stringify({ reporterWidth: 100 }))
await ctx.actions.localSettings.setPreferences(JSON.stringify({ reporterWidth: 100 }), 'global')
})
cy.scaffoldProject('todos')
@@ -271,7 +271,7 @@ describe('Sidebar Navigation', { viewportWidth: 1280 }, () => {
.trigger('mouseup', { eventConstructor: 'MouseEvent' })
cy.withRetryableCtx((ctx, o) => {
expect((ctx.actions.localSettings.setPreferences as SinonStub).lastCall.lastArg).to.eq('{"reporterWidth":336}')
expect((ctx.actions.localSettings.setPreferences as SinonStub).lastCall.args[0]).to.eq('{"reporterWidth":336}')
})
})

View File

@@ -1,10 +1,14 @@
import { getPathForPlatform } from '../../src/paths'
describe('App: Spec List (E2E)', () => {
beforeEach(() => {
const launchApp = (specFilter?: string) => {
cy.scaffoldProject('cypress-in-cypress')
cy.openProject('cypress-in-cypress')
cy.startAppServer('e2e')
cy.startAppServer('e2e', {
// we can't use skipMockingPrompts when we mock saved state for the spec filter
// due to it already being wrapped in startAppServer(), so we skip if a specFilter is passed
skipMockingPrompts: Boolean(specFilter),
})
cy.withCtx((ctx, o) => {
const yesterday = new Date()
@@ -21,197 +25,247 @@ describe('App: Spec List (E2E)', () => {
shortHash: '1234567890',
}
})
if (o.specFilter) {
o.sinon.stub(ctx._apis.projectApi, 'getCurrentProjectSavedState').resolves({
// avoid prompts being shown
firstOpened: 1609459200000,
lastOpened: 1609459200000,
promptsShown: { ci1: 1609459200000 },
// set the desired spec filter value
specFilter: o.specFilter,
})
}
}, {
specFilter,
})
cy.visitApp()
cy.contains('E2E specs')
})
}
it('shows the "Specs" navigation as highlighted in the lefthand nav bar', () => {
cy.findByTestId('sidebar').within(() => {
cy.findByTestId('sidebar-link-specs-page').should('be.visible')
cy.findByTestId('sidebar-link-specs-page').click()
const clearSearchAndType = (search: string) => {
return cy.get('@searchField').clear().type(search)
}
context('with no saved spec filter', () => {
beforeEach(() => {
launchApp()
})
cy.findByTestId('sidebar-link-specs-page').find('[data-selected="true"]').should('be.visible')
})
it('shows the "Specs" navigation as highlighted in the lefthand nav bar', () => {
cy.findByTestId('sidebar').within(() => {
cy.findByTestId('sidebar-link-specs-page').should('be.visible')
cy.findByTestId('sidebar-link-specs-page').click()
})
it('displays the App Top Nav', () => {
cy.get('[data-cy="app-header-bar"]').should('be.visible')
cy.get('[data-cy="app-header-bar"]').findByText('Specs').should('be.visible')
})
it('shows the "E2E specs" label as the header for the Spec Name column', () => {
cy.get('[data-cy="specs-testing-type-header"]').should('contain', 'E2E specs')
})
it('shows a git status for each spec', () => {
cy.get('[data-cy="git-info-row"]').each((row) => {
cy.wrap(row).find('svg').should('have.length', 1)
cy.findByTestId('sidebar-link-specs-page').find('[data-selected="true"]').should('be.visible')
})
})
it('collapses or expands folders when clicked, hiding or revealing the specs within it', () => {
cy.get('[data-cy="spec-item"]').should('contain', 'dom-content.spec.js')
cy.get('[data-cy="row-directory-depth-0"]').click()
cy.get('[data-cy="spec-item"]').should('not.exist')
cy.get('[data-cy="row-directory-depth-0"]').click()
cy.get('[data-cy="spec-item"]').should('contain', 'dom-content.spec.js')
})
it('displays the App Top Nav', () => {
cy.findByTestId('app-header-bar').should('be.visible')
cy.findByTestId('app-header-bar').findByText('Specs').should('be.visible')
})
it('opens the "Create a new spec" modal after clicking the "New Specs" button', () => {
cy.get('[data-cy="standard-modal"]').should('not.exist')
cy.get('[data-cy="new-spec-button"]').click()
cy.get('[data-cy="standard-modal"]').get('h2').contains('Create a new spec')
cy.get('button').contains('Scaffold example specs').should('be.visible')
cy.get('button').contains('Create new empty spec').should('be.visible')
cy.get('button').get('[aria-label="Close"]').click()
cy.get('[data-cy="standard-modal"]').should('not.exist')
})
it('shows the "E2E specs" label as the header for the Spec Name column', () => {
cy.findByTestId('specs-testing-type-header').should('contain', 'E2E specs')
})
it('has the correct defaultSpecFileName in the "Create a new spec" modal', () => {
cy.get('[data-cy="standard-modal"]').should('not.exist')
cy.get('[data-cy="new-spec-button"]').click()
cy.get('[data-cy="standard-modal"]').get('h2').contains('Create a new spec')
cy.get('button').contains('Scaffold example specs').should('be.visible')
cy.get('button').contains('Create new empty spec').should('be.visible').click()
cy.get('input').get('[aria-label="Enter a relative path..."]').invoke('val').should('contain', getPathForPlatform('cypress/e2e/spec.spec.js'))
})
it('shows a git status for each spec', () => {
cy.findAllByTestId('git-info-row').each((row) => {
cy.wrap(row).find('svg').should('have.length', 1)
})
})
it('has an <a> tag in the Spec File Row that runs the selected spec when clicked', () => {
cy.get('[data-selected-spec="true"]').should('not.exist')
cy.get('[data-cy="spec-item-link"]').should('have.attr', 'href')
cy.get('[data-cy="spec-item-link"]').contains('dom-content.spec.js').click()
it('collapses or expands folders when clicked, hiding or revealing the specs within it', () => {
cy.findAllByTestId('spec-item').should('contain', 'dom-content.spec.js')
cy.findByTestId('row-directory-depth-0').click()
cy.findAllByTestId('spec-item').should('not.exist')
cy.findByTestId('row-directory-depth-0').click()
cy.findAllByTestId('spec-item').should('contain', 'dom-content.spec.js')
})
cy.contains('[aria-controls=reporter-inline-specs-list]', 'Specs')
cy.findByText('Your tests are loading...').should('not.be.visible')
cy.get('body').type('f')
it('opens the "Create a new spec" modal after clicking the "New Specs" button', () => {
cy.findByTestId('standard-modal').should('not.exist')
cy.findByTestId('new-spec-button').click()
cy.findByTestId('standard-modal').get('h2').contains('Create a new spec')
cy.get('button').contains('Scaffold example specs').should('be.visible')
cy.get('button').contains('Create new empty spec').should('be.visible')
cy.get('button').get('[aria-label="Close"]').click()
cy.findByTestId('standard-modal').should('not.exist')
})
cy.get('[data-selected-spec="true"]').contains('dom-content.spec.js')
cy.get('[data-cy="runnable-header"]').should('be.visible')
})
it('has the correct defaultSpecFileName in the "Create a new spec" modal', () => {
cy.findByTestId('standard-modal').should('not.exist')
cy.findByTestId('new-spec-button').click()
cy.findByTestId('standard-modal').get('h2').contains('Create a new spec')
cy.get('button').contains('Scaffold example specs').should('be.visible')
cy.get('button').contains('Create new empty spec').should('be.visible').click()
cy.get('input').get('[aria-label="Enter a relative path..."]').invoke('val').should('contain', getPathForPlatform('cypress/e2e/spec.spec.js'))
cy.get('button').get('[aria-label="Close"]').click()
})
it('cannot open the Spec File Row link in a new tab with "cmd + click"', (done) => {
let numTargets
let newNumTargets
it('has an <a> tag in the Spec File Row that runs the selected spec when clicked', () => {
cy.get('[data-selected-spec="true"]').should('not.exist')
cy.findAllByTestId('spec-item-link').should('have.attr', 'href')
cy.findAllByTestId('spec-item-link').contains('dom-content.spec.js').click()
Cypress.automation('remote:debugger:protocol', { command: 'Target.getTargets' }).then((res) => {
numTargets = res.targetInfos.length
cy.contains('[aria-controls=reporter-inline-specs-list]', 'Specs')
cy.findByText('Your tests are loading...').should('not.be.visible')
cy.get('body').type('f')
cy.get('[data-cy="spec-item-link"]').first().click({ metaKey: true }).then(async () => {
await Cypress.automation('remote:debugger:protocol', { command: 'Target.getTargets' }).then((res) => {
newNumTargets = res.targetInfos.length
cy.get('[data-selected-spec="true"]').contains('dom-content.spec.js')
cy.findByTestId('runnable-header').should('be.visible')
})
it('cannot open the Spec File Row link in a new tab with "cmd + click"', (done) => {
let numTargets
let newNumTargets
Cypress.automation('remote:debugger:protocol', { command: 'Target.getTargets' }).then((res) => {
numTargets = res.targetInfos.length
cy.findAllByTestId('spec-item-link').first().click({ metaKey: true }).then(async () => {
await Cypress.automation('remote:debugger:protocol', { command: 'Target.getTargets' }).then((res) => {
newNumTargets = res.targetInfos.length
})
expect(numTargets).to.eq(newNumTargets)
done()
})
})
})
expect(numTargets).to.eq(newNumTargets)
describe('typing the filter', function () {
beforeEach(() => {
cy.findByLabelText('Search Specs').as('searchField')
})
done()
it('displays only matching spec', function () {
cy.get('button')
.contains('23 Matches')
.should('not.contain.text', 'of')
clearSearchAndType('content')
cy.findAllByTestId('spec-item')
.should('have.length', 2)
.and('contain', 'dom-content.spec.js')
cy.get('button').contains('2 of 23 Matches')
cy.findByLabelText('Search Specs').clear().type('asdf')
cy.findAllByTestId('spec-item')
.should('have.length', 0)
cy.get('button').contains('0 of 23 Matches')
})
it('only shows matching folders', () => {
clearSearchAndType('new')
cy.findAllByTestId('spec-list-directory')
.should('have.length', 1)
clearSearchAndType('admin')
cy.findAllByTestId('spec-list-directory')
.should('have.length', 2)
})
it('ignores non-letter characters', function () {
clearSearchAndType('appspec')
cy.findByTestId('spec-item')
.should('have.length', 1)
.and('contain', 'app.spec.js')
})
it('ignores non-number characters', function () {
clearSearchAndType('123spec')
cy.findByTestId('spec-item')
.should('have.length', 1)
.and('contain', '123.spec.js')
})
it('ignores commonly used path characters', function () {
clearSearchAndType('defg')
cy.findByTestId('spec-item')
.should('have.length', 1)
.and('contain', 'd~e(f)g.spec.js')
})
it('treats non-Latin characters as letters', function () {
clearSearchAndType('柏树很棒')
cy.findByTestId('spec-item')
.should('have.length', 1)
.and('contain', '柏树很棒.spec.js')
})
it('clears the filter on search bar clear button click', function () {
clearSearchAndType('123')
cy.findByLabelText('Clear search field').click()
cy.findByLabelText('Search Specs')
.should('have.value', '')
cy.get('button').contains('23 Matches')
})
it('clears the filter if the user presses ESC key', function () {
clearSearchAndType('123')
cy.get('@searchField').realType('{esc}')
cy.get('@searchField').should('have.value', '')
cy.get('button').contains('23 Matches')
})
it('shows empty message if no results', function () {
clearSearchAndType('foobarbaz')
cy.findByTestId('spec-item').should('not.exist')
cy.findByText('No specs matched your search:')
})
it('clears and focuses the filter field when clear search is clicked', function () {
clearSearchAndType('asdf')
cy.findByText('Clear Search').click()
cy.focused().should('have.id', 'spec-filter')
cy.get('button').contains('23 Matches')
})
it('saves the filter when navigating to a spec and back', function () {
const targetSpecFile = 'accounts_list.spec.js'
clearSearchAndType(targetSpecFile)
cy.contains('a', targetSpecFile).click()
cy.contains('input', targetSpecFile).should('not.exist')
cy.get('button[aria-controls="reporter-inline-specs-list"]').click({ force: true })
cy.get('input').should('be.visible').and('have.value', targetSpecFile)
cy.findByTestId('sidebar-link-specs-page').click()
// make sure we are back on the main specs list
cy.location().its('hash').should('eq', '#/specs')
cy.get('input').should('have.value', targetSpecFile)
})
})
})
describe('typing the filter', function () {
it('displays only matching spec', function () {
cy.get('button').contains('23 Matches')
cy.findByLabelText('Search Specs').type('content')
cy.get('[data-cy="spec-item"]')
.should('have.length', 2)
.and('contain', 'dom-content.spec.js')
context('with a saved spec filter', () => {
it('starts with saved filter when one is present', function () {
const targetSpecFile = 'accounts_new.spec.js'
cy.get('button').contains('2 of 23 Matches')
launchApp(targetSpecFile)
cy.findByLabelText('Search Specs').clear().type('asdf')
cy.get('[data-cy="spec-item"]')
.should('have.length', 0)
cy.get('button').contains('0 of 23 Matches')
})
it('only shows matching folders', () => {
cy.findByLabelText('Search Specs').type('new')
cy.get('[data-cy="spec-list-directory"]')
.should('have.length', 1)
cy.findByLabelText('Search Specs').clear().type('admin')
cy.get('[data-cy="spec-list-directory"]')
.should('have.length', 2)
})
it('ignores non-letter characters', function () {
cy.findByLabelText('Search Specs').clear().type('appspec')
cy.get('[data-cy="spec-item"]')
.should('have.length', 1)
.and('contain', 'app.spec.js')
})
it('ignores non-number characters', function () {
cy.findByLabelText('Search Specs').clear().type('123spec')
cy.get('[data-cy="spec-item"]')
.should('have.length', 1)
.and('contain', '123.spec.js')
})
it('ignores commonly used path characters', function () {
cy.findByLabelText('Search Specs').clear().type('defg')
cy.get('[data-cy="spec-item"]')
.should('have.length', 1)
.and('contain', 'd~e(f)g.spec.js')
})
it('treats non-Latin characters as letters', function () {
cy.findByLabelText('Search Specs').clear().type('柏树很棒')
cy.get('[data-cy="spec-item"]')
.should('have.length', 1)
.and('contain', '柏树很棒.spec.js')
})
// TODO: https://cypress-io.atlassian.net/browse/UNIFY-682
it.skip('clears the filter on search bar clear button click', function () {
cy.get('.clear-filter').click()
cy.findByLabelText('Search Specs')
.should('have.value', '')
cy.get('[data-cy="spec-item"]')
.should('have.length', 23)
})
it('clears the filter if the user presses ESC key', function () {
cy.findByLabelText('Search Specs').type('asdf')
cy.findByLabelText('Search Specs').realType('{esc}')
cy.findByLabelText('Search Specs')
.should('have.value', '')
cy.get('button').contains('23 Matches')
})
it('shows empty message if no results', function () {
cy.findByLabelText('Search Specs').clear().type('foobarbaz')
cy.get('[data-cy="spec-item"]').should('not.exist')
cy.findByText('No specs matched your search:')
})
it('clears and focuses the filter field when clear search is clicked', function () {
cy.findByLabelText('Search Specs').type('asdf')
cy.findByText('Clear Search').click()
cy.focused().should('have.id', 'spec-filter')
cy.get('button').contains('23 Matches')
})
//TODO: https://cypress-io.atlassian.net/browse/UNIFY-1588
it.skip('saves the filter to local storage for the project', function () {
cy.window().then((win) => {
expect(win.localStorage[`specsFilter-${this.config.projectId}-/foo/bar`]).to.be.a('string')
expect(JSON.parse(win.localStorage[`specsFilter-${this.config.projectId}-/foo/bar`])).to.equal('new')
})
cy.findByLabelText('Search Specs').should('have.value', targetSpecFile)
})
})
})

View File

@@ -5,7 +5,7 @@ import { Preferences_SetPreferencesDocument } from '@packages/app/src/generated/
gql`
mutation Preferences_SetPreferences ($value: String!) {
setPreferences (value: $value) {
setPreferences (value: $value, type: global) {
...TestingPreferences
...SpecRunner_Preferences
}

View File

@@ -0,0 +1,44 @@
import { useSpecStore } from '../store'
import { useMutation, gql } from '@urql/vue'
import { ref, watch } from 'vue'
import { useDebounce } from '@vueuse/core'
import { SpecFilter_SetPreferencesDocument } from '@packages/app/src/generated/graphql'
gql`
mutation SpecFilter_SetPreferences ($value: String!) {
setPreferences (value: $value, type: project) {
...TestingPreferences
...SpecRunner_Preferences
}
}`
export function useSpecFilter (savedFilter?: string) {
const specStore = useSpecStore()
const saveSpecFilter = useMutation(SpecFilter_SetPreferencesDocument)
// prefer a filter from client side store, saved filter in gql can be stale
// and is only used to set the value in the store on first load
const initialFilter = specStore.specFilter ?? savedFilter ?? ''
const specFilterModel = ref(initialFilter)
const debouncedSpecFilterModel = useDebounce(specFilterModel, 200)
function setSpecFilter (specFilter: string) {
if (specStore.specFilter !== specFilter) {
specStore.setSpecFilter(specFilter)
saveSpecFilter.executeMutation({ value: JSON.stringify({ specFilter }) })
}
}
watch(() => debouncedSpecFilterModel?.value, (newVal) => {
setSpecFilter(newVal ?? '')
})
// initialize spec filter in store
setSpecFilter(specFilterModel.value)
return {
specFilterModel,
debouncedSpecFilterModel,
}
}

View File

@@ -131,7 +131,7 @@ fragment SidebarNavigation on Query {
gql`
mutation SideBarNavigation_SetPreferences ($value: String!) {
setPreferences (value: $value) {
setPreferences (value: $value, type: global) {
...SidebarNavigation
}
}`

View File

@@ -26,7 +26,7 @@ import { useMutation } from '@urql/vue'
gql`
mutation ExternalEditorSettings_SetPreferredEditorBinary ($value: String!) {
setPreferences (value: $value) {
setPreferences (value: $value, type: global) {
...ExternalEditorSettings
}
}`

View File

@@ -49,7 +49,7 @@ fragment TestingPreferences on Query {
gql`
mutation SetTestingPreferences($value: String!) {
setPreferences (value: $value) {
setPreferences (value: $value, type: global) {
...TestingPreferences
}
}`

View File

@@ -1,21 +1,20 @@
import { Specs_InlineSpecListFragment, Specs_InlineSpecListFragmentDoc } from '../generated/graphql-test'
import { Specs_InlineSpecListFragment, Specs_InlineSpecListFragmentDoc, SpecFilter_SetPreferencesDocument } from '../generated/graphql-test'
import InlineSpecList from './InlineSpecList.vue'
import { defaultMessages } from '@cy/i18n'
let specs: Array<any> = []
describe('InlineSpecList', () => {
beforeEach(() => {
cy.fixture('found-specs').then((foundSpecs) => specs = foundSpecs)
})
const mountInlineSpecList = () => cy.mountFragment(Specs_InlineSpecListFragmentDoc, {
const mountInlineSpecList = (specFilter?: string) => cy.mountFragment(Specs_InlineSpecListFragmentDoc, {
onResult: (ctx) => {
if (!ctx.currentProject?.specs) {
return ctx
}
specs = ctx.currentProject.specs = specs.map((spec) => ({ __typename: 'Spec', ...spec, id: spec.relative }))
if (specFilter) {
ctx.currentProject.savedState = { specFilter }
}
return ctx
},
@@ -28,108 +27,156 @@ describe('InlineSpecList', () => {
},
})
it('should render a list of specs', () => {
mountInlineSpecList()
cy.get('li')
.should('exist')
.and('have.length', 7)
describe('with no saved search term', () => {
beforeEach(() => {
cy.fixture('found-specs').then((foundSpecs) => specs = foundSpecs)
})
// overflow is required for the virtual list to work
// this test will fail if the overflow set by `useVirtualList`
// is overridden
cy.get('[data-cy="specs-list-container"]')
.should('have.css', 'overflow-y', 'auto')
it('should render a list of specs', () => {
mountInlineSpecList()
cy.get('li')
.should('exist')
.and('have.length', 7)
cy.percySnapshot()
})
// overflow is required for the virtual list to work
// this test will fail if the overflow set by `useVirtualList`
// is overridden
cy.get('[data-cy="specs-list-container"]')
.should('have.css', 'overflow-y', 'auto')
it('should support fuzzy sort', () => {
mountInlineSpecList()
cy.get('input').type('scome', { force: true })
cy.percySnapshot()
})
cy.get('li').should('have.length', 4)
.should('contain', 'src/components')
.and('contain', 'Spec-A.spec.tsx')
})
it('should support fuzzy sort', () => {
mountInlineSpecList()
cy.get('input').type('scome', { force: true })
it('should open CreateSpec modal', () => {
mountInlineSpecList()
const newSpecSelector = `[aria-label="New Spec"]`
cy.get('li').should('have.length', 4)
.should('contain', 'src/components')
.and('contain', 'Spec-A.spec.tsx')
})
cy.get(newSpecSelector).click()
cy.contains(defaultMessages.createSpec.newSpecModalTitle).should('be.visible')
it('should open CreateSpec modal', () => {
mountInlineSpecList()
const newSpecSelector = `[aria-label="New Spec"]`
cy.percySnapshot()
})
cy.get(newSpecSelector).click()
cy.contains(defaultMessages.createSpec.newSpecModalTitle).should('be.visible')
it('should handle spec refresh', () => {
const scrollVirtualList = (lastItem: string) => {
cy.findAllByTestId('spec-row-item').last().dblclick().then(($el) => {
if (!$el.text().includes(lastItem)) {
scrollVirtualList(lastItem)
}
})
}
cy.percySnapshot()
})
let _gqlValue: Specs_InlineSpecListFragment
it('should handle spec refresh', () => {
const scrollVirtualList = (lastItem: string) => {
cy.findAllByTestId('spec-row-item').last().dblclick().then(($el) => {
if (!$el.text().includes(lastItem)) {
scrollVirtualList(lastItem)
}
})
}
cy.mountFragment(Specs_InlineSpecListFragmentDoc, {
onResult (ctx) {
if (ctx.currentProject?.specs) {
ctx.currentProject.specs = ctx.currentProject.specs.slice(0, 50)
}
let _gqlValue: Specs_InlineSpecListFragment
return ctx
},
render (gqlValue) {
_gqlValue = gqlValue
cy.mountFragment(Specs_InlineSpecListFragmentDoc, {
onResult (ctx) {
if (ctx.currentProject?.specs) {
ctx.currentProject.specs = ctx.currentProject.specs.slice(0, 50)
}
return (
<div class="bg-gray-1000">
<InlineSpecList gql={gqlValue}></InlineSpecList>
</div>
)
},
}).then(() => {
const sortedSpecs = _gqlValue?.currentProject?.specs.sort((a, b) => a.relative < b.relative ? -1 : 1) || []
const firstSpec = sortedSpecs[0]
const lastSpec = sortedSpecs[sortedSpecs.length - 1]
return ctx
},
render (gqlValue) {
_gqlValue = gqlValue
cy.contains(firstSpec.fileName).should('be.visible')
scrollVirtualList(lastSpec.fileName)
cy.contains(lastSpec.fileName).should('be.visible')
cy.then(() => {
return (
<div class="bg-gray-1000">
<InlineSpecList gql={gqlValue}></InlineSpecList>
</div>
)
},
}).then(() => {
const sortedSpecs = _gqlValue?.currentProject?.specs.sort((a, b) => a.relative < b.relative ? -1 : 1) || []
const firstSpec = sortedSpecs[0]
const lastSpec = sortedSpecs[sortedSpecs.length - 1]
cy.contains(firstSpec.fileName).should('be.visible')
scrollVirtualList(lastSpec.fileName)
cy.contains(lastSpec.fileName).should('be.visible')
cy.then(() => {
// Emulating a gql update that shouldn't cause a scroll snap
if (_gqlValue.currentProject?.specs) {
_gqlValue.currentProject.specs = [..._gqlValue.currentProject.specs]
}
})
if (_gqlValue.currentProject?.specs) {
_gqlValue.currentProject.specs = [..._gqlValue.currentProject.specs]
}
})
cy.contains(lastSpec.fileName).should('be.visible')
cy.contains(lastSpec.fileName).should('be.visible')
const newSpec = { ...lastSpec, relative: 'zzz/my-test.spec.tsx', fileName: 'my-test' }
const newSpec = { ...lastSpec, relative: 'zzz/my-test.spec.tsx', fileName: 'my-test' }
cy.then(() => {
cy.then(() => {
// Checking that specs list refreshes when spec is added
if (_gqlValue.currentProject?.specs) {
_gqlValue.currentProject.specs = _gqlValue.currentProject.specs.concat(newSpec)
}
})
if (_gqlValue.currentProject?.specs) {
_gqlValue.currentProject.specs = _gqlValue.currentProject.specs.concat(newSpec)
}
})
cy.contains(firstSpec.fileName).should('be.visible')
scrollVirtualList(newSpec.fileName)
cy.contains(newSpec.fileName).should('be.visible')
cy.contains(firstSpec.fileName).should('be.visible')
scrollVirtualList(newSpec.fileName)
cy.contains(newSpec.fileName).should('be.visible')
cy.then(() => {
cy.then(() => {
// Checking that specs list refreshes when spec is deleted
if (_gqlValue.currentProject?.specs) {
_gqlValue.currentProject.specs = _gqlValue.currentProject.specs.filter(((spec) => spec.relative !== newSpec.relative))
}
if (_gqlValue.currentProject?.specs) {
_gqlValue.currentProject.specs = _gqlValue.currentProject.specs.filter(((spec) => spec.relative !== newSpec.relative))
}
})
cy.contains(firstSpec.fileName).should('be.visible')
scrollVirtualList(lastSpec.fileName)
cy.contains(newSpec.fileName).should('not.exist')
})
})
})
describe('with a saved spec filter', () => {
beforeEach(() => {
cy.fixture('found-specs').then((foundSpecs) => specs = foundSpecs)
mountInlineSpecList('saved-search-term 🗑')
cy.findByLabelText(defaultMessages.specPage.searchPlaceholder)
.as('searchField')
cy.findByLabelText(defaultMessages.specPage.clearSearch, { selector: 'button' })
.as('searchFieldClearButton')
})
it('starts with the saved filter', () => {
cy.get('@searchField').should('have.value', 'saved-search-term 🗑')
cy.get('li').should('not.exist')
cy.get('@searchFieldClearButton').click()
cy.get('li').should('have.length.greaterThan', 0)
})
it('calls gql mutation to save updated filter', () => {
const setSpecFilterStub = cy.stub()
cy.stubMutationResolver(SpecFilter_SetPreferencesDocument, (defineResult, variables) => {
const specFilter = JSON.parse(variables.value)?.specFilter
setSpecFilterStub(specFilter)
})
cy.contains(firstSpec.fileName).should('be.visible')
scrollVirtualList(lastSpec.fileName)
cy.contains(newSpec.fileName).should('not.exist')
// since there is a saved search, clear it out
cy.get('@searchFieldClearButton').click()
cy.get('@searchField').type('test')
cy.wrap(setSpecFilterStub).should('have.been.calledWith', 'test')
cy.get('@searchField').type('{backspace}{backspace}')
cy.wrap(setSpecFilterStub).should('have.been.calledWith', 'te')
cy.get('@searchField').type('{backspace}{backspace}')
cy.wrap(setSpecFilterStub).should('have.been.calledWith', '')
})
})
})

View File

@@ -7,7 +7,7 @@
@close="showModal = false"
/>
<InlineSpecListHeader
v-model:search="search"
v-model:specFilterModel="specFilterModel"
:result-count="specs.length"
@newSpec="showModal = true"
/>
@@ -30,7 +30,7 @@ import InlineSpecListTree from './InlineSpecListTree.vue'
import CreateSpecModal from './CreateSpecModal.vue'
import { fuzzySortSpecs, makeFuzzyFoundSpec, useCachedSpecs } from './spec-utils'
import type { FuzzyFoundSpec } from './spec-utils'
import { useDebounce } from '@vueuse/core'
import { useSpecFilter } from '../composables/useSpecFilter'
gql`
fragment SpecNode_InlineSpecList on Spec {
@@ -53,6 +53,7 @@ fragment Specs_InlineSpecList on Query {
id
projectRoot
currentTestingType
savedState
specs {
id
...SpecNode_InlineSpecList
@@ -67,16 +68,16 @@ const props = defineProps<{
const showModal = ref(false)
const search = ref('')
const debouncedSearchString = useDebounce(search, 200)
const { debouncedSpecFilterModel, specFilterModel } = useSpecFilter(props.gql.currentProject?.savedState?.specFilter)
const cachedSpecs = useCachedSpecs(computed(() => (props.gql.currentProject?.specs) || []))
const specs = computed<FuzzyFoundSpec[]>(() => {
const specs = cachedSpecs.value.map((x) => makeFuzzyFoundSpec(x))
if (!debouncedSearchString.value) return specs
if (!debouncedSpecFilterModel.value) return specs
return fuzzySortSpecs(specs, debouncedSearchString.value)
return fuzzySortSpecs(specs, debouncedSpecFilterModel.value)
})
</script>

View File

@@ -4,21 +4,21 @@ import { defaultMessages } from '@cy/i18n'
describe('InlineSpecListHeader', () => {
const mountWithResultCount = (resultCount = 0) => {
const search = ref('')
const specFilterModel = ref('')
const onNewSpec = cy.spy().as('new-spec')
cy.wrap(search).as('search')
cy.wrap(specFilterModel).as('specFilterModel')
const methods = {
'onUpdate:search': (val: string) => {
search.value = val
'onUpdate:specFilterModel': (val: string) => {
specFilterModel.value = val
},
onNewSpec,
}
cy.mount(() =>
(<div class="bg-gray-1000">
<InlineSpecListHeader {...methods} search={search.value} resultCount={resultCount} />
<InlineSpecListHeader {...methods} specFilterModel={specFilterModel.value} resultCount={resultCount} />
</div>))
}
@@ -29,7 +29,7 @@ describe('InlineSpecListHeader', () => {
cy.findByLabelText(defaultMessages.specPage.searchPlaceholder)
// `force` necessary due to the field label being overlaid on top of the input
.type(searchString, { delay: 0, force: true })
.get('@search').its('value').should('eq', searchString)
.get('@specFilterModel').its('value').should('eq', searchString)
})
it('should emit add spec', () => {
@@ -49,10 +49,10 @@ describe('InlineSpecListHeader', () => {
cy.findByLabelText(defaultMessages.specPage.searchPlaceholder)
// `force` necessary due to the field label being overlaid on top of the input
.type('abcd', { delay: 0, force: true })
.get('@search').its('value').should('eq', 'abcd')
.get('@specFilterModel').its('value').should('eq', 'abcd')
cy.findByTestId('clear-search-button').click()
cy.get('@search').its('value').should('eq', '')
cy.get('@specFilterModel').its('value').should('eq', '')
})
it('exposes the result count correctly to assistive tech', () => {

View File

@@ -7,8 +7,7 @@
@click="input?.focus()"
>
<div
class="flex h-full inset-y-0 w-32px absolute items-center"
@mousedown.prevent.stop
class="flex h-full inset-y-0 w-32px absolute items-center pointer-events-none"
>
<i-cy-magnifying-glass_x16
:class="inputFocused ? 'icon-dark-indigo-300' : 'icon-dark-gray-800'"
@@ -27,8 +26,8 @@
placeholder-gray-700
text-gray-500
"
:class="inputFocused || props.search.length ? 'w-full' : 'w-16px'"
:value="props.search"
:class="inputFocused || props.specFilterModel.length ? 'w-full' : 'w-16px'"
:value="props.specFilterModel"
type="search"
minlength="1"
autocapitalize="off"
@@ -40,15 +39,15 @@
>
<label
for="inline-spec-list-header-search"
class="cursor-text font-light bottom-4px left-24px text-gray-500 select-none absolute"
class="cursor-text font-light bottom-4px left-24px text-gray-500 pointer-events-none absolute"
:class="{
'sr-only': inputFocused || props.search
'sr-only': inputFocused || props.specFilterModel
}"
>
{{ t('specPage.searchPlaceholder') }}
</label>
<button
v-if="props.search"
v-if="props.specFilterModel"
type="button"
data-cy="clear-search-button"
class="border-transparent rounded-md flex outline-none h-24px my-4px inset-y-0 right-0 w-24px duration-300 absolute items-center justify-center group hocus-default hocus:ring-0"
@@ -96,12 +95,12 @@ import { useI18n } from '@cy/i18n'
const { t } = useI18n()
const props = defineProps<{
search: string
specFilterModel: string
resultCount: number
}>()
const emit = defineEmits<{
(e: 'update:search', search: string): void
(e: 'update:specFilterModel', specFilterModel: string): void
(e: 'newSpec'): void
}>()
@@ -111,11 +110,11 @@ const input = ref<HTMLInputElement>()
const onInput = (e: Event) => {
const value = (e.target as HTMLInputElement).value
emit('update:search', value)
emit('update:specFilterModel', value)
}
const clearInput = (e: Event) => {
emit('update:search', '')
emit('update:specFilterModel', '')
}
</script>

View File

@@ -1,11 +1,11 @@
import SpecsList from './SpecsList.vue'
import { Specs_SpecsListFragmentDoc, SpecsListFragment, TestingTypeEnum } from '../generated/graphql-test'
import { Specs_SpecsListFragmentDoc, SpecsListFragment, TestingTypeEnum, SpecFilter_SetPreferencesDocument } from '../generated/graphql-test'
import { defaultMessages } from '@cy/i18n'
describe('<SpecsList />', { keystrokeDelay: 0 }, () => {
let specs: Array<SpecsListFragment>
function mountWithTestingType (testingType: TestingTypeEnum | undefined) {
function mountWithTestingType (testingType: TestingTypeEnum | undefined, specFilter?: string) {
specs = []
const showCreateSpecModalSpy = cy.spy().as('showCreateSpecModalSpy')
@@ -24,6 +24,10 @@ describe('<SpecsList />', { keystrokeDelay: 0 }, () => {
ctx.currentProject.currentTestingType = testingType
}
if (specFilter) {
ctx.currentProject.savedState = { specFilter }
}
return ctx
},
render: (gqlVal) => {
@@ -33,143 +37,210 @@ describe('<SpecsList />', { keystrokeDelay: 0 }, () => {
}
context('when testingType is unset', () => {
beforeEach(() => {
mountWithTestingType(undefined)
})
describe('with no saved filter', () => {
beforeEach(() => {
mountWithTestingType(undefined)
})
it('should filter specs', () => {
it('should filter specs', () => {
// make sure things have rendered for snapshot
// and that only a subset of the specs are displayed
// (this means the virtualized list is working)
cy.get('[data-cy="spec-list-file"]')
.should('have.length.above', 2)
.should('have.length.below', specs.length)
cy.percySnapshot('full list')
const longestSpec = specs.reduce((acc, spec) =>
acc.relative.length < spec.relative.length ? spec : acc
, specs[0])
cy.findByLabelText(defaultMessages.specPage.searchPlaceholder)
.as('specsListInput')
cy.get('@specsListInput').type('garbage 🗑', { delay: 0 })
.get('[data-cy-spec-list-file]')
.should('not.exist')
.get('[data-cy-spec-list-directory]')
.should('not.exist')
cy.contains(`${defaultMessages.specPage.noResultsMessage} garbage 🗑`)
.should('be.visible')
cy.percySnapshot('no results')
cy.get('[data-cy="no-results-clear"]').click()
cy.get('@specsListInput').invoke('val').should('be.empty')
// validate that something re-populated in the specs list
cy.get('[data-cy="spec-list-file"]').should('have.length.above', 2)
cy.get('@specsListInput').type(longestSpec.fileName)
cy.get('[data-cy="spec-list-directory"]').first()
.should('contain', longestSpec.relative.replace(`/${longestSpec.fileName}${longestSpec.specFileExtension}`, ''))
cy.get('[data-cy="spec-list-file"]').last().within(() => {
cy.contains('a', longestSpec.baseName)
.should('be.visible')
.and('have.attr', 'href', `#/specs/runner?file=${longestSpec.relative}`)
})
const directory = longestSpec.relative.slice(0, longestSpec.relative.lastIndexOf('/'))
cy.get('@specsListInput').clear().type(directory)
cy.get('[data-cy="spec-list-directory"]').first().should('contain', directory)
cy.percySnapshot('matching directory search')
// test interactions
const directories: string[] = Array.from(new Set(specs.map((spec) => spec.relative.split('/')[0]))).sort()
cy.get('@specsListInput').clear()
directories.forEach((dir) => {
cy.contains('button[data-cy="row-directory-depth-0"]', new RegExp(`^${dir}`))
.should('have.attr', 'aria-expanded', 'true')
.click()
.should('have.attr', 'aria-expanded', 'false')
})
cy.get('[data-cy="spec-item"]').should('not.exist')
cy.contains('button[data-cy="row-directory-depth-0"]', directories[0])
.should('have.attr', 'aria-expanded', 'false')
.focus()
.type('{enter}')
cy.contains('button[data-cy="row-directory-depth-0"]', directories[0])
.should('have.attr', 'aria-expanded', 'true')
.focus()
.realPress('Space')
cy.contains('button[data-cy="row-directory-depth-0"]', directories[0])
.should('have.attr', 'aria-expanded', 'false')
cy.get('[data-cy="spec-item"]').should('not.exist')
cy.contains(defaultMessages.createSpec.newSpec).click()
cy.get('@showCreateSpecModalSpy').should('have.been.calledOnce')
})
describe('responsive behavior', () => {
// Spec name (first) column is handled by type-specific tests below
it('should display last updated column', () => {
cy.findByTestId('last-updated-header').as('header')
cy.get('@header').should('be.visible').and('contain', 'Last updated')
})
context('when screen is wide', { viewportWidth: 1200 }, () => {
it('should display latest runs column with full text', () => {
cy.findByTestId('latest-runs-header').within(() => {
cy.findByTestId('short-header-text').should('not.be.visible')
cy.findByTestId('full-header-text').should('be.visible')
.and('have.text', 'Latest runs')
})
})
it('should display average duration column with full text', () => {
cy.findByTestId('average-duration-header').within(() => {
cy.findByTestId('short-header-text').should('not.be.visible')
cy.findByTestId('full-header-text').should('be.visible')
.and('have.text', 'Average duration')
})
})
})
context('when screen is narrow', { viewportWidth: 800 }, () => {
it('should display latest runs column with short text', () => {
cy.findByTestId('latest-runs-header').within(() => {
cy.findByTestId('full-header-text').should('not.be.visible')
cy.findByTestId('short-header-text').should('be.visible')
.and('have.text', 'Runs')
})
})
it('should display average duration column with full text', () => {
cy.findByTestId('average-duration-header').within(() => {
cy.findByTestId('full-header-text').should('not.be.visible')
cy.findByTestId('short-header-text').should('be.visible')
.and('have.text', 'Duration')
})
})
})
it('displays the list as expected visually at various widths', () => {
cy.get('[data-cy="spec-list-file"]')
.should('have.length.above', 2)
.should('have.length.below', specs.length)
cy.percySnapshot('full list')
const longestSpec = specs.reduce((acc, spec) =>
acc.relative.length < spec.relative.length ? spec : acc
, specs[0])
cy.findByLabelText(defaultMessages.specPage.searchPlaceholder)
.as('specsListInput')
cy.get('@specsListInput').type('garbage 🗑', { delay: 0 })
.get('[data-cy-spec-list-file]')
.should('not.exist')
.get('[data-cy-spec-list-directory]')
.should('not.exist')
cy.contains(`${defaultMessages.specPage.noResultsMessage} garbage 🗑`)
.should('be.visible')
cy.percySnapshot('no results')
cy.get('[data-cy="no-results-clear"]').click()
cy.get('@specsListInput').invoke('val').should('be.empty')
// validate that something re-populated in the specs list
cy.get('[data-cy="spec-list-file"]').should('have.length.above', 2)
cy.get('@specsListInput').type(longestSpec.fileName)
cy.get('[data-cy="spec-list-directory"]').first()
.should('contain', longestSpec.relative.replace(`/${longestSpec.fileName}${longestSpec.specFileExtension}`, ''))
cy.get('[data-cy="spec-list-file"]').last().within(() => {
cy.contains('a', longestSpec.baseName)
.should('be.visible')
.and('have.attr', 'href', `#/specs/runner?file=${longestSpec.relative}`)
})
const directory = longestSpec.relative.slice(0, longestSpec.relative.lastIndexOf('/'))
cy.get('@specsListInput').clear().type(directory)
cy.get('[data-cy="spec-list-directory"]').first().should('contain', directory)
cy.percySnapshot('matching directory search')
// test interactions
const directories: string[] = Array.from(new Set(specs.map((spec) => spec.relative.split('/')[0]))).sort()
cy.get('@specsListInput').clear()
directories.forEach((dir) => {
cy.contains('button[data-cy="row-directory-depth-0"]', new RegExp(`^${dir}`))
.should('have.attr', 'aria-expanded', 'true')
.click()
.should('have.attr', 'aria-expanded', 'false')
})
cy.get('[data-cy="spec-item"]').should('not.exist')
cy.contains('button[data-cy="row-directory-depth-0"]', directories[0])
.should('have.attr', 'aria-expanded', 'false')
.focus()
.type('{enter}')
cy.contains('button[data-cy="row-directory-depth-0"]', directories[0])
.should('have.attr', 'aria-expanded', 'true')
.focus()
.realPress('Space')
cy.contains('button[data-cy="row-directory-depth-0"]', directories[0])
.should('have.attr', 'aria-expanded', 'false')
cy.get('[data-cy="spec-item"]').should('not.exist')
cy.contains(defaultMessages.createSpec.newSpec).click()
cy.get('@showCreateSpecModalSpy').should('have.been.calledOnce')
})
describe('responsive behavior', () => {
// Spec name (first) column is handled by type-specific tests below
it('should display last updated column', () => {
cy.findByTestId('last-updated-header').as('header')
cy.get('@header').should('be.visible').and('contain', 'Last updated')
})
context('when screen is wide', { viewportWidth: 1200 }, () => {
it('should display latest runs column with full text', () => {
cy.findByTestId('latest-runs-header').within(() => {
cy.findByTestId('short-header-text').should('not.be.visible')
cy.findByTestId('full-header-text').should('be.visible')
.and('have.text', 'Latest runs')
})
})
it('should display average duration column with full text', () => {
cy.findByTestId('average-duration-header').within(() => {
cy.findByTestId('short-header-text').should('not.be.visible')
cy.findByTestId('full-header-text').should('be.visible')
.and('have.text', 'Average duration')
})
})
})
context('when screen is narrow', { viewportWidth: 800 }, () => {
it('should display latest runs column with short text', () => {
cy.findByTestId('latest-runs-header').within(() => {
cy.findByTestId('full-header-text').should('not.be.visible')
cy.findByTestId('short-header-text').should('be.visible')
.and('have.text', 'Runs')
})
})
it('should display average duration column with full text', () => {
cy.findByTestId('average-duration-header').within(() => {
cy.findByTestId('full-header-text').should('not.be.visible')
cy.findByTestId('short-header-text').should('be.visible')
.and('have.text', 'Duration')
})
})
})
it('displays the list as expected visually at various widths', () => {
cy.get('[data-cy="spec-list-file"]')
.should('have.length.above', 2)
.should('have.length.below', specs.length)
cy.wait(100) // there's an intentional 50ms delay in the code, lets just wait it out
cy.viewport(500, 850)
cy.percySnapshot('narrowest')
cy.viewport(650, 850)
cy.percySnapshot('narrow')
cy.viewport(800, 850)
cy.percySnapshot('medium')
cy.viewport(1200, 850)
cy.percySnapshot('wide')
cy.viewport(2000, 850)
cy.percySnapshot('widest')
})
})
})
describe('with a saved spec filter', () => {
beforeEach(() => {
mountWithTestingType(undefined, 'saved-search-term 🗑')
cy.findByLabelText(defaultMessages.specPage.searchPlaceholder)
.as('searchField')
cy.findByLabelText(defaultMessages.specPage.clearSearch, { selector: 'button' })
.as('searchFieldClearButton')
})
it('starts with the saved filter', () => {
cy.get('@searchField').should('have.value', 'saved-search-term 🗑')
cy.get('@searchFieldClearButton').should('be.visible')
// this shouldn't match any results, so let's confirm none are shown
cy.contains('button', defaultMessages.createSpec.viewSpecPatternButton)
.as('resultsCount')
.should('contain.text', '0 of 50 Matches')
// confirm results clear correctly
cy.contains('button', defaultMessages.noResults.clearSearch).click()
cy.get('@resultsCount')
.should('contain.text', '50 Matches')
// the exact wording here can be deceptive so confirm it's not still
// displaying "of", since X of 50 Matches would pass for containing "50 matches"
// but would be wrong.
.should('not.contain.text', 'of 50 Matches')
})
it('calls gql mutation to save updated filter', () => {
const setSpecFilterStub = cy.stub()
cy.stubMutationResolver(SpecFilter_SetPreferencesDocument, (defineResult, variables) => {
const specFilter = JSON.parse(variables.value)?.specFilter
setSpecFilterStub(specFilter)
})
// since there is a saved search, clear it out
cy.get('@searchFieldClearButton').click()
cy.get('@searchField').type('test')
cy.wrap(setSpecFilterStub).should('have.been.calledWith', 'test')
cy.get('@searchField').type('{backspace}{backspace}')
cy.wrap(setSpecFilterStub).should('have.been.calledWith', 'te')
cy.get('@searchField').type('{backspace}{backspace}')
cy.wrap(setSpecFilterStub).should('have.been.calledWith', '')
cy.wait(100) // there's an intentional 50ms delay in the code, lets just wait it out
// Specs List has a min width of ~650px in the app, so there's no need to snapshot below that

View File

@@ -12,7 +12,7 @@
@reconnect-project="showConnectToProject"
/>
<SpecsListHeader
v-model="search"
v-model="specFilterModel"
:specs-list-input-ref-fn="specsListInputRefFn"
class="pb-32px"
:result-count="specs.length"
@@ -143,7 +143,7 @@
</div>
<NoResults
v-show="!specs.length"
:search="search"
:search-term="specFilterModel"
:message="t('specPage.noResultsMessage')"
class="mt-56px"
@clear="handleClear"
@@ -189,6 +189,7 @@ import SpecPatternModal from '../components/SpecPatternModal.vue'
import { useDebounce, useOnline, useResizeObserver } from '@vueuse/core'
import { useRoute } from 'vue-router'
import type { RemoteFetchableStatus } from '@packages/frontend-shared/src/generated/graphql'
import { useSpecFilter } from '../composables/useSpecFilter'
const route = useRoute()
const { t } = useI18n()
@@ -285,6 +286,7 @@ fragment Specs_SpecsList on Query {
...SpecsList
}
config
savedState
...SpecPatternModal
}
...SpecHeaderCloudDataTooltip
@@ -313,25 +315,25 @@ const cachedSpecs = useCachedSpecs(
computed(() => props.gql.currentProject?.specs ?? []),
)
const search = ref('')
const { debouncedSpecFilterModel, specFilterModel } = useSpecFilter(props.gql.currentProject?.savedState?.specFilter)
const specsListInputRef = ref<HTMLInputElement>()
const debouncedSearchString = useDebounce(search, 200)
const specsListInputRefFn = () => specsListInputRef
function handleClear () {
search.value = ''
specFilterModel.value = ''
specsListInputRef.value?.focus()
}
const specs = computed(() => {
const fuzzyFoundSpecs = cachedSpecs.value.map(makeFuzzyFoundSpec)
if (!debouncedSearchString.value) {
if (!debouncedSpecFilterModel?.value) {
return fuzzyFoundSpecs
}
return fuzzySortSpecs(fuzzyFoundSpecs, debouncedSearchString.value)
return fuzzySortSpecs(fuzzyFoundSpecs, debouncedSpecFilterModel.value)
})
// Maintain a cache of what tree directories are expanded/collapsed so the tree state is visually preserved
@@ -339,7 +341,7 @@ const specs = computed(() => {
const treeExpansionCache = ref(new Map<string, boolean>())
// When search value changes or when specs are added/removed, reset the tree expansion cache so that any collapsed directories re-expand
watch([() => search.value, () => specs.value.length], () => treeExpansionCache.value.clear())
watch([() => specFilterModel.value, () => specs.value.length], () => treeExpansionCache.value.clear())
const collapsible = computed(() => {
return useCollapsibleTree(
@@ -368,9 +370,11 @@ useResizeObserver(containerProps.ref, (entries) => {
}
})
// If you are scrolled down the virtual list and the search filter changes,
// reset scroll position to top of list
watch(() => debouncedSearchString.value, () => scrollTo(0))
watch(() => debouncedSpecFilterModel?.value, () => {
// If you are scrolled down the virtual list and the search filter changes,
// reset scroll position to top of list
scrollTo(0)
})
function getIdIfDirectory (row) {
if (row.data.isLeaf && row.data) {

View File

@@ -54,7 +54,7 @@
<template #no-results>
<NoResults
empty-search
:search="noResults.search"
:search-term="noResults.search"
:message="noResults.message"
@clear="noResults.clear"
/>

View File

@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
export interface SpecState {
activeSpec: SpecFile | null | undefined
specFilter?: string
}
export const useSpecStore = defineStore({
@@ -11,6 +12,7 @@ export const useSpecStore = defineStore({
state (): SpecState {
return {
activeSpec: undefined,
specFilter: undefined,
}
},
@@ -18,5 +20,8 @@ export const useSpecStore = defineStore({
setActiveSpec (activeSpec: SpecFile | null) {
this.activeSpec = activeSpec
},
setSpecFilter (filter: string) {
this.specFilter = filter
},
},
})

View File

@@ -13,7 +13,7 @@ export interface LocalSettingsApiShape {
export class LocalSettingsActions {
constructor (private ctx: DataContext) {}
setPreferences (stringifiedJson: string) {
setPreferences (stringifiedJson: string, type: 'global' | 'project') {
const toJson = JSON.parse(stringifiedJson) as AllowedState
// update local data
@@ -21,8 +21,13 @@ export class LocalSettingsActions {
this.ctx.coreData.localSettings.preferences[key as keyof AllowedState] = value as any
}
// persist to appData
return this.ctx._apis.localSettingsApi.setPreferences(toJson)
if (type === 'global') {
// persist to global appData - projects/__global__/state.json
return this.ctx._apis.localSettingsApi.setPreferences(toJson)
}
// persist to project appData - for example projects/launchpad/state.json
return this.ctx._apis.projectApi.setProjectPreferences(toJson)
}
async refreshLocalSettings () {

View File

@@ -1,11 +1,11 @@
import type { CodeGenType, MutationSetProjectPreferencesArgs, NexusGenObjects, NexusGenUnions } from '@packages/graphql/src/gen/nxs.gen'
import type { InitializeProjectOptions, FoundBrowser, FoundSpec, LaunchOpts, OpenProjectLaunchOptions, Preferences, TestingType, ReceivedCypressOptions, AddProject, FullConfig } from '@packages/types'
import type { CodeGenType, MutationSetProjectPreferencesInGlobalCacheArgs, NexusGenObjects, NexusGenUnions } from '@packages/graphql/src/gen/nxs.gen'
import type { InitializeProjectOptions, FoundBrowser, FoundSpec, LaunchOpts, OpenProjectLaunchOptions, Preferences, TestingType, ReceivedCypressOptions, AddProject, FullConfig, AllowedState } from '@packages/types'
import type { EventEmitter } from 'events'
import execa from 'execa'
import path from 'path'
import assert from 'assert'
import type { Maybe, ProjectShape, SavedStateShape } from '../data/coreDataShape'
import type { ProjectShape } from '../data/coreDataShape'
import type { DataContext } from '..'
import { codeGenerator, SpecOptions } from '../codegen'
@@ -37,7 +37,8 @@ export interface ProjectApiShape {
getCurrentBrowser: () => Cypress.Browser | undefined
getCurrentProjectSavedState(): {} | undefined
setPromptShown(slug: string): void
makeProjectSavedState(projectRoot: string): () => Promise<Maybe<SavedStateShape>>
setProjectPreferences(stated: AllowedState): void
makeProjectSavedState(projectRoot: string): void
getDevServer (): {
updateSpecs(specs: FoundSpec[]): void
start(options: {specs: Cypress.Spec[], config: FullConfig}): Promise<{port: number}>
@@ -327,7 +328,7 @@ export class ProjectActions {
this.ctx.lifecycleManager.git?.setSpecs(specs.map((s) => s.absolute))
}
setProjectPreferences (args: MutationSetProjectPreferencesArgs) {
setProjectPreferencesInGlobalCache (args: MutationSetProjectPreferencesInGlobalCacheArgs) {
if (!this.ctx.currentProject) {
throw Error(`Cannot save preferences without currentProject.`)
}

View File

@@ -36,6 +36,7 @@ export interface SavedStateShape {
lastOpened?: number | null
promptsShown?: object | null
lastProjectId?: string | null
specFilter?: string | null
}
export interface ConfigChildProcessShape {

View File

@@ -51,7 +51,7 @@ export const stubMutation: MaybeResolver<Mutation> = {
return { }
},
setProjectPreferences (source, args, ctx) {
setProjectPreferencesInGlobalCache (source, args, ctx) {
return {}
},
generateSpecFromSource (source, args, ctx) {

View File

@@ -7,7 +7,7 @@ describe('<NoResults />', () => {
const clearSpy = cy.spy().as('clearSpy')
cy.mount(() => (
<div><NoResults onClear={clearSpy} search={testSearch} /></div>
<div><NoResults onClear={clearSpy} searchTerm={testSearch} /></div>
))
cy.contains(testSearch).should('be.visible')

View File

@@ -1,6 +1,6 @@
<template>
<div
v-if="search || emptySearch"
v-if="searchTerm || emptySearch"
data-testid="no-results"
class="text-center"
>
@@ -11,9 +11,9 @@
<p class="leading-normal text-gray-500 text-18px">
{{ message || t('noResults.defaultMessage') }}
<span
v-if="search"
v-if="searchTerm"
class="text-purple-500 truncate"
>{{ search }}</span>
>{{ searchTerm }}</span>
</p>
<Button
data-cy="no-results-clear"
@@ -36,7 +36,7 @@ import { useI18n } from '@cy/i18n'
import NoResultsIllustration from '../assets/illustrations/no-results.svg'
defineProps<{
search?: string
searchTerm?: string
message?: string
emptySearch?: boolean
}>()

View File

@@ -88,7 +88,7 @@ fragment ChooseExternalEditorModal on Query {
gql`
mutation ChooseExternalEditorModal_SetPreferredEditorBinary ($value: String!) {
setPreferences (value: $value) {
setPreferences (value: $value, type: global) {
localSettings {
preferences {
preferredEditorBinary

View File

@@ -1314,15 +1314,17 @@ type Mutation {
setCurrentProject(path: String!): Query
"""
Update local preferences (also known as appData). The payload, `value`, should be a `JSON.stringified()` object of the new values you'd like to persist. Example: `setPreferences (value: JSON.stringify({ lastOpened: Date.now() }))`
Update local preferences (also known as appData). The payload, `value`, should be a `JSON.stringified()` object of the new values you'd like to persist. Example: `setPreferences (value: JSON.stringify({ lastOpened: Date.now() }), "local")`
"""
setPreferences(value: String!): Query
setPreferences(type: PreferencesTypeEnum!, value: String!): Query
"""Set the projectId field in the config file of the current project"""
setProjectIdInConfigFile(projectId: String!): Query
"""Save the projects preferences to cache"""
setProjectPreferences(testingType: TestingTypeEnum!): Query!
"""
Save the projects preferences to cache, e.g. in dev: Library/Application Support/Cypress/cy/staging/cache
"""
setProjectPreferencesInGlobalCache(testingType: TestingTypeEnum!): Query!
"""Save the prompt-shown state for this project"""
setPromptShown(slug: String!): Boolean
@@ -1378,6 +1380,11 @@ enum PluginsState {
uninitialized
}
enum PreferencesTypeEnum {
global
project
}
"""Common base fields inherited by GlobalProject / CurrentProject"""
interface ProjectLike {
"""

View File

@@ -0,0 +1,6 @@
import { enumType } from 'nexus'
export const PreferencesTypeEnum = enumType({
name: 'PreferencesTypeEnum',
members: ['global', 'project'],
})

View File

@@ -6,6 +6,7 @@ export * from './gql-BrowserStatus'
export * from './gql-CodeGenTypeEnum'
export * from './gql-ErrorTypeEnum'
export * from './gql-FileExtensionEnum'
export * from './gql-PreferencesTypeEnum'
export * from './gql-ProjectEnums'
export * from './gql-SpecEnum'
export * from './gql-WizardEnums'

View File

@@ -2,6 +2,7 @@ import { arg, booleanArg, enumType, idArg, mutationType, nonNull, stringArg, lis
import { Wizard } from './gql-Wizard'
import { CodeGenTypeEnum } from '../enumTypes/gql-CodeGenTypeEnum'
import { TestingTypeEnum } from '../enumTypes/gql-WizardEnums'
import { PreferencesTypeEnum } from '../enumTypes/gql-PreferencesTypeEnum'
import { FileDetailsInput } from '../inputTypes/gql-FileDetailsInput'
import { WizardUpdateInput } from '../inputTypes/gql-WizardUpdateInput'
import { CurrentProject } from './gql-CurrentProject'
@@ -345,14 +346,15 @@ export const mutation = mutationType({
},
})
t.nonNull.field('setProjectPreferences', {
// TODO: #23202 hopefully we can stop using this for project data, and use `setPreferences` instead
t.nonNull.field('setProjectPreferencesInGlobalCache', {
type: Query,
description: 'Save the projects preferences to cache',
description: 'Save the projects preferences to cache, e.g. in dev: Library/Application Support/Cypress/cy/staging/cache',
args: {
testingType: nonNull(TestingTypeEnum),
},
async resolve (_, args, ctx) {
await ctx.actions.project.setProjectPreferences(args)
await ctx.actions.project.setProjectPreferencesInGlobalCache(args)
return {}
},
@@ -416,13 +418,16 @@ export const mutation = mutationType({
'Update local preferences (also known as appData).',
'The payload, `value`, should be a `JSON.stringified()`',
'object of the new values you\'d like to persist.',
'Example: `setPreferences (value: JSON.stringify({ lastOpened: Date.now() }))`',
'Example: `setPreferences (value: JSON.stringify({ lastOpened: Date.now() }), "local")`',
].join(' '),
args: {
value: nonNull(stringArg()),
type: nonNull(arg({
type: PreferencesTypeEnum,
})),
},
resolve: async (_, args, ctx) => {
await ctx.actions.localSettings.setPreferences(args.value)
resolve: async (_, { value, type }, ctx) => {
await ctx.actions.localSettings.setPreferences(value, type)
return {}
},

View File

@@ -120,7 +120,7 @@ describe('Choose a Browser Page', () => {
body: {
data: {
launchOpenProject: true,
setProjectPreferences: {
setProjectPreferencesInGlobalCache: {
currentProject: {
id: 'test-id',
title: 'launchpad',

View File

@@ -1,22 +1,22 @@
<template>
<div class="min-w-full col-start-1 col-end-3 flex items-center gap-16px mb-24px relative">
<div class="flex min-w-full mb-24px gap-16px col-start-1 col-end-3 items-center relative">
<Input
id="project-search"
v-model="localValue"
name="project-search"
type="search"
class="min-w-200px w-85% flex-grow"
class="flex-grow min-w-200px w-85%"
/>
<label
for="project-search"
class="absolute text-gray-400 left-42px transition-opacity duration-50"
class="transition-opacity left-42px text-gray-400 duration-50 absolute"
:class="{'opacity-0': localValue.length}"
>
{{ t('globalPage.searchPlaceholder') }}
</label>
<Button
aria-controls="dropzone"
class="text-size-16px h-full"
class="h-full text-size-16px"
data-cy="addProjectButton"
size="lg"
:variant="showDropzone ? 'pending' : 'primary'"
@@ -47,7 +47,7 @@
<NoResults
v-if="!projectCount"
class="mt-80px"
:search="localValue"
:search-term="localValue"
:message="t('globalPage.noResultsMessage')"
@clear="handleClear"
/>

View File

@@ -60,7 +60,7 @@ mutation OpenBrowser_LaunchProject ($testingType: TestingTypeEnum!) {
launchOpenProject {
id
}
setProjectPreferences(testingType: $testingType) {
setProjectPreferencesInGlobalCache(testingType: $testingType) {
currentProject {
id
title

View File

@@ -154,6 +154,9 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext {
},
})
},
setProjectPreferences (state) {
return openProject.getProject()?.saveState(state)
},
makeProjectSavedState (projectRoot: string) {
return () => savedState.create(projectRoot).then((s) => s.get())
},

View File

@@ -32,6 +32,7 @@ export const allowedKeys: Readonly<Array<keyof AllowedState>> = [
'lastOpened',
'lastProjectId',
'promptsShown',
'specFilter',
'preferredEditorBinary',
'isSideNavigationOpen',
'lastBrowser',
@@ -65,6 +66,7 @@ export type AllowedState = Partial<{
firstOpened: Maybe<number>
lastOpened: Maybe<number>
promptsShown: Maybe<object>
specFilter: Maybe<string>
preferredEditorBinary: Maybe<string>
isSideNavigationOpen: Maybe<boolean>
testingType: 'e2e' | 'component'