diff --git a/src/lib/desktop.test.ts b/src/lib/desktop.test.ts new file mode 100644 index 0000000000..922cfbf827 --- /dev/null +++ b/src/lib/desktop.test.ts @@ -0,0 +1,131 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { listProjects } from './desktop' +import { DeepPartial } from './types' +import { Configuration } from 'wasm-lib/kcl/bindings/Configuration' + +// Mock the electron window global +const mockElectron = { + readdir: vi.fn(), + path: { + join: vi.fn(), + basename: vi.fn(), + dirname: vi.fn(), + }, + stat: vi.fn(), + statIsDirectory: vi.fn(), + exists: vi.fn(), + writeFile: vi.fn(), + readFile: vi.fn(), + os: { + isMac: false, + isWindows: false, + }, + process: { + env: {}, + }, + getPath: vi.fn(), + kittycad: vi.fn(), +} + +vi.stubGlobal('window', { electron: mockElectron }) + +describe('desktop utilities', () => { + const mockConfig: DeepPartial = { + settings: { + project: { + directory: '/test/projects', + }, + }, + } + + const mockFileSystem: { [key: string]: string[] } = { + '/test/projects': [ + '.hidden-project', + 'valid-project', + '.git', + 'project-without-kcl-files', + 'another-valid-project', + ], + '/test/projects/valid-project': ['file1.kcl', 'file2.stp'], + '/test/projects/project-without-kcl-files': ['file3.glb'], + '/test/projects/another-valid-project': ['file4.kcl'], + } + + beforeEach(() => { + vi.clearAllMocks() + + // Setup default mock implementations + mockElectron.path.join.mockImplementation((...parts: string[]) => + parts.join('/') + ) + mockElectron.path.basename.mockImplementation((path: string) => + path.split('/').pop() + ) + mockElectron.path.dirname.mockImplementation((path: string) => + path.split('/').slice(0, -1).join('/') + ) + + // Mock readdir to return the entries for the given path + mockElectron.readdir.mockImplementation(async (path: string) => { + return mockFileSystem[path] || [] + }) + + // Mock statIsDirectory to return true if the path exists in mockFileSystem + mockElectron.statIsDirectory.mockImplementation(async (path: string) => { + return path in mockFileSystem + }) + + // Mock stat to always resolve with dummy metadata + mockElectron.stat.mockResolvedValue({ + mtimeMs: 123, + atimeMs: 456, + ctimeMs: 789, + size: 100, + mode: 0o666, + }) + + mockElectron.exists.mockResolvedValue(true) + mockElectron.readFile.mockResolvedValue('') + mockElectron.writeFile.mockResolvedValue(undefined) + mockElectron.getPath.mockResolvedValue('/appData') + mockElectron.kittycad.mockResolvedValue({}) + }) + + describe('listProjects', () => { + it('does not list .git directories', async () => { + const projects = await listProjects(mockConfig) + expect(projects.map((p) => p.name)).not.toContain('.git') + }) + it('lists projects excluding hidden and without .kcl files', async () => { + const projects = await listProjects(mockConfig) + + // Verify only non-dot projects with .kcl files were included + expect(projects.map((p) => p.name)).toEqual([ + 'valid-project', + 'another-valid-project', + ]) + + // Verify we didn't try to get project info for dot directories + expect(mockElectron.stat).not.toHaveBeenCalledWith( + expect.stringContaining('/.hidden-project') + ) + expect(mockElectron.stat).not.toHaveBeenCalledWith( + expect.stringContaining('/.git') + ) + + // Verify that projects without .kcl files are not included + expect(projects.map((p) => p.name)).not.toContain( + 'project-without-kcl-files' + ) + }) + + it('handles empty project directory', async () => { + // Adjust mockFileSystem to simulate empty directory + mockFileSystem['/test/projects'] = [] + + const projects = await listProjects(mockConfig) + + expect(projects).toEqual([]) + }) + }) +}) diff --git a/src/lib/desktop.ts b/src/lib/desktop.ts index 247c36dd2b..3e4c1e08d0 100644 --- a/src/lib/desktop.ts +++ b/src/lib/desktop.ts @@ -139,6 +139,11 @@ export async function listProjects( const entries = await window.electron.readdir(projectDir) for (let entry of entries) { + // Skip directories that start with a dot + if (entry.startsWith('.')) { + continue + } + const projectPath = window.electron.path.join(projectDir, entry) // if it's not a directory ignore. const isDirectory = await window.electron.statIsDirectory(projectPath) diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 0a5331fd78..7132e72ad0 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -221,6 +221,11 @@ const Home = () => { new FormData(e.target as HTMLFormElement) ) + if (typeof newProjectName === 'string' && newProjectName.startsWith('.')) { + toast.error('Project names cannot start with a dot (.)') + return + } + if (newProjectName !== project.name) { send({ type: 'Rename project',