Merge pull request #55747 from nextcloud/chore/migrate-cypress-vitest

refactor(test): migrate Cypress component test to vitest
pull/55681/head
Ferdinand Thiessen 2025-10-15 10:55:44 +07:00 committed by GitHub
commit 0c1be89f1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 1033 additions and 1181 deletions

@ -102,8 +102,8 @@ jobs:
matrix:
# Run multiple copies of the current job in parallel
# Please increase the number or runners as your tests suite grows (0 based index for e2e tests)
containers: ['component', 'setup', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
# Hack as strategy.job-total includes the component and GitHub does not allow math expressions
containers: ['setup', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
# Hack as strategy.job-total includes the "setup" and GitHub does not allow math expressions
# Always align this number with the total of e2e runners (max. index + 1)
total-containers: [10]

@ -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()
})
})

@ -1,377 +0,0 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { SetupConfig, SetupLinks } from '../install.ts'
import SetupView from './Setup.vue'
import '../../css/guest.css'
const defaultConfig = Object.freeze({
adminlogin: '',
adminpass: '',
dbuser: '',
dbpass: '',
dbname: '',
dbtablespace: '',
dbhost: '',
dbtype: '',
databases: {
sqlite: 'SQLite',
mysql: 'MySQL/MariaDB',
pgsql: 'PostgreSQL',
},
directory: '',
hasAutoconfig: false,
htaccessWorking: true,
serverRoot: '/var/www/html',
errors: [],
}) as SetupConfig
const links = {
adminInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-install',
adminSourceInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-source_install',
adminDBConfiguration: 'https://docs.nextcloud.com/server/32/go.php?to=admin-db-configuration',
} as SetupLinks
describe('Default setup page', () => {
beforeEach(() => {
cy.mockInitialState('core', 'links', links)
})
afterEach(() => cy.unmockInitialState())
it('Renders default config', () => {
cy.mockInitialState('core', 'config', defaultConfig)
cy.mount(SetupView)
cy.get('[data-cy-setup-form]').scrollIntoView()
cy.get('[data-cy-setup-form]').should('be.visible')
// Single note is the footer help
cy.get('[data-cy-setup-form-note]')
.should('have.length', 1)
.should('be.visible')
cy.get('[data-cy-setup-form-note]').should('contain', 'See the documentation')
// DB radio selectors
cy.get('[data-cy-setup-form-field^="dbtype"]')
.should('exist')
.find('input')
.should('be.checked')
cy.get('[data-cy-setup-form-field="dbtype-mysql"]').should('exist')
cy.get('[data-cy-setup-form-field="dbtype-pgsql"]').should('exist')
cy.get('[data-cy-setup-form-field="dbtype-oci"]').should('not.exist')
// Sqlite warning
cy.get('[data-cy-setup-form-db-note="sqlite"]')
.should('be.visible')
// admin login, password, data directory and 3 DB radio selectors
cy.get('[data-cy-setup-form-field]')
.should('be.visible')
.should('have.length', 6)
})
it('Renders single DB sqlite', () => {
const config = {
...defaultConfig,
databases: {
sqlite: 'SQLite',
},
}
cy.mockInitialState('core', 'config', config)
cy.mount(SetupView)
cy.get('[data-cy-setup-form-field^="dbtype"]')
.should('exist')
.should('not.be.visible')
.find('input')
.should('be.checked')
cy.get('[data-cy-setup-form-field="dbtype-sqlite"]').should('exist')
// Two warnings: sqlite and single db support
cy.get('[data-cy-setup-form-db-note="sqlite"]')
.should('be.visible')
cy.get('[data-cy-setup-form-db-note="single-db"]')
.should('be.visible')
// Admin login, password, data directory and db type
cy.get('[data-cy-setup-form-field]')
.should('be.visible')
.should('have.length', 4)
})
it('Renders single DB mysql', () => {
const config = {
...defaultConfig,
databases: {
mysql: 'MySQL/MariaDB',
},
}
cy.mockInitialState('core', 'config', config)
cy.mount(SetupView)
cy.get('[data-cy-setup-form-field^="dbtype"]')
.should('exist')
.should('not.be.visible')
.find('input')
.should('be.checked')
// Single db support warning
cy.get('[data-cy-setup-form-db-note="single-db"]')
.should('be.visible')
.invoke('html')
.should('contains', links.adminSourceInstall)
// No SQLite warning
cy.get('[data-cy-setup-form-db-note="sqlite"]')
.should('not.exist')
// Admin login, password, data directory, db type, db user,
// db password, db name and db host
cy.get('[data-cy-setup-form-field]')
.should('be.visible')
.should('have.length', 8)
})
it('Changes fields from sqlite to mysql then oci', () => {
const config = {
...defaultConfig,
databases: {
sqlite: 'SQLite',
mysql: 'MySQL/MariaDB',
pgsql: 'PostgreSQL',
oci: 'Oracle',
},
}
cy.mockInitialState('core', 'config', config)
cy.mount(SetupView)
// SQLite selected
cy.get('[data-cy-setup-form-field="dbtype-sqlite"]')
.should('be.visible')
.find('input')
.should('be.checked')
// Admin login, password, data directory and 4 DB radio selectors
cy.get('[data-cy-setup-form-field]')
.should('be.visible')
.should('have.length', 7)
// Change to MySQL
cy.get('[data-cy-setup-form-field="dbtype-mysql"]').click()
cy.get('[data-cy-setup-form-field="dbtype-mysql"] input').should('be.checked')
// Admin login, password, data directory, db user, db password,
// db name, db host and 4 DB radio selectors
cy.get('[data-cy-setup-form-field]')
.should('be.visible')
.should('have.length', 11)
// Change to Oracle
cy.get('[data-cy-setup-form-field="dbtype-oci"]').click()
cy.get('[data-cy-setup-form-field="dbtype-oci"] input').should('be.checked')
// Admin login, password, data directory, db user, db password,
// db name, db table space, db host and 4 DB radio selectors
cy.get('[data-cy-setup-form-field]')
.should('be.visible')
.should('have.length', 12)
cy.get('[data-cy-setup-form-field="dbtablespace"]')
.should('be.visible')
})
})
describe('Setup page with errors and warning', () => {
beforeEach(() => {
cy.mockInitialState('core', 'links', links)
})
afterEach(() => cy.unmockInitialState())
it('Renders error from backend', () => {
const config = {
...defaultConfig,
errors: [
{
error: 'Error message',
hint: 'Error hint',
},
],
}
cy.mockInitialState('core', 'config', config)
cy.mount(SetupView)
// Error message and hint
cy.get('[data-cy-setup-form-note="error"]')
.should('be.visible')
.should('have.length', 1)
.should('contain', 'Error message')
.should('contain', 'Error hint')
})
it('Renders errors from backend', () => {
const config = {
...defaultConfig,
errors: [
'Error message 1',
{
error: 'Error message',
hint: 'Error hint',
},
],
}
cy.mockInitialState('core', 'config', config)
cy.mount(SetupView)
// Error message and hint
cy.get('[data-cy-setup-form-note="error"]')
.should('be.visible')
.should('have.length', 2)
cy.get('[data-cy-setup-form-note="error"]').eq(0)
.should('contain', 'Error message 1')
cy.get('[data-cy-setup-form-note="error"]').eq(1)
.should('contain', 'Error message')
.should('contain', 'Error hint')
})
it('Renders all the submitted fields on error', () => {
const config = {
...defaultConfig,
adminlogin: 'admin',
adminpass: 'password',
dbname: 'nextcloud',
dbtype: 'mysql',
dbuser: 'nextcloud',
dbpass: 'password',
dbhost: 'localhost',
directory: '/var/www/html/nextcloud',
} as SetupConfig
cy.mockInitialState('core', 'config', config)
cy.mount(SetupView)
cy.get('input[data-cy-setup-form-field="adminlogin"]')
.should('have.value', 'admin')
cy.get('input[data-cy-setup-form-field="adminpass"]')
.should('have.value', 'password')
cy.get('[data-cy-setup-form-field="dbtype-mysql"] input')
.should('be.checked')
cy.get('input[data-cy-setup-form-field="dbname"]')
.should('have.value', 'nextcloud')
cy.get('input[data-cy-setup-form-field="dbuser"]')
.should('have.value', 'nextcloud')
cy.get('input[data-cy-setup-form-field="dbpass"]')
.should('have.value', 'password')
cy.get('input[data-cy-setup-form-field="dbhost"]')
.should('have.value', 'localhost')
cy.get('input[data-cy-setup-form-field="directory"]')
.should('have.value', '/var/www/html/nextcloud')
})
it('Renders the htaccess warning', () => {
const config = {
...defaultConfig,
htaccessWorking: false,
}
cy.mockInitialState('core', 'config', config)
cy.mount(SetupView)
cy.get('[data-cy-setup-form-note="htaccess"]')
.should('be.visible')
.should('contain', 'Security warning')
.invoke('html')
.should('contains', links.adminInstall)
})
})
describe('Setup page with autoconfig', () => {
beforeEach(() => {
cy.mockInitialState('core', 'links', links)
})
afterEach(() => cy.unmockInitialState())
it('Renders autoconfig', () => {
const config = {
...defaultConfig,
hasAutoconfig: true,
dbname: 'nextcloud',
dbtype: 'mysql',
dbuser: 'nextcloud',
dbpass: 'password',
dbhost: 'localhost',
directory: '/var/www/html/nextcloud',
} as SetupConfig
cy.mockInitialState('core', 'config', config)
cy.mount(SetupView)
// Autoconfig info note
cy.get('[data-cy-setup-form-note="autoconfig"]')
.should('be.visible')
.should('contain', 'Autoconfig file detected')
// Database and storage section is hidden as already set in autoconfig
cy.get('[data-cy-setup-form-advanced-config]').should('be.visible')
.invoke('attr', 'open')
.should('equal', undefined)
// Oracle tablespace is hidden
cy.get('[data-cy-setup-form-field="dbtablespace"]')
.should('not.exist')
})
})
describe('Submit a full form sends the data', () => {
beforeEach(() => {
cy.mockInitialState('core', 'links', links)
})
afterEach(() => cy.unmockInitialState())
it('Submits a full form', () => {
const config = {
...defaultConfig,
adminlogin: 'admin',
adminpass: 'password',
dbname: 'nextcloud',
dbtype: 'mysql',
dbuser: 'nextcloud',
dbpass: 'password',
dbhost: 'localhost',
dbtablespace: 'tablespace',
directory: '/var/www/html/nextcloud',
} as SetupConfig
cy.intercept('POST', '**', {
delay: 2000,
}).as('setup')
cy.mockInitialState('core', 'config', config)
cy.mount(SetupView)
// Not chaining breaks the test as the POST prevents the element from being retrieved twice
// eslint-disable-next-line cypress/unsafe-to-chain-command
cy.get('[data-cy-setup-form-submit]')
.click()
.invoke('attr', 'disabled')
.should('equal', 'disabled', { timeout: 500 })
cy.wait('@setup')
.its('request.body')
.should('deep.equal', new URLSearchParams({
adminlogin: 'admin',
adminpass: 'password',
directory: '/var/www/html/nextcloud',
dbtype: 'mysql',
dbuser: 'nextcloud',
dbpass: 'password',
dbname: 'nextcloud',
dbhost: 'localhost',
}).toString())
})
})

@ -0,0 +1,306 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { SetupConfig, SetupLinks } from '../install.ts'
import { cleanup, findByRole, fireEvent, getAllByRole, getByRole, render } from '@testing-library/vue'
import { beforeEach, describe, expect, it } from 'vitest'
import SetupView from './Setup.vue'
import '../../css/guest.css'
const defaultConfig = Object.freeze({
adminlogin: '',
adminpass: '',
dbuser: '',
dbpass: '',
dbname: '',
dbtablespace: '',
dbhost: '',
dbtype: '',
databases: {
sqlite: 'SQLite',
mysql: 'MySQL/MariaDB',
pgsql: 'PostgreSQL',
},
directory: '',
hasAutoconfig: false,
htaccessWorking: true,
serverRoot: '/var/www/html',
errors: [],
}) as SetupConfig
const links = {
adminInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-install',
adminSourceInstall: 'https://docs.nextcloud.com/server/32/go.php?to=admin-source_install',
adminDBConfiguration: 'https://docs.nextcloud.com/server/32/go.php?to=admin-db-configuration',
} as SetupLinks
describe('Default setup page', () => {
beforeEach(cleanup)
beforeEach(() => {
removeInitialState()
mockInitialState('core', 'links', links)
})
it('Renders default config', async () => {
mockInitialState('core', 'config', defaultConfig)
const component = render(SetupView)
// Single note is the footer help
expect(component.getAllByRole('note')).toHaveLength(1)
expect(component.getByRole('note').textContent).toContain('See the documentation')
// DB radio selectors
const dbTypes = component.getByRole('group', { name: 'Database type' })
expect(getAllByRole(dbTypes, 'radio')).toHaveLength(3)
await expect(findByRole(dbTypes, 'radio', { checked: true })).resolves.not.toThrow()
await expect(findByRole(dbTypes, 'radio', { name: /MySQL/ })).resolves.not.toThrow()
await expect(findByRole(dbTypes, 'radio', { name: /PostgreSQL/ })).resolves.not.toThrow()
await expect(findByRole(dbTypes, 'radio', { name: /SQLite/ })).resolves.not.toThrow()
// Sqlite warning
await expect(component.findByText(/SQLite should only be used for minimal and development instances/)).resolves.not.toThrow()
// admin login, password, data directory
await expect(component.findByRole('textbox', { name: 'Administration account name' })).resolves.not.toThrow()
await expect(component.findByLabelText('Administration account password')).resolves.not.toThrow()
await expect(component.findByRole('textbox', { name: 'Data folder' })).resolves.not.toThrow()
})
it('Renders single DB sqlite', async () => {
mockInitialState('core', 'config', {
...defaultConfig,
databases: {
sqlite: 'SQLite',
},
})
const component = render(SetupView)
const dbTypes = component.getByRole('group', { name: 'Database type' })
expect(getAllByRole(dbTypes, 'radio', { hidden: true })).toHaveLength(1)
await expect(findByRole(dbTypes, 'radio', { name: /SQLite/, hidden: true })).resolves.not.toThrow()
// Two warnings: sqlite and single db support
await expect(component.findByText(/Only SQLite is available./)).resolves.not.toThrow()
await expect(component.findByText(/SQLite should only be used for minimal and development instances/)).resolves.not.toThrow()
})
it('Renders single DB mysql', async () => {
mockInitialState('core', 'config', {
...defaultConfig,
databases: {
mysql: 'MySQL/MariaDB',
},
})
const component = render(SetupView)
const dbTypes = component.getByRole('group', { name: 'Database type' })
expect(getAllByRole(dbTypes, 'radio', { hidden: true })).toHaveLength(1)
await expect(findByRole(dbTypes, 'radio', { name: /MySQL/, hidden: true })).resolves.not.toThrow()
// Single db support warning
await expect(component.findByText(/Only MySQL.* is available./)).resolves.not.toThrow()
// No SQLite warning
await expect(component.findByText(/SQLite should only be used for minimal and development instances/)).rejects.toThrow()
// database config
await expect(component.findByRole('textbox', { name: /Database user/ })).resolves.not.toThrow()
await expect(component.findByRole('textbox', { name: /Database name/ })).resolves.not.toThrow()
await expect(component.findByRole('textbox', { name: /Database host/ })).resolves.not.toThrow()
await expect(component.findByLabelText(/Database password/)).resolves.not.toThrow()
})
it('Changes fields from sqlite to mysql then oci', async () => {
mockInitialState('core', 'config', {
...defaultConfig,
databases: {
sqlite: 'SQLite',
mysql: 'MySQL/MariaDB',
pgsql: 'PostgreSQL',
oci: 'Oracle',
},
})
const component = render(SetupView)
// SQLite selected
await expect(component.findByRole('radio', { name: /SQLite/, checked: true })).resolves.not.toThrow()
// 4 db toggles
const dbTypes = component.getByRole('group', { name: 'Database type' })
expect(getAllByRole(dbTypes, 'radio')).toHaveLength(4)
// but no database config fields
await expect(findByRole(dbTypes, 'group', { name: /Database connection/ })).rejects.toThrow()
// Change to MySQL
await fireEvent.click(getByRole(dbTypes, 'radio', { name: /MySQL/, checked: false }))
expect((getByRole(dbTypes, 'radio', { name: /SQLite/, checked: false }) as HTMLInputElement).checked).toBe(false)
expect((getByRole(dbTypes, 'radio', { name: /MySQL/, checked: true }) as HTMLInputElement).checked).toBe(true)
// now the database config fields are visible
await expect(component.findByRole('group', { name: /Database connection/ })).resolves.not.toThrow()
// but not the Database tablespace
await expect(component.findByRole('textbox', { name: /Database tablespace/ })).rejects.toThrow()
// Change to Oracle
await fireEvent.click(getByRole(dbTypes, 'radio', { name: /Oracle/, checked: false }))
// see database config fields are visible and tablespace
await expect(component.findByRole('textbox', { name: /Database tablespace/ })).resolves.not.toThrow()
await expect(component.findByRole('group', { name: /Database connection/ })).resolves.not.toThrow()
})
})
describe('Setup page with errors and warning', () => {
beforeEach(cleanup)
beforeEach(() => {
removeInitialState()
mockInitialState('core', 'links', links)
})
it('Renders error from backend', async () => {
mockInitialState('core', 'config', {
...defaultConfig,
errors: [
{
error: 'Error message',
hint: 'Error hint',
},
],
})
const component = render(SetupView)
// Error message and hint
await expect(component.findByText('Error message')).resolves.not.toThrow()
await expect(component.findByText('Error hint')).resolves.not.toThrow()
})
it('Renders errors from backend', async () => {
const config = {
...defaultConfig,
errors: [
'Error message 1',
{
error: 'Error message 2',
hint: 'Error hint',
},
],
}
mockInitialState('core', 'config', config)
const component = render(SetupView)
// Error message and hint
await expect(component.findByText('Error message 1')).resolves.not.toThrow()
await expect(component.findByText('Error message 2')).resolves.not.toThrow()
await expect(component.findByText('Error hint')).resolves.not.toThrow()
})
it('Renders all the submitted fields on error', async () => {
const config = {
...defaultConfig,
adminlogin: 'admin',
adminpass: 'password',
dbname: 'nextcloud',
dbtype: 'mysql',
dbuser: 'nextcloud',
dbpass: 'password',
dbhost: 'localhost',
directory: '/var/www/html/nextcloud',
} as SetupConfig
mockInitialState('core', 'config', config)
const component = render(SetupView)
await expect(component.findByRole('textbox', { name: 'Data folder' })).resolves.not.toThrow()
expect((component.getByRole('textbox', { name: 'Data folder' }) as HTMLInputElement).value).toBe('/var/www/html/nextcloud')
await expect(component.findByRole('textbox', { name: 'Administration account name' })).resolves.not.toThrow()
expect((component.getByRole('textbox', { name: 'Administration account name' }) as HTMLInputElement).value).toBe('admin')
await expect(component.findByLabelText('Administration account password')).resolves.not.toThrow()
expect((component.getByLabelText('Administration account password') as HTMLInputElement).value).toBe('password')
await expect(component.findByRole('radio', { name: /MySQL/, checked: true, hidden: true })).resolves.not.toThrow()
await expect(component.findByRole('textbox', { name: 'Database name' })).resolves.not.toThrow()
expect((component.getByRole('textbox', { name: 'Database name' }) as HTMLInputElement).value).toBe('nextcloud')
await expect(component.findByRole('textbox', { name: 'Database user' })).resolves.not.toThrow()
expect((component.getByRole('textbox', { name: 'Database user' }) as HTMLInputElement).value).toBe('nextcloud')
await expect(component.findByRole('textbox', { name: 'Database host' })).resolves.not.toThrow()
expect((component.getByRole('textbox', { name: 'Database host' }) as HTMLInputElement).value).toBe('localhost')
await expect(component.findByLabelText('Database password')).resolves.not.toThrow()
expect((component.getByLabelText('Database password') as HTMLInputElement).value).toBe('password')
})
it('Renders the htaccess warning', async () => {
const config = {
...defaultConfig,
htaccessWorking: false,
}
mockInitialState('core', 'config', config)
const component = render(SetupView)
await expect(component.findByText('Security warning')).resolves.not.toThrow()
})
})
describe('Setup page with autoconfig', () => {
beforeEach(cleanup)
beforeEach(() => {
removeInitialState()
mockInitialState('core', 'links', links)
})
it('Renders autoconfig', async () => {
const config = {
...defaultConfig,
hasAutoconfig: true,
dbname: 'nextcloud',
dbtype: 'mysql',
dbuser: 'nextcloud',
dbpass: 'password',
dbhost: 'localhost',
directory: '/var/www/html/nextcloud',
} as SetupConfig
mockInitialState('core', 'config', config)
const component = render(SetupView)
// Autoconfig info note
await expect(component.findByText('Autoconfig file detected')).resolves.not.toThrow()
// Oracle tablespace is hidden
await expect(component.findByRole('textbox', { name: 'Database tablespace' })).rejects.toThrow()
// Database and storage section is hidden as already set in autoconfig
await expect(component.findByText('Storage & database')).resolves.not.toThrow()
expect(component.getByText('Storage & database').closest('details')!.getAttribute('hidden')).toBeNull()
})
})
/**
* 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)
}

@ -89,6 +89,10 @@
<!-- Database type select -->
<fieldset class="setup-form__database-type">
<legend class="hidden-visually">
{{ t('core', 'Database type') }}
</legend>
<!-- Using v-show instead of v-if ensures that the input dbtype remains set even when only one database engine is available -->
<p v-show="!firstAndOnlyDatabase" :class="`setup-form__database-type-select--${DBTypeGroupDirection}`" class="setup-form__database-type-select">
<NcCheckboxRadioSwitch
@ -126,6 +130,10 @@
<!-- Database configuration -->
<fieldset v-if="config.dbtype !== 'sqlite'">
<legend class="hidden-visually">
{{ t('core', 'Database connection') }}
</legend>
<NcTextField
v-model="config.dbuser"
:label="t('core', 'Database user')"

@ -134,40 +134,4 @@ export default defineConfig({
return config
},
},
component: {
specPattern: ['core/**/*.cy.ts', 'apps/**/*.cy.ts'],
devServer: {
framework: 'vue',
bundler: 'webpack',
webpackConfig: async () => {
process.env.npm_package_name = 'NcCypress'
process.env.npm_package_version = '1.0.0'
process.env.NODE_ENV = 'development'
/**
* Needed for cypress stubbing
*
* @see https://github.com/sinonjs/sinon/issues/1121
* @see https://github.com/cypress-io/cypress/issues/18662
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
const babel = require('./babel.config.js')
babel.plugins.push([
'@babel/plugin-transform-modules-commonjs',
{
loose: true,
},
])
const config = webpackConfig
config.module.rules.push({
test: /\.svg$/,
type: 'asset/source',
})
return config
},
},
},
})

@ -1,16 +0,0 @@
<!DOCTYPE html>
<!--
- SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>

@ -1,43 +0,0 @@
/**
* SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mount } from '@cypress/vue2'
import '@testing-library/cypress/add-commands'
import 'cypress-axe'
// styles
import '../../apps/theming/css/default.css'
import '../../core/css/server.css'
Cypress.Commands.add('mount', (component, options = {}) => {
// Setup options object
options.extensions = options.extensions || {}
options.extensions.plugins = options.extensions.plugins || []
options.extensions.components = options.extensions.components || {}
return mount(component, options)
})
Cypress.Commands.add('mockInitialState', (app: string, key: string, value: unknown) => {
cy.document().then(($document) => {
const input = $document.createElement('input')
input.setAttribute('type', 'hidden')
input.setAttribute('id', `initial-state-${app}-${key}`)
input.setAttribute('value', btoa(JSON.stringify(value)))
$document.body.appendChild(input)
})
})
Cypress.Commands.add('unmockInitialState', (app?: string, key?: string) => {
cy.window().then(($window) => {
// @ts-expect-error internal value
delete $window._nc_initial_state
})
cy.document().then(($document) => {
$document.querySelectorAll('body > input[type="hidden"]' + (app ? `[id="initial-state-${app}-${key}"]` : ''))
.forEach((node) => $document.body.removeChild(node))
})
})

@ -1,17 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { mount } from '@cypress/vue2'
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount
mockInitialState: (app: string, key: string, value: unknown) => Cypress.Chainable<void>
unmockInitialState: (app?: string, key?: string) => Cypress.Chainable<void>
}
}
}

@ -1,6 +1,6 @@
{
"extends": "../tsconfig.json",
"include": ["./**/*.ts", "../**/*.cy.ts", "./cypress-e2e.d.ts", "./cypress-component.d.ts"],
"include": ["./**/*.ts", "./cypress-e2e.d.ts", "./cypress-component.d.ts"],
"exclude": [],
"compilerOptions": {
"types": [

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

45
package-lock.json generated

@ -92,7 +92,6 @@
"@babel/plugin-transform-private-methods": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@codecov/webpack-plugin": "^1.9.1",
"@cypress/vue2": "^2.1.1",
"@cypress/webpack-preprocessor": "^7.0.0",
"@nextcloud/babel-config": "^1.2.0",
"@nextcloud/cypress": "^1.0.0-beta.15",
@ -128,6 +127,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",
@ -2267,21 +2267,6 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/@cypress/vue2": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@cypress/vue2/-/vue2-2.1.1.tgz",
"integrity": "sha512-8/1Z6XrSdJWU9ybniGKyUe5iztVIi/Y5PwWg6mtsa8IMdtK2ZA8Vrv/ZIZ8jT3XAEUSaMhPBEh6TgUbq03kr8w==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"peerDependencies": {
"cypress": ">=4.5.0",
"vue": "^2.0.0"
}
},
"node_modules/@cypress/webpack-preprocessor": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@cypress/webpack-preprocessor/-/webpack-preprocessor-7.0.1.tgz",
@ -8068,6 +8053,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 +9558,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 +14977,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",

@ -19,9 +19,7 @@
"scripts": {
"build": "webpack --node-env production --progress",
"postbuild": "build/npm-post-build.sh",
"cypress": "npm run cypress:component && npm run cypress:e2e",
"cypress:component": "cypress run --component",
"cypress:e2e": "cypress run --e2e",
"cypress": "cypress run --e2e",
"cypress:gui": "cypress open",
"cypress:version": "cypress version",
"dev": "webpack --node-env development --progress",
@ -128,7 +126,6 @@
"@babel/plugin-transform-private-methods": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@codecov/webpack-plugin": "^1.9.1",
"@cypress/vue2": "^2.1.1",
"@cypress/webpack-preprocessor": "^7.0.0",
"@nextcloud/babel-config": "^1.2.0",
"@nextcloud/cypress": "^1.0.0-beta.15",
@ -164,6 +161,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",