refactor(test): migrate cypress component tests to vitest

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/55747/head
Ferdinand Thiessen 2025-10-14 02:25:34 +07:00
parent 5aa1f5bb84
commit 81cfb9580a
No known key found for this signature in database
GPG Key ID: 45FAE7268762B400
16 changed files with 709 additions and 663 deletions

@ -2,6 +2,7 @@
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: CC0-1.0
*/
export function setup() {
process.env.TZ = 'UTC'
}

@ -2,5 +2,6 @@
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: CC0-1.0
*/
import '@testing-library/jest-dom/vitest'
import 'core-js/stable/index.js'

@ -1,123 +0,0 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import RemoteShareDialog from './RemoteShareDialog.vue'
describe('RemoteShareDialog', () => {
it('can be mounted', () => {
cy.mount(RemoteShareDialog, {
propsData: {
owner: 'user123',
name: 'my-photos',
remote: 'nextcloud.local',
passwordRequired: false,
},
})
cy.findByRole('dialog')
.should('be.visible')
.and('contain.text', 'user123@nextcloud.local')
.and('contain.text', 'my-photos')
cy.findByRole('button', { name: 'Cancel' })
.should('be.visible')
cy.findByRole('button', { name: /add remote share/i })
.should('be.visible')
})
it('does not show password input if not enabled', () => {
cy.mount(RemoteShareDialog, {
propsData: {
owner: 'user123',
name: 'my-photos',
remote: 'nextcloud.local',
passwordRequired: false,
},
})
cy.findByRole('dialog')
.should('be.visible')
.find('input[type="password"]')
.should('not.exist')
})
it('emits true when accepted', () => {
const onClose = cy.spy().as('onClose')
cy.mount(RemoteShareDialog, {
listeners: {
close: onClose,
},
propsData: {
owner: 'user123',
name: 'my-photos',
remote: 'nextcloud.local',
passwordRequired: false,
},
})
cy.findByRole('button', { name: 'Cancel' }).click()
cy.get('@onClose')
.should('have.been.calledWith', false)
})
it('show password input if needed', () => {
cy.mount(RemoteShareDialog, {
propsData: {
owner: 'admin',
name: 'secret-data',
remote: 'nextcloud.local',
passwordRequired: true,
},
})
cy.findByRole('dialog')
.should('be.visible')
.find('input[type="password"]')
.should('be.visible')
})
it('emits the submitted password', () => {
const onClose = cy.spy().as('onClose')
cy.mount(RemoteShareDialog, {
listeners: {
close: onClose,
},
propsData: {
owner: 'admin',
name: 'secret-data',
remote: 'nextcloud.local',
passwordRequired: true,
},
})
cy.get('input[type="password"]')
.type('my password{enter}')
cy.get('@onClose')
.should('have.been.calledWith', true, 'my password')
})
it('emits no password if cancelled', () => {
const onClose = cy.spy().as('onClose')
cy.mount(RemoteShareDialog, {
listeners: {
close: onClose,
},
propsData: {
owner: 'admin',
name: 'secret-data',
remote: 'nextcloud.local',
passwordRequired: true,
},
})
cy.get('input[type="password"]')
.type('my password')
cy.findByRole('button', { name: 'Cancel' }).click()
cy.get('@onClose')
.should('have.been.calledWith', false)
})
})

@ -0,0 +1,115 @@
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { cleanup, fireEvent, render } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import RemoteShareDialog from './RemoteShareDialog.vue'
describe('RemoteShareDialog', () => {
beforeEach(cleanup)
it('can be mounted', async () => {
const component = render(RemoteShareDialog, {
props: {
owner: 'user123',
name: 'my-photos',
remote: 'nextcloud.local',
passwordRequired: false,
},
})
await expect(component.findByRole('dialog', { name: 'Remote share' })).resolves.not.toThrow()
expect(component.getByRole('dialog').innerText).toContain(/my-photos from user123@nextcloud.local/)
await expect(component.findByRole('button', { name: 'Cancel' })).resolves.not.toThrow()
await expect(component.findByRole('button', { name: /Add remote share/ })).resolves.not.toThrow()
})
it('does not show password input if not enabled', async () => {
const component = render(RemoteShareDialog, {
props: {
owner: 'user123',
name: 'my-photos',
remote: 'nextcloud.local',
passwordRequired: false,
},
})
await expect(component.findByLabelText('Remote share password')).rejects.toThrow()
})
it('emits true when accepted', () => {
const onClose = vi.fn()
const component = render(RemoteShareDialog, {
listeners: {
close: onClose,
},
props: {
owner: 'user123',
name: 'my-photos',
remote: 'nextcloud.local',
passwordRequired: false,
},
})
component.getByRole('button', { name: 'Cancel' }).click()
expect(onClose).toHaveBeenCalledWith(false)
})
it('show password input if needed', async () => {
const component = render(RemoteShareDialog, {
props: {
owner: 'admin',
name: 'secret-data',
remote: 'nextcloud.local',
passwordRequired: true,
},
})
await expect(component.findByLabelText('Remote share password')).resolves.not.toThrow()
})
it('emits the submitted password', async () => {
const onClose = vi.fn()
const component = render(RemoteShareDialog, {
listeners: {
close: onClose,
},
props: {
owner: 'admin',
name: 'secret-data',
remote: 'nextcloud.local',
passwordRequired: true,
},
})
const input = component.getByLabelText('Remote share password')
await fireEvent.update(input, 'my password')
component.getByRole('button', { name: 'Add remote share' }).click()
expect(onClose).toHaveBeenCalledWith(true, 'my password')
})
it('emits no password if cancelled', async () => {
const onClose = vi.fn()
const component = render(RemoteShareDialog, {
listeners: {
close: onClose,
},
props: {
owner: 'admin',
name: 'secret-data',
remote: 'nextcloud.local',
passwordRequired: true,
},
})
const input = component.getByLabelText('Remote share password')
await fireEvent.update(input, 'my password')
component.getByRole('button', { name: 'Cancel' }).click()
expect(onClose).toHaveBeenCalledWith(false)
})
})

@ -35,8 +35,8 @@ const buttons = computed(() => [
},
{
label: t('federatedfilesharing', 'Add remote share'),
nativeType: props.passwordRequired ? 'submit' : undefined,
type: 'primary',
type: props.passwordRequired ? 'submit' : undefined,
variant: 'primary',
callback: () => emit('close', true, password.value),
},
])

@ -1,55 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { defineComponent } from 'vue'
import { useFileListWidth } from './useFileListWidth.ts'
const ComponentMock = defineComponent({
template: '<div id="test-component" style="width: 100%;background: white;">{{ fileListWidth }}</div>',
setup() {
return {
fileListWidth: useFileListWidth(),
}
},
})
const FileListMock = defineComponent({
template: '<main id="app-content-vue" style="width: 100%;"><component-mock /></main>',
components: {
ComponentMock,
},
})
describe('composable: fileListWidth', () => {
it('Has initial value', () => {
cy.viewport(600, 400)
cy.mount(FileListMock, {})
cy.get('#app-content-vue')
.should('be.visible')
.and('contain.text', '600')
})
it('Is reactive to size change', () => {
cy.viewport(600, 400)
cy.mount(FileListMock)
cy.get('#app-content-vue').should('contain.text', '600')
cy.viewport(800, 400)
cy.screenshot()
cy.get('#app-content-vue').should('contain.text', '800')
})
it('Is reactive to style changes', () => {
cy.viewport(600, 400)
cy.mount(FileListMock)
cy.get('#app-content-vue')
.should('be.visible')
.and('contain.text', '600')
.invoke('attr', 'style', 'width: 100px')
cy.get('#app-content-vue')
.should('contain.text', '100')
})
})

@ -0,0 +1,80 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { cleanup, render } from '@testing-library/vue'
import { configMocks, mockResizeObserver } from 'jsdom-testing-mocks'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { defineComponent } from 'vue'
import { nextTick } from 'vue'
let resizeObserver: ReturnType<typeof mockResizeObserver>
describe('composable: fileListWidth', () => {
configMocks({ beforeAll, afterAll, beforeEach, afterEach })
beforeAll(() => {
resizeObserver = mockResizeObserver()
})
beforeEach(cleanup)
it('Has initial value', async () => {
const { component } = await getFileList()
expect(component.textContent).toBe('600')
})
it('observes the file list element', async () => {
const { fileList } = await getFileList()
expect(resizeObserver.getObservedElements()).toContain(fileList)
})
it('Is reactive to size change', async () => {
const { component, fileList } = await getFileList()
expect(component.textContent).toBe('600')
expect(resizeObserver.getObservedElements()).toHaveLength(1)
resizeObserver.mockElementSize(fileList, { contentBoxSize: { inlineSize: 800, blockSize: 300 } })
resizeObserver.resize(fileList)
// await rending
await nextTick()
expect(component.textContent).toBe('800')
})
})
async function getFileList() {
const { useFileListWidth } = await import('./useFileListWidth.ts')
const ComponentMock = defineComponent({
template: '<div data-testid="component" style="width: 100%;background: white;">{{ fileListWidth }}</div>',
setup() {
return {
fileListWidth: useFileListWidth(),
}
},
})
const FileListMock = defineComponent({
template: '<main id="app-content-vue" style="width: 100%;"><component-mock /></main>',
components: {
ComponentMock,
},
})
const root = render(FileListMock)
const fileList = root.baseElement.querySelector('#app-content-vue') as HTMLElement
// mock initial size
resizeObserver.mockElementSize(fileList, { contentBoxSize: { inlineSize: 600, blockSize: 200 } })
resizeObserver.resize()
// await rending
await nextTick()
return {
root,
component: root.getByTestId('component'),
fileList,
}
}

@ -1,162 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createTestingPinia } from '@pinia/testing'
import DialogConfirmFileExtension from './DialogConfirmFileExtension.vue'
import { useUserConfigStore } from '../store/userconfig.ts'
describe('DialogConfirmFileExtension', () => {
it('renders with both extensions', () => {
cy.mount(DialogConfirmFileExtension, {
propsData: {
oldExtension: '.old',
newExtension: '.new',
},
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
})
cy.findByRole('dialog')
.as('dialog')
.should('be.visible')
cy.get('@dialog')
.findByRole('heading')
.should('contain.text', 'Change file extension')
cy.get('@dialog')
.findByRole('checkbox', { name: /Do not show this dialog again/i })
.should('exist')
.and('not.be.checked')
cy.get('@dialog')
.findByRole('button', { name: 'Keep .old' })
.should('be.visible')
cy.get('@dialog')
.findByRole('button', { name: 'Use .new' })
.should('be.visible')
})
it('renders without old extension', () => {
cy.mount(DialogConfirmFileExtension, {
propsData: {
newExtension: '.new',
},
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
})
cy.findByRole('dialog')
.as('dialog')
.should('be.visible')
cy.get('@dialog')
.findByRole('button', { name: 'Keep without extension' })
.should('be.visible')
cy.get('@dialog')
.findByRole('button', { name: 'Use .new' })
.should('be.visible')
})
it('renders without new extension', () => {
cy.mount(DialogConfirmFileExtension, {
propsData: {
oldExtension: '.old',
},
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
})
cy.findByRole('dialog')
.as('dialog')
.should('be.visible')
cy.get('@dialog')
.findByRole('button', { name: 'Keep .old' })
.should('be.visible')
cy.get('@dialog')
.findByRole('button', { name: 'Remove extension' })
.should('be.visible')
})
it('emits correct value on keep old', () => {
cy.mount(DialogConfirmFileExtension, {
propsData: {
oldExtension: '.old',
newExtension: '.new',
},
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
}).as('component')
cy.findByRole('dialog')
.as('dialog')
.should('be.visible')
cy.get('@dialog')
.findByRole('button', { name: 'Keep .old' })
.click()
cy.get('@component')
.its('wrapper')
.should((wrapper) => expect(wrapper.emitted('close')).to.eql([[false]]))
})
it('emits correct value on use new', () => {
cy.mount(DialogConfirmFileExtension, {
propsData: {
oldExtension: '.old',
newExtension: '.new',
},
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
}).as('component')
cy.findByRole('dialog')
.as('dialog')
.should('be.visible')
cy.get('@dialog')
.findByRole('button', { name: 'Use .new' })
.click()
cy.get('@component')
.its('wrapper')
.should((wrapper) => expect(wrapper.emitted('close')).to.eql([[true]]))
})
it('updates user config when checking the checkbox', () => {
const pinia = createTestingPinia({
createSpy: cy.spy,
})
cy.mount(DialogConfirmFileExtension, {
propsData: {
oldExtension: '.old',
newExtension: '.new',
},
global: {
plugins: [pinia],
},
}).as('component')
cy.findByRole('dialog')
.as('dialog')
.should('be.visible')
cy.get('@dialog')
.findByRole('checkbox', { name: /Do not show this dialog again/i })
.check({ force: true })
cy.wrap(useUserConfigStore())
.its('update')
.should('have.been.calledWith', 'show_dialog_file_extension', false)
})
})

@ -0,0 +1,132 @@
/*!
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createTestingPinia } from '@pinia/testing'
import { cleanup, fireEvent, render } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DialogConfirmFileExtension from './DialogConfirmFileExtension.vue'
import { useUserConfigStore } from '../store/userconfig.ts'
describe('DialogConfirmFileExtension', () => {
beforeEach(cleanup)
it('renders with both extensions', async () => {
const component = render(DialogConfirmFileExtension, {
props: {
oldExtension: '.old',
newExtension: '.new',
},
global: {
plugins: [createTestingPinia({
createSpy: vi.fn,
})],
},
})
await expect(component.findByRole('dialog', { name: 'Change file extension' })).resolves.not.toThrow()
expect((component.getByRole('checkbox', { name: /Do not show this dialog again/i }) as HTMLInputElement).checked).toBe(false)
await expect(component.findByRole('button', { name: 'Keep .old' })).resolves.not.toThrow()
await expect(component.findByRole('button', { name: 'Use .new' })).resolves.not.toThrow()
})
it('renders without old extension', async () => {
const component = render(DialogConfirmFileExtension, {
props: {
newExtension: '.new',
},
global: {
plugins: [createTestingPinia({
createSpy: vi.fn,
})],
},
})
await expect(component.findByRole('dialog', { name: 'Change file extension' })).resolves.not.toThrow()
await expect(component.findByRole('button', { name: 'Keep without extension' })).resolves.not.toThrow()
await expect(component.findByRole('button', { name: 'Use .new' })).resolves.not.toThrow()
})
it('renders without new extension', async () => {
const component = render(DialogConfirmFileExtension, {
props: {
oldExtension: '.old',
},
global: {
plugins: [createTestingPinia({
createSpy: vi.fn,
})],
},
})
await expect(component.findByRole('dialog', { name: 'Change file extension' })).resolves.not.toThrow()
await expect(component.findByRole('button', { name: 'Keep .old' })).resolves.not.toThrow()
await expect(component.findByRole('button', { name: 'Remove extension' })).resolves.not.toThrow()
})
it('emits correct value on keep old', async () => {
const onclose = vi.fn()
const component = render(DialogConfirmFileExtension, {
props: {
oldExtension: '.old',
newExtension: '.new',
},
listeners: {
close: onclose,
},
global: {
plugins: [createTestingPinia({
createSpy: vi.fn,
})],
},
})
await fireEvent.click(component.getByRole('button', { name: 'Keep .old' }))
expect(onclose).toHaveBeenCalledOnce()
expect(onclose).toHaveBeenCalledWith(false)
})
it('emits correct value on use new', async () => {
const onclose = vi.fn()
const component = render(DialogConfirmFileExtension, {
props: {
oldExtension: '.old',
newExtension: '.new',
},
listeners: {
close: onclose,
},
global: {
plugins: [createTestingPinia({
createSpy: vi.fn,
})],
},
})
await fireEvent.click(component.getByRole('button', { name: 'Use .new' }))
expect(onclose).toHaveBeenCalledOnce()
expect(onclose).toHaveBeenCalledWith(true)
})
it('updates user config when checking the checkbox', async () => {
const pinia = createTestingPinia({
createSpy: vi.fn,
})
const component = render(DialogConfirmFileExtension, {
props: {
oldExtension: '.old',
newExtension: '.new',
},
global: {
plugins: [pinia],
},
})
await fireEvent.click(component.getByRole('checkbox', { name: /Do not show this dialog again/i }))
const store = useUserConfigStore()
expect(store.update).toHaveBeenCalledOnce()
expect(store.update).toHaveBeenCalledWith('show_dialog_file_extension', false)
})
})

@ -1,262 +0,0 @@
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Folder, Navigation } from '@nextcloud/files'
import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
import { getNavigation, View } from '@nextcloud/files'
import { createTestingPinia } from '@pinia/testing'
import NavigationView from './FilesNavigation.vue'
import router from '../router/router.ts'
import RouterService from '../services/RouterService.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
function resetNavigation() {
const nav = getNavigation()
;[...nav.views].forEach(({ id }) => nav.remove(id))
nav.setActive(null)
}
function createView(id: string, name: string, parent?: string) {
return new View({
id,
name,
getContents: async () => ({ folder: {} as Folder, contents: [] }),
icon: FolderSvg,
order: 1,
parent,
})
}
/**
*
*/
function mockWindow() {
window.OCP ??= {}
window.OCP.Files ??= {}
window.OCP.Files.Router = new RouterService(router)
}
describe('Navigation renders', () => {
before(async () => {
delete window._nc_navigation
mockWindow()
getNavigation().register(createView('files', 'Files'))
await router.replace({ name: 'filelist', params: { view: 'files' } })
cy.mockInitialState('files', 'storageStats', {
used: 1000 * 1000 * 1000,
quota: -1,
})
})
after(() => cy.unmockInitialState())
it('renders', () => {
cy.mount(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
})
cy.get('[data-cy-files-navigation]').should('be.visible')
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
cy.get('[data-cy-files-navigation-settings-button]').should('be.visible')
})
})
describe('Navigation API', () => {
let Navigation: Navigation
before(async () => {
delete window._nc_navigation
Navigation = getNavigation()
mockWindow()
await router.replace({ name: 'filelist', params: { view: 'files' } })
})
beforeEach(() => resetNavigation())
it('Check API entries rendering', () => {
Navigation.register(createView('files', 'Files'))
console.warn(Navigation.views)
cy.mount(NavigationView, {
router,
global: {
plugins: [
createTestingPinia({
createSpy: cy.spy,
}),
],
},
})
cy.get('[data-cy-files-navigation]').should('be.visible')
cy.get('[data-cy-files-navigation-item]').should('have.length', 1)
cy.get('[data-cy-files-navigation-item="files"]').should('be.visible')
cy.get('[data-cy-files-navigation-item="files"]').should('contain.text', 'Files')
})
it('Adds a new entry and render', () => {
Navigation.register(createView('files', 'Files'))
Navigation.register(createView('sharing', 'Sharing'))
cy.mount(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
})
cy.get('[data-cy-files-navigation]').should('be.visible')
cy.get('[data-cy-files-navigation-item]').should('have.length', 2)
cy.get('[data-cy-files-navigation-item="sharing"]').should('be.visible')
cy.get('[data-cy-files-navigation-item="sharing"]').should('contain.text', 'Sharing')
})
it('Adds a new children, render and open menu', () => {
Navigation.register(createView('files', 'Files'))
Navigation.register(createView('sharing', 'Sharing'))
Navigation.register(createView('sharingin', 'Shared with me', 'sharing'))
cy.mount(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
})
cy.wrap(useViewConfigStore()).as('viewConfigStore')
cy.get('[data-cy-files-navigation]').should('be.visible')
cy.get('[data-cy-files-navigation-item]').should('have.length', 3)
// Toggle the sharing entry children
cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').should('exist')
cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').click({ force: true })
// Expect store update to be called
cy.get('@viewConfigStore').its('update').should('have.been.calledWith', 'sharing', 'expanded', true)
// Validate children
cy.get('[data-cy-files-navigation-item="sharingin"]').should('be.visible')
cy.get('[data-cy-files-navigation-item="sharingin"]').should('contain.text', 'Shared with me')
// Toggle the sharing entry children 🇦again
cy.get('[data-cy-files-navigation-item="sharing"] button.icon-collapse').click({ force: true })
cy.get('[data-cy-files-navigation-item="sharingin"]').should('not.be.visible')
// Expect store update to be called
cy.get('@viewConfigStore').its('update').should('have.been.calledWith', 'sharing', 'expanded', false)
})
it('Throws when adding a duplicate entry', () => {
Navigation.register(createView('files', 'Files'))
expect(() => Navigation.register(createView('files', 'Files')))
.to.throw('View id files is already registered')
})
})
describe('Quota rendering', () => {
before(async () => {
delete window._nc_navigation
mockWindow()
getNavigation().register(createView('files', 'Files'))
await router.replace({ name: 'filelist', params: { view: 'files' } })
})
afterEach(() => cy.unmockInitialState())
it('Unknown quota', () => {
cy.mount(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
})
cy.get('[data-cy-files-navigation-settings-quota]').should('not.exist')
})
it('Unlimited quota', () => {
cy.mockInitialState('files', 'storageStats', {
used: 1024 * 1024 * 1024,
quota: -1,
total: 50 * 1024 * 1024 * 1024,
})
cy.mount(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
})
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '1 GB used')
cy.get('[data-cy-files-navigation-settings-quota] progress').should('not.exist')
})
it('Non-reached quota', () => {
cy.mockInitialState('files', 'storageStats', {
used: 1024 * 1024 * 1024,
quota: 5 * 1024 * 1024 * 1024,
total: 5 * 1024 * 1024 * 1024,
relative: 20, // percent
})
cy.mount(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
})
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '1 GB of 5 GB used')
cy.get('[data-cy-files-navigation-settings-quota] progress')
.should('exist')
.and('have.attr', 'value', '20')
})
it('Reached quota', () => {
cy.mockInitialState('files', 'storageStats', {
used: 5 * 1024 * 1024 * 1024,
quota: 1024 * 1024 * 1024,
total: 1024 * 1024 * 1024,
relative: 500, // percent
})
cy.mount(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: cy.spy,
})],
},
})
cy.get('[data-cy-files-navigation-settings-quota]').should('be.visible')
cy.get('[data-cy-files-navigation-settings-quota]').should('contain.text', '5 GB of 1 GB used')
cy.get('[data-cy-files-navigation-settings-quota] progress')
.should('exist')
.and('have.attr', 'value', '100') // progress max is 100
})
})

@ -0,0 +1,286 @@
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Folder, Navigation } from '@nextcloud/files'
import FolderSvg from '@mdi/svg/svg/folder.svg?raw'
import { getNavigation, View } from '@nextcloud/files'
import { createTestingPinia } from '@pinia/testing'
import { cleanup, fireEvent, getAllByRole, render } from '@testing-library/vue'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import NavigationView from './FilesNavigation.vue'
import router from '../router/router.ts'
import RouterService from '../services/RouterService.ts'
import { useViewConfigStore } from '../store/viewConfig.ts'
afterEach(() => removeInitialState())
beforeAll(async () => {
Object.defineProperty(document.documentElement, 'clientWidth', { value: 1920 })
await fireEvent.resize(window)
})
describe('Navigation', () => {
beforeEach(cleanup)
beforeEach(async () => {
delete window._nc_navigation
mockWindow()
getNavigation().register(createView('files', 'Files'))
await router.replace({ name: 'filelist', params: { view: 'files' } })
})
it('renders navigation with settings button and search', async () => {
const component = render(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: vi.fn,
})],
},
})
// see the navigation
await expect(component.findByRole('navigation', { name: 'Files' })).resolves.not.toThrow()
// see the search box
await expect(component.findByRole('searchbox', { name: /Search here/ })).resolves.not.toThrow()
// see the settings entry
await expect(component.findByRole('link', { name: /Files settings/ })).resolves.not.toThrow()
})
it('renders no quota without storage stats', () => {
const component = render(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: vi.fn,
})],
},
})
expect(component.baseElement.querySelector('[data-cy-files-navigation-settings-quota]')).toBeNull()
})
it('Unlimited quota shows used storage but no progressbar', async () => {
mockInitialState('files', 'storageStats', {
used: 1024 * 1024 * 1024,
quota: -1,
total: 50 * 1024 * 1024 * 1024,
})
const component = render(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: vi.fn,
})],
},
})
expect(component.baseElement.querySelector('[data-cy-files-navigation-settings-quota]')).not.toBeNull()
await expect(component.findByText('1 GB used')).resolves.not.toThrow()
await expect(component.findByRole('progressbar')).rejects.toThrow()
})
it('Non-reached quota shows stats and progress', async () => {
mockInitialState('files', 'storageStats', {
used: 1024 * 1024 * 1024,
quota: 5 * 1024 * 1024 * 1024,
total: 5 * 1024 * 1024 * 1024,
relative: 20, // percent
})
const component = render(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: vi.fn,
})],
},
})
await expect(component.findByText('1 GB of 5 GB used')).resolves.not.toThrow()
await expect(component.findByRole('progressbar')).resolves.not.toThrow()
expect((component.getByRole('progressbar') as HTMLProgressElement).value).toBe(20)
})
it('Reached quota', async () => {
mockInitialState('files', 'storageStats', {
used: 5 * 1024 * 1024 * 1024,
quota: 1024 * 1024 * 1024,
total: 1024 * 1024 * 1024,
relative: 500, // percent
})
const component = render(NavigationView, {
router,
global: {
plugins: [createTestingPinia({
createSpy: vi.fn,
})],
},
})
await expect(component.findByText('5 GB of 1 GB used')).resolves.not.toThrow()
await expect(component.findByRole('progressbar')).resolves.not.toThrow()
expect((component.getByRole('progressbar') as HTMLProgressElement).value).toBe(100)
})
})
describe('Navigation API', () => {
let Navigation: Navigation
beforeEach(async () => {
delete window._nc_navigation
Navigation = getNavigation()
mockWindow()
await router.replace({ name: 'filelist', params: { view: 'files' } })
})
beforeEach(resetNavigation)
beforeEach(cleanup)
it('Check API entries rendering', async () => {
Navigation.register(createView('files', 'Files'))
const component = render(NavigationView, {
router,
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
}),
],
},
})
// see the navigation
await expect(component.findByRole('navigation', { name: 'Files' })).resolves.not.toThrow()
// see the views
await expect(component.findByRole('list', { name: 'Views' })).resolves.not.toThrow()
// see the entry
await expect(component.findByRole('link', { name: 'Files' })).resolves.not.toThrow()
// see that the entry has all props
const entry = component.getByRole('link', { name: 'Files' })
expect(entry.getAttribute('href')).toMatch(/\/apps\/files\/files$/)
expect(entry.getAttribute('aria-current')).toBe('page')
expect(entry.getAttribute('title')).toBe('Files')
})
it('Adds a new entry and render', async () => {
Navigation.register(createView('files', 'Files'))
Navigation.register(createView('sharing', 'Sharing'))
const component = render(NavigationView, {
router,
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
}),
],
},
})
const list = component.getByRole('list', { name: 'Views' })
expect(getAllByRole(list, 'listitem')).toHaveLength(2)
await expect(component.findByRole('link', { name: 'Files' })).resolves.not.toThrow()
await expect(component.findByRole('link', { name: 'Sharing' })).resolves.not.toThrow()
// see that the entry has all props
const entry = component.getByRole('link', { name: 'Sharing' })
expect(entry.getAttribute('href')).toMatch(/\/apps\/files\/sharing$/)
expect(entry.getAttribute('aria-current')).toBeNull()
expect(entry.getAttribute('title')).toBe('Sharing')
})
it('Adds a new children, render and open menu', async () => {
Navigation.register(createView('files', 'Files'))
Navigation.register(createView('sharing', 'Sharing'))
Navigation.register(createView('sharingin', 'Shared with me', 'sharing'))
const component = render(NavigationView, {
router,
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
}),
],
},
})
const viewConfigStore = useViewConfigStore()
const list = component.getByRole('list', { name: 'Views' })
expect(getAllByRole(list, 'listitem')).toHaveLength(3)
// Toggle the sharing entry children
const entry = component.getByRole('link', { name: 'Sharing' })
expect(entry.getAttribute('aria-expanded')).toBe('false')
await fireEvent.click(component.getByRole('button', { name: 'Open menu' }))
expect(entry.getAttribute('aria-expanded')).toBe('true')
// Expect store update to be called
expect(viewConfigStore.update).toHaveBeenCalled()
expect(viewConfigStore.update).toHaveBeenCalledWith('sharing', 'expanded', true)
// Validate children
await expect(component.findByRole('link', { name: 'Shared with me' })).resolves.not.toThrow()
await fireEvent.click(component.getByRole('button', { name: 'Collapse menu' }))
// Expect store update to be called
expect(viewConfigStore.update).toHaveBeenCalledWith('sharing', 'expanded', false)
})
})
/**
* Remove the mocked initial state
*/
function removeInitialState(): void {
document.querySelectorAll('input[type="hidden"]').forEach((el) => {
el.remove()
})
// clear the cache
delete globalThis._nc_initial_state
}
/**
* Helper to mock an initial state value
* @param app - The app
* @param key - The key
* @param value - The value
*/
function mockInitialState(app: string, key: string, value: unknown): void {
const el = document.createElement('input')
el.value = btoa(JSON.stringify(value))
el.id = `initial-state-${app}-${key}`
el.type = 'hidden'
document.head.appendChild(el)
}
function resetNavigation() {
const nav = getNavigation()
;[...nav.views].forEach(({ id }) => nav.remove(id))
nav.setActive(null)
}
function createView(id: string, name: string, parent?: string) {
return new View({
id,
name,
getContents: async () => ({ folder: {} as Folder, contents: [] }),
icon: FolderSvg,
order: 1,
parent,
})
}
function mockWindow() {
window.OCP ??= {}
window.OCP.Files ??= {}
window.OCP.Files.Router = new RouterService(router)
}

@ -1,58 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Markdown from './Markdown.vue'
describe('Markdown component', () => {
it('renders links', () => {
cy.mount(Markdown, {
propsData: {
text: 'This is [a link](http://example.com)!',
},
})
cy.contains('This is')
.find('a')
.should('exist')
.and('have.attr', 'href', 'http://example.com')
.and('contain.text', 'a link')
})
it('renders headings', () => {
cy.mount(Markdown, {
propsData: {
text: '# level 1\nText\n## level 2\nText\n### level 3\nText\n#### level 4\nText\n##### level 5\nText\n###### level 6\nText\n',
},
})
for (let level = 1; level <= 6; level++) {
cy.contains(`h${level}`, `level ${level}`)
.should('be.visible')
}
})
it('can limit headings', () => {
cy.mount(Markdown, {
propsData: {
text: '# level 1\nText\n## level 2\nText\n### level 3\nText\n#### level 4\nText\n##### level 5\nText\n###### level 6\nText\n',
minHeading: 4,
},
})
cy.get('h1').should('not.exist')
cy.get('h2').should('not.exist')
cy.get('h3').should('not.exist')
cy.get('h4')
.should('exist')
.and('contain.text', 'level 1')
cy.get('h5')
.should('exist')
.and('contain.text', 'level 2')
cy.contains('h6', 'level 3').should('exist')
cy.contains('h6', 'level 4').should('exist')
cy.contains('h6', 'level 5').should('exist')
cy.contains('h6', 'level 6').should('exist')
})
})

@ -0,0 +1,58 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { cleanup, render } from '@testing-library/vue'
import { beforeEach, describe, expect, it } from 'vitest'
import Markdown from './Markdown.vue'
describe('Markdown component', () => {
beforeEach(cleanup)
it('renders links', () => {
const component = render(Markdown, {
props: {
text: 'This is [a link](http://example.com)!',
},
})
const link = component.getByRole('link')
expect(link).toBeInstanceOf(HTMLAnchorElement)
expect(link.getAttribute('href')).toBe('http://example.com')
expect(link.textContent).toBe('a link')
})
it('renders headings', () => {
const component = render(Markdown, {
props: {
text: '# level 1\nText\n## level 2\nText\n### level 3\nText\n#### level 4\nText\n##### level 5\nText\n###### level 6\nText\n',
},
})
for (let level = 1; level <= 6; level++) {
const heading = component.getByRole('heading', { level })
expect(heading.textContent).toBe(`level ${level}`)
}
})
it('can limit headings', async () => {
const component = render(Markdown, {
props: {
text: '# level 1\nText\n## level 2\nText\n### level 3\nText\n#### level 4\nText\n##### level 5\nText\n###### level 6\nText\n',
minHeading: 4,
},
})
await expect(component.findByRole('heading', { level: 1 })).rejects.toThrow()
await expect(component.findByRole('heading', { level: 2 })).rejects.toThrow()
await expect(component.findByRole('heading', { level: 3 })).rejects.toThrow()
expect(component.getByRole('heading', { level: 4 }).textContent).toBe('level 1')
expect(component.getByRole('heading', { level: 5 }).textContent).toBe('level 2')
await expect(component.findByRole('heading', { level: 6, name: 'level 3' })).resolves.not.toThrow()
await expect(component.findByRole('heading', { level: 6, name: 'level 4' })).resolves.not.toThrow()
await expect(component.findByRole('heading', { level: 6, name: 'level 5' })).resolves.not.toThrow()
await expect(component.findByRole('heading', { level: 6, name: 'level 6' })).resolves.not.toThrow()
})
})

@ -56,7 +56,10 @@ export default defineConfig([
},
},
// Cypress setup
CypressEslint.configs.recommended,
{
...CypressEslint.configs.recommended,
files: ['cypress/**', '**/*.cy.*'],
},
{
name: 'server/cypress',
files: ['cypress/**', '**/*.cy.*'],

29
package-lock.json generated

@ -128,6 +128,7 @@
"file-loader": "^6.2.0",
"handlebars-loader": "^1.7.3",
"jsdom": "^27.0.0",
"jsdom-testing-mocks": "^1.16.0",
"mime": "^4.1.0",
"msw": "^2.11.3",
"raw-loader": "^4.0.2",
@ -8068,6 +8069,13 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/bezier-easing": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz",
"integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==",
"dev": true,
"license": "MIT"
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@ -9566,6 +9574,13 @@
"node": ">=10"
}
},
"node_modules/css-mediaquery": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz",
"integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==",
"dev": true,
"license": "BSD"
},
"node_modules/css-tree": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
@ -14978,6 +14993,20 @@
}
}
},
"node_modules/jsdom-testing-mocks": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/jsdom-testing-mocks/-/jsdom-testing-mocks-1.16.0.tgz",
"integrity": "sha512-wLrulXiLpjmcUYOYGEvz4XARkrmdVpyxzdBl9IAMbQ+ib2/UhUTRCn49McdNfXLff2ysGBUms49ZKX0LR1Q0gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"bezier-easing": "^2.1.0",
"css-mediaquery": "^0.1.2"
},
"engines": {
"node": ">=14"
}
},
"node_modules/jsdom/node_modules/tldts": {
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.14.tgz",

@ -164,6 +164,7 @@
"file-loader": "^6.2.0",
"handlebars-loader": "^1.7.3",
"jsdom": "^27.0.0",
"jsdom-testing-mocks": "^1.16.0",
"mime": "^4.1.0",
"msw": "^2.11.3",
"raw-loader": "^4.0.2",