diff --git a/app/assets/javascripts/lib/utils/breadcrumbs.js b/app/assets/javascripts/lib/utils/breadcrumbs.js
index fd01582280e6e1af82ac17b6057dd584b701b526..62dfe3435fa74fa72e8881e089986936067cade1 100644
--- a/app/assets/javascripts/lib/utils/breadcrumbs.js
+++ b/app/assets/javascripts/lib/utils/breadcrumbs.js
@@ -7,6 +7,7 @@ export const injectVueAppBreadcrumbs = (
BreadcrumbsComponent,
apolloProvider = null,
provide = {},
+ keepRootItem = false,
// eslint-disable-next-line max-params
) => {
const injectBreadcrumbEl = document.querySelector('#js-injected-page-breadcrumbs');
@@ -31,7 +32,7 @@ export const injectVueAppBreadcrumbs = (
props: {
// The last item from the static breadcrumb set is replaced by the
// root of the vue app, so the last item should be removed
- staticBreadcrumbs: items.slice(0, -1),
+ staticBreadcrumbs: keepRootItem ? items : items.slice(0, -1),
},
});
},
diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs.vue b/app/assets/javascripts/vue_shared/spa/components/spa_breadcrumbs.vue
similarity index 72%
rename from ee/app/assets/javascripts/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs.vue
rename to app/assets/javascripts/vue_shared/spa/components/spa_breadcrumbs.vue
index af7611e4d5dc28c9ca5e9f925f1854732ed52685..3fa62ff9177756cb46371deabd6f4b25dc4e81b7 100644
--- a/ee/app/assets/javascripts/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs.vue
+++ b/app/assets/javascripts/vue_shared/spa/components/spa_breadcrumbs.vue
@@ -1,7 +1,5 @@
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/spa/index.js b/app/assets/javascripts/vue_shared/spa/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..88ac79dbfa6632275748bdebb006d2ce19bad55f
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/spa/index.js
@@ -0,0 +1,52 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs';
+import { activeNavigationWatcher } from './utils';
+import breadcrumbs from './components/spa_breadcrumbs.vue';
+import RootComponent from './components/spa_root.vue';
+
+export const initSinglePageApplication = ({
+ name = 'SinglePageAplication',
+ el,
+ router,
+ apolloCacheConfig = {},
+ provide,
+ propsData,
+}) => {
+ if (!el) {
+ throw new Error('You must provide a `el` prop to initVueSinglePageApplication');
+ }
+
+ if (!router) {
+ throw new Error('You must provide a `router` prop to initVueSinglePageApplication');
+ }
+
+ let apolloProvider;
+
+ // To not have an apollo cache, explicitly pass null
+ if (apolloCacheConfig) {
+ Vue.use(VueApollo);
+
+ apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(apolloCacheConfig),
+ });
+ }
+
+ // TODO: build dybnamically based on the router
+ injectVueAppBreadcrumbs(router, breadcrumbs, null, {}, true);
+
+ router.beforeEach(activeNavigationWatcher);
+
+ return new Vue({
+ el,
+ name,
+ router,
+ apolloProvider,
+ provide,
+ propsData,
+ render(h) {
+ return h(RootComponent);
+ },
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/spa/utils.js b/app/assets/javascripts/vue_shared/spa/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..6fb9dc5545c1fed9d6c344519c3f846a144da8ee
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/spa/utils.js
@@ -0,0 +1,34 @@
+export const extractNavScopeFromRoute = (route) => {
+ // The first matched object is always the top level nav element
+ const segments = route?.matched?.[0]?.path?.split('/') || [];
+ return segments.length > 1 ? segments[1] : '';
+};
+
+export const updateActiveNavigation = (currentScope) => {
+ // Find all navigation items and remove active class
+ const navItems = document.querySelectorAll('.nav-sidebar .nav-item');
+ navItems.forEach((item) => {
+ item.classList.remove('active');
+ });
+
+ // Add active class to the current scope navigation item
+ if (currentScope) {
+ const activeNavItem = document.querySelector(
+ `.nav-sidebar .nav-item[data-nav-scope="${currentScope}"]`,
+ );
+ if (activeNavItem) {
+ activeNavItem.classList.add('active');
+ }
+ }
+};
+
+export const activeNavigationWatcher = (to, from, next) => {
+ const currentScope = extractNavScopeFromRoute(to);
+ const oldScope = extractNavScopeFromRoute(from);
+
+ if (!from?.matched.length === 0 || currentScope !== oldScope) {
+ updateActiveNavigation(currentScope);
+ }
+
+ next();
+};
diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/router/dom_utils.js b/app/assets/javascripts/vue_shared/spa/utils/dom_utils.js
similarity index 100%
rename from ee/app/assets/javascripts/ai/duo_agents_platform/router/dom_utils.js
rename to app/assets/javascripts/vue_shared/spa/utils/dom_utils.js
diff --git a/app/assets/javascripts/vue_shared/spa/utils/index.js b/app/assets/javascripts/vue_shared/spa/utils/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..3edaf6f4398c83ad47c4fa3ed47e4f5abb2b855b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/spa/utils/index.js
@@ -0,0 +1,18 @@
+import { updateActiveNavigation } from './dom_utils';
+
+export const extractNavScopeFromRoute = (route) => {
+ // The first matched object is always the top level nav element
+ const segments = route?.matched?.[0]?.path?.split('/') || [];
+ return segments.length > 1 ? segments[1] : '';
+};
+
+export const activeNavigationWatcher = (to, from, next) => {
+ const currentScope = extractNavScopeFromRoute(to);
+ const oldScope = extractNavScopeFromRoute(from);
+
+ if (!from?.matched.length === 0 || currentScope !== oldScope) {
+ updateActiveNavigation(currentScope);
+ }
+
+ next();
+};
diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/index.js b/ee/app/assets/javascripts/ai/duo_agents_platform/index.js
index f5cc9558dbf915a3ca5e71dd8da85a67867f2b71..27c1a0ba86b41f0c0ef02244d085ba9f16306d6f 100644
--- a/ee/app/assets/javascripts/ai/duo_agents_platform/index.js
+++ b/ee/app/assets/javascripts/ai/duo_agents_platform/index.js
@@ -1,12 +1,7 @@
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
-import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs';
-import DuoAgentsPlatformBreadcrumbs from './router/duo_agents_platform_breadcrumbs.vue';
-import { activeNavigationWatcher } from './router/utils';
+import { initSinglePageApplication } from '~/vue_shared/spa';
import { createRouter } from './router';
-import DuoAgentsPlatformApp from './duo_agents_platform_app.vue';
import { getNamespaceDatasetProperties } from './utils';
+import { AGENTS_PLATFORM_INDEX_ROUTE } from './router/constants';
export const initDuoAgentsPlatformPage = ({ namespaceDatasetProperties = [], namespace }) => {
if (!namespace) {
@@ -39,29 +34,16 @@ export const initDuoAgentsPlatformPage = ({ namespaceDatasetProperties = [], nam
}
const router = createRouter(agentsPlatformBaseRoute, namespace);
- router.beforeEach(activeNavigationWatcher);
- Vue.use(VueApollo);
-
- const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
- });
-
- injectVueAppBreadcrumbs(router, DuoAgentsPlatformBreadcrumbs);
-
- return new Vue({
- el,
- name: 'DuoAgentsPlatformApp',
+ return initSinglePageApplication({
router,
- apolloProvider,
+ el,
+ keepAliveComponents: [AGENTS_PLATFORM_INDEX_ROUTE],
provide: {
emptyStateIllustrationPath,
exploreAiCatalogPath,
flowTriggersEventTypeOptions: JSON.parse(flowTriggersEventTypeOptions),
...namespaceProvideData,
},
- render(h) {
- return h(DuoAgentsPlatformApp);
- },
});
};
diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/router/utils.js b/ee/app/assets/javascripts/ai/duo_agents_platform/router/utils.js
index 6a3bb774240cb95f74fec367d94440ac16e90736..dc2909c1768dd76e568061e9a9c173bd131f11d2 100644
--- a/ee/app/assets/javascripts/ai/duo_agents_platform/router/utils.js
+++ b/ee/app/assets/javascripts/ai/duo_agents_platform/router/utils.js
@@ -1,24 +1,6 @@
import { AGENT_PLATFORM_PROJECT_PAGE, AGENT_PLATFORM_USER_PAGE } from '../constants';
import ProjectAgentsPlatformIndex from '../namespace/project/project_agents_platform_index.vue';
import userAgentsPlatformIndex from '../namespace/user/user_agents_platform_index.vue';
-import { updateActiveNavigation } from './dom_utils';
-
-export const extractNavScopeFromRoute = (route) => {
- // The first matched object is always the top level nav element
- const segments = route?.matched?.[0]?.path?.split('/') || [];
- return segments.length > 1 ? segments[1] : '';
-};
-
-export const activeNavigationWatcher = (to, from, next) => {
- const currentScope = extractNavScopeFromRoute(to);
- const oldScope = extractNavScopeFromRoute(from);
-
- if (!from?.matched.length === 0 || currentScope !== oldScope) {
- updateActiveNavigation(currentScope);
- }
-
- next();
-};
export const getNamespaceIndexComponent = (namespace) => {
if (!namespace) {
diff --git a/ee/spec/frontend/ai/duo_agents_platform/index_spec.js b/ee/spec/frontend/ai/duo_agents_platform/index_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..c2d79f8e5d3511e73dddf4e2f981d3326d7bfc89
--- /dev/null
+++ b/ee/spec/frontend/ai/duo_agents_platform/index_spec.js
@@ -0,0 +1,171 @@
+import { initSinglePageApplication } from '~/vue_shared/spa';
+import { initDuoAgentsPlatformPage } from 'ee/ai/duo_agents_platform';
+import { createRouter } from 'ee/ai/duo_agents_platform/router';
+import { getNamespaceDatasetProperties } from 'ee/ai/duo_agents_platform/utils';
+import { AGENTS_PLATFORM_INDEX_ROUTE } from 'ee/ai/duo_agents_platform/router/constants';
+import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures';
+
+// Mock GraphQL to prevent property redefinition error
+jest.mock('~/lib/graphql', () => ({
+ fetchPolicies: {
+ CACHE_AND_NETWORK: 'cache-and-network',
+ NETWORK_ONLY: 'network-only',
+ CACHE_ONLY: 'cache-only',
+ NO_CACHE: 'no-cache',
+ STANDBY: 'standby',
+ },
+}));
+jest.mock('~/vue_shared/spa');
+jest.mock('ee/ai/duo_agents_platform/router');
+jest.mock('ee/ai/duo_agents_platform/utils');
+
+describe('initDuoAgentsPlatformPage', () => {
+ let mockRouter;
+ let mockEl;
+
+ beforeEach(() => {
+ setHTMLFixture('
');
+ mockEl = document.getElementById('js-duo-agents-platform-page');
+ mockRouter = { beforeEach: jest.fn() };
+
+ createRouter.mockReturnValue(mockRouter);
+ getNamespaceDatasetProperties.mockReturnValue({
+ emptyStateIllustrationPath: '/empty-state.svg',
+ exploreAiCatalogPath: '/ai-catalog',
+ flowTriggersEventTypeOptions: '[]',
+ });
+ initSinglePageApplication.mockReturnValue({});
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ jest.clearAllMocks();
+ });
+
+ describe('when namespace is not provided', () => {
+ it('throws an error', () => {
+ expect(() => {
+ initDuoAgentsPlatformPage({
+ namespaceDatasetProperties: [],
+ });
+ }).toThrow('Namespace is required for the DuoAgentPlatform page to function');
+ });
+ });
+
+ describe('when element is not found', () => {
+ beforeEach(() => {
+ setHTMLFixture('');
+ });
+
+ it('returns null', () => {
+ const result = initDuoAgentsPlatformPage({
+ namespaceDatasetProperties: [],
+ namespace: 'project',
+ });
+
+ expect(result).toBeNull();
+ expect(initSinglePageApplication).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when required parameters are provided', () => {
+ const defaultParams = {
+ namespaceDatasetProperties: [
+ 'emptyStateIllustrationPath',
+ 'exploreAiCatalogPath',
+ 'flowTriggersEventTypeOptions',
+ ],
+ namespace: 'project',
+ };
+
+ beforeEach(() => {
+ // Set up dataset properties on the element
+ mockEl.dataset.agentsPlatformBaseRoute = '/automate';
+ mockEl.dataset.emptyStateIllustrationPath = '/empty-state.svg';
+ mockEl.dataset.exploreAiCatalogPath = '/ai-catalog';
+ mockEl.dataset.flowTriggersEventTypeOptions = '[]';
+ });
+
+ it('creates router with correct base route and namespace', () => {
+ initDuoAgentsPlatformPage(defaultParams);
+
+ expect(createRouter).toHaveBeenCalledWith('/automate', 'project');
+ });
+
+ it('calls initSinglePageApplication with correct parameters', () => {
+ initDuoAgentsPlatformPage(defaultParams);
+
+ expect(initSinglePageApplication).toHaveBeenCalledWith({
+ router: mockRouter,
+ el: mockEl,
+ keepAliveComponents: [AGENTS_PLATFORM_INDEX_ROUTE],
+ provide: {
+ emptyStateIllustrationPath: '/empty-state.svg',
+ exploreAiCatalogPath: '/ai-catalog',
+ flowTriggersEventTypeOptions: [],
+ ...getNamespaceDatasetProperties.mock.results[0].value,
+ },
+ });
+ });
+
+ it('parses flowTriggersEventTypeOptions JSON string', () => {
+ mockEl.dataset.flowTriggersEventTypeOptions = '[{"value": "push", "text": "Push"}]';
+
+ initDuoAgentsPlatformPage(defaultParams);
+
+ expect(initSinglePageApplication).toHaveBeenCalledWith({
+ router: mockRouter,
+ el: mockEl,
+ keepAliveComponents: [AGENTS_PLATFORM_INDEX_ROUTE],
+ provide: {
+ emptyStateIllustrationPath: '/empty-state.svg',
+ exploreAiCatalogPath: '/ai-catalog',
+ flowTriggersEventTypeOptions: [{ value: 'push', text: 'Push' }],
+ ...getNamespaceDatasetProperties.mock.results[0].value,
+ },
+ });
+ });
+
+ it('handles invalid JSON in flowTriggersEventTypeOptions', () => {
+ mockEl.dataset.flowTriggersEventTypeOptions = 'invalid-json';
+
+ expect(() => {
+ initDuoAgentsPlatformPage(defaultParams);
+ }).toThrow();
+ });
+
+ it('throws error when required dataset properties are missing', () => {
+ getNamespaceDatasetProperties.mockReturnValue({
+ emptyStateIllustrationPath: '/empty-state.svg',
+ // Missing other required properties
+ });
+
+ expect(() => {
+ initDuoAgentsPlatformPage(defaultParams);
+ }).toThrow('One or more required properties are missing in the dataset');
+ });
+ });
+
+ describe('when agentsPlatformBaseRoute is different', () => {
+ beforeEach(() => {
+ // Mock different dataset attribute
+ mockEl.dataset.agentsPlatformBaseRoute = '/custom-base';
+ mockEl.dataset.emptyStateIllustrationPath = '/empty-state.svg';
+ mockEl.dataset.exploreAiCatalogPath = '/ai-catalog';
+ mockEl.dataset.flowTriggersEventTypeOptions = '[]';
+ });
+
+ it('uses custom base route for router creation', () => {
+ initDuoAgentsPlatformPage({
+ namespaceDatasetProperties: [
+ 'emptyStateIllustrationPath',
+ 'exploreAiCatalogPath',
+ 'flowTriggersEventTypeOptions',
+ ],
+ namespace: 'project',
+ });
+
+ expect(createRouter).toHaveBeenCalledWith('/custom-base', 'project');
+ });
+ });
+});
diff --git a/ee/spec/frontend/ai/duo_agents_platform/router/dom_utils_spec.js b/ee/spec/frontend/ai/duo_agents_platform/router/dom_utils_spec.js
deleted file mode 100644
index fe38cf0fb24468ecece20311af692b3b922df225..0000000000000000000000000000000000000000
--- a/ee/spec/frontend/ai/duo_agents_platform/router/dom_utils_spec.js
+++ /dev/null
@@ -1,210 +0,0 @@
-import { updateActiveNavigation } from 'ee/ai/duo_agents_platform/router/dom_utils';
-
-// Test constants
-const CSS_CLASSES = {
- activeClass: 'super-sidebar-nav-item-current',
- hiddenClass: 'gl-hidden',
-};
-
-const SELECTORS = {
- superSidebar: '#super-sidebar',
-};
-
-// Mock factory functions
-const createMockElement = (methods = {}) => ({
- classList: {
- add: jest.fn(),
- remove: jest.fn(),
- ...methods.classList,
- },
- querySelector: jest.fn(),
- ...methods,
-});
-
-const createMockSuperSidebar = (queryResults = {}) => ({
- querySelectorAll: jest.fn().mockImplementation((selector) => {
- return queryResults[selector] || [];
- }),
-});
-
-describe('updateActiveNavigation', () => {
- let mockSuperSidebar;
- let mockElements;
-
- const setupMockSuperSidebar = (config = {}) => {
- const { activeNavItems = [], newNavItems = [] } = config;
-
- const queryResults = {
- [`.${CSS_CLASSES.activeClass}`]: activeNavItems,
- };
-
- // Add dynamic href-based queries
- if (newNavItems.length > 0) {
- // This will be set dynamically in tests
- mockSuperSidebar.querySelectorAll.mockImplementation((selector) => {
- if (selector.includes('[href*=')) {
- return newNavItems;
- }
- return queryResults[selector] || [];
- });
- } else {
- mockSuperSidebar = createMockSuperSidebar(queryResults);
- }
- };
-
- const setupDocumentMock = (superSidebarExists = true) => {
- jest.spyOn(document, 'querySelector').mockImplementation((selector) => {
- if (selector === SELECTORS.superSidebar) {
- return superSidebarExists ? mockSuperSidebar : null;
- }
- return null;
- });
- };
-
- beforeEach(() => {
- // Create reusable mock elements
- mockElements = {
- activeNavItems: [createMockElement(), createMockElement()],
- };
-
- mockElements.newNavItems = [createMockElement()];
-
- mockSuperSidebar = createMockSuperSidebar();
- setupDocumentMock();
- });
-
- afterEach(() => {
- jest.restoreAllMocks();
- });
-
- describe('when super-sidebar element exists', () => {
- beforeEach(() => {
- setupMockSuperSidebar({
- activeNavItems: mockElements.activeNavItems,
- newNavItems: mockElements.newNavItems,
- });
- });
-
- it('removes active class from current active nav items', () => {
- updateActiveNavigation('/test-href');
-
- mockElements.activeNavItems.forEach((item) => {
- expect(item.classList.remove).toHaveBeenCalledWith(CSS_CLASSES.activeClass);
- });
- });
-
- it('adds active class to new nav items matching href', () => {
- updateActiveNavigation('/test-href');
-
- expect(mockSuperSidebar.querySelectorAll).toHaveBeenCalledWith('[href*="/test-href"]');
- mockElements.newNavItems.forEach((item) => {
- expect(item.classList.add).toHaveBeenCalledWith(CSS_CLASSES.activeClass);
- });
- });
-
- it('handles href with special characters', () => {
- const specialHref = '/agents/test-agent-123';
- updateActiveNavigation(specialHref);
-
- expect(mockSuperSidebar.querySelectorAll).toHaveBeenCalledWith(`[href*="${specialHref}"]`);
- });
-
- describe('when no current active nav items exist', () => {
- beforeEach(() => {
- setupMockSuperSidebar({
- activeNavItems: [],
- newNavItems: [mockElements.newNavItems[0]],
- });
- });
-
- it('does not attempt to remove classes from non-existent elements', () => {
- updateActiveNavigation('/test-href');
-
- mockElements.activeNavItems.forEach((item) => {
- expect(item.classList.remove).not.toHaveBeenCalled();
- });
- });
-
- it('still adds active class to new nav items', () => {
- updateActiveNavigation('/test-href');
-
- expect(mockElements.newNavItems[0].classList.add).toHaveBeenCalledWith(
- CSS_CLASSES.activeClass,
- );
- });
- });
-
- describe('when no new nav items match the href', () => {
- beforeEach(() => {
- setupMockSuperSidebar({
- activeNavItems: [mockElements.activeNavItems[0]],
- newNavItems: [],
- });
- });
-
- it('still removes current active classes', () => {
- updateActiveNavigation('/non-matching-href');
-
- expect(mockElements.activeNavItems[0].classList.remove).toHaveBeenCalledWith(
- CSS_CLASSES.activeClass,
- );
- });
-
- it('does not attempt to add classes to non-existent new nav items', () => {
- updateActiveNavigation('/non-matching-href');
-
- mockElements.newNavItems.forEach((item) => {
- expect(item.classList.add).not.toHaveBeenCalled();
- });
- });
- });
-
- describe('when new nav items exist', () => {
- beforeEach(() => {
- const navItems = createMockElement();
- setupMockSuperSidebar({
- activeNavItems: [],
- newNavItems: [navItems],
- });
- });
-
- it('adds active class to nav items', () => {
- expect(() => updateActiveNavigation('/test-href')).not.toThrow();
- });
- });
- });
-
- describe('when super-sidebar element does not exist', () => {
- beforeEach(() => {
- setupDocumentMock(false);
- });
-
- it('returns early without throwing an error', () => {
- expect(() => updateActiveNavigation('/test-href')).not.toThrow();
- });
-
- it('does not attempt to query for nav items', () => {
- updateActiveNavigation('/test-href');
-
- expect(mockSuperSidebar.querySelectorAll).not.toHaveBeenCalled();
- });
- });
-
- describe('edge cases', () => {
- beforeEach(() => {
- setupMockSuperSidebar({});
- });
-
- const edgeCaseHrefs = [
- { value: '', description: 'empty href' },
- { value: undefined, description: 'undefined href' },
- { value: null, description: 'null href' },
- { value: '/test"path', description: 'href with quotes' },
- ];
-
- it.each(edgeCaseHrefs)('handles $description', ({ value }) => {
- expect(() => updateActiveNavigation(value)).not.toThrow();
- expect(mockSuperSidebar.querySelectorAll).toHaveBeenCalledWith(`[href*="${value}"]`);
- });
- });
-});
diff --git a/ee/spec/frontend/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs_spec.js b/ee/spec/frontend/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs_spec.js
deleted file mode 100644
index fb05351f18ec15e5f1c5de0c30298ed57ed9b152..0000000000000000000000000000000000000000
--- a/ee/spec/frontend/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs_spec.js
+++ /dev/null
@@ -1,121 +0,0 @@
-import { GlBreadcrumb } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import DuoAgentsPlatformBreadcrumbs from 'ee/ai/duo_agents_platform/router/duo_agents_platform_breadcrumbs.vue';
-import {
- AGENTS_PLATFORM_SHOW_ROUTE,
- AGENTS_PLATFORM_NEW_ROUTE,
-} from 'ee/ai/duo_agents_platform/router/constants';
-
-describe('DuoAgentsPlatformBreadcrumbs', () => {
- let wrapper;
-
- const defaultProps = {
- staticBreadcrumbs: [
- {
- text: 'Test Group',
- to: '/groups/test-group',
- },
- {
- text: 'Test Project',
- to: '/test-group/test-project',
- },
- ],
- };
-
- const createWrapper = (routeOptions = { matched: [], params: {} }) => {
- wrapper = shallowMount(DuoAgentsPlatformBreadcrumbs, {
- propsData: {
- ...defaultProps,
- },
- mocks: {
- $route: {
- path: '/agent-sessions',
- matched: routeOptions.matched,
- params: {
- ...routeOptions.params,
- },
- },
- },
- stubs: {
- GlBreadcrumb,
- },
- });
- };
-
- const findBreadcrumb = () => wrapper.findComponent(GlBreadcrumb);
- const getBreadcrumbItems = () => findBreadcrumb().props('items');
-
- describe('when component is mounted', () => {
- beforeEach(() => {
- createWrapper();
- });
-
- it('renders the breadcrumb component', () => {
- expect(findBreadcrumb().exists()).toBe(true);
- });
-
- it('passes auto-resize as false to breadcrumb', () => {
- expect(findBreadcrumb().props('autoResize')).toBe(false);
- });
- });
-
- describe.each`
- expectedText | path | matched | params
- ${'Agent sessions'} | ${'/agent-sessions'} | ${[{ path: '/agent-sessions', meta: { text: 'Agent sessions' } }]} | ${{}}
- ${'New'} | ${'/agent-sessions/new'} | ${[{ path: '/agent-sessions', meta: { text: 'Agent sessions' } }, { path: '/agent-sessions/new', name: AGENTS_PLATFORM_NEW_ROUTE, meta: { text: 'New' }, parent: {} }]} | ${{}}
- ${'4'} | ${'/agent-sessions/4'} | ${[{ path: '/agent-sessions', meta: { text: 'Agent sessions' } }, { path: '/agent-sessions/:id', name: AGENTS_PLATFORM_SHOW_ROUTE, parent: {} }]} | ${{ id: 4 }}
- ${'Agent sessions'} | ${'/unknown-scope/agent-sessions'} | ${[{ path: '/unknown-scope/agent-sessions', meta: { text: 'Agent sessions' } }]} | ${{}}
- `('breadcrumbs generation', ({ expectedText, matched, path, params }) => {
- beforeEach(() => {
- createWrapper({
- matched,
- params,
- });
- });
-
- it(`displays the correct number of breadcrumb items for ${path}`, () => {
- const items = getBreadcrumbItems();
- // static routes + Automate + Agent Sessions + dynamic routes
- const totalLength = 3 + matched.length;
-
- expect(items).toHaveLength(totalLength);
- expect(items[totalLength - 1].text).toBe(expectedText);
- });
- });
-
- describe('when matched route has a parent', () => {
- it('returns a to object with name', () => {
- createWrapper({
- matched: [
- { path: '/agent-sessions', meta: { text: 'Agent sessions' } },
- {
- path: '/agent-sessions/new',
- name: AGENTS_PLATFORM_NEW_ROUTE,
- meta: { text: 'New' },
- parent: {},
- },
- ],
- params: {},
- });
-
- const items = getBreadcrumbItems();
- const newRouteItem = items.find((item) => item.text === 'New');
-
- expect(newRouteItem.to).toEqual({ name: AGENTS_PLATFORM_NEW_ROUTE });
- });
- });
-
- describe('when matched route does not have a parent', () => {
- it('returns a to object with path', () => {
- createWrapper({
- matched: [{ path: '/agent-sessions', meta: { text: 'Agent sessions' } }],
- params: {},
- });
-
- const items = getBreadcrumbItems();
- const agentSessionsItem = items.find((item) => item.text === 'Agent sessions');
-
- expect(agentSessionsItem.to).toEqual({ path: '/agent-sessions' });
- });
- });
-});
diff --git a/ee/spec/frontend/ai/duo_agents_platform/router/utils_spec.js b/ee/spec/frontend/ai/duo_agents_platform/router/utils_spec.js
index bf545bb1b6a57cd08eb03a578508deadcc37a724..77f7d1235d0af11e4d832f1bbdc90d50ee627169 100644
--- a/ee/spec/frontend/ai/duo_agents_platform/router/utils_spec.js
+++ b/ee/spec/frontend/ai/duo_agents_platform/router/utils_spec.js
@@ -1,117 +1,47 @@
+import { getNamespaceIndexComponent } from 'ee/ai/duo_agents_platform/router/utils';
import {
- extractNavScopeFromRoute,
- activeNavigationWatcher,
- getNamespaceIndexComponent,
-} from 'ee/ai/duo_agents_platform/router/utils';
-import * as domUtils from 'ee/ai/duo_agents_platform/router/dom_utils';
+ AGENT_PLATFORM_PROJECT_PAGE,
+ AGENT_PLATFORM_USER_PAGE,
+} from 'ee/ai/duo_agents_platform/constants';
import ProjectAgentsPlatformIndex from 'ee/ai/duo_agents_platform/namespace/project/project_agents_platform_index.vue';
-import UserAgentsPlatformIndex from 'ee/ai/duo_agents_platform/namespace/user/user_agents_platform_index.vue';
+import userAgentsPlatformIndex from 'ee/ai/duo_agents_platform/namespace/user/user_agents_platform_index.vue';
-describe('extractNavScopeFromRoute', () => {
- describe('when route is empty', () => {
- it('returns an empty string', () => {
- expect(extractNavScopeFromRoute({})).toBe('');
- });
- });
-
- describe('when the route has no matched items', () => {
- it('returns an empty string', () => {
- expect(extractNavScopeFromRoute({ matched: [] })).toBe('');
- });
- });
-
- describe('when the route has multiple matched items', () => {
- it('returns the first item path without the leading slash', () => {
- expect(extractNavScopeFromRoute({ matched: [{ path: '/agent-sessions/new' }] })).toBe(
- 'agent-sessions',
- );
- });
- });
-});
-
-describe('activeNavigationWatcher', () => {
- let to;
- let from;
- let next;
-
- beforeEach(() => {
- jest.spyOn(domUtils, 'updateActiveNavigation').mockImplementation(() => {});
- next = jest.fn();
- });
-
- afterEach(() => {
- jest.clearAllMocks();
- });
+describe('Router utils', () => {
+ describe('getNamespaceIndexComponent', () => {
+ it('returns ProjectAgentsPlatformIndex for project namespace', () => {
+ const component = getNamespaceIndexComponent(AGENT_PLATFORM_PROJECT_PAGE);
- describe('when route scopes are different', () => {
- beforeEach(() => {
- to = { matched: [{ path: '/agent-issues' }, { path: '/agent-issues/new' }] };
- from = { matched: [{ path: '/agent-sessions' }] };
- activeNavigationWatcher(to, from, next);
+ expect(component).toBe(ProjectAgentsPlatformIndex);
});
- it('calls updateActiveNavigation with the correct scope', () => {
- expect(domUtils.updateActiveNavigation).toHaveBeenCalledWith('agent-issues');
- });
+ it('returns userAgentsPlatformIndex for user namespace', () => {
+ const component = getNamespaceIndexComponent(AGENT_PLATFORM_USER_PAGE);
- it('calls next to continue the navigation', () => {
- expect(next).toHaveBeenCalled();
+ expect(component).toBe(userAgentsPlatformIndex);
});
- });
-
- describe('when no changes are detected in navigation scope', () => {
- beforeEach(() => {
- to = { matched: [{ path: '/agent-sessions' }, { path: '/agent-sessions/new' }] };
- from = { matched: [{ path: '/agent-sessions' }, { path: '/agent-sessions/existing' }] };
- activeNavigationWatcher(to, from, next);
+ it('throws error for undefined namespace', () => {
+ expect(() => {
+ getNamespaceIndexComponent();
+ }).toThrow('The namespace argument must be passed to the Vue Router');
});
- it('does not call updateActiveNavigation', () => {
- expect(domUtils.updateActiveNavigation).not.toHaveBeenCalled();
+ it('throws error for null namespace', () => {
+ expect(() => {
+ getNamespaceIndexComponent(null);
+ }).toThrow('The namespace argument must be passed to the Vue Router');
});
- it('still calls next to continue the navigation', () => {
- expect(next).toHaveBeenCalled();
- });
- });
+ it('returns undefined for unknown namespace', () => {
+ const component = getNamespaceIndexComponent('unknown');
- describe('when there is no previous route', () => {
- beforeEach(() => {
- to = { matched: [{ path: '/agent-issues' }, { path: '/agent-issues/new' }] };
- from = { matched: [] };
- activeNavigationWatcher(to, from, next);
+ expect(component).toBeUndefined();
});
- it('calls updateActiveNavigation with the current scope', () => {
- expect(domUtils.updateActiveNavigation).toHaveBeenCalledWith('agent-issues');
- });
-
- it('calls next to continue the navigation', () => {
- expect(next).toHaveBeenCalled();
- });
- });
-});
-
-describe('getNamespaceIndexComponent', () => {
- describe('when namespace is not provided', () => {
- it('throws an error', () => {
- expect(() => getNamespaceIndexComponent()).toThrow(
- 'The namespace argument must be passed to the Vue Router',
- );
+ it('throws error for empty string namespace', () => {
+ expect(() => {
+ getNamespaceIndexComponent('');
+ }).toThrow('The namespace argument must be passed to the Vue Router');
});
});
-
- it.each`
- namespace | expectedComponent | expectedComponentName
- ${'project'} | ${ProjectAgentsPlatformIndex} | ${'ProjectAgentsPlatformIndex'}
- ${'user'} | ${UserAgentsPlatformIndex} | ${'UserAgentsPlatformIndex'}
- ${'group'} | ${undefined} | ${'undefined'}
- ${'unknown'} | ${undefined} | ${'undefined'}
- `(
- 'returns $expectedComponentName when namespace is $namespace',
- ({ namespace, expectedComponent }) => {
- expect(getNamespaceIndexComponent(namespace)).toBe(expectedComponent);
- },
- );
});
diff --git a/spec/frontend/lib/utils/breadcrumbs_spec.js b/spec/frontend/lib/utils/breadcrumbs_spec.js
index 0d71be101ee1efcda48010297b1e67c1eb7b51f7..e9eaed20e7720f6562ed0942fe2174b9440f2b2f 100644
--- a/spec/frontend/lib/utils/breadcrumbs_spec.js
+++ b/spec/frontend/lib/utils/breadcrumbs_spec.js
@@ -88,5 +88,52 @@ describe('Breadcrumbs utils', () => {
const component = wrapper.findComponent(MockComponent);
expect(component.props('staticBreadcrumbs')).toEqual([{ text: 'First', href: '/first' }]);
});
+
+ describe('when keepRootItem is true', () => {
+ it('keeps all staticBreadcrumbs items including the last one', () => {
+ const breadcrumbsHTML = `
+
+ `;
+ setHTMLFixture(breadcrumbsHTML);
+ staticBreadcrumbs.items = [
+ { text: 'First', href: '/first' },
+ { text: 'Last', href: '/last' },
+ ];
+ const wrapper = createWrapper(
+ injectVueAppBreadcrumbs(mockRouter, MockComponent, mockApolloProvider, {}, true),
+ );
+
+ const component = wrapper.findComponent(MockComponent);
+ expect(component.props('staticBreadcrumbs')).toEqual([
+ { text: 'First', href: '/first' },
+ { text: 'Last', href: '/last' },
+ ]);
+ });
+ });
+
+ describe('when keepRootItem is false', () => {
+ it('removes the last item from staticBreadcrumbs', () => {
+ const breadcrumbsHTML = `
+
+ `;
+ setHTMLFixture(breadcrumbsHTML);
+ staticBreadcrumbs.items = [
+ { text: 'First', href: '/first' },
+ { text: 'Last', href: '/last' },
+ ];
+ const wrapper = createWrapper(
+ injectVueAppBreadcrumbs(mockRouter, MockComponent, mockApolloProvider, {}, false),
+ );
+
+ const component = wrapper.findComponent(MockComponent);
+ expect(component.props('staticBreadcrumbs')).toEqual([{ text: 'First', href: '/first' }]);
+ });
+ });
});
});
diff --git a/spec/frontend/vue_shared/spa/components/spa_breadcrumbs_spec.js b/spec/frontend/vue_shared/spa/components/spa_breadcrumbs_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..7feeea55f7a78de4bf6506ebee9117c1d9444d8f
--- /dev/null
+++ b/spec/frontend/vue_shared/spa/components/spa_breadcrumbs_spec.js
@@ -0,0 +1,210 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlBreadcrumb } from '@gitlab/ui';
+import SpaBreadcrumbs from '~/vue_shared/spa/components/spa_breadcrumbs.vue';
+
+describe('SpaBreadcrumbs', () => {
+ let wrapper;
+
+ const findGlBreadcrumb = () => wrapper.findComponent(GlBreadcrumb);
+
+ const createWrapper = (props = {}, routeData = {}) => {
+ const defaultProps = {
+ staticBreadcrumbs: [
+ { text: 'Home', href: '/' },
+ { text: 'Projects', href: '/projects' },
+ ],
+ };
+
+ const defaultRoute = {
+ params: {},
+ matched: [],
+ };
+
+ wrapper = shallowMount(SpaBreadcrumbs, {
+ propsData: { ...defaultProps, ...props },
+ mocks: {
+ $route: { ...defaultRoute, ...routeData },
+ },
+ stubs: {
+ GlBreadcrumb: true,
+ },
+ });
+ };
+
+ describe('when only static breadcrumbs are provided', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('renders GlBreadcrumb component', () => {
+ expect(findGlBreadcrumb().exists()).toBe(true);
+ });
+
+ it('passes static breadcrumbs to GlBreadcrumb', () => {
+ const expectedCrumbs = [
+ { text: 'Home', href: '/' },
+ { text: 'Projects', href: '/projects' },
+ ];
+
+ expect(findGlBreadcrumb().props('items')).toEqual(expectedCrumbs);
+ });
+
+ it('sets auto-resize to false', () => {
+ expect(findGlBreadcrumb().props('autoResize')).toBe(false);
+ });
+ });
+
+ describe('when route has matched routes with meta', () => {
+ beforeEach(() => {
+ createWrapper(
+ {},
+ {
+ matched: [
+ {
+ name: 'projects',
+ path: '/projects',
+ meta: { text: 'All Projects' },
+ },
+ {
+ name: 'project-detail',
+ path: '/projects/:id',
+ meta: { text: 'Project Details' },
+ parent: true,
+ },
+ ],
+ },
+ );
+ });
+
+ it('includes route breadcrumbs with meta text', () => {
+ const expectedCrumbs = [
+ { text: 'Home', href: '/' },
+ { text: 'Projects', href: '/projects' },
+ { text: 'All Projects', to: { path: '/projects' } },
+ { text: 'Project Details', to: { name: 'project-detail' } },
+ ];
+
+ expect(findGlBreadcrumb().props('items')).toEqual(expectedCrumbs);
+ });
+ });
+
+ describe('when route has matched routes without meta', () => {
+ beforeEach(() => {
+ createWrapper(
+ {},
+ {
+ params: { id: '123' },
+ matched: [
+ {
+ name: 'projects',
+ path: '/projects',
+ },
+ {
+ name: 'project-detail',
+ path: '/projects/:id',
+ parent: true,
+ },
+ ],
+ },
+ );
+ });
+
+ it('uses route param id as breadcrumb text when meta is missing', () => {
+ const expectedCrumbs = [
+ { text: 'Home', href: '/' },
+ { text: 'Projects', href: '/projects' },
+ { text: '123', to: { path: '/projects' } },
+ { text: '123', to: { name: 'project-detail' } },
+ ];
+
+ expect(findGlBreadcrumb().props('items')).toEqual(expectedCrumbs);
+ });
+ });
+
+ describe('when route has mixed matched routes', () => {
+ beforeEach(() => {
+ createWrapper(
+ {},
+ {
+ params: { id: '456' },
+ matched: [
+ {
+ name: 'projects',
+ path: '/projects',
+ meta: { text: 'All Projects' },
+ },
+ {
+ name: 'project-detail',
+ path: '/projects/:id',
+ },
+ {
+ name: 'project-issues',
+ path: '/projects/:id/issues',
+ meta: { text: 'Issues' },
+ parent: true,
+ },
+ ],
+ },
+ );
+ });
+
+ it('combines static and route breadcrumbs correctly', () => {
+ const expectedCrumbs = [
+ { text: 'Home', href: '/' },
+ { text: 'Projects', href: '/projects' },
+ { text: 'All Projects', to: { path: '/projects' } },
+ { text: '456', to: { path: '/projects/:id' } },
+ { text: 'Issues', to: { name: 'project-issues' } },
+ ];
+
+ expect(findGlBreadcrumb().props('items')).toEqual(expectedCrumbs);
+ });
+ });
+
+ describe('when route has empty matched array', () => {
+ beforeEach(() => {
+ createWrapper(
+ {},
+ {
+ matched: [],
+ },
+ );
+ });
+
+ it('only shows static breadcrumbs', () => {
+ const expectedCrumbs = [
+ { text: 'Home', href: '/' },
+ { text: 'Projects', href: '/projects' },
+ ];
+
+ expect(findGlBreadcrumb().props('items')).toEqual(expectedCrumbs);
+ });
+ });
+
+ describe('when route has matched routes with empty meta', () => {
+ beforeEach(() => {
+ createWrapper(
+ {},
+ {
+ params: {},
+ matched: [
+ {
+ name: 'projects',
+ path: '/projects',
+ meta: {},
+ },
+ ],
+ },
+ );
+ });
+
+ it('filters out routes with empty meta and no id param', () => {
+ const expectedCrumbs = [
+ { text: 'Home', href: '/' },
+ { text: 'Projects', href: '/projects' },
+ ];
+
+ expect(findGlBreadcrumb().props('items')).toEqual(expectedCrumbs);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/spa/components/spa_root_spec.js b/spec/frontend/vue_shared/spa/components/spa_root_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..5de9231161ea78c309a710e7d5f7844ab9ec12d9
--- /dev/null
+++ b/spec/frontend/vue_shared/spa/components/spa_root_spec.js
@@ -0,0 +1,91 @@
+import { shallowMount } from '@vue/test-utils';
+import SpaRoot from '~/vue_shared/spa/components/spa_root.vue';
+
+describe('SpaRoot', () => {
+ let wrapper;
+
+ const findKeepAlive = () => wrapper.findComponent({ name: 'keep-alive' });
+ const findRouterView = () => wrapper.findComponent({ name: 'router-view' });
+ const findRootDiv = () => wrapper.find('#single-page-app');
+
+ const createWrapper = (props = {}) => {
+ const defaultProps = {
+ keepAliveComponents: [],
+ };
+
+ wrapper = shallowMount(SpaRoot, {
+ propsData: { ...defaultProps, ...props },
+ stubs: {
+ 'router-view': true,
+ 'keep-alive': {
+ template: '
',
+ props: ['include'],
+ },
+ },
+ });
+ };
+
+ describe('template structure', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('renders root div with correct id', () => {
+ expect(findRootDiv().exists()).toBe(true);
+ });
+
+ it('renders keep-alive component', () => {
+ expect(findKeepAlive().exists()).toBe(true);
+ });
+
+ it('renders router-view inside keep-alive', () => {
+ expect(findRouterView().exists()).toBe(true);
+ });
+ });
+
+ describe('keepAliveComponents prop', () => {
+ describe('when keepAliveComponents is empty', () => {
+ beforeEach(() => {
+ createWrapper({ keepAliveComponents: [] });
+ });
+
+ it('passes empty array to keep-alive include', () => {
+ expect(findKeepAlive().props('include')).toEqual([]);
+ });
+ });
+
+ describe('when keepAliveComponents has single component', () => {
+ beforeEach(() => {
+ createWrapper({ keepAliveComponents: ['ComponentA'] });
+ });
+
+ it('passes component name to keep-alive include', () => {
+ expect(findKeepAlive().props('include')).toEqual(['ComponentA']);
+ });
+ });
+
+ describe('when keepAliveComponents has multiple components', () => {
+ beforeEach(() => {
+ createWrapper({ keepAliveComponents: ['ComponentA', 'ComponentB', 'ComponentC'] });
+ });
+
+ it('passes comma-separated component names to keep-alive include', () => {
+ expect(findKeepAlive().props('include')).toEqual([
+ 'ComponentA',
+ 'ComponentB',
+ 'ComponentC',
+ ]);
+ });
+ });
+ });
+
+ describe('prop validation', () => {
+ it('has correct prop definition for keepAliveComponents', () => {
+ const { keepAliveComponents } = SpaRoot.props;
+
+ expect(keepAliveComponents.type).toBe(Array);
+ expect(keepAliveComponents.required).toBe(false);
+ expect(keepAliveComponents.default()).toEqual([]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/spa/index_spec.js b/spec/frontend/vue_shared/spa/index_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..2c8100af5b70f1c30598c02104c0e501e0d08e21
--- /dev/null
+++ b/spec/frontend/vue_shared/spa/index_spec.js
@@ -0,0 +1,255 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import { shallowMount } from '@vue/test-utils';
+import { initSinglePageApplication } from '~/vue_shared/spa';
+import { injectVueAppBreadcrumbs } from '~/lib/utils/breadcrumbs';
+import { activeNavigationWatcher } from '~/vue_shared/spa/utils';
+import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures';
+import RootComponent from '~/vue_shared/spa/components/spa_root.vue';
+
+jest.mock('~/lib/utils/breadcrumbs');
+jest.mock('~/vue_shared/spa/utils');
+jest.mock('~/lib/graphql', () => jest.fn(() => ({})));
+
+Vue.use(VueRouter);
+
+describe('initSinglePageApplication', () => {
+ let mockRouter;
+ let mockEl;
+ let wrapper;
+
+ const findRootComponent = () => wrapper.find('#single-page-app');
+ const findKeepAlive = () => wrapper.find('keep-alive-stub');
+ const findRouterView = () => wrapper.find('router-view-stub');
+
+ const createWrapper = (props = {}) => {
+ wrapper = shallowMount(RootComponent, {
+ propsData: props,
+ router: mockRouter,
+ });
+ return wrapper;
+ };
+
+ describe('when required parameters are missing', () => {
+ beforeEach(() => {
+ setHTMLFixture('');
+ mockEl = document.getElementById('app');
+ mockRouter = new VueRouter({
+ routes: [{ path: '/', component: { render: (h) => h('div', 'Home') } }],
+ });
+
+ mockRouter.beforeEach = jest.fn();
+ injectVueAppBreadcrumbs.mockReturnValue(true);
+ activeNavigationWatcher.mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ jest.clearAllMocks();
+ });
+
+ describe('when el is not provided', () => {
+ it('throws error', () => {
+ expect(() => {
+ initSinglePageApplication({
+ router: mockRouter,
+ });
+ }).toThrow('You must provide a `el` prop to initVueSinglePageApplication');
+ });
+ });
+
+ describe('when router is not provided', () => {
+ it('throws error', () => {
+ expect(() => {
+ initSinglePageApplication({
+ el: mockEl,
+ });
+ }).toThrow('You must provide a `router` prop to initVueSinglePageApplication');
+ });
+ });
+ });
+
+ describe('when required parameters are provided', () => {
+ beforeEach(() => {
+ setHTMLFixture('');
+ mockEl = document.getElementById('app');
+ mockRouter = new VueRouter({
+ routes: [{ path: '/', component: { render: (h) => h('div', 'Home') } }],
+ });
+
+ mockRouter.beforeEach = jest.fn();
+ injectVueAppBreadcrumbs.mockReturnValue(true);
+ activeNavigationWatcher.mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ jest.clearAllMocks();
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ describe('when creating Vue instance with default name', () => {
+ it('creates Vue instance with default name', () => {
+ const app = initSinglePageApplication({
+ el: mockEl,
+ router: mockRouter,
+ });
+
+ expect(app.$options.name).toBe('SinglePageAplication');
+ });
+ });
+
+ describe('when creating Vue instance with custom name', () => {
+ it('creates Vue instance with custom name', () => {
+ const app = initSinglePageApplication({
+ name: 'CustomApp',
+ el: mockEl,
+ router: mockRouter,
+ });
+
+ expect(app.$options.name).toBe('CustomApp');
+ });
+ });
+
+ describe('when rendering root component', () => {
+ it('renders root component with keep-alive and router-view', () => {
+ createWrapper();
+
+ expect(findRootComponent().exists()).toBe(true);
+ expect(findKeepAlive().exists()).toBe(true);
+ expect(findRouterView().exists()).toBe(true);
+ });
+ });
+
+ describe('when passing keepAliveComponents', () => {
+ it('passes keepAliveComponents to root component', () => {
+ const keepAliveComponents = ['ComponentA', 'ComponentB'];
+
+ createWrapper({ keepAliveComponents });
+ const keepAlive = findKeepAlive();
+
+ expect(keepAlive.attributes('include')).toBe(keepAliveComponents.join(','));
+ });
+ });
+
+ describe('when setting up breadcrumbs injection', () => {
+ it('sets up breadcrumbs injection', () => {
+ initSinglePageApplication({
+ el: mockEl,
+ router: mockRouter,
+ });
+
+ expect(injectVueAppBreadcrumbs).toHaveBeenCalledWith(
+ mockRouter,
+ expect.any(Object), // breadcrumbs component
+ null,
+ {},
+ true,
+ );
+ });
+ });
+
+ describe('when setting up router navigation watcher', () => {
+ it('sets up router navigation watcher', () => {
+ initSinglePageApplication({
+ el: mockEl,
+ router: mockRouter,
+ });
+
+ expect(mockRouter.beforeEach).toHaveBeenCalledWith(activeNavigationWatcher);
+ });
+ });
+
+ describe('when passing provide data', () => {
+ it('passes provide data to Vue instance', () => {
+ const provide = {
+ testData: 'test value',
+ anotherProp: 42,
+ };
+
+ const app = initSinglePageApplication({
+ el: mockEl,
+ router: mockRouter,
+ provide,
+ });
+
+ expect(app.$options.provide).toEqual(provide);
+ });
+ });
+
+ describe('when passing propsData', () => {
+ it('passes propsData to Vue instance', () => {
+ const propsData = {
+ keepAliveComponents: ['TestComponent'],
+ };
+
+ const app = initSinglePageApplication({
+ el: mockEl,
+ router: mockRouter,
+ propsData,
+ });
+
+ expect(app.$options.propsData).toEqual(propsData);
+ });
+ });
+ });
+
+ describe('Apollo provider setup', () => {
+ beforeEach(() => {
+ setHTMLFixture('');
+ mockEl = document.getElementById('app');
+ mockRouter = new VueRouter({
+ routes: [{ path: '/', component: { render: (h) => h('div', 'Home') } }],
+ });
+
+ mockRouter.beforeEach = jest.fn();
+ injectVueAppBreadcrumbs.mockReturnValue(true);
+ activeNavigationWatcher.mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ resetHTMLFixture();
+ jest.clearAllMocks();
+ });
+
+ describe('when apolloCacheConfig is provided', () => {
+ it('creates Apollo provider', () => {
+ const apolloCacheConfig = { cache: {} };
+
+ const app = initSinglePageApplication({
+ el: mockEl,
+ router: mockRouter,
+ apolloCacheConfig,
+ });
+
+ expect(app.$options.apolloProvider).toBeDefined();
+ });
+ });
+
+ describe('when apolloCacheConfig is null', () => {
+ it('does not create Apollo provider', () => {
+ const app = initSinglePageApplication({
+ el: mockEl,
+ router: mockRouter,
+ apolloCacheConfig: null,
+ });
+
+ expect(app.$options.apolloProvider).toBeUndefined();
+ });
+ });
+
+ describe('when apolloCacheConfig is empty object', () => {
+ it('creates Apollo provider with default config', () => {
+ const app = initSinglePageApplication({
+ el: mockEl,
+ router: mockRouter,
+ apolloCacheConfig: {},
+ });
+
+ expect(app.$options.apolloProvider).toBeDefined();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/spa/utils_spec.js b/spec/frontend/vue_shared/spa/utils_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..c4ac87266cc2fd5c9b7c362164286142067ea4e6
--- /dev/null
+++ b/spec/frontend/vue_shared/spa/utils_spec.js
@@ -0,0 +1,161 @@
+import {
+ extractNavScopeFromRoute,
+ updateActiveNavigation,
+ activeNavigationWatcher,
+} from '~/vue_shared/spa/utils';
+import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures';
+
+describe('SPA utils', () => {
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ describe('extractNavScopeFromRoute', () => {
+ it('returns empty string when route is undefined', () => {
+ expect(extractNavScopeFromRoute()).toBe('');
+ });
+
+ it('returns empty string when route has no matched property', () => {
+ const route = {};
+ expect(extractNavScopeFromRoute(route)).toBe('');
+ });
+
+ it('returns empty string when matched array is empty', () => {
+ const route = { matched: [] };
+ expect(extractNavScopeFromRoute(route)).toBe('');
+ });
+
+ it('returns the first segment when path has only one segment', () => {
+ const route = { matched: [{ path: '/dashboard' }] };
+ expect(extractNavScopeFromRoute(route)).toBe('dashboard');
+ });
+
+ it('returns the second segment when path has multiple segments', () => {
+ const route = { matched: [{ path: '/groups/my-group' }] };
+ expect(extractNavScopeFromRoute(route)).toBe('groups');
+ });
+
+ it('returns the second segment for deeply nested paths', () => {
+ const route = { matched: [{ path: '/projects/my-project/issues' }] };
+ expect(extractNavScopeFromRoute(route)).toBe('projects');
+ });
+ });
+
+ describe('updateActiveNavigation', () => {
+ beforeEach(() => {
+ const navHTML = `
+
+ `;
+ setHTMLFixture(navHTML);
+ });
+
+ it('removes active class from all nav items', () => {
+ updateActiveNavigation('projects');
+
+ const activeItems = document.querySelectorAll('.nav-sidebar .nav-item.active');
+ expect(activeItems).toHaveLength(1);
+ // Given this is for testin purposes, there are no dataset
+ // eslint-disable-next-line unicorn/prefer-dom-node-dataset
+ expect(activeItems[0].getAttribute('data-nav-scope')).toBe('projects');
+ });
+
+ it('adds active class to the specified scope nav item', () => {
+ updateActiveNavigation('admin');
+
+ const adminItem = document.querySelector('.nav-sidebar .nav-item[data-nav-scope="admin"]');
+ expect(adminItem.classList.contains('active')).toBe(true);
+ });
+
+ it('does not add active class when scope is not found', () => {
+ updateActiveNavigation('nonexistent');
+
+ const activeItems = document.querySelectorAll('.nav-sidebar .nav-item.active');
+ expect(activeItems).toHaveLength(0);
+ });
+
+ it('handles empty scope by removing all active classes', () => {
+ updateActiveNavigation('');
+
+ const activeItems = document.querySelectorAll('.nav-sidebar .nav-item.active');
+ expect(activeItems).toHaveLength(0);
+ });
+ });
+
+ describe('activeNavigationWatcher', () => {
+ const mockNext = jest.fn();
+
+ beforeEach(() => {
+ const navHTML = `
+
+ `;
+ setHTMLFixture(navHTML);
+ mockNext.mockClear();
+ });
+
+ it('calls next function', () => {
+ const to = { matched: [{ path: '/projects/test' }] };
+ const from = { matched: [{ path: '/groups/test' }] };
+
+ activeNavigationWatcher(to, from, mockNext);
+
+ expect(mockNext).toHaveBeenCalledTimes(1);
+ });
+
+ it('updates navigation when scope changes', () => {
+ const to = { matched: [{ path: '/projects/test' }] };
+ const from = { matched: [{ path: '/groups/test' }] };
+
+ activeNavigationWatcher(to, from, mockNext);
+
+ const projectsItem = document.querySelector(
+ '.nav-sidebar .nav-item[data-nav-scope="projects"]',
+ );
+ const groupsItem = document.querySelector('.nav-sidebar .nav-item[data-nav-scope="groups"]');
+
+ expect(projectsItem.classList.contains('active')).toBe(true);
+ expect(groupsItem.classList.contains('active')).toBe(false);
+ });
+
+ it('does not update navigation when scope remains the same', () => {
+ const to = { matched: [{ path: '/projects/test1' }] };
+ const from = { matched: [{ path: '/projects/test2' }] };
+
+ // Set initial state
+ document
+ .querySelector('.nav-sidebar .nav-item[data-nav-scope="projects"]')
+ .classList.add('active');
+ document
+ .querySelector('.nav-sidebar .nav-item[data-nav-scope="groups"]')
+ .classList.remove('active');
+
+ activeNavigationWatcher(to, from, mockNext);
+
+ const projectsItem = document.querySelector(
+ '.nav-sidebar .nav-item[data-nav-scope="projects"]',
+ );
+ const groupsItem = document.querySelector('.nav-sidebar .nav-item[data-nav-scope="groups"]');
+
+ expect(projectsItem.classList.contains('active')).toBe(true);
+ expect(groupsItem.classList.contains('active')).toBe(false);
+ });
+
+ it('updates navigation when from route has no matched routes', () => {
+ const to = { matched: [{ path: '/projects/test' }] };
+ const from = { matched: [] };
+
+ activeNavigationWatcher(to, from, mockNext);
+
+ const projectsItem = document.querySelector(
+ '.nav-sidebar .nav-item[data-nav-scope="projects"]',
+ );
+ expect(projectsItem.classList.contains('active')).toBe(true);
+ });
+ });
+});