From 845ef683aff4af5238a985c56416ea0dc5c421a9 Mon Sep 17 00:00:00 2001 From: Robert Luby Date: Mon, 15 Jun 2026 11:34:33 +0200 Subject: [PATCH 1/3] SRVOCF-840: Fix list page crashes and missing rows --- src/common/utils/utils.test.ts | 27 +++++++++- src/common/utils/utils.ts | 10 +++- src/pages/function-edit/FunctionEditPage.tsx | 4 +- .../function-list/FunctionsListPage.test.tsx | 50 +++++++++++++++++++ src/pages/function-list/FunctionsListPage.tsx | 20 +++++--- 5 files changed, 98 insertions(+), 13 deletions(-) diff --git a/src/common/utils/utils.test.ts b/src/common/utils/utils.test.ts index 1156f31..55c6453 100644 --- a/src/common/utils/utils.test.ts +++ b/src/common/utils/utils.test.ts @@ -1,4 +1,4 @@ -import { getLanguageFromPath } from './utils'; +import { getLanguageFromPath, parseFuncYaml } from './utils'; describe('getLanguageFromPath', () => { it.each([ @@ -18,3 +18,28 @@ describe('getLanguageFromPath', () => { expect(getLanguageFromPath(path)).toBe(expected); }); }); + +describe('parseFuncYaml', () => { + it('parses name, namespace, and runtime', () => { + const yaml = 'name: my-function\nruntime: node\nnamespace: demo\n'; + expect(parseFuncYaml(yaml)).toEqual({ + name: 'my-function', + namespace: 'demo', + runtime: 'node', + }); + }); + + it('returns empty name when name field is missing', () => { + const yaml = 'runtime: go\nnamespace: demo\n'; + expect(parseFuncYaml(yaml)).toEqual({ + name: '', + namespace: 'demo', + runtime: 'go', + }); + }); + + it('throws when runtime field is missing', () => { + const yaml = 'name: my-func\nnamespace: demo\n'; + expect(() => parseFuncYaml(yaml)).toThrow('func.yaml missing runtime field'); + }); +}); diff --git a/src/common/utils/utils.ts b/src/common/utils/utils.ts index 7ca8cfa..f58ddb0 100644 --- a/src/common/utils/utils.ts +++ b/src/common/utils/utils.ts @@ -33,14 +33,20 @@ export function getLanguageFromPath(path: string): Language { return (extensionMap[ext] ?? 'plaintext') as Language; } -export function parseNamespaceAndRuntime(funcYaml: string): { +export function parseFuncYaml(funcYaml: string): { + name: string; namespace: string; runtime: string; } { + const nameMatch = funcYaml.match(/^name:\s*(.+)$/m); const runtimeMatch = funcYaml.match(/^runtime:\s*(.+)$/m); const namespaceMatch = funcYaml.match(/^namespace:\s*(.+)$/m); if (!runtimeMatch) throw new Error(`func.yaml missing runtime field`); - return { namespace: namespaceMatch?.[1]?.trim() ?? '', runtime: runtimeMatch[1].trim() }; + return { + name: nameMatch?.[1]?.trim() ?? '', + namespace: namespaceMatch?.[1]?.trim() ?? '', + runtime: runtimeMatch[1].trim(), + }; } export const handlerMap: Record = { diff --git a/src/pages/function-edit/FunctionEditPage.tsx b/src/pages/function-edit/FunctionEditPage.tsx index 00c92d4..7657659 100644 --- a/src/pages/function-edit/FunctionEditPage.tsx +++ b/src/pages/function-edit/FunctionEditPage.tsx @@ -15,7 +15,7 @@ import { FileEntry, RepoMetadata } from '../../common/services/types'; import { getLanguageFromPath, handlerMap, - parseNamespaceAndRuntime, + parseFuncYaml, } from '../../common/utils/utils'; // --- page component --- @@ -203,7 +203,7 @@ function determineHandler(loadedFiles: FileEntry[]): string { const funcYaml = loadedFiles.find((f) => f.path === 'func.yaml'); if (!funcYaml) return ''; - const { runtime } = parseNamespaceAndRuntime(funcYaml.content); + const { runtime } = parseFuncYaml(funcYaml.content); const handlerPath = handlerMap[runtime]; if (loadedFiles.find((f) => f.path === handlerPath)) return handlerPath; diff --git a/src/pages/function-list/FunctionsListPage.test.tsx b/src/pages/function-list/FunctionsListPage.test.tsx index c6a1f83..0b56818 100644 --- a/src/pages/function-list/FunctionsListPage.test.tsx +++ b/src/pages/function-list/FunctionsListPage.test.tsx @@ -520,6 +520,56 @@ describe('FunctionsListPage', () => { }); }); + it('skips repo when fetchFileContent throws (deleted repo)', async () => { + renderAuthenticated(); + mockUseSourceControl.mockReturnValue({ + listFunctionRepos: vi + .fn() + .mockResolvedValue([repoFixture('good-func'), repoFixture('deleted-repo')]), + fetchFileContent: vi.fn().mockImplementation((repo: { name: string }) => { + if (repo.name === 'deleted-repo') return Promise.reject(new Error('Not Found')); + return Promise.resolve(`name: ${repo.name}\nruntime: go\nnamespace: demo\n`); + }), + }); + mockUseClusterService.mockReturnValue(clusterData()); + + render( + + + , + ); + + const names = await screen.findAllByTestId('fn-name'); + expect(names).toHaveLength(1); + expect(names[0]).toHaveTextContent('good-func'); + }); + + it('uses func.yaml name instead of repo name for cluster matching', async () => { + renderAuthenticated(); + mockUseSourceControl.mockReturnValue({ + listFunctionRepos: vi.fn().mockResolvedValue([repoFixture('my-repo')]), + fetchFileContent: vi + .fn() + .mockResolvedValue('name: my-function\nruntime: node\nnamespace: demo\n'), + }); + mockUseClusterService.mockReturnValue( + clusterData({ + knativeServices: [ksvcFixture('my-function', 'True')], + deployments: [deploymentFixture('my-function', 1, 1)], + }), + ); + + render( + + + , + ); + + expect(await screen.findByTestId('fn-name')).toHaveTextContent('my-function'); + expect(screen.getByTestId('fn-status')).toHaveTextContent('Running'); + expect(mockUseClusterService).toHaveBeenLastCalledWith(['my-function']); + }); + it('removes a deleted repo from the list after refresh', async () => { renderAuthenticated(); const mockListRepos = vi diff --git a/src/pages/function-list/FunctionsListPage.tsx b/src/pages/function-list/FunctionsListPage.tsx index 358a182..8ea8ec1 100644 --- a/src/pages/function-list/FunctionsListPage.tsx +++ b/src/pages/function-list/FunctionsListPage.tsx @@ -28,7 +28,7 @@ import { import { useClusterService } from '../../common/services/cluster/useClusterService'; import { SourceControlService } from '../../common/services/source-control/SourceControlService'; import { useSourceControlService } from '../../common/services/source-control/useSourceControlService'; -import { errorMessage, parseNamespaceAndRuntime } from '../../common/utils/utils'; +import { errorMessage, parseFuncYaml } from '../../common/utils/utils'; export default function FunctionsListPage() { return ( @@ -219,19 +219,23 @@ function useFunctionListPage(): { async function loadFunctionTableItems(svc: SourceControlService): Promise { const repos = await svc.listFunctionRepos(); - const items = await Promise.all( + const results = await Promise.all( repos.map(async (repo) => { - const funcYaml = await svc.fetchFileContent(repo, 'func.yaml'); - const { namespace, runtime } = parseNamespaceAndRuntime(funcYaml); - return newItem(repo.name, namespace, runtime); + try { + const funcYaml = await svc.fetchFileContent(repo, 'func.yaml'); + const { name, namespace, runtime } = parseFuncYaml(funcYaml); + return newItem(name || repo.name, namespace, runtime); + } catch { + return null; + } }), ); - return items; + return results.filter((item): item is FunctionTableItem => item !== null); } -function newItem(repoName: string, namespace: string, runtime: string): FunctionTableItem { +function newItem(name: string, namespace: string, runtime: string): FunctionTableItem { return { - name: repoName, + name, namespace, runtime, status: 'NotDeployed' as const, From 337bb6b11ea4532ecc1cd9a6a3a89369a769a7f1 Mon Sep 17 00:00:00 2001 From: Robert Luby Date: Mon, 15 Jun 2026 12:22:36 +0200 Subject: [PATCH 2/3] SRVOCF-840: fix lint import --- src/pages/function-edit/FunctionEditPage.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pages/function-edit/FunctionEditPage.tsx b/src/pages/function-edit/FunctionEditPage.tsx index 7657659..1c19f23 100644 --- a/src/pages/function-edit/FunctionEditPage.tsx +++ b/src/pages/function-edit/FunctionEditPage.tsx @@ -12,11 +12,7 @@ import { ForgeConnectionProvider } from '../../common/context/ForgeConnectionPro import { SourceControlService } from '../../common/services/source-control/SourceControlService'; import { useSourceControlService } from '../../common/services/source-control/useSourceControlService'; import { FileEntry, RepoMetadata } from '../../common/services/types'; -import { - getLanguageFromPath, - handlerMap, - parseFuncYaml, -} from '../../common/utils/utils'; +import { getLanguageFromPath, handlerMap, parseFuncYaml } from '../../common/utils/utils'; // --- page component --- From 4cde369f62d3b23134187cd8ad479a16b923f99f Mon Sep 17 00:00:00 2001 From: Robert Luby Date: Tue, 16 Jun 2026 13:52:35 +0200 Subject: [PATCH 3/3] SRVOCF-840: fix review comments --- src/pages/function-list/FunctionsListPage.tsx | 13 +++++++--- .../components/FunctionTable.test.tsx | 24 +++++++++++++++++++ .../components/FunctionTable.tsx | 3 ++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/pages/function-list/FunctionsListPage.tsx b/src/pages/function-list/FunctionsListPage.tsx index 8ea8ec1..01b26f7 100644 --- a/src/pages/function-list/FunctionsListPage.tsx +++ b/src/pages/function-list/FunctionsListPage.tsx @@ -224,8 +224,9 @@ async function loadFunctionTableItems(svc: SourceControlService): Promise item !== null); } -function newItem(name: string, namespace: string, runtime: string): FunctionTableItem { +function newItem( + name: string, + repoName: string, + namespace: string, + runtime: string, +): FunctionTableItem { return { name, + repoName, namespace, runtime, status: 'NotDeployed' as const, diff --git a/src/pages/function-list/components/FunctionTable.test.tsx b/src/pages/function-list/components/FunctionTable.test.tsx index 94df575..c6cf855 100644 --- a/src/pages/function-list/components/FunctionTable.test.tsx +++ b/src/pages/function-list/components/FunctionTable.test.tsx @@ -37,6 +37,7 @@ const mockDeployment = { const mockFunctions: FunctionTableItem[] = [ { name: 'my-func', + repoName: 'my-func', runtime: 'go', status: 'Running', url: 'http://my-func.demo.svc', @@ -46,6 +47,7 @@ const mockFunctions: FunctionTableItem[] = [ }, { name: 'idle-func', + repoName: 'idle-func', runtime: 'node', status: 'NotDeployed', replicas: 0, @@ -127,6 +129,28 @@ describe('FunctionTable', () => { expect(onEdit).toHaveBeenCalledWith('my-func'); }); + it('calls onEdit with repoName, not display name', async () => { + const onEdit = vi.fn(); + const user = userEvent.setup(); + const fn: FunctionTableItem = { + name: 'my-function', + repoName: 'my-repo', + runtime: 'node', + status: 'Running', + replicas: 1, + namespace: 'demo', + }; + + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Edit' })); + expect(onEdit).toHaveBeenCalledWith('my-repo'); + }); + it('launches delete modal when delete button is clicked', async () => { const mockLauncher = vi.fn(); mockUseDeleteModal.mockReturnValue(mockLauncher); diff --git a/src/pages/function-list/components/FunctionTable.tsx b/src/pages/function-list/components/FunctionTable.tsx index 993ee9a..702b7c6 100644 --- a/src/pages/function-list/components/FunctionTable.tsx +++ b/src/pages/function-list/components/FunctionTable.tsx @@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next'; export interface FunctionTableItem { name: string; + repoName: string; runtime: string; status: FunctionStatus; url?: string; @@ -83,7 +84,7 @@ export function FunctionTable({ variant="plain" aria-label={t('Edit')} icon={} - onClick={() => onEdit(fn.name)} + onClick={() => onEdit(fn.repoName)} />