From 5dc29141d5830ca3ce0382e3003b7aa5171dad55 Mon Sep 17 00:00:00 2001 From: Joakim Olsson Date: Sat, 22 Nov 2025 19:49:53 +0100 Subject: [PATCH] feat(graph): add Federation Graph page and enhance coverage Adds a new Federation Graph page to display subgraphs and their schemas. Implements loading state and error handling. Enhances coverage reporting by including 'lcov' format for better insights into test coverage metrics. --- .dockerignore | 1 + .gitignore | 8 ++ .gitlab-ci.yml | 3 + Dockerfile | 5 +- .../__tests__/OrganizationSwitcher.spec.ts | 46 ++++++ .../__tests__/useOrganizationSelector.spec.ts | 135 ++++++++++++++++++ app/pages/graph/[ref].vue | 124 ++++++++++++++++ app/pages/schemas/[id].vue | 6 + test/setup.ts | 64 +++++++-- vitest.config.ts | 2 +- 10 files changed, 382 insertions(+), 12 deletions(-) create mode 100644 app/components/__tests__/OrganizationSwitcher.spec.ts create mode 100644 app/composables/__tests__/useOrganizationSelector.spec.ts create mode 100644 app/pages/graph/[ref].vue diff --git a/.dockerignore b/.dockerignore index 3ad0cac..c40d480 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,6 +8,7 @@ node_modules .env.* !.env.example coverage +exported .idea .vscode *.log diff --git a/.gitignore b/.gitignore index 4a7f73a..f4ed4d1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ .cache dist +# Test coverage +coverage +exported + # Node dependencies node_modules @@ -18,6 +22,10 @@ logs .fleet .idea +# Linter caches +.eslintcache +.stylelintcache + # Local env files .env .env.* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1b3be7b..1523777 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,6 +15,9 @@ build: stage: build script: - build + - curl -Os https://uploader.codecov.io/latest/linux/codecov + - chmod +x codecov + - ./codecov -t ${CODECOV_TOKEN} -R $CI_PROJECT_DIR -C $CI_COMMIT_SHA -r $CI_PROJECT_PATH - push artifacts: paths: diff --git a/Dockerfile b/Dockerfile index 774fab5..5c10eba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,10 @@ RUN npm install --global node-gyp && \ COPY ./package.json ./package-lock.json ./ RUN npm ci --no-progress COPY . /build -RUN npm run generate +RUN npm run postinstall && npm run generate && npm run lint && npm run test:coverage + +FROM scratch as export +COPY --from=builder /build/coverage / FROM amd64/nginx:1.29.3@sha256:4fd8a65a560a906addb9930f2cd5a3d33ff5b8e8b50e983bce275c9c78151a96 MAINTAINER Joakim Olsson diff --git a/app/components/__tests__/OrganizationSwitcher.spec.ts b/app/components/__tests__/OrganizationSwitcher.spec.ts new file mode 100644 index 0000000..752ba5c --- /dev/null +++ b/app/components/__tests__/OrganizationSwitcher.spec.ts @@ -0,0 +1,46 @@ +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const mockSelectOrganization = vi.fn() + +const mockOrganizations = [ + { id: '1', name: 'Org 1', apiKeys: [{ name: 'key1' }, { name: 'key2' }] }, + { id: '2', name: 'Org 2', apiKeys: [{ name: 'key3' }] }, + { id: '3', name: 'Org 3', apiKeys: [] }, +] + +// TODO: Add more comprehensive component tests once vitest module mocking issues are resolved +// For now, the component is indirectly tested through useOrganizationSelector tests +// and manual testing shows it works correctly + +describe('OrganizationSwitcher - Single Organization', () => { + beforeEach(() => { + vi.resetModules() + mockSelectOrganization.mockClear() + + // Mock with single organization + vi.mock('~/composables/useOrganizationSelector', () => ({ + useOrganizationSelector: () => ({ + organizations: ref([mockOrganizations[0]]), + selectedOrganization: ref(mockOrganizations[0]), + selectedOrgId: ref('1'), + selectOrganization: mockSelectOrganization, + }), + })) + }) + + it('should show a chip for single organization', async () => { + const module = await import('../OrganizationSwitcher.vue') + const wrapper = mount(module.default) + + const html = wrapper.html() + + // Should show chip + expect(html).toContain('v-chip') + expect(html).toContain('Org 1') + + // Menu should not exist + expect(html).not.toContain('v-menu') + }) +}) diff --git a/app/composables/__tests__/useOrganizationSelector.spec.ts b/app/composables/__tests__/useOrganizationSelector.spec.ts new file mode 100644 index 0000000..c0ab7a1 --- /dev/null +++ b/app/composables/__tests__/useOrganizationSelector.spec.ts @@ -0,0 +1,135 @@ +import { afterEach,beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick,ref } from 'vue' + +const mockOrganizations = [ + { id: '1', name: 'Org 1' }, + { id: '2', name: 'Org 2' }, + { id: '3', name: 'Org 3' }, +] + +const mockResult = ref({ + organizations: mockOrganizations, +}) + +// Mock Auth0 +vi.mock('@auth0/auth0-vue', () => ({ + useAuth0: () => ({ + isAuthenticated: ref(true), + }), +})) + +// Mock the GraphQL query +vi.mock('~/graphql/generated', () => ({ + useOrganizationsQuery: () => ({ + result: mockResult, + loading: ref(false), + error: ref(null), + refetch: vi.fn(), + }), +})) + +describe('useOrganizationSelector', () => { + let useOrganizationSelector: any + + beforeEach(async () => { + // Clear localStorage before each test + localStorage.clear() + + // Reset modules to clear the shared selectedOrgId ref + vi.resetModules() + + // Re-import the composable to get fresh module state + const module = await import('../useOrganizationSelector') + useOrganizationSelector = module.useOrganizationSelector + }) + + afterEach(() => { + localStorage.clear() + }) + + it('should return organizations list', async () => { + const { organizations } = useOrganizationSelector() + await nextTick() + + expect(organizations.value).toHaveLength(3) + expect(organizations.value[0]).toEqual({ id: '1', name: 'Org 1' }) + }) + + it('should auto-select first organization if none selected and orgs available', async () => { + const { selectedOrganization, organizations } = useOrganizationSelector() + await nextTick() + + // Should auto-select first org + expect(selectedOrganization.value).toEqual(organizations.value[0]) + }) + + it('should select an organization by ID', async () => { + const { selectedOrganization, selectOrganization, organizations } = useOrganizationSelector() + await nextTick() + + // Should start with first org auto-selected + expect(selectedOrganization.value).toEqual(organizations.value[0]) + + // Select a different organization by ID + selectOrganization('2') + await nextTick() + + expect(selectedOrganization.value).toEqual({ id: '2', name: 'Org 2' }) + }) + + it('should persist selected organization ID to localStorage', async () => { + const { selectOrganization } = useOrganizationSelector() + await nextTick() + + // Auto-selection will have set it to '1', so select '2' to trigger the watch + selectOrganization('2') + await nextTick() + + const stored = localStorage.getItem('selectedOrgId') + expect(stored).toBe('2') + }) + + it('should restore selected organization from localStorage', async () => { + // Set up localStorage with a saved organization ID + localStorage.setItem('selectedOrgId', '2') + + const { selectedOrganization } = useOrganizationSelector() + await nextTick() + + expect(selectedOrganization.value).toEqual({ id: '2', name: 'Org 2' }) + }) + + it('should return selectedOrgId computed property', async () => { + const { selectedOrgId, selectOrganization } = useOrganizationSelector() + await nextTick() + + // Auto-selected to first org + expect(selectedOrgId.value).toBe('1') + + selectOrganization('3') + await nextTick() + + expect(selectedOrgId.value).toBe('3') + }) + + it('should handle selecting an organization that does not exist', async () => { + const { selectedOrganization, selectOrganization } = useOrganizationSelector() + await nextTick() + + selectOrganization('999') + await nextTick() + + // selectedOrgId is set but selectedOrganization returns null + expect(selectedOrganization.value).toBeNull() + }) + + it('should prioritize localStorage over auto-selection', async () => { + localStorage.setItem('selectedOrgId', '3') + + const { selectedOrganization } = useOrganizationSelector() + await nextTick() + + // Should restore from localStorage, not auto-select first org + expect(selectedOrganization.value).toEqual({ id: '3', name: 'Org 3' }) + }) +}) diff --git a/app/pages/graph/[ref].vue b/app/pages/graph/[ref].vue new file mode 100644 index 0000000..3d025c6 --- /dev/null +++ b/app/pages/graph/[ref].vue @@ -0,0 +1,124 @@ + + + + + diff --git a/app/pages/schemas/[id].vue b/app/pages/schemas/[id].vue index 73cf774..aca97d2 100644 --- a/app/pages/schemas/[id].vue +++ b/app/pages/schemas/[id].vue @@ -82,6 +82,7 @@ prepend-icon="mdi-graph" variant="outlined" color="primary" + @click="viewFederationGraph" > View Federation Graph @@ -159,6 +160,11 @@ const copyToClipboard = async () => { } } +const viewFederationGraph = () => { + if (!schema.value?.ref) return + navigateTo(`/graph/${schema.value.ref}`) +} + // TODO: Fetch actual data from the GraphQL API onMounted(() => { // Mock data for now diff --git a/test/setup.ts b/test/setup.ts index d220560..8da6362 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -2,16 +2,36 @@ import { config } from '@vue/test-utils' import { vi } from 'vitest' import * as Vue from 'vue' -// Mock localStorage -const localStorageMock = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - clear: vi.fn(), - key: vi.fn(), - length: 0, +// Mock localStorage with actual implementation +class LocalStorageMock { + private store: Map = new Map() + + getItem(key: string): string | null { + return this.store.get(key) ?? null + } + + setItem(key: string, value: string): void { + this.store.set(key, value) + } + + removeItem(key: string): void { + this.store.delete(key) + } + + clear(): void { + this.store.clear() + } + + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null + } + + get length(): number { + return this.store.size + } } -global.localStorage = localStorageMock as unknown as Storage + +global.localStorage = new LocalStorageMock() as unknown as Storage // Make Vue composables globally available global.ref = Vue.ref @@ -87,7 +107,31 @@ config.global.stubs = { VIcon: { name: 'v-icon', template: '', - props: ['icon', 'large'], + props: ['icon', 'large', 'start', 'end', 'size'], emits: ['click'], }, + VMenu: { + name: 'v-menu', + template: '
', + props: ['modelValue', 'closeOnContentClick'], + }, + VList: { + name: 'v-list', + template: '
', + }, + VListItem: { + name: 'v-list-item', + template: '
', + props: ['active', 'value'], + emits: ['click'], + }, + VListItemTitle: { + name: 'v-list-item-title', + template: '
', + }, + VChip: { + name: 'v-chip', + template: '
', + props: ['size', 'variant', 'prependIcon'], + }, } diff --git a/vitest.config.ts b/vitest.config.ts index ca4e0f1..3f24fbd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ setupFiles: ['./test/setup.ts'], coverage: { provider: 'v8', - reporter: ['text', 'json', 'html'], + reporter: ['text', 'json', 'html', 'lcov'], include: [ 'app/utils/**/*.{ts,js}', 'app/components/**/*.vue',