Skip to content
Open
Show file tree
Hide file tree
Changes from all 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);

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;
}
}),
);
return items;
return results.filter((item): item is FunctionTableItem => item !== null);
}

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