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.
This commit is contained in:
2025-11-22 19:49:53 +01:00
parent 535a516732
commit 5dc29141d5
10 changed files with 382 additions and 12 deletions
@@ -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')
})
})
@@ -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' })
})
})
+124
View File
@@ -0,0 +1,124 @@
<template>
<div>
<v-row>
<v-col cols="12">
<div class="d-flex align-center mb-4">
<v-btn
icon="mdi-arrow-left"
variant="text"
@click="$router.back()"
/>
<h1 class="text-h4 ml-2">
Federation Graph: {{ route.params.ref }}
</h1>
</div>
</v-col>
</v-row>
<v-row v-if="loading">
<v-col cols="12" class="text-center py-12">
<v-progress-circular indeterminate color="primary" size="64" />
<p class="mt-4">
Loading federation graph...
</p>
</v-col>
</v-row>
<v-row v-else-if="supergraphData">
<v-col cols="12">
<v-card>
<v-card-title>Subgraphs in Federation</v-card-title>
<v-card-text>
<v-list>
<v-list-item
v-for="subgraph in subgraphs"
:key="subgraph.service"
:title="subgraph.service"
:subtitle="`URL: ${subgraph.url}`"
>
<template #prepend>
<v-icon icon="mdi-cube-outline" />
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12">
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span>Federation Schema (SDL)</span>
<v-btn
icon="mdi-content-copy"
variant="text"
size="small"
@click="copySupergraph"
/>
</v-card-title>
<v-card-text>
<pre class="sdl-viewer"><code>{{ supergraphData.sdl }}</code></pre>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row v-else>
<v-col cols="12" class="text-center py-12">
<v-icon icon="mdi-alert-circle" size="64" class="mb-4 text-warning" />
<p class="text-h6">
No federation graph available
</p>
</v-col>
</v-row>
<v-snackbar v-model="snackbar" :timeout="3000">
{{ snackbarText }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { useSupergraphQuery } from '~/graphql/generated'
const route = useRoute()
const loading = ref(true)
const snackbar = ref(false)
const snackbarText = ref('')
const { result, loading: queryLoading } = useSupergraphQuery(() => ({
ref: route.params.ref as string,
isAfter: null,
}))
const supergraphData = computed(() => result.value?.supergraph)
const subgraphs = computed(() => supergraphData.value?.subgraphs || [])
watch(queryLoading, (newLoading) => {
loading.value = newLoading
})
const copySupergraph = async () => {
try {
await navigator.clipboard.writeText(supergraphData.value?.sdl || '')
snackbarText.value = 'Supergraph SDL copied to clipboard'
snackbar.value = true
} catch (_err) {
snackbarText.value = 'Failed to copy'
snackbar.value = true
}
}
</script>
<style scoped>
.sdl-viewer {
background: #f5f5f5;
padding: 16px;
border-radius: 4px;
overflow: auto;
font-family: Monaco, Menlo, 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
max-height: 60vh;
}
</style>
+6
View File
@@ -82,6 +82,7 @@
prepend-icon="mdi-graph"
variant="outlined"
color="primary"
@click="viewFederationGraph"
>
View Federation Graph
</v-btn>
@@ -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