diff --git a/__tests__/setup-global.js b/__tests__/setup-global.js index 93230b0deab..df4486751c7 100644 --- a/__tests__/setup-global.js +++ b/__tests__/setup-global.js @@ -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' } diff --git a/__tests__/setup-testing-library.js b/__tests__/setup-testing-library.js index 190e6f93e7c..2020513d2f5 100644 --- a/__tests__/setup-testing-library.js +++ b/__tests__/setup-testing-library.js @@ -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' diff --git a/apps/federatedfilesharing/src/components/RemoteShareDialog.cy.ts b/apps/federatedfilesharing/src/components/RemoteShareDialog.cy.ts deleted file mode 100644 index 79b5138327a..00000000000 --- a/apps/federatedfilesharing/src/components/RemoteShareDialog.cy.ts +++ /dev/null @@ -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) - }) -}) diff --git a/apps/federatedfilesharing/src/components/RemoteShareDialog.spec.ts b/apps/federatedfilesharing/src/components/RemoteShareDialog.spec.ts new file mode 100644 index 00000000000..d4603b06666 --- /dev/null +++ b/apps/federatedfilesharing/src/components/RemoteShareDialog.spec.ts @@ -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) + }) +}) diff --git a/apps/federatedfilesharing/src/components/RemoteShareDialog.vue b/apps/federatedfilesharing/src/components/RemoteShareDialog.vue index 9ee44f586bf..06847ca0f6f 100644 --- a/apps/federatedfilesharing/src/components/RemoteShareDialog.vue +++ b/apps/federatedfilesharing/src/components/RemoteShareDialog.vue @@ -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), }, ]) diff --git a/apps/files/src/composables/useFileListWidth.cy.ts b/apps/files/src/composables/useFileListWidth.cy.ts deleted file mode 100644 index b0d42c4a2d6..00000000000 --- a/apps/files/src/composables/useFileListWidth.cy.ts +++ /dev/null @@ -1,56 +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: '
{{ fileListWidth }}
', - setup() { - return { - fileListWidth: useFileListWidth(), - } - }, -}) -const FileListMock = defineComponent({ - template: '
', - 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') - }) -}) diff --git a/apps/files/src/composables/useFileListWidth.spec.ts b/apps/files/src/composables/useFileListWidth.spec.ts new file mode 100644 index 00000000000..c36f7cb4456 --- /dev/null +++ b/apps/files/src/composables/useFileListWidth.spec.ts @@ -0,0 +1,79 @@ +/*! + * 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, nextTick } from 'vue' + +let resizeObserver: ReturnType + +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: '
{{ fileListWidth }}
', + setup() { + return { + fileListWidth: useFileListWidth(), + } + }, + }) + + const FileListMock = defineComponent({ + template: '
', + 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, + } +} diff --git a/apps/files/src/views/DialogConfirmFileExtension.cy.ts b/apps/files/src/views/DialogConfirmFileExtension.cy.ts deleted file mode 100644 index 460497dd91f..00000000000 --- a/apps/files/src/views/DialogConfirmFileExtension.cy.ts +++ /dev/null @@ -1,161 +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' - -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) - }) -}) diff --git a/apps/files/src/views/DialogConfirmFileExtension.spec.ts b/apps/files/src/views/DialogConfirmFileExtension.spec.ts new file mode 100644 index 00000000000..4fba503767b --- /dev/null +++ b/apps/files/src/views/DialogConfirmFileExtension.spec.ts @@ -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) + }) +}) diff --git a/apps/files/src/views/Navigation.cy.ts b/apps/files/src/views/Navigation.cy.ts deleted file mode 100644 index 8dd0573201d..00000000000 --- a/apps/files/src/views/Navigation.cy.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ -import type { Navigation } from '@nextcloud/files' -import FolderSvg from '@mdi/svg/svg/folder.svg?raw' -import { createTestingPinia } from '@pinia/testing' - -import NavigationView from './Navigation.vue' -import { useViewConfigStore } from '../store/viewConfig' -import { Folder, View, getNavigation } from '@nextcloud/files' - -import router from '../router/router.ts' -import RouterService from '../services/RouterService' - -const resetNavigation = () => { - const nav = getNavigation() - ;[...nav.views].forEach(({ id }) => nav.remove(id)) - nav.setActive(null) -} - -const createView = (id: string, name: string, parent?: string) => 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 - }) -}) diff --git a/apps/files/src/views/Navigation.spec.ts b/apps/files/src/views/Navigation.spec.ts new file mode 100644 index 00000000000..dac79d4eda5 --- /dev/null +++ b/apps/files/src/views/Navigation.spec.ts @@ -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 './Navigation.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) +} diff --git a/apps/settings/src/components/Markdown.cy.ts b/apps/settings/src/components/Markdown.cy.ts deleted file mode 100644 index ccdf43c26df..00000000000 --- a/apps/settings/src/components/Markdown.cy.ts +++ /dev/null @@ -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') - }) -}) diff --git a/apps/settings/src/components/Markdown.spec.ts b/apps/settings/src/components/Markdown.spec.ts new file mode 100644 index 00000000000..64f5d902279 --- /dev/null +++ b/apps/settings/src/components/Markdown.spec.ts @@ -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() + }) +}) diff --git a/package-lock.json b/package-lock.json index 3f2d666c39a..2f0421ac471 100644 --- a/package-lock.json +++ b/package-lock.json @@ -128,7 +128,8 @@ "handlebars-loader": "^1.7.3", "jasmine-core": "~2.99.1", "jasmine-sinon": "^0.4.0", - "jsdom": "^26.1.0", + "jsdom": "^27.0.0", + "jsdom-testing-mocks": "^1.16.0", "karma": "^6.4.4", "karma-chrome-launcher": "^3.2.0", "karma-coverage": "2.2.1", @@ -163,6 +164,13 @@ "npm": "^10.5.0" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.29", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.29.tgz", + "integrity": "sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==", + "dev": true, + "license": "MIT" + }, "node_modules/@actions/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@actions/core/-/core-1.11.1.tgz", @@ -224,25 +232,59 @@ } }, "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" } }, "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "dev": true, - "license": "ISC" + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -2032,9 +2074,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", - "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "dev": true, "funding": [ { @@ -2076,9 +2118,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", - "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "dev": true, "funding": [ { @@ -2092,7 +2134,7 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.0.2", + "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "engines": { @@ -2127,9 +2169,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.19", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.19.tgz", - "integrity": "sha512-QW5/SM2ARltEhoKcmRI1LoLf3/C7dHGswwCnfLcoMgqurBT4f8GvwXMgAbK/FwcxthmJRK5MGTtddj0yQn0J9g==", + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.21.tgz", + "integrity": "sha512-plP8N8zKfEZ26figX4Nvajx8DuzfuRpLTqglQ5d0chfnt35Qt3X+m6ASZ+rG0D0kxe/upDVNwSIVJP5n4FuNfw==", "dev": true, "funding": [ { @@ -2141,6 +2183,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { "node": ">=18" } @@ -8930,6 +8973,23 @@ "tweetnacl": "^0.14.3" } }, + "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", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -10557,6 +10617,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", @@ -10592,17 +10659,18 @@ } }, "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.5.tgz", + "integrity": "sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/csstype": { @@ -10800,17 +10868,17 @@ } }, "node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" + "whatwg-url": "^15.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/data-view-buffer": { @@ -16833,35 +16901,35 @@ } }, "node_modules/jsdom": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", - "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "version": "27.3.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.3.0.tgz", + "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", "dev": true, "license": "MIT", "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", + "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", + "tough-cookie": "^6.0.0", "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", + "webidl-conversions": "^8.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -16872,6 +16940,53 @@ } } }, + "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.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/jsdom/node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom/node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/jsdom/node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -19444,13 +19559,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/nwsapi": { - "version": "2.2.21", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.21.tgz", - "integrity": "sha512-o6nIY3qwiSXl7/LuOU0Dmuctd34Yay0yeuZRLFmDPrrdHpXKFndPj3hM+YEPVHYC5fx2otBx4Ilc/gyYSAUaIA==", - "dev": true, - "license": "MIT" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -20002,9 +20110,9 @@ } }, "node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "dev": true, "license": "MIT", "dependencies": { @@ -22229,13 +22337,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT" - }, "node_modules/run-applescript": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", @@ -25206,16 +25307,16 @@ } }, "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/tr46/node_modules/punycode": { @@ -26989,13 +27090,13 @@ "dev": true }, "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=12" + "node": ">=20" } }, "node_modules/webpack": { @@ -27453,17 +27554,17 @@ } }, "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/which": { diff --git a/package.json b/package.json index e8a04e9984a..5ae92397500 100644 --- a/package.json +++ b/package.json @@ -159,7 +159,8 @@ "handlebars-loader": "^1.7.3", "jasmine-core": "~2.99.1", "jasmine-sinon": "^0.4.0", - "jsdom": "^26.1.0", + "jsdom": "^27.0.0", + "jsdom-testing-mocks": "^1.16.0", "karma": "^6.4.4", "karma-chrome-launcher": "^3.2.0", "karma-coverage": "2.2.1",