From 8f304a539923229f0a2f3bb13603134e3e041ffa Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Sun, 19 Jul 2026 11:05:53 +0200 Subject: [PATCH] feat: add jest tests --- .github/workflows/admin-jest.yml | 30 ++ .gitignore | 1 + README.md | 3 + .../app/administration/jest.config.js | 32 ++ src/Resources/app/administration/package.json | 4 + .../src/api/frosh-tools.spec.js | 78 +-- .../index.spec.js | 84 ++-- .../frosh-tools-tab-index/index.spec.js | 77 +-- .../frosh-tools-tab-scheduled/index.spec.js | 54 +-- .../component/ft-command-palette/commands.js | 448 ++++++++++++++++++ .../ft-command-palette/commands.spec.js | 147 ++++++ .../component/ft-command-palette/index.js | 409 ++++++++++++++++ .../ft-command-palette/index.spec.js | 145 ++++++ .../component/ft-command-palette/style.scss | 252 ++++++++++ .../ft-command-palette/template.html.twig | 159 +++++++ .../component/ft-modal/index.spec.js | 21 +- .../component/ft-th-sort/index.spec.js | 17 +- .../src/module/frosh-tools/index.js | 1 + .../module/frosh-tools/page/index/index.js | 39 ++ .../frosh-tools/page/index/template.twig | 24 + .../src/module/frosh-tools/snippet/de-DE.json | 41 ++ .../src/module/frosh-tools/snippet/en-GB.json | 41 ++ .../frosh-tools/styles/design-system.scss | 61 +++ .../app/administration/test/mount.js | 182 +++++++ .../administration/test/resolve-admin-path.js | 49 ++ src/Resources/app/administration/test/run.js | 57 +++ 26 files changed, 2293 insertions(+), 163 deletions(-) create mode 100644 .github/workflows/admin-jest.yml create mode 100644 src/Resources/app/administration/jest.config.js create mode 100644 src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.js create mode 100644 src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.spec.js create mode 100644 src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.js create mode 100644 src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.spec.js create mode 100644 src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/style.scss create mode 100644 src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/template.html.twig create mode 100644 src/Resources/app/administration/test/mount.js create mode 100644 src/Resources/app/administration/test/resolve-admin-path.js create mode 100644 src/Resources/app/administration/test/run.js diff --git a/.github/workflows/admin-jest.yml b/.github/workflows/admin-jest.yml new file mode 100644 index 00000000..182ee81c --- /dev/null +++ b/.github/workflows/admin-jest.yml @@ -0,0 +1,30 @@ +name: Administration Jest + +on: + pull_request: + paths: + - 'src/Resources/app/administration/**' + - .github/workflows/admin-jest.yml + push: + branches: + - main + paths: + - 'src/Resources/app/administration/**' + - .github/workflows/admin-jest.yml + workflow_dispatch: + +jobs: + jest: + strategy: + fail-fast: false + matrix: + shopware-version: + - '6.6.x' + - 'trunk' + uses: shopware/github-actions/.github/workflows/admin-jest.yml@main + with: + extensionName: ${{ github.event.repository.name }} + shopwareVersion: ${{ matrix.shopware-version }} + uploadCoverage: true + secrets: + codecovToken: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index f65cb550..3c4f5704 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /src/Resources/public/ /src/Resources/app/administration/node_modules/ /src/Resources/app/administration/.tmp/ +/src/Resources/app/administration/build/ diff --git a/README.md b/README.md index c9fd3904..58cd4104 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ The current feature set consists of: - basic view of order, transaction and delivery states - **Override system config by config files** - Overwrite any system config value with static or environment values +- **Command Palette** + - Open with `Ctrl/⌘ + K` (or the sidebar search control) + - Jump to any Tools tab and run common actions (clear caches, compile themes, reset queue, register tasks) ## Installation diff --git a/src/Resources/app/administration/jest.config.js b/src/Resources/app/administration/jest.config.js new file mode 100644 index 00000000..4c7337d6 --- /dev/null +++ b/src/Resources/app/administration/jest.config.js @@ -0,0 +1,32 @@ +/** + * Plugin test config: reuses the Shopware administration's jest config and + * only points it at this plugin's spec files. This keeps the plugin on the + * exact same harness (transformers, Shopware globals, jsdom setup) that the + * Shopware installation provides, instead of duplicating it. + */ +const { join } = require('path'); +const resolveAdminPath = require('./test/resolve-admin-path'); + +const adminPath = resolveAdminPath(); + +process.env.ADMIN_PATH = process.env.ADMIN_PATH || adminPath; +process.env.PROJECT_ROOT = process.env.PROJECT_ROOT || adminPath; + +// eslint-disable-next-line import/no-dynamic-require +const coreConfig = require(join(adminPath, 'jest.config.js')); + +module.exports = { + ...coreConfig, + rootDir: adminPath, + roots: [join(__dirname, 'src')], + testMatch: [join(__dirname, 'src/**/*.spec.js')], + moduleNameMapper: { + ...coreConfig.moduleNameMapper, + '^frosh-test/(.*)$': join(__dirname, 'test/$1'), + }, + coverageDirectory: join(__dirname, 'build', 'artifacts', 'jest'), + collectCoverageFrom: [ + join(__dirname, 'src/**/*.js'), + `!${join(__dirname, 'src/**/*.spec.js')}`, + ], +}; diff --git a/src/Resources/app/administration/package.json b/src/Resources/app/administration/package.json index 8b98a08b..2df03ca9 100644 --- a/src/Resources/app/administration/package.json +++ b/src/Resources/app/administration/package.json @@ -2,6 +2,10 @@ "name": "Resources", "version": "1.0.0", "main": "index.js", + "scripts": { + "unit": "node test/run.js", + "unit:watch": "node test/run.js --watch" + }, "dependencies": { "diff-match-patch": "^1.0.5", "lucide-static": "^1.14.0", diff --git a/src/Resources/app/administration/src/api/frosh-tools.spec.js b/src/Resources/app/administration/src/api/frosh-tools.spec.js index 0a964d8d..f7f37cc6 100644 --- a/src/Resources/app/administration/src/api/frosh-tools.spec.js +++ b/src/Resources/app/administration/src/api/frosh-tools.spec.js @@ -1,17 +1,25 @@ -import FroshTools from './frosh-tools'; +import FroshToolsService from './frosh-tools'; -describe('FroshTools queue API', () => { +/** + * The service is constructed the same way the DI container does it, with the + * HTTP client as the only mock — that is the system boundary. Base path and + * auth headers are the real implementation. + */ +describe('FroshTools API service', () => { + let httpClient; let service; beforeEach(() => { - service = Object.create(FroshTools.prototype); - service.httpClient = { + httpClient = { get: jest.fn().mockResolvedValue({ data: {} }), post: jest.fn().mockResolvedValue({ data: {} }), delete: jest.fn().mockResolvedValue({ data: {} }), }; - service.getApiBasePath = () => '/_action/frosh-tools'; - service.getBasicHeaders = () => ({}); + + service = new FroshToolsService(httpClient, { + getToken: () => 'test-token', + isLoggedIn: () => true, + }); }); it('encodes transport names in every queue route', async () => { @@ -23,24 +31,40 @@ describe('FroshTools queue API', () => { await service.deleteQueueMessage(name, 'message/id'); await service.purgeQueueTransport(name); - expect(service.httpClient.get).toHaveBeenCalledWith( - `/_action/frosh-tools/queue/transport/${encodedName}/messages`, - expect.any(Object) + expect(httpClient.get).toHaveBeenCalledWith( + `_action/frosh-tools/queue/transport/${encodedName}/messages`, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + }), + }) ); - expect(service.httpClient.post).toHaveBeenCalledWith( - `/_action/frosh-tools/queue/transport/${encodedName}/messages/message%2Fid/retry`, + expect(httpClient.post).toHaveBeenCalledWith( + `_action/frosh-tools/queue/transport/${encodedName}/messages/message%2Fid/retry`, {}, - expect.any(Object) + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + }), + }) ); - expect(service.httpClient.delete).toHaveBeenNthCalledWith( + expect(httpClient.delete).toHaveBeenNthCalledWith( 1, - `/_action/frosh-tools/queue/transport/${encodedName}/messages/message%2Fid`, - expect.any(Object) + `_action/frosh-tools/queue/transport/${encodedName}/messages/message%2Fid`, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + }), + }) ); - expect(service.httpClient.delete).toHaveBeenNthCalledWith( + expect(httpClient.delete).toHaveBeenNthCalledWith( 2, - `/_action/frosh-tools/queue/transport/${encodedName}`, - expect.any(Object) + `_action/frosh-tools/queue/transport/${encodedName}`, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + }), + }) ); }); @@ -48,23 +72,21 @@ describe('FroshTools queue API', () => { await service.getSecuritySbom(); await service.getSecuritySbom(true); - expect(service.httpClient.get).toHaveBeenNthCalledWith( + expect(httpClient.get).toHaveBeenNthCalledWith( 1, - '/_action/frosh-tools/security/sbom', - { - headers: {}, + '_action/frosh-tools/security/sbom', + expect.objectContaining({ params: {}, responseType: 'blob', - } + }) ); - expect(service.httpClient.get).toHaveBeenNthCalledWith( + expect(httpClient.get).toHaveBeenNthCalledWith( 2, - '/_action/frosh-tools/security/sbom', - { - headers: {}, + '_action/frosh-tools/security/sbom', + expect.objectContaining({ params: { includeDev: 1 }, responseType: 'blob', - } + }) ); }); }); diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-elasticsearch/index.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-elasticsearch/index.spec.js index f80653e8..cdffc450 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-elasticsearch/index.spec.js +++ b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-elasticsearch/index.spec.js @@ -1,6 +1,4 @@ -import { mount } from '@vue/test-utils'; -import '../../../../mixin/sortable-table'; -import '../ft-modal'; +import { mountFrosh } from 'frosh-test/mount'; import './index'; function createService() { @@ -18,41 +16,40 @@ function createService() { }; } +/** + * Mounts the tab with the real ft-* component tree (including the real + * ft-modal for the confirmation dialog) and real translations. Only the + * elasticsearch API service (the HTTP boundary) is faked; the Shopware + * core code editor stays stubbed. + */ async function createWrapper(service) { - const [component, ftModal] = await Promise.all([ - Shopware.Component.build('frosh-tools-tab-elasticsearch'), - Shopware.Component.build('ft-modal'), - ]); - - return mount(component, { - global: { - provide: { froshElasticSearch: service }, - components: { 'ft-modal': ftModal }, - stubs: [ - 'ft-page-head', - 'ft-panel', - 'ft-empty', - 'ft-hero-state', - 'ft-pill', - 'ft-icon', - 'ft-th-sort', - 'ft-refresh-button', - 'sw-code-editor', - 'teleport', - ], - mocks: { - $t: (key) => key, - $tc: (key) => key, - }, - directives: { - tooltip: {}, - }, - }, + return mountFrosh('frosh-tools-tab-elasticsearch', { + provide: { froshElasticSearch: service }, + stubs: ['sw-code-editor'], attachTo: document.body, }); } +/** + * Success notifications are growl-only and never persisted; errors are + * persisted. Read both store slices to see what the user was shown. + */ +function notificationsOfVariant(variant) { + const store = Shopware.Store.get('notification'); + + return [ + ...Object.values(store.growlNotifications), + ...Object.values(store.notifications), + ].filter((notification) => notification.variant === variant); +} + describe('frosh-tools-tab-elasticsearch destructive actions', () => { + beforeEach(() => { + const store = Shopware.Store.get('notification'); + store.growlNotifications = {}; + store.notifications = {}; + }); + afterEach(() => { document.body.innerHTML = ''; }); @@ -71,14 +68,10 @@ describe('frosh-tools-tab-elasticsearch destructive actions', () => { }); expect(service.deleteIndex).not.toHaveBeenCalled(); - // The confirm modal renders the matching snippet for the action. + // The confirm modal renders the real snippets for the action. const modalText = wrapper.find('[role="dialog"]').text(); - expect(modalText).toContain( - 'frosh-tools.tabs.elasticsearch.confirm.deleteIndex.title' - ); - expect(modalText).toContain( - 'frosh-tools.tabs.elasticsearch.confirm.deleteIndex.confirm' - ); + expect(modalText).toContain('Delete index "shopware-product"?'); + expect(modalText).toContain('Delete index'); }); it('does not run the action when the confirmation is cancelled', async () => { @@ -98,17 +91,12 @@ describe('frosh-tools-tab-elasticsearch destructive actions', () => { const wrapper = await createWrapper(service); await flushPromises(); - const notifySuccess = jest.spyOn( - wrapper.vm, - 'createNotificationSuccess' - ); - wrapper.vm.askFlushAll(); await wrapper.vm.runConfirmedAction(); await flushPromises(); expect(service.flushAll).toHaveBeenCalledTimes(1); - expect(notifySuccess).toHaveBeenCalled(); + expect(notificationsOfVariant('success')).toHaveLength(1); expect(wrapper.vm.confirmAction).toBeNull(); // Refreshed status + indices after the action. expect(service.status).toHaveBeenCalledTimes(2); @@ -121,13 +109,13 @@ describe('frosh-tools-tab-elasticsearch destructive actions', () => { const wrapper = await createWrapper(service); await flushPromises(); - const notifyError = jest.spyOn(wrapper.vm, 'createNotificationError'); - wrapper.vm.askReset(); await wrapper.vm.runConfirmedAction(); expect(service.reset).toHaveBeenCalledTimes(1); - expect(notifyError).toHaveBeenCalledWith({ message: 'boom' }); + expect( + notificationsOfVariant('error').map((n) => n.message) + ).toContain('boom'); expect(wrapper.vm.confirmAction).not.toBeNull(); expect(wrapper.vm.isConfirmingAction).toBe(false); }); diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-index/index.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-index/index.spec.js index 7f921c29..0ced6587 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-index/index.spec.js +++ b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-index/index.spec.js @@ -1,40 +1,39 @@ -import { mount } from '@vue/test-utils'; -import '../../../../mixin/sortable-table'; +import { mountFrosh } from 'frosh-test/mount'; import './index'; -const STUBS = [ - 'ft-page-head', - 'ft-panel', - 'ft-empty', - 'ft-hero-state', - 'ft-th-sort', - 'ft-pill', - 'ft-modal', - 'ft-button', - 'ft-refresh-button', -]; +const PHP_HEALTH = { + id: 'php', + snippet: 'PHP version', + state: 'STATE_OK', + current: '8.3.0', + recommended: '8.2.0', +}; +/** + * Mounts the tab with the real ft-* component tree and real translations. + * Only the API service (the HTTP boundary) is faked. + */ async function createWrapper(service) { - const component = await Shopware.Component.build('frosh-tools-tab-index'); - - return mount(component, { - global: { - provide: { - froshToolsService: service, - }, - stubs: STUBS, - mocks: { - $t: (key) => key, - $tc: (key) => key, - }, + return mountFrosh('frosh-tools-tab-index', { + provide: { + froshToolsService: service, }, }); } +function errorNotifications() { + return Object.values(Shopware.Store.get('notification').notifications) + .filter((notification) => notification.variant === 'error'); +} + describe('frosh-tools-tab-index', () => { + beforeEach(() => { + Shopware.Store.get('notification').notifications = {}; + }); + it('loads health and performance status on creation', async () => { const service = { - healthStatus: jest.fn().mockResolvedValue([{ id: 'php' }]), + healthStatus: jest.fn().mockResolvedValue([PHP_HEALTH]), performanceStatus: jest.fn().mockResolvedValue([]), }; @@ -43,8 +42,14 @@ describe('frosh-tools-tab-index', () => { expect(wrapper.vm.isLoading).toBe(false); expect(wrapper.vm.loadError).toBeNull(); - expect(wrapper.vm.health).toEqual([{ id: 'php' }]); + expect(wrapper.vm.health).toEqual([PHP_HEALTH]); expect(wrapper.vm.performanceStatus).toEqual([]); + + // The real table renders the loaded health row. + expect(wrapper.find('tbody tr').exists()).toBe(true); + expect(wrapper.find('.ft-table__name').text()).toContain( + 'PHP version' + ); }); it('shows an error state instead of loading forever when loading fails', async () => { @@ -56,18 +61,21 @@ describe('frosh-tools-tab-index', () => { }; const wrapper = await createWrapper(service); - const notifyError = jest.spyOn(wrapper.vm, 'createNotificationError'); await flushPromises(); // No infinite spinner: loading finished and an error is surfaced. expect(wrapper.vm.isLoading).toBe(false); expect(wrapper.vm.loadError).toBe('Request failed'); - expect(notifyError).toHaveBeenCalledWith({ - message: 'Request failed', - }); + + // The failure created a real error notification. + expect(errorNotifications().map((n) => n.message)).toContain( + 'Request failed' + ); await wrapper.vm.$nextTick(); - expect(wrapper.find('ft-hero-state-stub').exists()).toBe(true); + const heroState = wrapper.find('.ft-hero-state--danger'); + expect(heroState.exists()).toBe(true); + expect(heroState.text()).toContain('Request failed'); }); it('recovers when retrying after a failure', async () => { @@ -75,7 +83,7 @@ describe('frosh-tools-tab-index', () => { healthStatus: jest .fn() .mockRejectedValueOnce(new Error('Request failed')) - .mockResolvedValue([{ id: 'php' }]), + .mockResolvedValue([PHP_HEALTH]), performanceStatus: jest.fn().mockResolvedValue([]), }; @@ -87,7 +95,8 @@ describe('frosh-tools-tab-index', () => { await flushPromises(); expect(wrapper.vm.loadError).toBeNull(); - expect(wrapper.vm.health).toEqual([{ id: 'php' }]); + expect(wrapper.vm.health).toEqual([PHP_HEALTH]); expect(wrapper.vm.isLoading).toBe(false); + expect(wrapper.find('.ft-hero-state--danger').exists()).toBe(false); }); }); diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-scheduled/index.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-scheduled/index.spec.js index fe95184a..ff626585 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-scheduled/index.spec.js +++ b/src/Resources/app/administration/src/module/frosh-tools/component/frosh-tools-tab-scheduled/index.spec.js @@ -1,5 +1,4 @@ -import { mount } from '@vue/test-utils'; -import '../../../../mixin/sortable-table'; +import { mountFrosh } from 'frosh-test/mount'; import './index'; const TASKS = [ @@ -21,44 +20,27 @@ const TASKS = [ }, ]; -const STUBS = { - // The panel must render its default slot — the table lives inside it. - 'ft-panel': { template: '
' }, - 'ft-page-head': true, - 'ft-empty': true, - 'ft-hero-state': true, - 'ft-th-sort': { template: '' }, - 'ft-pill': true, - 'ft-icon': true, - 'ft-modal': true, - 'ft-button': true, - 'ft-refresh-button': true, - 'sw-number-field': true, - 'sw-datepicker': true, -}; - +/** + * Mounts the tab with the real ft-* component tree (panel, table headers, + * empty states, modals) and real translations. Only the system boundaries + * are faked: the repository (HTTP) and the shared admin search. Shopware + * core form fields inside the edit modal stay stubbed. + */ async function createWrapper({ searchTerm = '' } = {}) { const scheduledRepository = { search: jest.fn().mockResolvedValue(TASKS), save: jest.fn().mockResolvedValue({}), }; - const component = await Shopware.Component.build( - 'frosh-tools-tab-scheduled' - ); - - return mount(component, { - global: { - provide: { - repositoryFactory: { create: () => scheduledRepository }, - froshToolsService: {}, - froshToolsSearch: { searchTerm }, - }, - stubs: STUBS, - mocks: { - $t: (key) => key, - $tc: (key) => key, - }, + return mountFrosh('frosh-tools-tab-scheduled', { + provide: { + repositoryFactory: { create: () => scheduledRepository }, + froshToolsService: {}, + froshToolsSearch: { searchTerm }, + }, + stubs: { + 'sw-number-field': true, + 'sw-datepicker': true, }, }); } @@ -90,6 +72,8 @@ describe('frosh-tools-tab-scheduled search', () => { expect(wrapper.vm.visibleItems).toHaveLength(0); expect(wrapper.findAll('tbody tr')).toHaveLength(0); - expect(wrapper.find('ft-empty-stub').exists()).toBe(true); + + const emptyState = wrapper.find('.ft-empty'); + expect(emptyState.exists()).toBe(true); }); }); diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.js b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.js new file mode 100644 index 00000000..07721cba --- /dev/null +++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.js @@ -0,0 +1,448 @@ +/** + * Static command definitions for the FroshTools command palette. + * + * Each command is either navigation (route) or an executable action. + * Labels/descriptions are snippet keys resolved by the palette component. + */ + +/** + * @typedef {Object} CommandContext + * @property {(location: { name: string }) => Promise|unknown} routerPush + * @property {Record} froshToolsService + * @property {{ assignTheme: Function }} [themeService] + * @property {{ create: Function }} [repositoryFactory] + * @property {(payload: { message: string }) => void} notifySuccess + * @property {(payload: { message: string }) => void} notifyError + * @property {(key: string, params?: Record) => string} t + * @property {boolean} elasticsearchAvailable + * @property {boolean} logsAvailable + * @property {boolean} fastlyAvailable + */ + +/** + * @typedef {Object} CommandDefinition + * @property {string} id + * @property {'navigate'|'action'} type + * @property {string} group // snippet key suffix under frosh-tools.commandPalette.groups + * @property {string} icon + * @property {string} labelKey + * @property {string} [descriptionKey] + * @property {string[]} [keywords] + * @property {boolean} [confirm] + * @property {string} [confirmLabelKey] + * @property {(ctx: CommandContext) => boolean} [available] + * @property {string} [route] + * @property {(ctx: CommandContext) => Promise|void} [run] + */ + +/** + * @returns {CommandDefinition[]} + */ +export function getCommandDefinitions() { + return [ + // ── Navigation ────────────────────────────────────────────── + { + id: 'nav.index', + type: 'navigate', + group: 'navigate', + icon: 'bolt', + labelKey: 'frosh-tools.tabs.index.title', + descriptionKey: 'frosh-tools.tabs.index.subtitle', + keywords: ['status', 'health', 'system', 'overview', 'home'], + route: 'frosh.tools.index.index', + }, + { + id: 'nav.security', + type: 'navigate', + group: 'navigate', + icon: 'alert', + labelKey: 'frosh-tools.tabs.security.title', + descriptionKey: 'frosh-tools.tabs.security.subtitle', + keywords: ['security', 'sbom', 'audit', 'eol', 'vulnerabilities'], + route: 'frosh.tools.index.security', + }, + { + id: 'nav.shopmon', + type: 'navigate', + group: 'navigate', + icon: 'send', + labelKey: 'frosh-tools.tabs.shopmon.title', + descriptionKey: + 'frosh-tools.commandPalette.descriptions.shopmon', + keywords: ['shopmon', 'monitoring', 'integration'], + route: 'frosh.tools.index.shopmon', + }, + { + id: 'nav.cache', + type: 'navigate', + group: 'navigate', + icon: 'refresh', + labelKey: 'frosh-tools.tabs.cache.title', + descriptionKey: 'frosh-tools.tabs.cache.subtitle', + keywords: ['cache', 'opcache', 'theme'], + route: 'frosh.tools.index.cache', + }, + { + id: 'nav.statistics', + type: 'navigate', + group: 'navigate', + icon: 'chart', + labelKey: 'frosh-tools.tabs.statistics.title', + descriptionKey: 'frosh-tools.tabs.statistics.subtitle', + keywords: ['statistics', 'redis', 'fpm', 'database', 'opcache'], + route: 'frosh.tools.index.statistics', + }, + { + id: 'nav.elasticsearch', + type: 'navigate', + group: 'navigate', + icon: 'search', + labelKey: 'frosh-tools.tabs.elasticsearch.title', + descriptionKey: 'frosh-tools.tabs.elasticsearch.subtitle', + keywords: ['elasticsearch', 'opensearch', 'index', 'search'], + available: (ctx) => ctx.elasticsearchAvailable, + route: 'frosh.tools.index.elasticsearch', + }, + { + id: 'nav.queue', + type: 'navigate', + group: 'navigate', + icon: 'flow', + labelKey: 'frosh-tools.tabs.queue.title', + descriptionKey: 'frosh-tools.tabs.queue.subtitle', + keywords: ['queue', 'messenger', 'messages', 'worker'], + route: 'frosh.tools.index.queue', + }, + { + id: 'nav.scheduled', + type: 'navigate', + group: 'navigate', + icon: 'play', + labelKey: 'frosh-tools.tabs.scheduledTaskOverview.title', + descriptionKey: 'frosh-tools.tabs.scheduledTaskOverview.subtitle', + keywords: ['scheduled', 'tasks', 'cron', 'schedule'], + route: 'frosh.tools.index.scheduled', + }, + { + id: 'nav.statemachines', + type: 'navigate', + group: 'navigate', + icon: 'flow', + labelKey: 'frosh-tools.tabs.state-machines.title', + descriptionKey: 'frosh-tools.tabs.state-machines.subtitle', + keywords: ['state', 'machine', 'order', 'transaction', 'delivery'], + route: 'frosh.tools.index.statemachines', + }, + { + id: 'nav.logs', + type: 'navigate', + group: 'navigate', + icon: 'file', + labelKey: 'frosh-tools.tabs.logs.title', + descriptionKey: 'frosh-tools.tabs.logs.subtitle', + keywords: ['logs', 'log viewer', 'error', 'var/log'], + available: (ctx) => ctx.logsAvailable, + route: 'frosh.tools.index.logs', + }, + { + id: 'nav.featureflags', + type: 'navigate', + group: 'navigate', + icon: 'cog', + labelKey: 'frosh-tools.tabs.feature-flags.title', + descriptionKey: 'frosh-tools.tabs.feature-flags.subtitle', + keywords: ['feature', 'flags', 'toggle'], + route: 'frosh.tools.index.featureflags', + }, + { + id: 'nav.fastly', + type: 'navigate', + group: 'navigate', + icon: 'bolt', + labelKey: 'frosh-tools.tabs.fastly.title', + descriptionKey: 'frosh-tools.commandPalette.descriptions.fastly', + keywords: ['fastly', 'cdn', 'purge'], + available: (ctx) => ctx.fastlyAvailable, + route: 'frosh.tools.index.fastly', + }, + + // ── Cache actions ─────────────────────────────────────────── + { + id: 'action.cache.clear-all', + type: 'action', + group: 'cache', + icon: 'trash', + labelKey: 'frosh-tools.commandPalette.actions.clearAllCaches', + descriptionKey: + 'frosh-tools.commandPalette.actions.clearAllCachesDescription', + keywords: ['clear', 'cache', 'all', 'flush'], + confirm: true, + confirmLabelKey: + 'frosh-tools.commandPalette.actions.clearAllCachesConfirm', + async run(ctx) { + const pools = await ctx.froshToolsService.getCacheInfo(); + const list = Array.isArray(pools) ? pools : []; + + for (const pool of list) { + if (pool?.name) { + await ctx.froshToolsService.clearCache(pool.name); + } + } + + ctx.notifySuccess({ + message: ctx.t( + 'frosh-tools.commandPalette.actions.clearAllCachesSuccess', + { count: list.length } + ), + }); + }, + }, + { + id: 'action.cache.clear-http', + type: 'action', + group: 'cache', + icon: 'trash', + labelKey: 'frosh-tools.commandPalette.actions.clearHttpCache', + descriptionKey: + 'frosh-tools.commandPalette.actions.clearHttpCacheDescription', + keywords: ['clear', 'http', 'cache', 'http_cache'], + async run(ctx) { + await clearCacheIfPresent(ctx, [ + 'http', + 'http_cache', + 'shopware.http_cache', + ]); + }, + }, + { + id: 'action.cache.clear-object', + type: 'action', + group: 'cache', + icon: 'trash', + labelKey: 'frosh-tools.commandPalette.actions.clearObjectCache', + descriptionKey: + 'frosh-tools.commandPalette.actions.clearObjectCacheDescription', + keywords: ['clear', 'object', 'cache', 'app'], + async run(ctx) { + await clearCacheIfPresent(ctx, [ + 'object', + 'app', + 'cache.object', + 'shopware.cache', + ]); + }, + }, + { + id: 'action.cache.clear-opcache', + type: 'action', + group: 'cache', + icon: 'refresh', + labelKey: 'frosh-tools.clearOpCache', + descriptionKey: + 'frosh-tools.commandPalette.actions.clearOpcacheDescription', + keywords: ['opcache', 'php', 'clear'], + async run(ctx) { + await ctx.froshToolsService.clearOPcache(); + ctx.notifySuccess({ + message: ctx.t('frosh-tools.clearedOpcache'), + }); + }, + }, + { + id: 'action.cache.compile-theme', + type: 'action', + group: 'cache', + icon: 'paint', + labelKey: 'frosh-tools.compileTheme', + descriptionKey: + 'frosh-tools.commandPalette.actions.compileThemeDescription', + keywords: ['theme', 'compile', 'storefront', 'scss'], + available: (ctx) => + Boolean(ctx.themeService && ctx.repositoryFactory), + async run(ctx) { + const Criteria = Shopware.Data.Criteria; + const criteria = new Criteria(); + criteria.addAssociation('themes'); + + const salesChannelRepository = + ctx.repositoryFactory.create('sales_channel'); + const salesChannels = await salesChannelRepository.search( + criteria, + Shopware.Context.api + ); + + let compiled = 0; + + for (const salesChannel of salesChannels) { + const theme = salesChannel.extensions?.themes?.first?.(); + if (!theme) { + continue; + } + + await ctx.themeService.assignTheme( + theme.id, + salesChannel.id + ); + compiled += 1; + ctx.notifySuccess({ + message: `${salesChannel.translated?.name ?? salesChannel.name}: ${ctx.t('frosh-tools.themeCompiled')}`, + }); + } + + if (compiled === 0) { + ctx.notifyError({ + message: ctx.t( + 'frosh-tools.commandPalette.actions.compileThemeNone' + ), + }); + } + }, + }, + + // ── Queue actions ─────────────────────────────────────────── + { + id: 'action.queue.reset', + type: 'action', + group: 'queue', + icon: 'trash', + labelKey: 'frosh-tools.resetQueue', + descriptionKey: + 'frosh-tools.commandPalette.actions.resetQueueDescription', + keywords: ['queue', 'reset', 'clear', 'purge', 'messenger'], + confirm: true, + confirmLabelKey: + 'frosh-tools.commandPalette.actions.resetQueueConfirm', + async run(ctx) { + await ctx.froshToolsService.resetQueue(); + ctx.notifySuccess({ + message: ctx.t('frosh-tools.tabs.queue.reset.success'), + }); + }, + }, + + // ── Task actions ──────────────────────────────────────────── + { + id: 'action.tasks.register', + type: 'action', + group: 'tasks', + icon: 'refresh', + labelKey: 'frosh-tools.commandPalette.actions.registerTasks', + descriptionKey: + 'frosh-tools.commandPalette.actions.registerTasksDescription', + keywords: ['scheduled', 'tasks', 'register', 'cron'], + async run(ctx) { + await ctx.froshToolsService.scheduledTasksRegister(); + ctx.notifySuccess({ + message: ctx.t('frosh-tools.scheduledTasksRegisterSucceed'), + }); + }, + }, + ]; +} + +/** + * Clear the first cache pool whose name matches any candidate (case-insensitive). + * + * @param {CommandContext} ctx + * @param {string[]} candidates + */ +async function clearCacheIfPresent(ctx, candidates) { + const pools = await ctx.froshToolsService.getCacheInfo(); + const list = Array.isArray(pools) ? pools : []; + const lower = candidates.map((name) => name.toLowerCase()); + + const match = list.find((pool) => + lower.some( + (candidate) => + String(pool?.name ?? '') + .toLowerCase() + .includes(candidate) || + String(pool?.type ?? '') + .toLowerCase() + .includes(candidate) + ) + ); + + if (!match?.name) { + ctx.notifyError({ + message: ctx.t( + 'frosh-tools.commandPalette.actions.cachePoolNotFound', + { names: candidates.join(', ') } + ), + }); + return; + } + + await ctx.froshToolsService.clearCache(match.name); + ctx.notifySuccess({ + message: ctx.t('frosh-tools.cacheCleared', { name: match.name }), + }); +} + +/** + * Rank + filter commands by a free-text query. + * + * @param {Array} commands + * @param {string} query + * @returns {typeof commands} + */ +export function filterCommands(commands, query) { + const term = String(query ?? '') + .trim() + .toLowerCase(); + + if (!term) { + return commands; + } + + const tokens = term.split(/\s+/).filter(Boolean); + + return commands + .map((command) => { + const haystack = [ + command.label, + command.description, + command.groupLabel, + command.id, + ...(command.keywords ?? []), + ] + .join(' ') + .toLowerCase(); + + let score = 0; + + for (const token of tokens) { + if (!haystack.includes(token)) { + return null; + } + + if (command.label.toLowerCase().startsWith(token)) { + score += 8; + } else if (command.label.toLowerCase().includes(token)) { + score += 5; + } else if ( + (command.keywords ?? []).some((keyword) => + keyword.toLowerCase().includes(token) + ) + ) { + score += 3; + } else { + score += 1; + } + } + + // Prefer exact label hits + if (command.label.toLowerCase() === term) { + score += 20; + } + + return { command, score }; + }) + .filter(Boolean) + .sort( + (a, b) => + b.score - a.score || + a.command.label.localeCompare(b.command.label) + ) + .map(({ command }) => command); +} diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.spec.js new file mode 100644 index 00000000..5fd4370b --- /dev/null +++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/commands.spec.js @@ -0,0 +1,147 @@ +import { filterCommands, getCommandDefinitions } from './commands'; + +describe('ft-command-palette/commands', () => { + it('exposes navigation and action commands', () => { + const commands = getCommandDefinitions(); + + expect(commands.some((command) => command.id === 'nav.index')).toBe( + true + ); + expect( + commands.some((command) => command.id === 'action.cache.clear-all') + ).toBe(true); + expect( + commands.some((command) => command.id === 'action.queue.reset') + ).toBe(true); + }); + + it('hides conditional navigation when features are unavailable', () => { + const elasticsearch = getCommandDefinitions().find( + (command) => command.id === 'nav.elasticsearch' + ); + + expect(elasticsearch.available({ elasticsearchAvailable: false })).toBe( + false + ); + expect(elasticsearch.available({ elasticsearchAvailable: true })).toBe( + true + ); + }); + + it('filters commands by label and keywords', () => { + const commands = [ + { + id: 'nav.cache', + label: 'Cache', + description: 'Manage cache pools', + groupLabel: 'Go to', + keywords: ['opcache', 'theme'], + }, + { + id: 'nav.queue', + label: 'Queue', + description: 'Pending messages', + groupLabel: 'Go to', + keywords: ['messenger'], + }, + { + id: 'action.cache.clear-all', + label: 'Clear all cache pools', + description: 'Flush every pool', + groupLabel: 'Cache', + keywords: ['clear', 'flush'], + }, + ]; + + const cacheHits = filterCommands(commands, 'cache'); + expect(cacheHits.map((command) => command.id)).toEqual([ + 'nav.cache', + 'action.cache.clear-all', + ]); + + const messengerHits = filterCommands(commands, 'messenger'); + expect(messengerHits.map((command) => command.id)).toEqual([ + 'nav.queue', + ]); + + expect(filterCommands(commands, 'does-not-exist')).toEqual([]); + }); + + it('ranks exact and prefix label matches higher', () => { + const commands = [ + { + id: 'a', + label: 'Cache manager tools', + description: '', + groupLabel: '', + keywords: [], + }, + { + id: 'b', + label: 'Cache', + description: '', + groupLabel: '', + keywords: [], + }, + { + id: 'c', + label: 'Clear all cache pools', + description: '', + groupLabel: '', + keywords: ['cache'], + }, + ]; + + const ranked = filterCommands(commands, 'cache'); + expect(ranked[0].id).toBe('b'); + }); + + it('clears matching cache pools and reports missing ones', async () => { + const clearHttp = getCommandDefinitions().find( + (command) => command.id === 'action.cache.clear-http' + ); + + const froshToolsService = { + getCacheInfo: jest + .fn() + .mockResolvedValue([{ name: 'http_cache' }, { name: 'object' }]), + clearCache: jest.fn().mockResolvedValue({}), + }; + const notifySuccess = jest.fn(); + const notifyError = jest.fn(); + + await clearHttp.run({ + froshToolsService, + notifySuccess, + notifyError, + t: (key, params) => `${key}:${JSON.stringify(params || {})}`, + }); + + expect(froshToolsService.clearCache).toHaveBeenCalledWith('http_cache'); + expect(notifySuccess).toHaveBeenCalled(); + expect(notifyError).not.toHaveBeenCalled(); + }); + + it('resets the queue via the service', async () => { + const reset = getCommandDefinitions().find( + (command) => command.id === 'action.queue.reset' + ); + + const froshToolsService = { + resetQueue: jest.fn().mockResolvedValue({}), + }; + const notifySuccess = jest.fn(); + + await reset.run({ + froshToolsService, + notifySuccess, + notifyError: jest.fn(), + t: (key) => key, + }); + + expect(froshToolsService.resetQueue).toHaveBeenCalled(); + expect(notifySuccess).toHaveBeenCalledWith({ + message: 'frosh-tools.tabs.queue.reset.success', + }); + }); +}); diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.js b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.js new file mode 100644 index 00000000..68794ede --- /dev/null +++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.js @@ -0,0 +1,409 @@ +import './style.scss'; +import template from './template.html.twig'; +import { filterCommands, getCommandDefinitions } from './commands'; + +const { Component, Mixin } = Shopware; + +const RECENT_STORAGE_KEY = 'frosh-tools.command-palette.recent'; +const RECENT_LIMIT = 5; + +Component.register('ft-command-palette', { + template, + + inject: { + froshToolsService: { from: 'froshToolsService', default: null }, + themeService: { from: 'themeService', default: null }, + repositoryFactory: { from: 'repositoryFactory', default: null }, + }, + + mixins: [Mixin.getByName('notification')], + + props: { + elasticsearchAvailable: { type: Boolean, default: false }, + logsAvailable: { type: Boolean, default: false }, + fastlyAvailable: { type: Boolean, default: false }, + }, + + emits: ['close'], + + data() { + return { + query: '', + activeId: null, + isRunning: false, + pendingConfirm: null, + recentIds: this.readRecentIds(), + }; + }, + + computed: { + titleId() { + return `ft-command-palette-title-${this.$.uid}`; + }, + + listboxId() { + return `ft-command-palette-list-${this.$.uid}`; + }, + + commandContext() { + return { + routerPush: (location) => this.$router.push(location), + froshToolsService: this.froshToolsService, + themeService: this.themeService, + repositoryFactory: this.repositoryFactory, + notifySuccess: (payload) => this.createNotificationSuccess(payload), + notifyError: (payload) => this.createNotificationError(payload), + t: (key, params) => this.$t(key, params), + elasticsearchAvailable: this.elasticsearchAvailable, + logsAvailable: this.logsAvailable, + fastlyAvailable: this.fastlyAvailable, + }; + }, + + resolvedCommands() { + const ctx = this.commandContext; + + return getCommandDefinitions() + .filter((command) => + typeof command.available === 'function' + ? command.available(ctx) + : true + ) + .map((command) => ({ + ...command, + label: this.$t(command.labelKey), + description: command.descriptionKey + ? this.$t(command.descriptionKey) + : '', + groupLabel: this.$t( + `frosh-tools.commandPalette.groups.${command.group}` + ), + })); + }, + + visibleCommands() { + const filtered = filterCommands(this.resolvedCommands, this.query); + + if (this.query.trim()) { + return filtered; + } + + // Promote recent commands to the top when the query is empty. + const recent = []; + const rest = []; + + for (const id of this.recentIds) { + const match = filtered.find((command) => command.id === id); + if (match) { + recent.push(match); + } + } + + for (const command of filtered) { + if (!this.recentIds.includes(command.id)) { + rest.push(command); + } + } + + return [...recent, ...rest]; + }, + + groupedCommands() { + const groups = new Map(); + + for (const command of this.visibleCommands) { + const key = + !this.query.trim() && this.recentIds.includes(command.id) + ? 'recent' + : command.group; + + if (!groups.has(key)) { + groups.set(key, { + group: key, + groupLabel: + key === 'recent' + ? this.$t( + 'frosh-tools.commandPalette.groups.recent' + ) + : command.groupLabel, + items: [], + }); + } + + groups.get(key).items.push(command); + } + + return Array.from(groups.values()); + }, + + activeOptionId() { + return this.activeId ? this.optionId(this.activeId) : null; + }, + + pendingConfirmLabel() { + if (!this.pendingConfirm) { + return ''; + } + + if (this.pendingConfirm.confirmLabelKey) { + return this.$t(this.pendingConfirm.confirmLabelKey); + } + + return this.pendingConfirm.label; + }, + }, + + watch: { + visibleCommands: { + immediate: true, + handler(commands) { + if (commands.length === 0) { + this.activeId = null; + return; + } + + if (!commands.some((command) => command.id === this.activeId)) { + this.activeId = commands[0].id; + } + }, + }, + }, + + mounted() { + document.addEventListener('keydown', this.onDocumentKeydown, true); + this.previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + this.previousActiveElement = document.activeElement; + + this.$nextTick(() => { + this.focusInput(); + }); + }, + + unmounted() { + document.removeEventListener('keydown', this.onDocumentKeydown, true); + document.body.style.overflow = this.previousOverflow || ''; + this.restorePreviousFocus(); + }, + + methods: { + optionId(id) { + return `${this.listboxId}-option-${id}`; + }, + + focusInput() { + const input = this.$refs.input; + if (input && typeof input.focus === 'function') { + input.focus(); + if (typeof input.select === 'function') { + input.select(); + } + } + }, + + restorePreviousFocus() { + const element = this.previousActiveElement; + + if ( + element && + document.contains(element) && + typeof element.focus === 'function' + ) { + element.focus(); + } + + this.previousActiveElement = null; + }, + + onInput(event) { + this.query = event.target.value; + this.pendingConfirm = null; + }, + + onInputKeydown(event) { + if (this.pendingConfirm) { + if (event.key === 'Enter') { + event.preventDefault(); + this.confirmAndRun(); + } else if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + this.cancelConfirm(); + } + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.moveActive(1); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + this.moveActive(-1); + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + const active = this.visibleCommands.find( + (command) => command.id === this.activeId + ); + if (active) { + this.runCommand(active); + } + } + }, + + onDocumentKeydown(event) { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + + if (this.pendingConfirm) { + this.cancelConfirm(); + return; + } + + this.close(); + } + }, + + setActive(id) { + this.activeId = id; + }, + + moveActive(delta) { + const commands = this.visibleCommands; + if (commands.length === 0) { + return; + } + + const currentIndex = commands.findIndex( + (command) => command.id === this.activeId + ); + const nextIndex = + currentIndex < 0 + ? 0 + : (currentIndex + delta + commands.length) % + commands.length; + + this.activeId = commands[nextIndex].id; + this.scrollActiveIntoView(); + }, + + scrollActiveIntoView() { + this.$nextTick(() => { + const option = document.getElementById(this.activeOptionId); + if (option && typeof option.scrollIntoView === 'function') { + option.scrollIntoView({ block: 'nearest' }); + } + }); + }, + + async runCommand(command) { + if (this.isRunning || !command) { + return; + } + + if (command.confirm) { + this.pendingConfirm = command; + return; + } + + await this.executeCommand(command); + }, + + cancelConfirm() { + this.pendingConfirm = null; + this.$nextTick(() => this.focusInput()); + }, + + async confirmAndRun() { + if (!this.pendingConfirm) { + return; + } + + const command = this.pendingConfirm; + this.pendingConfirm = null; + await this.executeCommand(command); + }, + + async executeCommand(command) { + this.isRunning = true; + + try { + if (command.type === 'navigate' && command.route) { + try { + await this.$router.push({ name: command.route }); + } catch { + // Ignore duplicate-navigation rejections from vue-router. + } + this.rememberRecent(command.id); + // Drop the busy flag before close() — close refuses to emit + // while a command is still marked as running. + this.isRunning = false; + this.close(); + return; + } + + if (typeof command.run === 'function') { + await command.run(this.commandContext); + this.rememberRecent(command.id); + this.isRunning = false; + this.close(); + return; + } + } catch (error) { + this.createNotificationError({ + message: error?.response?.data?.error ?? error.message, + }); + } finally { + this.isRunning = false; + } + }, + + rememberRecent(id) { + const next = [ + id, + ...this.recentIds.filter((entry) => entry !== id), + ].slice(0, RECENT_LIMIT); + + this.recentIds = next; + + try { + window.localStorage.setItem( + RECENT_STORAGE_KEY, + JSON.stringify(next) + ); + } catch { + /* private mode / blocked storage — ignore */ + } + }, + + readRecentIds() { + try { + const raw = window.localStorage.getItem(RECENT_STORAGE_KEY); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter((entry) => typeof entry === 'string') + : []; + } catch { + return []; + } + }, + + close() { + if (this.isRunning) { + return; + } + + this.$emit('close'); + }, + }, +}); diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.spec.js new file mode 100644 index 00000000..dc944cad --- /dev/null +++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/index.spec.js @@ -0,0 +1,145 @@ +import { mountFrosh } from 'frosh-test/mount'; +import './index'; + +/** + * Mounts the palette with the real ft-* components and real translations. + * Only the system boundaries are faked: the API services (HTTP) and the + * router (navigation). + */ +async function createWrapper({ + props = {}, + service = {}, + routerPush = jest.fn().mockResolvedValue(), +} = {}) { + return mountFrosh('ft-command-palette', { + props: { + elasticsearchAvailable: true, + logsAvailable: true, + fastlyAvailable: true, + ...props, + }, + provide: { + froshToolsService: { + getCacheInfo: jest.fn().mockResolvedValue([]), + clearCache: jest.fn().mockResolvedValue({}), + clearOPcache: jest.fn().mockResolvedValue({}), + resetQueue: jest.fn().mockResolvedValue({}), + scheduledTasksRegister: jest.fn().mockResolvedValue({}), + ...service, + }, + themeService: { + assignTheme: jest.fn().mockResolvedValue({}), + }, + repositoryFactory: { + create: jest.fn(), + }, + }, + mocks: { + $router: { + push: routerPush, + }, + }, + attachTo: document.body, + }); +} + +describe('ft-command-palette', () => { + afterEach(() => { + document.body.innerHTML = ''; + window.localStorage.clear(); + }); + + it('renders the search field and navigation commands', async () => { + const wrapper = await createWrapper(); + await flushPromises(); + + expect(wrapper.find('.ft-command-palette__input').exists()).toBe(true); + expect(wrapper.text()).toContain('System-Status'); + expect(wrapper.text()).toContain('Cache'); + }); + + it('filters the list when the query changes', async () => { + const wrapper = await createWrapper(); + await flushPromises(); + + const input = wrapper.find('.ft-command-palette__input'); + await input.setValue('queue'); + await flushPromises(); + + expect(wrapper.text()).toContain('Queue'); + expect(wrapper.text()).not.toContain('Feature Flags'); + }); + + it('navigates when a navigation command is activated', async () => { + const routerPush = jest.fn().mockResolvedValue(); + const wrapper = await createWrapper({ routerPush }); + await flushPromises(); + + const indexCommand = wrapper.vm.visibleCommands.find( + (command) => command.id === 'nav.index' + ); + expect(indexCommand).toBeTruthy(); + + await wrapper.vm.runCommand(indexCommand); + await flushPromises(); + + expect(routerPush).toHaveBeenCalledWith({ + name: 'frosh.tools.index.index', + }); + expect(wrapper.emitted('close')).toBeTruthy(); + }); + + it('asks for confirmation before destructive actions', async () => { + const resetQueue = jest.fn().mockResolvedValue({}); + const wrapper = await createWrapper({ + service: { resetQueue }, + }); + await flushPromises(); + + const resetCommand = wrapper.vm.visibleCommands.find( + (command) => command.id === 'action.queue.reset' + ); + expect(resetCommand).toBeTruthy(); + + await wrapper.vm.runCommand(resetCommand); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.pendingConfirm).toBeTruthy(); + expect(resetQueue).not.toHaveBeenCalled(); + expect(wrapper.text()).toContain('Confirm action'); + + await wrapper.vm.confirmAndRun(); + await flushPromises(); + + expect(resetQueue).toHaveBeenCalled(); + expect(wrapper.emitted('close')).toBeTruthy(); + }); + + it('closes on Escape', async () => { + const wrapper = await createWrapper(); + await flushPromises(); + + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ); + await flushPromises(); + + expect(wrapper.emitted('close')).toHaveLength(1); + }); + + it('hides unavailable feature navigation', async () => { + const wrapper = await createWrapper({ + props: { + elasticsearchAvailable: false, + logsAvailable: false, + fastlyAvailable: false, + }, + }); + await flushPromises(); + + const ids = wrapper.vm.visibleCommands.map((command) => command.id); + expect(ids).not.toContain('nav.elasticsearch'); + expect(ids).not.toContain('nav.logs'); + expect(ids).not.toContain('nav.fastly'); + }); +}); diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/style.scss b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/style.scss new file mode 100644 index 00000000..b82872d7 --- /dev/null +++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/style.scss @@ -0,0 +1,252 @@ +.ft-command-palette { + position: fixed; + inset: 0; + z-index: 1100; + background: rgba(15, 23, 42, 0.55); + backdrop-filter: blur(2px); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 12vh 16px 24px; + overflow-y: auto; + animation: ft-command-palette-fade 140ms ease-out; + + &__dialog { + background: var(--ft-surface); + color: var(--ft-text); + border: 1px solid var(--ft-border); + border-radius: var(--ft-radius-lg); + box-shadow: var(--ft-shadow-lg); + width: min(640px, 100%); + display: flex; + flex-direction: column; + max-height: min(520px, calc(100vh - 18vh)); + overflow: hidden; + animation: ft-command-palette-pop 160ms cubic-bezier(0.16, 1, 0.3, 1); + + &:focus { + outline: none; + } + } + + &__head { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid var(--ft-divider); + flex-shrink: 0; + color: var(--ft-text-muted); + } + + &__input { + flex: 1; + min-width: 0; + border: 0; + background: transparent; + color: var(--ft-text); + font: inherit; + font-size: 15px; + line-height: 1.4; + outline: none; + + &::placeholder { + color: var(--ft-text-muted); + } + + // Hide native search clear in WebKit so layout stays stable. + &::-webkit-search-cancel-button { + display: none; + } + } + + &__body { + overflow-y: auto; + flex: 1; + min-height: 0; + padding: 8px 0 10px; + } + + &__empty { + padding: 28px 18px; + text-align: center; + color: var(--ft-text-muted); + font-size: 13px; + } + + &__group { + & + & { + margin-top: 4px; + } + } + + &__group-label { + padding: 8px 16px 4px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ft-text-muted); + } + + &__results { + padding: 0 8px; + } + + &__item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 9px 10px; + border-radius: var(--ft-radius-md); + cursor: pointer; + color: var(--ft-text); + transition: background var(--ft-transition); + + &.is-active { + background: var(--ft-accent-soft); + color: var(--ft-text); + + .ft-command-palette__item-icon { + color: var(--ft-accent); + background: var(--ft-surface); + } + } + } + + &__item-icon { + width: 28px; + height: 28px; + border-radius: var(--ft-radius-sm); + background: var(--ft-surface-2); + color: var(--ft-text-soft); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + &__item-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; + } + + &__item-label { + font-size: 13.5px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__item-description { + font-size: 11.5px; + color: var(--ft-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__item-badge { + flex-shrink: 0; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ft-accent); + background: var(--ft-surface); + border: 1px solid var(--ft-border); + border-radius: 999px; + padding: 2px 7px; + } + + &__confirm { + padding: 20px 18px 16px; + display: flex; + flex-direction: column; + gap: 10px; + } + + &__confirm-title { + font-size: 14px; + font-weight: 600; + } + + &__confirm-text { + margin: 0; + font-size: 13px; + color: var(--ft-text-soft); + line-height: 1.45; + } + + &__confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 6px; + } + + &__foot { + display: flex; + flex-wrap: wrap; + gap: 12px 18px; + padding: 8px 14px; + border-top: 1px solid var(--ft-divider); + color: var(--ft-text-muted); + font-size: 11.5px; + flex-shrink: 0; + } + + &__kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 5px; + border-radius: 4px; + border: 1px solid var(--ft-border); + background: var(--ft-surface-2); + color: var(--ft-text-soft); + font-family: var(--ft-mono); + font-size: 10.5px; + line-height: 1; + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04); + + &--muted { + opacity: 0.7; + } + } +} + +@keyframes ft-command-palette-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes ft-command-palette-pop { + from { + opacity: 0; + transform: translateY(-6px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +// Dark theme inherits .ft variables from the design system. +[data-theme='dark'] .ft-command-palette, +.dark .ft-command-palette, +html.dark .ft-command-palette, +body.dark .ft-command-palette { + background: rgba(2, 6, 23, 0.72); +} diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/template.html.twig b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/template.html.twig new file mode 100644 index 00000000..07eb2386 --- /dev/null +++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-command-palette/template.html.twig @@ -0,0 +1,159 @@ + +
+ +
+
diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-modal/index.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/ft-modal/index.spec.js index aa2f6eeb..d46bf444 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/component/ft-modal/index.spec.js +++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-modal/index.spec.js @@ -1,24 +1,12 @@ -import { mount } from '@vue/test-utils'; -import './index'; +import { mountFrosh } from 'frosh-test/mount'; async function createWrapper({ props = {}, slots = {} } = {}) { - const component = await Shopware.Component.build('ft-modal'); - - return mount(component, { + return mountFrosh('ft-modal', { props, slots: { default: '

Body content

', ...slots, }, - global: { - stubs: { - 'ft-icon': true, - teleport: true, - }, - mocks: { - $t: (key) => key, - }, - }, attachTo: document.body, }); } @@ -40,6 +28,11 @@ describe('ft-modal', () => { const titleId = dialog.attributes('aria-labelledby'); expect(titleId).toBeTruthy(); expect(wrapper.find(`#${titleId}`).text()).toBe('Confirm action'); + + // The close button is labelled with the real core snippet. + expect( + wrapper.find('.ft-modal__close').attributes('aria-label') + ).toBe('Close'); }); it('emits close when Escape is pressed', async () => { diff --git a/src/Resources/app/administration/src/module/frosh-tools/component/ft-th-sort/index.spec.js b/src/Resources/app/administration/src/module/frosh-tools/component/ft-th-sort/index.spec.js index 28caf7a5..d612fee2 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/component/ft-th-sort/index.spec.js +++ b/src/Resources/app/administration/src/module/frosh-tools/component/ft-th-sort/index.spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { froshComponents, froshI18nMocks } from 'frosh-test/mount'; import '../../../../mixin/sortable-table'; import './index'; @@ -8,7 +9,10 @@ import './index'; * real combination instead of mocking the host. */ async function createWrapper({ sortKey = 'name', table = 'default' } = {}) { - const thSort = await Shopware.Component.build('ft-th-sort'); + const [thSort, realComponents] = await Promise.all([ + Shopware.Component.build('ft-th-sort'), + froshComponents('ft-icon'), + ]); const host = { template: ` @@ -39,7 +43,8 @@ async function createWrapper({ sortKey = 'name', table = 'default' } = {}) { return mount(host, { global: { - stubs: { 'ft-icon': true }, + components: realComponents, + mocks: froshI18nMocks(), }, }); } @@ -87,12 +92,16 @@ describe('ft-th-sort', () => { }); it('works without a sort host', async () => { - const thSort = await Shopware.Component.build('ft-th-sort'); + const [thSort, realComponents] = await Promise.all([ + Shopware.Component.build('ft-th-sort'), + froshComponents('ft-icon'), + ]); const wrapper = mount(thSort, { props: { sortKey: 'name' }, slots: { default: 'Name' }, global: { - stubs: { 'ft-icon': true }, + components: realComponents, + mocks: froshI18nMocks(), }, }); diff --git a/src/Resources/app/administration/src/module/frosh-tools/index.js b/src/Resources/app/administration/src/module/frosh-tools/index.js index 5d99653d..6d9a0ece 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/index.js +++ b/src/Resources/app/administration/src/module/frosh-tools/index.js @@ -1,6 +1,7 @@ import './component/ft-icon'; import './component/ft-button'; import './component/ft-modal'; +import './component/ft-command-palette'; import './component/ft-page-head'; import './component/ft-panel'; import './component/ft-pill'; diff --git a/src/Resources/app/administration/src/module/frosh-tools/page/index/index.js b/src/Resources/app/administration/src/module/frosh-tools/page/index/index.js index 731b7dec..806aa088 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/page/index/index.js +++ b/src/Resources/app/administration/src/module/frosh-tools/page/index/index.js @@ -60,6 +60,7 @@ Component.register('frosh-tools-index', { data() { return { searchTerm: '', + commandPaletteOpen: false, }; }, @@ -79,9 +80,11 @@ Component.register('frosh-tools-index', { created() { this.adminMenuStore('collapseSidebar'); + document.addEventListener('keydown', this.onGlobalKeydown, true); }, unmounted() { + document.removeEventListener('keydown', this.onGlobalKeydown, true); this.adminMenuStore('expandSidebar'); }, @@ -176,6 +179,19 @@ Component.register('frosh-tools-index', { { labelKey: 'frosh-tools.nav.cdn', items: cdn }, ]; }, + + commandPaletteShortcut() { + const platform = + typeof navigator !== 'undefined' + ? navigator.platform || navigator.userAgent || '' + : ''; + + if (/Mac|iPhone|iPad|iPod/i.test(platform)) { + return '⌘K'; + } + + return 'Ctrl K'; + }, }, methods: { @@ -183,6 +199,29 @@ Component.register('frosh-tools-index', { this.searchTerm = term; }, + openCommandPalette() { + this.commandPaletteOpen = true; + }, + + closeCommandPalette() { + this.commandPaletteOpen = false; + }, + + onGlobalKeydown(event) { + // Ignore plain key presses inside editable fields except when the + // palette itself is open (it handles Escape/arrows on its own). + const isModifier = event.metaKey || event.ctrlKey; + const key = String(event.key || '').toLowerCase(); + + if (isModifier && key === 'k') { + // Only capture while FroshTools is the active page so we do not + // fight the rest of the admin when the user navigates away. + event.preventDefault(); + event.stopPropagation(); + this.commandPaletteOpen = !this.commandPaletteOpen; + } + }, + adminMenuStore(action) { // Pinia (Shopware 6.7+) try { diff --git a/src/Resources/app/administration/src/module/frosh-tools/page/index/template.twig b/src/Resources/app/administration/src/module/frosh-tools/page/index/template.twig index f99424af..2f0d1b7d 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/page/index/template.twig +++ b/src/Resources/app/administration/src/module/frosh-tools/page/index/template.twig @@ -24,6 +24,22 @@ + + + \ No newline at end of file diff --git a/src/Resources/app/administration/src/module/frosh-tools/snippet/de-DE.json b/src/Resources/app/administration/src/module/frosh-tools/snippet/de-DE.json index edaa981c..b3429bc5 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/snippet/de-DE.json +++ b/src/Resources/app/administration/src/module/frosh-tools/snippet/de-DE.json @@ -478,6 +478,47 @@ "sidebar": { "developerConsole": "Entwicklerkonsole" }, + "commandPalette": { + "trigger": "Befehle suchen\u2026", + "placeholder": "Befehl oder Seite eingeben\u2026", + "noResults": "Keine Befehle f\u00fcr \"{term}\"", + "actionBadge": "Ausf\u00fchren", + "confirmTitle": "Aktion best\u00e4tigen", + "confirm": "Best\u00e4tigen", + "cancel": "Abbrechen", + "hintNavigate": "navigieren", + "hintRun": "ausf\u00fchren", + "hintClose": "schlie\u00dfen", + "groups": { + "recent": "Zuletzt", + "navigate": "Gehe zu", + "cache": "Cache", + "queue": "Warteschlange", + "tasks": "Geplante Aufgaben" + }, + "descriptions": { + "shopmon": "Diesen Shop mit dem Shopmon-Monitoring verbinden.", + "fastly": "Fastly-CDN-Cache f\u00fcr diesen Shop leeren." + }, + "actions": { + "clearAllCaches": "Alle Cache-Pools leeren", + "clearAllCachesDescription": "Jeden gemeldeten Cache-Pool der Reihe nach leeren.", + "clearAllCachesConfirm": "Dadurch werden alle vom Cache-Manager gemeldeten Pools geleert. Fortfahren?", + "clearAllCachesSuccess": "{count} Cache-Pool(s) geleert", + "clearHttpCache": "HTTP-Cache leeren", + "clearHttpCacheDescription": "HTTP-/Reverse-Proxy-Cache leeren, falls vorhanden.", + "clearObjectCache": "Objekt-/App-Cache leeren", + "clearObjectCacheDescription": "Anwendungs-Objektcache leeren, falls vorhanden.", + "clearOpcacheDescription": "PHP-OPcache f\u00fcr diesen Runtime zur\u00fccksetzen.", + "compileThemeDescription": "Storefront-Themes f\u00fcr alle Verkaufskan\u00e4le neu kompilieren.", + "compileThemeNone": "Kein Verkaufskanal mit zugewiesenem Theme gefunden.", + "resetQueueDescription": "Alle ausstehenden Messenger-Nachrichten aus allen Transporten entfernen.", + "resetQueueConfirm": "Dadurch werden alle ausstehenden Warteschlangennachrichten endg\u00fcltig gel\u00f6scht. Fortfahren?", + "registerTasks": "Geplante Aufgaben registrieren", + "registerTasksDescription": "Geplante Aufgaben aus Core und aktiven Plugins neu registrieren.", + "cachePoolNotFound": "Kein Cache-Pool gefunden f\u00fcr: {names}" + } + }, "nav": { "overview": "Übersicht", "performance": "Performance", diff --git a/src/Resources/app/administration/src/module/frosh-tools/snippet/en-GB.json b/src/Resources/app/administration/src/module/frosh-tools/snippet/en-GB.json index bc2ca433..ae5ab014 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/snippet/en-GB.json +++ b/src/Resources/app/administration/src/module/frosh-tools/snippet/en-GB.json @@ -478,6 +478,47 @@ "sidebar": { "developerConsole": "Developer Console" }, + "commandPalette": { + "trigger": "Search commands\u2026", + "placeholder": "Type a command or page\u2026", + "noResults": "No commands match \"{term}\"", + "actionBadge": "Run", + "confirmTitle": "Confirm action", + "confirm": "Confirm", + "cancel": "Cancel", + "hintNavigate": "to navigate", + "hintRun": "to run", + "hintClose": "to close", + "groups": { + "recent": "Recent", + "navigate": "Go to", + "cache": "Cache", + "queue": "Queue", + "tasks": "Scheduled tasks" + }, + "descriptions": { + "shopmon": "Connect this shop to the Shopmon monitoring dashboard.", + "fastly": "Purge Fastly CDN cache for this shop." + }, + "actions": { + "clearAllCaches": "Clear all cache pools", + "clearAllCachesDescription": "Iterate every reported cache pool and clear it.", + "clearAllCachesConfirm": "This clears every cache pool reported by the cache manager. Continue?", + "clearAllCachesSuccess": "Cleared {count} cache pool(s)", + "clearHttpCache": "Clear HTTP cache", + "clearHttpCacheDescription": "Clear the HTTP / reverse-proxy cache pool if present.", + "clearObjectCache": "Clear object / app cache", + "clearObjectCacheDescription": "Clear the application object cache pool if present.", + "clearOpcacheDescription": "Reset PHP OPcache for this runtime.", + "compileThemeDescription": "Recompile storefront themes for all sales channels.", + "compileThemeNone": "No sales channel with an assigned theme was found.", + "resetQueueDescription": "Remove all pending messenger messages from every transport.", + "resetQueueConfirm": "This permanently deletes every pending queue message. Continue?", + "registerTasks": "Register scheduled tasks", + "registerTasksDescription": "Re-register scheduled tasks from all active plugins and core.", + "cachePoolNotFound": "No cache pool matching: {names}" + } + }, "nav": { "overview": "Overview", "performance": "Performance", diff --git a/src/Resources/app/administration/src/module/frosh-tools/styles/design-system.scss b/src/Resources/app/administration/src/module/frosh-tools/styles/design-system.scss index 345906d1..25cfbdcc 100644 --- a/src/Resources/app/administration/src/module/frosh-tools/styles/design-system.scss +++ b/src/Resources/app/administration/src/module/frosh-tools/styles/design-system.scss @@ -179,6 +179,67 @@ body.dark .ft { } } + &__command { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + margin: 0 0 14px; + padding: 8px 10px; + border: 1px solid var(--ft-border); + border-radius: var(--ft-radius-sm); + background: var(--ft-surface-2); + color: var(--ft-text-muted); + font: inherit; + font-size: 12.5px; + cursor: pointer; + transition: + background var(--ft-transition), + border-color var(--ft-transition), + color var(--ft-transition); + + &:hover { + background: var(--ft-surface-hover); + border-color: var(--ft-border-strong); + color: var(--ft-text-soft); + } + + &:focus-visible { + outline: 2px solid var(--ft-accent); + outline-offset: 1px; + } + + @media (max-width: 540px) { + display: none; + } + } + + &__command-label { + flex: 1; + min-width: 0; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__command-kbd { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 20px; + padding: 0 5px; + border-radius: 4px; + border: 1px solid var(--ft-border); + background: var(--ft-surface); + color: var(--ft-text-soft); + font-family: var(--ft-mono); + font-size: 10.5px; + line-height: 1; + } + &__group { margin-bottom: 14px; diff --git a/src/Resources/app/administration/test/mount.js b/src/Resources/app/administration/test/mount.js new file mode 100644 index 00000000..3b99785e --- /dev/null +++ b/src/Resources/app/administration/test/mount.js @@ -0,0 +1,182 @@ +/** + * Integration mount helper for the plugin's administration components. + * + * Mounts components with the real plugin component tree (all ft-* + * design-system components) and real translations resolved from the + * plugin's own snippet files through vue-i18n. Only system boundaries + * (HTTP services, repositories, router) are left to the specs to fake. + */ +import { mount } from '@vue/test-utils'; + +import '../src/mixin/sortable-table'; +import '../src/module/frosh-tools/component/ft-icon'; +import '../src/module/frosh-tools/component/ft-button'; +import '../src/module/frosh-tools/component/ft-modal'; +import '../src/module/frosh-tools/component/ft-page-head'; +import '../src/module/frosh-tools/component/ft-panel'; +import '../src/module/frosh-tools/component/ft-pill'; +import '../src/module/frosh-tools/component/ft-empty'; +import '../src/module/frosh-tools/component/ft-hero-state'; +import '../src/module/frosh-tools/component/ft-refresh-button'; +import '../src/module/frosh-tools/component/ft-th-sort'; + +const { join } = require('path'); +const { existsSync } = require('fs'); + +const { createI18n } = require(join( + process.env.ADMIN_PATH, + 'node_modules', + 'vue-i18n' +)); + +const pluginSnippetsEnGB = require('../src/module/frosh-tools/snippet/en-GB.json'); +const pluginSnippetsDeDE = require('../src/module/frosh-tools/snippet/de-DE.json'); + +/** + * Core app snippets (global.default.* etc.). The file was renamed in + * Shopware 6.7, so try both names and simply skip when unavailable — + * missing keys then render as the key itself, like in the real app. + */ +function loadCoreSnippets() { + const snippetDir = join(process.env.ADMIN_PATH, 'src', 'app', 'snippet'); + + for (const fileName of ['en.json', 'en-GB.json']) { + const filePath = join(snippetDir, fileName); + if (existsSync(filePath)) { + // eslint-disable-next-line import/no-dynamic-require + return require(filePath); + } + } + + return {}; +} + +/** + * Deep merge: the plugin also overrides single core keys (e.g. + * global.entities), a shallow spread would drop the whole core subtree. + */ +function deepMerge(target, source) { + const result = { ...target }; + + Object.entries(source).forEach(([key, value]) => { + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + typeof result[key] === 'object' && + result[key] !== null + ) { + result[key] = deepMerge(result[key], value); + } else { + result[key] = value; + } + }); + + return result; +} + +const i18n = createI18n({ + legacy: false, + locale: 'en-GB', + fallbackLocale: 'en-GB', + missingWarn: false, + fallbackWarn: false, + messages: { + 'en-GB': deepMerge(loadCoreSnippets(), pluginSnippetsEnGB), + 'de-DE': pluginSnippetsDeDE, + }, +}); + +/** + * $t/$tc backed by the real snippet catalog and the real vue-i18n + * interpolation/pluralization engine. Wired as mocks because Shopware + * itself also bridges $t onto the component instance instead of using + * vue-i18n's global injection. + */ +export function froshI18nMocks() { + return { + $t: (key, ...args) => i18n.global.t(key, ...args), + $tc: (key, ...args) => i18n.global.t(key, ...args), + }; +} + +const FROSH_COMPONENTS = [ + 'ft-icon', + 'ft-button', + 'ft-modal', + 'ft-page-head', + 'ft-panel', + 'ft-pill', + 'ft-empty', + 'ft-hero-state', + 'ft-refresh-button', + 'ft-th-sort', +]; + +/** Builds real component definitions keyed by their tag name. */ +export async function froshComponents(...names) { + const components = {}; + const built = await Promise.all( + names.map((name) => Shopware.Component.build(name)) + ); + + names.forEach((name, index) => { + components[name] = built[index]; + }); + + return components; +} + +/** + * Mounts a registered plugin component together with the real ft-* + * component tree and real translations. Pass `provide`/`mocks` only for + * system boundaries (services, repositories, router) and `stubs` for + * Shopware core components that are not under test. + */ +export async function mountFrosh( + name, + { + props, + slots, + provide = {}, + mocks = {}, + stubs = {}, + components = {}, + directives = {}, + attachTo, + data, + } = {} +) { + const [component, realChildren] = await Promise.all([ + Shopware.Component.build(name), + froshComponents( + ...FROSH_COMPONENTS.filter((childName) => childName !== name) + ), + ]); + + return mount(component, { + props, + slots, + attachTo, + data, + global: { + provide, + mocks: { + ...froshI18nMocks(), + ...mocks, + }, + directives: { + tooltip: {}, + ...directives, + }, + stubs: { + teleport: true, + ...stubs, + }, + components: { + ...realChildren, + ...components, + }, + }, + }); +} diff --git a/src/Resources/app/administration/test/resolve-admin-path.js b/src/Resources/app/administration/test/resolve-admin-path.js new file mode 100644 index 00000000..d573b804 --- /dev/null +++ b/src/Resources/app/administration/test/resolve-admin-path.js @@ -0,0 +1,49 @@ +/** + * Locates the Shopware administration app of the surrounding installation. + * + * The plugin has no test tooling of its own — the runner, Vue and the test + * harness all come from the Shopware installation the plugin is installed in. + * Works with the development template (src/Administration/…) and the + * production template (vendor/shopware/administration/…). + */ +const { existsSync } = require('fs'); +const { dirname, join } = require('path'); + +const RELATIVE_ADMIN_PATHS = [ + 'src/Administration/Resources/app/administration', + 'vendor/shopware/administration/Resources/app/administration', +]; + +function isAdminPath(candidate) { + return existsSync(join(candidate, 'jest.config.js')); +} + +function resolveAdminPath() { + if (process.env.ADMIN_PATH && isAdminPath(process.env.ADMIN_PATH)) { + return process.env.ADMIN_PATH; + } + + // Walk up from this file until a Shopware installation root is found. + let dir = __dirname; + for (;;) { + const match = RELATIVE_ADMIN_PATHS.map((relative) => + join(dir, relative) + ).find(isAdminPath); + + if (match) { + return match; + } + + const parent = dirname(dir); + if (parent === dir) { + throw new Error( + 'Could not locate a Shopware administration. Install the plugin into a Shopware ' + + 'installation (custom/plugins) or set the ADMIN_PATH environment variable to the ' + + 'administration app (the directory containing its jest.config.js).' + ); + } + dir = parent; + } +} + +module.exports = resolveAdminPath; diff --git a/src/Resources/app/administration/test/run.js b/src/Resources/app/administration/test/run.js new file mode 100644 index 00000000..42a5b6b8 --- /dev/null +++ b/src/Resources/app/administration/test/run.js @@ -0,0 +1,57 @@ +/** + * Runs the plugin's administration unit tests with the jest installation of + * the surrounding Shopware installation. All arguments are passed through to + * jest, e.g. `npm run unit -- --watch` or `npm run unit -- --coverage`. + */ +const { existsSync } = require('fs'); +const { join } = require('path'); +const { spawnSync } = require('child_process'); +const resolveAdminPath = require('./resolve-admin-path'); + +const adminPath = resolveAdminPath(); + +// Shopware's jest config refuses to run without the generated component +// import map, so make sure it exists before handing over to jest. Older +// Shopware versions neither generate nor require it. +const componentImports = join( + adminPath, + 'test', + '_helper_', + 'componentWrapper', + 'component-imports.js' +); +const adminPackage = require(join(adminPath, 'package.json')); +const hasGeneratorScript = Boolean( + adminPackage.scripts && + adminPackage.scripts['generate-component-import-resolver-map'] +); + +if (!existsSync(componentImports) && hasGeneratorScript) { + const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const setup = spawnSync( + npm, + ['run', 'generate-component-import-resolver-map'], + { cwd: adminPath, stdio: 'inherit' } + ); + + if (setup.status !== 0) { + process.exit(setup.status ?? 1); + } +} + +const result = spawnSync( + process.execPath, + [ + join(adminPath, 'node_modules', 'jest', 'bin', 'jest.js'), + '--config', + join(__dirname, '..', 'jest.config.js'), + ...process.argv.slice(2), + ], + { + cwd: adminPath, + stdio: 'inherit', + env: { ...process.env, ADMIN_PATH: adminPath }, + } +); + +process.exit(result.status ?? 1);