test: move integration testing of hotkeys to Cypress

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-01-16 15:39:02 +01:00
parent 24b3059de7
commit b63aca792f
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
3 changed files with 139 additions and 55 deletions

View file

@ -4,18 +4,14 @@
*/
import type { View } from '@nextcloud/files'
import type { Mock } from 'vitest'
import type { Location } from 'vue-router'
import axios from '@nextcloud/axios'
import { File, Folder, Permission } from '@nextcloud/files'
import { File, Folder, Permission, registerFileAction } from '@nextcloud/files'
import { enableAutoDestroy, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { action as deleteAction } from '../actions/deleteAction.ts'
import { action as favoriteAction } from '../actions/favoriteAction.ts'
import { action as renameAction } from '../actions/renameAction.ts'
import { action as sidebarAction } from '../actions/sidebarAction.ts'
import { useActiveStore } from '../store/active.ts'
import { useFilesStore } from '../store/files.ts'
import { getPinia } from '../store/index.ts'
@ -63,10 +59,23 @@ const TestComponent = defineComponent({
template: '<div />',
})
beforeAll(() => {
// @ts-expect-error mocking for tests
window.OCP ??= {}
// @ts-expect-error mocking for tests
window.OCP.Files ??= {}
// @ts-expect-error mocking for tests
window.OCP.Files.Router ??= {
...router,
goToRoute: vi.fn(),
}
})
describe('HotKeysService testing', () => {
const activeStore = useActiveStore(getPinia())
let initialState: HTMLInputElement
let component: ReturnType<typeof mount>
enableAutoDestroy(afterEach)
@ -114,54 +123,15 @@ describe('HotKeysService testing', () => {
})))
document.body.appendChild(initialState)
mount(TestComponent)
component = mount(TestComponent)
})
it('Pressing d should open the sidebar once', () => {
dispatchEvent({ key: 'd', code: 'KeyD' })
// tests for register action handling
// Modifier keys should not trigger the action
dispatchEvent({ key: 'd', code: 'KeyD', ctrlKey: true })
dispatchEvent({ key: 'd', code: 'KeyD', altKey: true })
dispatchEvent({ key: 'd', code: 'KeyD', shiftKey: true })
dispatchEvent({ key: 'd', code: 'KeyD', metaKey: true })
expect(sidebarAction.enabled).toHaveReturnedWith(true)
expect(sidebarAction.exec).toHaveBeenCalledOnce()
})
it('Pressing F2 should rename the file', () => {
dispatchEvent({ key: 'F2', code: 'F2' })
// Modifier keys should not trigger the action
dispatchEvent({ key: 'F2', code: 'F2', ctrlKey: true })
dispatchEvent({ key: 'F2', code: 'F2', altKey: true })
dispatchEvent({ key: 'F2', code: 'F2', shiftKey: true })
dispatchEvent({ key: 'F2', code: 'F2', metaKey: true })
expect(renameAction.enabled).toHaveReturnedWith(true)
expect(renameAction.exec).toHaveBeenCalledOnce()
})
it('Pressing s should toggle favorite', () => {
(favoriteAction.enabled as Mock).mockReturnValue(true);
(favoriteAction.exec as Mock).mockImplementationOnce(() => Promise.resolve(null))
vi.spyOn(axios, 'post').mockImplementationOnce(() => Promise.resolve())
dispatchEvent({ key: 's', code: 'KeyS' })
// Modifier keys should not trigger the action
dispatchEvent({ key: 's', code: 'KeyS', ctrlKey: true })
dispatchEvent({ key: 's', code: 'KeyS', altKey: true })
dispatchEvent({ key: 's', code: 'KeyS', shiftKey: true })
dispatchEvent({ key: 's', code: 'KeyS', metaKey: true })
expect(favoriteAction.exec).toHaveBeenCalledOnce()
})
it('Pressing Delete should delete the file', async () => {
// @ts-expect-error unit testing - private method access
vi.spyOn(deleteAction._action, 'exec').mockResolvedValue(() => true)
it('registeres actions', () => {
component.destroy()
registerFileAction(deleteAction)
component = mount(TestComponent)
dispatchEvent({ key: 'Delete', code: 'Delete' })
@ -175,6 +145,8 @@ describe('HotKeysService testing', () => {
expect(deleteAction.exec).toHaveBeenCalledOnce()
})
// actions implemented by the composable
it('Pressing alt+up should go to parent directory', () => {
expect(router.push).toHaveBeenCalledTimes(0)
dispatchEvent({ key: 'ArrowUp', code: 'ArrowUp', altKey: true })
@ -197,9 +169,8 @@ describe('HotKeysService testing', () => {
it.each([
['ctrlKey'],
['altKey'],
// those meta keys are still triggering...
// ['shiftKey'],
// ['metaKey']
['shiftKey'],
['metaKey'],
])('Pressing v with modifier key %s should not toggle grid view', async (modifier: string) => {
vi.spyOn(axios, 'put').mockImplementationOnce(() => Promise.resolve())

View file

@ -7,7 +7,7 @@ import type { User } from '@nextcloud/e2e-test-server/cypress'
const ACTION_COPY_MOVE = 'move-copy'
export const getRowForFileId = (fileid: number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"]`)
export const getRowForFileId = (fileid: string | number) => cy.get(`[data-cy-files-list-row-fileid="${fileid}"]`)
export const getRowForFile = (filename: string) => cy.get(`[data-cy-files-list-row-name="${CSS.escape(filename)}"]`)
export const getActionsForFileId = (fileid: number) => getRowForFileId(fileid).find('[data-cy-files-list-row-actions]')

View file

@ -0,0 +1,113 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getRowForFileId } from './FilesUtils.ts'
describe('Files hotkey handling', () => {
before(() => {
cy.createRandomUser().then((user) => {
cy.mkdir(user, '/abcd')
cy.mkdir(user, '/zyx')
cy.rm(user, '/welcome.txt')
cy.login(user)
})
})
beforeEach(() => cy.visit('/apps/files'))
it('Pressing "arrow down" should go to first file', () => {
cy.get('[data-cy-files-list]')
.press(Cypress.Keyboard.Keys.DOWN)
cy.url()
.should('match', /\/apps\/files\/files\/\d+/)
.then((url) => new URL(url).pathname.split('/').at(-1))
.then((fileId) => getRowForFileId(fileId)
.should('exist')
.and('have.attr', 'data-cy-files-list-row-name', 'abcd'))
})
it('Pressing "arrow up" should go to first file', () => {
cy.get('[data-cy-files-list]')
.press(Cypress.Keyboard.Keys.UP)
cy.url()
.should('match', /\/apps\/files\/files\/\d+/)
.then((url) => new URL(url).pathname.split('/').at(-1))
.then((fileId) => getRowForFileId(fileId)
.should('exist')
.and('have.attr', 'data-cy-files-list-row-name', 'zyx'))
})
it('Pressing D should open the sidebar once', () => {
activateFirstRow()
cy.get('[data-cy-files-list]')
.press('d')
cy.get('[data-cy-sidebar]')
.should('exist')
.and('be.visible')
})
it('Pressing F2 should rename the file', () => {
activateFirstRow()
cy.get('[data-cy-files-list]')
.should('exist')
.then(($el) => {
const el = $el.get(0)
// manually dispatch as Cypress refuses to press F-keys for "security reasons"
cy.log('Dispatching F2 keydown/keyup events')
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'F2', code: 'F2', bubbles: true }))
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'F2', code: 'F2', bubbles: true }))
el.dispatchEvent(new KeyboardEvent('keypress', { key: 'F2', code: 'F2', bubbles: true }))
})
cy.get('[data-cy-files-list-row-name]')
.first()
.findByRole('textbox', { name: /Folder name/ })
.should('exist')
})
it('Pressing S should toggle favorite', () => {
activateFirstRow()
cy.get('[data-cy-files-list]')
.press('s')
cy.get('[data-cy-files-list-row-name]')
.first()
.as('firstRow')
.findByRole('img', { name: /Favorite/ })
.should('exist')
cy.get('[data-cy-files-list]')
.press('s')
cy.get('@firstRow')
.findByRole('img', { name: /Favorite/ })
.should('not.exist')
})
it('Pressing DELETE should delete the folder', () => {
activateFirstRow()
cy.get('td[data-cy-files-list-row-name]')
.should('have.length', 2)
cy.get('[data-cy-files-list]')
.press(Cypress.Keyboard.Keys.DELETE)
cy.get('td[data-cy-files-list-row-name]')
.should('have.length', 1)
})
})
/**
* Activates the first row in the files list by simulating a press of the down arrow key.
*/
function activateFirstRow() {
cy.get('[data-cy-files-list]')
.press(Cypress.Keyboard.Keys.DOWN)
cy.url()
.should('match', /\/apps\/files\/files\/\d+/)
}