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
+1
View File
@@ -8,6 +8,7 @@ node_modules
.env.*
!.env.example
coverage
exported
.idea
.vscode
*.log
+8
View File
@@ -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.*
+3
View File
@@ -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:
+4 -1
View File
@@ -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 <joakim@unbound.se>
@@ -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
+54 -10
View File
@@ -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<string, string> = 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: '<i class="v-icon" :class="icon" @click="$emit(\'click\')"></i>',
props: ['icon', 'large'],
props: ['icon', 'large', 'start', 'end', 'size'],
emits: ['click'],
},
VMenu: {
name: 'v-menu',
template: '<div class="v-menu"><slot name="activator" :props="{}" /><slot /></div>',
props: ['modelValue', 'closeOnContentClick'],
},
VList: {
name: 'v-list',
template: '<div class="v-list"><slot /></div>',
},
VListItem: {
name: 'v-list-item',
template: '<div class="v-list-item" :data-active="active" @click="$emit(\'click\')"><slot /><slot name="append" /></div>',
props: ['active', 'value'],
emits: ['click'],
},
VListItemTitle: {
name: 'v-list-item-title',
template: '<div class="v-list-item-title"><slot /></div>',
},
VChip: {
name: 'v-chip',
template: '<div class="v-chip" :class="variant"><slot /></div>',
props: ['size', 'variant', 'prependIcon'],
},
}
+1 -1
View File
@@ -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',