Skip to content
Closed
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
10 changes: 10 additions & 0 deletions docs/claude-progress.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
# Claude Progress Log
# Newest entries first. Agents: append your entry at the top after the header.

---
## 2026-06-19 | Session: SRVOCF-840 PR review fixes
Worked on: Address PR review comments on error handling catch block
Completed:
- Replaced '<UNKNOWN>' with empty strings for namespace/runtime (TextOrDash renders em dashes automatically)
- Added console.error logging in catch block for developer debugging
- Removed redundant null filter since catch always returns an item now
Left off: Changes committed on SRVOCF-840 branch.
Blockers: None

---
## 2026-06-16 | Session: PR #36 - GitHub Pages local dev + clipboard fix (SRVOCF-843)
Worked on: Local GitHub Pages dev support, clipboard copy error handling, PR review feedback
Expand Down
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
51 changes: 51 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,57 @@ describe('FunctionsListPage', () => {
});
});

it('shows error item 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(2);
expect(names[0]).toHaveTextContent('good-func');
expect(names[1]).toHaveTextContent('deleted-repo');
});

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
29 changes: 21 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,32 @@ 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) {
console.error(`Failed to load func.yaml for ${repo.name}:`, err);
const item = newItem(repo.name, repo.name, '', '');
item.status = 'Error';
return item;
}
}),
);
return items;
return results;
}

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