Skip to content
This repository was archived by the owner on Jun 26, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion src/common/utils/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getLanguageFromPath } from './utils';
import { getLanguageFromPath, parseFuncYaml } from './utils';

describe('getLanguageFromPath', () => {
it.each([
Expand All @@ -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');
});
});
10 changes: 8 additions & 2 deletions src/common/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand Down
8 changes: 2 additions & 6 deletions src/pages/function-edit/FunctionEditPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
parseNamespaceAndRuntime,
} from '../../common/utils/utils';
import { getLanguageFromPath, handlerMap, parseFuncYaml } from '../../common/utils/utils';

// --- page component ---

Expand Down Expand Up @@ -203,7 +199,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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may also throw if there is syntax error in func.yaml, which results in infinite "Loading sources…".

Screencast.From.2026-06-19.13-44-41.mp4

However feel free to create separate bugreport/pr for this if you want.


const handlerPath = handlerMap[runtime];
if (loadedFiles.find((f) => f.path === handlerPath)) return handlerPath;
Expand Down
50 changes: 50 additions & 0 deletions src/pages/function-list/FunctionsListPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MemoryRouter>
<FunctionsListPage />
</MemoryRouter>,
);

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(
<MemoryRouter>
<FunctionsListPage />
</MemoryRouter>,
);

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
Expand Down
27 changes: 19 additions & 8 deletions src/pages/function-list/FunctionsListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -219,19 +219,30 @@ function useFunctionListPage(): {

async function loadFunctionTableItems(svc: SourceControlService): Promise<FunctionTableItem[]> {
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, repo.name, namespace, runtime);
} catch (err: unknown) {
console.warn(`Skipping repo ${repo.name}: ${err instanceof Error ? err.message : err}`);
return null;
Comment thread
matejvasek marked this conversation as resolved.
Outdated
}
}),
);
return items;
return results.filter((item): item is FunctionTableItem => item !== null);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are returning nulls no more we might not need this filter.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue addresed in Openshift repo's PR openshift#4

}

function newItem(repoName: string, namespace: string, runtime: string): FunctionTableItem {
function newItem(
name: string,
repoName: string,
namespace: string,
runtime: string,
): FunctionTableItem {
return {
name: repoName,
name,
repoName,
namespace,
runtime,
status: 'NotDeployed' as const,
Expand Down
24 changes: 24 additions & 0 deletions src/pages/function-list/components/FunctionTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -46,6 +47,7 @@ const mockFunctions: FunctionTableItem[] = [
},
{
name: 'idle-func',
repoName: 'idle-func',
runtime: 'node',
status: 'NotDeployed',
replicas: 0,
Expand Down Expand Up @@ -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(
<MemoryRouter>
<FunctionTable functions={[fn]} onEdit={onEdit} />
</MemoryRouter>,
);

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);
Expand Down
3 changes: 2 additions & 1 deletion src/pages/function-list/components/FunctionTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';

export interface FunctionTableItem {
name: string;
repoName: string;
runtime: string;
status: FunctionStatus;
url?: string;
Expand Down Expand Up @@ -83,7 +84,7 @@ export function FunctionTable({
variant="plain"
aria-label={t('Edit')}
icon={<PencilAltIcon />}
onClick={() => onEdit(fn.name)}
onClick={() => onEdit(fn.repoName)}
/>
</ActionListItem>
<ActionListItem>
Expand Down
Loading