import { mkdtempSync, rmSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { assertSandboxProjectRootAvailable, detectEntryFile, listFiles, resolveProjectDir, SandboxImportedProjectError, } from '../src/projects.js'; function withSandboxMode(run: () => T): T { const previous = process.env.OD_SANDBOX_MODE; process.env.OD_SANDBOX_MODE = '1'; try { return run(); } finally { if (previous == null) delete process.env.OD_SANDBOX_MODE; else process.env.OD_SANDBOX_MODE = previous; } } describe('resolveProjectDir', () => { const projectsRoot = '/var/od/projects'; const projectId = 'proj-abc'; it('returns the standard path when no metadata is given', () => { expect(resolveProjectDir(projectsRoot, projectId)).toBe( path.join(projectsRoot, projectId), ); }); it('returns the standard path when metadata has no baseDir', () => { expect(resolveProjectDir(projectsRoot, projectId, { kind: 'prototype' })).toBe( path.join(projectsRoot, projectId), ); }); it('returns metadata.baseDir when set to an absolute path', () => { const baseDir = '/Users/me/projects/site'; expect( resolveProjectDir(projectsRoot, projectId, { kind: 'prototype', baseDir }), ).toBe(path.normalize(baseDir)); }); it('falls back to the standard path when baseDir is relative', () => { expect( resolveProjectDir(projectsRoot, projectId, { kind: 'prototype', baseDir: 'relative/site', }), ).toBe(path.join(projectsRoot, projectId)); }); it('throws on an invalid project id only when no baseDir is set', () => { // No baseDir → relies on isSafeId expect(() => resolveProjectDir(projectsRoot, '../escape')).toThrowError(); // baseDir present → project id is not consulted, so a bogus id is fine expect(() => resolveProjectDir(projectsRoot, '../escape', { kind: 'prototype', baseDir: '/Users/me/site', }), ).not.toThrow(); }); it('rejects metadata.baseDir in sandbox mode before resolving a project file root', () => { withSandboxMode(() => { const baseDir = '/Users/me/projects/site'; expect( () => resolveProjectDir(projectsRoot, projectId, { kind: 'prototype', baseDir }), ).toThrowError(SandboxImportedProjectError); expect(() => assertSandboxProjectRootAvailable({ kind: 'prototype', baseDir }), ).toThrowError(SandboxImportedProjectError); expect(() => resolveProjectDir(projectsRoot, '../escape', { kind: 'prototype', baseDir, })).toThrowError(); }); }); }); describe('detectEntryFile', () => { let dir = ''; beforeEach(() => { dir = mkdtempSync(path.join(tmpdir(), 'od-detect-entry-')); }); afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); it('returns index.html when present at the root', async () => { await writeFile(path.join(dir, 'index.html'), ''); await writeFile(path.join(dir, 'about.html'), ''); expect(await detectEntryFile(dir)).toBe('index.html'); }); it('returns the first .html file when no index.html is present', async () => { await writeFile(path.join(dir, 'about.html'), ''); const result = await detectEntryFile(dir); expect(result).toBe('about.html'); }); it('returns null when the folder has no html files', async () => { await writeFile(path.join(dir, 'README.md'), '# hi'); expect(await detectEntryFile(dir)).toBeNull(); }); it('returns null when the folder does not exist', async () => { const missing = path.join(dir, 'no-such-subdir'); expect(await detectEntryFile(missing)).toBeNull(); }); it('does not descend into subdirectories', async () => { await mkdir(path.join(dir, 'public')); await writeFile(path.join(dir, 'public', 'index.html'), ''); expect(await detectEntryFile(dir)).toBeNull(); }); }); describe('listFiles with metadata.baseDir', () => { let baseDir = ''; beforeEach(async () => { baseDir = mkdtempSync(path.join(tmpdir(), 'od-list-')); await writeFile(path.join(baseDir, 'index.html'), ''); await writeFile(path.join(baseDir, 'app.css'), 'body{}'); await mkdir(path.join(baseDir, 'node_modules', 'react'), { recursive: true }); await writeFile(path.join(baseDir, 'node_modules', 'react', 'index.js'), ''); await mkdir(path.join(baseDir, '.git')); await writeFile(path.join(baseDir, '.git', 'HEAD'), 'ref: refs/heads/main'); await mkdir(path.join(baseDir, 'dist')); await writeFile(path.join(baseDir, 'dist', 'bundle.js'), '/*compiled*/'); await mkdir(path.join(baseDir, 'Build', 'DerivedData-KeeTests'), { recursive: true }); await writeFile(path.join(baseDir, 'Build', 'DerivedData-KeeTests', 'index-store'), ''); await mkdir(path.join(baseDir, 'vendor', 'package'), { recursive: true }); await writeFile(path.join(baseDir, 'vendor', 'package', 'generated.js'), ''); await mkdir(path.join(baseDir, 'Rust', 'KeePassCore', 'target', 'release'), { recursive: true }); await writeFile(path.join(baseDir, 'Rust', 'KeePassCore', 'target', 'release', 'libkeepass.a'), ''); await mkdir(path.join(baseDir, 'src')); await writeFile(path.join(baseDir, 'src', 'app.ts'), 'export {}'); }); afterEach(() => { if (baseDir) rmSync(baseDir, { recursive: true, force: true }); }); it('walks the folder rooted at metadata.baseDir', async () => { const files = await listFiles('/unused/projects', 'unused-id', { metadata: { kind: 'prototype', baseDir }, }); const paths = files.map((f) => f.path).sort(); expect(paths).toContain('index.html'); expect(paths).toContain('app.css'); expect(paths).toContain('src/app.ts'); }); // Regression: callers that pass the metadata object directly as opts // (instead of wrapping it in `{ metadata }`) were silently scanning the // standard .od/projects// instead of the imported folder. Codex // review of #624 caught one in chat-route. Lock the contract: when a // bare metadata object is passed at the top level, listFiles must // ignore it and fall back to the standard project dir — no false // positives on a folder the caller didn't ask for. it('ignores bare metadata at opts top-level (must be opts.metadata)', async () => { // Pass the metadata object directly as opts. With the documented // contract this means opts.metadata is undefined, so listFiles // resolves to projectsRoot/projectId — which here doesn't exist, // so the result must be an empty array, not the contents of baseDir. const files = await listFiles('/unused/projects', 'unused-id', { kind: 'prototype', baseDir, } as never); expect(files).toEqual([]); }); it('skips conventional build / install dirs (node_modules, .git, dist)', async () => { const files = await listFiles('/unused/projects', 'unused-id', { metadata: { kind: 'prototype', baseDir }, }); const paths = files.map((f) => f.path); expect(paths.some((p) => p.startsWith('node_modules/'))).toBe(false); expect(paths.some((p) => p.startsWith('.git/'))).toBe(false); expect(paths.some((p) => p.startsWith('dist/'))).toBe(false); expect(paths.some((p) => p.startsWith('Build/'))).toBe(false); expect(paths.some((p) => p.startsWith('vendor/'))).toBe(false); expect(paths.some((p) => p.includes('/target/'))).toBe(false); }); it('does not skip those dirs for non-baseDir projects (back-compat)', async () => { // Without metadata.baseDir, listFiles points at the standard project dir. // We don't have one set up for this test — just assert the call doesn't // *apply* the skip filter when the metadata is absent. We check this // indirectly: passing the same baseDir as a non-baseDir directory // (impossible here since listFiles uses standard path). So instead, // verify the default path behavior is unchanged: no metadata, no // skipDirs, no baseDir resolution. const standardDir = mkdtempSync(path.join(tmpdir(), 'od-list-std-')); try { await mkdir(path.join(standardDir, 'std-project'), { recursive: true }); await mkdir(path.join(standardDir, 'std-project', 'node_modules')); await writeFile(path.join(standardDir, 'std-project', 'node_modules', 'a.js'), ''); await writeFile(path.join(standardDir, 'std-project', 'main.html'), ''); const files = await listFiles(standardDir, 'std-project'); const paths = files.map((f) => f.path).sort(); // node_modules contents *do* appear when no skip filter is applied expect(paths).toContain('main.html'); expect(paths).toContain('node_modules/a.js'); } finally { rmSync(standardDir, { recursive: true, force: true }); } }); });